Compare commits
144
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a18e671817 | ||
|
|
a3fec5486d | ||
|
|
dc947d30c1 | ||
|
|
49567e9594 | ||
|
|
831a5e5c96 | ||
|
|
8e80d6bcc9 | ||
|
|
0bf7422d9c | ||
|
|
caeecf4b26 | ||
|
|
58e3916e03 | ||
|
|
3013bbc94f | ||
|
|
12b56818b6 | ||
|
|
aad734e264 | ||
|
|
256e938fff | ||
|
|
acc708ec83 | ||
|
|
746a271aeb | ||
|
|
85dafc78d7 | ||
|
|
2b56a91ce2 | ||
|
|
ce12b4b0f5 | ||
|
|
b5a7994df0 | ||
|
|
5d49a2f4bf | ||
|
|
048db2d230 | ||
|
|
80307f11da | ||
|
|
038f344deb | ||
|
|
b2c6764472 | ||
|
|
accfec7146 | ||
|
|
7485dac6e8 | ||
|
|
d218e60168 | ||
|
|
bc4063f701 | ||
|
|
27e8c695c1 | ||
|
|
1e4db64362 | ||
|
|
3121bba275 | ||
|
|
0687d2d127 | ||
|
|
7c3030ff52 | ||
|
|
edc7c931dc | ||
|
|
249b34fc7c | ||
|
|
87d1acd589 | ||
|
|
cea2ee6a54 | ||
|
|
8f7949c30b | ||
|
|
079c46dafb | ||
|
|
e792d2a964 | ||
|
|
5196337ca8 | ||
|
|
0cf2879d06 | ||
|
|
4f159b98fb | ||
|
|
6b294c28b2 | ||
|
|
ecbb2c8c42 | ||
|
|
e6da1ca3c3 | ||
|
|
5be1790717 | ||
|
|
62d6ea4bed | ||
|
|
df31117196 | ||
|
|
fc59594f6a | ||
|
|
c2f6508447 | ||
|
|
440174d563 | ||
|
|
219c8d4ae3 | ||
|
|
1a5b3f00ba | ||
|
|
b33e964aec | ||
|
|
18df24c04d | ||
|
|
21b3dbf522 | ||
|
|
8b4056aca6 | ||
|
|
062cbdc655 | ||
|
|
79bd76c7e9 | ||
|
|
e91183f0df | ||
|
|
a3109c95f6 | ||
|
|
33a91ad140 | ||
|
|
f020d6cb35 | ||
|
|
e47671ea32 | ||
|
|
9c2f3886c7 | ||
|
|
95532ad2c8 | ||
|
|
bbb7e2839f | ||
|
|
9ee0c4287f | ||
|
|
7ead49429e | ||
|
|
e02d2f64d5 | ||
|
|
b4ad8106d7 | ||
|
|
2bdb739383 | ||
|
|
4a35e9c494 | ||
|
|
de779cc820 | ||
|
|
e99c9d1d6d | ||
|
|
cc60c8711c | ||
|
|
af8b7c7b34 | ||
|
|
a4f88b5b31 | ||
|
|
7487782dea | ||
|
|
2e071f9e0e | ||
|
|
5c1e78b2ab | ||
|
|
b6bdef3de6 | ||
|
|
4a676c4d5e | ||
|
|
f17c34a067 | ||
|
|
f4b5029714 | ||
|
|
d5a060a009 | ||
|
|
f5c3ecb47c | ||
|
|
a1f2f23b72 | ||
|
|
86de124cc8 | ||
|
|
820f405c61 | ||
|
|
aa9e2e89c8 | ||
|
|
5e0c911633 | ||
|
|
17e654a738 | ||
|
|
3b2376af9c | ||
|
|
c074a8031f | ||
|
|
ad8c31b3af | ||
|
|
7830a41b3e | ||
|
|
41c7495844 | ||
|
|
a54be8298f | ||
|
|
59212cb0d4 | ||
|
|
6d1d94bf11 | ||
|
|
3cd2fddeda | ||
|
|
7feeb73a81 | ||
|
|
0830ad68c3 | ||
|
|
c322f24086 | ||
|
|
464a93c7f9 | ||
|
|
736a1bde25 | ||
|
|
133390ef7a | ||
|
|
1b3a03316e | ||
|
|
0b844a13af | ||
|
|
bef928bf17 | ||
|
|
5cacbe36b3 | ||
|
|
1c3b7b16db | ||
|
|
3bc4365ee2 | ||
|
|
1f7873273b | ||
|
|
364c579c04 | ||
|
|
9cd91deafa | ||
|
|
7de04544e4 | ||
|
|
65c5953516 | ||
|
|
0648e99fc4 | ||
|
|
ccdb4d89c1 | ||
|
|
a92905a816 | ||
|
|
6f54843d5a | ||
|
|
f8e3cfbf35 | ||
|
|
7eb3f25f1f | ||
|
|
27a1a48176 | ||
|
|
f9209e8896 | ||
|
|
f16403a851 | ||
|
|
5623f90ef7 | ||
|
|
681d18a08f | ||
|
|
52e1c0eca0 | ||
|
|
fcaeaa74a7 | ||
|
|
b64dcc02df | ||
|
|
406c27ebd8 | ||
|
|
dfe7f1a311 | ||
|
|
7e0ed6b183 | ||
|
|
6f9b160038 | ||
|
|
dcbe59dae9 | ||
|
|
d370f0a542 | ||
|
|
e5e09d5d48 | ||
|
|
d9136c46b9 | ||
|
|
a40260a441 | ||
|
|
ef167a1345 |
No files matched your search
@@ -1,7 +1,7 @@
|
||||
# Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64
|
||||
# runner, unsigned here), then install that MSI on a Windows 11 ARM64 runner, check the installed
|
||||
# binaries are ARM64, run the CLI natively, and uninstall. (A native ARM64 `cargo test` job is left
|
||||
# out for now: PrintCraft saw an intermittent access violation in egui UI tests there.)
|
||||
# out for now: PdfCraft saw an intermittent access violation in egui UI tests there.)
|
||||
name: Windows ARM64
|
||||
|
||||
on:
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# Experimental, unsupported Windows 7 SP1 x64 portable build (docs/windows7.md). It runs by hand and
|
||||
# weekly, so ordinary PRs never wait on it, and the weekly run shows when a dependency update starts
|
||||
# importing a post-Windows-7 API. packaging/windows/windows7.ps1 does the work: it is the only build
|
||||
# that uses the patched vendor/windows-link. A modern Windows runner proves compilation and host
|
||||
# smoke tests only: run the checklist in docs/windows7.md on actual Windows 7 before claiming support.
|
||||
name: Windows 7 x64 compatibility
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "23 5 * * 1" # weekly
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 # v1.13.0
|
||||
with:
|
||||
arch: x64
|
||||
- uses: dtolnay/rust-toolchain@7e38f4b43b4db5c8dd498af069a4f6196df1d067 # master
|
||||
with:
|
||||
toolchain: nightly-2026-10-01
|
||||
components: rust-src
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: windows7-x64
|
||||
- name: Build, check and test the portable package
|
||||
shell: pwsh
|
||||
run: ./packaging/windows/windows7.ps1 -Test
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: vectorcraft-windows7-x64-portable
|
||||
path: dist/release/vectorcraft-windows7-x64-portable.zip
|
||||
if-no-files-found: error
|
||||
@@ -1,6 +1,6 @@
|
||||
# VectorCraft — instructions for agents
|
||||
|
||||
VectorCraft is a clean-room, open-source, Rust-native vector illustration app targeting Adobe Illustrator parity (and beyond). It runs natively on macOS, Windows and Linux, and on the web via WASM. Siblings: `../photocraft` (Photoshop-class) and `../printcraft` (Acrobat-class); same conventions.
|
||||
VectorCraft is a clean-room, open-source, Rust-native vector illustration app targeting Adobe Illustrator parity (and beyond). It runs natively on macOS, Windows and Linux, and on the web via WASM. Siblings: `../photocraft` (Photoshop-class) and `../pdfcraft` (Acrobat-class); same conventions.
|
||||
|
||||
Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format": "drawcraft"` headers still open (`vectorcraft_format::LEGACY_EXTENSION`), and preferences migrate from the old config folder. Keep those paths working; use the new name everywhere else.
|
||||
|
||||
|
||||
@@ -166,7 +166,9 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
||||
| `assets/icons/dc-para-justify-left.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-para-justify-right.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-para-left.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-para-ltr.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-para-right.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-para-rtl.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-path-eraser.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-pathfinder.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-pen-add.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
@@ -314,6 +316,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
||||
| `docs/images/shot-2-ribbons.png` | VectorCraft contributors | Screenshot of VectorCraft itself, editing `examples/ribbons.vectorcraft` | MIT OR Apache-2.0 | |
|
||||
| `docs/images/shot-3-sheet.png` | VectorCraft contributors | Screenshot of VectorCraft itself, editing `examples/feature-sheet.vectorcraft` | MIT OR Apache-2.0 | |
|
||||
| `docs/images/shot-4-bezier.png` | VectorCraft contributors | Screenshot of VectorCraft itself, editing `examples/feature-sheet.vectorcraft` | MIT OR Apache-2.0 | |
|
||||
| `docs/images/shot-5-perspective.png` | VectorCraft contributors | Screenshot of VectorCraft itself, editing `examples/perspective-city.vectorcraft` | MIT OR Apache-2.0 | |
|
||||
| `docs/images/art-neon-drive.png` | VectorCraft contributors | Rendered by VectorCraft from `examples/neon-drive.vectorcraft` | MIT OR Apache-2.0 | |
|
||||
| `docs/images/art-ribbons.png` | VectorCraft contributors | Rendered by VectorCraft from `examples/ribbons.vectorcraft` | MIT OR Apache-2.0 | |
|
||||
| `docs/images/art-pathfinder.png` | VectorCraft contributors | Rendered by VectorCraft from `examples/feature-sheet.vectorcraft` (artboard 1) | MIT OR Apache-2.0 | |
|
||||
@@ -323,3 +326,4 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
||||
| `examples/neon-drive.vectorcraft` | VectorCraft contributors | Original artwork built through the VectorCraft command API | MIT OR Apache-2.0 | |
|
||||
| `examples/ribbons.vectorcraft` | VectorCraft contributors | Original artwork built through the VectorCraft command API | MIT OR Apache-2.0 | |
|
||||
| `examples/feature-sheet.vectorcraft` | VectorCraft contributors | Original artwork built through the VectorCraft command API | MIT OR Apache-2.0 | |
|
||||
| `examples/perspective-city.vectorcraft` | VectorCraft contributors | Original artwork built through the VectorCraft command API | MIT OR Apache-2.0 | |
|
||||
@@ -1,6 +1,6 @@
|
||||
# VectorCraft — instructions for agents
|
||||
|
||||
VectorCraft is a clean-room, open-source, Rust-native vector illustration app targeting Adobe Illustrator parity (and beyond). It runs natively on macOS, Windows and Linux, and on the web via WASM. Siblings: `../photocraft` (Photoshop-class) and `../printcraft` (Acrobat-class); same conventions.
|
||||
VectorCraft is a clean-room, open-source, Rust-native vector illustration app targeting Adobe Illustrator parity (and beyond). It runs natively on macOS, Windows and Linux, and on the web via WASM. Siblings: `../photocraft` (Photoshop-class) and `../pdfcraft` (Acrobat-class); same conventions.
|
||||
|
||||
Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format": "drawcraft"` headers still open (`vectorcraft_format::LEGACY_EXTENSION`), and preferences migrate from the old config folder. Keep those paths working; use the new name everywhere else.
|
||||
|
||||
|
||||
Generated
+44
@@ -145,6 +145,21 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alloc-no-stdlib"
|
||||
version = "2.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
|
||||
|
||||
[[package]]
|
||||
name = "alloc-stdlib"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
@@ -509,6 +524,27 @@ dependencies = [
|
||||
"piper",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli"
|
||||
version = "8.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
"brotli-decompressor",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli-decompressor"
|
||||
version = "5.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
@@ -916,6 +952,7 @@ dependencies = [
|
||||
"egui-wgpu",
|
||||
"egui-winit",
|
||||
"egui_glow",
|
||||
"glow",
|
||||
"glutin",
|
||||
"glutin-winit",
|
||||
"image",
|
||||
@@ -1650,6 +1687,7 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d285dc30731c8485de5fa732fbdf2b3affdf01e4da7c2135022ca6fa664bf6"
|
||||
dependencies = [
|
||||
"brotli",
|
||||
"hayro-postscript",
|
||||
]
|
||||
|
||||
@@ -4214,6 +4252,7 @@ name = "vectorcraft"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"block2 0.6.2",
|
||||
"clipboard-win",
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4222,6 +4261,7 @@ dependencies = [
|
||||
"muda",
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"vectorcraft-engine",
|
||||
@@ -4451,6 +4491,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"skrifa 0.47.0",
|
||||
"smallvec",
|
||||
"thiserror 2.0.21",
|
||||
"vectorcraft-brush",
|
||||
@@ -4551,6 +4592,9 @@ dependencies = [
|
||||
"log",
|
||||
"skrifa 0.47.0",
|
||||
"subsetter",
|
||||
"unicode-bidi",
|
||||
"unicode-general-category",
|
||||
"unicode-script",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-geom",
|
||||
"write-fonts",
|
||||
|
||||
@@ -30,7 +30,45 @@ based in part on the work of the Independent JPEG Group.
|
||||
WebAssembly plug-ins run in the wasmi interpreter (MIT OR Apache-2.0); its wasmparser dependency is
|
||||
under the Apache License 2.0 with the LLVM exception.
|
||||
|
||||
The experimental Windows 7 build (packaging/windows/windows7.ps1) compiles a modified copy of the
|
||||
windows-link crate 0.2.1 (vendor/windows-link; Microsoft, MIT OR Apache-2.0) that binds CoTaskMemFree
|
||||
through ole32.dll. Its license texts are in vendor/windows-link and ship in that build's ZIP; every
|
||||
other build uses the unmodified crate from crates.io.
|
||||
|
||||
Builds made with craft-fonts (the CRAFT_FONTS_DIR build option; all official releases) embed the
|
||||
fonts listed in https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (today BIZ
|
||||
UDPGothic, Shippori Mincho and BIZ UDMincho; web builds BIZ UDPGothic Regular only) under the SIL
|
||||
fonts listed in https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (BIZ UDPGothic,
|
||||
Shippori Mincho, BIZ UDMincho, Noto Sans CJK SC and Noto Sans Arabic in the current manifest;
|
||||
web builds BIZ UDPGothic Regular only) under the SIL
|
||||
Open Font License 1.1. Their license texts ship with the release packages as OFL-<family>.txt.
|
||||
|
||||
PDF import decodes CJK text with Adobe's CMap resources (https://github.com/adobe-type-tools/cmap-resources),
|
||||
compiled into the app through the hayro-cmap crate under the BSD 3-Clause License:
|
||||
|
||||
Copyright 1990-2023 Adobe. All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are
|
||||
met:
|
||||
|
||||
Redistributions of source code must retain the above copyright notice,
|
||||
this list of conditions and the following disclaimer.
|
||||
|
||||
Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
|
||||
Neither the name of Adobe nor the names of its contributors may be
|
||||
used to endorse or promote products derived from this software without
|
||||
specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
@@ -40,7 +40,7 @@
|
||||
<br>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/shot-1-neon.png" alt="VectorCraft editing the Neon Drive poster: the title is selected, the Appearance panel shows its live Outer Glow, and the Properties panel shows its character settings" width="100%">
|
||||
<img src="docs/images/shot-1-neon.png" alt="VectorCraft editing the Neon Drive poster: the title is selected, the Appearance panel shows the settings of its live Outer Glow, and the Properties panel shows its character settings" width="100%">
|
||||
<br><sub><b>Neon Drive</b>: a Pathfinder-cut sun, live Outer Glow on the type and grid, and clipping masks · <code>examples/neon-drive.vectorcraft</code></sub>
|
||||
</p>
|
||||
|
||||
@@ -63,8 +63,8 @@
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<img src="docs/images/shot-2-ribbons.png" alt="Three live blend ribbons of 55 to 70 steps with smooth colour, clipped to the artboard, with the Layers panel open" width="100%">
|
||||
<p align="center"><sub><b>Live Blends</b>: editable key paths and smooth colour, clipped to the artboard</sub></p>
|
||||
<img src="docs/images/shot-2-ribbons.png" alt="Three live blend ribbons clipped to the artboard, one selected with its key paths showing; the Layers panel lists the clip group, the blends and the selected blend's two key paths" width="100%">
|
||||
<p align="center"><sub><b>Live Blends and Layers</b>: editable key paths, smooth colour, every object a row</sub></p>
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<img src="docs/images/shot-4-bezier.png" alt="Direct Selection tool showing anchor points and Bézier handles on a crescent built with Pathfinder, with the contextual task bar below it" width="100%">
|
||||
@@ -72,7 +72,11 @@
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" valign="top">
|
||||
<td width="50%" valign="top">
|
||||
<img src="docs/images/shot-5-perspective.png" alt="Two lit building facades drawn on the left and right planes of a two-point perspective grid at sunset, with the Perspective Selection tool and the Plane Switching Widget" width="100%">
|
||||
<p align="center"><sub><b>Perspective Grid</b>: art attached to its planes stays editable in perspective · <code>examples/perspective-city.vectorcraft</code></sub></p>
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<img src="docs/images/shot-3-sheet.png" alt="Four artboards in the light UI theme: Pathfinder, Gradient Mesh, radial Repeat and Envelope Distort" width="100%">
|
||||
<p align="center"><sub><b>Multiple artboards, light theme</b>: Pathfinder · Gradient Mesh · live radial Repeat · Envelope Distort · <code>examples/feature-sheet.vectorcraft</code></sub></p>
|
||||
</td>
|
||||
@@ -158,6 +162,10 @@ Japanese fonts come from [storytold/craft-fonts](https://github.com/storytold/cr
|
||||
build input (releases always use it): `CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo run --release -p vectorcraft` (an absolute path).
|
||||
Without it, Japanese text uses the installed system fonts. See [`docs/development.md`](docs/development.md#fonts-craft-fonts-optional-build-input).
|
||||
|
||||
On laptops with two graphics processors, VectorCraft uses the power-saving (integrated) one by default.
|
||||
To use the discrete one, choose **Preferences › Performance › Graphics Processor › High Performance**
|
||||
and restart, or start the app with `WGPU_POWER_PREF=high` (see [`docs/development.md`](docs/development.md#desktop-graphics-processor)).
|
||||
|
||||
### Use it from Claude Code and other agents
|
||||
|
||||
Register the MCP server with Claude Code:
|
||||
@@ -168,15 +176,19 @@ claude mcp add vectorcraft -- /path/to/vectorcraft-cli mcp
|
||||
|
||||
The details are in [`docs/mcp.md`](docs/mcp.md) and [`docs/control-protocol.md`](docs/control-protocol.md).
|
||||
|
||||
For the experimental, unsupported 64-bit Windows 7 build, see [Windows 7 instructions](docs/windows7.md).
|
||||
|
||||
## Status
|
||||
|
||||
VectorCraft is under active development. [**ROADMAP.md**](ROADMAP.md) covers what ships today, the
|
||||
milestones, and honest time-to-parity estimates.
|
||||
|
||||
**Where we are (2026-10-06):** roughly 69–75% of Illustrator's features exist and work, and about 40–55% of
|
||||
**Where we are (2026-10-07):** roughly 69–75% of Illustrator's features exist and work, and about 40–55% of
|
||||
"a power user can't tell the difference". Everyday vector illustration is close to usable: drawing and path tools,
|
||||
Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles and threading, and
|
||||
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, EPS, DXF, EMF/WMF, raster formats and PSD, Print, Package). The scores are
|
||||
Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles, threading and Hebrew/Arabic bidirectional layout, and
|
||||
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, EPS, DXF, EMF/WMF, raster formats and PSD, Print, Package). The interface
|
||||
speaks English, Japanese, Traditional and Simplified Chinese and Spanish (and Czech and Brazilian Portuguese in the menus).
|
||||
The scores are
|
||||
self-assessed, so the [honest assessment](ROADMAP.md#honest-assessment-2026-10-05) explains how far to trust them.
|
||||
|
||||
**What's missing:**
|
||||
@@ -212,7 +224,7 @@ stand on its own.
|
||||
| <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/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/printcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.printcraft.png" alt="" width="32" height="32"> | **PrintCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/printcraft) | [Website](https://getartcraft.com/apps/printcraft) |
|
||||
| <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) |
|
||||
|
||||
@@ -240,6 +252,10 @@ And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video stud
|
||||
<a href="https://getartcraft.com/apps/vectorcraft">VectorCraft</a>
|
||||
</p>
|
||||
|
||||
## Star history
|
||||
|
||||
[](https://www.star-history.com/?repos=storytold%2Fvectorcraft&type=date&legend=top-left)
|
||||
|
||||
## License and credits
|
||||
|
||||
VectorCraft is dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE), at your option.
|
||||
@@ -247,8 +263,8 @@ Copyright (c) 2026 ArtCraft Team and the VectorCraft contributors. Required noti
|
||||
|
||||
Bundled fonts, icons, images and other assets keep their own open licenses; each one is listed
|
||||
with its author, source and license in [ASSETS.md](ASSETS.md). Release builds also embed the
|
||||
Japanese fonts of [craft-fonts](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md)
|
||||
(SIL Open Font License 1.1).
|
||||
fonts of [craft-fonts](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md) (Japanese,
|
||||
Simplified Chinese and Arabic faces; SIL Open Font License 1.1).
|
||||
|
||||
The app icon (an engraved dragon on VectorCraft red, `#e8573f`) is the owner's original artwork; its
|
||||
palette and files are in [`assets/app-icon/`](assets/app-icon/README.md).
|
||||
|
||||
+40
-32
@@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
|
||||
|
||||
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
|
||||
|
||||
_Last updated: 2026-10-07 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, and the 2026-10-07 issue fixes; see [Honest assessment](#honest-assessment-2026-10-05))._
|
||||
_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS; see [Honest assessment](#honest-assessment-2026-10-05))._
|
||||
|
||||
## Where we are
|
||||
|
||||
@@ -33,16 +33,16 @@ since 2026-10-01.
|
||||
|
||||
| Dimension | Estimate | Evidence and what's missing |
|
||||
|---|---|---|
|
||||
| **Breadth:** menus, tools and panels exist | ~90% | 16 menu items still stubbed; every tool implemented except Touch Type; 51 panel modules |
|
||||
| **Breadth:** menus, tools and panels exist | ~90% | 13 menu items still stubbed; every tool implemented except Touch Type; 51 panel modules |
|
||||
| **Depth:** each feature behaves like Illustrator | ~69–75% | Strong: paint, appearance and colour (M3), Pathfinder and booleans, selection, drawing, files (M4). Weak: advanced type (~45%), brushes and symbols (in progress), raster effects (~20%) |
|
||||
| **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~1 of ~56 filters), SVG Filters, CJK composition (vertical type with kinsoku and tate-chu-yoko; ruby, mojikumi and vertical metrics open), Variables (data merge), scripting |
|
||||
| **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~1 of ~56 filters), SVG Filters, CJK composition (vertical type with kinsoku, tate-chu-yoko and the font's vertical metrics; ruby, mojikumi and proportional vertical metrics open), Variables (data merge), scripting |
|
||||
| **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first |
|
||||
| **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured |
|
||||
| **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai`, EPS, DXF, EMF/WMF, raster formats, PSD export, Place and Links, Package, Print, clipboard flavours. Native `.ai` private data is out of scope by design; DWG has no open spec |
|
||||
| **Bundled content:** brush, symbol, style and swatch libraries | ~30% | Ours are original and generated in code, and far fewer than Illustrator ships; brush and symbol libraries are still missing |
|
||||
| **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine |
|
||||
| **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA |
|
||||
| **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work; Windows/Linux packaging and accessibility are pending |
|
||||
| **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); hybrid-graphics laptops render on the power-saving GPU by default (#306); web drops land in the document they were dropped on (#359); Windows/Linux packaging and accessibility are pending; an experimental, unsupported Windows 7 x64 OpenGL build exists (community-contributed, not verified by maintainers) |
|
||||
| **Agent automation** | beyond Illustrator | Every command, gesture and dialog is drivable over MCP, the CLI and the control channel |
|
||||
|
||||
### Where we're lacking (in priority order)
|
||||
@@ -58,7 +58,7 @@ Ordered by how much each gap blocks someone from switching. Sizes are one-agent
|
||||
3. **3D and Materials:** Extrude & Bevel, Revolve, Inflate and Rotate with lighting and materials, using a software
|
||||
renderer in its own crate (layering allows it below L6), with output in SVG/PDF as rasters or projected vectors.
|
||||
The largest single gap. 50–80 h.
|
||||
4. **Advanced type:** CJK composition for vertical type (ruby, mojikumi, vertical font metrics, manual
|
||||
4. **Advanced type:** CJK composition for vertical type (ruby, mojikumi, proportional vertical metrics, manual
|
||||
tate-chu-yoko), Optical Margin Alignment, the composer/hyphenation options, tab leaders, a
|
||||
spell-check dictionary (open licence), Touch Type and Snap to Glyph. Vertical point, area and path type have
|
||||
initial support. 35–48 h across type core and advanced.
|
||||
@@ -89,40 +89,47 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command.
|
||||
- JSON-lines control channel with real egui pointer and keyboard injection.
|
||||
- MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings.
|
||||
- MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads).
|
||||
- MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), and the CLI sweeps the protocol surface over stdio against the compiled binary. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads).
|
||||
- Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`).
|
||||
- Actions panel that records and plays back commands.
|
||||
- **UI:** Illustrator 2026 layout restyled to measured values:
|
||||
- Medium Dark theme, categorized and Advanced toolbars, 35 pt document tabs, 33 pt panel tabs.
|
||||
- Hint bar, contextual task bar, 19 dock panels with ≡ menus.
|
||||
- Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan) ships complete, and Czech and Japanese cover every menu label, with system-locale detection on macOS, Windows and Linux, a VectorCraft ▸ Language menu and a Language preference. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins).
|
||||
- The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done.
|
||||
- Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese and Spanish ship complete, and Czech and Brazilian Portuguese cover every menu label (and more), with system-locale detection on macOS, Windows and Linux, a VectorCraft ▸ Language menu and a Language preference. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins).
|
||||
- Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool.
|
||||
- A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas.
|
||||
- macOS: Finder opens every format File › Open reads with VectorCraft (double-click, Open With, a drop on the Dock icon, `open -a`), at launch or while it runs; the release and `cargo xtask bundle` apps share one `Info.plist` that declares those document types, owning only VectorCraft's own formats.
|
||||
- A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas.
|
||||
- Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close).
|
||||
- Four brightness themes, persistent preferences.
|
||||
- Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). Number fields in panels and dialogs step with Up/Down (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once.
|
||||
- Tab shows and hides the panels (in the Type tool it types a tab) without moving the keyboard focus onto a field, so the next shortcut or letter still works; in dialogs and panel fields it moves between fields.
|
||||
- On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content. As on a native menu bar, once one menu is open, moving the pointer onto another title opens that one.
|
||||
- **Tools:**
|
||||
- **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso.
|
||||
- **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join.
|
||||
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools.
|
||||
- **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso. Dragging a path segment with Direct Selection bends it when it's curved (its anchors stay) and moves its two anchors when it's straight, one undo step; the fill, Shift or Alt still pick the whole path (Alt-drag copies it).
|
||||
- **Saved selections:** Select ▸ Save Selection… asks for a name and keeps the selection in the document (saved with the file, undoable, up to 25); each one is listed at the bottom of the Select menu and a click selects its objects again; Edit Selection… lists them to rename or delete, applied together on OK. A file's saved selections are capped and checked against its objects when it opens. Not yet: Update Selection and the Layers panel's saved-selection menu.
|
||||
- **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. Direction handles dragged with Direct Selection or the Anchor Point tool snap to smart guides, and Shift keeps them at 45° steps from their anchor.
|
||||
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers).
|
||||
- **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes).
|
||||
- **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag).
|
||||
- **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres; Alt-click opens the tool's dialog, and a drag with Alt down at the release, from the press or pressed mid-drag, transforms a copy), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag).
|
||||
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout. Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them.
|
||||
- **Pen:** Alt pressed in the middle of dragging out an anchor splits its handles and keeps the curve already shaped into it (only the outgoing handle follows the pointer); Space held while dragging out an anchor moves it, handles and all. Clicking the last anchor of the path being drawn retracts its outgoing handle (convert cursor), and dragging from it pulls out a new one on its own. Auto Add/Delete: over a selected path, a click on a segment adds an anchor and a click on an anchor deletes it (add and delete cursors); Shift held, or Preferences › General › Disable Auto Add/Delete, starts a new path instead.
|
||||
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type.
|
||||
- **Other:** Eyedropper, Gradient annotator, Artboard, Measure, Type, Hand, Zoom, Rotate View.
|
||||
- **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on), Measure, Type, Hand, Zoom, Rotate View.
|
||||
- **Editing type:** double-clicking type with a selection tool switches to the Type tool with the caret where it was clicked.
|
||||
- **Temporary tools:** Cmd with any other tool drags with the selection tool used last (Selection, Direct Selection or Group Selection). Held Space pans and Cmd+Space zooms (Cmd+Alt+Space out) whatever the tool.
|
||||
- **Panel drag and drop:** art dragged off the canvas onto the Symbols panel becomes a symbol, and a symbol dragged onto the canvas places an instance where it is dropped.
|
||||
- **Panel drag and drop:** art dragged off the canvas onto the Symbols panel becomes a symbol, onto the Swatches panel a pattern swatch and onto the Brushes panel an Art brush; a symbol dragged onto the canvas places an instance where it is dropped, and a brush dragged onto a path is applied to it.
|
||||
- **Tool double-clicks:** double-clicking a tool's button opens its options: Hand fits the artboard in the window, Zoom shows 100 %, Rotate, Scale, Reflect and Shear open their Object › Transform dialogs, Gradient, Eyedropper, Blend and the Liquify tools their panels or options.
|
||||
- **Drawing aids:** Smart Guides and snapping, guides dragged out of the rulers, and Draw Normal / Behind / Inside modes.
|
||||
- **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard and fits it, and Fit Artboard in Window and Actual Size show the navigator's artboard.
|
||||
- **Layers panel:** every layer, sublayer, group and object is a clickable row at any depth: a click highlights it and makes its layer current (new art goes there), Shift-click a range, Ctrl/Cmd-click toggles. Sublayers are no longer treated as objects: clicks, marquees, Select All and the Select menu take the art inside them, and each sublayer selects in its own colour. Selection squares on rows with selected art (click selects a row's art, Shift adds or removes; dragging the square moves the selection to another row, Alt copies), the eye and lock on every row with drag-down-the-column (one undo step), Ctrl/Cmd-click an eye for per-layer Outline, Alt-click an eye or lock to hide or lock the other layers (again: show or unlock them all), triangles with Alt to open or close everything inside, drag and drop rows above, below or into layers and groups with a drop line (Alt copies, the trash deletes), inline rename, Delete key on highlighted rows. Buttons and the full panel menu: New Layer/Sublayer (Alt: options, Ctrl: on top), Duplicate, Delete, Options for Selection, clipping mask, isolation, Locate Object, Merge Selected, Flatten Artwork, Collect in New Layer, Release to Layers (Sequence and Build), Reverse Order, Template, Hide/Outline/Lock Others and Show/Preview/Unlock All, Paste Remembers Layers, Panel Options (row size, thumbnails per kind, Show Layers Only). Layer Options: name, colour (presets or custom), Template, Lock, Show, Print, Preview (outline drawing and clicking) and Dim Images to %. Every operation is a command (`layer.move`, `layer.highlight`, `layer.merge`…) with validation against cycles and invalid nesting.
|
||||
- **Drawing aids:** Smart Guides and snapping (moving, and resizing by the bounding-box handles: the dragged edges align with the other objects; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H), guides dragged out of the rulers, and Draw Normal / Behind / Inside modes.
|
||||
- **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard and fits it, and Fit Artboard in Window and Actual Size show the navigator's artboard. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it.
|
||||
- **Geometry and effects:**
|
||||
- Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below.
|
||||
- Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected.
|
||||
- Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters).
|
||||
- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity), Character and Paragraph Styles (override-preserving redefine), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline. Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done.
|
||||
- **Interface languages, vertical type:** VectorCraft ▸ Language (or Preferences ▸ User Interface) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional Chinese covers the whole interface, Czech and Japanese every menu label (tested); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech ones from the bundled UI fonts. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku (the strict set) applies to horizontal and vertical Japanese; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines.
|
||||
- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity), Character and Paragraph Styles (override-preserving redefine), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline. Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text.
|
||||
- **Interface languages, vertical type:** VectorCraft ▸ Language (or Preferences ▸ User Interface) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese and Spanish (neutral international Spanish, for every `es-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech and Spanish ones from the bundled UI fonts. The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku (the strict set) applies to horizontal and vertical Japanese, and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width, and puts a quarter em between Japanese and Latin letters or digits; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines.
|
||||
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks.
|
||||
- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type, Gradient Mesh, Shape Builder, Live Paint (every path an edge, open lines included), Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
|
||||
- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder, Live Paint (every path an edge, open lines included), Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
|
||||
- **Paint and appearance (M3):**
|
||||
- **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved and loaded as `.vcswatches`, `.gpl` or CSS. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate.
|
||||
- **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel follows each colour's own model and Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers, panels have their shortcuts, and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening.
|
||||
@@ -134,13 +141,14 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; six generated style libraries save and load as `.vcstyles`.
|
||||
- **Neutral wording:** labels, MCP tool text, docs and packaging use neutral names, and `cargo xtask brands` (part of `cargo xtask ci`) fails on vendor names.
|
||||
- **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize.
|
||||
- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing.
|
||||
- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed.
|
||||
- **Files (M4):**
|
||||
- `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash, background save and export.
|
||||
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units; PDF and PDF-compatible `.ai` in and out (layers, masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3).
|
||||
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export. EPS import runs Illustrator's AGM/CoolType prologs (resource categories, `resourceforall`, `clipsave`/`cliprestore`, subarrays and substrings that share storage) and falls back to its palette TIFF preview. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole).
|
||||
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units; PDF and PDF-compatible `.ai` in and out (layers, masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3).
|
||||
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export. EPS import runs Illustrator's AGM/CoolType prologs (resource categories, `resourceforall`, `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters, `cshow`, metadata skipped with `flushfile`), so Illustrator 30 EPS files open as vectors, and falls back to its palette TIFF preview. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole).
|
||||
- Place and the Links panel, Package, File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours.
|
||||
- **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped.
|
||||
- **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so the desktop app asks for the power-saving GPU by default (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306). Preferences › Performance › Graphics Processor (`gpuPreference`) or `WGPU_POWER_PREF` picks the discrete one; Help › About and `ui.inspect` name the adapter in use.
|
||||
- **Tests and robustness:** ~2,840 automated tests: model-based property tests, a junk-parameter sweep over every command, import fuzzing (SVG, PDF, libraries), golden renders, and MCP end-to-end tests over stdio. Shipped code never panics (workspace lints and a rollback safety net; see `docs/development.md`).
|
||||
|
||||
## Milestones and estimates
|
||||
@@ -149,19 +157,19 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
|---|---|---|---|
|
||||
| M0 | Skeleton + vertical slice | ✅ done | — |
|
||||
| M1 | Selection, transform, layers, MCP | ✅ mostly done (transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending) | 4–6 |
|
||||
| M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape, Live Corners widget dragging landed; Shaper, Pen modifier nuances) | 15–20 |
|
||||
| M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape, Live Corners widget dragging landed; Shaper, the remaining Pen modifier nuances) | 15–20 |
|
||||
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork; gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool, Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; confirm the Scale Strokes & Effects default | 4–8 |
|
||||
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info. Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, polish | 3–6 |
|
||||
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
|
||||
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder edge erase, large-offset bug open) | 3–6 |
|
||||
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options (with Width/Height), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 |
|
||||
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options (with Width/Height), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel, Hebrew/Arabic bidirectional text and Paragraph Direction done; tab leaders, spell check, vertical type pending | 45–60 |
|
||||
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 |
|
||||
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 |
|
||||
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
|
||||
| M11 | Artboards & views (artboard panel/tool, Trim View, middle-button pan, print tiling and the status bar's artboard navigator done; multiple windows, presentation polish) | 🟡 | 15–25 |
|
||||
| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (12 presets, 18 ms/1k² image), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, regenerate in place) done; graph Design/Column/Marker designs pending | 6–10 |
|
||||
| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 |
|
||||
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed), workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs and menus that scroll when longer than the window done; accessibility, Windows/Linux packaging pending | 18–28 |
|
||||
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed), workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs and menus that scroll when longer than the window done; an experimental, unsupported Windows 7 x64 portable OpenGL build (not verified by maintainers); accessibility and broader Windows/Linux packaging/QA pending | 18–28 |
|
||||
| — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance) | ⬜ | 60–90 |
|
||||
| — | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟡 | 50–80 |
|
||||
| | **Total to full parity** (one agent; re-derived from the [Parity estimate](#parity-estimate) table) | | **~320–500** |
|
||||
@@ -181,7 +189,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
|
||||
| Area | Weight | Done | Missing (main items) | One agent (h) |
|
||||
|---|---:|---:|---|---:|
|
||||
| Selection, transform & align tools | 6 | 85% | Free Transform/Scale/Reflect along a rotated box, Start Global Edit, transform nuances (the rotated persistent bounding box is done) | 4–6 |
|
||||
| Drawing tools | 7 | 85% | Shaper Groups (merge/punch overlapping shapes), pen/pencil modifier nuances, Touch Type | 10–15 |
|
||||
| Drawing tools | 7 | 85% | Shaper Groups (merge/punch overlapping shapes), pencil and the remaining pen modifier nuances, Touch Type | 10–15 |
|
||||
| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder edge cases | 3–6 |
|
||||
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform and mixed spot/process gradients in SVG/PDF (exported as stops or process colours), pattern fills in Expand | 2–4 |
|
||||
| Strokes, brushes, width profiles | 5 | 88% | brush options depth, brush libraries (generated in code) | 6–10 |
|
||||
@@ -189,14 +197,14 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
|
||||
| Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap, SVG Filters | 6–10 |
|
||||
| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 20% | ~55 Photoshop-style filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Stylize, Texture, Video) and the Effect Gallery; Document Raster Effects Settings and raster effects in PDF are done | 28–42 |
|
||||
| 3D and Materials | 4 | 0% | Extrude & Bevel, Revolve, Inflate, Rotate, lighting, materials (software renderer) | 50–80 |
|
||||
| Type core | 9 | 78% | composer/hyphenation options, Optical Margin Alignment, hidden characters, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee | 15–20 |
|
||||
| Type advanced | 4 | 48% | CJK composition for vertical type (ruby, mojikumi, vertical metrics, manual tate-chu-yoko), variable font axis sliders (named instances are styles), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support | 20–28 |
|
||||
| Type core | 9 | 79% | Middle Eastern features beyond bidi layout and Paragraph Direction (Character Direction, digit types, kashidas, the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); composer/hyphenation options, Optical Margin Alignment, hidden characters, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee | 15–20 |
|
||||
| Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — hanging punctuation, line-start brackets, kinsoku sets and the Mojikumi Settings dialog —, proportional vertical metrics, manual tate-chu-yoko), variable font axis sliders (named instances are styles), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support | 20–28 |
|
||||
| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | symbol libraries (original), dynamic symbols, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done) | 5–8 |
|
||||
| Image Trace, graphs, image tools | 3 | 70% | graph Design/Column/Marker, Create Object Mosaic, Crop Image polish (Vector Halftone is done) | 6–10 |
|
||||
| Layers, artboards, document setup | 5 | 80% | Layers panel options depth, artboard presets/rearrange polish (Document Setup and New Document are done) | 5–8 |
|
||||
| Layers, artboards, document setup | 5 | 82% | Layers panel: a confirmation before deleting layers that hold art, Collect for Export, row context menus, the Layer Options dialog for several rows with mixed values, Paste Remembers Layers for sublayers, drag-scrolling a long panel while dragging rows; artboard presets/rearrange polish (Document Setup, New Document and the rest of the Layers panel are done) | 4–7 |
|
||||
| View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 |
|
||||
| Guides, grids, smart guides, snapping, rulers | 3 | 75% | global/video rulers, smart-guide preference depth, ruler-drag modifiers (Shift snaps to ticks, Alt swaps orientation) and dragging existing guides | 4–8 |
|
||||
| File formats | 6 | 85% | DWG (no open spec: DXF instead), PSD placement as layers, Illustrator EPS checked against real files (only reduced repros so far), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers and presets are done | 6–10 |
|
||||
| File formats | 6 | 85% | DWG (no open spec: DXF instead), PSD placement as layers, Illustrator EPS checked against more real files (two Illustrator 30 files open as vectors so far), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers and presets are done | 6–10 |
|
||||
| Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 |
|
||||
| Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done) | 1–2 |
|
||||
| Automation | 3 | 60% | Variables (data merge), scripting surface, batch | 8–12 |
|
||||
@@ -211,7 +219,7 @@ With 4–6 agents working on disjoint crates (as the layering allows) the wall-c
|
||||
(integration, review and shared files such as `menus.rs` serialize some work): **~55–95 h** to feature parity,
|
||||
**~80–145 h** to full parity.
|
||||
|
||||
_Inventories (2026-10-05):_ 16 menu items still stubbed (`todo(…)` in `crates/ui-egui/src/menus.rs`); every tool
|
||||
_Inventories (2026-10-07):_ 13 menu items still stubbed (`todo(…)` in `crates/ui-egui/src/menus.rs`); every tool
|
||||
implemented except Touch Type; 51 panel modules; Illustrator-style live
|
||||
effects ~44/54, Photoshop-style raster effects ~1/56, 3D 0/5; ~2,840 tests; ~221k lines of Rust.
|
||||
|
||||
|
||||
@@ -83,6 +83,119 @@ fn mcp_headless_over_stdio() {
|
||||
assert_eq!(replies[3]["result"]["content"][0]["type"], "image");
|
||||
}
|
||||
|
||||
/// End-to-end protocol sweep over stdio against the compiled binary. The in-process
|
||||
/// tests drive `Server::handle_line` directly, so the binary's own wiring (argument
|
||||
/// parsing, logger install, stdout pollution, notification order) is untested: this
|
||||
/// drives the real `mcp --headless` process through prompts, completions, resource
|
||||
/// templates, logging and the error taxonomy instead.
|
||||
#[test]
|
||||
fn mcp_protocol_over_stdio() {
|
||||
use std::io::{BufRead, BufReader};
|
||||
let mut child =
|
||||
Command::new(BIN).args(["mcp", "--headless"]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
let mut lines = BufReader::new(child.stdout.take().unwrap()).lines();
|
||||
let mut notes = vec![];
|
||||
|
||||
fn send(stdin: &mut std::process::ChildStdin, v: Value) {
|
||||
writeln!(stdin, "{v}").unwrap();
|
||||
}
|
||||
// Next reply with this id; notifications (no id) are stashed for later.
|
||||
fn recv(lines: &mut std::io::Lines<BufReader<std::process::ChildStdout>>, notes: &mut Vec<Value>, id: u64) -> Value {
|
||||
loop {
|
||||
let line = lines.next().unwrap().unwrap();
|
||||
let v: Value = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!(v["jsonrpc"], "2.0", "{v}");
|
||||
if v.get("id").and_then(|i| i.as_u64()) == Some(id) {
|
||||
return v;
|
||||
}
|
||||
notes.push(v);
|
||||
}
|
||||
}
|
||||
|
||||
send(
|
||||
&mut stdin,
|
||||
json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}),
|
||||
);
|
||||
let r = recv(&mut lines, &mut notes, 1);
|
||||
assert_eq!(r["result"]["serverInfo"]["name"], "vectorcraft");
|
||||
assert!(r["result"]["capabilities"]["prompts"].is_object(), "{r}");
|
||||
send(&mut stdin, json!({"jsonrpc":"2.0","method":"notifications/initialized"}));
|
||||
|
||||
send(&mut stdin, json!({"jsonrpc":"2.0","id":2,"method":"prompts/list","params":{}}));
|
||||
let r = recv(&mut lines, &mut notes, 2);
|
||||
assert!(r["result"]["prompts"].as_array().is_some_and(|p| !p.is_empty()), "{r}");
|
||||
send(&mut stdin, json!({"jsonrpc":"2.0","id":3,"method":"prompts/get","params":{"name":"poster","arguments":{"brief":"a jazz festival"}}}));
|
||||
let r = recv(&mut lines, &mut notes, 3);
|
||||
assert!(r["result"]["messages"][0]["content"]["text"].as_str().is_some_and(|t| t.contains("a jazz festival")), "{r}");
|
||||
|
||||
send(
|
||||
&mut stdin,
|
||||
json!({"jsonrpc":"2.0","id":4,"method":"completion/complete","params":{"ref":{"type":"ref/prompt","name":"export-set"},"argument":{"name":"formats","value":"sv"}}}),
|
||||
);
|
||||
let r = recv(&mut lines, &mut notes, 4);
|
||||
assert!(r["result"]["completion"]["values"].as_array().is_some_and(|v| v.contains(&json!("svg"))), "{r}");
|
||||
|
||||
send(&mut stdin, json!({"jsonrpc":"2.0","id":5,"method":"resources/list","params":{}}));
|
||||
assert!(recv(&mut lines, &mut notes, 5)["result"]["resources"].as_array().is_some_and(|v| v.len() == 2));
|
||||
send(&mut stdin, json!({"jsonrpc":"2.0","id":6,"method":"resources/templates/list","params":{}}));
|
||||
assert!(recv(&mut lines, &mut notes, 6)["result"]["resourceTemplates"].as_array().is_some_and(|v| v.len() >= 4));
|
||||
|
||||
send(
|
||||
&mut stdin,
|
||||
json!({"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"draw_shape","arguments":{"shape":"rectangle","x":10,"y":10,"width":60,"height":40}}}),
|
||||
);
|
||||
let r = recv(&mut lines, &mut notes, 7);
|
||||
assert_eq!(r["result"]["isError"], false, "{r}");
|
||||
let id: i64 = serde_json::from_str::<Value>(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap()["id"].as_i64().unwrap();
|
||||
|
||||
send(&mut stdin, json!({"jsonrpc":"2.0","id":8,"method":"resources/read","params":{"uri": format!("vectorcraft://object/{id}")}}));
|
||||
let r = recv(&mut lines, &mut notes, 8);
|
||||
let body: Value = serde_json::from_str(r["result"]["contents"][0]["text"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(body["id"].as_i64(), Some(id), "{body}");
|
||||
|
||||
// Unknown resources (-32002) and bad template values (-32602) stay apart.
|
||||
send(&mut stdin, json!({"jsonrpc":"2.0","id":9,"method":"resources/read","params":{"uri":"vectorcraft://nope" }}));
|
||||
assert_eq!(recv(&mut lines, &mut notes, 9)["error"]["code"], -32002);
|
||||
send(&mut stdin, json!({"jsonrpc":"2.0","id":10,"method":"resources/read","params":{"uri":"vectorcraft://object/"}}));
|
||||
assert_eq!(recv(&mut lines, &mut notes, 10)["error"]["code"], -32602);
|
||||
|
||||
// The binary installed its logger: records arrive as notifications/message, and only
|
||||
// after the client asked for them.
|
||||
send(&mut stdin, json!({"jsonrpc":"2.0","id":11,"method":"logging/setLevel","params":{"level":"debug"}}));
|
||||
assert!(recv(&mut lines, &mut notes, 11).get("error").is_none());
|
||||
send(&mut stdin, json!({"jsonrpc":"2.0","id":12,"method":"tools/list","params":{}}));
|
||||
assert!(recv(&mut lines, &mut notes, 12)["result"]["tools"].as_array().is_some_and(|v| !v.is_empty()));
|
||||
|
||||
// Malformed input is a parse error, and the server keeps serving afterwards.
|
||||
stdin.write_all(b"{not json\n").unwrap();
|
||||
let parse_err: Value = loop {
|
||||
let line = lines.next().unwrap().unwrap();
|
||||
let v: Value = serde_json::from_str(&line).unwrap();
|
||||
if v.get("error").is_some() {
|
||||
break v;
|
||||
}
|
||||
notes.push(v);
|
||||
};
|
||||
assert_eq!(parse_err["error"]["code"], -32700);
|
||||
assert!(parse_err["id"].is_null(), "{parse_err}");
|
||||
send(&mut stdin, json!({"jsonrpc":"2.0","id":13,"method":"ping","params":{}}));
|
||||
assert!(recv(&mut lines, &mut notes, 13).get("error").is_none());
|
||||
drop(stdin);
|
||||
// Drain whatever the debug window queued; every line off the protocol stream parses.
|
||||
for line in lines.by_ref() {
|
||||
let line = line.unwrap();
|
||||
let v: Value = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!(v["jsonrpc"], "2.0", "{v}");
|
||||
notes.push(v);
|
||||
}
|
||||
let out = child.wait_with_output().unwrap();
|
||||
assert!(out.status.success());
|
||||
assert!(!String::from_utf8_lossy(&out.stderr).contains("panicked"));
|
||||
let messages = notes.iter().filter(|n| n["method"] == "notifications/message").count();
|
||||
assert!(messages > 0, "no log records after logging/setLevel: {notes:?}");
|
||||
}
|
||||
|
||||
/// `vectorcraft-cli commands | head -1` panicked with "failed printing to
|
||||
/// stdout: Broken pipe (os error 32)" and exit status 101.
|
||||
#[test]
|
||||
|
||||
@@ -108,8 +108,8 @@ impl eframe::App for WebShell {
|
||||
match f.bytes_async().await {
|
||||
Ok(bytes) => {
|
||||
match target {
|
||||
DropTarget::Place { at, embed } => {
|
||||
place_inbox.lock().unwrap_or_else(|e| e.into_inner()).push(PlaceArrival { name, bytes, drop: Some((at, embed)) })
|
||||
DropTarget::Place(d) => {
|
||||
place_inbox.lock().unwrap_or_else(|e| e.into_inner()).push(PlaceArrival { name, bytes, drop: Some(d) })
|
||||
}
|
||||
DropTarget::Open => inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes)),
|
||||
}
|
||||
|
||||
@@ -9,10 +9,17 @@ default-run = "vectorcraft"
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
default = ["wgpu", "accessibility"]
|
||||
wgpu = ["eframe/wgpu"]
|
||||
accessibility = ["eframe/accesskit"]
|
||||
# Build with --no-default-features --features windows7 and the win7 Rust target.
|
||||
windows7 = ["eframe/glow"]
|
||||
|
||||
[dependencies]
|
||||
vectorcraft-engine = { workspace = true }
|
||||
vectorcraft-ui-egui = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wayland", "x11"] }
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
@@ -31,7 +38,10 @@ clipboard-win = { workspace = true }
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
muda = { version = "0.21", default-features = false }
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSTextInputContext"] }
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSTextInputContext"] }
|
||||
# Files opened from Finder and the Dock (src/open_documents.rs); both already come with winit, muda and rfd.
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSNotification", "NSOperation", "NSString", "NSURL"] }
|
||||
block2 = "0.6"
|
||||
|
||||
[build-dependencies]
|
||||
# Icon + VERSIONINFO for vectorcraft.exe; build.rs only uses it when targeting Windows.
|
||||
|
||||
@@ -7,10 +7,19 @@
|
||||
//! See `vectorcraft_ui_egui::control` for the methods.
|
||||
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
||||
|
||||
#[cfg(all(feature = "windows7", any(feature = "wgpu", feature = "accessibility")))]
|
||||
compile_error!("windows7 requires --no-default-features (wgpu and accessibility must be disabled)");
|
||||
#[cfg(all(windows, feature = "windows7", not(target_vendor = "win7")))]
|
||||
compile_error!("windows7 requires --target x86_64-win7-windows-msvc; the ordinary Windows target still imports newer APIs");
|
||||
#[cfg(all(target_vendor = "win7", not(feature = "windows7")))]
|
||||
compile_error!("the win7 target requires --no-default-features --features windows7");
|
||||
|
||||
mod clipboard;
|
||||
mod control_server;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod open_documents;
|
||||
mod printing;
|
||||
mod window;
|
||||
|
||||
@@ -30,6 +39,7 @@ impl eframe::App for App {
|
||||
if let Some(m) = &mut self.1 {
|
||||
m.poll(&mut self.0);
|
||||
}
|
||||
open_files(&mut self.0, open_documents::take());
|
||||
}
|
||||
self.0.logic(ctx);
|
||||
window::track(ctx, &mut self.0.ui.window);
|
||||
@@ -47,9 +57,25 @@ impl eframe::App for App {
|
||||
discard_marked_text();
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "windows7"))]
|
||||
fn on_exit(&mut self) {
|
||||
save_prefs(&self.0);
|
||||
}
|
||||
#[cfg(feature = "windows7")]
|
||||
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
|
||||
save_prefs(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Open files handed to the app (command line, macOS Finder and Dock) as documents. A file that
|
||||
/// can't be opened is reported in the status bar and on stderr; the others still open.
|
||||
fn open_files(app: &mut VectorcraftApp, files: Vec<String>) {
|
||||
for f in files {
|
||||
if let Err(e) = vectorcraft_ui_egui::io::open_path(app, &f) {
|
||||
eprintln!("vectorcraft: {f}: {e}");
|
||||
app.status(format!("Couldn't open {}: {e}", fileio::file_name(&f)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tell the macOS input method to drop its composition (the Type tool kept the marked text as
|
||||
@@ -241,6 +267,29 @@ fn app_icon() -> egui::IconData {
|
||||
eframe::icon_data::from_png_bytes(png).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The power preference wgpu picks the window's graphics adapter with (#306): the
|
||||
/// `WGPU_POWER_PREF` environment variable (`low`, `high`, `none`) when set, otherwise Preferences ›
|
||||
/// Performance › Graphics Processor (`gpuPreference`). Power saving unless the preference asks for
|
||||
/// high performance: the canvas is rasterized on the CPU and the GPU only composites it, which the
|
||||
/// integrated GPU of a hybrid-graphics laptop does easily, while presenting frames rendered on the
|
||||
/// discrete GPU through the integrated one made the window flicker on some laptops. With a single
|
||||
/// GPU both preferences pick it.
|
||||
#[cfg(feature = "wgpu")]
|
||||
fn power_preference(pref: Option<&str>, env: Option<eframe::wgpu::PowerPreference>) -> eframe::wgpu::PowerPreference {
|
||||
use eframe::wgpu::PowerPreference;
|
||||
match (env, pref) {
|
||||
(Some(p), _) => p,
|
||||
(None, Some("highPerformance")) => PowerPreference::HighPerformance,
|
||||
(None, _) => PowerPreference::LowPower,
|
||||
}
|
||||
}
|
||||
|
||||
/// "name (backend, kind)" of the adapter the window renders with, for Help › About and bug reports.
|
||||
#[cfg(feature = "wgpu")]
|
||||
fn adapter_summary(info: &eframe::wgpu::AdapterInfo) -> String {
|
||||
format!("{} ({:?}, {:?})", info.name.trim(), info.backend, info.device_type)
|
||||
}
|
||||
|
||||
/// Windows and Linux: no OS title bar; the app bar is the title bar (`vectorcraft_ui_egui::titlebar`).
|
||||
/// macOS keeps its traffic lights over the integrated title strip.
|
||||
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
|
||||
@@ -262,6 +311,10 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
let saved = read_prefs();
|
||||
let saved_window = saved.as_ref().and_then(|ui| ui.window);
|
||||
#[cfg(feature = "wgpu")]
|
||||
let gpu_pref = saved.as_ref().and_then(|ui| ui.engine_prefs.get("gpuPreference")).and_then(serde_json::Value::as_str);
|
||||
#[cfg(feature = "wgpu")]
|
||||
let power = power_preference(gpu_pref, eframe::wgpu::PowerPreference::from_env());
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("VectorCraft")
|
||||
@@ -274,8 +327,21 @@ fn main() -> eframe::Result {
|
||||
.with_title_shown(false)
|
||||
.with_icon(app_icon())
|
||||
.with_app_id("ai.storyteller.vectorcraft"),
|
||||
#[cfg(feature = "windows7")]
|
||||
renderer: eframe::Renderer::Glow,
|
||||
..Default::default()
|
||||
};
|
||||
#[cfg(feature = "wgpu")]
|
||||
let options = {
|
||||
let mut options = options;
|
||||
if let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
|
||||
create.power_preference = power;
|
||||
}
|
||||
options
|
||||
};
|
||||
// Files opened from Finder and the Dock arrive as events, not arguments.
|
||||
#[cfg(target_os = "macos")]
|
||||
open_documents::install();
|
||||
eframe::run_native(
|
||||
"VectorCraft",
|
||||
options,
|
||||
@@ -297,17 +363,25 @@ fn main() -> eframe::Result {
|
||||
let recovery = prefs_path().and_then(|p| Some(p.parent()?.join("Data Recovery").to_string_lossy().to_string()));
|
||||
app.session.recovery.set_default_folder(recovery);
|
||||
}
|
||||
#[cfg(feature = "wgpu")]
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
let summary = adapter_summary(&rs.adapter.get_info());
|
||||
log::info!("vectorcraft: rendering with {summary} (power preference {power:?})");
|
||||
app.graphics_adapter = Some(summary);
|
||||
}
|
||||
#[cfg(feature = "windows7")]
|
||||
{
|
||||
app.graphics_adapter = Some("OpenGL (Windows 7 compatibility)".into());
|
||||
}
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
app.custom_titlebar = CUSTOM_TITLEBAR;
|
||||
if let Some(port) = control_port {
|
||||
let rx = control_server::start(port, cc.egui_ctx.clone());
|
||||
app = app.with_control(rx);
|
||||
}
|
||||
for f in files {
|
||||
if let Err(e) = vectorcraft_ui_egui::io::open_path(&mut app, &f) {
|
||||
eprintln!("vectorcraft: {f}: {e}");
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
open_documents::set_ui(&cc.egui_ctx);
|
||||
open_files(&mut app, files);
|
||||
Ok(Box::new(App(
|
||||
app,
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -316,3 +390,64 @@ fn main() -> eframe::Result {
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "wgpu"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use eframe::wgpu::PowerPreference;
|
||||
|
||||
#[test]
|
||||
fn power_saving_unless_the_preference_or_the_environment_says_otherwise() {
|
||||
assert_eq!(power_preference(None, None), PowerPreference::LowPower);
|
||||
assert_eq!(power_preference(Some("powerSaving"), None), PowerPreference::LowPower);
|
||||
assert_eq!(power_preference(Some("highPerformance"), None), PowerPreference::HighPerformance);
|
||||
// A value this version doesn't know (a newer or damaged preference file) is the default.
|
||||
assert_eq!(power_preference(Some("turbo"), None), PowerPreference::LowPower);
|
||||
assert_eq!(power_preference(Some(""), None), PowerPreference::LowPower);
|
||||
// WGPU_POWER_PREF wins over the preference, either way.
|
||||
assert_eq!(power_preference(Some("powerSaving"), Some(PowerPreference::HighPerformance)), PowerPreference::HighPerformance);
|
||||
assert_eq!(power_preference(Some("highPerformance"), Some(PowerPreference::LowPower)), PowerPreference::LowPower);
|
||||
assert_eq!(power_preference(None, Some(PowerPreference::None)), PowerPreference::None);
|
||||
}
|
||||
|
||||
/// The file extensions the macOS bundle declares: its document types and its own exported type.
|
||||
fn plist_extensions(plist: &str) -> Vec<&str> {
|
||||
["<key>CFBundleTypeExtensions</key>", "<key>public.filename-extension</key>"]
|
||||
.iter()
|
||||
.flat_map(|key| plist.split(key).skip(1))
|
||||
.filter_map(|rest| rest.split("</array>").next())
|
||||
.flat_map(|array| array.split("<string>").skip(1))
|
||||
.filter_map(|s| s.split("</string>").next())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Finder offers the app for every file File › Open reads (#295, #354), takes over no other
|
||||
/// app's files, and hands them to the app rather than to AppKit's document machinery.
|
||||
#[test]
|
||||
fn the_macos_bundle_opens_every_readable_format() {
|
||||
let plist = include_str!("../../../packaging/macos/Info.plist.in");
|
||||
let declared = plist_extensions(plist);
|
||||
for e in fileio::OPEN_EXTS {
|
||||
assert!(declared.contains(e), "Info.plist.in doesn't declare .{e}");
|
||||
}
|
||||
for e in &declared {
|
||||
assert!(fileio::OPEN_EXTS.contains(e), "Info.plist.in declares .{e}, which the app doesn't open");
|
||||
}
|
||||
assert!(!plist.contains("<key>NSDocumentClass</key>"), "not an NSDocument app: AppKit would refuse the files");
|
||||
let types = plist.matches("<key>CFBundleTypeName</key>").count();
|
||||
assert!(types > 1 && plist.matches("<key>LSHandlerRank</key>").count() == types, "every document type has a rank");
|
||||
assert_eq!(plist.matches("<string>Owner</string>").count(), 2, "only VectorCraft documents and templates are owned");
|
||||
}
|
||||
|
||||
/// The preference the engine saves is the one the app reads back before the window opens.
|
||||
#[test]
|
||||
fn the_saved_engine_preference_is_found() {
|
||||
let mut prefs = vectorcraft_engine::Prefs::default();
|
||||
let saved = prefs.to_json();
|
||||
assert_eq!(saved.get("gpuPreference").and_then(serde_json::Value::as_str), Some("powerSaving"));
|
||||
prefs.gpu_preference = "highPerformance".into();
|
||||
let saved = prefs.to_json();
|
||||
let pref = saved.get("gpuPreference").and_then(serde_json::Value::as_str);
|
||||
assert_eq!(power_preference(pref, None), PowerPreference::HighPerformance);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
//! macOS: files opened from Finder (double-click, Open With, a drop on the Dock icon) or with
|
||||
//! `open -a VectorCraft file`.
|
||||
//!
|
||||
//! macOS doesn't pass these on the command line: AppKit hands them to the application delegate's
|
||||
//! `application:openURLs:`, and when the delegate has no such method it asks `NSDocumentController`,
|
||||
//! which fails with "VectorCraft cannot open files in the … format" because VectorCraft isn't an
|
||||
//! `NSDocument` app. winit 0.30's delegate doesn't implement the method, so [`install`] adds it to
|
||||
//! the delegate's class when the app is about to finish launching: after winit set the delegate and
|
||||
//! before AppKit delivers the files the app was launched with. The handler queues the paths and
|
||||
//! wakes the UI, which opens them with [`take`] like files named on the command line.
|
||||
//!
|
||||
//! Adding a method to another crate's Objective-C class has no safe API, hence the scoped
|
||||
//! `unsafe_code` allowance (winit overrides `sendEvent:` on `NSApplication` the same way).
|
||||
#![allow(unsafe_code)]
|
||||
|
||||
use std::ptr::NonNull;
|
||||
use std::sync::{Mutex, OnceLock, PoisonError};
|
||||
|
||||
use block2::RcBlock;
|
||||
use objc2::MainThreadMarker;
|
||||
use objc2::runtime::{AnyObject, Imp, Sel};
|
||||
use objc2::sel;
|
||||
use objc2_app_kit::{NSApplication, NSApplicationWillFinishLaunchingNotification};
|
||||
use objc2_foundation::{NSArray, NSNotification, NSNotificationCenter, NSURL};
|
||||
|
||||
/// Paths that arrived and haven't been opened yet.
|
||||
static PENDING: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
||||
/// The UI to wake when files arrive (unset until the window exists; the first frame opens them).
|
||||
static UI: OnceLock<egui::Context> = OnceLock::new();
|
||||
|
||||
/// Handle open-document events from now on. Call before the event loop runs.
|
||||
pub fn install() {
|
||||
let block = RcBlock::new(|_: NonNull<NSNotification>| add_open_urls_method());
|
||||
// SAFETY: the name is AppKit's notification constant; no object or queue filter, so the block
|
||||
// runs on the posting thread (AppKit posts it on the main thread). The notification center
|
||||
// keeps the registration (and the block) for the life of the app.
|
||||
unsafe {
|
||||
NSNotificationCenter::defaultCenter().addObserverForName_object_queue_usingBlock(
|
||||
Some(NSApplicationWillFinishLaunchingNotification),
|
||||
None,
|
||||
None,
|
||||
&block,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Wake `ctx` when files arrive while the app runs.
|
||||
pub fn set_ui(ctx: &egui::Context) {
|
||||
// Set once; a second call would hand over the same context.
|
||||
let _ = UI.set(ctx.clone());
|
||||
}
|
||||
|
||||
/// The paths that arrived since the last call.
|
||||
pub fn take() -> Vec<String> {
|
||||
std::mem::take(&mut *PENDING.lock().unwrap_or_else(PoisonError::into_inner))
|
||||
}
|
||||
|
||||
/// `application:openURLs:` on the application delegate's class.
|
||||
fn add_open_urls_method() {
|
||||
let Some(mtm) = MainThreadMarker::new() else { return };
|
||||
let Some(delegate) = NSApplication::sharedApplication(mtm).delegate() else {
|
||||
log::warn!("vectorcraft: no application delegate; files opened from Finder won't open");
|
||||
return;
|
||||
};
|
||||
let class = AsRef::<AnyObject>::as_ref(&*delegate).class();
|
||||
let handler: extern "C-unwind" fn(&AnyObject, Sel, &AnyObject, &NSArray<NSURL>) = open_urls;
|
||||
// SAFETY: `Imp` is the untyped form of a method implementation; the Objective-C runtime calls
|
||||
// it with the arguments the type encoding declares, which match `open_urls`: no return value
|
||||
// (`v`), the receiver (`@`), the selector (`:`), the application (`@`) and the URL array (`@`).
|
||||
let added = unsafe {
|
||||
let imp = std::mem::transmute::<extern "C-unwind" fn(&AnyObject, Sel, &AnyObject, &NSArray<NSURL>), Imp>(handler);
|
||||
objc2::ffi::class_addMethod(std::ptr::from_ref(class).cast_mut(), sel!(application:openURLs:), imp, c"v@:@@".as_ptr())
|
||||
};
|
||||
if !added.as_bool() {
|
||||
log::warn!("vectorcraft: the application delegate already handles open-document events");
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue the files among `urls` and wake the UI. Never panics: it runs inside AppKit.
|
||||
extern "C-unwind" fn open_urls(_delegate: &AnyObject, _cmd: Sel, _app: &AnyObject, urls: &NSArray<NSURL>) {
|
||||
let paths = urls.iter().filter_map(|u| u.to_file_path()).map(|p| p.to_string_lossy().into_owned());
|
||||
PENDING.lock().unwrap_or_else(PoisonError::into_inner).extend(paths);
|
||||
if let Some(ctx) = UI.get() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use objc2::runtime::NSObject;
|
||||
use objc2_foundation::NSString;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Files queue until the UI takes them; URLs that aren't files are left out.
|
||||
#[test]
|
||||
fn opened_files_wait_for_the_ui() {
|
||||
let web = NSURL::URLWithString(&NSString::from_str("https://example.com/a.svg")).unwrap();
|
||||
let urls = NSArray::from_retained_slice(&[NSURL::from_file_path("/tmp/a b.pdf").unwrap(), web, NSURL::from_file_path("/tmp/c.ai").unwrap()]);
|
||||
let any = NSObject::new();
|
||||
open_urls(&any, sel!(application:openURLs:), &any, &urls);
|
||||
assert_eq!(take(), ["/tmp/a b.pdf", "/tmp/c.ai"]);
|
||||
assert!(take().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -156,6 +156,9 @@ mod system {
|
||||
}
|
||||
|
||||
pub fn printers() -> Vec<Printer> {
|
||||
#[cfg(feature = "windows7")]
|
||||
let script = "Get-WmiObject Win32_Printer | ForEach-Object { \"$($_.Default)`t$($_.Name)\" }";
|
||||
#[cfg(not(feature = "windows7"))]
|
||||
let script = "Get-CimInstance Win32_Printer | ForEach-Object { \"$($_.Default)`t$($_.Name)\" }";
|
||||
output(&mut powershell(script)).map(|t| windows_printers(&t)).unwrap_or_default()
|
||||
}
|
||||
@@ -174,7 +177,19 @@ mod system {
|
||||
}
|
||||
|
||||
pub fn setup(_printer: Option<&str>) -> Result<(), String> {
|
||||
Command::new("explorer").arg("ms-settings:printers").spawn().map(|_| ()).map_err(|e| e.to_string())
|
||||
#[cfg(feature = "windows7")]
|
||||
let mut command = {
|
||||
let mut command = Command::new("control.exe");
|
||||
command.arg("printers");
|
||||
command
|
||||
};
|
||||
#[cfg(not(feature = "windows7"))]
|
||||
let mut command = {
|
||||
let mut command = Command::new("explorer");
|
||||
command.arg("ms-settings:printers");
|
||||
command
|
||||
};
|
||||
command.spawn().map(|_| ()).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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="butt" stroke-linejoin="miter"><path d="M11 3V14" /><path d="M14 3V14" /><path d="M16 3H9.5a3 3 0 0 0 0 6H11" /><path d="M4 19H20" /><path d="M17 16L20 19L17 22" /></svg>
|
||||
|
After Width: | Height: | Size: 322 B |
@@ -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="butt" stroke-linejoin="miter"><path d="M13 3V14" /><path d="M10 3V14" /><path d="M8 3H14.5a3 3 0 0 1 0 6H13" /><path d="M20 19H4" /><path d="M7 16L4 19L7 22" /></svg>
|
||||
|
After Width: | Height: | Size: 319 B |
+78
-8
@@ -23,6 +23,8 @@ pub struct Hit {
|
||||
/// The innermost envelope in the ancestry whose contents are being edited (Edit Contents):
|
||||
/// its content, not the envelope, is the object clicked.
|
||||
pub contents_of: Option<NodeId>,
|
||||
/// How many entries at the start of `ancestry` are layers (the layer and its sublayers).
|
||||
pub layers: usize,
|
||||
}
|
||||
|
||||
impl Hit {
|
||||
@@ -34,8 +36,8 @@ impl Hit {
|
||||
if let Some(i) = at(scope).max(at(self.contents_of)) {
|
||||
return self.ancestry.get(i + 1).copied().unwrap_or(self.leaf);
|
||||
}
|
||||
// Skip layers and sublayers.
|
||||
self.ancestry.get(1).copied().unwrap_or(self.leaf)
|
||||
// Skip layers and sublayers: they are not objects.
|
||||
self.ancestry.get(self.layers.max(1)).copied().unwrap_or(self.leaf)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +65,7 @@ pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit> {
|
||||
if !layer.visible || layer.locked {
|
||||
continue;
|
||||
}
|
||||
if let NodeKind::Layer { template: true, .. } = layer.kind {
|
||||
if layer.is_template() {
|
||||
continue;
|
||||
}
|
||||
// Pattern editing mode: only the tile art is editable.
|
||||
@@ -71,8 +73,10 @@ pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit> {
|
||||
continue;
|
||||
}
|
||||
chain.push(layer.id);
|
||||
let opt = if matches!(layer.kind, NodeKind::Layer { preview: false, .. }) { HitOptions { outline: true, ..opt } } else { opt };
|
||||
if let Some(mut h) = hit_children(layer, p, opt, &mut chain) {
|
||||
h.contents_of = h.ancestry.iter().rev().copied().find(|a| doc.node(*a).is_some_and(edits_contents));
|
||||
h.layers = h.ancestry.iter().take_while(|a| doc.node(**a).is_some_and(Node::is_layer)).count();
|
||||
return Some(h);
|
||||
}
|
||||
chain.pop();
|
||||
@@ -105,7 +109,8 @@ fn hit_children(parent: &Node, p: Point, opt: HitOptions, chain: &mut Vec<NodeId
|
||||
return None;
|
||||
}
|
||||
for c in children.iter().rev() {
|
||||
if !c.visible || c.locked {
|
||||
// Hidden, locked and template sublayers block clicks like top-level layers do.
|
||||
if !c.visible || c.locked || c.is_template() {
|
||||
continue;
|
||||
}
|
||||
// (An envelope's content sits where it was, not where the envelope draws it.)
|
||||
@@ -117,14 +122,16 @@ fn hit_children(parent: &Node, p: Point, opt: HitOptions, chain: &mut Vec<NodeId
|
||||
}
|
||||
chain.push(c.id);
|
||||
let hit = match &c.kind {
|
||||
// A sublayer whose Preview is off is clicked in outline, like an outline view.
|
||||
NodeKind::Layer { preview: false, .. } => hit_children(c, p, HitOptions { outline: true, ..opt }, chain),
|
||||
NodeKind::Layer { .. } | NodeKind::Group { .. } => hit_children(c, p, opt, chain),
|
||||
// Edit Contents: the envelope's content hits, undistorted.
|
||||
NodeKind::Envelope { editing: true, .. } => hit_children(c, p, opt, chain),
|
||||
// A blend's key objects first (Direct and Group Selection pick them); its steps hit
|
||||
// as the blend.
|
||||
NodeKind::Blend { .. } => hit_children(c, p, opt, chain)
|
||||
.or_else(|| hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None })),
|
||||
_ => hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None }),
|
||||
.or_else(|| hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None, layers: 1 })),
|
||||
_ => hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None, layers: 1 }),
|
||||
};
|
||||
if hit.is_some() {
|
||||
return hit;
|
||||
@@ -192,13 +199,28 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
|
||||
let mut out = Vec::new();
|
||||
let tops: Vec<&std::sync::Arc<Node>> = match scope.and_then(|s| doc.node(s)) {
|
||||
Some(s) => s.children().map(|c| c.iter().collect()).unwrap_or_default(),
|
||||
// The objects of the layers, looking through sublayers (hidden, locked and template ones
|
||||
// are left out).
|
||||
None => doc
|
||||
.layers
|
||||
.iter()
|
||||
.filter(|l| l.visible && !l.locked && doc.pattern_edit.as_ref().is_none_or(|e| e.layer == l.id))
|
||||
.flat_map(|l| l.children().into_iter().flatten())
|
||||
.filter(|l| l.visible && !l.locked && !l.is_template() && doc.pattern_edit.as_ref().is_none_or(|e| e.layer == l.id))
|
||||
.flat_map(|l| layer_objects(l))
|
||||
.collect(),
|
||||
};
|
||||
fn layer_objects(l: &Node) -> Vec<&std::sync::Arc<Node>> {
|
||||
let mut out = vec![];
|
||||
for c in l.children().into_iter().flatten() {
|
||||
if c.is_layer() {
|
||||
if c.visible && !c.locked && !c.is_template() {
|
||||
out.extend(layer_objects(c));
|
||||
}
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
fn touches(n: &Node, r: Rect) -> bool {
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, .. } => vectorcraft_geom::hit::intersects_rect(path, r),
|
||||
@@ -296,6 +318,54 @@ mod tests {
|
||||
assert!(marquee(&d, Rect::new(100.0, 100.0, 105.0, 105.0), None, false).is_empty());
|
||||
}
|
||||
|
||||
/// Art in a sublayer is an object of its own: a click or a marquee takes it, not the
|
||||
/// sublayer; hidden, locked and template sublayers block both.
|
||||
#[test]
|
||||
fn sublayers_are_not_objects() {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
let sub = d.alloc_id();
|
||||
d.insert(Some(l), 0, Node::layer(sub, "Sub", crate::LayerColor::Preset(2))).unwrap();
|
||||
let inner = d.alloc_id();
|
||||
d.insert(Some(sub), 0, Node::layer(inner, "Inner", crate::LayerColor::Preset(3))).unwrap();
|
||||
let a = d.alloc_id();
|
||||
d.insert(Some(inner), 0, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap();
|
||||
let g = d.alloc_id();
|
||||
let b = d.alloc_id();
|
||||
let pb = Node::path(b, shapes::rectangle(Rect::new(100.0, 0.0, 150.0, 50.0)), Appearance::default_art());
|
||||
d.insert(Some(sub), 9, Node::group(g, vec![Arc::new(pb)])).unwrap();
|
||||
let h = hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).unwrap();
|
||||
assert_eq!((h.leaf, h.top_object(None), h.layers), (a, a, 3));
|
||||
let h = hit_test(&d, Point::new(110.0, 10.0), HitOptions::default()).unwrap();
|
||||
assert_eq!((h.top_object(None), h.top_object(Some(g))), (g, b));
|
||||
let all = Rect::new(-5.0, -5.0, 200.0, 200.0);
|
||||
assert_eq!(marquee(&d, all, None, false), vec![a, g]);
|
||||
assert_eq!(d.layer_containing(a), Some(inner));
|
||||
assert_eq!(d.layer_color(a), crate::LayerColor::Preset(3).rgb(), "the sublayer's own colour");
|
||||
assert_eq!(d.selectable_art(), vec![a, g]);
|
||||
for f in [
|
||||
|n: &mut Node| n.visible = false,
|
||||
|n: &mut Node| n.locked = true,
|
||||
|n: &mut Node| {
|
||||
if let NodeKind::Layer { template, .. } = &mut n.kind {
|
||||
*template = true;
|
||||
}
|
||||
},
|
||||
] {
|
||||
let mut d2 = d.clone();
|
||||
f(d2.node_mut(inner).unwrap());
|
||||
assert!(hit_test(&d2, Point::new(10.0, 10.0), HitOptions::default()).is_none());
|
||||
assert_eq!(marquee(&d2, all, None, false), vec![g]);
|
||||
assert_eq!(d2.selectable_art(), vec![g]);
|
||||
}
|
||||
// A sublayer whose Preview is off is clicked in outline: its fill no longer hits.
|
||||
if let NodeKind::Layer { preview, .. } = &mut d.node_mut(inner).unwrap().kind {
|
||||
*preview = false;
|
||||
}
|
||||
assert!(hit_test(&d, Point::new(25.0, 25.0), HitOptions::default()).is_none());
|
||||
assert!(hit_test(&d, Point::new(0.5, 25.0), HitOptions::default()).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_and_hidden_skip() {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
|
||||
+115
-11
@@ -77,8 +77,8 @@ 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::{
|
||||
AreaOptions, CharPosition, CharStyle, FirstBaseline, Justify, ParaStyle, PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, TextObject,
|
||||
TextRun, TextStyleDef, TextWrap, WrapShape,
|
||||
AreaOptions, CharPosition, CharStyle, FirstBaseline, Justify, Mojikumi, ParaDirection, ParaStyle, PathEffect, ScriptMetrics, TabAlign, TabStop,
|
||||
TextKind, TextObject, TextRun, TextStyleDef, TextWrap, WrapShape,
|
||||
};
|
||||
pub use vectorcraft_color as color;
|
||||
pub use vectorcraft_geom as geom;
|
||||
@@ -339,6 +339,32 @@ pub struct SavedView {
|
||||
pub rotation: f64,
|
||||
}
|
||||
|
||||
/// A saved selection (Select → Save Selection…): the objects that were selected, listed by name at
|
||||
/// the bottom of the Select menu.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SavedSelection {
|
||||
pub name: String,
|
||||
pub objects: Vec<NodeId>,
|
||||
}
|
||||
|
||||
impl SavedSelection {
|
||||
/// Most saved selections a document keeps (the Select menu lists every one).
|
||||
pub const MAX: usize = 25;
|
||||
/// Longest name, in characters.
|
||||
pub const MAX_NAME: usize = 255;
|
||||
|
||||
/// `name` trimmed and cut to [`Self::MAX_NAME`] characters (`None` when blank).
|
||||
pub fn clean_name(name: &str) -> Option<String> {
|
||||
let name: String = name.trim().chars().take(Self::MAX_NAME).collect();
|
||||
(!name.is_empty()).then_some(name)
|
||||
}
|
||||
|
||||
/// The first "Selection N" none of `saved` is named.
|
||||
pub fn default_name(saved: &[SavedSelection]) -> String {
|
||||
(1..=saved.len() + 1).map(|i| format!("Selection {i}")).find(|n| !saved.iter().any(|x| &x.name == n)).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Guide {
|
||||
/// true = vertical guide at `pos` (x), false = horizontal at `pos` (y).
|
||||
@@ -490,6 +516,9 @@ pub struct Document {
|
||||
/// View → New View… (up to 25, like Illustrator).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub views: Vec<SavedView>,
|
||||
/// Select → Save Selection… (at most [`SavedSelection::MAX`]); saved with the document.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub saved_selections: Vec<SavedSelection>,
|
||||
#[serde(default)]
|
||||
pub grid: GridPrefs,
|
||||
#[serde(default = "ppi72")]
|
||||
@@ -616,6 +645,7 @@ impl Document {
|
||||
symbols: vec![],
|
||||
guides: vec![],
|
||||
views: vec![],
|
||||
saved_selections: vec![],
|
||||
grid: GridPrefs::default(),
|
||||
raster_effects_ppi: 72.0,
|
||||
images: BTreeMap::new(),
|
||||
@@ -810,15 +840,17 @@ impl Document {
|
||||
self.layers.push(Arc::new(Node::layer(id, &name, LayerColor::Preset((n % LAYER_COLORS.len()) as u8))));
|
||||
id
|
||||
}
|
||||
/// The default name of a new layer or sublayer, "Layer N". Layers and sublayers share the
|
||||
/// numbering: it starts after the number of layers there are and skips any layer's name.
|
||||
pub fn next_layer_name(&self) -> String {
|
||||
let mut i = self.layers.len() + 1;
|
||||
loop {
|
||||
let name = format!("Layer {i}");
|
||||
if !self.layers.iter().any(|l| l.name.as_deref() == Some(&name)) {
|
||||
return name;
|
||||
let (mut count, mut taken) = (0, std::collections::HashSet::new());
|
||||
self.walk(|n| {
|
||||
if n.is_layer() {
|
||||
count += 1;
|
||||
taken.extend(n.name.as_deref());
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
});
|
||||
(count + 1..).map(|i| format!("Layer {i}")).find(|name| !taken.contains(name.as_str())).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Visit every node depth first in paint order (bottom to top).
|
||||
@@ -837,9 +869,19 @@ impl Document {
|
||||
pub fn is_visible(&self, id: NodeId) -> bool {
|
||||
self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| n.visible)))
|
||||
}
|
||||
/// Colour of the layer containing `id` (selection highlight colour).
|
||||
/// The innermost layer or sublayer containing `id` (`id` itself when it is a layer).
|
||||
pub fn layer_containing(&self, id: NodeId) -> Option<NodeId> {
|
||||
let a = self.ancestry(id)?;
|
||||
a.iter().rev().copied().find(|i| self.node(*i).is_some_and(Node::is_layer))
|
||||
}
|
||||
/// Every art object the Selection tool can reach (Select All): the visible, unlocked objects
|
||||
/// of the visible, unlocked, non-template layers, looking through sublayers (bottom first).
|
||||
pub fn selectable_art(&self) -> Vec<NodeId> {
|
||||
self.layers.iter().filter(|l| l.visible && !l.locked && !l.is_template()).flat_map(|l| l.layer_art(true)).collect()
|
||||
}
|
||||
/// Colour of the innermost layer or sublayer containing `id` (selection highlight colour).
|
||||
pub fn layer_color(&self, id: NodeId) -> [u8; 3] {
|
||||
let l = self.layer_of(id).and_then(|l| self.node(l));
|
||||
let l = self.layer_containing(id).and_then(|l| self.node(l));
|
||||
match l.map(|n| &n.kind) {
|
||||
Some(NodeKind::Layer { color, .. }) => color.rgb(),
|
||||
_ => LAYER_COLORS[0].1,
|
||||
@@ -859,6 +901,30 @@ impl Document {
|
||||
pub fn artboard_at(&self, p: Point) -> Option<usize> {
|
||||
self.artboards.iter().rposition(|a| a.rect.contains(p))
|
||||
}
|
||||
/// The art that moves with an artboard at `rect`: unlocked top-level objects (children of
|
||||
/// unlocked layers and sublayers) lying entirely inside it.
|
||||
pub fn art_on_artboard(&self, rect: Rect) -> Vec<NodeId> {
|
||||
fn collect(n: &Node, rect: Rect, out: &mut Vec<NodeId>) {
|
||||
for c in n.children().into_iter().flatten() {
|
||||
if c.locked {
|
||||
continue;
|
||||
}
|
||||
if c.is_layer() {
|
||||
collect(c, rect, out);
|
||||
} else if let Some(b) = c.geometric_bounds()
|
||||
&& rect.contains(Point::new(b.x0, b.y0))
|
||||
&& rect.contains(Point::new(b.x1, b.y1))
|
||||
{
|
||||
out.push(c.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut art = vec![];
|
||||
for l in self.layers.iter().filter(|l| !l.locked) {
|
||||
collect(l, rect, &mut art);
|
||||
}
|
||||
art
|
||||
}
|
||||
pub fn next_artboard_id(&self) -> u32 {
|
||||
self.artboards.iter().map(|a| a.id).max().unwrap_or(0) + 1
|
||||
}
|
||||
@@ -881,6 +947,33 @@ impl Document {
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Saved selections as a file gives them, made safe to list and recall: at most
|
||||
/// [`SavedSelection::MAX`], each with a clean, unique name and naming objects this document
|
||||
/// has, each once. (An id it doesn't have would select whatever object takes that id later.)
|
||||
pub fn tidy_saved_selections(&mut self) {
|
||||
if self.saved_selections.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut ids = std::collections::HashSet::new();
|
||||
self.walk(|n| {
|
||||
ids.insert(n.id);
|
||||
});
|
||||
let mut kept: Vec<SavedSelection> = vec![];
|
||||
for s in std::mem::take(&mut self.saved_selections) {
|
||||
if kept.len() >= SavedSelection::MAX {
|
||||
break;
|
||||
}
|
||||
let Some(name) = SavedSelection::clean_name(&s.name) else { continue };
|
||||
if kept.iter().any(|k| k.name == name) {
|
||||
continue;
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let objects = s.objects.into_iter().filter(|id| ids.contains(id) && seen.insert(*id)).collect();
|
||||
kept.push(SavedSelection { name, objects });
|
||||
}
|
||||
self.saved_selections = 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).
|
||||
pub fn drop_edit_modes(&mut self) {
|
||||
@@ -979,6 +1072,17 @@ mod tests {
|
||||
assert_eq!(d.artboards[0].rect, Rect::new(0.0, 0.0, 612.0, 792.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn art_on_artboard_takes_unlocked_objects_wholly_inside() {
|
||||
let (mut d, a, b) = doc_with_rects();
|
||||
assert_eq!(d.art_on_artboard(Rect::new(-1.0, -1.0, 15.0, 15.0)), vec![a]);
|
||||
assert_eq!(d.art_on_artboard(Rect::new(-1.0, -1.0, 25.0, 15.0)), vec![a], "b only half inside");
|
||||
d.node_mut(a).unwrap().locked = true;
|
||||
assert_eq!(d.art_on_artboard(Rect::new(-1.0, -1.0, 40.0, 15.0)), vec![b]);
|
||||
Arc::make_mut(&mut d.layers[0]).locked = true;
|
||||
assert!(d.art_on_artboard(Rect::new(-1.0, -1.0, 40.0, 15.0)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_and_paths() {
|
||||
let (d, a, b) = doc_with_rects();
|
||||
|
||||
+36
-1
@@ -259,6 +259,14 @@ pub enum NodeKind {
|
||||
/// Layer clipping mask: the first (bottom-most) child clips the others, as in a clip group.
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
clip: bool,
|
||||
/// Layer Options → Preview: off, the layer's art is drawn (and clicked) in outline on screen
|
||||
/// (Ctrl-click its eye in the Layers panel).
|
||||
#[serde(default = "yes", skip_serializing_if = "crate::skip::is_true")]
|
||||
preview: bool,
|
||||
/// Layer Options → Dim Images to: on screen, images on the layer show faded to this
|
||||
/// percentage (0–100); `None`: not dimmed.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
dim_images: Option<u8>,
|
||||
},
|
||||
/// A group. With `clip`, the first (bottom-most) child is the clipping path.
|
||||
Group {
|
||||
@@ -448,7 +456,10 @@ impl Node {
|
||||
Self::new(id, NodeKind::Group { children, clip: false })
|
||||
}
|
||||
pub fn layer(id: NodeId, name: &str, color: LayerColor) -> Self {
|
||||
let mut n = Self::new(id, NodeKind::Layer { color, template: false, printable: true, children: vec![], clip: false });
|
||||
let mut n = Self::new(
|
||||
id,
|
||||
NodeKind::Layer { color, template: false, printable: true, children: vec![], clip: false, preview: true, dim_images: None },
|
||||
);
|
||||
n.name = Some(name.to_string());
|
||||
n
|
||||
}
|
||||
@@ -477,6 +488,30 @@ impl Node {
|
||||
pub fn is_layer(&self) -> bool {
|
||||
matches!(self.kind, NodeKind::Layer { .. })
|
||||
}
|
||||
/// A template layer (Layer Options → Template).
|
||||
pub fn is_template(&self) -> bool {
|
||||
matches!(self.kind, NodeKind::Layer { template: true, .. })
|
||||
}
|
||||
/// The art objects of this container: its children, looking through sublayers, which are not
|
||||
/// objects (bottom first). With `editable`, only the visible, unlocked objects in visible,
|
||||
/// unlocked, non-template sublayers.
|
||||
pub fn layer_art(&self, editable: bool) -> Vec<NodeId> {
|
||||
fn visit(n: &Node, editable: bool, out: &mut Vec<NodeId>) {
|
||||
for c in n.children().into_iter().flatten() {
|
||||
if editable && (!c.visible || c.locked || c.is_template()) {
|
||||
continue;
|
||||
}
|
||||
if c.is_layer() {
|
||||
visit(c, editable, out);
|
||||
} else {
|
||||
out.push(c.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = vec![];
|
||||
visit(self, editable, &mut out);
|
||||
out
|
||||
}
|
||||
pub fn is_container(&self) -> bool {
|
||||
self.children().is_some()
|
||||
}
|
||||
|
||||
@@ -98,11 +98,11 @@ impl Selection {
|
||||
}
|
||||
self.slices.retain(|id| doc.is_slice(*id));
|
||||
}
|
||||
/// Target `id` (see [`Selection::target`]): a layer gets its visible, unlocked art selected,
|
||||
/// anything else is selected itself.
|
||||
/// Target `id` (see [`Selection::target`]): a layer gets its visible, unlocked art selected
|
||||
/// (the art of its sublayers too), anything else is selected itself.
|
||||
pub fn set_target(&mut self, doc: &Document, id: NodeId) {
|
||||
match doc.node(id) {
|
||||
Some(n) if n.is_layer() => self.set(n.children().into_iter().flatten().filter(|c| c.visible && !c.locked).map(|c| c.id)),
|
||||
Some(n) if n.is_layer() => self.set(n.layer_art(true)),
|
||||
_ => self.set([id]),
|
||||
}
|
||||
self.target = Some(id);
|
||||
|
||||
+173
-9
@@ -8,6 +8,9 @@ use crate::appearance::{Appearance, AppearanceItem, Dash, FillLayer, LineCap, Li
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Justify {
|
||||
/// Align to the start of each paragraph's direction ([`ParaStyle::direction`]): left for
|
||||
/// left-to-right paragraphs, right for right-to-left ones. New type's alignment.
|
||||
Auto,
|
||||
#[default]
|
||||
Left,
|
||||
Center,
|
||||
@@ -265,28 +268,148 @@ fn default_align_on() -> char {
|
||||
pub const DEFAULT_TAB_INTERVAL: f64 = 36.0;
|
||||
|
||||
/// Paragraph attributes (the Paragraph panel).
|
||||
///
|
||||
/// Saved through [`ParaStyleFile`], which keeps files with text openable by builds from before
|
||||
/// [`Justify::Auto`].
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(from = "ParaStyleFile", into = "ParaStyleFile")]
|
||||
pub struct ParaStyle {
|
||||
#[serde(default)]
|
||||
pub justify: Justify,
|
||||
#[serde(default)]
|
||||
pub left_indent: f64,
|
||||
#[serde(default)]
|
||||
pub right_indent: f64,
|
||||
#[serde(default)]
|
||||
pub first_line_indent: f64,
|
||||
#[serde(default)]
|
||||
pub space_before: f64,
|
||||
#[serde(default)]
|
||||
pub space_after: f64,
|
||||
#[serde(default)]
|
||||
pub hyphenate: bool,
|
||||
/// Tab stops (Tabs panel), sorted by position.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub tabs: Vec<TabStop>,
|
||||
/// Paragraph style (Paragraph Styles panel) these attributes come from; None = Normal.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub style_name: Option<String>,
|
||||
/// Japanese composition: the spacing of punctuation (Paragraph panel › Mojikumi). Type made
|
||||
/// with the Type tools and `text.create` takes [`Mojikumi::LineEndHalf`]; documents from before
|
||||
/// it and imported text (already set) keep [`Mojikumi::None`].
|
||||
pub mojikumi: Mojikumi,
|
||||
/// Paragraph direction (Paragraph panel): the base direction of each paragraph for
|
||||
/// bidirectional text (UAX #9). None: from each paragraph's first strong character (Hebrew or
|
||||
/// Arabic: right to left).
|
||||
pub direction: Option<ParaDirection>,
|
||||
}
|
||||
|
||||
/// [`ParaStyle`] as saved. [`Justify::Auto`] is written as the alignment it has in the paragraph
|
||||
/// direction (`Right` for right to left, else `Left`) with `justify_auto` set: builds without Auto
|
||||
/// read the alignment and ignore the flag, and builds with it read Auto back.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct ParaStyleFile {
|
||||
#[serde(default)]
|
||||
justify: Justify,
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
justify_auto: bool,
|
||||
#[serde(default)]
|
||||
left_indent: f64,
|
||||
#[serde(default)]
|
||||
right_indent: f64,
|
||||
#[serde(default)]
|
||||
first_line_indent: f64,
|
||||
#[serde(default)]
|
||||
space_before: f64,
|
||||
#[serde(default)]
|
||||
space_after: f64,
|
||||
#[serde(default)]
|
||||
hyphenate: bool,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
tabs: Vec<TabStop>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
style_name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Mojikumi::is_none")]
|
||||
mojikumi: Mojikumi,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
direction: Option<ParaDirection>,
|
||||
}
|
||||
|
||||
impl From<ParaStyle> for ParaStyleFile {
|
||||
fn from(p: ParaStyle) -> Self {
|
||||
let justify_auto = p.justify == Justify::Auto;
|
||||
let justify = match p.justify {
|
||||
Justify::Auto if p.direction == Some(ParaDirection::RightToLeft) => Justify::Right,
|
||||
Justify::Auto => Justify::Left,
|
||||
j => j,
|
||||
};
|
||||
let ParaStyle {
|
||||
left_indent,
|
||||
right_indent,
|
||||
first_line_indent,
|
||||
space_before,
|
||||
space_after,
|
||||
hyphenate,
|
||||
tabs,
|
||||
style_name,
|
||||
mojikumi,
|
||||
direction,
|
||||
..
|
||||
} = p;
|
||||
Self {
|
||||
justify,
|
||||
justify_auto,
|
||||
left_indent,
|
||||
right_indent,
|
||||
first_line_indent,
|
||||
space_before,
|
||||
space_after,
|
||||
hyphenate,
|
||||
tabs,
|
||||
style_name,
|
||||
mojikumi,
|
||||
direction,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ParaStyleFile> for ParaStyle {
|
||||
fn from(f: ParaStyleFile) -> Self {
|
||||
let justify = if f.justify_auto { Justify::Auto } else { f.justify };
|
||||
let ParaStyleFile {
|
||||
left_indent,
|
||||
right_indent,
|
||||
first_line_indent,
|
||||
space_before,
|
||||
space_after,
|
||||
hyphenate,
|
||||
tabs,
|
||||
style_name,
|
||||
mojikumi,
|
||||
direction,
|
||||
..
|
||||
} = f;
|
||||
Self { justify, left_indent, right_indent, first_line_indent, space_before, space_after, hyphenate, tabs, style_name, mojikumi, direction }
|
||||
}
|
||||
}
|
||||
|
||||
/// A paragraph's base direction ([`ParaStyle::direction`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ParaDirection {
|
||||
LeftToRight,
|
||||
RightToLeft,
|
||||
}
|
||||
|
||||
/// How Japanese punctuation is spaced (JLREQ 3.1). Full-width punctuation is half a glyph and half
|
||||
/// a space: an opening bracket's space before it, a closing bracket's, a comma's or a full stop's
|
||||
/// after it.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Mojikumi {
|
||||
/// Every character takes its full advance.
|
||||
#[default]
|
||||
None,
|
||||
/// Consecutive punctuation shares one half-em space (JLREQ 3.1.4), and a closing bracket,
|
||||
/// comma or full stop ending a line is set half width.
|
||||
LineEndHalf,
|
||||
}
|
||||
|
||||
impl Mojikumi {
|
||||
pub fn is_none(&self) -> bool {
|
||||
*self == Mojikumi::None
|
||||
}
|
||||
}
|
||||
|
||||
/// Area Type Options "First Baseline" offset.
|
||||
@@ -593,4 +716,45 @@ mod tests {
|
||||
assert!(!t.move_area_anchors(&[(3, 0)], Vec2::new(5.0, 5.0)));
|
||||
assert_eq!(t, before);
|
||||
}
|
||||
|
||||
/// Builds from before [`Justify::Auto`] read new type's alignment as Left or Right (and ignore
|
||||
/// `justify_auto`); this build reads Auto back.
|
||||
#[test]
|
||||
fn auto_alignment_saves_readable_by_older_builds() {
|
||||
#[derive(Deserialize, Debug, PartialEq)]
|
||||
enum OldJustify {
|
||||
Left,
|
||||
Center,
|
||||
Right,
|
||||
JustifyLeft,
|
||||
JustifyCenter,
|
||||
JustifyRight,
|
||||
JustifyAll,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct OldPara {
|
||||
justify: OldJustify,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct OldText {
|
||||
para: OldPara,
|
||||
}
|
||||
for (direction, physical) in
|
||||
[(Some(ParaDirection::RightToLeft), OldJustify::Right), (Some(ParaDirection::LeftToRight), OldJustify::Left), (None, OldJustify::Left)]
|
||||
{
|
||||
let mut t = TextObject::point(Point::ZERO, "שלום", CharStyle::default());
|
||||
t.para = ParaStyle { justify: Justify::Auto, direction, ..Default::default() };
|
||||
let json = serde_json::to_string(&t).unwrap();
|
||||
let old: OldText = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(old.para.justify, physical, "{json}");
|
||||
assert_eq!(serde_json::from_str::<TextObject>(&json).unwrap(), t);
|
||||
}
|
||||
// Other alignments save as before, without the flag.
|
||||
let p = ParaStyle { justify: Justify::Right, ..Default::default() };
|
||||
let json = serde_json::to_string(&p).unwrap();
|
||||
assert!(!json.contains("justify_auto"), "{json}");
|
||||
assert_eq!(serde_json::from_str::<ParaStyle>(&json).unwrap(), p);
|
||||
// Files from before the flag keep their alignment.
|
||||
assert_eq!(serde_json::from_str::<ParaStyle>(r#"{"justify":"Center"}"#).unwrap().justify, Justify::Center);
|
||||
}
|
||||
}
|
||||
@@ -402,6 +402,7 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let text = str_param(p, "text").unwrap_or("");
|
||||
let mut t = TextObject::point(Point::new(x, y), text, new_type_style(s, p));
|
||||
t.vertical = p.get("vertical").and_then(Value::as_bool).unwrap_or(false);
|
||||
t.para = new_type_para();
|
||||
if let Some(a) = p.get("area") {
|
||||
let w = f64_or(a, "width", 200.0);
|
||||
let h = f64_or(a, "height", 100.0);
|
||||
@@ -412,6 +413,13 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
add_node(s, "Type", NodeKind::Text(Box::new(t)), Appearance::default(), None)
|
||||
}
|
||||
|
||||
/// The paragraph attributes new type gets (the Type tools, `text.create`): aligned to the start of
|
||||
/// each paragraph's direction (left, right for Hebrew and Arabic), Japanese punctuation composed
|
||||
/// with Line-end Punctuation Half Width. Imported text keeps its own.
|
||||
pub(crate) fn new_type_para() -> vectorcraft_doc::ParaStyle {
|
||||
vectorcraft_doc::ParaStyle { justify: vectorcraft_doc::Justify::Auto, mojikumi: vectorcraft_doc::Mojikumi::LineEndHalf, ..Default::default() }
|
||||
}
|
||||
|
||||
/// The character style new type gets: `size`, `font`, `style` and `color` from `p`, else the
|
||||
/// defaults, filled with the current fill unless that is None or white (black then).
|
||||
pub(crate) fn new_type_style(s: &Session, p: &Value) -> CharStyle {
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{CharStyle, Guide, Node, NodeId, NodeKind, TextKind};
|
||||
use vectorcraft_doc::{CharStyle, Guide, Node, NodeId, NodeKind, SavedSelection, TextKind};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::*;
|
||||
@@ -93,15 +93,30 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Save Selection…",
|
||||
["Select"],
|
||||
None,
|
||||
"{name?} remember the current selection for this session (default name \"Selection N\") → {name}",
|
||||
"{name?} save the current selection under a name, in the document (default \"Selection N\"; an existing name is replaced; at most 25, names up to 255 characters) → {name}",
|
||||
has_selection,
|
||||
save_selection
|
||||
),
|
||||
cmd!("select.recall", "Recall Selection", ["Select"], None, "{name} → {count}", has_doc, recall_selection),
|
||||
cmd!("select.editSaved", "Edit Selection…", ["Select"], None, "{name, newName?: rename, delete?: bool}", has_doc, edit_saved),
|
||||
cmd!(
|
||||
"select.recall",
|
||||
"Recall Selection",
|
||||
["Select"],
|
||||
None,
|
||||
"{name} select the objects of a saved selection (those since deleted are left out) → {count}",
|
||||
has_saved_selection,
|
||||
recall_selection
|
||||
),
|
||||
cmd!(
|
||||
"select.editSaved",
|
||||
"Edit Selection…",
|
||||
["Select"],
|
||||
None,
|
||||
"{name, newName?: rename, delete?: bool} | {edits: [{name, newName?, delete?}…]} rename or delete saved selections, all in one undo step; names are those before the edit and must stay unique",
|
||||
has_saved_selection,
|
||||
edit_saved
|
||||
),
|
||||
cmd!(query "select.savedList", "Saved Selections", [], None, "{} → [name…] for the active document", has_doc, |s, _| {
|
||||
let t = s.doc()?.title();
|
||||
Ok(json!(s.menu.saved_selections.iter().filter(|x| x.0 == t).map(|x| x.1.clone()).collect::<Vec<_>>()))
|
||||
Ok(json!(s.doc()?.doc.saved_selections.iter().map(|x| x.name.clone()).collect::<Vec<_>>()))
|
||||
}),
|
||||
cmd!(
|
||||
"view.guides.make",
|
||||
@@ -259,43 +274,79 @@ fn direction_handles(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn save_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "select.save";
|
||||
let st = s.doc()?;
|
||||
let title = st.title();
|
||||
let ids = st.selection.objects.clone();
|
||||
let name = match str_param(p, "name").filter(|n| !n.trim().is_empty()) {
|
||||
Some(n) => n.trim().to_string(),
|
||||
None => {
|
||||
let mut i = 1;
|
||||
while s.menu.saved_selections.iter().any(|x| x.0 == title && x.1 == format!("Selection {i}")) {
|
||||
i += 1;
|
||||
}
|
||||
format!("Selection {i}")
|
||||
let saved = &st.doc.saved_selections;
|
||||
let name = str_param(p, "name").and_then(SavedSelection::clean_name).unwrap_or_else(|| SavedSelection::default_name(saved));
|
||||
let existing = saved.iter().position(|x| x.name == name);
|
||||
if existing.is_none() && saved.len() >= SavedSelection::MAX {
|
||||
return Err(bad(C, format!("a document keeps at most {} saved selections", SavedSelection::MAX)));
|
||||
}
|
||||
s.edit("Save Selection", |d, _| {
|
||||
let entry = SavedSelection { name: name.clone(), objects: ids };
|
||||
match existing.and_then(|i| d.saved_selections.get_mut(i)) {
|
||||
Some(x) => *x = entry,
|
||||
None => d.saved_selections.push(entry),
|
||||
}
|
||||
};
|
||||
s.menu.saved_selections.retain(|x| !(x.0 == title && x.1 == name));
|
||||
s.menu.saved_selections.push((title, name.clone(), ids));
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "name": name }))
|
||||
}
|
||||
|
||||
fn saved_index(s: &Session, p: &Value, cmd: &str) -> Result<usize> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad(cmd, "missing name"))?;
|
||||
let t = s.doc()?.title();
|
||||
s.menu.saved_selections.iter().position(|x| x.0 == t && x.1 == name).ok_or_else(|| bad(cmd, format!("no saved selection `{name}`")))
|
||||
}
|
||||
|
||||
fn recall_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = saved_index(s, p, "select.recall")?;
|
||||
let ids = s.menu.saved_selections[i].2.clone();
|
||||
const C: &str = "select.recall";
|
||||
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing name"))?;
|
||||
let ids = s.doc()?.doc.saved_selections.iter().find(|x| x.name == name).map(|x| x.objects.clone());
|
||||
let ids = ids.ok_or_else(|| bad(C, format!("no saved selection `{name}`")))?;
|
||||
s.select(|d, sel| sel.set(ids.into_iter().filter(|id| d.node(*id).is_some())))?;
|
||||
Ok(json!({ "count": s.doc()?.selection.len() }))
|
||||
}
|
||||
|
||||
/// Recall and Edit Selection… need a document with at least one saved selection.
|
||||
fn has_saved_selection(s: &Session) -> std::result::Result<(), String> {
|
||||
let st = s.active().ok_or("no document open")?;
|
||||
if st.doc.saved_selections.is_empty() { Err("no saved selections".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn edit_saved(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = saved_index(s, p, "select.editSaved")?;
|
||||
if bool_or(p, "delete", false) {
|
||||
s.menu.saved_selections.remove(i);
|
||||
} else if let Some(n) = str_param(p, "newName").filter(|n| !n.trim().is_empty()) {
|
||||
s.menu.saved_selections[i].1 = n.trim().to_string();
|
||||
const C: &str = "select.editSaved";
|
||||
let edits = match p.get("edits").and_then(Value::as_array) {
|
||||
Some(list) => list.as_slice(),
|
||||
None => std::slice::from_ref(p),
|
||||
};
|
||||
let before = s.doc()?.doc.saved_selections.clone();
|
||||
// Edits name the selections as they are now, so they don't depend on each other's order.
|
||||
let mut after: Vec<Option<SavedSelection>> = before.iter().cloned().map(Some).collect();
|
||||
// Files may hold any number of saved selections: keep this linear.
|
||||
let mut index = std::collections::HashMap::with_capacity(before.len());
|
||||
for (i, x) in before.iter().enumerate() {
|
||||
index.entry(x.name.as_str()).or_insert(i);
|
||||
}
|
||||
for e in edits {
|
||||
let name = str_param(e, "name").ok_or_else(|| bad(C, "missing name"))?;
|
||||
let i = *index.get(name).ok_or_else(|| bad(C, format!("no saved selection `{name}`")))?;
|
||||
let Some(slot) = after.get_mut(i) else { continue };
|
||||
if bool_or(e, "delete", false) {
|
||||
*slot = None;
|
||||
} else if let Some(n) = str_param(e, "newName").and_then(SavedSelection::clean_name)
|
||||
&& let Some(x) = slot
|
||||
{
|
||||
x.name = n;
|
||||
}
|
||||
}
|
||||
let after: Vec<SavedSelection> = after.into_iter().flatten().collect();
|
||||
let mut names = std::collections::HashSet::with_capacity(after.len());
|
||||
for x in &after {
|
||||
if !names.insert(x.name.as_str()) {
|
||||
return Err(bad(C, format!("two saved selections would be named `{}`", x.name)));
|
||||
}
|
||||
}
|
||||
if after != before {
|
||||
s.edit("Edit Selection", |d, _| {
|
||||
d.saved_selections = after;
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
@@ -45,6 +45,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
remove_anchor
|
||||
),
|
||||
cmd!(
|
||||
"path.removeAnchors",
|
||||
"Remove Anchor Points",
|
||||
["Object", "Path"],
|
||||
None,
|
||||
"{} remove the direct-selected anchors without opening their paths, refitting the curve round each → {removedObjects}",
|
||||
has_anchors,
|
||||
remove_anchors
|
||||
),
|
||||
cmd!(
|
||||
"path.convertAnchor",
|
||||
"Convert Anchor Point",
|
||||
@@ -592,66 +601,62 @@ fn remove_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let ai = usize_req(p, "anchor", C)?;
|
||||
let removed = s.edit("Delete Anchor Point", |d, sel| {
|
||||
let path = path_mut(d, id)?;
|
||||
let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
|
||||
let n = sp.anchors.len();
|
||||
if ai >= n {
|
||||
let sp = path_mut(d, id)?.subpaths.get(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
|
||||
if ai >= sp.anchors.len() {
|
||||
return Err(EngineError::Other("no such anchor".into()));
|
||||
}
|
||||
let interior = sp.closed || (ai > 0 && ai + 1 < n);
|
||||
if interior && n >= 3 {
|
||||
let (pi, ni) = ((ai + n - 1) % n, (ai + 1) % n);
|
||||
let (prev, a, next) = (sp.anchors[pi], sp.anchors[ai], sp.anchors[ni]);
|
||||
// Keep the outer tangents; stretch them to span both removed segments.
|
||||
let l1 = prev.p.distance(a.p);
|
||||
let l2 = a.p.distance(next.p);
|
||||
let chord = prev.p.distance(next.p).max(1e-9);
|
||||
let first_line = !prev.has_out() && !a.has_in();
|
||||
let second_line = !a.has_out() && !next.has_in();
|
||||
let (mut hout, mut hin) = (prev.h_out, next.h_in);
|
||||
if !(first_line && second_line) {
|
||||
// Curved: stretch the outer handles to span both segments; a straight side aims a
|
||||
// third of the chord at the removed anchor.
|
||||
hout = if prev.has_out() {
|
||||
prev.p + (prev.h_out - prev.p) * ((l1 + l2) / l1.max(1e-9)).min(3.0)
|
||||
} else {
|
||||
prev.p + (a.p - prev.p) * (chord / 3.0 / l1.max(1e-9))
|
||||
};
|
||||
hin = if next.has_in() {
|
||||
next.p + (next.h_in - next.p) * ((l1 + l2) / l2.max(1e-9)).min(3.0)
|
||||
} else {
|
||||
next.p + (a.p - next.p) * (chord / 3.0 / l2.max(1e-9))
|
||||
};
|
||||
}
|
||||
sp.anchors[pi].h_out = hout;
|
||||
sp.anchors[ni].h_in = hin;
|
||||
}
|
||||
sp.anchors.remove(ai);
|
||||
if !sp.closed && !sp.anchors.is_empty() {
|
||||
let (f, l) = (0, sp.anchors.len() - 1);
|
||||
let a0 = sp.anchors[f].p;
|
||||
sp.anchors[f].h_in = a0;
|
||||
let al = sp.anchors[l].p;
|
||||
sp.anchors[l].h_out = al;
|
||||
}
|
||||
if sp.anchors.len() < 3 {
|
||||
sp.closed = sp.closed && sp.anchors.len() == 2 && sp.anchors.iter().any(|a| a.has_in() || a.has_out());
|
||||
}
|
||||
let empty = sp.anchors.len() < 2;
|
||||
if empty {
|
||||
path.subpaths.remove(si);
|
||||
}
|
||||
if path.is_empty() {
|
||||
d.remove(id)?;
|
||||
sel.clear();
|
||||
return Ok(true);
|
||||
}
|
||||
sel.anchors.remove(&id);
|
||||
Ok(false)
|
||||
remove_anchors_of(d, sel, id, [(si, ai)])
|
||||
})?;
|
||||
Ok(json!({ "removedObject": removed }))
|
||||
}
|
||||
|
||||
fn remove_anchors(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let jobs: Vec<(NodeId, Vec<(usize, usize)>)> = st
|
||||
.selection
|
||||
.anchors
|
||||
.iter()
|
||||
.filter(|(id, set)| !set.is_empty() && st.doc.node(**id).is_some_and(|n| matches!(n.kind, NodeKind::Path { .. })))
|
||||
.map(|(id, set)| (*id, set.iter().copied().collect()))
|
||||
.collect();
|
||||
if jobs.is_empty() {
|
||||
return Err(bad("path.removeAnchors", "direct-select anchor points of a path"));
|
||||
}
|
||||
let removed = s.edit("Remove Anchor Points", |d, sel| {
|
||||
let mut removed = 0;
|
||||
for (id, anchors) in jobs {
|
||||
removed += usize::from(remove_anchors_of(d, sel, id, anchors)?);
|
||||
}
|
||||
Ok(removed)
|
||||
})?;
|
||||
Ok(json!({ "removedObjects": removed }))
|
||||
}
|
||||
|
||||
/// Remove `anchors` (subpath, anchor) of path `id` without opening it, refitting the curve round
|
||||
/// each one; a subpath left with one anchor goes, and the path with its last subpath. True when
|
||||
/// the path went.
|
||||
fn remove_anchors_of(d: &mut Document, sel: &mut Selection, id: NodeId, anchors: impl IntoIterator<Item = (usize, usize)>) -> Result<bool> {
|
||||
let mut anchors: Vec<(usize, usize)> = anchors.into_iter().collect();
|
||||
// The last first, so the indexes still to come stay valid.
|
||||
anchors.sort_unstable_by(|a, b| b.cmp(a));
|
||||
anchors.dedup();
|
||||
let path = path_mut(d, id)?;
|
||||
for (si, ai) in anchors {
|
||||
if let Some(sp) = path.subpaths.get_mut(si) {
|
||||
po::remove_anchor(sp, ai);
|
||||
}
|
||||
}
|
||||
path.subpaths.retain(|sp| sp.anchors.len() >= 2);
|
||||
let gone = path.is_empty();
|
||||
if gone {
|
||||
d.remove(id)?;
|
||||
sel.remove(id);
|
||||
} else {
|
||||
sel.anchors.remove(&id);
|
||||
}
|
||||
Ok(gone)
|
||||
}
|
||||
|
||||
fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.convertAnchor";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
|
||||
@@ -25,7 +25,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("file.close", "Close", ["File"], Some("Cmd+W"), "{index?}", has_doc, file_close),
|
||||
cmd!("document.activate", "Activate Document", [], None, "{index}", always, doc_activate),
|
||||
cmd!(query "document.inspect", "Inspect Document", [], None, "{} → layer tree, artboards, selection, history", has_doc, |s, _| Ok(inspect::document(s))),
|
||||
cmd!(query "document.node", "Inspect Object", [], None, "{id} → full object JSON", has_doc, doc_node),
|
||||
cmd!(query "document.node", "Inspect Object", [], None, "{id, summary?: the compact document.inspect-style summary instead of the full object JSON} → one object", has_doc, doc_node),
|
||||
cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model", has_doc, |s, _| Ok(serde_json::to_value(&*s.doc()?.doc).unwrap_or(Value::Null))),
|
||||
cmd!(
|
||||
"document.setUnits",
|
||||
@@ -106,7 +106,7 @@ fn doc_activate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn doc_node(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("document.node", "missing id"))?;
|
||||
let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?;
|
||||
Ok(serde_json::to_value(n).unwrap_or(Value::Null))
|
||||
if bool_or(p, "summary", false) { Ok(inspect::node_summary(n)) } else { Ok(serde_json::to_value(n).unwrap_or(Value::Null)) }
|
||||
}
|
||||
|
||||
/// Document Setup's units (also `document.setup {units}`).
|
||||
|
||||
+530
-97
@@ -1,31 +1,125 @@
|
||||
//! Layers panel, artboards.
|
||||
//!
|
||||
//! The Layers panel works on rows: layers, sublayers, groups and objects. Clicking a row makes it
|
||||
//! the highlighted row (`layer.setCurrent`; Shift and Ctrl change several, `layer.highlight`) and
|
||||
//! its layer the current one, where new art goes. Panel commands without `ids` act on the
|
||||
//! highlighted rows, else on the current layer. More panel-menu operations live in
|
||||
//! [`super::layerpanel`].
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Artboard, LayerColor, NodeId, NodeKind};
|
||||
use vectorcraft_doc::{Artboard, Document, LAYER_COLORS, LayerColor, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
|
||||
/// Most rows one panel command takes (ids are untrusted input).
|
||||
pub(crate) const MAX_ROWS: usize = 100_000;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("layer.new", "New Layer…", ["Window", "Layers"], Some("Cmd+L"), "{name?} → {id} (above the current layer)", has_doc, new_layer),
|
||||
cmd!("layer.newSublayer", "New Sublayer…", ["Window", "Layers"], None, "{parent?: id, name?} → {id}", has_doc, new_sublayer),
|
||||
cmd!("layer.delete", "Delete Layer", ["Window", "Layers"], None, "{id?} (default: current layer)", has_doc, delete_layer),
|
||||
cmd!("layer.duplicate", "Duplicate Layer", ["Window", "Layers"], None, "{id?}", has_doc, duplicate_layer),
|
||||
cmd!("layer.setCurrent", "Set Current Layer", [], None, "{id}", has_doc, set_current),
|
||||
cmd!(
|
||||
"layer.new",
|
||||
"New Layer…",
|
||||
["Window", "Layers"],
|
||||
Some("Cmd+L"),
|
||||
"{name?, top?: bool, color?, template?, locked?, visible?, printable?, preview?, dimImages?} a layer directly above the current layer, at its level (a sublayer beside a current sublayer); `top`: above all layers (Ctrl-click the button). Options as layer.setProps → {id}",
|
||||
has_doc,
|
||||
new_layer
|
||||
),
|
||||
cmd!(
|
||||
"layer.newSublayer",
|
||||
"New Sublayer…",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{parent?: layer id (default: the highlighted row's layer, else the current layer), name?, options as layer.new} a sublayer on top of the parent's contents → {id}",
|
||||
has_doc,
|
||||
new_sublayer
|
||||
),
|
||||
cmd!(
|
||||
"layer.delete",
|
||||
"Delete Selection",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{ids?|id?} delete rows: layers, sublayers, groups or objects with what they hold (default: the highlighted rows, else the current layer). The last layer stays → {deleted}",
|
||||
has_doc,
|
||||
delete_rows
|
||||
),
|
||||
cmd!(
|
||||
"layer.duplicate",
|
||||
"Duplicate Selection",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{ids?|id?} copy rows (default: the highlighted rows, else the current layer), each just above its original; a layer's copy is named `<name> copy`. The copies are highlighted → {ids}",
|
||||
has_doc,
|
||||
duplicate_rows
|
||||
),
|
||||
cmd!(
|
||||
"layer.setCurrent",
|
||||
"Set Current Layer",
|
||||
[],
|
||||
None,
|
||||
"{id} click a row: it alone is highlighted, and the layer it is (or the layer it is in) becomes the current layer, where new art goes. Not an undo step → {id, layer}",
|
||||
has_doc,
|
||||
set_current
|
||||
),
|
||||
cmd!(
|
||||
"layer.highlight",
|
||||
"Highlight Rows",
|
||||
[],
|
||||
None,
|
||||
"{ids, mode?: \"set\"|\"add\"|\"toggle\"} highlight Layers panel rows (Shift-click: a range, Ctrl-click: toggle one); the last one's layer becomes current. Not an undo step → {rows}",
|
||||
has_doc,
|
||||
highlight
|
||||
),
|
||||
cmd!(
|
||||
"layer.setProps",
|
||||
"Layer Options…",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{id, name?, visible?, locked?, template?, printable?, color?: index 0..26}",
|
||||
"{ids?|id? (default: the highlighted rows, else the current layer), name?, visible?, locked?, template?, printable?, preview?, dimImages?: 0..100|false, color?: index 0..26|\"#rrggbb\"|preset name} Layer Options; rows that are not layers take name, visible and locked. Template on also locks the layer and dims its images to 50% (unless given); off unlocks it and stops dimming. One undo step",
|
||||
has_doc,
|
||||
set_props
|
||||
),
|
||||
cmd!("layer.collectInNew", "Collect in New Layer", ["Window", "Layers"], None, "{}", has_selection, collect),
|
||||
cmd!("layer.selectAll", "Select All Art on Layer", [], None, "{id}", has_doc, select_all_on),
|
||||
cmd!("node.move", "Reorder", [], None, "{id, parent?: id (null = top level), index} drag-reorder in the Layers panel", has_doc, move_node),
|
||||
cmd!(
|
||||
"layer.collectInNew",
|
||||
"Collect in New Layer",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{ids?} move rows (default: the highlighted rows, else the selected objects) into a new layer where the topmost of them was (a sublayer when they are in a layer) → {id}",
|
||||
has_doc,
|
||||
collect
|
||||
),
|
||||
cmd!(
|
||||
"layer.selectAll",
|
||||
"Select Art",
|
||||
[],
|
||||
None,
|
||||
"{id, add?: bool} the Layers panel's selection column: select the visible, unlocked art of a layer (its sublayers' too), or the object itself; `add` (Shift-click) adds it to the selection, or removes it when it is all selected already → {selected: [..]}",
|
||||
has_doc,
|
||||
select_art
|
||||
),
|
||||
cmd!(
|
||||
"layer.move",
|
||||
"Move Rows",
|
||||
[],
|
||||
None,
|
||||
"{ids, target, place?: \"above\"|\"below\"|\"inside\" (default inside), copy?: bool} drag rows in the Layers panel: above or below the target row (beside it, in its layer or group), or inside a layer or group (on top of its contents). Layers go only in layers or at the top level, objects in layers and groups; an object placed beside a top-level layer goes inside it. A row never goes into itself; a locked layer or group takes nothing. `copy` (Alt-drag) moves copies. One undo step → {ids}",
|
||||
has_doc,
|
||||
move_rows
|
||||
),
|
||||
cmd!(
|
||||
"node.move",
|
||||
"Reorder",
|
||||
[],
|
||||
None,
|
||||
"{id, parent?: id (null = top level), index} put a row at `index` (0 = bottom) of `parent` (layers only at the top level or in layers)",
|
||||
has_doc,
|
||||
move_node
|
||||
),
|
||||
cmd!(
|
||||
"artboard.new",
|
||||
"New Artboard",
|
||||
@@ -52,7 +146,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make/Release Clipping Mask",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{id?: layer or group (default: the one selected group, else the current layer)} make: its top object (a path, compound path or text, which loses its paint) clips the rest and moves to the bottom (new art added on top is clipped); release: it stops clipping, the clipping path stays unpainted → {clip}",
|
||||
"{id?: layer or group (default: the one highlighted layer or group row, else the one selected group, else the current layer)} make: its top object (a path, compound path or text, which loses its paint) clips the rest and moves to the bottom (new art added on top is clipped); release: it stops clipping, the clipping path stays unpainted → {clip}",
|
||||
has_doc,
|
||||
clip_toggle
|
||||
),
|
||||
@@ -61,7 +155,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Target",
|
||||
[],
|
||||
None,
|
||||
"{id} target a layer, group or object as clicking its target circle in the Layers panel does: a layer gets its visible, unlocked art selected and becomes the current layer, and appearance.*, effect.*, transparency.* and the opacity-mask commands without `ids` then act on the layer itself; anything else is selected. Any other selection change ends the targeting (`document.inspect` → target) → {id, selected: [..]}",
|
||||
"{id} target a layer, group or object as clicking its target circle in the Layers panel does: a layer gets its visible, unlocked art (its sublayers' too) selected and becomes the current layer, and appearance.*, effect.*, transparency.* and the opacity-mask commands without `ids` then act on the layer itself; anything else is selected. Any other selection change ends the targeting (`document.inspect` → target) → {id, selected: [..]}",
|
||||
has_doc,
|
||||
target
|
||||
),
|
||||
@@ -77,6 +171,165 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
/// The rows a panel command acts on: `ids` or `id`, else the highlighted rows, else the current
|
||||
/// layer (capped at [`MAX_ROWS`]).
|
||||
pub(crate) fn rows_param(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
|
||||
let mut ids = match (ids_param(p, "ids"), id_param(p, "id")) {
|
||||
(Some(ids), _) => ids,
|
||||
(None, Some(id)) => vec![id],
|
||||
(None, None) => {
|
||||
let st = s.doc()?;
|
||||
let rows = st.highlighted_rows();
|
||||
if rows.is_empty() { st.current_layer().into_iter().collect() } else { rows }
|
||||
}
|
||||
};
|
||||
ids.truncate(MAX_ROWS);
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
/// `ids` that exist, without repeats or rows inside another listed row, in paint order (bottom
|
||||
/// first).
|
||||
pub(crate) fn row_roots(d: &Document, ids: &[NodeId]) -> Vec<NodeId> {
|
||||
let set: HashSet<NodeId> = ids.iter().copied().filter(|i| d.node(*i).is_some()).collect();
|
||||
let keep: Vec<NodeId> = set
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|id| d.ancestry(*id).is_some_and(|a| a.split_last().is_none_or(|(_, above)| !above.iter().any(|x| set.contains(x)))))
|
||||
.collect();
|
||||
d.paint_order(keep)
|
||||
}
|
||||
|
||||
/// Whether `parent` (None: the top level) can hold `n`: layers go at the top level or in layers,
|
||||
/// anything else in layers and groups.
|
||||
pub(crate) fn accepts(d: &Document, parent: Option<NodeId>, n: &Node) -> bool {
|
||||
match parent {
|
||||
None => n.is_layer(),
|
||||
Some(p) => match d.node(p).map(|x| &x.kind) {
|
||||
Some(NodeKind::Layer { .. }) => true,
|
||||
Some(NodeKind::Group { .. }) => !n.is_layer(),
|
||||
_ => false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `id` or a container around it is locked.
|
||||
fn locked_within(d: &Document, id: NodeId) -> bool {
|
||||
d.ancestry(id).unwrap_or_default().iter().any(|a| d.node(*a).is_some_and(|n| n.locked))
|
||||
}
|
||||
|
||||
/// Every layer and sublayer, depth first.
|
||||
fn all_layers(d: &Document) -> Vec<&Node> {
|
||||
let mut out = vec![];
|
||||
d.walk(|n| {
|
||||
if n.is_layer() {
|
||||
out.push(n);
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// A new layer named `name` (default: the next `Layer N`) in the next layer colour.
|
||||
pub(crate) fn make_layer(d: &mut Document, name: Option<&str>) -> Node {
|
||||
let n = all_layers(d).len();
|
||||
let name = name.map(str::to_string).unwrap_or_else(|| d.next_layer_name());
|
||||
let id = d.alloc_id();
|
||||
Node::layer(id, &name, LayerColor::Preset((n % LAYER_COLORS.len()) as u8))
|
||||
}
|
||||
|
||||
/// A layer colour: a preset index (0–26), `#rrggbb` or a preset's name (any case).
|
||||
fn layer_color_param(v: &Value) -> std::result::Result<LayerColor, String> {
|
||||
match v {
|
||||
Value::Number(n) => match n.as_u64() {
|
||||
Some(i) if (i as usize) < LAYER_COLORS.len() => Ok(LayerColor::Preset(i as u8)),
|
||||
_ => Err(format!("a colour index is 0 to {}, not {n}", LAYER_COLORS.len() - 1)),
|
||||
},
|
||||
Value::String(s) => {
|
||||
if let Some(i) = LAYER_COLORS.iter().position(|(name, _)| name.eq_ignore_ascii_case(s.trim())) {
|
||||
return Ok(LayerColor::Preset(i as u8));
|
||||
}
|
||||
let c = vectorcraft_color::Color::from_hex(s).ok_or_else(|| format!("`{s}` is not a colour (#rrggbb or a preset name)"))?;
|
||||
let [r, g, b, _] = c.to_rgba8(1.0);
|
||||
Ok(LayerColor::Custom([r, g, b]))
|
||||
}
|
||||
_ => Err(format!("a colour is an index, #rrggbb or a preset name, not {v}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Layer Options in `p` applied to layer or row `n` (validated first by [`check_options`]).
|
||||
fn apply_options(n: &mut Node, p: &Value) {
|
||||
if let Some(v) = str_param(p, "name") {
|
||||
if v.trim().is_empty() {
|
||||
// A layer keeps a name; an object goes back to its generated `<Kind>` one.
|
||||
if !n.is_layer() {
|
||||
n.name = None;
|
||||
}
|
||||
} else {
|
||||
n.name = Some(v.chars().take(512).collect());
|
||||
}
|
||||
}
|
||||
if let Some(v) = p.get("visible").and_then(Value::as_bool) {
|
||||
n.visible = v;
|
||||
}
|
||||
let locked = p.get("locked").and_then(Value::as_bool);
|
||||
if let Some(v) = locked {
|
||||
n.locked = v;
|
||||
}
|
||||
let mut lock = None;
|
||||
if let NodeKind::Layer { template, printable, color, preview, dim_images, .. } = &mut n.kind {
|
||||
let dim_given = p.get("dimImages").is_some();
|
||||
if let Some(v) = p.get("template").and_then(Value::as_bool)
|
||||
&& v != *template
|
||||
{
|
||||
*template = v;
|
||||
// A template is locked and its images dimmed; it stops being either with it.
|
||||
lock = Some(v);
|
||||
if !dim_given {
|
||||
*dim_images = v.then_some(50);
|
||||
}
|
||||
}
|
||||
if let Some(v) = p.get("printable").and_then(Value::as_bool) {
|
||||
*printable = v;
|
||||
}
|
||||
if let Some(v) = p.get("preview").and_then(Value::as_bool) {
|
||||
*preview = v;
|
||||
}
|
||||
match p.get("dimImages") {
|
||||
Some(Value::Bool(false) | Value::Null) => *dim_images = None,
|
||||
Some(Value::Bool(true)) => *dim_images = Some(50),
|
||||
Some(v) => {
|
||||
if let Some(x) = v.as_f64().filter(|x| x.is_finite()) {
|
||||
*dim_images = Some(x.clamp(0.0, 100.0).round() as u8);
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
if let Some(c) = p.get("color").and_then(|v| layer_color_param(v).ok()) {
|
||||
*color = c;
|
||||
}
|
||||
}
|
||||
if let (Some(v), None) = (lock, locked) {
|
||||
n.locked = v;
|
||||
}
|
||||
}
|
||||
|
||||
/// Bad values in Layer Options params, as an error for `cmd`.
|
||||
fn check_options(cmd: &str, p: &Value) -> Result<()> {
|
||||
if let Some(v) = p.get("color") {
|
||||
layer_color_param(v).map_err(|e| bad(cmd, e))?;
|
||||
}
|
||||
for k in ["visible", "locked", "template", "printable", "preview"] {
|
||||
if p.get(k).is_some_and(|v| !v.is_boolean()) {
|
||||
return Err(bad(cmd, format!("`{k}` is true or false")));
|
||||
}
|
||||
}
|
||||
if let Some(v) = p.get("dimImages")
|
||||
&& !(v.is_boolean() || v.is_null() || v.as_f64().is_some_and(f64::is_finite))
|
||||
{
|
||||
return Err(bad(cmd, "`dimImages` is a percentage or false"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Layers panel → Paste Remembers Layers.
|
||||
fn paste_remembers_layers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cur = s.doc()?.doc.paste_remembers_layers;
|
||||
@@ -100,8 +353,11 @@ fn target(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, "the object is hidden or locked"));
|
||||
}
|
||||
let layer = n.is_layer();
|
||||
let rows = st.layer_rows.clone();
|
||||
s.select(|d, sel| sel.set_target(d, id))?;
|
||||
let st = s.doc_mut()?;
|
||||
// Targeting doesn't change which rows are highlighted.
|
||||
st.layer_rows = rows;
|
||||
if layer {
|
||||
st.active_layer = Some(id);
|
||||
}
|
||||
@@ -112,11 +368,16 @@ fn target(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// or the clipping mask is released.
|
||||
fn clip_toggle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let container = |id: &NodeId| st.doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }));
|
||||
let row = match st.highlighted_rows().as_slice() {
|
||||
[id] if container(id) => Some(*id),
|
||||
_ => None,
|
||||
};
|
||||
let group = match st.selection.objects.as_slice() {
|
||||
[id] if matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Group { .. })) => Some(*id),
|
||||
_ => None,
|
||||
};
|
||||
let id = id_param(p, "id").or(group).or(st.current_layer()).ok_or_else(|| bad("layer.clippingMask.toggle", "no layer"))?;
|
||||
let id = id_param(p, "id").or(row).or(group).or(st.current_layer()).ok_or_else(|| bad("layer.clippingMask.toggle", "no layer"))?;
|
||||
let clip = s.edit("Make/Release Clipping Mask", |d, _| {
|
||||
let n = d.node(id).ok_or(EngineError::NoNode(id))?;
|
||||
if !matches!(n.kind, NodeKind::Layer { .. } | NodeKind::Group { .. }) {
|
||||
@@ -138,99 +399,142 @@ fn clip_toggle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "clip": clip }))
|
||||
}
|
||||
|
||||
fn new_layer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").map(str::to_string);
|
||||
let st = s.doc()?;
|
||||
let cur = st.active_layer.and_then(|l| st.doc.position(l)).filter(|(par, _, _)| par.is_none()).map(|(_, i, _)| i);
|
||||
let id = s.edit("New Layer", |d, _| {
|
||||
let id = d.add_layer(name.as_deref());
|
||||
if let Some(i) = cur {
|
||||
d.move_node(id, None, i + 1)?;
|
||||
}
|
||||
Ok(id)
|
||||
/// A new layer with the options in `p`, at `parent`/`index`; it becomes current and highlighted.
|
||||
fn add_layer(s: &mut Session, p: &Value, cmd: &str, label: &str, place: impl FnOnce(&Document) -> Result<(Option<NodeId>, usize)>) -> Result<Value> {
|
||||
check_options(cmd, p)?;
|
||||
let name = str_param(p, "name").filter(|n| !n.trim().is_empty()).map(str::to_string);
|
||||
let id = s.edit(label, |d, _| {
|
||||
let (parent, index) = place(d)?;
|
||||
let mut n = make_layer(d, name.as_deref());
|
||||
apply_options(&mut n, p);
|
||||
Ok(d.insert(parent, index, n)?)
|
||||
})?;
|
||||
s.doc_mut()?.active_layer = Some(id);
|
||||
let st = s.doc_mut()?;
|
||||
st.active_layer = Some(id);
|
||||
st.layer_rows = vec![id];
|
||||
Ok(json!({ "id": id.0 }))
|
||||
}
|
||||
|
||||
fn new_layer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
// Above the current layer, at its level; on top of everything with `top`.
|
||||
let cur = st.current_layer().filter(|_| !bool_or(p, "top", false));
|
||||
add_layer(s, p, "layer.new", "New Layer", |d| match cur.and_then(|c| d.position(c)) {
|
||||
Some((parent, i, _)) => Ok((parent, i + 1)),
|
||||
None => Ok((None, usize::MAX)),
|
||||
})
|
||||
}
|
||||
|
||||
fn new_sublayer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let parent = id_param(p, "parent").or(s.doc()?.current_layer()).ok_or_else(|| bad("layer.newSublayer", "no parent layer"))?;
|
||||
let name = str_param(p, "name").map(str::to_string);
|
||||
let id = s.edit("New Sublayer", |d, _| {
|
||||
let n = d.node(parent).and_then(|n| n.children()).map(|c| c.iter().filter(|c| c.is_layer()).count()).unwrap_or(0);
|
||||
let id = d.alloc_id();
|
||||
let color = LayerColor::Preset(((d.layers.len() + n + 1) % 27) as u8);
|
||||
let nm = name.unwrap_or_else(|| format!("Layer {}", d.node_count()));
|
||||
d.insert(Some(parent), usize::MAX, vectorcraft_doc::Node::layer(id, &nm, color))?;
|
||||
Ok(id)
|
||||
})?;
|
||||
Ok(json!({ "id": id.0 }))
|
||||
const C: &str = "layer.newSublayer";
|
||||
let st = s.doc()?;
|
||||
let row = st.highlighted_rows().last().and_then(|r| st.doc.layer_containing(*r));
|
||||
let parent = id_param(p, "parent").or(row).or(st.current_layer()).ok_or_else(|| bad(C, "no parent layer"))?;
|
||||
if !st.doc.node(parent).is_some_and(Node::is_layer) {
|
||||
return Err(bad(C, "the parent must be a layer or sublayer"));
|
||||
}
|
||||
add_layer(s, p, C, "New Sublayer", |_| Ok((Some(parent), usize::MAX)))
|
||||
}
|
||||
|
||||
fn delete_layer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").or(s.doc()?.current_layer()).ok_or_else(|| bad("layer.delete", "no layer"))?;
|
||||
fn delete_rows(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = rows_param(s, p)?;
|
||||
let st = s.doc()?;
|
||||
if st.doc.layers.len() == 1 && st.doc.layers[0].id == id {
|
||||
let ids = row_roots(&st.doc, &ids);
|
||||
if ids.is_empty() {
|
||||
return Err(bad("layer.delete", "no such rows"));
|
||||
}
|
||||
if st.doc.layers.iter().all(|l| ids.contains(&l.id)) {
|
||||
return Err(EngineError::Other("a document needs at least one layer".into()));
|
||||
}
|
||||
s.edit("Delete Layer", |d, sel| {
|
||||
d.remove(id)?;
|
||||
s.edit("Delete Selection", |d, sel| {
|
||||
for id in &ids {
|
||||
d.remove(*id)?;
|
||||
}
|
||||
sel.prune(d);
|
||||
Ok(())
|
||||
})?;
|
||||
let st = s.doc_mut()?;
|
||||
if st.active_layer == Some(id) || st.active_layer.is_some_and(|l| st.doc.node(l).is_none()) {
|
||||
st.layer_rows.clear();
|
||||
if st.current_layer().is_none() {
|
||||
st.active_layer = st.doc.default_layer();
|
||||
}
|
||||
ok()
|
||||
Ok(json!({ "deleted": ids.len() }))
|
||||
}
|
||||
|
||||
fn duplicate_layer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").or(s.doc()?.current_layer()).ok_or_else(|| bad("layer.duplicate", "no layer"))?;
|
||||
let nid = s.edit("Duplicate Layer", |d, _| {
|
||||
let (par, idx, _) = d.position(id).ok_or(EngineError::NoNode(id))?;
|
||||
let n = d.node(id).cloned().ok_or(EngineError::NoNode(id))?;
|
||||
let mut c = d.reid(&n);
|
||||
c.name = Some(format!("{} copy", n.display_name()));
|
||||
Ok(d.insert(par, idx + 1, c)?)
|
||||
fn duplicate_rows(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = rows_param(s, p)?;
|
||||
let ids = row_roots(&s.doc()?.doc, &ids);
|
||||
if ids.is_empty() {
|
||||
return Err(bad("layer.duplicate", "no such rows"));
|
||||
}
|
||||
let copies = s.edit("Duplicate Selection", |d, _| {
|
||||
let mut out = vec![];
|
||||
for id in &ids {
|
||||
let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?;
|
||||
let n = d.node(*id).cloned().ok_or(EngineError::NoNode(*id))?;
|
||||
let mut c = d.reid(&n);
|
||||
if n.is_layer() {
|
||||
c.name = Some(format!("{} copy", n.display_name()));
|
||||
}
|
||||
out.push(d.insert(par, idx + 1, c)?);
|
||||
}
|
||||
Ok(out)
|
||||
})?;
|
||||
Ok(json!({ "id": nid.0 }))
|
||||
s.doc_mut()?.layer_rows = copies.clone();
|
||||
Ok(json!({ "ids": copies.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
fn set_current(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("layer.setCurrent", "missing id"))?;
|
||||
const C: &str = "layer.setCurrent";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let st = s.doc_mut()?;
|
||||
if !st.doc.node(id).is_some_and(|n| n.is_layer()) {
|
||||
return Err(bad("layer.setCurrent", "not a layer"));
|
||||
}
|
||||
st.active_layer = Some(id);
|
||||
let layer = st.doc.layer_containing(id).ok_or(EngineError::NoNode(id))?;
|
||||
st.active_layer = Some(layer);
|
||||
st.layer_rows = vec![id];
|
||||
st.revision += 1;
|
||||
ok()
|
||||
Ok(json!({ "id": id.0, "layer": layer.0 }))
|
||||
}
|
||||
|
||||
fn highlight(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "layer.highlight";
|
||||
let mut ids = ids_param(p, "ids").ok_or_else(|| bad(C, "missing ids"))?;
|
||||
ids.truncate(MAX_ROWS);
|
||||
let mode = str_param(p, "mode").unwrap_or("set");
|
||||
let st = s.doc_mut()?;
|
||||
ids.retain(|id| st.doc.node(*id).is_some());
|
||||
let mut rows = st.highlighted_rows();
|
||||
match mode {
|
||||
"set" => rows.clear(),
|
||||
"add" | "toggle" => {}
|
||||
other => return Err(bad(C, format!("mode is set, add or toggle, not `{other}`"))),
|
||||
}
|
||||
let mut last = None;
|
||||
for id in ids {
|
||||
if mode == "toggle" && rows.contains(&id) {
|
||||
rows.retain(|r| *r != id);
|
||||
} else if !rows.contains(&id) {
|
||||
rows.push(id);
|
||||
last = Some(id);
|
||||
}
|
||||
}
|
||||
if let Some(layer) = last.or(rows.last().copied()).and_then(|id| st.doc.layer_containing(id)) {
|
||||
st.active_layer = Some(layer);
|
||||
}
|
||||
st.layer_rows = rows;
|
||||
st.revision += 1;
|
||||
Ok(json!({ "rows": st.layer_rows.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("layer.setProps", "missing id"))?;
|
||||
const C: &str = "layer.setProps";
|
||||
check_options(C, p)?;
|
||||
let ids = rows_param(s, p)?;
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "no rows"));
|
||||
}
|
||||
s.edit("Layer Options", |d, sel| {
|
||||
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
|
||||
if let Some(v) = str_param(p, "name") {
|
||||
n.name = Some(v.to_string());
|
||||
}
|
||||
if let Some(v) = p.get("visible").and_then(Value::as_bool) {
|
||||
n.visible = v;
|
||||
}
|
||||
if let Some(v) = p.get("locked").and_then(Value::as_bool) {
|
||||
n.locked = v;
|
||||
}
|
||||
if let NodeKind::Layer { template, printable, color, .. } = &mut n.kind {
|
||||
if let Some(v) = p.get("template").and_then(Value::as_bool) {
|
||||
*template = v;
|
||||
}
|
||||
if let Some(v) = p.get("printable").and_then(Value::as_bool) {
|
||||
*printable = v;
|
||||
}
|
||||
if let Some(v) = p.get("color").and_then(Value::as_u64) {
|
||||
*color = LayerColor::Preset((v % 27) as u8);
|
||||
}
|
||||
for id in &ids {
|
||||
apply_options(d.node_mut(*id).ok_or(EngineError::NoNode(*id))?, p);
|
||||
}
|
||||
// Hidden or locked content can't stay selected.
|
||||
let hidden: Vec<NodeId> = sel.objects.iter().copied().filter(|o| !d.is_editable(*o)).collect();
|
||||
@@ -242,37 +546,166 @@ fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
fn collect(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = super::edit::selected_roots(s)?;
|
||||
let lid = s.edit("Collect in New Layer", |d, _| {
|
||||
let lid = d.add_layer(None);
|
||||
fn collect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "layer.collectInNew";
|
||||
let ids = match ids_param(p, "ids") {
|
||||
Some(ids) => ids,
|
||||
None => {
|
||||
let rows = s.doc()?.highlighted_rows();
|
||||
if rows.is_empty() { super::edit::selected_roots(s)? } else { rows }
|
||||
}
|
||||
};
|
||||
let ids = row_roots(&s.doc()?.doc, &ids);
|
||||
let Some(top) = ids.last().copied() else { return Err(bad(C, "select rows or objects to collect")) };
|
||||
let lid = s.edit("Collect in New Layer", |d, sel| {
|
||||
// Where the topmost of them is: beside it in its layer, or on top of the layer holding
|
||||
// its group.
|
||||
let (parent, index) = match d.position(top) {
|
||||
Some((par, i, _)) if par.is_none_or(|p| d.node(p).is_some_and(Node::is_layer)) => (par, i + 1),
|
||||
_ => (d.layer_containing(top), usize::MAX),
|
||||
};
|
||||
let layer = make_layer(d, None);
|
||||
let lid = d.insert(parent, index, layer)?;
|
||||
for id in &ids {
|
||||
d.move_node(*id, Some(lid), usize::MAX)?;
|
||||
}
|
||||
sel.prune(d);
|
||||
Ok(lid)
|
||||
})?;
|
||||
let st = s.doc_mut()?;
|
||||
st.active_layer = Some(lid);
|
||||
st.layer_rows = vec![lid];
|
||||
Ok(json!({ "id": lid.0 }))
|
||||
}
|
||||
|
||||
fn select_all_on(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("layer.selectAll", "missing id"))?;
|
||||
let ids: Vec<NodeId> = s
|
||||
.doc()?
|
||||
.doc
|
||||
.node(id)
|
||||
.and_then(|n| n.children())
|
||||
.map(|c| c.iter().filter(|n| n.visible && !n.locked).map(|n| n.id).collect())
|
||||
.unwrap_or_default();
|
||||
s.select(|_, sel| sel.set(ids.iter().copied()))?;
|
||||
ok()
|
||||
/// The selection column: select a row's art.
|
||||
fn select_art(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "layer.selectAll";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let add = bool_or(p, "add", false);
|
||||
let st = s.doc()?;
|
||||
let n = st.doc.node(id).ok_or(EngineError::NoNode(id))?;
|
||||
let ids: Vec<NodeId> = if !st.doc.is_editable(id) {
|
||||
vec![]
|
||||
} else if n.is_layer() {
|
||||
n.layer_art(true)
|
||||
} else {
|
||||
vec![id]
|
||||
};
|
||||
let all_selected = !ids.is_empty() && ids.iter().all(|i| st.selection.contains(*i));
|
||||
let rows = st.layer_rows.clone();
|
||||
s.select(|_, sel| {
|
||||
if !add {
|
||||
sel.set(ids.iter().copied());
|
||||
} else if all_selected {
|
||||
for i in &ids {
|
||||
sel.remove(*i);
|
||||
}
|
||||
} else {
|
||||
for i in &ids {
|
||||
sel.add(*i);
|
||||
}
|
||||
}
|
||||
})?;
|
||||
let st = s.doc_mut()?;
|
||||
// The selection column leaves the highlighted rows as they are.
|
||||
st.layer_rows = rows;
|
||||
Ok(json!({ "selected": st.selection.objects.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Place {
|
||||
Above,
|
||||
Below,
|
||||
Inside,
|
||||
}
|
||||
|
||||
/// Drag rows in the Layers panel.
|
||||
fn move_rows(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "layer.move";
|
||||
let mut ids = ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])).ok_or_else(|| bad(C, "missing ids"))?;
|
||||
ids.truncate(MAX_ROWS);
|
||||
let target = id_param(p, "target").ok_or_else(|| bad(C, "missing target"))?;
|
||||
let place = match str_param(p, "place").unwrap_or("inside") {
|
||||
"above" => Place::Above,
|
||||
"below" => Place::Below,
|
||||
"inside" | "into" => Place::Inside,
|
||||
other => return Err(bad(C, format!("place is above, below or inside, not `{other}`"))),
|
||||
};
|
||||
let copy = bool_or(p, "copy", false);
|
||||
let st = s.doc()?;
|
||||
let d = &st.doc;
|
||||
d.node(target).ok_or(EngineError::NoNode(target))?;
|
||||
let ids = row_roots(d, &ids);
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "no such rows"));
|
||||
}
|
||||
// Never into itself or a row inside it.
|
||||
let around = d.ancestry(target).unwrap_or_default();
|
||||
if ids.iter().any(|i| around.contains(i)) {
|
||||
return Err(bad(C, "a row can't go into itself"));
|
||||
}
|
||||
let nodes: Vec<&Node> = ids.iter().filter_map(|i| d.node(*i)).collect();
|
||||
let (parent, anchor) = match place {
|
||||
Place::Inside => (Some(target), None),
|
||||
_ => match d.parent_of(target) {
|
||||
// Objects can't be at the top level: beside a top-level layer means in it.
|
||||
None if nodes.iter().any(|n| !n.is_layer()) => (Some(target), None),
|
||||
parent => (parent, Some(target)),
|
||||
},
|
||||
};
|
||||
if let Some(n) = nodes.iter().find(|n| !accepts(d, parent, n)) {
|
||||
let into = parent.and_then(|p| d.node(p)).map_or("the top level", |p| if p.is_layer() { "a layer" } else { p.kind_label() });
|
||||
return Err(bad(C, format!("{} can't go into {into}", n.display_name())));
|
||||
}
|
||||
if parent.is_some_and(|p| locked_within(d, p)) {
|
||||
return Err(EngineError::Other("the layer or group is locked".into()));
|
||||
}
|
||||
let label = if copy { "Duplicate Rows" } else { "Move Rows" };
|
||||
let moved = s.edit(label, |d, sel| {
|
||||
let mut nodes = vec![];
|
||||
for id in &ids {
|
||||
nodes.push(if copy {
|
||||
let n = d.node(*id).cloned().ok_or(EngineError::NoNode(*id))?;
|
||||
d.reid(&n)
|
||||
} else {
|
||||
Arc::unwrap_or_clone(d.remove(*id)?)
|
||||
});
|
||||
}
|
||||
let count = d.children(parent).map_or(0, Vec::len);
|
||||
let mut index = match anchor {
|
||||
None => count,
|
||||
Some(a) => {
|
||||
let (_, i, _) = d.position(a).ok_or(EngineError::NoNode(a))?;
|
||||
if place == Place::Above { i + 1 } else { i }
|
||||
}
|
||||
};
|
||||
// The clipping path stays at the bottom of a clip group or a clipping layer.
|
||||
if count > 0 && parent.and_then(|p| d.node(p)).is_some_and(Node::clips) {
|
||||
index = index.max(1);
|
||||
}
|
||||
let mut out = vec![];
|
||||
for n in nodes {
|
||||
out.push(d.insert(parent, index, n)?);
|
||||
index += 1;
|
||||
}
|
||||
sel.prune(d);
|
||||
Ok(out)
|
||||
})?;
|
||||
let st = s.doc_mut()?;
|
||||
st.layer_rows = moved.clone();
|
||||
Ok(json!({ "ids": moved.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
fn move_node(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("node.move", "missing id"))?;
|
||||
const C: &str = "node.move";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let parent = id_param(p, "parent");
|
||||
let index = p.get("index").and_then(Value::as_u64).unwrap_or(u64::MAX) as usize;
|
||||
if parent.is_none() && !s.doc()?.doc.node(id).is_some_and(|n| n.is_layer()) {
|
||||
return Err(bad("node.move", "only layers can be at the top level"));
|
||||
let index = p.get("index").and_then(Value::as_u64).unwrap_or(u64::MAX).min(usize::MAX as u64) as usize;
|
||||
let d = &s.doc()?.doc;
|
||||
let n = d.node(id).ok_or(EngineError::NoNode(id))?;
|
||||
if !accepts(d, parent, n) {
|
||||
return Err(bad(C, "layers go only at the top level or in layers, objects in layers and groups"));
|
||||
}
|
||||
s.edit("Reorder", |d, _| Ok(d.move_node(id, parent, index)?))?;
|
||||
ok()
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
//! The Layers panel menu's operations on rows: Merge Selected, Flatten Artwork, Release to Layers,
|
||||
//! Reverse Order, Template, Hide/Outline/Lock Others and their Show/Preview/Unlock All
|
||||
//! counterparts, Locate Object. Rows default to the highlighted ones, else the current layer (see
|
||||
//! [`super::layer`]).
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
|
||||
|
||||
use super::layer::{make_layer, row_roots, rows_param};
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"layer.merge",
|
||||
"Merge Selected",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{ids?} (default: the highlighted rows) merge two or more rows into the layer listed (highlighted) last: the other layers' contents and the other rows move into it, keeping their stacking order, and the emptied layers go. One undo step → {id}",
|
||||
has_doc,
|
||||
merge
|
||||
),
|
||||
cmd!(
|
||||
"layer.flatten",
|
||||
"Flatten Artwork",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{id?} (default: the highlighted row's top-level layer, else the current one's) move the art of every other visible top-level layer into this one, keeping the stacking order; hidden layers and their art are deleted, template layers stay. One undo step → {id, discarded}",
|
||||
has_doc,
|
||||
flatten
|
||||
),
|
||||
cmd!(
|
||||
"layer.releaseToLayers",
|
||||
"Release to Layers (Sequence)",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{id?: layer or group (default: the highlighted row, else the current layer), build?: bool} each object in it goes into a new sublayer of its own, in stacking order (a group's objects go into sublayers of its layer, where the group was). `build`: Release to Layers (Build), each new sublayer holds copies of the objects up to its own, so the layers add up. One undo step → {ids}",
|
||||
has_doc,
|
||||
release
|
||||
),
|
||||
cmd!(
|
||||
"layer.releaseToLayersBuild",
|
||||
"Release to Layers (Build)",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{id?} layer.releaseToLayers with build: true → {ids}",
|
||||
has_doc,
|
||||
|s, p| {
|
||||
let mut p = p.clone();
|
||||
if let Some(m) = p.as_object_mut() {
|
||||
m.insert("build".into(), json!(true));
|
||||
} else {
|
||||
p = json!({"build": true});
|
||||
}
|
||||
release(s, &p)
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"layer.reverse",
|
||||
"Reverse Order",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{ids?} (default: the highlighted rows) reverse the stacking order of two or more rows that share a layer or group; each keeps the places the others held. One undo step",
|
||||
has_doc,
|
||||
reverse
|
||||
),
|
||||
cmd!(
|
||||
"layer.template",
|
||||
"Template",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{ids?, on?: bool (default: toggle the first)} make layers templates (locked, dimmed, left out of print and export) or ordinary layers again (layer.setProps template) → {on}",
|
||||
has_doc,
|
||||
template
|
||||
),
|
||||
cmd!(
|
||||
"layer.hideOthers",
|
||||
"Hide Others",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{ids?} hide every top-level layer but those holding the highlighted rows (else the current layer). One undo step → {count}",
|
||||
has_doc,
|
||||
|s, p| others(s, p, Flag::Visible, false)
|
||||
),
|
||||
cmd!("layer.showAll", "Show All Layers", ["Window", "Layers"], None, "{} show every layer and sublayer → {count}", has_doc, |s, _| all(
|
||||
s,
|
||||
Flag::Visible,
|
||||
true
|
||||
)),
|
||||
cmd!(
|
||||
"layer.outlineOthers",
|
||||
"Outline Others",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{ids?} turn Preview off (outline view) for every top-level layer but those holding the highlighted rows (else the current layer) → {count}",
|
||||
has_doc,
|
||||
|s, p| others(s, p, Flag::Preview, false)
|
||||
),
|
||||
cmd!(
|
||||
"layer.previewAll",
|
||||
"Preview All Layers",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{} turn Preview on for every layer and sublayer → {count}",
|
||||
has_doc,
|
||||
|s, _| all(s, Flag::Preview, true)
|
||||
),
|
||||
cmd!(
|
||||
"layer.lockOthers",
|
||||
"Lock Others",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{ids?} lock every top-level layer but those holding the highlighted rows (else the current layer) → {count}",
|
||||
has_doc,
|
||||
|s, p| others(s, p, Flag::Locked, true)
|
||||
),
|
||||
cmd!(
|
||||
"layer.unlockAll",
|
||||
"Unlock All Layers",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{} unlock every layer and sublayer → {count}",
|
||||
has_doc,
|
||||
|s, _| all(s, Flag::Locked, false)
|
||||
),
|
||||
cmd!(
|
||||
"layer.locate",
|
||||
"Locate Object",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{id?} (default: the last selected object) highlight the object's row; the Layers panel opens the layers around it and scrolls to it → {id, ancestry: [layer…, id]}",
|
||||
has_doc,
|
||||
locate
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// A layer flag the panel menu sets on many layers.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Flag {
|
||||
Visible,
|
||||
Locked,
|
||||
Preview,
|
||||
}
|
||||
|
||||
impl Flag {
|
||||
fn get(self, n: &Node) -> bool {
|
||||
match (self, &n.kind) {
|
||||
(Flag::Visible, _) => n.visible,
|
||||
(Flag::Locked, _) => n.locked,
|
||||
(Flag::Preview, NodeKind::Layer { preview, .. }) => *preview,
|
||||
(Flag::Preview, _) => true,
|
||||
}
|
||||
}
|
||||
fn set(self, n: &mut Node, v: bool) {
|
||||
match (self, &mut n.kind) {
|
||||
(Flag::Visible, _) => n.visible = v,
|
||||
(Flag::Locked, _) => n.locked = v,
|
||||
(Flag::Preview, NodeKind::Layer { preview, .. }) => *preview = v,
|
||||
(Flag::Preview, _) => {}
|
||||
}
|
||||
}
|
||||
fn label(self, on: bool) -> &'static str {
|
||||
match (self, on) {
|
||||
(Flag::Visible, true) => "Show All Layers",
|
||||
(Flag::Visible, false) => "Hide Others",
|
||||
(Flag::Locked, true) => "Lock Others",
|
||||
(Flag::Locked, false) => "Unlock All Layers",
|
||||
(Flag::Preview, true) => "Preview All Layers",
|
||||
(Flag::Preview, false) => "Outline Others",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set `flag` to `v` on `ids` (layers that differ) in one undo step labelled `label` → how many
|
||||
/// changed.
|
||||
fn set_flag(s: &mut Session, ids: Vec<NodeId>, flag: Flag, v: bool, label: &str) -> Result<Value> {
|
||||
let d = &s.doc()?.doc;
|
||||
let ids: Vec<NodeId> = ids.into_iter().filter(|id| d.node(*id).is_some_and(|n| flag.get(n) != v)).collect();
|
||||
if !ids.is_empty() {
|
||||
s.edit(label, |d, sel| {
|
||||
for id in &ids {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
flag.set(n, v);
|
||||
}
|
||||
}
|
||||
// Hidden or locked art can't stay selected.
|
||||
let out: Vec<NodeId> = sel.objects.iter().copied().filter(|o| !d.is_editable(*o)).collect();
|
||||
for o in out {
|
||||
sel.remove(o);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(json!({ "count": ids.len() }))
|
||||
}
|
||||
|
||||
/// The top-level layers holding the rows a command acts on.
|
||||
fn kept_layers(s: &Session, p: &Value) -> Result<HashSet<NodeId>> {
|
||||
let ids = rows_param(s, p)?;
|
||||
let d = &s.doc()?.doc;
|
||||
Ok(ids.iter().filter_map(|id| d.layer_of(*id)).collect())
|
||||
}
|
||||
|
||||
fn others(s: &mut Session, p: &Value, flag: Flag, v: bool) -> Result<Value> {
|
||||
let keep = kept_layers(s, p)?;
|
||||
let ids: Vec<NodeId> = s.doc()?.doc.layers.iter().map(|l| l.id).filter(|l| !keep.contains(l)).collect();
|
||||
set_flag(s, ids, flag, v, flag.label(v))
|
||||
}
|
||||
|
||||
fn all(s: &mut Session, flag: Flag, v: bool) -> Result<Value> {
|
||||
let mut ids = vec![];
|
||||
s.doc()?.doc.walk(|n| {
|
||||
if n.is_layer() {
|
||||
ids.push(n.id);
|
||||
}
|
||||
});
|
||||
set_flag(s, ids, flag, v, flag.label(v))
|
||||
}
|
||||
|
||||
fn template(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = rows_param(s, p)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let ids: Vec<NodeId> = ids.into_iter().filter(|id| d.node(*id).is_some_and(Node::is_layer)).collect();
|
||||
let Some(first) = ids.first().and_then(|id| d.node(*id)) else { return Err(bad("layer.template", "select a layer")) };
|
||||
let on = bool_or(p, "on", !first.is_template());
|
||||
s.execute("layer.setProps", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "template": on}))?;
|
||||
Ok(json!({ "on": on }))
|
||||
}
|
||||
|
||||
/// Move the contents of `others` (layers give their children, anything else itself; none inside
|
||||
/// another) into layer `into`, keeping the document's stacking order, then delete the emptied
|
||||
/// layers.
|
||||
fn merge_into(d: &mut Document, into: NodeId, others: &[NodeId]) -> Result<()> {
|
||||
let own: Vec<NodeId> =
|
||||
d.node(into).and_then(|n| n.children()).map(|c| c.iter().map(|c| c.id).filter(|c| !others.contains(c)).collect()).unwrap_or_default();
|
||||
let mut moved = vec![];
|
||||
for id in others {
|
||||
let n = d.node(*id).ok_or(EngineError::NoNode(*id))?;
|
||||
if n.is_layer() {
|
||||
moved.extend(n.children().into_iter().flatten().map(|c| c.id));
|
||||
} else {
|
||||
moved.push(*id);
|
||||
}
|
||||
}
|
||||
// Where everything is now: the merged layer stacks its contents in this order.
|
||||
let keys: HashMap<NodeId, Vec<usize>> = own.iter().chain(&moved).filter_map(|id| Some((*id, d.index_path(*id)?))).collect();
|
||||
let mut taken = vec![];
|
||||
for id in &moved {
|
||||
taken.push(d.remove(*id)?);
|
||||
}
|
||||
for id in others {
|
||||
if d.node(*id).is_some() {
|
||||
d.remove(*id)?;
|
||||
}
|
||||
}
|
||||
let ch = d.children_mut(Some(into))?;
|
||||
let mut all = std::mem::take(ch);
|
||||
all.extend(taken);
|
||||
all.sort_by(|a, b| keys.get(&a.id).cmp(&keys.get(&b.id)));
|
||||
*ch = all;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "layer.merge";
|
||||
let ids = rows_param(s, p)?;
|
||||
let st = s.doc()?;
|
||||
let d = &st.doc;
|
||||
let mut seen = HashSet::new();
|
||||
let ids: Vec<NodeId> = ids.into_iter().filter(|id| d.node(*id).is_some() && seen.insert(*id)).collect();
|
||||
if ids.len() < 2 {
|
||||
return Err(bad(C, "highlight two or more rows to merge"));
|
||||
}
|
||||
let into =
|
||||
ids.iter().rev().copied().find(|id| d.node(*id).is_some_and(Node::is_layer)).ok_or_else(|| bad(C, "highlight a layer to merge into"))?;
|
||||
let around = d.ancestry(into).unwrap_or_default();
|
||||
// (Rows inside the target layer merge too: only rows inside other listed rows go with those.)
|
||||
let rest: Vec<NodeId> = ids.iter().copied().filter(|id| *id != into).collect();
|
||||
let others = row_roots(d, &rest);
|
||||
if others.iter().any(|o| around.contains(o)) {
|
||||
return Err(bad(C, "a layer can't be merged into its own sublayer"));
|
||||
}
|
||||
s.edit("Merge Selected", |d, sel| {
|
||||
merge_into(d, into, &others)?;
|
||||
sel.prune(d);
|
||||
Ok(())
|
||||
})?;
|
||||
let st = s.doc_mut()?;
|
||||
st.active_layer = Some(into);
|
||||
st.layer_rows = vec![into];
|
||||
Ok(json!({ "id": into.0 }))
|
||||
}
|
||||
|
||||
fn flatten(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "layer.flatten";
|
||||
let st = s.doc()?;
|
||||
let d = &st.doc;
|
||||
let from = id_param(p, "id").or_else(|| st.highlighted_rows().last().copied()).or(st.current_layer());
|
||||
let into = from.and_then(|id| d.layer_of(id)).ok_or_else(|| bad(C, "no layer"))?;
|
||||
if !d.node(into).is_some_and(|n| n.visible) {
|
||||
return Err(EngineError::Other("the layer to flatten into is hidden".into()));
|
||||
}
|
||||
let others: Vec<NodeId> = d.layers.iter().filter(|l| l.id != into && l.visible && !l.is_template()).map(|l| l.id).collect();
|
||||
let hidden: Vec<NodeId> = d.layers.iter().filter(|l| l.id != into && !l.visible && !l.is_template()).map(|l| l.id).collect();
|
||||
s.edit("Flatten Artwork", |d, sel| {
|
||||
for h in &hidden {
|
||||
d.remove(*h)?;
|
||||
}
|
||||
merge_into(d, into, &others)?;
|
||||
sel.prune(d);
|
||||
Ok(())
|
||||
})?;
|
||||
let st = s.doc_mut()?;
|
||||
st.active_layer = Some(into);
|
||||
st.layer_rows = vec![into];
|
||||
Ok(json!({ "id": into.0, "discarded": hidden.len() }))
|
||||
}
|
||||
|
||||
fn release(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "layer.releaseToLayers";
|
||||
let build = bool_or(p, "build", false);
|
||||
let st = s.doc()?;
|
||||
let id = match id_param(p, "id") {
|
||||
Some(id) => id,
|
||||
None => match st.highlighted_rows().as_slice() {
|
||||
[id] => *id,
|
||||
_ => st.current_layer().ok_or_else(|| bad(C, "no layer"))?,
|
||||
},
|
||||
};
|
||||
let n = st.doc.node(id).ok_or(EngineError::NoNode(id))?;
|
||||
let group = match n.kind {
|
||||
NodeKind::Layer { .. } => false,
|
||||
NodeKind::Group { .. } => true,
|
||||
_ => return Err(bad(C, "release a layer or a group")),
|
||||
};
|
||||
// Its objects, bottom first (sublayers stay as they are).
|
||||
let objects: Vec<NodeId> = n.children().into_iter().flatten().filter(|c| !c.is_layer()).map(|c| c.id).collect();
|
||||
if objects.is_empty() {
|
||||
return Err(EngineError::Other("there are no objects to release".into()));
|
||||
}
|
||||
let new = s.edit(if build { "Release to Layers (Build)" } else { "Release to Layers (Sequence)" }, |d, sel| {
|
||||
// A group's objects go into sublayers of the layer holding it, where it was.
|
||||
let (parent, mut index) = if group {
|
||||
let layer = d.layer_containing(id).ok_or(EngineError::NoNode(id))?;
|
||||
let (par, i, _) = d.position(id).ok_or(EngineError::NoNode(id))?;
|
||||
if par == Some(layer) { (layer, i) } else { (layer, usize::MAX) }
|
||||
} else {
|
||||
(id, 0)
|
||||
};
|
||||
let mut originals = vec![];
|
||||
for o in &objects {
|
||||
originals.push(Arc::unwrap_or_clone(d.remove(*o)?));
|
||||
}
|
||||
if group {
|
||||
d.remove(id)?;
|
||||
}
|
||||
let mut new = vec![];
|
||||
let last = originals.len().saturating_sub(1);
|
||||
for k in 0..originals.len() {
|
||||
let mut layer = make_layer(d, None);
|
||||
let lid = layer.id;
|
||||
let held: Vec<Node> = if build {
|
||||
// Copies of the objects below and itself; the top layer keeps the originals.
|
||||
(0..=k).filter_map(|j| originals.get(j)).map(|o| if k == last { o.clone() } else { d.reid(o) }).collect()
|
||||
} else {
|
||||
originals.get(k).cloned().into_iter().collect()
|
||||
};
|
||||
if let Some(ch) = layer.children_mut() {
|
||||
ch.extend(held.into_iter().map(Arc::new));
|
||||
}
|
||||
let at = if group { index } else { index.min(d.children(Some(parent)).map_or(0, Vec::len)) };
|
||||
d.insert(Some(parent), at, layer)?;
|
||||
index = at.saturating_add(1);
|
||||
new.push(lid);
|
||||
}
|
||||
sel.prune(d);
|
||||
Ok(new)
|
||||
})?;
|
||||
s.doc_mut()?.layer_rows = new.clone();
|
||||
Ok(json!({ "ids": new.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
fn reverse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "layer.reverse";
|
||||
let ids = rows_param(s, p)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let ids = row_roots(d, &ids);
|
||||
// Rows grouped by the layer or group holding them (None: top-level layers).
|
||||
let mut by_parent: Vec<(Option<NodeId>, Vec<usize>)> = vec![];
|
||||
for id in &ids {
|
||||
let Some((par, i, _)) = d.position(*id) else { continue };
|
||||
match by_parent.iter_mut().find(|(p, _)| *p == par) {
|
||||
Some((_, v)) => v.push(i),
|
||||
None => by_parent.push((par, vec![i])),
|
||||
}
|
||||
}
|
||||
by_parent.retain(|(_, v)| v.len() >= 2);
|
||||
if by_parent.is_empty() {
|
||||
return Err(bad(C, "highlight two or more rows in one layer or group"));
|
||||
}
|
||||
s.edit("Reverse Order", |d, _| {
|
||||
for (par, mut slots) in by_parent {
|
||||
slots.sort_unstable();
|
||||
let ch = d.children_mut(par)?;
|
||||
let nodes: Vec<Arc<Node>> = slots.iter().filter_map(|i| ch.get(*i).cloned()).collect();
|
||||
for (slot, n) in slots.iter().zip(nodes.into_iter().rev()) {
|
||||
if let Some(c) = ch.get_mut(*slot) {
|
||||
*c = n;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
fn locate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc_mut()?;
|
||||
let id = id_param(p, "id").or_else(|| st.selection.objects.last().copied()).ok_or_else(|| bad("layer.locate", "nothing selected"))?;
|
||||
let ancestry = st.doc.ancestry(id).ok_or(EngineError::NoNode(id))?;
|
||||
st.layer_rows = vec![id];
|
||||
st.revision += 1;
|
||||
Ok(json!({ "id": id.0, "ancestry": ancestry.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
@@ -18,8 +18,6 @@ use super::*;
|
||||
/// Session-level (not saved) state owned by the menu commands.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct MenuState {
|
||||
/// Saved selections: (document title, name, object ids).
|
||||
pub saved_selections: Vec<(String, String, Vec<NodeId>)>,
|
||||
/// View → Guides → Lock Guides.
|
||||
pub guides_locked: bool,
|
||||
/// Transparency panel menu: "New Opacity Masks Are Clipping" turned off.
|
||||
|
||||
@@ -30,6 +30,7 @@ pub(crate) mod graph;
|
||||
mod halftone;
|
||||
pub mod help;
|
||||
mod layer;
|
||||
mod layerpanel;
|
||||
pub mod links;
|
||||
mod live;
|
||||
pub(crate) mod maskedit;
|
||||
@@ -143,6 +144,10 @@ pub fn has_selection(s: &Session) -> std::result::Result<(), String> {
|
||||
let st = s.active().ok_or("no document open")?;
|
||||
if st.selection.is_empty() { Err("nothing selected".into()) } else { Ok(()) }
|
||||
}
|
||||
pub fn has_anchors(s: &Session) -> std::result::Result<(), String> {
|
||||
let st = s.active().ok_or("no document open")?;
|
||||
if st.selection.anchors.values().all(|a| a.is_empty()) { Err("no anchor points selected".into()) } else { Ok(()) }
|
||||
}
|
||||
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(()) }
|
||||
@@ -191,6 +196,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(gradient::specs());
|
||||
v.extend(opacitymask::specs());
|
||||
v.extend(layer::specs());
|
||||
v.extend(layerpanel::specs());
|
||||
v.extend(draw2::specs());
|
||||
v.extend(xform::specs());
|
||||
v.extend(effectcmd::specs());
|
||||
|
||||
@@ -24,7 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Character / Paragraph",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt, hyphenate?: bool}",
|
||||
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt, hyphenate?: bool, mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character)}",
|
||||
has_doc,
|
||||
set_format
|
||||
),
|
||||
@@ -54,17 +54,27 @@ fn artboard_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let index = s.edit("Duplicate Artboard", |d, _| {
|
||||
let src = d.artboards.get(i).cloned().ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
let right = d.artboards.iter().map(|a| a.rect.x1).fold(f64::MIN, f64::max);
|
||||
let mut a = src.clone();
|
||||
a.id = d.artboards.iter().map(|a| a.id).max().unwrap_or(0) + 1;
|
||||
a.name = format!("{} copy", src.name);
|
||||
let dx = right + 20.0 - src.rect.x0;
|
||||
a.rect = vectorcraft_geom::Rect::new(src.rect.x0 + dx, src.rect.y0, src.rect.x1 + dx, src.rect.y1);
|
||||
d.artboards.push(a);
|
||||
Ok(d.artboards.len() - 1)
|
||||
Ok(push_artboard_copy(d, &src, vectorcraft_geom::Rect::new(src.rect.x0 + dx, src.rect.y0, src.rect.x1 + dx, src.rect.y1)))
|
||||
})?;
|
||||
Ok(json!({"index": index}))
|
||||
}
|
||||
|
||||
/// Add a copy of artboard `src` at `rect`, named `<name> copy` (`<name> copy 2`… when taken),
|
||||
/// with an id of its own → its index.
|
||||
pub(crate) fn push_artboard_copy(d: &mut vectorcraft_doc::Document, src: &vectorcraft_doc::Artboard, rect: vectorcraft_geom::Rect) -> usize {
|
||||
let mut a = src.clone();
|
||||
a.id = d.artboards.iter().map(|a| a.id).max().unwrap_or(0).saturating_add(1);
|
||||
let taken = |name: &str| d.artboards.iter().any(|a| a.name == name);
|
||||
a.name = std::iter::once(format!("{} copy", src.name))
|
||||
.chain((2u64..).map(|i| format!("{} copy {i}", src.name)))
|
||||
.find(|name| !taken(name))
|
||||
.unwrap_or_default();
|
||||
a.rect = rect;
|
||||
d.artboards.push(a);
|
||||
d.artboards.len() - 1
|
||||
}
|
||||
|
||||
// ---------- text ----------
|
||||
|
||||
fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -101,11 +111,30 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"spaceBefore",
|
||||
"spaceAfter",
|
||||
"hyphenate",
|
||||
"mojikumi",
|
||||
"direction",
|
||||
];
|
||||
if !keys.iter().any(|k| p.get(*k).is_some()) {
|
||||
return Err(bad(C, "nothing to change"));
|
||||
}
|
||||
let (position, small_caps) = super::docsetup::script_params(p, &s.doc()?.doc.setup, C)?;
|
||||
let mojikumi = match p.get("mojikumi") {
|
||||
None => None,
|
||||
Some(v) => Some(match v.as_str() {
|
||||
Some("none") => vectorcraft_doc::Mojikumi::None,
|
||||
Some("lineEndHalf") => vectorcraft_doc::Mojikumi::LineEndHalf,
|
||||
_ => return Err(bad(C, "`mojikumi` must be \"none\" or \"lineEndHalf\"")),
|
||||
}),
|
||||
};
|
||||
let direction = match p.get("direction") {
|
||||
None => None,
|
||||
Some(v) => Some(match v.as_str() {
|
||||
Some("auto") => None,
|
||||
Some("leftToRight") => Some(vectorcraft_doc::ParaDirection::LeftToRight),
|
||||
Some("rightToLeft") => Some(vectorcraft_doc::ParaDirection::RightToLeft),
|
||||
_ => return Err(bad(C, "`direction` must be \"auto\", \"leftToRight\" or \"rightToLeft\"")),
|
||||
}),
|
||||
};
|
||||
s.edit("Character", |d, _| {
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
@@ -161,6 +190,12 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(v) = flag("hyphenate") {
|
||||
para.hyphenate = v;
|
||||
}
|
||||
if let Some(v) = mojikumi {
|
||||
para.mojikumi = v;
|
||||
}
|
||||
if let Some(v) = direction {
|
||||
para.direction = v;
|
||||
}
|
||||
super::typecmd::refresh_bounds(t);
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -87,11 +87,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
cmd!(
|
||||
"path.deleteAnchors",
|
||||
"Remove Anchor Points",
|
||||
["Object", "Path"],
|
||||
"Delete Anchor Points",
|
||||
[],
|
||||
None,
|
||||
"{} delete direct-selected anchors",
|
||||
has_selection,
|
||||
"{} delete the direct-selected anchors and their segments, opening closed paths there (the Delete key)",
|
||||
has_anchors,
|
||||
delete_anchors
|
||||
),
|
||||
cmd!(
|
||||
|
||||
@@ -452,7 +452,7 @@ impl Session {
|
||||
let w = self.prefs.perspective_widget;
|
||||
let st = self.active().filter(|_| w.show)?;
|
||||
let g = PerspectiveGrid::current(&st.doc);
|
||||
if !g.shown(self.tool_id()) {
|
||||
if !g.visible {
|
||||
return None;
|
||||
}
|
||||
let place = WidgetPlace { screen: view.screen.as_ref(), corner: w.position };
|
||||
@@ -476,6 +476,6 @@ impl Session {
|
||||
ToolKey::Digit(4) => Plane::None,
|
||||
_ => return None,
|
||||
};
|
||||
PerspectiveGrid::current(&self.active()?.doc).shown(self.tool_id()).then_some(plane)
|
||||
PerspectiveGrid::current(&self.active()?.doc).visible.then_some(plane)
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,8 @@ pub const UNITS: &[(&str, &str)] = &[
|
||||
("feet", "Feet"),
|
||||
];
|
||||
const LINE_STYLE: &[(&str, &str)] = &[("lines", "Lines"), ("dots", "Dots")];
|
||||
/// Performance › Graphics Processor (`gpuPreference`), read by the desktop app at startup.
|
||||
pub const GPU_PREFERENCES: &[(&str, &str)] = &[("powerSaving", "Power Saving (integrated)"), ("highPerformance", "High Performance (discrete)")];
|
||||
const BLACK: &[(&str, &str)] = &[("accurate", "Display All Blacks Accurately"), ("rich", "Display All Blacks as Rich Black")];
|
||||
const BLACK_OUT: &[(&str, &str)] = &[("accurate", "Output All Blacks Accurately"), ("rich", "Output All Blacks as Rich Black")];
|
||||
|
||||
@@ -265,6 +267,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[
|
||||
// Performance
|
||||
p!("gpuPerformance", "Performance", "GPU Performance", "GPU Performance", bool),
|
||||
p!("animatedZoom", "Performance", "GPU Performance", "Animated Zoom", bool),
|
||||
p!("gpuPreference", "Performance", "GPU Performance", "Graphics Processor", choice(GPU_PREFERENCES)),
|
||||
p!("historyStates", "Performance", "", "History States", int(5, 1000)),
|
||||
p!("realTimeDrawing", "Performance", "", "Real-time Drawing and Editing", bool),
|
||||
p!("renderThreads", "Performance", "", "Render Threads (-1 = Automatic)", int(-1, 64)),
|
||||
|
||||
@@ -97,17 +97,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
|
||||
/// Selectable objects: children of visible, unlocked layers (or of the isolation container).
|
||||
/// The objects Select All takes: those of the isolated container, else of every layer (looking
|
||||
/// through sublayers, which are not objects).
|
||||
fn selectable(d: &Document, iso: Option<NodeId>) -> Vec<NodeId> {
|
||||
match iso.and_then(|i| d.node(i)) {
|
||||
Some(c) => c.children().map(|v| v.iter().filter(|n| n.visible && !n.locked).map(|n| n.id).collect()).unwrap_or_default(),
|
||||
None => d
|
||||
.layers
|
||||
.iter()
|
||||
.filter(|l| l.visible && !l.locked)
|
||||
.flat_map(|l| l.children().into_iter().flatten())
|
||||
.filter(|n| n.visible && !n.locked)
|
||||
.map(|n| n.id)
|
||||
.collect(),
|
||||
Some(c) => c.layer_art(true),
|
||||
None => d.selectable_art(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,7 +245,11 @@ fn same(s: &mut Session, cmd: &str, eq: fn(&Node, &Node) -> bool) -> Result<Valu
|
||||
/// inside accepted ones); Select > Reselect repeats `cmd` with `p`.
|
||||
fn select_where(s: &mut Session, cmd: &str, p: &Value, f: impl Fn(&Document, &Node) -> bool) -> Result<Value> {
|
||||
fn visit(d: &Document, n: &Node, f: &impl Fn(&Document, &Node) -> bool, ids: &mut Vec<NodeId>) {
|
||||
if n.visible && !n.locked && !n.is_layer() && f(d, n) {
|
||||
// Hidden or locked objects and sublayers (and what they hold) are out of reach.
|
||||
if !n.visible || n.locked || n.is_template() {
|
||||
return;
|
||||
}
|
||||
if !n.is_layer() && f(d, n) {
|
||||
return ids.push(n.id);
|
||||
}
|
||||
for c in n.children().into_iter().flatten() {
|
||||
@@ -307,14 +306,9 @@ fn same_attribute(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn same_layers(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let layers: BTreeSet<NodeId> = st.selection.objects.iter().filter_map(|id| st.doc.layer_of(*id)).collect();
|
||||
let ids: Vec<NodeId> = layers
|
||||
.iter()
|
||||
.filter_map(|l| st.doc.node(*l))
|
||||
.flat_map(|l| l.children().into_iter().flatten())
|
||||
.filter(|n| n.visible && !n.locked)
|
||||
.map(|n| n.id)
|
||||
.collect();
|
||||
// The layers or sublayers the selected objects are on (art in their sublayers too).
|
||||
let layers: BTreeSet<NodeId> = st.selection.objects.iter().filter_map(|id| st.doc.layer_containing(*id)).collect();
|
||||
let ids: Vec<NodeId> = layers.iter().filter_map(|l| st.doc.node(*l)).flat_map(|l| l.layer_art(true)).collect();
|
||||
s.select(|_, sel| sel.set(ids.iter().copied()))?;
|
||||
ok()
|
||||
}
|
||||
|
||||
@@ -372,7 +372,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
kind,
|
||||
xf: Affine::IDENTITY,
|
||||
runs: vec![TextRun { text, style }],
|
||||
para: Default::default(),
|
||||
para: super::create::new_type_para(),
|
||||
area: Default::default(),
|
||||
path_effect: Default::default(),
|
||||
wrap: Vec::new(),
|
||||
|
||||
@@ -115,12 +115,20 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
// ---------- attribute plumbing ----------
|
||||
|
||||
/// `v` as a JSON map with one key per attribute: Auto alignment, which files save as its
|
||||
/// direction's alignment plus `justify_auto` (see `vectorcraft_doc::ParaStyle`), is `"justify":
|
||||
/// "Auto"`, so a style's `justify` replaces it.
|
||||
fn to_map<T: Serialize>(v: &T) -> Option<Map<String, Value>> {
|
||||
let Ok(Value::Object(mut m)) = serde_json::to_value(v) else { return None };
|
||||
if m.remove("justify_auto") == Some(Value::Bool(true)) {
|
||||
m.insert("justify".into(), Value::from("Auto"));
|
||||
}
|
||||
Some(m)
|
||||
}
|
||||
|
||||
/// A style's attributes as a JSON map, without the style name.
|
||||
fn to_attrs<T: Serialize>(v: &T) -> Map<String, Value> {
|
||||
let mut m = match serde_json::to_value(v) {
|
||||
Ok(Value::Object(m)) => m,
|
||||
_ => Map::new(),
|
||||
};
|
||||
let mut m = to_map(v).unwrap_or_default();
|
||||
m.remove("style_name");
|
||||
m
|
||||
}
|
||||
@@ -128,7 +136,7 @@ fn to_attrs<T: Serialize>(v: &T) -> Map<String, Value> {
|
||||
/// `base` with `attrs` written over it (unknown or ill-typed attributes are an error).
|
||||
fn with_attrs<T: Serialize + DeserializeOwned>(base: &T, attrs: &Map<String, Value>, cmd: &str) -> Result<T> {
|
||||
let mut m = to_attrs(base);
|
||||
if let Ok(Value::Object(full)) = serde_json::to_value(base) {
|
||||
if let Some(full) = to_map(base) {
|
||||
m.insert("style_name".into(), full.get("style_name").cloned().unwrap_or(Value::Null));
|
||||
}
|
||||
for (k, v) in attrs {
|
||||
@@ -144,7 +152,7 @@ fn with_attrs<T: Serialize + DeserializeOwned>(base: &T, attrs: &Map<String, Val
|
||||
/// value (or not set by it) take the new value; overrides stay. Unchanged if the style doesn't
|
||||
/// round-trip through JSON.
|
||||
fn restyle<T: Serialize + DeserializeOwned + Clone>(cur: &T, old: &Map<String, Value>, new: &Map<String, Value>) -> T {
|
||||
let Ok(Value::Object(mut m)) = serde_json::to_value(cur) else { return cur.clone() };
|
||||
let Some(mut m) = to_map(cur) else { return cur.clone() };
|
||||
for (k, v) in new {
|
||||
if old.get(k).is_none_or(|o| m.get(k) == Some(o)) {
|
||||
m.insert(k.clone(), v.clone());
|
||||
|
||||
@@ -75,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Character",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?, font?, style?, size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, features?: [\"dlig\", \"-liga\", …] OpenType}",
|
||||
"{ids?|id?, font?, style?, size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, features?: [\"dlig\", \"-liga\", …] OpenType}",
|
||||
has_doc,
|
||||
set_style
|
||||
),
|
||||
@@ -190,6 +190,7 @@ fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn justify_param(v: &str) -> Option<Justify> {
|
||||
Some(match v.to_ascii_lowercase().as_str() {
|
||||
"auto" => Justify::Auto,
|
||||
"left" => Justify::Left,
|
||||
"center" => Justify::Center,
|
||||
"right" => Justify::Right,
|
||||
@@ -216,7 +217,7 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let tracking = num_param(p, "tracking");
|
||||
let justify = match str_param(p, "justify") {
|
||||
Some(j) => Some(justify_param(j).ok_or_else(|| bad(C, "justify must be left|center|right|justifyAll"))?),
|
||||
Some(j) => Some(justify_param(j).ok_or_else(|| bad(C, "justify must be auto|left|center|right|justifyAll"))?),
|
||||
None => None,
|
||||
};
|
||||
let fill = match p.get("fill") {
|
||||
@@ -407,6 +408,31 @@ fn reshape_area(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
mod area_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn new_type_is_composed_with_line_end_half_width_punctuation() {
|
||||
use vectorcraft_doc::Mojikumi;
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "雅楽。"})).unwrap()["id"].as_u64().unwrap();
|
||||
let para = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => t.para.clone(),
|
||||
_ => panic!("text"),
|
||||
};
|
||||
assert_eq!(para(&s).mojikumi, Mojikumi::LineEndHalf);
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
assert!(s.execute("text.setFormat", &json!({"mojikumi": "everything"})).is_err());
|
||||
s.execute("text.setFormat", &json!({"mojikumi": "none"})).unwrap();
|
||||
assert_eq!(para(&s).mojikumi, Mojikumi::None);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(para(&s).mojikumi, Mojikumi::LineEndHalf);
|
||||
// Saved only when set; documents from before it read as None.
|
||||
let json = serde_json::to_value(para(&s)).unwrap();
|
||||
assert_eq!(json["mojikumi"], "lineEndHalf");
|
||||
let old: vectorcraft_doc::ParaStyle = serde_json::from_value(json!({"justify": "Left"})).unwrap();
|
||||
assert_eq!(old.mojikumi, Mojikumi::None);
|
||||
assert!(serde_json::to_value(&old).unwrap().get("mojikumi").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_point_type_keeps_its_anchor_on_the_column_centre_line() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -275,6 +275,7 @@ fn to_area(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (sl, sr) = match t.para.justify {
|
||||
Justify::Center | Justify::JustifyCenter => (slack * 0.5, slack * 0.5),
|
||||
Justify::Right | Justify::JustifyRight => (slack, 0.0),
|
||||
Justify::Auto if lay.lines.first().is_some_and(|l| l.rtl) => (slack, 0.0),
|
||||
_ => (0.0, slack),
|
||||
};
|
||||
let frame = Rect::new(b.x0 - sl, top, b.x1 + sr, b.y1.max(top + 1.0));
|
||||
@@ -330,6 +331,7 @@ fn to_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ox = match t.para.justify {
|
||||
Justify::Center | Justify::JustifyCenter => (x0 + x1) * 0.5,
|
||||
Justify::Right | Justify::JustifyRight => x1,
|
||||
Justify::Auto if lay.lines.first().is_some_and(|l| l.rtl) => x1,
|
||||
_ => x0,
|
||||
};
|
||||
t.kind = TextKind::Point;
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{Node, NodeId, NodeKind};
|
||||
use vectorcraft_doc::{Node, NodeKind};
|
||||
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
@@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Move Artboard",
|
||||
[],
|
||||
None,
|
||||
"{index, dx, dy, moveArt?: bool} move an artboard (and the unlocked art fully inside it)",
|
||||
"{index, dx, dy, moveArt?: bool, copy?: bool} move an artboard (and the unlocked art fully inside it); `copy` (Alt-drag) leaves them and moves copies → {index, moved: the art moved or the copies}",
|
||||
has_doc,
|
||||
artboard_move
|
||||
),
|
||||
@@ -374,40 +374,32 @@ fn artboard_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let move_art = bool_or(p, "moveArt", false);
|
||||
let st = s.doc()?;
|
||||
let rect = st.doc.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
// Top-level objects (children of layers) lying entirely inside the artboard.
|
||||
let mut art = vec![];
|
||||
if move_art {
|
||||
fn collect(n: &Node, rect: Rect, out: &mut Vec<NodeId>) {
|
||||
for c in n.children().into_iter().flatten() {
|
||||
if c.locked {
|
||||
continue;
|
||||
}
|
||||
if c.is_layer() {
|
||||
collect(c, rect, out);
|
||||
} else if let Some(b) = c.geometric_bounds()
|
||||
&& rect.contains(Point::new(b.x0, b.y0))
|
||||
&& rect.contains(Point::new(b.x1, b.y1))
|
||||
{
|
||||
out.push(c.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
for l in &st.doc.layers {
|
||||
if !l.locked {
|
||||
collect(l, rect, &mut art);
|
||||
}
|
||||
}
|
||||
}
|
||||
let art = if move_art { st.doc.art_on_artboard(rect) } else { vec![] };
|
||||
let scale_strokes = s.prefs.scale_strokes;
|
||||
s.edit("Move Artboard", |d, _| {
|
||||
let a = d.artboards.get_mut(i).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
a.rect = a.rect + dv;
|
||||
for id in &art {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
n.transform(Affine::translate(dv), scale_strokes);
|
||||
let copy = bool_or(p, "copy", false);
|
||||
let (index, moved) = s.edit(if copy { "Duplicate Artboard" } else { "Move Artboard" }, |d, _| {
|
||||
let src = d.artboards.get(i).cloned().ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
// A copy keeps the artboard (and its art) where they are and moves duplicates instead.
|
||||
if !copy {
|
||||
if let Some(a) = d.artboards.get_mut(i) {
|
||||
a.rect = src.rect + dv;
|
||||
}
|
||||
for id in &art {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
n.transform(Affine::translate(dv), scale_strokes);
|
||||
}
|
||||
}
|
||||
return Ok((i, art.clone()));
|
||||
}
|
||||
Ok(())
|
||||
let index = super::panelcmds::push_artboard_copy(d, &src, src.rect + dv);
|
||||
let mut copies = Vec::with_capacity(art.len());
|
||||
for id in &art {
|
||||
let (Some((parent, at, _)), Some(n)) = (d.position(*id), d.node(*id).cloned()) else { continue };
|
||||
let mut c = d.reid(&n);
|
||||
c.transform(Affine::translate(dv), scale_strokes);
|
||||
copies.push(d.insert(parent, at + 1, c)?);
|
||||
}
|
||||
Ok((index, copies))
|
||||
})?;
|
||||
Ok(json!({ "index": i, "moved": art.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
Ok(json!({ "index": index, "moved": moved.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
@@ -52,6 +52,15 @@ pub fn node_summary(n: &Node) -> Value {
|
||||
v["strokeOptions"] = stroke_options(&st);
|
||||
}
|
||||
match &n.kind {
|
||||
NodeKind::Layer { color, template, printable, preview, dim_images, clip, .. } => {
|
||||
let [r, g, b] = color.rgb();
|
||||
v["color"] = json!(format!("#{r:02x}{g:02x}{b:02x}"));
|
||||
v["template"] = json!(template);
|
||||
v["printable"] = json!(printable);
|
||||
v["preview"] = json!(preview);
|
||||
v["dimImages"] = json!(dim_images);
|
||||
v["clip"] = json!(clip);
|
||||
}
|
||||
NodeKind::Path { path, .. } => {
|
||||
v["anchors"] = json!(path.anchor_count());
|
||||
v["closed"] = json!(path.is_closed());
|
||||
@@ -79,6 +88,8 @@ pub fn document(s: &Session) -> Value {
|
||||
// Top of the stack first, like the Layers panel.
|
||||
"layers": d.layers.iter().rev().map(|l| node_summary(l)).collect::<Vec<_>>(),
|
||||
"currentLayer": st.active_layer.map(|l| l.0),
|
||||
// The rows highlighted in the Layers panel (`layer.setCurrent`, `layer.highlight`).
|
||||
"layerRows": st.highlighted_rows().iter().map(|i| i.0).collect::<Vec<_>>(),
|
||||
"isolation": st.isolation.map(|l| l.0),
|
||||
"selection": st.selection.objects.iter().map(|i| i.0).collect::<Vec<_>>(),
|
||||
"selectionBounds": rect_json(d.bounds_of(&st.selection.objects, false)),
|
||||
|
||||
@@ -102,6 +102,11 @@ pub struct DocState {
|
||||
saved_doc: Arc<Document>,
|
||||
/// The layer new art goes into (the "current layer" in the Layers panel).
|
||||
pub active_layer: Option<NodeId>,
|
||||
/// The rows highlighted in the Layers panel (layers, sublayers, groups or objects, in the order
|
||||
/// they were clicked): what the panel's Duplicate, Delete, Merge, Options… and similar act on.
|
||||
/// Panel state, not art selection: not saved, not undoable (`layer.setCurrent`,
|
||||
/// `layer.highlight`).
|
||||
pub layer_rows: Vec<NodeId>,
|
||||
/// Isolation mode container.
|
||||
pub isolation: Option<NodeId>,
|
||||
pub interaction: Option<Interaction>,
|
||||
@@ -156,6 +161,7 @@ impl DocState {
|
||||
path,
|
||||
revision: 1,
|
||||
active_layer,
|
||||
layer_rows: vec![],
|
||||
isolation: None,
|
||||
interaction: None,
|
||||
last_transform: None,
|
||||
@@ -236,7 +242,13 @@ impl DocState {
|
||||
{
|
||||
return Some(i);
|
||||
}
|
||||
self.active_layer.filter(|l| self.doc.node(*l).is_some_and(|n| n.is_layer() && !n.locked)).or_else(|| self.doc.default_layer())
|
||||
// A sublayer takes new art only while it and the layers around it are shown and unlocked.
|
||||
self.active_layer.filter(|l| self.doc.node(*l).is_some_and(|n| n.is_layer()) && self.doc.is_editable(*l)).or_else(|| self.doc.default_layer())
|
||||
}
|
||||
/// The highlighted Layers panel rows that still exist (ids are reused after undo, so a
|
||||
/// remembered row must still be in the document).
|
||||
pub fn highlighted_rows(&self) -> Vec<NodeId> {
|
||||
self.layer_rows.iter().copied().filter(|id| self.doc.node(*id).is_some()).collect()
|
||||
}
|
||||
/// The object whose opacity mask View Opacity Mask shows ([`DocState::mask_view`]): only while
|
||||
/// its mask is being edited, so leaving editing by any route (undo, deleting the object) ends it.
|
||||
@@ -397,6 +409,12 @@ pub struct Prefs {
|
||||
// Performance
|
||||
pub gpu_performance: bool,
|
||||
pub animated_zoom: bool,
|
||||
/// Which graphics processor the desktop app asks for at startup (it takes effect after a
|
||||
/// restart): `powerSaving` (the integrated GPU on hybrid-graphics laptops) or `highPerformance`
|
||||
/// (the discrete one). The canvas is rasterized on the CPU and only composited on the GPU, so
|
||||
/// the integrated GPU is plenty; presenting from the discrete GPU through the integrated one
|
||||
/// made some hybrid laptops flicker (#306). Single-GPU machines are unaffected.
|
||||
pub gpu_preference: String,
|
||||
pub history_states: u32,
|
||||
pub real_time_drawing: bool,
|
||||
/// Rasterizer worker threads; -1 = automatic.
|
||||
@@ -598,6 +616,7 @@ impl Default for Prefs {
|
||||
interface_language: s("auto"),
|
||||
gpu_performance: true,
|
||||
animated_zoom: true,
|
||||
gpu_preference: s("powerSaving"),
|
||||
history_states: 500,
|
||||
real_time_drawing: true,
|
||||
render_threads: -1,
|
||||
@@ -1062,8 +1081,18 @@ impl Session {
|
||||
pub fn select(&mut self, f: impl FnOnce(&Document, &mut Selection)) -> Result<()> {
|
||||
self.active_appearance_item = None;
|
||||
let st = self.doc_mut()?;
|
||||
let before = st.selection.objects.clone();
|
||||
f(&st.doc, &mut st.selection);
|
||||
st.selection.prune(&st.doc);
|
||||
// Selecting art makes its layer (or sublayer) the current one, as in the Layers panel of
|
||||
// the reference app: new art then goes beside it.
|
||||
if st.selection.objects != before
|
||||
&& let Some(layer) = st.selection.objects.last().and_then(|id| st.doc.layer_containing(*id)).filter(|l| st.doc.is_editable(*l))
|
||||
&& st.active_layer != Some(layer)
|
||||
{
|
||||
st.active_layer = Some(layer);
|
||||
st.layer_rows.clear();
|
||||
}
|
||||
st.revision += 1;
|
||||
// Puppet Warp pins belong to the art they were placed on: another selection starts afresh.
|
||||
if st.doc.puppet.as_ref().is_some_and(|p| p.ids != st.selection.objects) {
|
||||
@@ -1260,6 +1289,8 @@ mod tests_labspots;
|
||||
#[cfg(test)]
|
||||
mod tests_layerclip;
|
||||
#[cfg(test)]
|
||||
mod tests_layers;
|
||||
#[cfg(test)]
|
||||
mod tests_linked_stops;
|
||||
#[cfg(test)]
|
||||
mod tests_links;
|
||||
|
||||
@@ -252,6 +252,33 @@ fn pen_tool_draws_closed_path() {
|
||||
assert!(p.is_closed());
|
||||
}
|
||||
|
||||
/// Auto Add/Delete: a Pen click on a selected path's segment adds an anchor, unless General →
|
||||
/// Disable Auto Add/Delete is on.
|
||||
#[test]
|
||||
fn pen_click_on_a_selected_path_adds_an_anchor_unless_disabled() {
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
let r = s.execute("path.create", &json!({"anchors": [{"x": 10, "y": 100}, {"x": 110, "y": 100}, {"x": 210, "y": 100}]})).unwrap();
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
s.select_tool("pen", v).unwrap();
|
||||
let click = |s: &mut Session| {
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 60.0, 101.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 60.0, 101.0), v).unwrap();
|
||||
};
|
||||
click(&mut s);
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(d.layers[0].children().unwrap().len(), 1);
|
||||
assert_eq!(d.node(id).unwrap().path_data().unwrap().anchor_count(), 4);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("prefs.set", &json!({"key": "disableAutoAddDelete", "value": true})).unwrap();
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
click(&mut s);
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(d.layers[0].children().unwrap().len(), 2, "a new path starts");
|
||||
assert_eq!(d.node(id).unwrap().path_data().unwrap().anchor_count(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_selection_moves_one_anchor() {
|
||||
let mut s = session();
|
||||
@@ -267,6 +294,43 @@ fn direct_selection_moves_one_anchor() {
|
||||
assert_eq!(p.subpaths[0].anchors[1].p, vectorcraft_geom::Point::new(200.0, 100.0));
|
||||
}
|
||||
|
||||
/// Dragging a curved segment with Direct Selection bends it (its anchors stay), and dragging a
|
||||
/// straight one moves its two anchors, not the whole path; each is one undo step.
|
||||
#[test]
|
||||
fn direct_selection_drags_segments() {
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
let drag = |s: &mut Session, from: (f64, f64), to: (f64, f64)| {
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, from.0, from.1), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, to.0, to.1), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, to.0, to.1), v).unwrap();
|
||||
};
|
||||
let arch = &json!({"anchors": [{"x": 100, "y": 200, "out": [100, 100]}, {"x": 300, "y": 200, "in": [300, 100]}]});
|
||||
let arch = NodeId(s.execute("path.create", arch).unwrap()["id"].as_u64().unwrap());
|
||||
let square = &json!({"anchors": [{"x": 150, "y": 400}, {"x": 150, "y": 300}, {"x": 250, "y": 300}, {"x": 250, "y": 400}], "closed": true});
|
||||
let square = NodeId(s.execute("path.create", square).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
s.select_tool("directSelection", v).unwrap();
|
||||
let steps = |s: &Session| s.doc().unwrap().history.undo.len();
|
||||
let before = steps(&s);
|
||||
// The arch's middle, 40 up: the curve bends, its two anchors stay.
|
||||
drag(&mut s, (200.0, 125.0), (200.0, 85.0));
|
||||
let sp = s.doc().unwrap().doc.node(arch).unwrap().path_data().unwrap().subpaths[0].clone();
|
||||
let (a, b) = (sp.anchors[0], sp.anchors[1]);
|
||||
assert_eq!((a.p, b.p), (vectorcraft_geom::Point::new(100.0, 200.0), vectorcraft_geom::Point::new(300.0, 200.0)), "the anchors stay");
|
||||
assert!(a.h_out.y < 100.0 && b.h_in.y < 100.0, "the curve bent up: {a:?} {b:?}");
|
||||
assert_eq!(steps(&s), before + 1);
|
||||
// The square's top edge, 40 up: its two anchors move, the bottom two stay.
|
||||
drag(&mut s, (200.0, 300.0), (200.0, 260.0));
|
||||
let sq = s.doc().unwrap().doc.node(square).unwrap().geometric_bounds().unwrap();
|
||||
assert_eq!((sq.y0, sq.y1, sq.x0, sq.x1), (260.0, 400.0, 150.0, 250.0), "the edge moved, the rest stayed");
|
||||
assert_eq!(steps(&s), before + 2);
|
||||
// Its fill still moves the whole square.
|
||||
drag(&mut s, (200.0, 350.0), (200.0, 330.0));
|
||||
let sq = s.doc().unwrap().doc.node(square).unwrap().geometric_bounds().unwrap();
|
||||
assert_eq!((sq.y0, sq.y1), (240.0, 380.0), "the whole path moved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_create_has_bounds() {
|
||||
let mut s = session();
|
||||
@@ -285,6 +349,21 @@ fn inspect_lists_everything() {
|
||||
assert!(s.commands().len() > 100);
|
||||
}
|
||||
|
||||
/// `document.node {summary: true}` answers the `document.inspect` shape for one node:
|
||||
/// the same fields the full-tree summary carries, without serializing the whole tree.
|
||||
#[test]
|
||||
fn document_node_summary_matches_inspect() {
|
||||
let mut s = session();
|
||||
let id = rect(&mut s, 0.0, 0.0, 10.0, 10.0).0;
|
||||
let full = s.execute("document.node", &json!({"id": id})).unwrap();
|
||||
assert!(full.get("bounds").is_none(), "full object JSON carries geometry, not bounds: {full}");
|
||||
let summary = s.execute("document.node", &json!({"id": id, "summary": true})).unwrap();
|
||||
let v = s.execute("document.inspect", &json!({})).unwrap();
|
||||
let node = v["layers"][0]["children"].as_array().unwrap().iter().find(|n| n["id"] == id).cloned().unwrap();
|
||||
assert_eq!(summary, node, "{summary} vs {node}");
|
||||
assert!(s.execute("document.node", &json!({"id": 999999, "summary": true})).is_err());
|
||||
}
|
||||
|
||||
/// Type reports the paint its characters show, and its own object-level paint apart from it.
|
||||
#[test]
|
||||
fn inspect_reports_the_paint_of_types_characters() {
|
||||
@@ -543,3 +622,20 @@ fn a_panicking_command_rolls_back_instead_of_crashing() {
|
||||
assert!(s.doc().unwrap().doc.node(b).is_some());
|
||||
assert_eq!(s.journal.last().map(|j| j.0.as_str()), Some("shape.rectangle"));
|
||||
}
|
||||
|
||||
/// New layers and sublayers share one "Layer N" numbering, whatever art the document holds, and
|
||||
/// never repeat a name.
|
||||
#[test]
|
||||
fn new_layers_and_sublayers_are_numbered_together() {
|
||||
let name = |s: &Session, id: &Value| s.doc().unwrap().doc.node(NodeId(id["id"].as_u64().unwrap())).unwrap().display_name().to_string();
|
||||
let mut s = session();
|
||||
let sub = s.execute("layer.newSublayer", &json!({})).unwrap();
|
||||
assert_eq!(name(&s, &sub), "Layer 2", "not its parent's name");
|
||||
for x in [0.0, 60.0, 120.0, 180.0, 240.0] {
|
||||
rect(&mut s, x, 0.0, 50.0, 50.0);
|
||||
}
|
||||
let layer = s.execute("layer.new", &json!({})).unwrap();
|
||||
assert_eq!(name(&s, &layer), "Layer 3", "after the sublayer, not a repeat of it");
|
||||
let sub = s.execute("layer.newSublayer", &json!({})).unwrap();
|
||||
assert_eq!(name(&s, &sub), "Layer 4", "the art doesn't count");
|
||||
}
|
||||
@@ -247,6 +247,42 @@ fn grid_show_and_plane_are_view_state() {
|
||||
assert!(s.overlays(ViewInfo::default()).len() > 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_grid_hides_while_a_perspective_tool_is_chosen() {
|
||||
// #321: choosing the Perspective Grid tool showed the grid for good.
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
for tool in ["perspectiveGrid", "perspectiveSelection"] {
|
||||
s.select_tool(tool, v).unwrap();
|
||||
assert!(s.overlays(v).len() > 20, "{tool}");
|
||||
let n = undo_len(&s);
|
||||
assert_eq!(s.execute("perspective.grid.show", &json!({})).unwrap(), json!({"visible": false}));
|
||||
assert!(s.overlays(v).is_empty(), "{tool}: the hidden grid still draws");
|
||||
assert_eq!(undo_len(&s), n);
|
||||
// Choosing the tool again shows it again.
|
||||
s.select_tool(tool, v).unwrap();
|
||||
assert!(s.overlays(v).len() > 20, "{tool}");
|
||||
s.execute("perspective.grid.show", &json!({"visible": false})).unwrap();
|
||||
s.select_tool("selection", v).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hidden_grid_stays_hidden_back_from_a_temporary_tool() {
|
||||
// Cmd-dragging with a perspective tool borrows the Selection tool; going back to the
|
||||
// perspective tool is not choosing it, so the grid hidden meanwhile stays hidden.
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("perspectiveGrid", v).unwrap();
|
||||
s.execute("perspective.grid.show", &json!({"visible": false})).unwrap();
|
||||
s.switch_tool("selection", v).unwrap();
|
||||
s.switch_tool("perspectiveGrid", v).unwrap();
|
||||
assert!(s.overlays(v).is_empty());
|
||||
s.select_tool("selection", v).unwrap();
|
||||
s.select_tool("perspectiveGrid", v).unwrap();
|
||||
assert!(s.overlays(v).len() > 20, "choosing it shows the grid");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_preset_and_set_are_undoable() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -375,6 +375,7 @@ fn draw2_commands_reject_bad_params() {
|
||||
("path.freehand", json!({"points": [[1, 2]]})),
|
||||
("path.curvature", json!({"points": []})),
|
||||
("path.removeAnchor", json!({"id": id.0, "anchor": 99})),
|
||||
("path.removeAnchors", json!({})),
|
||||
("path.convertAnchor", json!({"id": 9999, "anchor": 0, "to": "corner"})),
|
||||
("path.reshapeSegment", json!({"id": id.0, "segment": 42})),
|
||||
("path.split", json!({"id": id.0})),
|
||||
@@ -389,3 +390,147 @@ fn draw2_commands_reject_bad_params() {
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), before, "{cmd} left an undo step");
|
||||
}
|
||||
}
|
||||
|
||||
/// A curve from (100, 100) to (300, 100) whose first anchor has an out handle at (150, 100).
|
||||
fn curve(s: &mut Session) -> NodeId {
|
||||
let anchors = json!([{"x": 100, "y": 100, "out": [150, 100]}, {"x": 300, "y": 100, "in": [250, 100]}]);
|
||||
NodeId(s.execute("path.create", &json!({"anchors": anchors})).unwrap()["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
/// Drag the out handle of path `id`'s first anchor with `tool` to `to`, holding `mods`. Direct
|
||||
/// Selection clicks the anchor first, so its handles show.
|
||||
fn drag_handle(s: &mut Session, tool: &str, id: NodeId, to: (f64, f64), mods: Mods, v: ViewInfo) {
|
||||
let from = path(s, id).subpaths[0].anchors[0].h_out;
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.select_tool(tool, v).unwrap();
|
||||
if tool == "directSelection" {
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 100.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 100.0, 100.0), v).unwrap();
|
||||
}
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, from.x, from.y).with_mods(mods), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, to.0, to.1).with_mods(mods), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, to.0, to.1).with_mods(mods), v).unwrap();
|
||||
assert!(!s.in_interaction());
|
||||
}
|
||||
|
||||
/// The out handle of path `id`'s first anchor.
|
||||
fn out_handle(s: &Session, id: NodeId) -> Point {
|
||||
path(s, id).subpaths[0].anchors[0].h_out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_keeps_a_dragged_handle_at_45_degree_steps() {
|
||||
// #322.
|
||||
let shift = Mods { shift: true, ..Mods::default() };
|
||||
for tool in ["directSelection", "anchorPoint"] {
|
||||
let mut s = session();
|
||||
let id = curve(&mut s);
|
||||
drag_handle(&mut s, tool, id, (190.0, 130.0), shift, view());
|
||||
let h = out_handle(&s, id);
|
||||
assert!(near(h, Point::new(100.0 + 90.0f64.hypot(30.0), 100.0)), "{tool}: {h:?}");
|
||||
drag_handle(&mut s, tool, id, (160.0, 155.0), shift, view());
|
||||
let h = out_handle(&s, id);
|
||||
assert!((h.x - h.y).abs() < 1e-6 && h.x > 100.0, "{tool}: 45°: {h:?}");
|
||||
// Without Shift the handle goes where it is dragged.
|
||||
drag_handle(&mut s, tool, id, (190.0, 130.0), Mods::default(), view());
|
||||
assert_eq!(out_handle(&s, id), Point::new(190.0, 130.0), "{tool}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dragged_handle_snaps_to_smart_guides() {
|
||||
// #322: in line with the other anchor, with smart guides on.
|
||||
for tool in ["directSelection", "anchorPoint"] {
|
||||
let mut s = session();
|
||||
let id = curve(&mut s);
|
||||
drag_handle(&mut s, tool, id, (298.0, 160.0), Mods::default(), ViewInfo::default());
|
||||
assert_eq!(out_handle(&s, id), Point::new(300.0, 160.0), "{tool}");
|
||||
drag_handle(&mut s, tool, id, (298.0, 170.0), Mods::default(), view());
|
||||
assert_eq!(out_handle(&s, id), Point::new(298.0, 170.0), "{tool}: smart guides off");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_drag_keeps_a_smooth_anchor_smooth_and_alt_breaks_it() {
|
||||
// #322: the opposite handle of a smooth anchor turns with the constrained one; Alt still
|
||||
// moves the dragged handle alone.
|
||||
let shift = Mods { shift: true, ..Mods::default() };
|
||||
let shift_alt = Mods { shift: true, alt: true, ..Mods::default() };
|
||||
for (mods, opposite_follows) in [(shift, true), (shift_alt, false)] {
|
||||
let mut s = session();
|
||||
let anchors = json!([{"x": 100, "y": 200}, {"x": 200, "y": 100, "in": [150, 100], "out": [250, 100]}, {"x": 300, "y": 200}]);
|
||||
let id = NodeId(s.execute("path.create", &json!({"anchors": anchors})).unwrap()["id"].as_u64().unwrap());
|
||||
s.select_tool("directSelection", view()).unwrap();
|
||||
for (kind, x, y, m) in [
|
||||
(PointerKind::Down, 200.0, 100.0, Mods::default()),
|
||||
(PointerKind::Up, 200.0, 100.0, Mods::default()),
|
||||
(PointerKind::Down, 250.0, 100.0, mods),
|
||||
(PointerKind::Drag, 275.0, 165.0, mods),
|
||||
(PointerKind::Up, 275.0, 165.0, mods),
|
||||
] {
|
||||
s.pointer(&PointerEvent::new(kind, x, y).with_mods(m), view()).unwrap();
|
||||
}
|
||||
let a = path(&s, id).subpaths[0].anchors[1];
|
||||
let (o, i) = (a.h_out - a.p, a.h_in - a.p);
|
||||
assert!((o.x - o.y).abs() < 1e-6 && o.x > 0.0, "{mods:?}: 45°: {o:?}");
|
||||
if opposite_follows {
|
||||
assert!(near(a.h_in, a.p - o.normalize() * 50.0), "{mods:?}: {i:?}");
|
||||
} else {
|
||||
assert_eq!(a.h_in, Point::new(150.0, 100.0), "{mods:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_an_anchor_refits_a_split_curve_and_undo_restores_it() {
|
||||
let mut s = session();
|
||||
let made = s
|
||||
.execute(
|
||||
"path.create",
|
||||
&json!({"anchors": [
|
||||
{"x": 0, "y": 0, "out": [30, 80]},
|
||||
{"x": 100, "y": 0, "in": [70, 80]}
|
||||
]}),
|
||||
)
|
||||
.unwrap();
|
||||
let id = NodeId(made["id"].as_u64().unwrap());
|
||||
let before = path(&s, id);
|
||||
let inserted = s.execute("path.insertAnchor", &json!({"id": id.0, "segment": 0, "t": 0.4})).unwrap();
|
||||
let ai = inserted["anchor"].as_u64().unwrap();
|
||||
s.execute("path.removeAnchor", &json!({"id": id.0, "anchor": ai})).unwrap();
|
||||
let after = path(&s, id);
|
||||
assert_eq!(after.subpaths[0].anchors.len(), 2);
|
||||
let (got, orig) = (&after.subpaths[0].anchors, &before.subpaths[0].anchors);
|
||||
assert!(got[0].h_out.distance(orig[0].h_out) < 0.5, "{:?}", got[0].h_out);
|
||||
assert!(got[1].h_in.distance(orig[1].h_in) < 0.5, "{:?}", got[1].h_in);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(path(&s, id).subpaths[0].anchors.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_anchor_points_keeps_the_paths_closed() {
|
||||
// Object › Path › Remove Anchor Points, unlike the Delete key: a rectangle loses a corner and
|
||||
// stays closed, a straight-sided triangle; a curve keeps its shape round a removed point.
|
||||
let mut s = session();
|
||||
let r = rect(&mut s, 0.0, 0.0, 100.0, 80.0);
|
||||
let made = s.execute("path.create", &json!({"anchors": [{"x": 300, "y": 0, "out": [330, 80]}, {"x": 400, "y": 0, "in": [370, 80]}]})).unwrap();
|
||||
let c = NodeId(made["id"].as_u64().unwrap());
|
||||
let before = path(&s, c).subpaths[0].segment(0);
|
||||
s.execute("path.insertAnchor", &json!({"id": c.0, "segment": 0, "t": 0.5})).unwrap();
|
||||
s.execute("select.anchors", &json!({"id": r.0, "anchors": [[0, 0]]})).unwrap();
|
||||
s.execute("select.anchors", &json!({"id": c.0, "anchors": [[0, 1]], "mode": "add"})).unwrap();
|
||||
assert_eq!(s.execute("path.removeAnchors", &json!({})).unwrap()["removedObjects"], 0);
|
||||
let sp = &path(&s, r).subpaths[0];
|
||||
assert!(sp.closed && sp.anchors.len() == 3, "{sp:?}");
|
||||
assert!(!sp.anchors.iter().any(|a| a.has_in() || a.has_out()), "straight sides stay straight: {sp:?}");
|
||||
let after = path(&s, c).subpaths[0].segment(0);
|
||||
assert_eq!(path(&s, c).subpaths[0].anchors.len(), 2);
|
||||
assert!(after.p1.distance(before.p1) < 0.5 && after.p2.distance(before.p2) < 0.5, "{after:?}");
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Remove Anchor Points");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(path(&s, r).subpaths[0].anchors.len(), 4);
|
||||
assert_eq!(path(&s, c).subpaths[0].anchors.len(), 3);
|
||||
let spec = find_command("path.removeAnchors").unwrap();
|
||||
assert_eq!(spec.menu, &["Object", "Path"][..]);
|
||||
}
|
||||
@@ -100,9 +100,10 @@ fn type_picks_up_font_and_size() {
|
||||
assert!(width(&s, dst) > before, "laid out again at the new size");
|
||||
// Without character or paragraph attributes only the paints go.
|
||||
let dst2 = id_of(s.execute("text.create", &json!({"x": 10, "y": 500, "text": "Other"})).unwrap());
|
||||
let preserved_alignment = style(&s, dst2).1;
|
||||
s.execute("appearance.copyFrom", &json!({"source": src.0, "pickUp": {"character": false, "paragraph": false}})).unwrap();
|
||||
let (st, justify) = style(&s, dst2);
|
||||
assert_eq!((st.size, justify, st.fill.color().unwrap().to_hex()), (12.0, Justify::Left, "#00ff00".into()));
|
||||
assert_eq!((st.size, justify, st.fill.color().unwrap().to_hex()), (12.0, preserved_alignment, "#00ff00".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -115,8 +115,8 @@ fn the_top_object_must_be_able_to_clip() {
|
||||
assert_eq!(layer(&s), before);
|
||||
// An explicit id picks the layer even with a group selected.
|
||||
let l = before.id;
|
||||
s.execute("layer.newSublayer", &json!({"parent": l.0})).unwrap();
|
||||
let sub = layer(&s).children().unwrap().last().unwrap().id;
|
||||
let sub = id_of(&s.execute("layer.newSublayer", &json!({"parent": l.0})).unwrap());
|
||||
assert_eq!(s.doc().unwrap().doc.node(l).unwrap().children().unwrap().last().unwrap().id, sub, "on top of the layer's contents");
|
||||
let err = s.execute("layer.clippingMask.toggle", &json!({"id": sub.0}));
|
||||
assert!(err.is_err(), "an empty sublayer has nothing to clip by");
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
//! The Layers panel's commands: rows at every depth (layers, sublayers, groups, objects) are
|
||||
//! clickable, highlighted and targeted; their art is selected through the selection column; rows
|
||||
//! move, copy and reparent with validation; and the panel menu's operations (merge, flatten,
|
||||
//! release to layers, reverse, template, others/all) are single undo steps.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{LayerColor, Node, NodeId, NodeKind};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn id(v: &Value) -> NodeId {
|
||||
NodeId(v["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn ids(v: &Value) -> Vec<NodeId> {
|
||||
v["ids"].as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect()
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, x: f64) -> NodeId {
|
||||
id(&s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 20, "height": 20})).unwrap())
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
|
||||
}
|
||||
|
||||
fn node(s: &Session, id: NodeId) -> Node {
|
||||
s.doc().unwrap().doc.node(id).unwrap().clone()
|
||||
}
|
||||
|
||||
fn parent(s: &Session, id: NodeId) -> Option<NodeId> {
|
||||
s.doc().unwrap().doc.parent_of(id)
|
||||
}
|
||||
|
||||
fn children(s: &Session, id: NodeId) -> Vec<NodeId> {
|
||||
node(s, id).children().unwrap().iter().map(|c| c.id).collect()
|
||||
}
|
||||
|
||||
fn undos(s: &Session) -> usize {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
/// Layer 1 holding a sublayer `sub` with a rectangle `a` and a group `g` of rectangle `b`, plus a
|
||||
/// rectangle `c` on Layer 1 itself → (layer, sub, a, g, b, c). Nothing selected.
|
||||
fn tree(s: &mut Session) -> (NodeId, NodeId, NodeId, NodeId, NodeId, NodeId) {
|
||||
let layer = s.doc().unwrap().doc.layers[0].id;
|
||||
let c = rect(s, 300.0);
|
||||
let sub = id(&run(s, "layer.newSublayer", json!({"name": "Sub"})));
|
||||
let a = rect(s, 0.0);
|
||||
let b = rect(s, 100.0);
|
||||
run(s, "select.set", json!({"ids": [b.0]}));
|
||||
let g = id(&run(s, "object.group", json!({})));
|
||||
run(s, "select.none", json!({}));
|
||||
(layer, sub, a, g, b, c)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn art_in_a_sublayer_is_selected_as_objects_not_the_sublayer() {
|
||||
let mut s = session();
|
||||
let (_, sub, a, g, _, c) = tree(&mut s);
|
||||
assert_eq!(parent(&s, a), Some(sub), "new art went into the current sublayer");
|
||||
let n = run(&mut s, "select.all", json!({}))["count"].as_u64().unwrap();
|
||||
assert_eq!(n, 3);
|
||||
let sel = s.doc().unwrap().selection.objects.clone();
|
||||
assert!(sel.contains(&a) && sel.contains(&g) && sel.contains(&c) && !sel.contains(&sub), "{sel:?}");
|
||||
// The Selection tool's click takes the object in the sublayer.
|
||||
let h = vectorcraft_doc::hit::hit_test(&s.doc().unwrap().doc, vectorcraft_geom::Point::new(10.0, 10.0), Default::default()).unwrap();
|
||||
assert_eq!(h.top_object(None), a);
|
||||
// Deleting it works (layers were skipped by Clear before).
|
||||
run(&mut s, "select.set", json!({"ids": [a.0]}));
|
||||
run(&mut s, "edit.clear", json!({}));
|
||||
assert!(s.doc().unwrap().doc.node(a).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_rows_at_any_depth_highlights_them_and_sets_the_current_layer() {
|
||||
let mut s = session();
|
||||
let (layer, sub, a, g, b, c) = tree(&mut s);
|
||||
let before = undos(&s);
|
||||
for (row, current) in [(layer, layer), (sub, sub), (g, sub), (b, sub), (a, sub), (c, layer)] {
|
||||
let r = run(&mut s, "layer.setCurrent", json!({"id": row.0}));
|
||||
assert_eq!(NodeId(r["layer"].as_u64().unwrap()), current);
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!((st.highlighted_rows(), st.current_layer()), (vec![row], Some(current)));
|
||||
}
|
||||
assert_eq!(undos(&s), before, "clicking rows is not an undo step");
|
||||
// New art goes into the current sublayer.
|
||||
run(&mut s, "layer.setCurrent", json!({"id": a.0}));
|
||||
let n = rect(&mut s, 50.0);
|
||||
assert_eq!(parent(&s, n), Some(sub));
|
||||
assert!(s.execute("layer.setCurrent", &json!({"id": 9999})).is_err());
|
||||
// Shift/Ctrl: several rows.
|
||||
run(&mut s, "layer.highlight", json!({"ids": [a.0, g.0], "mode": "set"}));
|
||||
run(&mut s, "layer.highlight", json!({"ids": [c.0], "mode": "add"}));
|
||||
run(&mut s, "layer.highlight", json!({"ids": [a.0], "mode": "toggle"}));
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!((st.highlighted_rows(), st.current_layer()), (vec![g, c], Some(layer)));
|
||||
assert!(s.execute("layer.highlight", &json!({"ids": [a.0], "mode": "nope"})).is_err());
|
||||
assert_eq!(document_rows(&s), vec![g.0, c.0], "document.inspect lists the rows");
|
||||
}
|
||||
|
||||
fn document_rows(s: &Session) -> Vec<u64> {
|
||||
crate::inspect::document(s)["layerRows"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_art_makes_its_layer_current() {
|
||||
let mut s = session();
|
||||
let (layer, sub, a, _, _, c) = tree(&mut s);
|
||||
run(&mut s, "select.set", json!({"ids": [c.0]}));
|
||||
assert_eq!(s.doc().unwrap().current_layer(), Some(layer));
|
||||
run(&mut s, "select.set", json!({"ids": [a.0]}));
|
||||
assert_eq!(s.doc().unwrap().current_layer(), Some(sub));
|
||||
let n = rect(&mut s, 200.0);
|
||||
assert_eq!(parent(&s, n), Some(sub));
|
||||
// A locked sublayer takes no new art: it goes to the top layer.
|
||||
run(&mut s, "select.none", json!({}));
|
||||
run(&mut s, "layer.setProps", json!({"id": sub.0, "locked": true}));
|
||||
run(&mut s, "layer.setCurrent", json!({"id": sub.0}));
|
||||
let n = rect(&mut s, 200.0);
|
||||
assert_eq!(parent(&s, n), Some(layer));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_selection_column_selects_a_rows_art_and_shift_adds_or_removes_it() {
|
||||
let mut s = session();
|
||||
let (layer, sub, a, g, _, c) = tree(&mut s);
|
||||
run(&mut s, "layer.setCurrent", json!({"id": sub.0}));
|
||||
let sel = |s: &Session| s.doc().unwrap().selection.objects.clone();
|
||||
run(&mut s, "layer.selectAll", json!({"id": layer.0}));
|
||||
assert_eq!(sel(&s), vec![c, a, g], "the layer's art, its sublayer's too");
|
||||
run(&mut s, "layer.selectAll", json!({"id": sub.0}));
|
||||
assert_eq!(sel(&s), vec![a, g]);
|
||||
run(&mut s, "layer.selectAll", json!({"id": c.0, "add": true}));
|
||||
assert_eq!(sel(&s), vec![a, g, c]);
|
||||
run(&mut s, "layer.selectAll", json!({"id": sub.0, "add": true}));
|
||||
assert_eq!(sel(&s), vec![c], "all selected already: removed");
|
||||
assert_eq!(s.doc().unwrap().highlighted_rows(), vec![sub], "the highlighted row stays");
|
||||
// Locked rows select nothing.
|
||||
run(&mut s, "layer.setProps", json!({"id": sub.0, "locked": true}));
|
||||
run(&mut s, "layer.selectAll", json!({"id": a.0}));
|
||||
assert!(sel(&s).is_empty());
|
||||
// Targeting a layer selects its sublayers' art too.
|
||||
run(&mut s, "layer.setProps", json!({"id": sub.0, "locked": false}));
|
||||
assert_eq!(ids_sel(&run(&mut s, "layer.target", json!({"id": layer.0}))), vec![c, a, g]);
|
||||
}
|
||||
|
||||
fn ids_sel(v: &Value) -> Vec<NodeId> {
|
||||
v["selected"].as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visibility_and_lock_per_row_are_undoable() {
|
||||
let mut s = session();
|
||||
let (_, sub, a, g, b, _) = tree(&mut s);
|
||||
run(&mut s, "select.set", json!({"ids": [a.0, g.0]}));
|
||||
let before = undos(&s);
|
||||
run(&mut s, "layer.setProps", json!({"ids": [b.0, sub.0], "visible": false}));
|
||||
assert!(!node(&s, b).visible && !node(&s, sub).visible);
|
||||
assert!(s.doc().unwrap().selection.is_empty(), "hidden art is deselected");
|
||||
assert_eq!(undos(&s), before + 1, "one step for several rows");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(node(&s, b).visible && node(&s, sub).visible);
|
||||
run(&mut s, "layer.setProps", json!({"id": a.0, "locked": true, "name": "Box"}));
|
||||
assert!(node(&s, a).locked);
|
||||
assert_eq!(node(&s, a).display_name(), "Box");
|
||||
run(&mut s, "layer.setProps", json!({"id": a.0, "name": ""}));
|
||||
assert_eq!(node(&s, a).display_name(), "<Rectangle>", "an empty name goes back to the generated one");
|
||||
run(&mut s, "layer.setProps", json!({"id": sub.0, "name": ""}));
|
||||
assert_eq!(node(&s, sub).display_name(), "Sub", "a layer keeps its name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_options_validate_and_template_locks_and_dims() {
|
||||
let mut s = session();
|
||||
let (layer, ..) = tree(&mut s);
|
||||
for bad in [json!({"color": 27}), json!({"color": "#zz"}), json!({"color": [1]}), json!({"locked": "yes"}), json!({"dimImages": "half"})] {
|
||||
let mut p = bad.clone();
|
||||
p["id"] = json!(layer.0);
|
||||
assert!(s.execute("layer.setProps", &p).is_err(), "{bad}");
|
||||
}
|
||||
let color = |s: &Session| match node(s, layer).kind {
|
||||
NodeKind::Layer { color, .. } => color,
|
||||
_ => panic!("not a layer"),
|
||||
};
|
||||
run(&mut s, "layer.setProps", json!({"id": layer.0, "color": "red"}));
|
||||
assert_eq!(color(&s), LayerColor::Preset(1));
|
||||
run(&mut s, "layer.setProps", json!({"id": layer.0, "color": "#123456"}));
|
||||
assert_eq!(color(&s), LayerColor::Custom([0x12, 0x34, 0x56]));
|
||||
run(&mut s, "layer.setProps", json!({"id": layer.0, "color": 4}));
|
||||
assert_eq!(color(&s), LayerColor::Preset(4));
|
||||
run(&mut s, "layer.template", json!({"ids": [layer.0]}));
|
||||
let n = node(&s, layer);
|
||||
assert!(n.is_template() && n.locked);
|
||||
assert!(matches!(n.kind, NodeKind::Layer { dim_images: Some(50), .. }));
|
||||
run(&mut s, "layer.template", json!({"ids": [layer.0]}));
|
||||
let n = node(&s, layer);
|
||||
assert!(!n.is_template() && !n.locked);
|
||||
assert!(matches!(n.kind, NodeKind::Layer { dim_images: None, .. }));
|
||||
run(&mut s, "layer.setProps", json!({"id": layer.0, "preview": false, "dimImages": 30, "printable": false}));
|
||||
assert!(matches!(node(&s, layer).kind, NodeKind::Layer { preview: false, dim_images: Some(30), printable: false, .. }));
|
||||
let summary = &crate::inspect::document(&s)["layers"][0];
|
||||
assert_eq!((summary["preview"].clone(), summary["dimImages"].clone()), (json!(false), json!(30)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_layers_go_above_the_current_one_at_its_level() {
|
||||
let mut s = session();
|
||||
let (layer, sub, _, g, ..) = tree(&mut s);
|
||||
run(&mut s, "layer.setCurrent", json!({"id": sub.0}));
|
||||
let n = id(&run(&mut s, "layer.new", json!({})));
|
||||
assert_eq!(parent(&s, n), Some(layer), "a sublayer beside the current sublayer");
|
||||
assert_eq!(children(&s, layer).iter().position(|c| *c == n), children(&s, layer).iter().position(|c| *c == sub).map(|i| i + 1));
|
||||
assert_eq!(s.doc().unwrap().highlighted_rows(), vec![n]);
|
||||
let top = id(&run(&mut s, "layer.new", json!({"top": true, "name": "Top", "color": "green", "locked": true})));
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.doc.layers.last().unwrap().id, top);
|
||||
assert!(st.doc.layers.last().unwrap().locked);
|
||||
// Sublayers only go into layers.
|
||||
assert!(s.execute("layer.newSublayer", &json!({"parent": g.0})).is_err());
|
||||
let inner = id(&run(&mut s, "layer.newSublayer", json!({"parent": sub.0})));
|
||||
assert_eq!(parent(&s, inner), Some(sub));
|
||||
// Names count sublayers: no two layers share one.
|
||||
let mut names = vec![];
|
||||
s.doc().unwrap().doc.walk(|n| {
|
||||
if n.is_layer() {
|
||||
names.push(n.display_name());
|
||||
}
|
||||
});
|
||||
let unique: std::collections::HashSet<&String> = names.iter().collect();
|
||||
assert_eq!(unique.len(), names.len(), "{names:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_move_above_below_and_into_layers_and_groups() {
|
||||
let mut s = session();
|
||||
let (layer, sub, a, g, b, c) = tree(&mut s);
|
||||
let before = undos(&s);
|
||||
// Into the group: on top of its contents.
|
||||
run(&mut s, "layer.move", json!({"ids": [a.0], "target": g.0, "place": "inside"}));
|
||||
assert_eq!(children(&s, g), vec![b, a]);
|
||||
// Below b, in the group.
|
||||
run(&mut s, "layer.move", json!({"ids": [a.0], "target": b.0, "place": "below"}));
|
||||
assert_eq!(children(&s, g), vec![a, b]);
|
||||
// Above the sublayer, out into Layer 1.
|
||||
run(&mut s, "layer.move", json!({"ids": [a.0], "target": sub.0, "place": "above"}));
|
||||
assert_eq!(parent(&s, a), Some(layer));
|
||||
assert_eq!(undos(&s), before + 3);
|
||||
// Out of the layer into a new top-level layer: an object beside a top-level layer goes in it.
|
||||
let l2 = id(&run(&mut s, "layer.new", json!({"top": true})));
|
||||
run(&mut s, "layer.move", json!({"ids": [c.0], "target": l2.0, "place": "above"}));
|
||||
assert_eq!(parent(&s, c), Some(l2));
|
||||
// A sublayer becomes a top-level layer, and back.
|
||||
run(&mut s, "layer.move", json!({"ids": [sub.0], "target": l2.0, "place": "above"}));
|
||||
assert_eq!(parent(&s, sub), None);
|
||||
assert_eq!(s.doc().unwrap().doc.layers.last().unwrap().id, sub);
|
||||
run(&mut s, "layer.move", json!({"ids": [sub.0], "target": layer.0}));
|
||||
assert_eq!(parent(&s, sub), Some(layer));
|
||||
// Several rows keep their stacking order.
|
||||
run(&mut s, "layer.move", json!({"ids": [g.0, a.0], "target": l2.0}));
|
||||
assert_eq!(children(&s, l2), vec![c, a, g]);
|
||||
// Undo walks it back.
|
||||
for _ in 0..4 {
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
}
|
||||
assert_eq!(parent(&s, sub), Some(layer));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moves_are_validated() {
|
||||
let mut s = session();
|
||||
let (layer, sub, a, g, b, _) = tree(&mut s);
|
||||
let doc_before = s.doc().unwrap().doc.clone();
|
||||
let bad = [
|
||||
json!({"ids": [sub.0], "target": g.0, "place": "inside"}),
|
||||
json!({"ids": [layer.0], "target": a.0, "place": "inside"}),
|
||||
json!({"ids": [layer.0], "target": sub.0, "place": "above"}),
|
||||
json!({"ids": [sub.0], "target": sub.0}),
|
||||
json!({"ids": [g.0], "target": b.0, "place": "inside"}),
|
||||
json!({"ids": [a.0], "target": b.0, "place": "inside"}),
|
||||
json!({"ids": [a.0], "target": 9999}),
|
||||
json!({"ids": [9999], "target": a.0}),
|
||||
json!({"ids": [a.0], "target": g.0, "place": "sideways"}),
|
||||
json!({"ids": [], "target": g.0}),
|
||||
json!({"target": g.0}),
|
||||
];
|
||||
for p in bad {
|
||||
assert!(s.execute("layer.move", &p).is_err(), "{p}");
|
||||
}
|
||||
// A locked group takes nothing.
|
||||
run(&mut s, "layer.setProps", json!({"id": g.0, "locked": true}));
|
||||
assert!(s.execute("layer.move", &json!({"ids": [a.0], "target": g.0})).is_err());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &doc_before), "nothing changed");
|
||||
// node.move: layers only at the top level or in layers.
|
||||
assert!(s.execute("node.move", &json!({"id": sub.0, "parent": g.0, "index": 0})).is_err());
|
||||
assert!(s.execute("node.move", &json!({"id": a.0, "index": 0})).is_err());
|
||||
assert!(s.execute("node.move", &json!({"id": layer.0, "parent": sub.0, "index": 0})).is_err());
|
||||
run(&mut s, "node.move", json!({"id": sub.0, "index": 5}));
|
||||
assert_eq!(parent(&s, sub), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_drag_copies_rows_and_the_selected_art_moves_between_layers() {
|
||||
let mut s = session();
|
||||
let (layer, sub, a, g, _, c) = tree(&mut s);
|
||||
let count = s.doc().unwrap().doc.node_count();
|
||||
let copies = ids(&run(&mut s, "layer.move", json!({"ids": [sub.0], "target": layer.0, "place": "above", "copy": true})));
|
||||
assert_eq!(copies.len(), 1);
|
||||
assert_eq!(s.doc().unwrap().doc.node_count(), count + 4, "the sublayer and its art (a, the group, b), copied");
|
||||
assert_eq!(parent(&s, copies[0]), None, "a copy of the sublayer at the top level");
|
||||
// Dragging the selected-art square: the selection moves into another layer.
|
||||
run(&mut s, "select.set", json!({"ids": [a.0, g.0]}));
|
||||
run(&mut s, "layer.move", json!({"ids": [a.0, g.0], "target": layer.0}));
|
||||
assert_eq!(children(&s, layer), vec![c, sub, a, g]);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![a, g], "it stays selected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_and_duplicate_rows() {
|
||||
let mut s = session();
|
||||
let (layer, sub, a, g, _, c) = tree(&mut s);
|
||||
run(&mut s, "layer.highlight", json!({"ids": [a.0, g.0]}));
|
||||
let copies = ids(&run(&mut s, "layer.duplicate", json!({})));
|
||||
assert_eq!(copies.len(), 2);
|
||||
assert_eq!(children(&s, sub).len(), 4);
|
||||
assert_eq!(s.doc().unwrap().highlighted_rows(), copies);
|
||||
run(&mut s, "layer.duplicate", json!({"id": sub.0}));
|
||||
assert!(children(&s, layer).iter().any(|l| node(&s, *l).display_name() == "Sub copy"));
|
||||
run(&mut s, "layer.highlight", json!({"ids": copies.iter().map(|c| c.0).collect::<Vec<_>>()}));
|
||||
assert_eq!(run(&mut s, "layer.delete", json!({}))["deleted"], 2);
|
||||
assert_eq!(children(&s, sub), vec![a, g]);
|
||||
run(&mut s, "layer.delete", json!({"ids": [c.0, a.0]}));
|
||||
assert!(s.doc().unwrap().doc.node(c).is_none() && s.doc().unwrap().doc.node(a).is_none());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.doc().unwrap().doc.node(c).is_some());
|
||||
assert!(s.execute("layer.delete", &json!({"ids": [layer.0]})).is_err(), "the last layer stays");
|
||||
run(&mut s, "layer.setCurrent", json!({"id": sub.0}));
|
||||
run(&mut s, "layer.delete", json!({}));
|
||||
assert!(s.doc().unwrap().doc.node(sub).is_none());
|
||||
assert_eq!(s.doc().unwrap().current_layer(), Some(layer), "the current layer moves on");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_merge_flatten_and_reverse() {
|
||||
let mut s = session();
|
||||
let (layer, sub, a, g, _, c) = tree(&mut s);
|
||||
// Collect two rows of the sublayer in a new sublayer of it.
|
||||
run(&mut s, "layer.highlight", json!({"ids": [a.0, g.0]}));
|
||||
let col = id(&run(&mut s, "layer.collectInNew", json!({})));
|
||||
assert_eq!((parent(&s, col), children(&s, col)), (Some(sub), vec![a, g]));
|
||||
// Reverse their order.
|
||||
run(&mut s, "layer.reverse", json!({"ids": [a.0, g.0]}));
|
||||
assert_eq!(children(&s, col), vec![g, a]);
|
||||
assert!(s.execute("layer.reverse", &json!({"ids": [a.0]})).is_err());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
// Merge the sublayer into Layer 1 (listed last).
|
||||
run(&mut s, "layer.merge", json!({"ids": [col.0, layer.0]}));
|
||||
assert!(s.doc().unwrap().doc.node(col).is_none());
|
||||
assert_eq!(children(&s, layer), vec![c, sub, a, g]);
|
||||
assert!(s.execute("layer.merge", &json!({"ids": [layer.0, sub.0]})).is_err(), "not into its own sublayer");
|
||||
assert!(s.execute("layer.merge", &json!({"ids": [layer.0]})).is_err());
|
||||
// Flatten: other visible layers merge in, hidden ones go, templates stay.
|
||||
let l2 = id(&run(&mut s, "layer.new", json!({"top": true})));
|
||||
let d = rect(&mut s, 0.0);
|
||||
let l3 = id(&run(&mut s, "layer.new", json!({"top": true, "visible": false})));
|
||||
let l4 = id(&run(&mut s, "layer.new", json!({"top": true, "template": true})));
|
||||
let before = undos(&s);
|
||||
let r = run(&mut s, "layer.flatten", json!({"id": layer.0}));
|
||||
assert_eq!(r["discarded"], 1);
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.doc.layers.iter().map(|l| l.id).collect::<Vec<_>>(), vec![layer, l4]);
|
||||
assert!(st.doc.node(l2).is_none() && st.doc.node(l3).is_none());
|
||||
assert_eq!(parent(&s, d), Some(layer));
|
||||
assert_eq!(undos(&s), before + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_to_layers_sequence_and_build() {
|
||||
let mut s = session();
|
||||
let layer = s.doc().unwrap().doc.layers[0].id;
|
||||
let (a, b, c) = (rect(&mut s, 0.0), rect(&mut s, 50.0), rect(&mut s, 100.0));
|
||||
let new = ids(&run(&mut s, "layer.releaseToLayers", json!({"id": layer.0})));
|
||||
assert_eq!(new.len(), 3);
|
||||
assert_eq!(children(&s, layer), new);
|
||||
assert_eq!(new.iter().map(|l| children(&s, *l)).collect::<Vec<_>>(), vec![vec![a], vec![b], vec![c]]);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
let new = ids(&run(&mut s, "layer.releaseToLayers", json!({"id": layer.0, "build": true})));
|
||||
let counts: Vec<usize> = new.iter().map(|l| children(&s, *l).len()).collect();
|
||||
assert_eq!(counts, vec![1, 2, 3], "the layers add up");
|
||||
assert_eq!(children(&s, new[2]), vec![a, b, c], "the top one keeps the originals");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
// A group's objects go into sublayers where the group was.
|
||||
run(&mut s, "select.set", json!({"ids": [a.0, b.0]}));
|
||||
let g = id(&run(&mut s, "object.group", json!({})));
|
||||
let new = ids(&run(&mut s, "layer.releaseToLayersBuild", json!({"id": g.0})));
|
||||
assert_eq!(new.len(), 2);
|
||||
assert!(s.doc().unwrap().doc.node(g).is_none());
|
||||
assert_eq!(children(&s, layer), vec![new[0], new[1], c]);
|
||||
assert!(s.execute("layer.releaseToLayers", &json!({"id": c.0})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hide_lock_and_outline_others_and_their_all_counterparts() {
|
||||
let mut s = session();
|
||||
let l1 = s.doc().unwrap().doc.layers[0].id;
|
||||
let l2 = id(&run(&mut s, "layer.new", json!({})));
|
||||
let l3 = id(&run(&mut s, "layer.new", json!({})));
|
||||
run(&mut s, "layer.setCurrent", json!({"id": l2.0}));
|
||||
let flags = |s: &Session, f: fn(&Node) -> bool| [l1, l2, l3].map(|l| f(&node(s, l)));
|
||||
assert_eq!(run(&mut s, "layer.hideOthers", json!({}))["count"], 2);
|
||||
assert_eq!(flags(&s, |n| n.visible), [false, true, false]);
|
||||
run(&mut s, "layer.showAll", json!({}));
|
||||
assert_eq!(flags(&s, |n| n.visible), [true; 3]);
|
||||
run(&mut s, "layer.lockOthers", json!({}));
|
||||
assert_eq!(flags(&s, |n| n.locked), [true, false, true]);
|
||||
run(&mut s, "layer.unlockAll", json!({}));
|
||||
assert_eq!(flags(&s, |n| n.locked), [false; 3]);
|
||||
run(&mut s, "layer.outlineOthers", json!({}));
|
||||
let preview = |n: &Node| matches!(n.kind, NodeKind::Layer { preview: true, .. });
|
||||
assert_eq!(flags(&s, preview), [false, true, false]);
|
||||
run(&mut s, "layer.previewAll", json!({}));
|
||||
assert_eq!(flags(&s, preview), [true; 3]);
|
||||
assert_eq!(run(&mut s, "layer.previewAll", json!({}))["count"], 0, "nothing to do: no undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locate_object_highlights_its_row() {
|
||||
let mut s = session();
|
||||
let (layer, sub, _, g, b, _) = tree(&mut s);
|
||||
run(&mut s, "select.set", json!({"ids": [g.0]}));
|
||||
let r = run(&mut s, "layer.locate", json!({}));
|
||||
assert_eq!(r["ancestry"], json!([layer.0, sub.0, g.0]));
|
||||
assert_eq!(s.doc().unwrap().highlighted_rows(), vec![g]);
|
||||
assert_eq!(run(&mut s, "layer.locate", json!({"id": b.0}))["id"], b.0);
|
||||
run(&mut s, "select.none", json!({}));
|
||||
assert!(s.execute("layer.locate", &json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_clipping_mask_button_acts_on_the_highlighted_group() {
|
||||
let mut s = session();
|
||||
let (_, _, a, g, _, _) = tree(&mut s);
|
||||
run(&mut s, "layer.move", json!({"ids": [a.0], "target": g.0}));
|
||||
run(&mut s, "layer.setCurrent", json!({"id": g.0}));
|
||||
assert_eq!(run(&mut s, "layer.clippingMask.toggle", json!({}))["clip"], true);
|
||||
assert!(node(&s, g).clips());
|
||||
}
|
||||
@@ -526,6 +526,92 @@ fn save_and_recall_selection() {
|
||||
assert!(s.execute("select.recall", &json!({"name": "Pair"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_selection_needs_a_saved_selection_and_a_free_name() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
sel(&mut s, &[a]);
|
||||
// Edit Selection… is off until something is saved.
|
||||
assert!(s.execute("select.editSaved", &json!({"name": "Selection 1", "newName": "A"})).is_err());
|
||||
s.execute("select.save", &json!({"name": "One"})).unwrap();
|
||||
s.execute("select.save", &json!({"name": "Two"})).unwrap();
|
||||
// A name already saved can't be taken; both selections keep theirs.
|
||||
assert!(s.execute("select.editSaved", &json!({"name": "One", "newName": " Two "})).is_err());
|
||||
assert_eq!(s.execute("select.savedList", &json!({})).unwrap(), json!(["One", "Two"]));
|
||||
// Renaming to its own name, or a blank name, changes nothing; a free name renames.
|
||||
s.execute("select.editSaved", &json!({"name": "One", "newName": "One"})).unwrap();
|
||||
s.execute("select.editSaved", &json!({"name": "One", "newName": " "})).unwrap();
|
||||
s.execute("select.editSaved", &json!({"name": "One", "newName": " Uno "})).unwrap();
|
||||
assert_eq!(s.execute("select.savedList", &json!({})).unwrap(), json!(["Uno", "Two"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_selections_live_in_the_document() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0);
|
||||
let list = |s: &mut Session| s.execute("select.savedList", &json!({})).unwrap();
|
||||
sel(&mut s, &[a]);
|
||||
s.execute("select.save", &json!({"name": "A"})).unwrap();
|
||||
sel(&mut s, &[a, b]);
|
||||
s.execute("select.save", &json!({"name": "Both"})).unwrap();
|
||||
// They travel with the file.
|
||||
let loaded = vectorcraft_format::load(&vectorcraft_format::save_file(&s.doc().unwrap().doc)).unwrap();
|
||||
assert_eq!(loaded.saved_selections.iter().map(|x| x.name.as_str()).collect::<Vec<_>>(), ["A", "Both"]);
|
||||
// Saving is an undo step.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(list(&mut s), json!(["A"]));
|
||||
// An existing name is replaced by the current selection.
|
||||
sel(&mut s, &[b]);
|
||||
s.execute("select.save", &json!({"name": "A"})).unwrap();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
assert_eq!(s.execute("select.recall", &json!({"name": "A"})).unwrap()["count"], 1);
|
||||
assert_eq!(selected(&s), vec![b]);
|
||||
// Objects deleted since are left out of a recall.
|
||||
sel(&mut s, &[a, b]);
|
||||
s.execute("select.save", &json!({"name": "Pair"})).unwrap();
|
||||
sel(&mut s, &[a]);
|
||||
s.execute("edit.clear", &json!({})).unwrap();
|
||||
assert_eq!(s.execute("select.recall", &json!({"name": "Pair"})).unwrap()["count"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_selection_applies_several_edits_as_one_step() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
sel(&mut s, &[a]);
|
||||
for n in ["A", "B", "C"] {
|
||||
s.execute("select.save", &json!({ "name": n })).unwrap();
|
||||
}
|
||||
let list = |s: &mut Session| s.execute("select.savedList", &json!({})).unwrap();
|
||||
// Edits name the selections as they are, so two can swap names; a delete goes with them.
|
||||
s.execute("select.editSaved", &json!({"edits": [{"name": "A", "newName": "B"}, {"name": "B", "newName": "A"}, {"name": "C", "delete": true}]}))
|
||||
.unwrap();
|
||||
assert_eq!(list(&mut s), json!(["B", "A"]));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(list(&mut s), json!(["A", "B", "C"]));
|
||||
// Two ending with one name, or an unknown name, change nothing.
|
||||
assert!(s.execute("select.editSaved", &json!({"edits": [{"name": "A", "newName": "B"}]})).is_err());
|
||||
assert!(s.execute("select.editSaved", &json!({"edits": [{"name": "Z", "delete": true}]})).is_err());
|
||||
assert_eq!(list(&mut s), json!(["A", "B", "C"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_keeps_at_most_25_saved_selections() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
sel(&mut s, &[a]);
|
||||
for i in 0..25 {
|
||||
s.execute("select.save", &json!({ "name": format!("S{i}") })).unwrap();
|
||||
}
|
||||
assert!(s.execute("select.save", &json!({"name": "One too many"})).is_err());
|
||||
// A name already saved is replaced even when the list is full.
|
||||
s.execute("select.save", &json!({"name": "S3"})).unwrap();
|
||||
// Unnamed saves take the first free "Selection N".
|
||||
s.execute("select.editSaved", &json!({"name": "S0", "delete": true})).unwrap();
|
||||
assert_eq!(s.execute("select.save", &json!({})).unwrap()["name"], "Selection 1");
|
||||
}
|
||||
|
||||
// ---------- Type ----------
|
||||
|
||||
#[test]
|
||||
@@ -707,3 +793,16 @@ fn every_new_command_has_params_doc_and_menu() {
|
||||
assert!(!c.params.is_empty() && !c.menu.is_empty(), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_selection_names_are_trimmed_and_capped() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
sel(&mut s, &[a]);
|
||||
let long = "n".repeat(1000);
|
||||
let name = s.execute("select.save", &json!({ "name": format!(" {long} ") })).unwrap()["name"].clone();
|
||||
assert_eq!(name.as_str().map(|n| n.chars().count()), Some(vectorcraft_doc::SavedSelection::MAX_NAME));
|
||||
s.execute("select.editSaved", &json!({"name": name, "newName": format!("{long}é")})).unwrap();
|
||||
let list = s.execute("select.savedList", &json!({})).unwrap();
|
||||
assert_eq!(list[0].as_str().map(|n| n.chars().count()), Some(vectorcraft_doc::SavedSelection::MAX_NAME));
|
||||
}
|
||||
@@ -401,7 +401,7 @@ fn the_plane_widget_stays_on_screen_and_takes_clicks_with_any_tool() {
|
||||
assert_eq!(grid(&s).plane, Plane::Right);
|
||||
assert!(s.doc().unwrap().selection.is_empty());
|
||||
assert_eq!(undo_len(&s), n);
|
||||
// While the grid is hidden only the perspective tools show it (and take its clicks).
|
||||
// A hidden grid takes no clicks; choosing a perspective tool shows it.
|
||||
run(&mut s, "perspective.grid.show", json!({"visible": false}));
|
||||
let q = face(&s, view, WidgetCorner::TopLeft, Plane::Ground);
|
||||
click(&mut s, q, view);
|
||||
|
||||
@@ -122,3 +122,33 @@ fn prefs_serde_round_trip_and_tolerates_missing_fields() {
|
||||
assert_eq!(partial.keyboard_increment, 4.0);
|
||||
assert_eq!(partial.corner_radius, 12.0);
|
||||
}
|
||||
|
||||
/// Performance › Graphics Processor (#306): power saving by default, set by value or label, kept
|
||||
/// through a save/load round trip, and a bad value is an error that changes nothing.
|
||||
#[test]
|
||||
fn gpu_preference_defaults_to_power_saving_and_validates() {
|
||||
let mut s = Session::new();
|
||||
assert_eq!(s.prefs.gpu_preference, "powerSaving");
|
||||
assert_eq!(s.execute("prefs.get", &json!({"key": "gpuPreference"})).unwrap(), json!("powerSaving"));
|
||||
s.execute("prefs.set", &json!({"key": "gpuPreference", "value": "highPerformance"})).unwrap();
|
||||
assert_eq!(s.prefs.gpu_preference, "highPerformance");
|
||||
s.execute("prefs.set", &json!({"key": "gpuPreference", "value": "Power Saving (integrated)"})).unwrap();
|
||||
assert_eq!(s.prefs.gpu_preference, "powerSaving");
|
||||
for bad in [json!("turbo"), json!(""), json!(1), json!(null), json!(["highPerformance"])] {
|
||||
assert!(s.execute("prefs.set", &json!({"key": "gpuPreference", "value": bad})).is_err(), "{bad}");
|
||||
assert_eq!(s.prefs.gpu_preference, "powerSaving");
|
||||
}
|
||||
let p = Prefs { gpu_preference: "highPerformance".into(), ..Default::default() };
|
||||
let back: Prefs = serde_json::from_value(p.to_json()).unwrap();
|
||||
assert_eq!(back.gpu_preference, "highPerformance");
|
||||
// Preference files written before the preference existed get the default.
|
||||
let old: Prefs = serde_json::from_value(json!({"gpuPerformance": true})).unwrap();
|
||||
assert_eq!(old.gpu_preference, "powerSaving");
|
||||
let l = s.execute("prefs.list", &json!({})).unwrap();
|
||||
let row = l.as_array().unwrap().iter().find(|e| e["key"] == "gpuPreference").unwrap().clone();
|
||||
assert_eq!(row["category"], "Performance");
|
||||
assert_eq!(row["options"], json!(["powerSaving", "highPerformance"]));
|
||||
s.execute("prefs.set", &json!({"key": "gpuPreference", "value": "highPerformance"})).unwrap();
|
||||
s.execute("prefs.reset", &json!({"category": "Performance"})).unwrap();
|
||||
assert_eq!(s.prefs.gpu_preference, "powerSaving");
|
||||
}
|
||||
@@ -506,3 +506,51 @@ fn vertical_text_creation_orientation_and_persistence() {
|
||||
let old: TextObject = serde_json::from_value(old).unwrap();
|
||||
assert!(!old.vertical);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtl_edit_commands_and_undo_preserve_logical_source_and_style_ranges() {
|
||||
let mut s = session();
|
||||
let id = text(&mut s, "שלום Rust 123");
|
||||
s.execute("text.editRange", &json!({"id": id.0, "start": 0, "end": 8, "insert": "مرحبا"})).unwrap();
|
||||
let t = obj(&s, id);
|
||||
assert_eq!(t.plain_text(), "مرحبا Rust 123");
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
|
||||
assert!(lay.glyphs.iter().any(|g| g.rtl));
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 10, "size": 30})).unwrap();
|
||||
assert_eq!(obj(&s, id).runs[0].text, "مرحبا");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(obj(&s, id).plain_text(), "שלום Rust 123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_alignment_updates_during_edits_and_can_be_overridden() {
|
||||
let mut s = session();
|
||||
let id = text(&mut s, "English");
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
assert_eq!(obj(&s, id).para.justify, vectorcraft_doc::Justify::Auto);
|
||||
for content in ["שלום", "مرحبا", "English"] {
|
||||
let len = obj(&s, id).plain_text().len();
|
||||
s.execute("text.editRange", &json!({"id": id.0, "start": 0, "end": len, "insert": content})).unwrap();
|
||||
let t = obj(&s, id);
|
||||
let l = vectorcraft_text::layout(db, &t);
|
||||
if content == "English" {
|
||||
assert_eq!(l.lines[0].x0, 0.0);
|
||||
} else {
|
||||
assert!(l.lines[0].x1.abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
s.execute("text.setStyle", &json!({"id": id.0, "justify": "center"})).unwrap();
|
||||
s.execute("text.editRange", &json!({"id": id.0, "start": 0, "end": 7, "insert": "שלום"})).unwrap();
|
||||
assert_eq!(obj(&s, id).para.justify, vectorcraft_doc::Justify::Center);
|
||||
s.execute("text.setStyle", &json!({"id": id.0, "justify": "auto"})).unwrap();
|
||||
let t = obj(&s, id);
|
||||
assert!(vectorcraft_text::layout(db, &t).lines[0].x1.abs() < 1e-6);
|
||||
let restored: TextObject = serde_json::from_value(serde_json::to_value(&t).unwrap()).unwrap();
|
||||
assert_eq!(restored.para.justify, vectorcraft_doc::Justify::Auto);
|
||||
// Legacy documents with explicit alignment retain that choice.
|
||||
let mut legacy = serde_json::to_value(t).unwrap();
|
||||
legacy["para"].as_object_mut().unwrap().remove("justify_auto");
|
||||
legacy["para"]["justify"] = json!("Left");
|
||||
assert_eq!(serde_json::from_value::<TextObject>(legacy).unwrap().para.justify, vectorcraft_doc::Justify::Left);
|
||||
}
|
||||
@@ -265,6 +265,54 @@ fn artboard_resize_via_handle_and_move_command() {
|
||||
assert!(s.execute("artboard.move", &json!({"index": 7, "dx": 1})).is_err());
|
||||
}
|
||||
|
||||
/// Alt-dragging an artboard with the Artboard tool leaves it and its art in place and moves
|
||||
/// copies of both.
|
||||
#[test]
|
||||
fn artboard_alt_drag_duplicates_it_with_its_art() {
|
||||
let mut s = session();
|
||||
let r = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
let alt = Mods { alt: true, ..Mods::default() };
|
||||
gesture(&mut s, "artboard", &[(400.0, 300.0), (600.0, 300.0), (1300.0, 300.0), (1300.0, 300.0)], alt);
|
||||
let st = s.doc().unwrap();
|
||||
let d = &st.doc;
|
||||
assert_eq!(d.artboards.len(), 2);
|
||||
assert_eq!(d.artboards[0].rect, Rect::new(0.0, 0.0, 800.0, 600.0), "the original stays");
|
||||
assert_eq!((d.artboards[1].rect, d.artboards[1].name.as_str()), (Rect::new(900.0, 0.0, 1700.0, 600.0), "Artboard 1 copy"));
|
||||
assert_ne!(d.artboards[1].id, d.artboards[0].id);
|
||||
let kids = d.layers[0].children().unwrap();
|
||||
assert_eq!(kids.len(), 2, "the rectangle and its copy");
|
||||
assert_eq!(d.node(r).unwrap().geometric_bounds().unwrap().x0, 100.0);
|
||||
let copy = kids.iter().find(|n| n.id != r).unwrap();
|
||||
assert_eq!(copy.geometric_bounds().unwrap().x0, 1000.0);
|
||||
assert_eq!(st.history.undo.last().unwrap().label, "Duplicate Artboard");
|
||||
// One undo takes the copies away.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!((d.artboards.len(), d.layers[0].children().unwrap().len()), (1, 1));
|
||||
}
|
||||
|
||||
/// `artboard.move` with `copy` reports the copies it moved, and copies only the artboard when Move/Copy
|
||||
/// Artwork with Artboard is off.
|
||||
#[test]
|
||||
fn artboard_move_copy_reports_the_copies_and_follows_move_art() {
|
||||
let mut s = session();
|
||||
let r = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
|
||||
let out = s.execute("artboard.move", &json!({"index": 0, "dx": 900, "dy": 0, "copy": true, "moveArt": true})).unwrap();
|
||||
assert_eq!(out["index"], 1);
|
||||
let copies: Vec<NodeId> = out["moved"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect();
|
||||
assert_eq!(copies.len(), 1);
|
||||
assert_ne!(copies[0], r);
|
||||
assert_eq!(s.doc().unwrap().doc.node(copies[0]).unwrap().geometric_bounds().unwrap().x0, 1000.0);
|
||||
let out = s.execute("artboard.move", &json!({"index": 0, "dx": 0, "dy": 900, "copy": true})).unwrap();
|
||||
assert_eq!(out["moved"], json!([]));
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!((d.artboards.len(), d.layers[0].children().unwrap().len()), (3, 2), "an artboard alone");
|
||||
assert_eq!(d.artboards[2].name, "Artboard 1 copy 2", "a name of its own");
|
||||
let ids: std::collections::HashSet<u32> = d.artboards.iter().map(|a| a.id).collect();
|
||||
assert_eq!(ids.len(), 3, "every artboard has an id of its own");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn magic_wand_selects_same_fill() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -56,8 +56,21 @@ impl Session {
|
||||
self.tool.id()
|
||||
}
|
||||
|
||||
/// Switch tools (finishing any pending tool work first).
|
||||
/// Choose a tool (the toolbar, its shortcut, MCP), finishing any pending tool work first.
|
||||
pub fn select_tool(&mut self, id: &str, view: ViewInfo) -> Result<()> {
|
||||
// Choosing a perspective tool shows the document's perspective grid; Hide Grid hides it
|
||||
// again, the tool staying chosen.
|
||||
if matches!(id, "perspectiveGrid" | "perspectiveSelection")
|
||||
&& self.active().is_some_and(|d| !vectorcraft_tools::distort::perspective::PerspectiveGrid::current(&d.doc).visible)
|
||||
{
|
||||
crate::cmd::distortcmds::silent(self, |g| g.visible = true)?;
|
||||
}
|
||||
self.switch_tool(id, view)
|
||||
}
|
||||
|
||||
/// Switch to tool `id` without choosing it afresh (back from a temporary tool: a hidden
|
||||
/// perspective grid stays hidden), finishing any pending tool work first.
|
||||
pub fn switch_tool(&mut self, id: &str, view: ViewInfo) -> Result<()> {
|
||||
if self.tool.id() == id {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -138,6 +151,7 @@ impl Session {
|
||||
paste_plain_text: self.prefs.paste_text_formatting == "plain",
|
||||
slices_hidden: self.menu.slices_hidden,
|
||||
slices_locked: self.menu.slices_locked,
|
||||
auto_add_delete: !self.prefs.disable_auto_add_delete,
|
||||
screen: view.screen,
|
||||
plane_widget: self.prefs.perspective_widget.show.then_some(self.prefs.perspective_widget.position),
|
||||
};
|
||||
@@ -246,7 +260,7 @@ impl Session {
|
||||
if let Some(d) = self.active() {
|
||||
let w = self.prefs.perspective_widget;
|
||||
let place = w.show.then_some(WidgetPlace { screen: view.screen.as_ref(), corner: w.position });
|
||||
v.splice(0..0, vectorcraft_tools::distort::perspective::grid_overlays_in(&d.doc, 1.0 / view.zoom.max(1e-9), self.tool.id(), place));
|
||||
v.splice(0..0, vectorcraft_tools::distort::perspective::grid_overlays_in(&d.doc, 1.0 / view.zoom.max(1e-9), place));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
@@ -913,6 +913,9 @@ fn arb_ps_token() -> impl Strategy<Value = String> {
|
||||
"clipsave", "cliprestore", "languagelevel", "cvx", "findresource", "defineresource", "undefineresource", "resourcestatus",
|
||||
"resourceforall", "/Category", "/Generic", "/ProcSet", "(*)", "(G?n*\\\\*?)", "{pop}", "8 string",
|
||||
"(*) {pop} 8 string /Category resourceforall", "/Generic /Category findresource dup length dict copy /Category defineresource",
|
||||
"dictstack", "8 array", "1183615869 internaldict", "cshow", "currentsystemparams", "currentuserparams", "flushfile", "xcheck",
|
||||
"systemdict", "/userdict", "SharedFontDirectory", "(1 2 add) 0 () /SubFileDecode filter cvx exec", "currentfile cvx exec",
|
||||
"/p { (p) 0 () /SubFileDecode filter cvx exec } def p", "currentfile 0 () /SubFileDecode filter cvx exec", "cvlit", "{ (a) cshow }",
|
||||
"/ASCII85Decode", "/ASCIIHexDecode", "/FlateDecode", "/RunLengthDecode", "/LZWDecode", "/DCTDecode", "/SubFileDecode",
|
||||
"/DeviceRGB", "/DeviceCMYK", "/DeviceGray", "/Pattern", "/x", "x", "/Helvetica", "(abc)", "<ff00>", "<~87cURD]i,\"Ebo80~>",
|
||||
"true", "false", "null", "[/Separation (S) /DeviceCMYK {dup dup dup}]", "[/Indexed /DeviceRGB 1 <ff000000ff00>]",
|
||||
|
||||
@@ -185,7 +185,8 @@ fn decode(f: &Filter, raw: &[u8]) -> Res<(Vec<u8>, usize, Option<u8>)> {
|
||||
"SubFileDecode" => {
|
||||
let (count, eod) = &f.sub;
|
||||
if eod.is_empty() {
|
||||
let n = (*count).min(raw.len());
|
||||
// A count of 0 without an end string sets no end: the data passes through whole.
|
||||
let n = if *count == 0 { raw.len() } else { (*count).min(raw.len()) };
|
||||
(raw.get(..n).unwrap_or_default().to_vec(), n)
|
||||
} else {
|
||||
let (mut at, mut seen) = (0, 0);
|
||||
@@ -218,11 +219,11 @@ impl Interp<'_> {
|
||||
pub fn data_op(&mut self, op: Op) -> Res {
|
||||
use Op::*;
|
||||
match op {
|
||||
CurrentFile => self.push(Obj::File(Rc::new(RefCell::new(Stream::Current))))?,
|
||||
CurrentFile => self.push(Obj::File { stream: Rc::new(RefCell::new(Stream::Current)), exec: false })?,
|
||||
Filter => self.filter()?,
|
||||
ReadHexString | ReadString => {
|
||||
let s = self.pop_str()?;
|
||||
let Obj::File(f) = self.pop()? else { return ps_err("typecheck", "") };
|
||||
let Obj::File { stream: f, .. } = self.pop()? else { return ps_err("typecheck", "") };
|
||||
let n = s.len();
|
||||
let got = if op == ReadString { self.read(&f, n)? } else { self.read_hex(&f, n)? };
|
||||
self.fill(s, &got)?;
|
||||
@@ -230,7 +231,7 @@ impl Interp<'_> {
|
||||
}
|
||||
ReadLine => {
|
||||
let s = self.pop_str()?;
|
||||
let Obj::File(f) = self.pop()? else { return ps_err("typecheck", "") };
|
||||
let Obj::File { stream: f, .. } = self.pop()? else { return ps_err("typecheck", "") };
|
||||
let n = s.len();
|
||||
let mut line = vec![];
|
||||
let mut ended = false;
|
||||
@@ -250,7 +251,14 @@ impl Interp<'_> {
|
||||
self.fill(s, &line)?;
|
||||
self.push(Obj::Bool(ended))?;
|
||||
}
|
||||
FlushFile | CloseFile => {
|
||||
FlushFile => {
|
||||
// An input file is read to its end: a filter over the program's data skips it
|
||||
// (metadata read with `flushfile`). The program's own file goes on being run.
|
||||
if let Obj::File { stream, .. } = self.pop()? {
|
||||
self.materialize(&stream)?;
|
||||
}
|
||||
}
|
||||
CloseFile => {
|
||||
self.pop()?;
|
||||
}
|
||||
File => {
|
||||
@@ -329,7 +337,7 @@ impl Interp<'_> {
|
||||
}
|
||||
let spec = Filter { name, sub };
|
||||
let stream = match self.pop()? {
|
||||
Obj::File(f) => {
|
||||
Obj::File { stream: f, .. } => {
|
||||
if let Stream::Buf { data, pos, .. } = &*f.borrow() {
|
||||
self.alloc(data.len().saturating_sub(*pos))?;
|
||||
}
|
||||
@@ -340,6 +348,11 @@ impl Interp<'_> {
|
||||
};
|
||||
match chain {
|
||||
Some(mut c) => {
|
||||
// The new filter reads a pending one's data: it has none left of its own
|
||||
// (`flushfile` on it skips nothing more).
|
||||
if !c.is_empty() {
|
||||
*f.borrow_mut() = Stream::Buf { data: vec![], pos: 0, rgb: None };
|
||||
}
|
||||
c.push(spec);
|
||||
Stream::Pending(c)
|
||||
}
|
||||
@@ -364,7 +377,16 @@ impl Interp<'_> {
|
||||
}
|
||||
_ => return ps_err("typecheck", "filter"),
|
||||
};
|
||||
self.push(Obj::File(Rc::new(RefCell::new(stream))))
|
||||
self.push(Obj::File { stream: Rc::new(RefCell::new(stream)), exec: false })
|
||||
}
|
||||
|
||||
/// The data an executable file runs as a program: what is left of it. `None` for the
|
||||
/// program's own file, which is being run already.
|
||||
pub(super) fn program_of(&mut self, f: &Rc<RefCell<Stream>>) -> Res<Option<Vec<u8>>> {
|
||||
if matches!(&*f.borrow(), Stream::Current) {
|
||||
return Ok(None);
|
||||
}
|
||||
self.read(f, MAX_DATA).map(Some)
|
||||
}
|
||||
|
||||
/// Decode the filters a file waits to run over the program's data, from where it is now.
|
||||
@@ -688,7 +710,7 @@ struct Pixels {
|
||||
|
||||
fn source(o: &Obj) -> Res<Source> {
|
||||
Ok(match o {
|
||||
Obj::File(f) => Source::File(f.clone()),
|
||||
Obj::File { stream: f, .. } => Source::File(f.clone()),
|
||||
Obj::Str(s) => Source::Str(s.to_vec()),
|
||||
Obj::Array { items, .. } => Source::Proc(Obj::Array { items: items.clone(), exec: true }),
|
||||
_ => return ps_err("typecheck", "DataSource"),
|
||||
|
||||
@@ -946,6 +946,16 @@ impl Interp<'_> {
|
||||
d.insert(Key::name("PageSize"), Obj::array(vec![Obj::Real(f.width()), Obj::Real(f.height())]));
|
||||
self.push(Obj::dict(d))?;
|
||||
}
|
||||
// The cache and stack sizes programs read (they `get` them without asking first).
|
||||
CurrentUserParams | CurrentSystemParams => {
|
||||
let keys: &[&str] = if op == CurrentUserParams {
|
||||
&["MaxFontItem", "MaxFormItem", "MaxPatternItem", "MaxUPathItem", "MaxOpStack", "MaxDictStack", "MaxExecStack"]
|
||||
} else {
|
||||
&["MaxFontCache", "MaxFormCache", "MaxPatternCache", "MaxUPathCache", "MaxScreenStorage", "MaxDisplayList"]
|
||||
};
|
||||
let d: super::obj::Dict = keys.iter().map(|k| (Key::name(k), Obj::Int(1 << 20))).collect();
|
||||
self.push(Obj::dict(d))?;
|
||||
}
|
||||
ShowPage => return Err(PsError::Quit),
|
||||
CopyPage | ErasePage => {}
|
||||
Matrix => self.push(matrix_obj(Affine::IDENTITY))?,
|
||||
|
||||
@@ -38,6 +38,8 @@ pub(crate) struct Interp<'a> {
|
||||
global: DictRef,
|
||||
status: DictRef,
|
||||
error: DictRef,
|
||||
/// `internaldict`'s dictionary, for font programs.
|
||||
internal: DictRef,
|
||||
/// `FontDirectory`: fonts defined with `definefont`.
|
||||
pub fonts: DictRef,
|
||||
/// Resources by category (`defineresource`).
|
||||
@@ -89,8 +91,24 @@ impl<'a> Interp<'a> {
|
||||
resources.borrow_mut().insert(Key::name("Category"), Obj::dict(categories));
|
||||
let fonts = new_dict();
|
||||
system.borrow_mut().insert(Key::name("FontDirectory"), Obj::Dict(fonts.clone()));
|
||||
system.borrow_mut().insert(Key::name("GlobalFontDirectory"), Obj::Dict(fonts.clone()));
|
||||
let (user, global) = (new_dict(), new_dict());
|
||||
for name in ["GlobalFontDirectory", "SharedFontDirectory"] {
|
||||
system.borrow_mut().insert(Key::name(name), Obj::Dict(fonts.clone()));
|
||||
}
|
||||
let (user, global, status, error) = (new_dict(), new_dict(), new_dict(), new_dict());
|
||||
// Errors don't reach the program's handler, but prologs wrap the one they find.
|
||||
error.borrow_mut().insert(Key::name("handleerror"), Obj::proc(vec![]));
|
||||
// The standard dictionaries are values in systemdict, so `get`, `load` and `where` find
|
||||
// them as dictionaries (`/globaldict where { /globaldict get begin } if`).
|
||||
for (name, d) in [
|
||||
("systemdict", &system),
|
||||
("userdict", &user),
|
||||
("globaldict", &global),
|
||||
("statusdict", &status),
|
||||
("errordict", &error),
|
||||
("$error", &error),
|
||||
] {
|
||||
system.borrow_mut().insert(Key::name(name), Obj::Dict(d.clone()));
|
||||
}
|
||||
Self {
|
||||
lex: Lexer::new(src),
|
||||
stack: vec![],
|
||||
@@ -98,8 +116,9 @@ impl<'a> Interp<'a> {
|
||||
system,
|
||||
user,
|
||||
global,
|
||||
status: new_dict(),
|
||||
error: new_dict(),
|
||||
status,
|
||||
error,
|
||||
internal: new_dict(),
|
||||
fonts,
|
||||
resources,
|
||||
depth: 0,
|
||||
@@ -123,6 +142,18 @@ impl<'a> Interp<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Empty the standard dictionaries, so the memory they hold is freed with the interpreter:
|
||||
/// systemdict holds itself, and programs link dictionaries into each other.
|
||||
pub fn release(&mut self) {
|
||||
for d in [&self.system, &self.user, &self.global, &self.status, &self.error, &self.internal, &self.fonts, &self.resources] {
|
||||
if let Ok(mut d) = d.try_borrow_mut() {
|
||||
d.clear();
|
||||
}
|
||||
}
|
||||
self.dicts.clear();
|
||||
self.stack.clear();
|
||||
}
|
||||
|
||||
// ---------- the operand stack ----------
|
||||
|
||||
pub fn push(&mut self, o: Obj) -> Res {
|
||||
@@ -236,7 +267,13 @@ impl<'a> Interp<'a> {
|
||||
}
|
||||
|
||||
fn tick(&mut self) -> Res {
|
||||
self.ops += 1;
|
||||
self.spend(1)
|
||||
}
|
||||
|
||||
/// Count `ops` operations' worth of work against [`MAX_OPS`]: what an operator that costs
|
||||
/// far more than one operation (laying out type) does in a loop has to end too.
|
||||
pub fn spend(&mut self, ops: u64) -> Res {
|
||||
self.ops = self.ops.saturating_add(ops);
|
||||
if self.ops > MAX_OPS {
|
||||
return Err(PsError::Limit("the program runs too long"));
|
||||
}
|
||||
@@ -262,6 +299,7 @@ impl<'a> Interp<'a> {
|
||||
pub fn call(&mut self, v: Obj) -> Res {
|
||||
match v {
|
||||
Obj::Array { items, exec: true } => self.run_proc(&items),
|
||||
Obj::File { stream, exec: true } => self.run_file(&stream),
|
||||
Obj::Op(op) => self.op(op),
|
||||
Obj::Exec(name) => {
|
||||
self.enter()?;
|
||||
@@ -291,6 +329,26 @@ impl<'a> Interp<'a> {
|
||||
r
|
||||
}
|
||||
|
||||
/// Run what is left of an executable file as a program, token by token.
|
||||
fn run_file(&mut self, f: &Rc<RefCell<super::data::Stream>>) -> Res {
|
||||
let Some(program) = self.program_of(f)? else { return Ok(()) };
|
||||
self.enter()?;
|
||||
let mut lex = Lexer::new(&program);
|
||||
let r = loop {
|
||||
match lex.next() {
|
||||
Ok(Some(o)) => {
|
||||
if let Err(e) = self.exec_token(o) {
|
||||
break Err(e);
|
||||
}
|
||||
}
|
||||
Ok(None) => break Ok(()),
|
||||
Err(e) => break Err(e),
|
||||
}
|
||||
};
|
||||
self.depth -= 1;
|
||||
r
|
||||
}
|
||||
|
||||
fn run_items(&mut self, items: &Shared<Obj>) -> Res {
|
||||
let mut i = 0;
|
||||
loop {
|
||||
@@ -620,6 +678,7 @@ impl<'a> Interp<'a> {
|
||||
Cvx => {
|
||||
let o = match self.pop()? {
|
||||
Obj::Array { items, .. } => Obj::Array { items, exec: true },
|
||||
Obj::File { stream, .. } => Obj::File { stream, exec: true },
|
||||
Obj::Name(n) => Obj::Exec(n),
|
||||
o => o,
|
||||
};
|
||||
@@ -628,6 +687,7 @@ impl<'a> Interp<'a> {
|
||||
Cvlit => {
|
||||
let o = match self.pop()? {
|
||||
Obj::Array { items, .. } => Obj::Array { items, exec: false },
|
||||
Obj::File { stream, .. } => Obj::File { stream, exec: false },
|
||||
Obj::Exec(n) => Obj::Name(n),
|
||||
o => o,
|
||||
};
|
||||
@@ -635,7 +695,7 @@ impl<'a> Interp<'a> {
|
||||
}
|
||||
Xcheck => {
|
||||
let o = self.pop()?;
|
||||
self.push(Obj::Bool(matches!(o, Obj::Array { exec: true, .. } | Obj::Exec(_) | Obj::Op(_))))?;
|
||||
self.push(Obj::Bool(matches!(o, Obj::Array { exec: true, .. } | Obj::File { exec: true, .. } | Obj::Exec(_) | Obj::Op(_))))?;
|
||||
}
|
||||
Cvn => {
|
||||
let s = self.pop_str()?;
|
||||
@@ -853,11 +913,23 @@ impl<'a> Interp<'a> {
|
||||
self.push(Obj::Dict(d))?;
|
||||
}
|
||||
CountDictStack => self.push(Obj::Int(self.dicts.len() as i64))?,
|
||||
DictStack => {
|
||||
// The dictionary stack, bottom first, stored into the array's start.
|
||||
let items = self.pop_array()?;
|
||||
let dicts: Vec<Obj> = self.dicts.iter().map(|d| Obj::Dict(d.clone())).collect();
|
||||
let part = items.sub(0, dicts.len()).filter(|p| p.write(0, &dicts)).ok_or(PsError::Ps("rangecheck", String::new()))?;
|
||||
self.push(Obj::Array { items: part, exec: false })?;
|
||||
}
|
||||
SystemDict => self.push(Obj::Dict(self.system.clone()))?,
|
||||
UserDict => self.push(Obj::Dict(self.user.clone()))?,
|
||||
GlobalDict => self.push(Obj::Dict(self.global.clone()))?,
|
||||
StatusDict => self.push(Obj::Dict(self.status.clone()))?,
|
||||
ErrorDict | DollarError => self.push(Obj::Dict(self.error.clone()))?,
|
||||
InternalDict => {
|
||||
// The operand is a password that every interpreter accepts.
|
||||
self.pop_int()?;
|
||||
self.push(Obj::Dict(self.internal.clone()))?;
|
||||
}
|
||||
Bind => {
|
||||
let o = self.pop()?;
|
||||
if let Obj::Array { items, .. } = &o {
|
||||
|
||||
@@ -137,6 +137,7 @@ pub fn import(bytes: &[u8]) -> Result<Imported, String> {
|
||||
let page = Affine::new([1.0, 0.0, 0.0, -1.0, -llx, ury]);
|
||||
let mut it = Interp::new(ps, GState::default(), Out::new(doc, page, frame));
|
||||
let result = it.run();
|
||||
it.release();
|
||||
let mut out = it.out;
|
||||
if let Some(n) = dsc.pages.filter(|n| *n > 1) {
|
||||
out.warn(&format!("only the first of the file's {n} pages was read"));
|
||||
|
||||
@@ -40,7 +40,11 @@ pub(crate) enum Obj {
|
||||
Dict(DictRef),
|
||||
Op(Op),
|
||||
Mark,
|
||||
File(Rc<RefCell<Stream>>),
|
||||
/// A file; executable ones run their data as a program (`cvx exec`).
|
||||
File {
|
||||
stream: Rc<RefCell<Stream>>,
|
||||
exec: bool,
|
||||
},
|
||||
/// A `save` level (the graphics state depth it restores to).
|
||||
Save(usize),
|
||||
/// A graphics state object (`gstate`, `currentgstate`).
|
||||
@@ -118,7 +122,7 @@ impl Obj {
|
||||
Self::Dict(_) => "dicttype",
|
||||
Self::Op(_) => "operatortype",
|
||||
Self::Mark => "marktype",
|
||||
Self::File(_) => "filetype",
|
||||
Self::File { .. } => "filetype",
|
||||
Self::Save(_) => "savetype",
|
||||
Self::GState(_) => "gstatetype",
|
||||
}
|
||||
@@ -270,8 +274,8 @@ ops! {
|
||||
// Dictionaries.
|
||||
NewDict = "dict", MaxLength = "maxlength", Begin = "begin", End = "end", Def = "def", Load = "load", Store = "store",
|
||||
Known = "known", Where = "where", Undef = "undef", CurrentDict = "currentdict", CountDictStack = "countdictstack",
|
||||
SystemDict = "systemdict", UserDict = "userdict", GlobalDict = "globaldict", StatusDict = "statusdict",
|
||||
ErrorDict = "errordict", DollarError = "$error", Bind = "bind",
|
||||
DictStack = "dictstack", SystemDict = "systemdict", UserDict = "userdict", GlobalDict = "globaldict", StatusDict = "statusdict",
|
||||
ErrorDict = "errordict", DollarError = "$error", InternalDict = "internaldict", Bind = "bind",
|
||||
// Virtual memory and the environment.
|
||||
Save = "save", Restore = "restore", SetGlobal = "setglobal", CurrentGlobal = "currentglobal", VmStatus = "vmstatus",
|
||||
Version = "version", Product = "product", RealTime = "realtime", UserTime = "usertime",
|
||||
@@ -292,6 +296,7 @@ ops! {
|
||||
SetUnderColorRemoval = "setundercolorremoval", SetColorRendering = "setcolorrendering", SetSmoothness = "setsmoothness",
|
||||
SetStrokeAdjust = "setstrokeadjust", CurrentStrokeAdjust = "currentstrokeadjust", SetPageDevice = "setpagedevice",
|
||||
CurrentPageDevice = "currentpagedevice", SetUserParams = "setuserparams", SetSystemParams = "setsystemparams",
|
||||
CurrentUserParams = "currentuserparams", CurrentSystemParams = "currentsystemparams",
|
||||
SetObjectFormat = "setobjectformat", ShowPage = "showpage", CopyPage = "copypage", ErasePage = "erasepage",
|
||||
CurrentScreen = "currentscreen", SetColorScreen = "setcolorscreen", CurrentHalftone = "currenthalftone",
|
||||
CurrentTransfer = "currenttransfer", CurrentColorTransfer = "currentcolortransfer",
|
||||
@@ -320,6 +325,6 @@ ops! {
|
||||
DefineResource = "defineresource", UndefineResource = "undefineresource", ResourceStatus = "resourcestatus",
|
||||
ResourceForAll = "resourceforall", Show = "show", AShow = "ashow",
|
||||
WidthShow = "widthshow", AWidthShow = "awidthshow", XShow = "xshow", YShow = "yshow", XYShow = "xyshow",
|
||||
KShow = "kshow", GlyphShow = "glyphshow", StringWidth = "stringwidth", CharPath = "charpath",
|
||||
KShow = "kshow", CShow = "cshow", GlyphShow = "glyphshow", StringWidth = "stringwidth", CharPath = "charpath",
|
||||
SetCacheDevice = "setcachedevice", SetCharWidth = "setcharwidth",
|
||||
}
|
||||
@@ -569,6 +569,74 @@ fn resourceforall_enumerates_matching_names() {
|
||||
check("/n 0 def (Gen*) { pop /n n 1 add def } 16 string /Category resourceforall n 1 eq");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_standard_dictionaries_are_dictionaries_in_systemdict() {
|
||||
// The reference app's EPS prolog looks them up by name rather than running them.
|
||||
check("systemdict /userdict get type /dicttype eq");
|
||||
check("/globaldict where { /globaldict get begin currentdict globaldict eq end } { false } ifelse");
|
||||
check("systemdict /systemdict get systemdict eq systemdict /statusdict known and");
|
||||
check("userdict /x 5 put systemdict /userdict get /x get 5 eq");
|
||||
check("/errordict load /handleerror known errordict $error eq and");
|
||||
check("SharedFontDirectory type /dicttype eq");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dictstack_and_internaldict_answer() {
|
||||
check("10 array dictstack length countdictstack eq");
|
||||
check("userdict begin 10 array dictstack dup length 1 sub get userdict eq end");
|
||||
check("{ 1 array dictstack } stopped");
|
||||
check("1183615869 internaldict /k 1 put 1183615869 internaldict /k get 1 eq");
|
||||
check("currentsystemparams /MaxPatternCache get type /integertype eq");
|
||||
check("<< /MaxPatternItem currentsystemparams /MaxPatternCache get >> setuserparams currentuserparams /MaxFontItem known");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_executable_filter_runs_its_data_as_a_program() {
|
||||
// A string run as a program through a filter (0 and an empty string set no end of data).
|
||||
check("(1 2 add) 0 () /SubFileDecode filter cvx exec 3 eq");
|
||||
check("(1 2 add) 0 () /SubFileDecode filter dup xcheck not exch cvx xcheck and");
|
||||
check("(abcd) 2 () /SubFileDecode filter 8 string readstring pop (ab) eq");
|
||||
check("{ (1 add) 0 () /SubFileDecode filter cvx exec } stopped");
|
||||
// The data following it in the file: `74727565` is `true`.
|
||||
check("currentfile /ASCIIHexDecode filter cvx exec 74727565>");
|
||||
// A literal file is only pushed.
|
||||
check("(1 2 add) 0 () /SubFileDecode filter exec type /filetype eq");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flushfile_skips_the_data_of_a_filter() {
|
||||
// Metadata that isn't PostScript, skipped up to its end marker.
|
||||
blue("currentfile 0 (%%EndPacket) /SubFileDecode filter flushfile\n<?xml version=\"1.0\"?><x a=\"(\"/>\n%%EndPacket\n0 0 1 setrgbcolor");
|
||||
}
|
||||
|
||||
/// Executable files are untrusted programs too: running one inside itself stops at the nesting
|
||||
/// limit, and the program's own file runs once. Type laid out in a loop (`cshow` on a long
|
||||
/// string, `stringwidth` in `loop`) counts against the operation budget like the loop itself.
|
||||
#[test]
|
||||
fn hostile_executable_files_and_type_loops_are_refused() {
|
||||
let refused = |body: &str, why: &str| {
|
||||
let e = import(&eps(body)).err().unwrap_or_default();
|
||||
assert!(e.contains(why), "{body}: {e}");
|
||||
};
|
||||
refused("/p { (p) 0 () /SubFileDecode filter cvx exec } def p", "too deeply");
|
||||
refused("/p { (p) 0 () /SubFileDecode filter cvx exec } def { p } stopped pop p", "too deeply");
|
||||
refused("/a { (b) 0 () /SubFileDecode filter cvx exec } def /b { (a) 0 () /SubFileDecode filter cvx exec } def a", "too deeply");
|
||||
refused("/Helvetica findfont 10 scalefont setfont { pop pop pop } 1000000 string cshow", "too long");
|
||||
// The program's own file, made executable, is already running.
|
||||
blue("currentfile cvx exec currentfile flushfile 0 0 1 setrgbcolor");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cshow_runs_its_procedure_for_each_character() {
|
||||
check("/n 0 def { pop pop pop /n n 1 add def } (abc) cshow n 3 eq");
|
||||
// Each call gets the character's code and width.
|
||||
check("/c 0 def { pop pop /c exch def } (A) cshow c 65 eq");
|
||||
check(
|
||||
"/Helvetica findfont 10 scalefont setfont /w 0 def { pop w add /w exch def pop } (ab) cshow \
|
||||
(ab) stringwidth pop w sub abs 0.001 lt",
|
||||
);
|
||||
}
|
||||
|
||||
/// A `w` × `h` 8-bit palette TIFF with an alpha sample (`ExtraSamples` [1]), uncompressed in
|
||||
/// strips of `per` rows, in either byte order, as the reference app writes its EPS previews:
|
||||
/// pixel (x, y) is entry `(x + y) % 3` of a red, green, blue map, at alpha `12 x`.
|
||||
|
||||
@@ -165,6 +165,20 @@ impl Interp<'_> {
|
||||
self.pop()?;
|
||||
self.show(&s)?;
|
||||
}
|
||||
CShow => {
|
||||
// The procedure shows or places each character: it gets its code and width.
|
||||
let s = self.pop_str()?.to_vec();
|
||||
let proc = self.pop_proc()?;
|
||||
for b in s {
|
||||
let (_, _, v) = self.set_type(&[b], Point::ZERO)?;
|
||||
self.push(Obj::Int(i64::from(b)))?;
|
||||
self.push_num(v.x)?;
|
||||
self.push_num(v.y)?;
|
||||
if !self.body(&proc)? {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
GlyphShow => {
|
||||
let name = self.pop()?.text().unwrap_or_else(|| Rc::from(""));
|
||||
let ch = glyph_char(&name);
|
||||
@@ -224,6 +238,10 @@ impl Interp<'_> {
|
||||
/// `s` (read as Latin-1) set in the current font at user point `at`: point type placed there,
|
||||
/// its layout in text space, and how far it moves the current point (user space).
|
||||
fn set_type(&mut self, s: &[u8], at: Point) -> Res<(TextObject, TextLayout, Vec2)> {
|
||||
// Laying out type costs about as much as 300 operations, and 64 more a character
|
||||
// (measured), so `{ (a) stringwidth pop pop } loop` or `cshow` on a long string ends
|
||||
// within the budget like any other loop instead of running for minutes.
|
||||
self.spend((s.len() as u64).saturating_mul(64).saturating_add(300))?;
|
||||
let (name, f1) = self.font()?;
|
||||
// Text space (y down, `size` points an em) onto the document.
|
||||
let m = self.xf() * Affine::translate(at.to_vec2()) * f1;
|
||||
|
||||
@@ -380,6 +380,8 @@ pub fn load_file(bytes: &[u8]) -> Result<NativeFile, FormatError> {
|
||||
// Saved before v3: the assigned profiles move out of `unknown`.
|
||||
doc.migrate_color_profiles();
|
||||
doc.fix_next_id();
|
||||
// Untrusted: capped, named and pointing at objects the file has.
|
||||
doc.tidy_saved_selections();
|
||||
let profiles = f.profiles.into_iter().filter_map(|(name, icc)| Some((name, base64_decode(&icc.data)?))).collect();
|
||||
Ok(NativeFile { doc, info: FileInfo { version: f.version, legacy: f.format == LEGACY_EXTENSION }, profiles })
|
||||
}
|
||||
|
||||
@@ -203,3 +203,35 @@ fn bug_f64_bit_exact_roundtrip() {
|
||||
d.raster_effects_ppi = 0.1 + 0.2;
|
||||
check_native_roundtrip_exact(&d).unwrap();
|
||||
}
|
||||
|
||||
/// Saved selections come from the file: older files have none, and a hostile one is cut down to
|
||||
/// what the Select menu can list, with names that fit and ids the document has.
|
||||
#[test]
|
||||
fn saved_selections_from_a_file_are_tidied() {
|
||||
let d = rich_doc();
|
||||
let mut v: Value = serde_json::from_slice(&save(&d, false)).unwrap();
|
||||
assert!(v["document"].get("saved_selections").is_none(), "nothing written when there are none");
|
||||
assert!(load(&serde_json::to_vec(&v).unwrap()).unwrap().saved_selections.is_empty());
|
||||
let id = fixtures::all_ids(&d).into_iter().next().unwrap();
|
||||
let missing = fixtures::all_ids(&d).into_iter().map(|NodeId(i)| i).max().unwrap() + 1;
|
||||
let mut list = vec![
|
||||
json!({"name": format!(" {} ", "x".repeat(1000)), "objects": [id.0, missing, id.0]}),
|
||||
json!({"name": " ", "objects": [id.0]}),
|
||||
json!({"name": "Dup", "objects": []}),
|
||||
json!({"name": "Dup", "objects": [id.0]}),
|
||||
];
|
||||
list.extend((0..100).map(|i| json!({"name": format!("S{i}"), "objects": [id.0]})));
|
||||
v["document"]["saved_selections"] = json!(list);
|
||||
let back = load(&serde_json::to_vec(&v).unwrap()).unwrap();
|
||||
let saved = &back.saved_selections;
|
||||
assert_eq!(saved.len(), vectorcraft_doc::SavedSelection::MAX);
|
||||
assert_eq!(saved[0].name, "x".repeat(vectorcraft_doc::SavedSelection::MAX_NAME));
|
||||
assert_eq!(saved[0].objects, vec![id], "a missing id is dropped, a repeated one kept once");
|
||||
assert_eq!((saved[1].name.as_str(), saved[1].objects.len()), ("Dup", 0), "blank names go, the first of a name stays");
|
||||
assert_eq!(saved[2].name, "S0");
|
||||
// What loads saves and loads again unchanged.
|
||||
assert_eq!(&load(&save(&back, false)).unwrap().saved_selections, saved);
|
||||
// Junk in the member is a format error, not a crash.
|
||||
v["document"]["saved_selections"] = json!([{"name": 5, "objects": "x"}]);
|
||||
assert!(load(&serde_json::to_vec(&v).unwrap()).is_err());
|
||||
}
|
||||
+28
-25
@@ -44,7 +44,7 @@ pub static TEMPLATES: &[Template] = &[
|
||||
uri: "vectorcraft://object/{id}",
|
||||
name: "object",
|
||||
title: "One layer or object",
|
||||
description: "The layer or object with this id, with its children, bounds and paint (document.inspect)",
|
||||
description: "The layer or object with this id, with its children, bounds and paint (document.node {summary: true})",
|
||||
mime: "application/json",
|
||||
live: Live::Objects,
|
||||
},
|
||||
@@ -118,7 +118,7 @@ pub fn read(b: &mut dyn Backend, uri: &str) -> Result<Value, ReadError> {
|
||||
return Err(ReadError::Invalid(format!("{} needs a value for `{}`", template.uri, variable_name(template.uri))));
|
||||
}
|
||||
let found = match template.name {
|
||||
"object" => find_object(b, &var).map_err(ReadError::Backend)?,
|
||||
"object" => read_object(b, &var).map_err(ReadError::Backend)?,
|
||||
// The command catalogue is a control method; the rest are engine commands.
|
||||
"command" => find_in(b.call("engine.commands", json!({})).map_err(ReadError::Backend)?, None, "id", &var),
|
||||
"effect" => find_in(exec(b, "effect.list").map_err(ReadError::Backend)?, Some("catalog"), "id", &var),
|
||||
@@ -171,30 +171,21 @@ fn decode(s: &str) -> String {
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
/// The layer or object with this id in the active document, with its subtree.
|
||||
fn find_object(b: &mut dyn Backend, id: &str) -> Result<Option<Value>, String> {
|
||||
let doc = exec(b, "document.inspect")?;
|
||||
Ok(find_node(doc.get("layers"), id))
|
||||
}
|
||||
|
||||
/// Depth-first search of the layer tree for a node whose `id` matches.
|
||||
fn find_node(node: Option<&Value>, id: &str) -> Option<Value> {
|
||||
let nodes = node?.as_array()?;
|
||||
let mut stack: Vec<&Value> = nodes.iter().rev().collect();
|
||||
while let Some(n) = stack.pop() {
|
||||
let hit = match n.get("id") {
|
||||
Some(Value::String(s)) => s == id,
|
||||
Some(Value::Number(number)) => number.to_string() == id,
|
||||
_ => false,
|
||||
};
|
||||
if hit {
|
||||
return Some(n.clone());
|
||||
}
|
||||
if let Some(kids) = n.get("children").and_then(Value::as_array) {
|
||||
stack.extend(kids.iter().rev());
|
||||
}
|
||||
/// The layer or object with this id in the active document, as its compact summary
|
||||
/// (bounds, paint labels, subtree: the `document.inspect` shape for one node).
|
||||
///
|
||||
/// One `document.node {summary: true}` lookup instead of summarizing the whole document
|
||||
/// and searching it. A non-numeric id, or one that names nothing, is "not found" (the
|
||||
/// caller turns it into `-32602`); other failures (no document, an unreachable app) stay
|
||||
/// backend errors.
|
||||
fn read_object(b: &mut dyn Backend, id: &str) -> Result<Option<Value>, String> {
|
||||
let Ok(id) = id.parse::<u64>() else { return Ok(None) };
|
||||
match b.call("engine.execute", json!({"command": "document.node", "params": {"id": id, "summary": true}})) {
|
||||
Ok(v) => Ok(Some(v)),
|
||||
// `EngineError::NoNode`, as both backends word it.
|
||||
Err(e) if e.starts_with("no such object") => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The item in a query command's array whose `field` equals `value`.
|
||||
@@ -251,6 +242,12 @@ mod tests {
|
||||
assert!(read(b.as_mut(), "vectorcraft://object/").is_err());
|
||||
assert!(read(b.as_mut(), "vectorcraft://object/99999").is_err());
|
||||
assert!(read(b.as_mut(), DOC_URI).is_ok());
|
||||
// An id that names nothing is a bad value; without a document the backend says why.
|
||||
for uri in ["vectorcraft://object/99999", "vectorcraft://object/abc"] {
|
||||
assert!(matches!(read(b.as_mut(), uri), Err(ReadError::Invalid(_))), "{uri}");
|
||||
}
|
||||
let mut empty = Box::new(crate::Headless::new());
|
||||
assert!(matches!(read(empty.as_mut(), "vectorcraft://object/1"), Err(ReadError::Backend(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -261,5 +258,11 @@ mod tests {
|
||||
let v = read(b.as_mut(), &format!("vectorcraft://object/{id}")).unwrap();
|
||||
assert_eq!(v["id"].to_string(), id);
|
||||
assert!(v["bounds"].is_object(), "{v}");
|
||||
// Layers resolve too, not just drawn objects.
|
||||
let inspect = b.call("document.inspect", json!({})).unwrap();
|
||||
let layer = inspect["layers"].as_array().and_then(|l| l.first()).expect("a layer");
|
||||
let lid = layer["id"].to_string();
|
||||
let v = read(b.as_mut(), &format!("vectorcraft://object/{lid}")).unwrap();
|
||||
assert_eq!(v["id"].to_string(), lid);
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,9 @@ vectorcraft://swatch/{name} read one thing at a time instead of the whole docume
|
||||
pub struct Server {
|
||||
backend: Box<dyn Backend>,
|
||||
initialized: bool,
|
||||
/// Whether this server's client asked for log records (`logging/setLevel`). The queue is
|
||||
/// process-wide, so a server whose client didn't ask leaves it to the one that did.
|
||||
logging: bool,
|
||||
}
|
||||
|
||||
fn response(id: Value, result: Value) -> Value {
|
||||
@@ -45,7 +48,7 @@ fn error(id: Value, code: i64, message: impl Into<String>) -> Value {
|
||||
|
||||
impl Server {
|
||||
pub fn new(backend: Box<dyn Backend>) -> Self {
|
||||
Self { backend, initialized: false }
|
||||
Self { backend, initialized: false, logging: false }
|
||||
}
|
||||
|
||||
pub fn backend(&mut self) -> &mut dyn Backend {
|
||||
@@ -63,6 +66,9 @@ impl Server {
|
||||
/// line produced them rather than before it. [`serve`] drains this itself; a caller driving
|
||||
/// [`handle_line`] by hand does the same.
|
||||
pub fn take_notifications(&mut self) -> Vec<String> {
|
||||
if !self.logging {
|
||||
return vec![];
|
||||
}
|
||||
crate::logging::drain()
|
||||
.into_iter()
|
||||
.map(|record| json!({"jsonrpc": "2.0", "method": "notifications/message", "params": record}).to_string())
|
||||
@@ -197,6 +203,7 @@ impl Server {
|
||||
Some(given) => (INVALID_PARAMS, format!("unknown level `{given}`")),
|
||||
None => (INVALID_PARAMS, "`level` must be a severity name or null".to_string()),
|
||||
})?;
|
||||
self.logging = level.is_some();
|
||||
log::debug!("client set logging to {}", level.unwrap_or("off"));
|
||||
Ok(json!({}))
|
||||
}
|
||||
|
||||
@@ -233,8 +233,8 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
tool(
|
||||
"open_panel",
|
||||
"Open panel",
|
||||
"Open a panel in the desktop app's dock (e.g. Layers, Swatches, Stroke, Align, Pathfinder, Transform). Desktop app only.",
|
||||
obj(json!({"panel": string("Panel name")}), &["panel"]),
|
||||
"Open a panel in the desktop app's dock by id (layers, swatches, stroke, align, pathfinder, transform, …; case-insensitive, display labels like \"Layers\" work too). Desktop app only.",
|
||||
obj(json!({"panel": string("Panel id or display label")}), &["panel"]),
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
//! Object → Path commands: Simplify, Smooth, Remove redundant points, Add Anchor Points, Average,
|
||||
//! Join, Split Into Grid.
|
||||
|
||||
use kurbo::{ParamCurveArclen, Point, Rect, Vec2};
|
||||
use kurbo::{CubicBez, ParamCurveArclen, Point, Rect, Vec2};
|
||||
use vectorcraft_geom::{Anchor, PathData, SubPath};
|
||||
|
||||
use crate::boolean::{Seg, segs_to_subpath};
|
||||
use crate::fit::{end_tangent, fit_cubics, fit_single, is_straight, sample, start_tangent};
|
||||
use crate::fit::{end_tangent, fit_cubics, fit_single, fit_single_from, is_straight, sample, start_tangent};
|
||||
|
||||
/// Options for [`simplify_with`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -226,6 +226,75 @@ fn merge_pair(a: &Seg, b: &Seg, tol: f64) -> Option<Seg> {
|
||||
(err <= tol).then_some(Seg { c, line: false })
|
||||
}
|
||||
|
||||
/// Remove anchor `index` without opening the path (the Delete Anchor Point tool and Object → Path
|
||||
/// → Remove Anchor Points). Its neighbours keep their handle directions and their facing handles
|
||||
/// are refitted so one cubic follows the two segments that met there; two straight segments
|
||||
/// become one. Removing an end of an open path drops its segment. Returns false, leaving `sp`
|
||||
/// alone, when `index` is out of range.
|
||||
pub fn remove_anchor(sp: &mut SubPath, index: usize) -> bool {
|
||||
let n = sp.anchors.len();
|
||||
if index >= n {
|
||||
return false;
|
||||
}
|
||||
if (sp.closed || (index > 0 && index + 1 < n)) && n >= 3 {
|
||||
let (pi, ni) = ((index + n - 1) % n, (index + 1) % n);
|
||||
if !(sp.segment_is_line(pi) && sp.segment_is_line(index)) {
|
||||
let (left, right) = (sp.segment(pi), sp.segment(index));
|
||||
const N: usize = 16;
|
||||
let mut pts = Vec::with_capacity(2 * N + 1);
|
||||
sample(&left, N, &mut pts, true);
|
||||
sample(&right, N, &mut pts, false);
|
||||
let (t0, t1) = (start_tangent(&left), end_tangent(&right));
|
||||
// The new curve passes the removed point at some parameter k: the left samples sit
|
||||
// at k·i/N, the right ones at k + (1 − k)·i/N. Search k for the closest fit.
|
||||
let fit = |k: f64| {
|
||||
let u = (0..=N).map(|i| k * i as f64 / N as f64).chain((1..=N).map(|i| k + (1.0 - k) * i as f64 / N as f64)).collect();
|
||||
fit_single_from(&pts, u, t0, t1)
|
||||
};
|
||||
let (c, _, _) = golden_min(fit, 0.0, 1.0);
|
||||
if c.p1.is_finite() && c.p2.is_finite() {
|
||||
sp.anchors[pi].h_out = c.p1;
|
||||
sp.anchors[ni].h_in = c.p2;
|
||||
}
|
||||
}
|
||||
}
|
||||
sp.anchors.remove(index);
|
||||
if !sp.closed {
|
||||
// A new end has nothing beyond it.
|
||||
if let Some(a) = sp.anchors.first_mut() {
|
||||
a.h_in = a.p;
|
||||
}
|
||||
if let Some(a) = sp.anchors.last_mut() {
|
||||
a.h_out = a.p;
|
||||
}
|
||||
}
|
||||
if sp.anchors.len() < 3 {
|
||||
sp.closed = sp.closed && sp.anchors.len() == 2 && sp.anchors.iter().any(|a| a.has_in() || a.has_out());
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The fit `f` (curve, error, worst point) with the least error for an argument in `[a, b]`, by a
|
||||
/// golden-section search (a bounded number of steps).
|
||||
fn golden_min(f: impl Fn(f64) -> (CubicBez, f64, usize), mut a: f64, mut b: f64) -> (CubicBez, f64, usize) {
|
||||
const R: f64 = 0.618_033_988_749_895;
|
||||
let (mut x1, mut x2) = (b - R * (b - a), a + R * (b - a));
|
||||
let (mut f1, mut f2) = (f(x1), f(x2));
|
||||
for _ in 0..40 {
|
||||
// A NaN error never wins.
|
||||
if f1.1 < f2.1 || f2.1.is_nan() {
|
||||
(b, x2, f2) = (x2, x1, f1);
|
||||
x1 = b - R * (b - a);
|
||||
f1 = f(x1);
|
||||
} else {
|
||||
(a, x1, f1) = (x1, x2, f2);
|
||||
x2 = a + R * (b - a);
|
||||
f2 = f(x2);
|
||||
}
|
||||
}
|
||||
if f1.1 <= f2.1 { f1 } else { f2 }
|
||||
}
|
||||
|
||||
/// Object → Path → Add Anchor Points: one new anchor at the middle (t = 0.5) of every segment.
|
||||
pub fn add_anchor_points(path: &PathData) -> PathData {
|
||||
let mut out = path.clone();
|
||||
|
||||
@@ -69,7 +69,12 @@ fn fit_rec(pts: &[Point], t0: Vec2, t1: Vec2, tol: f64, out: &mut Vec<CubicBez>,
|
||||
/// Best single cubic for `pts` with the given end tangents. Returns (curve, max error, index of
|
||||
/// the worst point).
|
||||
pub(crate) fn fit_single(pts: &[Point], t0: Vec2, t1: Vec2) -> (CubicBez, f64, usize) {
|
||||
let mut u = chord_params(pts);
|
||||
fit_single_from(pts, chord_params(pts), t0, t1)
|
||||
}
|
||||
|
||||
/// [`fit_single`] starting from the parameters `u` (one per point, 0 to 1) instead of the
|
||||
/// chord-length ones.
|
||||
pub(crate) fn fit_single_from(pts: &[Point], mut u: Vec<f64>, t0: Vec2, t1: Vec2) -> (CubicBez, f64, usize) {
|
||||
let mut best = generate(pts, &u, t0, t1);
|
||||
let (mut best_err, mut best_split) = max_error(pts, &best, &u);
|
||||
for _ in 0..MAX_REPARAM {
|
||||
|
||||
@@ -15,7 +15,8 @@ mod planar;
|
||||
|
||||
pub use boolean::{BoolOp, DEFAULT_PRECISION, area, boolean, boolean_n, normalize, try_boolean, try_normalize, unite_all};
|
||||
pub use edit::{
|
||||
AverageAxis, SimplifyOptions, add_anchor_points, average, join, remove_redundant_points, simplify, simplify_with, smooth, split_into_grid,
|
||||
AverageAxis, SimplifyOptions, add_anchor_points, average, join, remove_anchor, remove_redundant_points, simplify, simplify_with, smooth,
|
||||
split_into_grid,
|
||||
};
|
||||
pub use offset::{Cap, Join, offset_path, outline_stroke, stroke_region};
|
||||
pub use pathfinder::{PathfinderOp, Region, Shape, merge_regions, pathfinder, region_at, regions};
|
||||
|
||||
@@ -437,3 +437,49 @@ fn timing_unite_1000_circles() {
|
||||
println!("unite 1000 circles: {} ms, {} anchors", t.elapsed().as_millis(), r[0].path.anchor_count());
|
||||
assert!(!r.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_anchor_refits_the_curve_round_it() {
|
||||
use vectorcraft_geom::Anchor;
|
||||
let (p0, p3) = (Point::new(0.0, 0.0), Point::new(100.0, 0.0));
|
||||
let original =
|
||||
SubPath::new(vec![Anchor::with_handles(p0, p0, Point::new(30.0, 80.0)), Anchor::with_handles(p3, Point::new(70.0, 80.0), p3)], false);
|
||||
let mut sp = original.clone();
|
||||
let i = sp.insert_anchor(0, 0.4);
|
||||
assert!(remove_anchor(&mut sp, i));
|
||||
assert_eq!(sp.anchors.len(), 2);
|
||||
assert!(sp.anchors[0].h_out.distance(original.anchors[0].h_out) < 0.5, "{:?}", sp.anchors[0].h_out);
|
||||
assert!(sp.anchors[1].h_in.distance(original.anchors[1].h_in) < 0.5, "{:?}", sp.anchors[1].h_in);
|
||||
// A circle stays closed and round with one anchor fewer.
|
||||
let mut c = circle(0.0, 0.0, 50.0).subpaths.remove(0);
|
||||
let n = c.anchors.len();
|
||||
assert!(remove_anchor(&mut c, 1));
|
||||
assert!(c.closed && c.anchors.len() == n - 1);
|
||||
let a = ar(&PathData::single(c));
|
||||
assert!(close(a, PI * 2500.0, 0.03), "{a}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_anchor_between_straight_sides_draws_a_straight_one() {
|
||||
let mut sp = rect(0.0, 0.0, 100.0, 80.0).subpaths.remove(0);
|
||||
let n = sp.anchors.len();
|
||||
assert!(remove_anchor(&mut sp, 0));
|
||||
assert!(sp.closed && sp.anchors.len() == n - 1);
|
||||
assert!(!sp.anchors.iter().any(|a| a.has_in() || a.has_out()), "{sp:?}");
|
||||
let mut line = SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(40.0, 30.0), Point::new(100.0, 0.0)], false);
|
||||
assert!(remove_anchor(&mut line, 1));
|
||||
assert!(line.anchors.len() == 2 && line.segment_is_line(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_anchor_at_an_end_drops_its_segment_and_rejects_bad_input() {
|
||||
let mut sp = SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(20.0, 5.0)], false);
|
||||
assert!(!remove_anchor(&mut sp, 3));
|
||||
assert!(remove_anchor(&mut sp, 0));
|
||||
assert_eq!(sp.anchors[0].p, Point::new(10.0, 0.0));
|
||||
// Non-finite points never panic.
|
||||
let mut bad = SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(f64::NAN, 1.0), Point::new(f64::INFINITY, 0.0)], false);
|
||||
bad.anchors[0].h_out = Point::new(5.0, f64::NAN);
|
||||
assert!(remove_anchor(&mut bad, 1));
|
||||
assert_eq!(bad.anchors.len(), 2);
|
||||
}
|
||||
@@ -15,13 +15,14 @@ vectorcraft-effects = { workspace = true }
|
||||
vectorcraft-brush = { workspace = true }
|
||||
vectorcraft-color = { workspace = true }
|
||||
vectorcraft-text = { workspace = true }
|
||||
skrifa = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
flate2 = { workspace = true }
|
||||
image = { workspace = true }
|
||||
krilla = { workspace = true }
|
||||
hayro-syntax = "0.7.2"
|
||||
hayro-interpret = { version = "0.7.0", default-features = false, features = ["embed-fonts"] }
|
||||
hayro-interpret = { version = "0.7.0", default-features = false, features = ["embed-fonts", "embed-cmaps"] }
|
||||
log = { workspace = true }
|
||||
smallvec = "1"
|
||||
serde = { workspace = true }
|
||||
|
||||
+243
-25
@@ -20,10 +20,10 @@ use vectorcraft_doc::{
|
||||
use vectorcraft_geom::{FillRule, PathData};
|
||||
|
||||
use crate::import_color::{Colors, Native};
|
||||
use crate::import_mask::{MaskSpec, contains, is_rectangle, luminance, mask_spec};
|
||||
use crate::import_mask::{MaskSpec, contains, is_rectangle, luminance, mask_spec, white_cover};
|
||||
use crate::import_scan::{Ocgs, Scan, all_on, hides_forms, scan_page};
|
||||
use crate::import_shading::{clipped, extend_clip, fold_stop_opacity, mesh_shading, shading_gradient};
|
||||
use crate::import_text::{Families, Look, Placement, TextLine};
|
||||
use crate::import_text::{Families, Look, Placement, TextLine, Upright};
|
||||
use crate::{CropTo, ImportOptions, ImportReport, PdfError, TextAs};
|
||||
|
||||
/// The name of the paths text imports as.
|
||||
@@ -112,6 +112,11 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
|
||||
b.begin_page(scan);
|
||||
interpret_page(page, &mut ctx, &mut b);
|
||||
let mut parts = b.end_page();
|
||||
// A PDF-compatible `.ai` (the editor's private data next to the PDF art).
|
||||
let ai = crate::pages::has_private_data(page);
|
||||
if ai {
|
||||
drop_page_fill(&mut parts, xf.transform_rect_bbox(crate::pages::page_box(page, CropTo::Crop)));
|
||||
}
|
||||
// A file with several artboards writes, on each page, the art of its neighbours that
|
||||
// reaches into the page's box: art lying wholly outside this page is theirs (each page
|
||||
// draws it shifted by its own artboard spacing), so keep it only where it belongs.
|
||||
@@ -122,7 +127,7 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
|
||||
parts.retain(|(_, art)| !art.is_empty());
|
||||
}
|
||||
let children: Vec<Arc<Node>> = parts.iter().flat_map(|(_, v)| v.iter().cloned()).collect();
|
||||
placeholder &= crate::pages::has_private_data(page) && only_text(&children);
|
||||
placeholder &= ai && only_text(&children);
|
||||
let mut right = ab.x1;
|
||||
if opts.crop == CropTo::Bounding
|
||||
&& let Some(art) = vectorcraft_doc::live::nodes_bounds(&children)
|
||||
@@ -220,6 +225,155 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
|
||||
Ok(ImportReport { document: doc, warnings, native: crate::editing::editing_in(&pdf) })
|
||||
}
|
||||
|
||||
/// The characters of a CID-keyed CFF font embedded without a ToUnicode map (as macOS writes
|
||||
/// Hiragino): each glyph's CID from the font's charset, then Adobe's CID → Unicode table of its
|
||||
/// character collection (Japan1, GB1, CNS1, Korea1).
|
||||
pub(crate) struct CidText {
|
||||
/// CID of each glyph.
|
||||
cids: Vec<u16>,
|
||||
table: hayro_interpret::hayro_cmap::CMap,
|
||||
}
|
||||
|
||||
impl CidText {
|
||||
pub(crate) fn of(data: &[u8]) -> Option<Self> {
|
||||
// An embedded CFF of a few CJK glyphs is kilobytes; whole fonts are some megabytes.
|
||||
if data.len() > 64 << 20 {
|
||||
return None;
|
||||
}
|
||||
use hayro_interpret::hayro_cmap::{CMap, CMapName, load_embedded};
|
||||
use skrifa::raw::ps::cff::{CffFontRef, dict, v1::Cff};
|
||||
use skrifa::raw::{FontData, FontRead};
|
||||
let cff = Cff::read(FontData::new(data)).ok()?;
|
||||
let top = cff.top_dicts().get(0)?;
|
||||
let ordering = dict::entries(top, None).filter_map(Result::ok).find_map(|e| match e {
|
||||
dict::Entry::Ros { ordering, .. } => Some(ordering),
|
||||
_ => None,
|
||||
})?;
|
||||
let name = match cff.string(ordering)? {
|
||||
b"Japan1" => CMapName::AdobeJapan1Ucs2,
|
||||
b"GB1" => CMapName::AdobeGb1Ucs2,
|
||||
b"CNS1" => CMapName::AdobeCns1Ucs2,
|
||||
b"Korea1" => CMapName::AdobeKorea1Ucs2,
|
||||
_ => return None,
|
||||
};
|
||||
let table = CMap::parse(load_embedded(name)?, load_embedded)?;
|
||||
let charset = CffFontRef::new(data, 0, None).ok()?.charset()?;
|
||||
let cids = (0..charset.num_glyphs().min(65_536)).map(|g| charset.string_id(skrifa::GlyphId::new(g)).map_or(0, |s| s.to_u16())).collect();
|
||||
Some(Self { cids, table })
|
||||
}
|
||||
|
||||
fn unicode(&self, glyph: u32) -> Option<hayro_interpret::hayro_cmap::BfString> {
|
||||
let cid = *self.cids.get(glyph as usize)?;
|
||||
self.table.lookup_bf_string(u32::from(cid))
|
||||
}
|
||||
}
|
||||
|
||||
/// The ideographs of the Kangxi radicals U+2F00–U+2FD5 (their NFKC forms): the CID → Unicode
|
||||
/// tables of some PDFs map 龍 to the radical ⿓, which looks the same but isn't the character.
|
||||
const KANGXI: &str = "一丨丶丿乙亅二亠人儿入八冂冖冫几凵刀力勹匕匚匸十卜卩厂厶又口囗土士夂夊夕大女子宀寸小尢尸屮山巛工己巾干幺广廴廾弋弓彐彡彳心戈戶手支攴文斗斤方无日曰月木欠止歹殳毋比毛氏气水火爪父爻爿片牙牛犬玄玉瓜瓦甘生用田疋疒癶白皮皿目矛矢石示禸禾穴立竹米糸缶网羊羽老而耒耳聿肉臣自至臼舌舛舟艮色艸虍虫血行衣襾見角言谷豆豕豸貝赤走足身車辛辰辵邑酉釆里金長門阜隶隹雨靑非面革韋韭音頁風飛食首香馬骨高髟鬥鬯鬲鬼魚鳥鹵鹿麥麻黃黍黑黹黽鼎鼓鼠鼻齊齒龍龜龠";
|
||||
|
||||
/// How far glyph `o` (the PDF's, for `text`) is from the installed `face`'s glyph for it: the
|
||||
/// largest difference between their boxes, in thousandths of an em. `None` when they are clearly
|
||||
/// different glyphs (or the face lacks the character); `Some(0)` when there is nothing to compare
|
||||
/// (several characters, a vertical form, no ink in either).
|
||||
fn glyph_deviation(face: &vectorcraft_text::FontFace, text: &str, o: &hayro_interpret::font::OutlineGlyph, vertical: bool) -> Option<f64> {
|
||||
let Some(c) = comparable(text, vertical) else { return Some(0.0) };
|
||||
let gid = face.glyph_for(c);
|
||||
if gid == 0 {
|
||||
return None;
|
||||
}
|
||||
let embedded = o.outline();
|
||||
let installed = vectorcraft_text::FontDb::global().outline(face, gid);
|
||||
match (embedded.elements().is_empty(), installed.elements().is_empty()) {
|
||||
(true, true) => Some(0.0),
|
||||
(false, false) => {
|
||||
let e = embedded.bounding_box();
|
||||
let k = 1000.0 / face.units_per_em();
|
||||
// Installed outlines are y-down; the PDF's glyph space is y-up.
|
||||
let i = installed.bounding_box();
|
||||
let i = Rect::new(i.x0 * k, -i.y1 * k, i.x1 * k, -i.y0 * k);
|
||||
let off = [(e.x0, i.x0), (e.x1, i.x1), (e.y0, i.y0), (e.y1, i.y1)].iter().fold(0.0f64, |m, (a, b)| m.max((a - b).abs()));
|
||||
(off <= 100.0).then_some(off)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The face of `face`'s family that draws glyph `o` (for `text`): `face` itself, or the style whose
|
||||
/// glyph is closest (a variable font's named instances, or a family whose PostScript names don't
|
||||
/// tell its styles apart, draw a character differently in each style). `None`: no style of the
|
||||
/// family has that glyph, so the font isn't the installed one.
|
||||
fn matching_face(
|
||||
face: &Arc<vectorcraft_text::FontFace>,
|
||||
text: &str,
|
||||
o: &hayro_interpret::font::OutlineGlyph,
|
||||
vertical: bool,
|
||||
) -> Option<Arc<vectorcraft_text::FontFace>> {
|
||||
let own = glyph_deviation(face, text, o, vertical);
|
||||
if own == Some(0.0) {
|
||||
return Some(face.clone());
|
||||
}
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let others =
|
||||
db.styles(&face.family).into_iter().filter(|s| !s.eq_ignore_ascii_case(&face.style)).take(64).filter_map(|s| db.face(&face.family, &s));
|
||||
let mut best = own.map(|d| (d, face.clone()));
|
||||
for f in others {
|
||||
if let Some(d) = glyph_deviation(&f, text, o, vertical)
|
||||
&& best.as_ref().is_none_or(|(b, _)| d < *b)
|
||||
{
|
||||
best = Some((d, f));
|
||||
}
|
||||
}
|
||||
best.map(|(_, f)| f)
|
||||
}
|
||||
|
||||
/// The character of glyph `text` when its outline can be compared with an installed font's: a
|
||||
/// single character, and not CJK punctuation set vertically (its vertical form differs).
|
||||
fn comparable(text: &str, vertical: bool) -> Option<char> {
|
||||
let mut chars = text.chars();
|
||||
let (Some(c), None) = (chars.next(), chars.next()) else { return None };
|
||||
if vertical && matches!(c as u32, 0x2014 | 0x2015 | 0x2025 | 0x2026 | 0x3000..=0x303F | 0x30FC | 0xFE30..=0xFE4F | 0xFF00..=0xFF65) {
|
||||
return None;
|
||||
}
|
||||
Some(c)
|
||||
}
|
||||
|
||||
pub(crate) fn unify_radical(c: char) -> char {
|
||||
let i = (c as u32).wrapping_sub(0x2F00);
|
||||
if i < 214 { KANGXI.chars().nth(i as usize).unwrap_or(c) } else { c }
|
||||
}
|
||||
|
||||
/// The font a PDF font draws with ([`Builder::font_name`]).
|
||||
#[derive(Clone)]
|
||||
struct FontInfo {
|
||||
family: String,
|
||||
style: String,
|
||||
/// The installed face of its PostScript name.
|
||||
face: Option<Arc<vectorcraft_text::FontFace>>,
|
||||
}
|
||||
|
||||
/// The page a PDF-compatible `.ai` paints under its layers is the editor's page, not art: an opaque
|
||||
/// white rectangle the size of the page (`page`, document space), outside every layer and before
|
||||
/// any of them. Drop it from the page's art (`parts`, as [`Builder::end_page`] gives them).
|
||||
fn drop_page_fill(parts: &mut Vec<(Option<usize>, Vec<Arc<Node>>)>, page: Rect) {
|
||||
let layered = parts.iter().any(|(g, _)| g.is_some());
|
||||
let Some((None, art)) = parts.first_mut() else { return };
|
||||
// Not a spot colour: a white ink is art.
|
||||
let fill = art.first().is_some_and(|n| {
|
||||
layered
|
||||
&& n.blend == BlendMode::Normal
|
||||
&& white_cover(n, page)
|
||||
&& n.appearance.fill().is_some_and(|f| matches!(f.paint, Paint::Solid { swatch: None, .. }))
|
||||
&& n.geometric_bounds().is_some_and(|b| contains(page.inflate(0.5, 0.5), b))
|
||||
});
|
||||
if fill {
|
||||
art.remove(0);
|
||||
if art.is_empty() {
|
||||
parts.remove(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Is this art text and nothing else (in groups and clips), with some text?
|
||||
fn only_text(nodes: &[Arc<Node>]) -> bool {
|
||||
fn walk(nodes: &[Arc<Node>], text: &mut bool) -> bool {
|
||||
@@ -311,9 +465,15 @@ struct Builder<'p> {
|
||||
warnings: Vec<String>,
|
||||
/// Fonts by cache key → base font name (from [`scan_page`]).
|
||||
fonts: HashMap<u128, String>,
|
||||
font_names: HashMap<u128, (String, String)>,
|
||||
font_names: HashMap<u128, FontInfo>,
|
||||
families: Option<Families>,
|
||||
missing_fonts: Vec<String>,
|
||||
/// Font (cache key) → its characters by glyph, for CID-keyed fonts embedded without a
|
||||
/// ToUnicode map.
|
||||
cid_text: HashMap<u128, Option<Arc<CidText>>>,
|
||||
/// Font (cache key) → the installed face that draws its glyphs, decided from its first glyph
|
||||
/// that can be compared ([`matching_face`]); `None`: its glyphs differ, so it stays outlines.
|
||||
matched: HashMap<u128, Option<Arc<vectorcraft_text::FontFace>>>,
|
||||
/// The soft mask of the graphics state, the art drawn through it so far, and the masks read.
|
||||
mask: Option<Arc<MaskSpec>>,
|
||||
masked: Vec<Arc<Node>>,
|
||||
@@ -427,6 +587,8 @@ impl<'p> Builder<'p> {
|
||||
font_names: HashMap::new(),
|
||||
families: None,
|
||||
missing_fonts: vec![],
|
||||
cid_text: HashMap::new(),
|
||||
matched: HashMap::new(),
|
||||
mask: None,
|
||||
masked: vec![],
|
||||
masks: HashMap::new(),
|
||||
@@ -844,26 +1006,34 @@ impl<'p> Builder<'p> {
|
||||
self.push_node(content);
|
||||
}
|
||||
|
||||
/// The family and style of font `key` (drawing glyph `o`).
|
||||
fn font_name(&mut self, key: u128, o: &hayro_interpret::font::OutlineGlyph) -> (String, String) {
|
||||
/// The family and style of font `key` (drawing glyph `o`): the installed face of its PostScript
|
||||
/// name, else the available family its name reads as; whether it is available.
|
||||
fn font_name(&mut self, key: u128, o: &hayro_interpret::font::OutlineGlyph) -> FontInfo {
|
||||
if let Some(n) = self.font_names.get(&key) {
|
||||
return n.clone();
|
||||
}
|
||||
let data = o.font_data();
|
||||
let name = self.fonts.get(&key).cloned().or_else(|| data.as_ref().and_then(|d| d.postscript_name.clone()));
|
||||
let (weight, italic) = data.as_ref().map_or((None, false), |d| (d.weight, d.is_italic));
|
||||
let families = self.families.get_or_insert_with(Families::available);
|
||||
let n = match name {
|
||||
Some(name) => {
|
||||
// A subset's six-letter tag.
|
||||
let ps = name.as_deref().map(|n| match n.split_once('+') {
|
||||
Some((tag, rest)) if tag.len() == 6 && tag.chars().all(|c| c.is_ascii_uppercase()) => rest.to_string(),
|
||||
_ => n.to_string(),
|
||||
});
|
||||
let installed = ps.as_deref().and_then(|ps| vectorcraft_text::FontDb::global().find_postscript(ps));
|
||||
let n = match (installed, name) {
|
||||
(Some(face), _) => FontInfo { family: face.family.clone(), style: face.style.clone(), face: Some(face) },
|
||||
(None, Some(name)) => {
|
||||
let families = self.families.get_or_insert_with(Families::available);
|
||||
let (family, style, found) = families.resolve(&name, weight, italic);
|
||||
if !found && !family.is_empty() && !self.missing_fonts.contains(&family) {
|
||||
self.missing_fonts.push(family.clone());
|
||||
}
|
||||
(family, style)
|
||||
FontInfo { family, style, face: None }
|
||||
}
|
||||
None => {
|
||||
(None, None) => {
|
||||
let d = vectorcraft_doc::CharStyle::default();
|
||||
(d.font_family, d.font_style)
|
||||
FontInfo { family: d.font_family, style: d.font_style, face: None }
|
||||
}
|
||||
};
|
||||
self.font_names.insert(key, n.clone());
|
||||
@@ -883,32 +1053,75 @@ impl<'p> Builder<'p> {
|
||||
if self.text_as != TextAs::Text || self.nested > 0 {
|
||||
return false;
|
||||
}
|
||||
let text = match o.as_unicode() {
|
||||
let key = o.font_cache_key();
|
||||
let (unicode, from_cid) = match o.as_unicode() {
|
||||
Some(u) => (Some(u), false),
|
||||
None => {
|
||||
let cid = self.cid_text.entry(key).or_insert_with(|| o.font_data().and_then(|d| CidText::of(d.data.as_ref().as_ref())).map(Arc::new));
|
||||
(cid.as_ref().and_then(|c| c.unicode(o.glyph_id().to_u32())), true)
|
||||
}
|
||||
};
|
||||
let text: String = match unicode {
|
||||
Some(BfString::Char(c)) => c.to_string(),
|
||||
Some(BfString::String(s)) => s,
|
||||
None => return false,
|
||||
};
|
||||
// Adobe's CID tables map some ideographs to their look-alike Kangxi radicals.
|
||||
let text: String = if from_cid { text.chars().map(unify_radical).collect() } else { text };
|
||||
let Some(at) = Placement::of(m) else { return false };
|
||||
if text.is_empty() || text.chars().any(|c| c.is_control()) {
|
||||
return false;
|
||||
}
|
||||
self.flush_glyphs();
|
||||
let font = o.font_cache_key();
|
||||
let width = o.advance_width().filter(|w| w.is_finite());
|
||||
// Set vertically (WMode 1): no horizontal advance; its em box is a full em wide.
|
||||
let upright = width.is_some_and(|w| w.abs() <= 1.0);
|
||||
let top = m * kurbo::Point::new(f64::from(width.filter(|w| *w > 1.0).unwrap_or(1000.0)) * 0.5, 880.0);
|
||||
let mut info = self.font_name(key, o);
|
||||
if let Some(face) = &info.face {
|
||||
let decided = match self.matched.get(&key) {
|
||||
Some(m) => m.clone(),
|
||||
// A glyph that can't be compared (several characters, a vertical form) decides
|
||||
// nothing: it takes the face of its name until one that can be compared does.
|
||||
None if comparable(&text, upright).is_none() => Some(face.clone()),
|
||||
None => {
|
||||
let m = matching_face(face, &text, o, upright);
|
||||
self.matched.insert(key, m.clone());
|
||||
m
|
||||
}
|
||||
};
|
||||
let Some(f) = decided else {
|
||||
self.warn("text whose glyphs differ from the installed font of the same name was kept as outlines");
|
||||
return false;
|
||||
};
|
||||
info = FontInfo { family: f.family.clone(), style: f.style.clone(), face: Some(f) };
|
||||
}
|
||||
let (paint, opacity) = self.paint(paint, stroke.is_some());
|
||||
let stroke = stroke.map(|p| (paint.clone(), p.line_width as f64 * scale));
|
||||
let look = Look {
|
||||
font: key,
|
||||
family: info.family,
|
||||
style: info.style,
|
||||
size: at.size,
|
||||
h_scale: at.h_scale,
|
||||
fill: stroke.is_none().then_some(paint),
|
||||
stroke,
|
||||
};
|
||||
let advance = width.map_or(at.size * 0.5, |w| w as f64 / 1000.0 * at.size * at.h_scale / 100.0);
|
||||
let place = |line: &mut TextLine| {
|
||||
if upright { line.push_upright(&look, at, opacity, Upright { top }, &text) } else { line.push(&look, at, opacity, advance, &text) }
|
||||
};
|
||||
self.flush_glyphs();
|
||||
// A stroke over the glyph just filled (fill and stroke rendering).
|
||||
if let (Some(s), Some(line)) = (&stroke, &mut self.text)
|
||||
&& line.stroke_last(font, at, s.clone())
|
||||
if let (Some(s), Some(line)) = (&look.stroke, &mut self.text)
|
||||
&& look.fill.is_none()
|
||||
&& line.stroke_last(key, at, s.clone())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
let (family, style) = self.font_name(font, o);
|
||||
let look = Look { font, family, style, size: at.size, h_scale: at.h_scale, fill: stroke.is_none().then_some(paint), stroke };
|
||||
let advance = o.advance_width().filter(|w| w.is_finite()).map_or(at.size * 0.5, |w| w as f64 / 1000.0 * at.size * at.h_scale / 100.0);
|
||||
if !self.text.as_mut().is_some_and(|l| l.push(&look, at, opacity, advance, &text)) {
|
||||
if !self.text.as_mut().is_some_and(place) {
|
||||
self.flush_text();
|
||||
let mut line = TextLine::new(at, opacity);
|
||||
line.push(&look, at, opacity, advance, &text);
|
||||
place(&mut line);
|
||||
self.text = Some(line);
|
||||
}
|
||||
true
|
||||
@@ -1196,7 +1409,6 @@ impl<'a> Device<'a> for Builder<'_> {
|
||||
if self.nested > 0 {
|
||||
return;
|
||||
}
|
||||
self.flush();
|
||||
let at = self.tag_at;
|
||||
self.tag_at += 1;
|
||||
let group = match self.scan.tags.get(at) {
|
||||
@@ -1210,6 +1422,11 @@ impl<'a> Device<'a> for Builder<'_> {
|
||||
}
|
||||
}
|
||||
.filter(|_| self.route);
|
||||
// Only a layer's marked content ends the runs gathered so far: other tags (a span with its
|
||||
// actual text, an artifact) leave a line of type whole.
|
||||
if group.is_some() {
|
||||
self.flush();
|
||||
}
|
||||
if let Some(g) = group
|
||||
&& self.stack.len() > 1
|
||||
&& let Some(f) = self.stack.last_mut()
|
||||
@@ -1223,7 +1440,8 @@ impl<'a> Device<'a> for Builder<'_> {
|
||||
if self.nested > 0 {
|
||||
return;
|
||||
}
|
||||
self.flush();
|
||||
self.marked.pop();
|
||||
if self.marked.pop().flatten().is_some() {
|
||||
self.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,11 @@ fn cover(n: &Node, page: Rect) -> Option<(Color, f32)> {
|
||||
(covers && n.mask.is_none()).then_some((*color, f.opacity * n.opacity))
|
||||
}
|
||||
|
||||
/// An opaque white rectangle covering `page`, filled only.
|
||||
pub(crate) fn white_cover(n: &Node, page: Rect) -> bool {
|
||||
cover(n, page).is_some_and(|(c, a)| a >= 0.999 && luminance(&c) > 0.999)
|
||||
}
|
||||
|
||||
/// Does `outer` contain `inner`?
|
||||
pub(crate) fn contains(outer: Rect, inner: Rect) -> bool {
|
||||
outer.x0 <= inner.x0 && outer.y0 <= inner.y0 && outer.x1 >= inner.x1 && outer.y1 >= inner.y1
|
||||
@@ -105,7 +110,7 @@ pub(crate) fn mask_spec(
|
||||
if art.len() > 1
|
||||
&& let Some(last) = art.last()
|
||||
&& last.blend == BlendMode::Difference
|
||||
&& cover(last, page).is_some_and(|(c, a)| a >= 0.999 && luminance(&c) > 0.999)
|
||||
&& white_cover(last, page)
|
||||
{
|
||||
art.pop();
|
||||
invert = !invert;
|
||||
|
||||
@@ -11,7 +11,7 @@ use std::collections::HashMap;
|
||||
|
||||
use kurbo::{Affine, BezPath, Point, Vec2};
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{CharStyle, TextKind, TextObject, TextRun};
|
||||
use vectorcraft_doc::{CharStyle, ParaDirection, TextKind, TextObject, TextRun};
|
||||
|
||||
/// One glyph's placement: its baseline origin, advance direction, size and horizontal scale.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -48,6 +48,13 @@ impl Placement {
|
||||
}
|
||||
}
|
||||
|
||||
/// A glyph set vertically (WMode 1: no horizontal advance) and the top centre of its em box: a
|
||||
/// column of them is vertical type.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct Upright {
|
||||
pub top: Point,
|
||||
}
|
||||
|
||||
/// What a run of type is drawn with: its font (cache key, family and style), size, horizontal
|
||||
/// scale, fill and stroke (paint and width).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -101,6 +108,10 @@ pub(crate) struct TextLine {
|
||||
/// Each glyph's baseline origin, then where the last one ends.
|
||||
baseline: Vec<Point>,
|
||||
runs: Vec<(Look, String)>,
|
||||
/// The first glyph was set vertically ([`Upright`]).
|
||||
upright: Option<Upright>,
|
||||
/// The glyphs make a column (vertical type), each below the last.
|
||||
column: bool,
|
||||
}
|
||||
|
||||
/// The most a glyph on a curve turns from the one before it (about 20°).
|
||||
@@ -108,12 +119,58 @@ const CURVE_TURN_COS: f64 = 0.94;
|
||||
|
||||
impl TextLine {
|
||||
pub fn new(at: Placement, opacity: f32) -> Self {
|
||||
Self { at, opacity, next: at.origin, last: at.origin, last_dir: at.dir, baseline: vec![], runs: vec![] }
|
||||
Self { at, opacity, next: at.origin, last: at.origin, last_dir: at.dir, baseline: vec![], runs: vec![], upright: None, column: false }
|
||||
}
|
||||
|
||||
/// Add glyph `text` set vertically (`upright`) if it continues this column (or makes this
|
||||
/// line's single glyph a column): below the last one, in the same column. `false`: it starts
|
||||
/// another line.
|
||||
pub fn push_upright(&mut self, look: &Look, at: Placement, opacity: f32, upright: Upright, text: &str) -> bool {
|
||||
let down = -self.at.up();
|
||||
let size = self.at.size.max(at.size);
|
||||
let Some((last, run)) = self.runs.last_mut() else {
|
||||
self.runs.push((look.clone(), text.to_string()));
|
||||
(self.upright, self.next, self.last) = (Some(upright), at.origin + down * at.size, at.origin);
|
||||
return true;
|
||||
};
|
||||
let across = (at.origin - self.at.origin).dot(self.at.dir);
|
||||
let step = (at.origin - self.next).dot(down);
|
||||
// Only glyphs set vertically (WMode 1) come here, so a second one below the first, one em
|
||||
// on or letter-spaced up to another em, makes a column.
|
||||
let first_step = (at.origin - self.last).dot(down);
|
||||
let starts_column = !self.column && self.upright.is_some() && first_step > size * 0.95 && first_step < size * 2.0;
|
||||
let in_column = (self.column || starts_column)
|
||||
&& opacity == self.opacity
|
||||
&& at.dir.dot(self.at.dir) > 0.9995
|
||||
&& across.abs() < size * 0.6
|
||||
&& step > -size * 0.3
|
||||
&& step < size * 1.2;
|
||||
if !in_column {
|
||||
return false;
|
||||
}
|
||||
self.column = true;
|
||||
// Letter-spaced Japanese is common: a gap reads as a space only when a whole em is left
|
||||
// out (the tracking set in `finish` keeps narrower gaps).
|
||||
if step > size * 0.9 && !run.ends_with(' ') && !text.starts_with(' ') {
|
||||
run.push(' ');
|
||||
}
|
||||
if last.takes(look) {
|
||||
run.push_str(text);
|
||||
} else {
|
||||
self.runs.push((look.clone(), text.to_string()));
|
||||
}
|
||||
self.next = at.origin + down * at.size;
|
||||
self.last = at.origin;
|
||||
true
|
||||
}
|
||||
|
||||
/// Add glyph `text` drawn with `look` at `at` (advancing `advance` points) at `opacity` if
|
||||
/// it continues this line; `false`: it starts another.
|
||||
pub fn push(&mut self, look: &Look, at: Placement, opacity: f32, advance: f64, text: &str) -> bool {
|
||||
// A vertical line (even a single upright glyph) takes no horizontal glyphs.
|
||||
if self.column || self.upright.is_some() {
|
||||
return false;
|
||||
}
|
||||
if let Some((last, run)) = self.runs.last_mut() {
|
||||
let size = self.at.size.max(at.size);
|
||||
let gap = (at.origin - self.next).dot(self.last_dir);
|
||||
@@ -205,11 +262,46 @@ impl TextLine {
|
||||
let first = runs.next()?;
|
||||
let mut t = TextObject::point(Point::ORIGIN, &first.text, first.style);
|
||||
t.runs.extend(runs);
|
||||
t.xf = Affine::translate(self.at.origin.to_vec2()) * Affine::rotate(self.at.dir.atan2());
|
||||
if self.upright.is_none() {
|
||||
to_logical(&mut t);
|
||||
}
|
||||
let angle = self.at.dir.atan2();
|
||||
t.xf = Affine::translate(self.at.origin.to_vec2()) * Affine::rotate(angle);
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
if let Some(path) = path {
|
||||
t.kind = TextKind::OnPath { path: vectorcraft_geom::PathData::from_bezpath(&path), start: 0.0 };
|
||||
t.xf = Affine::IDENTITY;
|
||||
t.cached_bounds = Some(vectorcraft_text::layout(db, &t).bounds);
|
||||
return Some((t, self.opacity));
|
||||
}
|
||||
let (start, along) = match self.upright {
|
||||
// Vertical point type (a column, or a single upright glyph) is anchored at the top
|
||||
// centre of its first em box.
|
||||
Some(u) => {
|
||||
t.vertical = true;
|
||||
t.xf = Affine::translate(u.top.to_vec2()) * Affine::rotate(angle);
|
||||
(self.at.origin, -self.at.up())
|
||||
}
|
||||
_ => (self.at.origin, self.at.dir),
|
||||
};
|
||||
// Tracking that makes the line as long as in the file: the PDF placed each glyph, the
|
||||
// layout sets them by their advances. Spread over the gaps between characters (tracking
|
||||
// follows each character, the last one's past the end of the line).
|
||||
let length = (self.next - start).dot(along);
|
||||
let laid = vectorcraft_text::layout(db, &t);
|
||||
let natural: f64 = laid.glyphs.iter().map(|g| g.advance).sum();
|
||||
let chars: usize = t.runs.iter().map(|r| r.text.chars().count()).sum();
|
||||
let size = self.at.size;
|
||||
let mut bounds = laid.bounds;
|
||||
if chars > 1 && length.is_finite() && (length - natural).abs() > size * 0.01 {
|
||||
let tracking = ((length - natural) / (chars - 1) as f64 / size * 1000.0).clamp(-1000.0, 1000.0).round();
|
||||
for r in &mut t.runs {
|
||||
r.style.tracking = tracking;
|
||||
}
|
||||
// Tracked: laid out again for its bounds.
|
||||
bounds = vectorcraft_text::layout(db, &t).bounds;
|
||||
}
|
||||
t.cached_bounds = Some(bounds);
|
||||
Some((t, self.opacity))
|
||||
}
|
||||
}
|
||||
@@ -281,3 +373,20 @@ impl Families {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hebrew or Arabic comes from a PDF in visual order (each glyph where it is drawn): put `t`'s text
|
||||
/// back in logical order, with the paragraph direction that shows it as drawn.
|
||||
fn to_logical(t: &mut TextObject) {
|
||||
let Some((order, rtl)) = vectorcraft_text::logical_order(&t.plain_text()) else { return };
|
||||
let chars: Vec<(char, usize)> = t.runs.iter().enumerate().flat_map(|(i, r)| r.text.chars().map(move |c| (c, i))).collect();
|
||||
let mut runs: Vec<(usize, String)> = Vec::with_capacity(t.runs.len());
|
||||
for &(c, i) in order.iter().filter_map(|&k| chars.get(k)) {
|
||||
match runs.last_mut() {
|
||||
Some((run, text)) if *run == i => text.push(c),
|
||||
_ => runs.push((i, c.to_string())),
|
||||
}
|
||||
}
|
||||
let runs = runs.into_iter().filter_map(|(i, text)| Some(TextRun { text, style: t.runs.get(i)?.style.clone() })).collect();
|
||||
t.runs = runs;
|
||||
t.para.direction = Some(if rtl { ParaDirection::RightToLeft } else { ParaDirection::LeftToRight });
|
||||
}
|
||||
@@ -232,6 +232,8 @@ mod tests_import_options;
|
||||
#[cfg(test)]
|
||||
mod tests_layers;
|
||||
#[cfg(test)]
|
||||
mod tests_live_text;
|
||||
#[cfg(test)]
|
||||
mod tests_marks;
|
||||
#[cfg(test)]
|
||||
mod tests_output;
|
||||
|
||||
@@ -134,3 +134,61 @@ fn an_encrypted_file_keeps_its_hidden_layers() {
|
||||
assert!(d.layers[0].visible && !d.layers[1].visible);
|
||||
assert_eq!(colors(&d.layers[1]), [Color::rgb(0.0, 0.0, 1.0)]);
|
||||
}
|
||||
|
||||
/// A page drawing `before`, white and red squares in "Shapes", then `after`; `ai`: with an
|
||||
/// editor's private data (a PDF-compatible `.ai`).
|
||||
fn page_fill(before: &str, after: &str, ai: bool) -> Document {
|
||||
let (objs, catalog, ids) = groups(1, "");
|
||||
let page = PdfPage {
|
||||
resources: format!(
|
||||
"/Properties << /MC0 {} 0 R >> /ExtGState << /GS0 << /ca 0.5 >> >> /ColorSpace << /W [/Separation /White /DeviceCMYK << /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [0 0 0 0] /N 1 >>] >>",
|
||||
ids[0]
|
||||
),
|
||||
entries: if ai { "/PieceInfo << /Illustrator << /Private << /AIPrivateData1 7 /NumBlock 1 >> >> >>".into() } else { String::new() },
|
||||
..PdfPage::new(200.0, 100.0, &format!("{before} /OC /MC0 BDC 1 1 1 rg 20 20 60 60 re f 1 0 0 rg 120 20 60 60 re f EMC {after}"))
|
||||
};
|
||||
import(&pdf_with_catalog(&[page], &[&objs[0], &objs[1]], &catalog, None)).unwrap()
|
||||
}
|
||||
|
||||
fn path_count(d: &Document) -> usize {
|
||||
let mut n = 0;
|
||||
d.walk(|c| n += usize::from(matches!(c.kind, NodeKind::Path { .. })));
|
||||
n
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ai_files_white_page_outside_its_layers_is_not_art() {
|
||||
// RGB, grey and CMYK white, the rectangle drawn either way round.
|
||||
for fill in ["1 1 1 rg 0 0 200 100 re f", "1 g 0 100 200 -100 re f", "0 0 0 0 k 0 0 200 100 re f"] {
|
||||
let d = page_fill(fill, "", true);
|
||||
assert_eq!(names(&d), ["Shapes"], "{fill}");
|
||||
assert_eq!(path_count(&d), 2, "{fill}");
|
||||
assert_eq!(colors(&d.layers[0]), [Color::rgb(1.0, 1.0, 1.0), Color::rgb(1.0, 0.0, 0.0)], "{fill}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_fill_that_is_art_stays() {
|
||||
// A plain PDF's white background is art, as are an .ai's fills that aren't a plain white
|
||||
// page under its layers: grey, smaller or larger than the page, stroked, translucent or a
|
||||
// white ink.
|
||||
let page = "1 1 1 rg 0 0 200 100 re f";
|
||||
let kept = [
|
||||
(page, false),
|
||||
("0.9 g 0 0 200 100 re f", true),
|
||||
("1 g 10 10 180 80 re f", true),
|
||||
("1 g -20 -20 240 140 re f", true),
|
||||
("1 g 0 G 0 0 200 100 re B", true),
|
||||
("/GS0 gs 1 g 0 0 200 100 re f", true),
|
||||
("/W cs 1 scn 0 0 200 100 re f", true),
|
||||
];
|
||||
for (fill, ai) in kept {
|
||||
let d = page_fill(fill, "", ai);
|
||||
assert_eq!(names(&d), ["Page 1", "Shapes"], "{fill}");
|
||||
assert_eq!(path_count(&d), 3, "{fill}");
|
||||
}
|
||||
// Painted over the layers.
|
||||
let d = page_fill("", page, true);
|
||||
assert_eq!(names(&d), ["Shapes", "Page 1"]);
|
||||
assert_eq!(path_count(&d), 3);
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
//! PDF text in installed fonts imports as live type (one point type object per line).
|
||||
|
||||
use krilla::geom::{Point as KPoint, Transform};
|
||||
use krilla::page::PageSettings;
|
||||
use krilla::text::{Font, TextDirection};
|
||||
use vectorcraft_doc::{Document, NodeKind, TextObject};
|
||||
|
||||
use crate::*;
|
||||
|
||||
const SOURCE_SANS: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf");
|
||||
|
||||
/// A 300 × 200 pt page with `lines` of (x, y from the top, size, text) in Source Sans 3, and the
|
||||
/// same rotated 90° when `rotated`.
|
||||
fn text_pdf(lines: &[(f32, f32, f32, &str)], rotated: bool) -> Vec<u8> {
|
||||
let mut pdf = krilla::Document::new();
|
||||
let mut page = pdf.start_page_with(PageSettings::from_wh(300.0, 200.0).unwrap());
|
||||
let mut s = page.surface();
|
||||
let font = Font::new(SOURCE_SANS.into(), 0).unwrap();
|
||||
if rotated {
|
||||
s.push_transform(&Transform::from_row(0.0, 1.0, -1.0, 0.0, 150.0, 20.0));
|
||||
}
|
||||
for &(x, y, size, text) in lines {
|
||||
s.draw_text(KPoint::from_xy(x, y), font.clone(), size, text, false, TextDirection::Auto);
|
||||
}
|
||||
if rotated {
|
||||
s.pop();
|
||||
}
|
||||
s.finish();
|
||||
page.finish();
|
||||
pdf.finish().unwrap()
|
||||
}
|
||||
|
||||
fn texts(d: &Document) -> Vec<TextObject> {
|
||||
let mut v = vec![];
|
||||
d.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
v.push((**t).clone());
|
||||
}
|
||||
});
|
||||
v
|
||||
}
|
||||
|
||||
fn ink_width(t: &TextObject) -> f64 {
|
||||
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).glyphs.iter().map(|g| g.advance).sum()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_of_text_in_installed_fonts_come_in_as_point_type() {
|
||||
let bytes = text_pdf(&[(20.0, 50.0, 24.0, "Gagaku Concert"), (20.0, 90.0, 12.0, "Hall A")], false);
|
||||
let r = import_with_report(&bytes, &ImportOptions::default()).unwrap();
|
||||
let t = texts(&r.document);
|
||||
assert_eq!(t.len(), 2, "{t:?}");
|
||||
let first = t.iter().find(|t| t.plain_text() == "Gagaku Concert").unwrap();
|
||||
let st = first.first_style();
|
||||
assert_eq!((st.font_family.as_str(), st.font_style.as_str()), ("Source Sans 3", "Regular"));
|
||||
assert!((st.size - 24.0).abs() < 0.01);
|
||||
let origin = first.xf * kurbo::Point::ZERO;
|
||||
assert!((origin.x - 20.0).abs() < 0.01 && (origin.y - 50.0).abs() < 0.01, "on the PDF's baseline: {origin:?}");
|
||||
assert!(st.tracking.abs() <= 1.0, "set as VectorCraft sets it: {}", st.tracking);
|
||||
assert!(t.iter().any(|t| t.plain_text() == "Hall A" && (t.first_style().size - 12.0).abs() < 0.01));
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("outlines")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_lines_keep_their_angle_and_outline_text_option_outlines_everything() {
|
||||
let bytes = text_pdf(&[(0.0, 0.0, 18.0, "Sideways")], true);
|
||||
let d = import_with_report(&bytes, &ImportOptions::default()).unwrap().document;
|
||||
let t = texts(&d);
|
||||
assert_eq!(t.len(), 1);
|
||||
let dir = t[0].xf * kurbo::Point::new(1.0, 0.0) - t[0].xf * kurbo::Point::ZERO;
|
||||
assert!(dir.x.abs() < 1e-6 && (dir.y - 1.0).abs() < 1e-6, "the baseline runs down the page: {dir:?}");
|
||||
let outlined = import_with_report(&bytes, &ImportOptions { text_as: TextAs::Outlines, ..Default::default() }).unwrap();
|
||||
assert!(texts(&outlined.document).is_empty());
|
||||
assert!(outlined.warnings.iter().any(|w| w.contains("outlines")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn letter_spacing_in_the_pdf_becomes_tracking() {
|
||||
// Source Sans 3 at 20 pt, every letter 2 pt further apart than its advance.
|
||||
let mut x = 20.0;
|
||||
let mut lines = vec![];
|
||||
let word = "Wide";
|
||||
let font = vectorcraft_text::FontDb::global().find_postscript("SourceSans3-Regular").unwrap();
|
||||
let mut pieces = vec![];
|
||||
for c in word.chars() {
|
||||
pieces.push((x, c.to_string()));
|
||||
x += font.advance(font.glyph_for(c)) as f32 / font.units_per_em() as f32 * 20.0 + 2.0;
|
||||
}
|
||||
for (x, c) in &pieces {
|
||||
lines.push((*x, 60.0, 20.0, c.as_str()));
|
||||
}
|
||||
let d = import_with_report(&text_pdf(&lines, false), &ImportOptions::default()).unwrap().document;
|
||||
let t = texts(&d);
|
||||
assert_eq!(t.len(), 1, "{t:?}");
|
||||
assert_eq!(t[0].plain_text(), "Wide");
|
||||
assert!((t[0].first_style().tracking - 100.0).abs() <= 1.0, "2 pt at 20 pt = 100/1000 em: {}", t[0].first_style().tracking);
|
||||
let expected = (x - 2.0 - 20.0) as f64 + 2.0;
|
||||
assert!((ink_width(&t[0]) - expected).abs() < 0.3, "{} vs {expected}", ink_width(&t[0]));
|
||||
}
|
||||
|
||||
/// Embedded font data is untrusted: anything that isn't a CID-keyed CFF is no table, never a panic.
|
||||
#[test]
|
||||
fn cid_tables_ignore_fonts_that_are_not_cid_keyed_cff() {
|
||||
assert!(crate::import::CidText::of(&[]).is_none());
|
||||
assert!(crate::import::CidText::of(b"not a font at all").is_none());
|
||||
let mut x: u32 = 0x9E37_79B9;
|
||||
for len in [4usize, 64, 1024, 65_536] {
|
||||
let junk: Vec<u8> = (0..len)
|
||||
.map(|_| {
|
||||
x ^= x << 13;
|
||||
x ^= x >> 17;
|
||||
x ^= x << 5;
|
||||
x as u8
|
||||
})
|
||||
.collect();
|
||||
assert!(crate::import::CidText::of(&junk).is_none(), "{len} bytes");
|
||||
// A CFF header in front of the junk.
|
||||
let mut cff = vec![1, 0, 4, 4];
|
||||
cff.extend_from_slice(&junk);
|
||||
let _ = crate::import::CidText::of(&cff);
|
||||
}
|
||||
let sans = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/fonts/SourceSans3-Regular.ttf")).unwrap();
|
||||
assert!(crate::import::CidText::of(&sans).is_none(), "a TrueType font has no CID charset");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kangxi_radicals_from_cid_tables_become_ideographs() {
|
||||
assert_eq!(crate::import::unify_radical('\u{2FD3}'), '龍');
|
||||
assert_eq!(crate::import::unify_radical('\u{2F00}'), '一');
|
||||
assert_eq!(crate::import::unify_radical('笙'), '笙');
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_glyph_whose_unicode_names_another_letter_stays_outlined() {
|
||||
// The glyph of `l` labelled `W` in the PDF's ToUnicode map: opening it as live `W` would change
|
||||
// what the page shows.
|
||||
let font = vectorcraft_text::FontDb::global().find_postscript("SourceSans3-Regular").unwrap();
|
||||
let l = font.glyph_for('l');
|
||||
let mut pdf = krilla::Document::new();
|
||||
let mut page = pdf.start_page_with(PageSettings::from_wh(300.0, 200.0).unwrap());
|
||||
let mut s = page.surface();
|
||||
let kfont = Font::new(SOURCE_SANS.into(), 0).unwrap();
|
||||
let glyph = krilla::text::KrillaGlyph {
|
||||
glyph_id: krilla::text::GlyphId::new(l),
|
||||
text_range: 0..1,
|
||||
x_advance: 0.25,
|
||||
x_offset: 0.0,
|
||||
y_offset: 0.0,
|
||||
y_advance: 0.0,
|
||||
location: None,
|
||||
};
|
||||
s.draw_glyphs(KPoint::from_xy(20.0, 50.0), &[glyph], kfont, "W", 24.0, false);
|
||||
s.finish();
|
||||
page.finish();
|
||||
let r = import_with_report(&pdf.finish().unwrap(), &ImportOptions::default()).unwrap();
|
||||
assert!(texts(&r.document).is_empty(), "{:?}", texts(&r.document));
|
||||
assert!(r.warnings.iter().any(|w| w.contains("differ from the installed font")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// Source Sans 3 renamed `SourceSans9-Regular` (a font no machine has installed).
|
||||
fn uninstalled_font() -> Vec<u8> {
|
||||
let mut bytes = SOURCE_SANS.to_vec();
|
||||
let utf16: Vec<u8> = "SourceSans3-Regular".encode_utf16().flat_map(u16::to_be_bytes).collect();
|
||||
let renamed16: Vec<u8> = "SourceSans9-Regular".encode_utf16().flat_map(u16::to_be_bytes).collect();
|
||||
for (from, to) in [(b"SourceSans3-Regular".to_vec(), b"SourceSans9-Regular".to_vec()), (utf16, renamed16)] {
|
||||
let mut i = 0;
|
||||
while let Some(at) = bytes[i..].windows(from.len()).position(|w| w == from.as_slice()) {
|
||||
bytes[i + at..i + at + from.len()].copy_from_slice(&to);
|
||||
i += at + from.len();
|
||||
}
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_in_a_missing_font_stays_live_type() {
|
||||
for stroke_only in [false, true] {
|
||||
let mut pdf = krilla::Document::new();
|
||||
let mut page = pdf.start_page_with(PageSettings::from_wh(300.0, 200.0).unwrap());
|
||||
let mut s = page.surface();
|
||||
let font = Font::new(uninstalled_font().into(), 0).unwrap();
|
||||
if stroke_only {
|
||||
s.set_fill(None);
|
||||
s.set_stroke(Some(krilla::paint::Stroke::default()));
|
||||
}
|
||||
s.draw_text(KPoint::from_xy(20.0, 50.0), font, 24.0, "Gagaku", false, TextDirection::Auto);
|
||||
s.finish();
|
||||
page.finish();
|
||||
let r = import_with_report(&pdf.finish().unwrap(), &ImportOptions::default()).unwrap();
|
||||
assert_eq!(r.document.layers.len(), 1, "no extra layers");
|
||||
let t = texts(&r.document);
|
||||
assert_eq!(t.len(), 1, "stroke only {stroke_only}");
|
||||
assert_eq!(t[0].plain_text(), "Gagaku");
|
||||
// Named by the family its PostScript name reads as, so it picks the font up once installed.
|
||||
let family = t[0].first_style().font_family.clone();
|
||||
assert_eq!(family, "Source Sans 9");
|
||||
assert!(r.warnings.iter().any(|w| w.contains(family.as_str()) && w.contains("fallback font")), "{:?}", r.warnings);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_upright_glyph_comes_in_as_vertical_type_and_takes_no_horizontal_glyphs() {
|
||||
use crate::import_text::{Look, Placement, TextLine, Upright};
|
||||
use vectorcraft_geom::{Point, Vec2};
|
||||
let look = Look {
|
||||
font: 1,
|
||||
family: "Source Sans 3".into(),
|
||||
style: "Regular".into(),
|
||||
size: 20.0,
|
||||
h_scale: 100.0,
|
||||
fill: Some(vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 0.0))),
|
||||
stroke: None,
|
||||
};
|
||||
let at = |x: f64, y: f64| Placement { origin: Point::new(x, y), dir: Vec2::new(1.0, 0.0), size: 20.0, h_scale: 100.0 };
|
||||
let mut line = TextLine::new(at(100.0, 100.0), 1.0);
|
||||
assert!(line.push_upright(&look, at(100.0, 100.0), 1.0, Upright { top: Point::new(110.0, 82.0) }, "§"));
|
||||
assert!(!line.push(&look, at(130.0, 100.0), 1.0, 10.0, "a"), "a horizontal glyph starts another line");
|
||||
let (t, _) = line.finish().unwrap();
|
||||
assert!(t.vertical, "one upright glyph is vertical type");
|
||||
assert_eq!(t.plain_text(), "§");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn letter_spaced_vertical_type_keeps_its_characters_together() {
|
||||
use crate::import_text::{Look, Placement, TextLine, Upright};
|
||||
use vectorcraft_geom::{Point, Vec2};
|
||||
let look = Look {
|
||||
font: 1,
|
||||
family: "Source Sans 3".into(),
|
||||
style: "Regular".into(),
|
||||
size: 20.0,
|
||||
h_scale: 100.0,
|
||||
fill: Some(vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 0.0))),
|
||||
stroke: None,
|
||||
};
|
||||
// Glyphs set down a column 1.5 em apart (tracking 500): no spaces come in between them.
|
||||
let at = |y: f64| Placement { origin: Point::new(100.0, y), dir: Vec2::new(1.0, 0.0), size: 20.0, h_scale: 100.0 };
|
||||
let mut line = TextLine::new(at(100.0), 1.0);
|
||||
for (i, c) in ["§", "§", "§"].iter().enumerate() {
|
||||
let y = 100.0 + 30.0 * i as f64;
|
||||
assert!(line.push_upright(&look, at(y), 1.0, Upright { top: Point::new(110.0, y - 18.0) }, c));
|
||||
}
|
||||
let (t, _) = line.finish().unwrap();
|
||||
assert!(t.vertical);
|
||||
assert_eq!(t.plain_text(), "§§§", "letter spacing isn't a space");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tagged_span_inside_a_line_leaves_the_line_whole() {
|
||||
use krilla::tagging::{ContentTag, SpanTag};
|
||||
let mut pdf = krilla::Document::new();
|
||||
let mut page = pdf.start_page_with(PageSettings::from_wh(300.0, 200.0).unwrap());
|
||||
let mut s = page.surface();
|
||||
let font = Font::new(SOURCE_SANS.into(), 0).unwrap();
|
||||
// "Ga" "ga" "ku" with the middle part in its own marked content (a span, as apps write for
|
||||
// actual text), all on one baseline.
|
||||
s.draw_text(KPoint::from_xy(20.0, 50.0), font.clone(), 24.0, "Ga", false, TextDirection::Auto);
|
||||
let x = 20.0 + ink_width_of(&font, 24.0, "Ga");
|
||||
s.start_tagged(ContentTag::Span(SpanTag::empty()));
|
||||
s.draw_text(KPoint::from_xy(x, 50.0), font.clone(), 24.0, "ga", false, TextDirection::Auto);
|
||||
s.end_tagged();
|
||||
let x = x + ink_width_of(&font, 24.0, "ga");
|
||||
s.draw_text(KPoint::from_xy(x, 50.0), font, 24.0, "ku", false, TextDirection::Auto);
|
||||
s.finish();
|
||||
page.finish();
|
||||
let d = import(&pdf.finish().unwrap()).unwrap();
|
||||
let t: Vec<String> = texts(&d).iter().map(TextObject::plain_text).collect();
|
||||
assert_eq!(t, ["Gagaku"]);
|
||||
}
|
||||
|
||||
/// Advance of `text` in `font` at `size` (from the bundled Source Sans 3, as laid out).
|
||||
fn ink_width_of(_font: &Font, size: f32, text: &str) -> f32 {
|
||||
let style = vectorcraft_doc::CharStyle { font_family: "Source Sans 3".into(), size: f64::from(size), ..Default::default() };
|
||||
ink_width(&TextObject::point(vectorcraft_geom::Point::ZERO, text, style)) as f32
|
||||
}
|
||||
|
||||
/// Hebrew drawn by a PDF (in visual order, as VectorCraft's own export draws it) comes back as type
|
||||
/// in logical order that shows as drawn. Needs an installed font with Hebrew (skipped without one).
|
||||
#[test]
|
||||
fn hebrew_comes_back_in_logical_order() {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let Some(face) =
|
||||
["Arial", "Noto Sans Hebrew", "DejaVu Sans", "Liberation Sans"].into_iter().filter_map(|f| db.face(f, "Regular")).find(|f| f.covers('ש'))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
for text in ["שלום עולם", "שלום 123", "Hello שלום"] {
|
||||
let style = vectorcraft_doc::CharStyle { font_family: face.family.clone(), size: 24.0, ..Default::default() };
|
||||
let mut d = Document::new(300.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
let n = vectorcraft_doc::Node::new(
|
||||
d.alloc_id(),
|
||||
NodeKind::Text(Box::new(TextObject::point(vectorcraft_geom::Point::new(20.0, 60.0), text, style))),
|
||||
);
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
let settings: PdfSettings = serde_json::from_value(serde_json::json!({"advanced": {"outlineText": false}})).unwrap();
|
||||
let bytes = export_with_report(&d, &PdfOptions { settings, ..Default::default() }).unwrap().bytes;
|
||||
let r = import_with_report(&bytes, &ImportOptions::default()).unwrap();
|
||||
let t = texts(&r.document);
|
||||
assert_eq!(t.iter().map(TextObject::plain_text).collect::<Vec<_>>(), [text], "{:?}", r.warnings);
|
||||
let shown = |t: &TextObject| -> String {
|
||||
let plain = t.plain_text();
|
||||
vectorcraft_text::layout(db, t).glyphs.iter().filter_map(|g| plain.get(g.byte..)?.chars().next()).collect()
|
||||
};
|
||||
let original = TextObject::point(vectorcraft_geom::Point::ZERO, text, vectorcraft_doc::CharStyle::default());
|
||||
assert_eq!(shown(&t[0]), shown(&original), "{text}");
|
||||
}
|
||||
}
|
||||
@@ -211,3 +211,20 @@ fn pdf_a_takes_real_text() {
|
||||
let bytes = export(&d, &PdfOptions { settings, created: Some(0), ..Default::default() }).unwrap();
|
||||
assert_eq!(texts(&import_as(&bytes, TextAs::Text)), ["Archive"]);
|
||||
}
|
||||
|
||||
/// A named instance comes back as live type in that style (not the default instance's).
|
||||
#[test]
|
||||
fn a_variable_font_instance_comes_back_in_its_own_style() {
|
||||
use vectorcraft_text::test_fonts::{VARIABLE_CHARS, VARIABLE_FAMILY, variable_font};
|
||||
FontDb::global().add_font(variable_font().unwrap());
|
||||
let st = CharStyle { font_family: VARIABLE_FAMILY.into(), font_style: "Bold".into(), ..style(48.0) };
|
||||
let d = doc(vec![TextObject::point(Point::new(20.0, 80.0), VARIABLE_CHARS, st)]);
|
||||
let back = crate::import_with_report(&pdf(&d, false).bytes, &ImportOptions { text_as: TextAs::Text, ..Default::default() }).unwrap().document;
|
||||
let mut styles = vec![];
|
||||
back.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
styles.extend(t.runs.iter().map(|r| r.style.font_style.clone()));
|
||||
}
|
||||
});
|
||||
assert_eq!(styles, ["Bold"]);
|
||||
}
|
||||
@@ -330,6 +330,9 @@ pub struct Renderer {
|
||||
/// The keys and sizes of the recoloured images colour adjustments made in `images`, oldest
|
||||
/// first (see [`Self::adjusted_image`]).
|
||||
adjusted: std::collections::VecDeque<(String, usize)>,
|
||||
/// Layer Options → Dim Images to, of the layer being drawn (screen views only): images show
|
||||
/// faded to this opacity over white.
|
||||
dim_images: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
@@ -406,6 +409,7 @@ impl Renderer {
|
||||
ink_images: Default::default(),
|
||||
stroke_slices: PtrMap::default(),
|
||||
adjusted: Default::default(),
|
||||
dim_images: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -794,6 +798,31 @@ impl Renderer {
|
||||
} else if !n.is_container() {
|
||||
return;
|
||||
}
|
||||
// Layer Options on screen: a layer whose Preview is off draws in outline, and Dim Images
|
||||
// fades its images (exports and thumbnails, which leave templates out, ignore both).
|
||||
// (A template is dimmed as a whole already.)
|
||||
if let NodeKind::Layer { preview, dim_images, template, .. } = &n.kind
|
||||
&& f.opts.dim_templates
|
||||
&& !f.opts.skip_templates
|
||||
{
|
||||
if !preview && !f.opts.outline {
|
||||
let opts = RenderOptions { outline: true, ..f.opts.clone() };
|
||||
return self.draw_node(ctx, &Frame { opts: &opts, ..*f }, n, force);
|
||||
}
|
||||
if let Some(p) = dim_images.filter(|_| !template) {
|
||||
let outer = self.dim_images;
|
||||
let dim = f32::from(p).clamp(0.0, 100.0) / 100.0;
|
||||
self.dim_images = Some(outer.map_or(dim, |o| o.min(dim)));
|
||||
self.draw_layer_node(ctx, f, n);
|
||||
self.dim_images = outer;
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.draw_layer_node(ctx, f, n);
|
||||
}
|
||||
|
||||
/// [`Self::draw_node`] after culling and Layer Options.
|
||||
fn draw_layer_node(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node) {
|
||||
if fx::has_object_fx(n) {
|
||||
return self.draw_object_fx(ctx, f, &Arc::new(n.clone()), false);
|
||||
}
|
||||
@@ -1309,6 +1338,21 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
ctx.reset_paint_transform();
|
||||
// A dimmed layer's images: white over them, so they show at that opacity on paper.
|
||||
if let Some(dim) = self.dim_images.filter(|d| *d < 1.0 && !outline) {
|
||||
ctx.set_paint(f.ink.fixed([255, 255, 255, ((1.0 - dim) * 255.0).round() as u8]));
|
||||
match area {
|
||||
Some((bp, rule)) => {
|
||||
ctx.set_transform(f.view);
|
||||
ctx.set_fill_rule(fill_rule(*rule));
|
||||
ctx.fill_path(bp);
|
||||
}
|
||||
None => {
|
||||
ctx.set_transform(f.view * im.xf);
|
||||
ctx.fill_rect(&rect);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if outline {
|
||||
let mut p = rect.to_path(0.1);
|
||||
@@ -1592,6 +1636,8 @@ mod tests_isolation;
|
||||
#[cfg(test)]
|
||||
mod tests_knockout;
|
||||
#[cfg(test)]
|
||||
mod tests_layeropts;
|
||||
#[cfg(test)]
|
||||
mod tests_objectfx;
|
||||
#[cfg(test)]
|
||||
mod tests_setup;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
//! Layer Options on screen: Preview off draws a layer in outline, Dim Images fades its images;
|
||||
//! exports (which leave templates out) draw both as they are.
|
||||
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, ImageBlob, ImageObject, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::shapes;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A red square (10..40) and a 30×30 red image at (50, 10), on one layer set up by `layer`.
|
||||
fn doc(layer: impl FnOnce(&mut NodeKind)) -> Document {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let img = image::RgbaImage::from_pixel(30, 30, image::Rgba([255, 0, 0, 255]));
|
||||
let mut png = vec![];
|
||||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
d.images.insert("red".into(), ImageBlob::new("image/png", png));
|
||||
let l = d.layers[0].id;
|
||||
let red = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
|
||||
let sq = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 40.0, 40.0)), red);
|
||||
d.insert(Some(l), 0, sq).unwrap();
|
||||
let mut im = Node::new(
|
||||
NodeId(0),
|
||||
NodeKind::Image(ImageObject {
|
||||
key: "red".into(),
|
||||
width: 30,
|
||||
height: 30,
|
||||
xf: Affine::translate((50.0, 10.0)),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
}),
|
||||
);
|
||||
im.id = d.alloc_id();
|
||||
d.insert(Some(l), 9, im).unwrap();
|
||||
layer(&mut d.node_mut(l).unwrap().kind);
|
||||
d
|
||||
}
|
||||
|
||||
fn render(d: &Document, export: bool) -> Rendered {
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), skip_templates: export, ..Default::default() };
|
||||
Renderer::new().render(d, 100, 100, Affine::IDENTITY, &opts)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_off_draws_the_layer_in_outline_on_screen_only() {
|
||||
let d = doc(|k| {
|
||||
if let NodeKind::Layer { preview, .. } = k {
|
||||
*preview = false;
|
||||
}
|
||||
});
|
||||
assert_eq!(render(&d, false).pixel(25, 25), [255, 255, 255, 255], "no fill in outline");
|
||||
assert_eq!(render(&d, true).pixel(25, 25), [255, 0, 0, 255], "exports draw it as it is");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dim_images_fades_only_the_images() {
|
||||
let plain = render(&doc(|_| {}), false);
|
||||
assert_eq!(plain.pixel(65, 25), [255, 0, 0, 255]);
|
||||
let d = doc(|k| {
|
||||
if let NodeKind::Layer { dim_images, .. } = k {
|
||||
*dim_images = Some(50);
|
||||
}
|
||||
});
|
||||
let img = render(&d, false);
|
||||
let [r, g, b, _] = img.pixel(65, 25);
|
||||
assert!(r == 255 && (120..=135).contains(&g) && g == b, "half way to white: {:?}", img.pixel(65, 25));
|
||||
assert_eq!(img.pixel(25, 25), [255, 0, 0, 255], "vector art stays");
|
||||
assert_eq!(render(&d, true).pixel(65, 25), [255, 0, 0, 255], "exports are not dimmed");
|
||||
}
|
||||
@@ -545,7 +545,7 @@ impl Rules<'_> {
|
||||
art.props.push(("line-height", self.len(l)));
|
||||
}
|
||||
let align = match t.para.justify {
|
||||
Justify::Left => None,
|
||||
Justify::Auto | Justify::Left => None,
|
||||
Justify::Center => Some("center"),
|
||||
Justify::Right => Some("right"),
|
||||
Justify::JustifyLeft | Justify::JustifyCenter | Justify::JustifyRight | Justify::JustifyAll => Some("justify"),
|
||||
|
||||
@@ -1642,10 +1642,17 @@ impl Writer<'_> {
|
||||
/// (`text-anchor`) at their centre or right end, so they stay aligned in a viewer whose font
|
||||
/// differs; other lines are placed where each style run (and, justified, each word) starts.
|
||||
fn text(&mut self, n: &Node, t: &TextObject) {
|
||||
// Live SVG text is written in logical order from left-aligned pieces: bidirectional text
|
||||
// (and right-to-left paragraphs) keep their look as outlines until it is written with
|
||||
// `direction`/`unicode-bidi`.
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
if lay.glyphs.iter().any(|g| g.rtl) || lay.lines.iter().any(|l| l.rtl) {
|
||||
self.warn("bidirectional text is outlined to preserve shaping and visual order");
|
||||
return self.text_outlines(n, t);
|
||||
}
|
||||
if let TextKind::OnPath { path, start } = &t.kind {
|
||||
return self.text_on_path(n, t, path, *start);
|
||||
}
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
self.note_fonts(t, &lay);
|
||||
let lines = text_lines(t, &lay, self.opts.fewer_tspans);
|
||||
// A tab starts a new chunk at its stop: only lines without tabs can be anchored.
|
||||
@@ -1911,7 +1918,7 @@ struct Segment {
|
||||
/// caps are upper case.
|
||||
fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) -> Vec<Vec<Segment>> {
|
||||
let plain = t.plain_text();
|
||||
let split_words = !fewer && !matches!(t.para.justify, Justify::Left | Justify::Center | Justify::Right);
|
||||
let split_words = !fewer && !matches!(t.para.justify, Justify::Auto | Justify::Left | Justify::Center | Justify::Right);
|
||||
let mut lines = Vec::with_capacity(lay.lines.len());
|
||||
for line in &lay.lines {
|
||||
let mut segs: Vec<Segment> = Vec::new();
|
||||
|
||||
@@ -26,7 +26,7 @@ use std::str::FromStr;
|
||||
|
||||
use usvg::roxmltree;
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{CharStyle, Dash, Justify, LineCap, LineJoin, StrokeLayer, TextKind, TextObject, TextRun};
|
||||
use vectorcraft_doc::{CharStyle, Dash, Justify, LineCap, LineJoin, ParaDirection, StrokeLayer, TextKind, TextObject, TextRun};
|
||||
use vectorcraft_geom::kurbo::ParamCurveArclen;
|
||||
use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect};
|
||||
use vectorcraft_text::{FontDb, TextLayout};
|
||||
@@ -612,6 +612,13 @@ impl Ctx {
|
||||
}
|
||||
let (runs, servers) = assemble(&cells, &breaks).0;
|
||||
obj.runs = runs;
|
||||
// SVG text runs left to right unless `direction: rtl` says otherwise, whatever its first
|
||||
// strong character: pinned when that would read as right to left.
|
||||
if self.css.prop(t, "direction").is_some_and(|d| d.trim() == "rtl") {
|
||||
obj.para.direction = Some(ParaDirection::RightToLeft);
|
||||
} else if obj.plain_text().split('\n').any(|p| vectorcraft_text::paragraph_is_rtl(p, None)) {
|
||||
obj.para.direction = Some(ParaDirection::LeftToRight);
|
||||
}
|
||||
// The placeholder covers the text (laid out when a paint server needs its bounding box).
|
||||
let b = if paints.is_empty() { obj.bounds() } else { Some(obj.xf.transform_rect_bbox(measure(&obj).bounds)) }.unwrap_or_default();
|
||||
let marker = Rect::new(b.x0, b.y0, b.x1.max(b.x0 + 1.0), b.y1.max(b.y0 + 1.0));
|
||||
|
||||
@@ -868,3 +868,16 @@ fn outline_text_writes_glyph_paths() {
|
||||
let b = art(&back).iter().filter_map(|n| n.geometric_bounds()).reduce(|a, b| a.union(b)).unwrap();
|
||||
assert!(b.width() > 80.0 && b.x0 >= 9.0, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bidirectional_svg_export_preserves_shaped_appearance_as_outlines() {
|
||||
let t = TextObject::point(Point::new(10.0, 40.0), "שלום Rust 123 مرحبا", CharStyle::default());
|
||||
let expected =
|
||||
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).glyphs.iter().filter(|g| !g.outline.elements().is_empty()).count();
|
||||
assert!(expected > 0);
|
||||
let d = doc_with(vec![Node::new(NodeId(100), NodeKind::Text(Box::new(t)))]);
|
||||
let svg = export(&d, &ExportOptions::default());
|
||||
assert!(!svg.contains("<text"), "visual clusters must not be emitted as reversed live text");
|
||||
assert!(svg.contains("<path"));
|
||||
assert!(import(&svg).is_ok());
|
||||
}
|
||||
@@ -18,6 +18,9 @@ log = { workspace = true }
|
||||
# Font subsets for embedding (SVG @font-face); MIT OR Apache-2.0, already used by the PDF writer
|
||||
# (which turns on `variable-fonts` too: named instances of variable fonts are instanced).
|
||||
subsetter = { version = "0.2", default-features = false, features = ["variable-fonts"] }
|
||||
unicode-bidi = "0.3.18"
|
||||
unicode-general-category = "1"
|
||||
unicode-script = "0.5"
|
||||
# Builds a small variable font for tests (`test_fonts`; no font file is committed). MIT OR
|
||||
# Apache-2.0, already in the build through subsetter.
|
||||
write-fonts = { version = "0.48", optional = true }
|
||||
|
||||
+207
-5
@@ -45,6 +45,8 @@ enum FontBytes {
|
||||
pub struct FontFace {
|
||||
/// Every name the face answers to (normalized), in every language its name table has.
|
||||
keys: FaceKeys,
|
||||
/// Its kind, for font menus' filters.
|
||||
pub traits: FontTraits,
|
||||
id: u32,
|
||||
/// Typographic family name (e.g. "Source Sans 3").
|
||||
pub family: String,
|
||||
@@ -142,6 +144,35 @@ impl FontFace {
|
||||
.map(|a| a as f64)
|
||||
.unwrap_or(self.upem * 0.5)
|
||||
}
|
||||
/// Glyph `gid` set upright in vertical type, from the font's vertical metrics: (its advance down
|
||||
/// the column, the height above the baseline of its vertical origin, the top of its cell), in
|
||||
/// font units. The origin is the `VORG` table's, else the glyph's top plus its top side bearing.
|
||||
/// `None` when the font has no vertical metrics (`vhea`/`vmtx`), or they make no sense.
|
||||
pub fn vertical_glyph(&self, gid: u32) -> Option<(f64, f64)> {
|
||||
use skrifa::raw::TableProvider;
|
||||
let f = self.skrifa()?;
|
||||
let vmtx = f.vmtx().ok()?;
|
||||
let g = GlyphId::new(gid);
|
||||
let advance = f64::from(vmtx.advance(g)?);
|
||||
let origin = match f.vorg() {
|
||||
Ok(vorg) => f64::from(vorg.vertical_origin_y(g)),
|
||||
Err(_) => {
|
||||
let top = f.glyph_metrics(Size::unscaled(), self.location()).bounds(g).map_or(self.ascent, |b| f64::from(b.y_max));
|
||||
top + f64::from(vmtx.side_bearing(g)?)
|
||||
}
|
||||
};
|
||||
let em = self.upem;
|
||||
// A glyph's cell is somewhere between a tenth of an em and a few ems, its top within a few
|
||||
// ems of the baseline: anything else is a damaged table.
|
||||
(advance > em * 0.1 && advance < em * 4.0 && origin.abs() < em * 4.0).then_some((advance, origin))
|
||||
}
|
||||
/// Height above the baseline of the centre of the face's ideographic em box, in ems: from its
|
||||
/// vertical metrics (an ideograph's cell), else the usual 0.38 (the box running from 0.12 em
|
||||
/// below the baseline to 0.88 em above it).
|
||||
pub fn ideographic_centre(&self) -> f64 {
|
||||
let gid = ['国', 'あ', '一'].into_iter().map(|c| self.glyph_for(c)).find(|g| *g != 0);
|
||||
gid.and_then(|g| self.vertical_glyph(g)).map_or(0.38, |(advance, origin)| (origin - advance * 0.5) / self.upem)
|
||||
}
|
||||
/// Glyph id for `c` (0 = .notdef).
|
||||
pub fn glyph_for(&self, c: char) -> u32 {
|
||||
self.skrifa().and_then(|f| f.charmap().map(c)).map(|g| g.to_u32()).unwrap_or(0)
|
||||
@@ -174,6 +205,8 @@ impl FontFace {
|
||||
struct CatalogFamily {
|
||||
name: String,
|
||||
faces: Vec<CatalogFace>,
|
||||
/// The family's kind (from its first face found).
|
||||
traits: Option<FontTraits>,
|
||||
}
|
||||
|
||||
/// One style of an installed family: a face, or a named instance of a variable font.
|
||||
@@ -326,6 +359,153 @@ impl FaceKeys {
|
||||
}
|
||||
}
|
||||
|
||||
/// A font's kind, for filtering font menus: the classification of its OS/2 table (IBM family
|
||||
/// class, PANOSE) and, for CJK fonts whose tables seldom say, its names (明朝, ゴシック, 丸…).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||||
pub enum FontClass {
|
||||
/// Serif (Latin) or Mincho (CJK).
|
||||
Serif,
|
||||
/// Sans serif (Latin) or Gothic (CJK).
|
||||
Sans,
|
||||
/// Rounded sans / Maru Gothic.
|
||||
Rounded,
|
||||
/// Script, handwriting, brush (楷書・行書・教科書…).
|
||||
Script,
|
||||
Monospaced,
|
||||
/// Decorative, display, symbol and pictorial fonts.
|
||||
Decorative,
|
||||
#[default]
|
||||
Other,
|
||||
}
|
||||
|
||||
impl FontClass {
|
||||
/// The kinds a font menu filters by (all but `Other`).
|
||||
pub const ALL: [FontClass; 6] =
|
||||
[FontClass::Serif, FontClass::Sans, FontClass::Rounded, FontClass::Script, FontClass::Monospaced, FontClass::Decorative];
|
||||
}
|
||||
|
||||
/// What a font menu filters on: whether a family sets Japanese, and its kind.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct FontTraits {
|
||||
pub japanese: bool,
|
||||
pub class: FontClass,
|
||||
}
|
||||
|
||||
impl FontTraits {
|
||||
/// From a face's OS/2 table (when it has one) and names.
|
||||
fn of(font: &skrifa::FontRef<'_>, keys: &FaceKeys) -> Self {
|
||||
use skrifa::raw::TableProvider;
|
||||
let os2 = font.os2().ok();
|
||||
let names: Vec<&str> = keys.families.iter().chain(&keys.postscript).map(String::as_str).collect();
|
||||
let named = |words: &[&str]| names.iter().any(|n| words.iter().any(|w| n.contains(w)));
|
||||
let has_japanese_name = keys.families.iter().any(|k| k.chars().any(|c| matches!(c as u32, 0x3040..=0x30FF | 0x4E00..=0x9FFF)));
|
||||
let (class_id, panose, ranges2, codepages) = match &os2 {
|
||||
Some(o) => (o.s_family_class(), o.panose_10().to_vec(), o.ul_unicode_range_2(), o.ul_code_page_range_1().unwrap_or(0)),
|
||||
None => (0, vec![], 0, 0),
|
||||
};
|
||||
// Hiragana (bit 49) or Katakana (bit 50) in the Unicode ranges, or the JIS code page.
|
||||
let japanese = ranges2 & (0b11 << 17) != 0 || codepages & (1 << 17) != 0 || has_japanese_name;
|
||||
let family_type = panose.first().copied().unwrap_or(0);
|
||||
let serif_style = panose.get(1).copied().unwrap_or(0);
|
||||
let proportion = panose.get(3).copied().unwrap_or(0);
|
||||
let ibm = (class_id >> 8) as u8;
|
||||
let class = if named(&["丸", "maru", "rounded", "round"]) || japanese && named(&["jun"]) {
|
||||
FontClass::Rounded
|
||||
} else if named(&[
|
||||
"楷書",
|
||||
"行書",
|
||||
"草書",
|
||||
"隷書",
|
||||
"教科書",
|
||||
"毛筆",
|
||||
"筆",
|
||||
"kaisho",
|
||||
"gyosho",
|
||||
"sosho",
|
||||
"reisho",
|
||||
"kyokasho",
|
||||
"brush",
|
||||
"script",
|
||||
"hand",
|
||||
"kaiti",
|
||||
"xingkai",
|
||||
"libian",
|
||||
]) {
|
||||
FontClass::Script
|
||||
} else if named(&["mono", "code", "courier", "menlo", "consol"]) {
|
||||
FontClass::Monospaced
|
||||
} else if named(&[
|
||||
"明朝",
|
||||
"mincho",
|
||||
"ryumin",
|
||||
"hiramin",
|
||||
"yumin",
|
||||
"kozmin",
|
||||
"serif",
|
||||
"songti",
|
||||
"simsun",
|
||||
"stsong",
|
||||
"fangsong",
|
||||
"myungjo",
|
||||
"times",
|
||||
"georgia",
|
||||
"garamond",
|
||||
"baskerville",
|
||||
"bodoni",
|
||||
"caslon",
|
||||
"palatino",
|
||||
"didot",
|
||||
"century",
|
||||
"cambria",
|
||||
"minion",
|
||||
"charter",
|
||||
"hoefler",
|
||||
]) && !named(&["sansserif", "sans"])
|
||||
{
|
||||
FontClass::Serif
|
||||
} else if named(&[
|
||||
"ゴシック",
|
||||
"gothic",
|
||||
"goth",
|
||||
"sans",
|
||||
"kakugo",
|
||||
"kaku",
|
||||
"heiti",
|
||||
"helvetica",
|
||||
"arial",
|
||||
"grotesk",
|
||||
"grotesque",
|
||||
"futura",
|
||||
"gill",
|
||||
"verdana",
|
||||
"tahoma",
|
||||
"frutiger",
|
||||
"avenir",
|
||||
"geneva",
|
||||
"lucidagrande",
|
||||
"inter",
|
||||
"roboto",
|
||||
"optima",
|
||||
"pingfang",
|
||||
]) {
|
||||
FontClass::Sans
|
||||
} else if family_type == 2 && proportion == 9 {
|
||||
FontClass::Monospaced
|
||||
} else if family_type == 3 || ibm == 10 {
|
||||
FontClass::Script
|
||||
} else if family_type == 4 || family_type == 5 || ibm == 9 || ibm == 12 {
|
||||
FontClass::Decorative
|
||||
} else if matches!(ibm, 1..=5 | 7) || family_type == 2 && (2..=10).contains(&serif_style) {
|
||||
FontClass::Serif
|
||||
} else if ibm == 8 || family_type == 2 && (11..=13).contains(&serif_style) {
|
||||
FontClass::Sans
|
||||
} else {
|
||||
FontClass::Other
|
||||
};
|
||||
FontTraits { japanese, class }
|
||||
}
|
||||
}
|
||||
|
||||
/// What a name other than a family's own one stands for ([`FontDb::canonical`]).
|
||||
#[derive(Clone, Debug)]
|
||||
struct Alias {
|
||||
@@ -394,14 +574,20 @@ pub(crate) struct FaceStyle {
|
||||
weight: Option<f32>,
|
||||
/// A named instance is italic (by its `ital` or `slnt` setting, or its name).
|
||||
italic: bool,
|
||||
/// Its kind, for font menus' filters (the face's, shared by its named instances).
|
||||
traits: FontTraits,
|
||||
}
|
||||
|
||||
/// The styles of a face: the face itself, then its named instances when it is a variable font.
|
||||
pub(crate) fn face_styles(f: &skrifa::FontRef<'_>) -> Vec<FaceStyle> {
|
||||
let Some((family, style)) = face_names(f) else { return vec![] };
|
||||
let mut keys = FaceKeys::of(f, &family, &style);
|
||||
let instances = named_instances(f, &family, &style, &mut keys);
|
||||
let face = FaceStyle { family, style, keys, variations: vec![], weight: None, italic: false };
|
||||
let traits = FontTraits::of(f, &keys);
|
||||
let mut instances = named_instances(f, &family, &style, &mut keys);
|
||||
for i in &mut instances {
|
||||
i.traits = traits;
|
||||
}
|
||||
let face = FaceStyle { family, style, keys, variations: vec![], weight: None, italic: false, traits };
|
||||
std::iter::once(face).chain(instances).collect()
|
||||
}
|
||||
|
||||
@@ -453,7 +639,7 @@ fn named_instances(f: &skrifa::FontRef<'_>, family: &str, style: &str, keys: &mu
|
||||
}
|
||||
let keys = FaceKeys { families: keys.families.clone(), styles, legacy: vec![], postscript };
|
||||
let variations = tags.iter().zip(&coords).map(|(t, v)| (t.to_be_bytes(), *v)).collect();
|
||||
out.push(FaceStyle { family: family.to_string(), style, keys, variations, weight, italic });
|
||||
out.push(FaceStyle { family: family.to_string(), style, keys, variations, weight, italic, traits: FontTraits::default() });
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -514,11 +700,16 @@ fn file_face_names(path: &Path) -> Vec<FaceStyle> {
|
||||
let name = table(b"name")?;
|
||||
// A variable font's named instances are styles too.
|
||||
let fvar = table(b"fvar");
|
||||
// Its OS/2 table (the font's classification, for the menus' filters), when it has one.
|
||||
let os2 = table(b"OS/2");
|
||||
// A font holding just these tables, to read them as the font itself would.
|
||||
let mut tables: Vec<(&[u8; 4], &[u8])> = vec![(b"name", &name)];
|
||||
if let Some(fvar) = &fvar {
|
||||
tables.push((b"fvar", fvar));
|
||||
}
|
||||
if let Some(os2) = &os2 {
|
||||
tables.push((b"OS/2", os2));
|
||||
}
|
||||
let font = sfnt_of(&tables)?;
|
||||
Some(face_styles(&skrifa::FontRef::new(&font).ok()?))
|
||||
})
|
||||
@@ -593,7 +784,7 @@ fn make_face(bytes: FontBytes, index: u32, spec: FaceStyle, path: Option<std::pa
|
||||
FontBytes::Owned(v) => v.as_slice(),
|
||||
};
|
||||
let f = skrifa::FontRef::from_index(data, index).ok()?;
|
||||
let FaceStyle { family, style, keys, variations, weight, italic } = spec;
|
||||
let FaceStyle { family, style, keys, variations, weight, italic, traits } = spec;
|
||||
// A named instance: outlines, metrics and advances at its axis settings.
|
||||
let location = if variations.is_empty() { Location::default() } else { f.axes().location(variations.iter().map(|(t, v)| (Tag::new(t), *v))) };
|
||||
let m = f.metrics(Size::unscaled(), &location);
|
||||
@@ -604,6 +795,7 @@ fn make_face(bytes: FontBytes, index: u32, spec: FaceStyle, path: Option<std::pa
|
||||
(!variations.is_empty()).then(|| harfrust::ShaperInstance::from_variations(&hb, variations.iter().map(|(t, v)| (harfrust::Tag::new(t), *v))));
|
||||
Some(FontFace {
|
||||
keys,
|
||||
traits,
|
||||
id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
|
||||
family,
|
||||
style,
|
||||
@@ -869,7 +1061,7 @@ impl FontDb {
|
||||
if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) {
|
||||
continue;
|
||||
}
|
||||
for FaceStyle { family, style, keys, weight, italic, .. } in file_face_names(&p) {
|
||||
for FaceStyle { family, style, keys, weight, italic, traits, .. } in file_face_names(&p) {
|
||||
for (k, a) in Alias::of(&family, &style, &keys) {
|
||||
aliases.entry(k).or_default().push(a);
|
||||
}
|
||||
@@ -878,6 +1070,7 @@ impl FontDb {
|
||||
postscript.insert(ps.clone(), (family.clone(), style.clone()));
|
||||
}
|
||||
let entry = catalog.entry(family.to_ascii_lowercase()).or_default();
|
||||
entry.traits.get_or_insert(traits);
|
||||
if entry.name.is_empty() {
|
||||
entry.name = family;
|
||||
}
|
||||
@@ -925,6 +1118,15 @@ impl FontDb {
|
||||
}
|
||||
}
|
||||
|
||||
/// The kind of `family` (whether it sets Japanese, its class), for font menu filters: from a
|
||||
/// loaded face, else from the system font scan. Cheap: no font file is read.
|
||||
pub fn family_traits(&self, family: &str) -> FontTraits {
|
||||
if let Some(f) = self.read_faces().iter().find(|f| f.family.eq_ignore_ascii_case(family)) {
|
||||
return f.traits;
|
||||
}
|
||||
self.read_catalog().get(&family.to_ascii_lowercase()).and_then(|c| c.traits).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// [`face`](Self::face) by any of the family's names ([`canonical`](Self::canonical)), and how
|
||||
/// it matched.
|
||||
pub fn resolve(&self, family: &str, style: &str) -> Option<(Arc<FontFace>, FontMatch)> {
|
||||
|
||||
+263
-24
@@ -3,12 +3,13 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use kurbo::{Affine, BezPath, ParamCurve, ParamCurveArclen, PathEl, PathSeg, Point, Rect, Shape, Vec2};
|
||||
use vectorcraft_doc::{CharStyle, Justify, ParaStyle, PathEffect, TextKind, TextObject};
|
||||
use unicode_bidi::{BidiInfo, Level};
|
||||
use vectorcraft_doc::{CharStyle, Justify, Mojikumi, ParaDirection, ParaStyle, PathEffect, TextKind, TextObject};
|
||||
|
||||
use crate::composer::{Breakpoint, compose};
|
||||
use crate::fontdb::FontDb;
|
||||
use crate::hyphen::hyphen_points;
|
||||
use crate::shape::{SGlyph, Tcy, cap_x_heights, hyphen_glyph, no_line_end, no_line_start, shape_range, style_metrics};
|
||||
use crate::shape::{Punct, SGlyph, Tcy, cap_x_heights, hyphen_glyph, is_cjk, no_line_end, no_line_start, punct, shape_range, style_metrics};
|
||||
use crate::{Composer, FirstBaseline, LayoutOptions, LineInfo, OtFeatures, PositionedGlyph, TextLayout};
|
||||
|
||||
const EPS: f64 = 1e-6;
|
||||
@@ -33,13 +34,16 @@ impl Ctx<'_> {
|
||||
find(b).or_else(|| b.checked_sub(1).and_then(find)).or_else(|| self.runs.first().map(|(_, s)| *s)).unwrap_or(&self.default)
|
||||
}
|
||||
|
||||
fn shape_para(&self, r: Range<usize>) -> Vec<SGlyph> {
|
||||
/// Shape paragraph `r` (its bidi resolution `bidi`).
|
||||
fn shape_para(&self, r: Range<usize>, bidi: Option<&BidiInfo<'_>>) -> Vec<SGlyph> {
|
||||
let mut v = Vec::with_capacity(r.len());
|
||||
shape_range(self.db, self.text, r, &self.runs, &self.opts.features, &mut v);
|
||||
let levels = bidi.map_or(&[][..], |b| &b.levels);
|
||||
shape_range(self.db, self.text, r, &self.runs, &self.opts.features, levels, &mut v);
|
||||
if self.vertical {
|
||||
tate_chu_yoko(&mut v, |g| self.style_at(g.byte).size);
|
||||
// An upright glyph advances at least one em down the column (the vertical advance of
|
||||
// CJK fonts), centred in it: a narrow mark like § must not overlap its neighbours.
|
||||
// An upright glyph advances down the column by its vertical advance (the font's vertical
|
||||
// metrics; without them at least one em, as CJK fonts' is), centred across it: a narrow
|
||||
// mark like § must not overlap its neighbours.
|
||||
for g in v.iter_mut().filter(|g| g.adv > 0.0 && stands_upright(g)) {
|
||||
let extra = upright_cell(g) - g.face.advance(g.gid) * g.sx;
|
||||
g.dx += extra * 0.5;
|
||||
@@ -51,7 +55,12 @@ impl Ctx<'_> {
|
||||
|
||||
fn emit(&mut self, g: &SGlyph, pre: Affine, origin: Point, angle: f64, advance: f64, line: usize) {
|
||||
let src = self.db.outline(&g.face, g.gid);
|
||||
let local = Affine::rotate(-g.rotation.to_radians()) * Affine::translate((g.dx, g.dy - g.bshift)) * Affine::scale_non_uniform(g.sx, g.sy);
|
||||
// A glyph whose leading space was taken off (mojikumi) is drawn that much earlier: an
|
||||
// upright one in vertical type by turning about a point that much higher.
|
||||
let upright = self.vertical && !self.on_path && g.tcy.is_none() && stands_upright(g);
|
||||
let lead = if upright { 0.0 } else { g.lead };
|
||||
let local =
|
||||
Affine::rotate(-g.rotation.to_radians()) * Affine::translate((g.dx - lead, g.dy - g.bshift)) * Affine::scale_non_uniform(g.sx, g.sy);
|
||||
let mut m = pre * local;
|
||||
if self.vertical && self.on_path {
|
||||
// Vertical path type keeps the baseline path and turns each glyph across it.
|
||||
@@ -60,17 +69,20 @@ impl Ctx<'_> {
|
||||
// Tate-chu-yoko: the block set across the column, centred on its em of it.
|
||||
let em = self.style_at(g.byte).size;
|
||||
let start = origin.x - t.pen;
|
||||
let centre = Point::new(start + em * 0.5, origin.y - EM_CENTER * em);
|
||||
let centre = Point::new(start + em * 0.5, origin.y - g.face.ideographic_centre() * em);
|
||||
let across = Affine::translate((centre.x - t.width * 0.5 + t.ink - origin.x, 0.0));
|
||||
let squeeze = Affine::translate((centre.x, 0.0)) * Affine::scale_non_uniform(t.squeeze, 1.0) * Affine::translate((-centre.x, 0.0));
|
||||
m = Affine::rotate_about(-std::f64::consts::FRAC_PI_2, centre) * squeeze * across * m;
|
||||
} else if self.vertical && stands_upright(g) {
|
||||
// Turned about the centre of its em box, which the column's centre line runs through:
|
||||
// the middle of its own cell, not of its advance (tracking and justification add space
|
||||
// Turned about the centre of its cell, which the column's centre line runs through: the
|
||||
// middle of its own cell, not of its advance (tracking and justification add space
|
||||
// after the cell, and must not push the glyph off the centre line).
|
||||
let em = self.style_at(g.byte).size;
|
||||
let cell = if g.adv > 0.0 { upright_cell(g) } else { advance };
|
||||
m = Affine::rotate_about(-std::f64::consts::FRAC_PI_2, Point::new(origin.x + cell * 0.5, origin.y - EM_CENTER * em)) * m;
|
||||
m = Affine::rotate_about(
|
||||
-std::f64::consts::FRAC_PI_2,
|
||||
Point::new(origin.x + cell * 0.5 - g.lead, origin.y - upright_centre(g, cell, em)),
|
||||
) * m;
|
||||
}
|
||||
// Control characters (tabs) and soft hyphens draw nothing (fonts map them to .notdef).
|
||||
let outline = if src.elements().is_empty() || g.is_soft_hyphen() || g.ch.is_control() {
|
||||
@@ -95,6 +107,8 @@ impl Ctx<'_> {
|
||||
font_id,
|
||||
gid: g.gid,
|
||||
xf: m,
|
||||
// Vertical type keeps its logical order down the column.
|
||||
rtl: g.level.is_rtl() && !self.vertical,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -149,7 +163,11 @@ pub fn layout_with(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLay
|
||||
let line_xf = match &t.kind {
|
||||
_ if !vertical => Affine::IDENTITY,
|
||||
TextKind::OnPath { .. } => Affine::IDENTITY,
|
||||
TextKind::Point => Affine::translate((-EM_CENTER * cx.style_at(0).size, 0.0)) * QUARTER_TURN,
|
||||
TextKind::Point => {
|
||||
let first = cx.style_at(0);
|
||||
let centre = db.face(&first.font_family, &first.font_style).map_or(EM_CENTER, |f| f.ideographic_centre());
|
||||
Affine::translate((-centre * first.size, 0.0)) * QUARTER_TURN
|
||||
}
|
||||
_ => QUARTER_TURN,
|
||||
};
|
||||
match &t.kind {
|
||||
@@ -214,14 +232,115 @@ fn tate_chu_yoko(g: &mut [SGlyph], size: impl Fn(&SGlyph) -> f64) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Half an em of `g`'s size when it is full-width Japanese punctuation of kind `kind` (its
|
||||
/// advance an em, give or take a tenth), the space that mojikumi can take off.
|
||||
fn punct_half(g: &SGlyph, kind: Punct) -> Option<f64> {
|
||||
if punct(g.ch)? != kind || g.tcy.is_some() {
|
||||
return None;
|
||||
}
|
||||
let em = g.face.units_per_em();
|
||||
((g.face.advance(g.gid) - em).abs() <= em * 0.1).then_some(em * 0.5 * g.sx)
|
||||
}
|
||||
|
||||
/// Mojikumi (JLREQ 3.1.4): consecutive punctuation shares one half-em space. A closing bracket,
|
||||
/// comma or full stop followed by punctuation loses the space after it; an opening bracket after
|
||||
/// another loses the space before it. Returns which glyphs lost the space after them (a line
|
||||
/// ending in one takes nothing more off).
|
||||
fn compress_punctuation(sg: &mut [SGlyph]) -> Vec<bool> {
|
||||
let mut lost_after = vec![false; sg.len()];
|
||||
for j in 1..sg.len() {
|
||||
let (before, after) = sg.split_at_mut(j);
|
||||
let (Some(a), Some(b)) = (before.last_mut(), after.first_mut()) else { continue };
|
||||
if punct(b.ch).is_none() {
|
||||
continue;
|
||||
}
|
||||
if let Some(h) = punct_half(a, Punct::Closing) {
|
||||
a.adv -= h;
|
||||
if let Some(l) = lost_after.get_mut(j - 1) {
|
||||
*l = true;
|
||||
}
|
||||
} else if punct(a.ch) == Some(Punct::Opening)
|
||||
&& let Some(h) = punct_half(b, Punct::Opening)
|
||||
{
|
||||
b.adv -= h;
|
||||
b.lead += h;
|
||||
}
|
||||
}
|
||||
lost_after
|
||||
}
|
||||
|
||||
/// The space between Japanese and Latin letters or digits (JLREQ 3.2.2: a quarter em of the
|
||||
/// Japanese characters' size), with Line-end Punctuation Half Width.
|
||||
const WAKAN_AKI: f64 = 0.25;
|
||||
|
||||
/// A Japanese character for the space next to Latin text: kana and kanji (and full-width
|
||||
/// letters, and the iteration and abbreviation marks 々 〆 〇), not punctuation or symbols, nor
|
||||
/// Hangul (Korean sets a word space instead).
|
||||
fn is_japanese_letter(c: char) -> bool {
|
||||
if matches!(c, '々' | '〆' | '〇') {
|
||||
return true;
|
||||
}
|
||||
is_cjk(c)
|
||||
&& !matches!(c as u32, 0x3000..=0x303F | 0xAC00..=0xD7AF | 0xFF01..=0xFF0F | 0xFF1A..=0xFF20 | 0xFF3B..=0xFF40 | 0xFF5B..=0xFF65)
|
||||
&& c != '・'
|
||||
&& punct(c).is_none()
|
||||
}
|
||||
|
||||
/// A Latin letter or digit (or another script's letter), set proportionally.
|
||||
fn is_latin_letter(c: char) -> bool {
|
||||
c.is_alphanumeric() && !is_cjk(c)
|
||||
}
|
||||
|
||||
/// Mojikumi (JLREQ 3.2.2): a quarter em between a Japanese character and a Latin letter or digit,
|
||||
/// either way round, added after the first of the two. Returns what each glyph got after it (taken
|
||||
/// off again when the line ends there).
|
||||
fn space_japanese_and_latin(sg: &mut [SGlyph]) -> Vec<f64> {
|
||||
let mut added = vec![0.0; sg.len()];
|
||||
for j in 1..sg.len() {
|
||||
let (before, after) = sg.split_at_mut(j);
|
||||
let (Some(a), Some(b)) = (before.last_mut(), after.first()) else { continue };
|
||||
if a.tcy.is_some() || b.tcy.is_some() {
|
||||
continue;
|
||||
}
|
||||
let japanese = if is_japanese_letter(a.ch) && is_latin_letter(b.ch) {
|
||||
Some(&*a)
|
||||
} else if is_latin_letter(a.ch) && is_japanese_letter(b.ch) {
|
||||
Some(b)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(g) = japanese {
|
||||
let aki = WAKAN_AKI * g.face.units_per_em() * g.sx;
|
||||
a.adv += aki;
|
||||
if let Some(x) = added.get_mut(j - 1) {
|
||||
*x = aki;
|
||||
}
|
||||
}
|
||||
}
|
||||
added
|
||||
}
|
||||
|
||||
/// The length an upright glyph takes down the column before tracking and justification: its
|
||||
/// advance, at least one em.
|
||||
/// vertical advance (the font's vertical metrics), else its advance, at least one em.
|
||||
fn upright_cell(g: &SGlyph) -> f64 {
|
||||
(g.face.advance(g.gid) * g.sx).max(g.face.units_per_em() * g.sx)
|
||||
match g.face.vertical_glyph(g.gid) {
|
||||
Some((advance, _)) => advance * g.sy,
|
||||
None => (g.face.advance(g.gid) * g.sx).max(g.face.units_per_em() * g.sx),
|
||||
}
|
||||
}
|
||||
|
||||
/// Height above the baseline (line space) of the centre of an upright glyph's `cell`, the point it
|
||||
/// turns about: from the font's vertical metrics, the cell hanging from the glyph's vertical origin;
|
||||
/// else the centre of the ideographic em box ([`EM_CENTER`] of the size `em`).
|
||||
fn upright_centre(g: &SGlyph, cell: f64, em: f64) -> f64 {
|
||||
match g.face.vertical_glyph(g.gid) {
|
||||
Some((_, origin)) => origin * g.sy - cell * 0.5,
|
||||
None => EM_CENTER * em,
|
||||
}
|
||||
}
|
||||
|
||||
/// Height of the centre of the ideographic em box above the baseline, in ems (the em box runs
|
||||
/// from 0.12 em below the baseline to 0.88 em above it).
|
||||
/// from 0.12 em below the baseline to 0.88 em above it), for fonts without vertical metrics.
|
||||
const EM_CENTER: f64 = 0.38;
|
||||
|
||||
/// A quarter turn clockwise (y down), exact: line space → text space for vertical type.
|
||||
@@ -653,6 +772,17 @@ fn kinsoku_between(before: char, after: Option<char>) -> bool {
|
||||
!no_line_end(before) && after.is_none_or(|c| !no_line_start(c))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn japanese_letters_for_the_latin_space_are_kana_kanji_and_marks_not_hangul_or_punctuation() {
|
||||
for c in ['あ', 'カ', '漢', '々', '〆', '〇', 'A'] {
|
||||
assert!(is_japanese_letter(c), "{c}");
|
||||
}
|
||||
for c in ['한', '。', '「', '・', '、', 'a'] {
|
||||
assert!(!is_japanese_letter(c), "{c}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn kinsoku_keeps_closing_marks_off_line_starts_and_opening_ones_off_line_ends() {
|
||||
@@ -723,7 +853,7 @@ fn candidates(text: &str, g: &[SGlyph], hyphenate: bool) -> Vec<Breakpoint> {
|
||||
/// Every-line composition of paragraph glyphs `sg` if applicable (justified area text with uniform
|
||||
/// line metrics); `None` falls back to the greedy single-line composer.
|
||||
fn compose_para(cx: &Ctx<'_>, sg: &[SGlyph], para: &ParaStyle, pen: &Pen<'_>) -> Option<Vec<(usize, bool)>> {
|
||||
let justified = !matches!(para.justify, Justify::Left | Justify::Center | Justify::Right);
|
||||
let justified = !matches!(para.justify, Justify::Auto | Justify::Left | Justify::Center | Justify::Right);
|
||||
if cx.opts.composer != Composer::EveryLine || !justified || pen.regions.is_none() || sg.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
@@ -759,7 +889,14 @@ fn compose_para(cx: &Ctx<'_>, sg: &[SGlyph], para: &ParaStyle, pen: &Pen<'_>) ->
|
||||
fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, regions: Option<&[Region]>) {
|
||||
let mut pen = Pen { regions, ri: 0, prev: None, pending: 0.0, fb: cx.opts.first_baseline, fb_min: cx.opts.first_baseline_min, queued: vec![] };
|
||||
'paras: for (pi, pr) in paras.iter().enumerate() {
|
||||
let sg = cx.shape_para(pr.clone());
|
||||
let text = cx.text;
|
||||
let bidi = para_bidi(text.get(pr.clone()).unwrap_or_default(), para.direction);
|
||||
let rtl = is_rtl(bidi.as_ref());
|
||||
let mut sg = cx.shape_para(pr.clone(), bidi.as_ref());
|
||||
// Japanese composition: consecutive punctuation shares one half-em space.
|
||||
let compressed = if para.mojikumi == Mojikumi::LineEndHalf { compress_punctuation(&mut sg) } else { vec![] };
|
||||
// …and a quarter em between Japanese and Latin text.
|
||||
let wakan = if para.mojikumi == Mojikumi::LineEndHalf { space_japanese_and_latin(&mut sg) } else { vec![] };
|
||||
let pm = {
|
||||
let (asc, desc, lead) = style_metrics(cx.db, cx.style_at(pr.start));
|
||||
let (cap, xh) = cap_x_heights(cx.db, cx.style_at(pr.start));
|
||||
@@ -817,8 +954,18 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, regions: Opt
|
||||
trimmed -= 1;
|
||||
}
|
||||
let hyphen = (hyph && end > i).then(|| hyphen_glyph(&sg[end - 1]));
|
||||
let w: f64 = sg[i..trimmed].iter().map(|g| g.adv).sum::<f64>() + hyphen.as_ref().map_or(0.0, |h| h.adv);
|
||||
// Mojikumi: a closing bracket, comma or full stop ending the line is set half width, and
|
||||
// no Japanese–Latin space is left at the end of a line.
|
||||
let end_trim = match trimmed.checked_sub(1).filter(|&k| k >= i && para.mojikumi == Mojikumi::LineEndHalf) {
|
||||
Some(k) => {
|
||||
let half = if compressed.get(k).copied().unwrap_or(false) { None } else { sg.get(k).and_then(|g| punct_half(g, Punct::Closing)) };
|
||||
half.unwrap_or(0.0) + wakan.get(k).copied().unwrap_or(0.0)
|
||||
}
|
||||
None => 0.0,
|
||||
};
|
||||
let w: f64 = sg[i..trimmed].iter().map(|g| g.adv).sum::<f64>() + hyphen.as_ref().map_or(0.0, |h| h.adv) - end_trim;
|
||||
let (align, justify) = match para.justify {
|
||||
Justify::Auto => (if rtl { 2 } else { 0 }, false),
|
||||
Justify::Left => (0, false),
|
||||
Justify::Center => (1, false),
|
||||
Justify::Right => (2, false),
|
||||
@@ -828,10 +975,24 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, regions: Opt
|
||||
Justify::JustifyAll => (0, true),
|
||||
};
|
||||
let justify = justify && regions.is_some();
|
||||
let (mut per_space, mut per_gap) = (0.0, 0.0);
|
||||
let (mut per_space, mut per_gap, mut per_cjk) = (0.0, 0.0, 0.0);
|
||||
let spaces = sg[i..trimmed].iter().filter(|g| g.is_space()).count();
|
||||
// Japanese (and Chinese) lines are justified between their characters (JLREQ 3.8): the
|
||||
// gaps next to a CJK character, not inside a Latin word or a tate-chu-yoko block.
|
||||
let cjk_gap = |j: usize| {
|
||||
j + 1 < trimmed
|
||||
&& sg
|
||||
.get(j)
|
||||
.zip(sg.get(j + 1))
|
||||
.is_some_and(|(a, b)| !a.is_space() && !b.is_space() && !b.continues_tcy() && a.ch != '\t' && (is_cjk(a.ch) || is_cjk(b.ch)))
|
||||
};
|
||||
let cjk_gaps = (i..trimmed).filter(|&j| cjk_gap(j)).count();
|
||||
if justify && (width - w).abs() > EPS {
|
||||
if spaces > 0 {
|
||||
if cjk_gaps > 0 && width > w {
|
||||
// Spread over the CJK gaps and the word spaces alike.
|
||||
let per = (width - w) / (cjk_gaps + spaces) as f64;
|
||||
(per_space, per_cjk) = (per, per);
|
||||
} else if spaces > 0 {
|
||||
// Composed lines may shrink word spaces (never below zero).
|
||||
per_space =
|
||||
((width - w) / spaces as f64).max(-sg[i..trimmed].iter().filter(|g| g.is_space()).map(|g| g.adv).fold(f64::MAX, f64::min));
|
||||
@@ -859,17 +1020,33 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, regions: Opt
|
||||
};
|
||||
let li = cx.out.lines.len();
|
||||
let glyph_start = cx.out.glyphs.len();
|
||||
let mut x = start_x;
|
||||
let mut x_end = start_x;
|
||||
// Tab stops are measured from the frame's left edge (point type: the origin).
|
||||
let tab_origin = if regions.is_some() { x0 } else { 0.0 };
|
||||
for (j, g) in sg.iter().enumerate().take(end).skip(i) {
|
||||
let line_start = sg.get(i).map_or(pr.start, |g| g.byte);
|
||||
let line_end = sg.get(end).map_or(pr.end, |g| g.byte);
|
||||
let order = visual_order(bidi.as_ref().filter(|_| !cx.vertical), pr.start, line_start..line_end, &sg[i..end]);
|
||||
// RTL's logical trailing spaces precede the visible content. Keep them outside
|
||||
// the aligned content extent, just as LTR trailing spaces extend to its right.
|
||||
let leading_space_width: f64 =
|
||||
order.iter().take_while(|&&offset| i + offset >= trimmed).filter_map(|&offset| sg.get(i + offset)).map(|g| g.adv).sum();
|
||||
let mut x = start_x - leading_space_width;
|
||||
for offset in order {
|
||||
let j = i + offset;
|
||||
let Some(g) = sg.get(j) else { continue };
|
||||
let mut adv = g.adv;
|
||||
if j + 1 == trimmed {
|
||||
adv -= end_trim;
|
||||
}
|
||||
if g.ch == '\t' {
|
||||
adv = tab_advance(¶.tabs, tab_origin, x, &sg[j + 1..trimmed.max(j + 1)]);
|
||||
} else if j < trimmed {
|
||||
if g.is_space() {
|
||||
adv += per_space;
|
||||
} else if per_cjk != 0.0 {
|
||||
if cjk_gap(j) {
|
||||
adv += per_cjk;
|
||||
}
|
||||
} else if j + 1 < trimmed && sg.get(j + 1).is_some_and(|next| !next.continues_tcy()) {
|
||||
// Between glyphs, never inside a tate-chu-yoko block (one cell).
|
||||
adv += per_gap;
|
||||
@@ -877,7 +1054,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, regions: Opt
|
||||
}
|
||||
cx.emit(g, Affine::translate((x, baseline)), Point::new(x, baseline), 0.0, adv, li);
|
||||
x += adv;
|
||||
if j + 1 == trimmed {
|
||||
if j < trimmed {
|
||||
x_end = x;
|
||||
}
|
||||
}
|
||||
@@ -888,6 +1065,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, regions: Opt
|
||||
x_end = hx + h.adv;
|
||||
}
|
||||
cx.out.lines.push(LineInfo {
|
||||
rtl: rtl && !cx.vertical,
|
||||
baseline,
|
||||
x0: start_x,
|
||||
x1: x_end,
|
||||
@@ -952,9 +1130,20 @@ impl ArcPath {
|
||||
fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, path: &BezPath, start: f64, closed: bool, effect: PathEffect) {
|
||||
cx.out.on_path = true;
|
||||
let mut sg = Vec::new();
|
||||
// The first paragraph's direction aligns the line (Auto) and sets the caret's.
|
||||
let mut rtl = None;
|
||||
for pr in paras {
|
||||
sg.extend(cx.shape_para(pr.clone()));
|
||||
let text = cx.text;
|
||||
let bidi = para_bidi(text.get(pr.clone()).unwrap_or_default(), para.direction);
|
||||
rtl.get_or_insert(is_rtl(bidi.as_ref()) && !cx.vertical);
|
||||
let shaped = cx.shape_para(pr.clone(), bidi.as_ref());
|
||||
for i in visual_order(bidi.as_ref().filter(|_| !cx.vertical), pr.start, pr.clone(), &shaped) {
|
||||
if let Some(g) = shaped.get(i) {
|
||||
sg.push(g.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let rtl = rtl.unwrap_or(false);
|
||||
let ap = ArcPath::new(path);
|
||||
let m = Metrics::max(&sg).map(|m| (m.asc, m.desc)).unwrap_or_else(|| {
|
||||
let s = style_metrics(cx.db, cx.style_at(0));
|
||||
@@ -964,6 +1153,7 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, path: &Be
|
||||
if ap.segs.is_empty() {
|
||||
cx.out.overflow = !sg.is_empty();
|
||||
cx.out.lines.push(LineInfo {
|
||||
rtl,
|
||||
baseline: 0.0,
|
||||
x0: 0.0,
|
||||
x1: 0.0,
|
||||
@@ -982,6 +1172,7 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, path: &Be
|
||||
let avail = if closed { ap.len } else { ap.len - s_start };
|
||||
let w: f64 = sg.iter().map(|g| g.adv).sum();
|
||||
let s0 = match para.justify {
|
||||
Justify::Auto if rtl => s_start + (avail - w).max(0.0),
|
||||
Justify::Center | Justify::JustifyCenter => s_start + ((avail - w) * 0.5).max(0.0),
|
||||
Justify::Right | Justify::JustifyRight => s_start + (avail - w).max(0.0),
|
||||
_ => s_start,
|
||||
@@ -1036,6 +1227,7 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, path: &Be
|
||||
let (ps, _) = ap.at(if closed { s0.rem_euclid(ap.len) } else { s0 });
|
||||
let (pe, _) = ap.at(if closed { (s0 + x).rem_euclid(ap.len) } else { s0 + x });
|
||||
cx.out.lines.push(LineInfo {
|
||||
rtl,
|
||||
baseline: ps.y,
|
||||
x0: ps.x,
|
||||
x1: pe.x,
|
||||
@@ -1048,3 +1240,50 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, path: &Be
|
||||
avail: (0.0, ap.len),
|
||||
});
|
||||
}
|
||||
|
||||
/// The bidirectional resolution (UAX #9) of paragraph `text`, its base direction `direction` or,
|
||||
/// without one, from its first strong character. `None` for the usual paragraph with nothing right
|
||||
/// to left in it (no right-to-left character or direction), which skips the algorithm.
|
||||
pub(crate) fn para_bidi(text: &str, direction: Option<ParaDirection>) -> Option<BidiInfo<'_>> {
|
||||
use unicode_bidi::BidiClass::{AL, AN, FSI, R, RLE, RLI, RLO};
|
||||
let rtl_set = direction == Some(ParaDirection::RightToLeft);
|
||||
if !rtl_set && !text.chars().any(|c| matches!(unicode_bidi::bidi_class(c), R | AL | AN | RLE | RLO | RLI | FSI)) {
|
||||
return None;
|
||||
}
|
||||
let level = direction.map(|d| if d == ParaDirection::RightToLeft { Level::rtl() } else { Level::ltr() });
|
||||
Some(BidiInfo::new(text, level))
|
||||
}
|
||||
|
||||
/// Does the paragraph run right to left ([`para_bidi`])?
|
||||
pub(crate) fn is_rtl(bidi: Option<&BidiInfo<'_>>) -> bool {
|
||||
bidi.and_then(|b| b.paragraphs.first()).is_some_and(|p| p.level.is_rtl())
|
||||
}
|
||||
|
||||
/// The visual order of a line's `glyphs` (indices into them), whole shaping clusters reordered
|
||||
/// (UAX #9 L1–L2) for the line `line` (bytes of the text) of the paragraph starting at byte
|
||||
/// `paragraph_start`; their own order when nothing is right to left (`bidi` None).
|
||||
fn visual_order(bidi: Option<&BidiInfo<'_>>, paragraph_start: usize, line: Range<usize>, glyphs: &[SGlyph]) -> Vec<usize> {
|
||||
let Some((bidi, para)) = bidi.filter(|b| b.has_rtl()).and_then(|b| Some((b, b.paragraphs.first()?))) else {
|
||||
return (0..glyphs.len()).collect();
|
||||
};
|
||||
if glyphs.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
let levels = bidi.reordered_levels(para, line.start.saturating_sub(paragraph_start)..line.end.saturating_sub(paragraph_start));
|
||||
let mut clusters: Vec<Range<usize>> = Vec::new();
|
||||
for (i, g) in glyphs.iter().enumerate() {
|
||||
if i > 0 && glyphs.get(i - 1).is_some_and(|p| p.byte == g.byte) {
|
||||
if let Some(c) = clusters.last_mut() {
|
||||
c.end = i + 1;
|
||||
}
|
||||
} else {
|
||||
clusters.push(i..i + 1);
|
||||
}
|
||||
}
|
||||
let cluster_levels: Vec<_> = clusters
|
||||
.iter()
|
||||
.filter_map(|c| glyphs.get(c.start))
|
||||
.map(|g| levels.get(g.byte.saturating_sub(paragraph_start)).copied().unwrap_or(g.level))
|
||||
.collect();
|
||||
unicode_bidi::BidiInfo::reorder_visual(&cluster_levels).into_iter().filter_map(|i| clusters.get(i)).flat_map(|c| c.clone()).collect()
|
||||
}
|
||||
+90
-14
@@ -24,7 +24,7 @@ pub mod thread;
|
||||
|
||||
pub use craft_fonts::{CRAFT_FONTS, CraftFont};
|
||||
pub use features::OtFeatures;
|
||||
pub use fontdb::{FALLBACK_FAMILY, FontDb, FontFace, FontMatch, style_weight, system_font_dirs};
|
||||
pub use fontdb::{FALLBACK_FAMILY, FontClass, FontDb, FontFace, FontMatch, FontTraits, style_weight, system_font_dirs};
|
||||
use kurbo::{Affine, BezPath, Point, Rect, Vec2};
|
||||
pub use layout::{layout, layout_with};
|
||||
pub use vectorcraft_doc::TextObject;
|
||||
@@ -100,6 +100,8 @@ pub struct PositionedGlyph {
|
||||
/// Font units (y down, as [`FontDb::outline`] gives them) → text space: where the glyph is
|
||||
/// drawn, also for glyphs without an outline (spaces).
|
||||
pub xf: Affine,
|
||||
/// True when the logical leading edge is the glyph’s right edge.
|
||||
pub rtl: bool,
|
||||
}
|
||||
|
||||
/// One line of laid-out text.
|
||||
@@ -108,6 +110,8 @@ pub struct PositionedGlyph {
|
||||
/// path and `x1` the x of its end point.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LineInfo {
|
||||
/// Automatically detected paragraph base direction.
|
||||
pub rtl: bool,
|
||||
pub baseline: f64,
|
||||
/// Left edge of the line content (after alignment).
|
||||
pub x0: f64,
|
||||
@@ -202,11 +206,11 @@ pub fn caret_position(layout: &TextLayout, byte: usize) -> (Point, Point) {
|
||||
return (Point::ZERO, Point::ZERO);
|
||||
};
|
||||
let glyphs = &layout.glyphs[line.glyph_start..line.glyph_end];
|
||||
let (pos, angle) = if let Some(g) = glyphs.iter().find(|g| g.byte + g.len > byte) {
|
||||
let (pos, angle) = if let Some(g) = glyphs.iter().find(|g| g.byte <= byte && g.byte + g.len > byte) {
|
||||
let frac = if byte <= g.byte { 0.0 } else { (byte - g.byte) as f64 / g.len.max(1) as f64 };
|
||||
(g.origin + dir(g.angle) * (g.advance * frac), g.angle)
|
||||
} else if let Some(g) = glyphs.last() {
|
||||
(g.origin + dir(g.angle) * g.advance, g.angle)
|
||||
(g.origin + dir(g.angle) * (g.advance * if g.rtl { 1.0 - frac } else { frac }), g.angle)
|
||||
} else if let Some(g) = glyphs.iter().max_by_key(|g| g.byte + g.len) {
|
||||
(g.origin + dir(g.angle) * if g.rtl { 0.0 } else { g.advance }, g.angle)
|
||||
} else {
|
||||
(layout.physical_point(Point::new(line.x0, line.baseline)), if layout.vertical { std::f64::consts::FRAC_PI_2 } else { 0.0 })
|
||||
};
|
||||
@@ -225,7 +229,7 @@ pub fn hit_byte(layout: &TextLayout, p: Point) -> usize {
|
||||
(p - ca).hypot2().total_cmp(&(p - cb).hypot2())
|
||||
});
|
||||
return match best {
|
||||
Some(g) if (p - g.origin).dot(dir(g.angle)) < g.advance * 0.5 => g.byte,
|
||||
Some(g) if ((p - g.origin).dot(dir(g.angle)) < g.advance * 0.5) != g.rtl => g.byte,
|
||||
Some(g) => g.byte + g.len,
|
||||
None => layout.lines.first().map_or(0, |l| l.start),
|
||||
};
|
||||
@@ -264,10 +268,15 @@ pub fn hit_byte(layout: &TextLayout, p: Point) -> usize {
|
||||
pub fn byte_in_line(layout: &TextLayout, li: usize, x: f64) -> usize {
|
||||
let Some(line) = layout.lines.get(li) else { return 0 };
|
||||
let glyphs = &layout.glyphs[line.glyph_start..line.glyph_end];
|
||||
if let Some(g) = glyphs.iter().find(|g| x < layout.logical_point(g.origin).x + g.advance * 0.5) {
|
||||
return g.byte;
|
||||
}
|
||||
line_end(layout, li)
|
||||
let nearest = glyphs
|
||||
.iter()
|
||||
.flat_map(|g| {
|
||||
let left = layout.logical_point(g.origin).x;
|
||||
let right = left + g.advance;
|
||||
if g.rtl { [(right, g.byte), (left, g.byte + g.len)] } else { [(left, g.byte), (right, g.byte + g.len)] }
|
||||
})
|
||||
.min_by(|a, b| (a.0 - x).abs().total_cmp(&(b.0 - x).abs()));
|
||||
nearest.map_or(line.start, |(_, byte)| byte)
|
||||
}
|
||||
|
||||
/// Caret byte at the end of line `li`: a soft-wrapped line ends before its trailing space so the
|
||||
@@ -276,7 +285,7 @@ pub fn line_end(layout: &TextLayout, li: usize) -> usize {
|
||||
let Some(line) = layout.lines.get(li) else { return 0 };
|
||||
let glyphs = &layout.glyphs[line.glyph_start..line.glyph_end];
|
||||
let soft_wrap = layout.lines.get(li + 1).is_some_and(|n| n.start == line.end);
|
||||
match glyphs.iter().rev().find(|g| g.len > 0) {
|
||||
match glyphs.iter().filter(|g| g.len > 0).max_by_key(|g| g.byte) {
|
||||
Some(g) if soft_wrap && g.byte + g.len == line.end => g.byte,
|
||||
_ => line.end,
|
||||
}
|
||||
@@ -296,11 +305,11 @@ pub fn line_end_of(layout: &TextLayout, byte: usize) -> usize {
|
||||
fn x_in_line(layout: &TextLayout, li: usize, byte: usize) -> f64 {
|
||||
let line = &layout.lines[li];
|
||||
let glyphs = &layout.glyphs[line.glyph_start..line.glyph_end];
|
||||
if let Some(g) = glyphs.iter().find(|g| g.byte + g.len > byte) {
|
||||
if let Some(g) = glyphs.iter().find(|g| g.byte <= byte && g.byte + g.len > byte) {
|
||||
let frac = if byte <= g.byte { 0.0 } else { (byte - g.byte) as f64 / g.len.max(1) as f64 };
|
||||
return layout.logical_point(g.origin).x + g.advance * frac;
|
||||
return layout.logical_point(g.origin).x + g.advance * if g.rtl { 1.0 - frac } else { frac };
|
||||
}
|
||||
glyphs.last().map_or(line.x0, |g| layout.logical_point(g.origin).x + g.advance)
|
||||
glyphs.iter().max_by_key(|g| g.byte + g.len).map_or(line.x0, |g| layout.logical_point(g.origin).x + if g.rtl { 0.0 } else { g.advance })
|
||||
}
|
||||
|
||||
/// Move the caret `delta` lines up (negative) or down, keeping horizontal position `goal_x`
|
||||
@@ -354,6 +363,23 @@ pub fn selection_quads(layout: &TextLayout, a: usize, b: usize) -> Vec<[Point; 4
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if layout.glyphs.iter().any(|g| g.rtl) {
|
||||
for g in layout.glyphs.iter().filter(|g| g.byte < b && g.byte + g.len > a) {
|
||||
let Some(l) = layout.lines.get(g.line) else { continue };
|
||||
let p = layout.logical_point(g.origin);
|
||||
let (x0, x1) = (p.x, p.x + g.advance);
|
||||
out.push(
|
||||
[
|
||||
Point::new(x0, l.baseline - l.ascent),
|
||||
Point::new(x1, l.baseline - l.ascent),
|
||||
Point::new(x1, l.baseline + l.descent),
|
||||
Point::new(x0, l.baseline + l.descent),
|
||||
]
|
||||
.map(|p| layout.physical_point(p)),
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
for (li, l) in layout.lines.iter().enumerate() {
|
||||
if l.start > b || l.end < a || (l.end == a && l.start < a && layout.lines.get(li + 1).is_some_and(|n| n.start == l.end)) {
|
||||
continue;
|
||||
@@ -374,9 +400,59 @@ pub fn selection_quads(layout: &TextLayout, a: usize, b: usize) -> Vec<[Point; 4
|
||||
out
|
||||
}
|
||||
|
||||
/// Move horizontally through visual caret stops; source offsets stay in logical order.
|
||||
pub fn caret_horizontal(layout: &TextLayout, byte: usize, right: bool) -> usize {
|
||||
let li = layout.line_of(byte);
|
||||
let Some(line) = layout.lines.get(li) else { return byte };
|
||||
let x = layout.logical_point(caret_position(layout, byte).0).x;
|
||||
let mut stops: Vec<_> =
|
||||
layout.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or_default().iter().flat_map(|g| [g.byte, g.byte + g.len]).collect();
|
||||
stops.sort_unstable();
|
||||
stops.dedup();
|
||||
stops
|
||||
.into_iter()
|
||||
.filter_map(|b| {
|
||||
let bx = layout.logical_point(caret_position(layout, b).0).x;
|
||||
let d = if right { bx - x } else { x - bx };
|
||||
(d > 1e-6).then_some((d, b))
|
||||
})
|
||||
.min_by(|a, b| a.0.total_cmp(&b.0))
|
||||
.map_or_else(
|
||||
|| {
|
||||
if right != line.rtl {
|
||||
layout.lines.get(li + 1).map_or(byte, |next| next.start)
|
||||
} else {
|
||||
li.checked_sub(1).and_then(|previous| layout.lines.get(previous)).map_or(byte, |previous| previous.end)
|
||||
}
|
||||
},
|
||||
|(_, b)| b,
|
||||
)
|
||||
}
|
||||
|
||||
/// Does paragraph `text` run right to left: its `direction`, else (None) from its first strong
|
||||
/// character (numbers and punctuation don't count)?
|
||||
pub fn paragraph_is_rtl(text: &str, direction: Option<vectorcraft_doc::ParaDirection>) -> bool {
|
||||
layout::is_rtl(layout::para_bidi(text, direction).as_ref())
|
||||
}
|
||||
|
||||
/// Text stored in visual order (as PDF content draws it, each glyph where it is drawn), back in
|
||||
/// logical order: the order of its characters (indices into `visual.chars()`) and whether its
|
||||
/// paragraph runs right to left; laid out that way, it reads as drawn. `None` when nothing in it is
|
||||
/// right to left (the order stands).
|
||||
pub fn logical_order(visual: &str) -> Option<(Vec<usize>, bool)> {
|
||||
let bidi = layout::para_bidi(visual, None)?;
|
||||
let para = bidi.paragraphs.first()?;
|
||||
// Reordering is its own inverse for runs of one direction (the usual case): the visual order
|
||||
// of the visual text is its logical order.
|
||||
let levels = bidi.reordered_levels_per_char(para, para.range.clone());
|
||||
Some((unicode_bidi::BidiInfo::reorder_visual(&levels), para.level.is_rtl()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_bidi;
|
||||
#[cfg(test)]
|
||||
mod tests_embed;
|
||||
#[cfg(test)]
|
||||
mod tests_scripts;
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
use std::ops::Range;
|
||||
use std::sync::Arc;
|
||||
|
||||
use unicode_bidi::Level;
|
||||
use unicode_script::{Script, UnicodeScript};
|
||||
|
||||
use harfrust::{Direction, Feature, ShapeOptions, UnicodeBuffer};
|
||||
use skrifa::MetadataProvider;
|
||||
use skrifa::instance::Size;
|
||||
@@ -42,6 +45,11 @@ pub(crate) struct SGlyph {
|
||||
pub ch: char,
|
||||
/// Vertical type: part of a tate-chu-yoko block (set across the column, upright).
|
||||
pub tcy: Option<Tcy>,
|
||||
/// Japanese composition: the space taken off before the glyph (an opening bracket after
|
||||
/// another, see [`crate::layout`]); the glyph is drawn that much earlier on the line.
|
||||
pub lead: f64,
|
||||
/// Resolved Unicode bidi embedding level (logical source order).
|
||||
pub level: Level,
|
||||
}
|
||||
|
||||
/// A glyph's place in a tate-chu-yoko block: the block takes one em of the column, its glyphs side
|
||||
@@ -137,7 +145,26 @@ pub(crate) fn no_line_end(c: char) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
fn is_cjk(c: char) -> bool {
|
||||
/// Full-width punctuation for Japanese composition (JLREQ cl-01, cl-02, cl-06, cl-07).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Punct {
|
||||
/// An opening bracket: its half-em space is before the mark.
|
||||
Opening,
|
||||
/// A closing bracket, comma or full stop: its half-em space is after the mark.
|
||||
Closing,
|
||||
}
|
||||
|
||||
pub(crate) fn punct(c: char) -> Option<Punct> {
|
||||
match c {
|
||||
'(' | '「' | '『' | '【' | '〔' | '〈' | '《' | '[' | '{' | '〘' | '〖' | '⦅' | '〝' => Some(Punct::Opening),
|
||||
')' | '」' | '』' | '】' | '〕' | '〉' | '》' | ']' | '}' | '〙' | '〗' | '⦆' | '〟' | '、' | '。' | ',' | '.' => {
|
||||
Some(Punct::Closing)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_cjk(c: char) -> bool {
|
||||
matches!(c as u32, 0x2E80..=0x9FFF | 0xAC00..=0xD7AF | 0xF900..=0xFAFF | 0xFF00..=0xFFEF | 0x20000..=0x2FFFF)
|
||||
}
|
||||
|
||||
@@ -157,15 +184,20 @@ pub(crate) fn cap_x_heights(db: &FontDb, st: &CharStyle) -> (f64, f64) {
|
||||
(face.cap_height * k, face.x_height * k)
|
||||
}
|
||||
|
||||
/// Shape `text[range]`, where `runs` gives each run's byte range in `text` and style.
|
||||
/// Shape `text[range]`, where `runs` gives each run's byte range in `text` and style, and `levels`
|
||||
/// each byte's bidi embedding level from `range.start` (empty: all left to right). Glyphs come out
|
||||
/// in logical order, right-to-left ones shaped right to left.
|
||||
pub(crate) fn shape_range(
|
||||
db: &FontDb,
|
||||
text: &str,
|
||||
range: Range<usize>,
|
||||
runs: &[(Range<usize>, &CharStyle)],
|
||||
feats: &OtFeatures,
|
||||
levels: &[Level],
|
||||
out: &mut Vec<SGlyph>,
|
||||
) {
|
||||
let level_at = |i: usize| levels.get(i - range.start).copied().unwrap_or_else(Level::ltr);
|
||||
let output_start = out.len();
|
||||
for (ri, (rr, st)) in runs.iter().enumerate() {
|
||||
let a = rr.start.max(range.start);
|
||||
let b = rr.end.min(range.end);
|
||||
@@ -177,10 +209,12 @@ pub(crate) fn shape_range(
|
||||
// Synthesized Small Caps shape lowercase letters separately (as smaller capitals).
|
||||
let small_caps = st.small_caps.is_some() && !st.all_caps;
|
||||
// Split into segments by font coverage (and case, for Small Caps).
|
||||
let mut seg = Segment { range: a..a, run: ri, st, face: primary.clone(), small: false };
|
||||
let mut seg = Segment { range: a..a, run: ri, st, face: primary.clone(), small: false, level: level_at(a) };
|
||||
let mut cache: Vec<(char, Arc<FontFace>)> = Vec::new();
|
||||
let mut script = Script::Common;
|
||||
for (i, c) in text[a..b].char_indices() {
|
||||
let i = a + i;
|
||||
let level = level_at(i);
|
||||
let covered = c.is_whitespace() || c.is_control() || pmap.as_ref().is_none_or(|m| m.map(c).is_some());
|
||||
let face = if covered {
|
||||
primary.clone()
|
||||
@@ -192,13 +226,19 @@ pub(crate) fn shape_range(
|
||||
f
|
||||
};
|
||||
let small = small_caps && c.is_lowercase();
|
||||
let next_script = shaping_script(c);
|
||||
let strong_script = !matches!(next_script, Script::Common | Script::Inherited);
|
||||
let script_change = strong_script && script != Script::Common && script != next_script;
|
||||
// Combining marks stay with their base.
|
||||
if (face.id() != seg.face.id() || small != seg.small) && !is_mark(c) {
|
||||
if (face.id() != seg.face.id() || small != seg.small || level != seg.level || script_change) && !is_mark(c) {
|
||||
if i > seg.range.start {
|
||||
seg.range.end = i;
|
||||
shape_segment(text, &seg, feats, out);
|
||||
}
|
||||
seg = Segment { range: i..i, face, small, ..seg };
|
||||
seg = Segment { range: i..i, face, small, level, ..seg };
|
||||
}
|
||||
if strong_script {
|
||||
script = next_script;
|
||||
}
|
||||
}
|
||||
if b > seg.range.start {
|
||||
@@ -206,6 +246,22 @@ pub(crate) fn shape_range(
|
||||
shape_segment(text, &seg, feats, out);
|
||||
}
|
||||
}
|
||||
// Right-to-left segments come out of the shaper in visual order: back to logical order for
|
||||
// line breaking (the lines are put in visual order once broken).
|
||||
if levels.iter().any(|l| l.is_rtl())
|
||||
&& let Some(o) = out.get_mut(output_start..)
|
||||
{
|
||||
o.sort_by_key(|g| g.byte);
|
||||
}
|
||||
}
|
||||
|
||||
/// The script `c` is shaped in: Japanese and Chinese text mixes Han, Hiragana, Katakana and
|
||||
/// Bopomofo, shaped together (splitting them would cost a shaper call per change of script).
|
||||
fn shaping_script(c: char) -> Script {
|
||||
match c.script() {
|
||||
Script::Hiragana | Script::Katakana | Script::Bopomofo => Script::Han,
|
||||
s => s,
|
||||
}
|
||||
}
|
||||
|
||||
/// A piece of one run shaped in one go: one face and, for Small Caps, one case.
|
||||
@@ -216,14 +272,17 @@ struct Segment<'a> {
|
||||
face: Arc<FontFace>,
|
||||
/// Lowercase letters drawn as synthesized small capitals.
|
||||
small: bool,
|
||||
level: Level,
|
||||
}
|
||||
|
||||
fn is_mark(c: char) -> bool {
|
||||
matches!(c as u32, 0x0300..=0x036F | 0x1AB0..=0x1AFF | 0x1DC0..=0x1DFF | 0x20D0..=0x20FF | 0xFE20..=0xFE2F | 0x200D | 0xFE00..=0xFE0F)
|
||||
use unicode_general_category::{GeneralCategory, get_general_category};
|
||||
matches!(get_general_category(c), GeneralCategory::NonspacingMark | GeneralCategory::SpacingMark | GeneralCategory::EnclosingMark)
|
||||
|| matches!(c, '\u{200D}' | '\u{FE00}'..='\u{FE0F}')
|
||||
}
|
||||
|
||||
fn shape_segment(text: &str, seg: &Segment, feats: &OtFeatures, out: &mut Vec<SGlyph>) {
|
||||
let Segment { range, run, st, face, small } = seg;
|
||||
let Segment { range, run, st, face, small, level } = seg;
|
||||
let (range, run, small) = (range.clone(), *run, *small);
|
||||
let text_seg = &text[range.clone()];
|
||||
let full = st.size.max(0.0);
|
||||
@@ -259,7 +318,7 @@ fn shape_segment(text: &str, seg: &Segment, feats: &OtFeatures, out: &mut Vec<SG
|
||||
buf.add(c, cl);
|
||||
}
|
||||
}
|
||||
buf.set_direction(Direction::LeftToRight);
|
||||
buf.set_direction(if level.is_rtl() { Direction::RightToLeft } else { Direction::LeftToRight });
|
||||
buf.guess_segment_properties();
|
||||
let feats: Vec<Feature> = feats.resolve(st);
|
||||
let gb = shaper.shape(buf, ShapeOptions::new().features(&feats));
|
||||
@@ -283,11 +342,14 @@ fn shape_segment(text: &str, seg: &Segment, feats: &OtFeatures, out: &mut Vec<SG
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut cluster_starts: Vec<usize> = raw.iter().map(|r| r.1 as usize).collect();
|
||||
cluster_starts.sort_unstable();
|
||||
cluster_starts.dedup();
|
||||
let n = raw.len();
|
||||
for (gi, &(gid, cl, xa, xo, yo)) in raw.iter().enumerate() {
|
||||
let cl = cl as usize;
|
||||
// Cluster end: the next larger cluster value in the segment, else the segment end.
|
||||
let end = raw[gi + 1..].iter().map(|r| r.1 as usize).find(|&c| c > cl).unwrap_or(range.end);
|
||||
let end = cluster_starts.get(cluster_starts.partition_point(|&c| c <= cl)).copied().unwrap_or(range.end);
|
||||
let last_in_cluster = gi + 1 == n || raw[gi + 1].1 as usize != cl;
|
||||
let ch = first_char(cl);
|
||||
let mut adv = xa as f64 * k * hs;
|
||||
@@ -316,6 +378,8 @@ fn shape_segment(text: &str, seg: &Segment, feats: &OtFeatures, out: &mut Vec<SG
|
||||
xh,
|
||||
ch,
|
||||
tcy: None,
|
||||
lead: 0.0,
|
||||
level: *level,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -110,3 +110,67 @@ pub fn name_table(records: &[(u16, &str)]) -> Option<Vec<u8>> {
|
||||
head.extend(strings);
|
||||
Some(head)
|
||||
}
|
||||
|
||||
/// The family of [`vertical_font`].
|
||||
pub const VERTICAL_FAMILY: &str = "Vertitest Sans";
|
||||
|
||||
/// The character whose vertical metrics [`vertical_font`] sets apart: 1.4 em down the column, its
|
||||
/// vertical origin 1.1 em above the baseline.
|
||||
pub const VERTICAL_TALL: char = '§';
|
||||
|
||||
/// The bundled Source Sans 3 Regular as family [`VERTICAL_FAMILY`] with vertical metrics (`vhea`,
|
||||
/// `vmtx`, and `VORG` when `vorg`): every glyph one em down the column with its vertical origin at
|
||||
/// the usual 0.88 em, except [`VERTICAL_TALL`] (1.4 em, origin 1.1 em). Without `VORG` the origin
|
||||
/// comes from each glyph's top and top side bearing, as in TrueType fonts. `None` when it can't be
|
||||
/// built.
|
||||
pub fn vertical_font(vorg: bool) -> Option<Vec<u8>> {
|
||||
const BASE: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf");
|
||||
let font = skrifa::FontRef::new(BASE).ok()?;
|
||||
let n = font.maxp().ok()?.num_glyphs();
|
||||
let metrics = font.glyph_metrics(Size::unscaled(), LocationRef::default());
|
||||
let tall = font.charmap().map(VERTICAL_TALL)?;
|
||||
let cell = |g: GlyphId| -> (u16, i16) { if g == tall { (1400, 1100) } else { (1000, 880) } };
|
||||
let be16 = |v: &mut Vec<u8>, x: i16| v.extend_from_slice(&x.to_be_bytes());
|
||||
// vmtx: (advance height, top side bearing = origin − the glyph's top) for every glyph.
|
||||
let mut vmtx = Vec::with_capacity(4 * usize::from(n));
|
||||
for g in 0..u32::from(n) {
|
||||
let id = GlyphId::new(g);
|
||||
let (advance, origin) = cell(id);
|
||||
let top = metrics.bounds(id).map_or(0.0, |b| b.y_max).round() as i16;
|
||||
vmtx.extend_from_slice(&advance.to_be_bytes());
|
||||
be16(&mut vmtx, origin - top);
|
||||
}
|
||||
// vhea 1.1: ascent/descent of the em box across the column, then the metrics' count.
|
||||
let mut vhea = Vec::with_capacity(36);
|
||||
vhea.extend_from_slice(&0x0001_1000_u32.to_be_bytes());
|
||||
for x in [500, -500, 0] {
|
||||
be16(&mut vhea, x);
|
||||
}
|
||||
vhea.extend_from_slice(&1400_u16.to_be_bytes());
|
||||
for x in [0, 0, 1400, 1, 0, 0, 0, 0, 0, 0, 0] {
|
||||
be16(&mut vhea, x);
|
||||
}
|
||||
vhea.extend_from_slice(&n.to_be_bytes());
|
||||
let name = name_table(&[(1, VERTICAL_FAMILY), (2, "Regular"), (4, "Vertitest Sans Regular"), (6, "VertitestSans-Regular")])?;
|
||||
let mut builder = FontBuilder::new();
|
||||
builder.add_raw(Tag::new(b"name"), name);
|
||||
builder.add_raw(Tag::new(b"vhea"), vhea);
|
||||
builder.add_raw(Tag::new(b"vmtx"), vmtx);
|
||||
if vorg {
|
||||
// VORG 1.0: the default origin, then the one glyph that differs.
|
||||
let mut t = Vec::new();
|
||||
for x in [1, 0, 880, 1] {
|
||||
be16(&mut t, x);
|
||||
}
|
||||
t.extend_from_slice(&u16::try_from(tall.to_u32()).ok()?.to_be_bytes());
|
||||
be16(&mut t, 1100);
|
||||
builder.add_raw(Tag::new(b"VORG"), t);
|
||||
}
|
||||
for r in font.table_directory.table_records() {
|
||||
let tag = Tag::new(&r.tag().to_be_bytes());
|
||||
if !builder.contains(tag) {
|
||||
builder.add_raw(tag, font.table_data(r.tag())?.as_bytes());
|
||||
}
|
||||
}
|
||||
Some(builder.build())
|
||||
}
|
||||
@@ -491,3 +491,34 @@ fn vertical_area_type_wraps_into_columns_inside_the_frame() {
|
||||
assert!(l.glyphs.iter().all(|g| g.origin.x >= 0.0 && g.origin.x <= 100.0 && g.origin.y >= 0.0 && g.origin.y <= 65.0));
|
||||
assert!(l.glyphs[l.lines[1].glyph_start].origin.x < l.glyphs[0].origin.x);
|
||||
}
|
||||
|
||||
/// Justified Japanese lines (no word spaces) spread the leftover room over the gaps between their
|
||||
/// characters (JLREQ 3.8), not inside a Latin word; the last line of a paragraph stays set solid.
|
||||
#[test]
|
||||
fn justified_japanese_lines_spread_between_characters() {
|
||||
// The ideographs' advance in the fonts at hand, measured; five fit a line, 10 pt left over.
|
||||
let measure = layout(db(), &point("一", style(20.0)));
|
||||
let a = measure.glyphs[0].advance;
|
||||
let width = 5.0 * a + 10.0;
|
||||
let t = area("一二三四五六七", style(20.0), Rect::new(0.0, 0.0, width, 400.0), Justify::JustifyLeft);
|
||||
let l = layout(db(), &t);
|
||||
let first = &l.glyphs[l.lines[0].glyph_start..l.lines[0].glyph_end];
|
||||
assert_eq!(first.len(), 5);
|
||||
for w in first.windows(2) {
|
||||
assert!((w[1].origin.x - w[0].origin.x - (a + 2.5)).abs() < 0.01, "10 pt over 4 gaps");
|
||||
}
|
||||
let end = first.last().map(|g| g.origin.x + a).unwrap();
|
||||
assert!((end - width).abs() < 0.01, "the line reaches the frame's edge: {end} vs {width}");
|
||||
// The last line keeps its natural spacing.
|
||||
let last = &l.glyphs[l.lines[1].glyph_start..l.lines[1].glyph_end];
|
||||
assert!((last[1].origin.x - last[0].origin.x - a).abs() < 0.01);
|
||||
// A Latin word inside a Japanese line keeps its letters together; the gaps around it share.
|
||||
let t = area("一二ABC三四五六七八九十一二三", style(20.0), Rect::new(0.0, 0.0, 8.0 * a + 10.0, 400.0), Justify::JustifyLeft);
|
||||
let l = layout(db(), &t);
|
||||
let g = &l.glyphs[l.lines[0].glyph_start..l.lines[0].glyph_end];
|
||||
let at = g.iter().position(|g| g.byte == "一二".len()).unwrap();
|
||||
let solid =
|
||||
|k: usize| (g[k + 1].origin.x - g[k].origin.x - layout(db(), &point(&"ABC"[k - at..=k - at], style(20.0))).glyphs[0].advance).abs() < 0.01;
|
||||
assert!(solid(at) && solid(at + 1), "A–B–C set solid");
|
||||
assert!(g[at].origin.x - g[at - 1].origin.x > a + 0.1, "二–A takes its share");
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
//! Direction, source-cluster and editing regressions; no RTL font assets required.
|
||||
use super::*;
|
||||
use kurbo::{Point, Rect, Shape};
|
||||
use vectorcraft_doc::{CharStyle, TextKind};
|
||||
|
||||
fn text_layout(s: &str) -> TextLayout {
|
||||
layout(FontDb::global(), &TextObject::point(Point::ZERO, s, CharStyle::default()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hebrew_and_arabic_are_visually_rtl_with_exact_source_clusters() {
|
||||
for s in ["שלום", "مرحبا"] {
|
||||
let l = text_layout(s);
|
||||
assert!(!l.glyphs.is_empty());
|
||||
assert!(l.glyphs.iter().all(|g| g.rtl));
|
||||
let bytes: Vec<_> = l.glyphs.iter().map(|g| g.byte).collect();
|
||||
assert!(bytes.windows(2).all(|w| w[0] >= w[1]), "{s}: {bytes:?}");
|
||||
for g in &l.glyphs {
|
||||
assert!(s.get(g.byte..g.byte + g.len).is_some(), "{g:?}");
|
||||
assert!(g.len <= s.len());
|
||||
}
|
||||
assert!(caret_position(&l, 0).0.x > caret_position(&l, s.len()).0.x);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_text_preserves_latin_and_number_order() {
|
||||
let s = "שלום Rust 123";
|
||||
let l = text_layout(s);
|
||||
let visible: String = l.glyphs.iter().filter_map(|g| s.get(g.byte..)?.chars().next()).collect();
|
||||
assert_eq!(visible, "Rust 123 םולש");
|
||||
assert_eq!(s, "שלום Rust 123", "layout must not rewrite source text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtl_hit_testing_arrows_and_selection_follow_visual_positions() {
|
||||
let s = "שלום";
|
||||
let l = text_layout(s);
|
||||
for g in &l.glyphs {
|
||||
let p = Point::new(g.origin.x + g.advance * 0.9, g.origin.y);
|
||||
assert_eq!(hit_byte(&l, p), g.byte);
|
||||
let p = Point::new(g.origin.x + g.advance * 0.1, g.origin.y);
|
||||
assert_eq!(hit_byte(&l, p), g.byte + g.len);
|
||||
}
|
||||
assert_eq!(caret_horizontal(&l, 0, false), 2);
|
||||
assert_eq!(caret_horizontal(&l, 2, true), 0);
|
||||
assert!(!selection_quads(&l, 0, s.len()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapping_retains_logical_line_ranges_and_reorders_each_line() {
|
||||
let s = "שלום עולם שלום עולם שלום עולם";
|
||||
let mut t = TextObject::point(Point::ZERO, s, CharStyle::default());
|
||||
t.kind = TextKind::Area { frame: vectorcraft_geom::PathData::from_bezpath(&Rect::new(0.0, 0.0, 55.0, 400.0).to_path(0.1)) };
|
||||
let l = layout(FontDb::global(), &t);
|
||||
assert!(l.lines.len() > 1);
|
||||
assert!(!l.overflow);
|
||||
for line in &l.lines {
|
||||
assert!(s.get(line.start..line.end).is_some());
|
||||
for g in &l.glyphs[line.glyph_start..line.glyph_end] {
|
||||
assert!(g.byte >= line.start && g.byte + g.len <= line.end);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_direction_is_independent_and_neutrals_do_not_force_ltr() {
|
||||
let l = text_layout("123 שלום\nEnglish\nمرحبا");
|
||||
assert!(l.glyphs.iter().any(|g| g.line == 0 && g.rtl));
|
||||
assert!(l.glyphs.iter().filter(|g| g.line == 1).all(|g| !g.rtl));
|
||||
assert!(l.glyphs.iter().filter(|g| g.line == 2).all(|g| g.rtl));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hebrew_marks_stay_with_base_clusters() {
|
||||
let s = "שָׁלוֹם";
|
||||
let l = text_layout(s);
|
||||
for g in &l.glyphs {
|
||||
assert!(s.get(g.byte..g.byte + g.len).is_some());
|
||||
}
|
||||
assert!(l.glyphs.iter().all(|g| g.rtl));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_contextual_forms_and_lam_alef_use_the_shaper() {
|
||||
let db = FontDb::global();
|
||||
// Installed fonts are optional; no proprietary or test font is copied into the repo.
|
||||
let face =
|
||||
["Geeza Pro", "Arial", "Noto Sans Arabic"].into_iter().filter_map(|name| db.face(name, "Regular")).find(|f| f.covers('ل') && f.covers('ا'));
|
||||
let Some(face) = face else { return };
|
||||
let st = CharStyle { font_family: face.family.clone(), font_style: face.style.clone(), ..CharStyle::default() };
|
||||
for s in ["سلام", "שלום سلام"] {
|
||||
let l = layout(db, &TextObject::point(Point::ZERO, s, st.clone()));
|
||||
let arabic: Vec<_> = l.glyphs.iter().filter(|g| s.get(g.byte..).is_some_and(|t| t.starts_with(['س', 'ل', 'ا', 'م']))).collect();
|
||||
assert!(
|
||||
arabic.iter().any(|g| s.get(g.byte..).and_then(|t| t.chars().next()).is_some_and(|c| g.gid != face.glyph_for(c))),
|
||||
"Arabic must use contextual forms: {s}"
|
||||
);
|
||||
assert!(arabic.iter().all(|g| g.rtl));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bidi_isolates_numbers_and_brackets_keep_valid_clusters() {
|
||||
for s in ["שלום (123)", "مرحبا (Rust 42)", "English \u{2067}שלום 123\u{2069} end"] {
|
||||
let l = text_layout(s);
|
||||
for g in &l.glyphs {
|
||||
assert!(s.get(g.byte..g.byte + g.len).is_some());
|
||||
}
|
||||
assert!(!selection_quads(&l, 0, s.len()).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_aligned_wrapped_rtl_keeps_trailing_spaces_outside_content() {
|
||||
let mut t = TextObject::point(Point::ZERO, "שלום עולם שלום עולם שלום עולם", CharStyle::default());
|
||||
t.kind = TextKind::Area { frame: vectorcraft_geom::PathData::from_bezpath(&Rect::new(0.0, 0.0, 80.0, 400.0).to_path(0.1)) };
|
||||
t.para.justify = vectorcraft_doc::Justify::Right;
|
||||
let l = layout(FontDb::global(), &t);
|
||||
assert!(l.lines.len() > 1);
|
||||
for line in &l.lines {
|
||||
assert!((line.x1 - 80.0).abs() < 1e-6, "{line:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visual_arrows_cross_rtl_paragraph_boundaries() {
|
||||
let l = text_layout("שלום\nעולם");
|
||||
assert!(l.lines.iter().all(|l| l.rtl));
|
||||
assert_eq!(caret_horizontal(&l, 8, false), 9);
|
||||
assert_eq!(caret_horizontal(&l, 9, true), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_alignment_follows_each_paragraph_and_explicit_choices_win() {
|
||||
let db = FontDb::global();
|
||||
for s in ["שלום", "مرحبا", "123 שלום"] {
|
||||
let mut t = TextObject::point(Point::ZERO, s, CharStyle::default());
|
||||
t.para.justify = vectorcraft_doc::Justify::Auto;
|
||||
let l = layout(db, &t);
|
||||
assert!((l.lines[0].x1).abs() < 1e-6, "RTL point type grows left from its anchor: {s}");
|
||||
t.kind = TextKind::Area { frame: vectorcraft_geom::PathData::from_bezpath(&Rect::new(0.0, 0.0, 200.0, 200.0).to_path(0.1)) };
|
||||
let l = layout(db, &t);
|
||||
assert!((l.lines[0].x1 - 200.0).abs() < 1e-6);
|
||||
t.para.justify = vectorcraft_doc::Justify::Left;
|
||||
assert_eq!(layout(db, &t).lines[0].x0, 0.0, "explicit left alignment is preserved");
|
||||
t.para.justify = vectorcraft_doc::Justify::Center;
|
||||
let l = layout(db, &t);
|
||||
assert!((l.lines[0].x0 + l.lines[0].x1 - 200.0).abs() < 1e-6);
|
||||
}
|
||||
let mut t = TextObject::point(Point::ZERO, "English\nשלום\nمرحبا\nEnglish again", CharStyle::default());
|
||||
t.para.justify = vectorcraft_doc::Justify::Auto;
|
||||
let l = layout(db, &t);
|
||||
assert_eq!(l.lines[0].x0, 0.0);
|
||||
assert!(l.lines[1].x0 < 0.0 && l.lines[1].x1.abs() < 1e-6);
|
||||
assert!(l.lines[2].x0 < 0.0 && l.lines[2].x1.abs() < 1e-6);
|
||||
assert_eq!(l.lines[3].x0, 0.0);
|
||||
}
|
||||
|
||||
/// Paragraph Direction set on the text wins over its first strong character: English set right to
|
||||
/// left keeps its words but ends at the start of its line (Auto alignment: the right), its full
|
||||
/// stop on the left; Hebrew set left to right starts on the left.
|
||||
#[test]
|
||||
fn paragraph_direction_overrides_the_first_strong_character() {
|
||||
use vectorcraft_doc::{Justify, ParaDirection};
|
||||
let db = FontDb::global();
|
||||
let order = |s: &str, direction| {
|
||||
let mut t = TextObject::point(Point::ZERO, s, CharStyle::default());
|
||||
(t.para.justify, t.para.direction) = (Justify::Auto, direction);
|
||||
let l = layout(db, &t);
|
||||
let visible: String = l.glyphs.iter().filter_map(|g| s.get(g.byte..)?.chars().next()).collect();
|
||||
(visible, l.lines[0].rtl, l.lines[0].x1)
|
||||
};
|
||||
let (visible, rtl, x1) = order("Hello.", Some(ParaDirection::RightToLeft));
|
||||
assert_eq!((visible.as_str(), rtl), (".Hello", true));
|
||||
assert!(x1.abs() < 1e-6, "aligned to the right of the anchor: {x1}");
|
||||
assert_eq!(order("Hello.", None).0, "Hello.");
|
||||
assert!(!order("Hello.", None).1);
|
||||
assert_eq!(order("שלום abc", None).0, "abc םולש", "a Hebrew paragraph starts on the right");
|
||||
let (visible, rtl, _) = order("שלום abc", Some(ParaDirection::LeftToRight));
|
||||
assert_eq!((visible.as_str(), rtl), ("םולש abc", false), "set left to right, the Hebrew word comes first on the left");
|
||||
assert!(crate::paragraph_is_rtl("123 שלום", None) && !crate::paragraph_is_rtl("123 שלום", Some(ParaDirection::LeftToRight)));
|
||||
}
|
||||
|
||||
/// Plain left-to-right text skips the bidi algorithm: nothing is marked right to left.
|
||||
#[test]
|
||||
fn left_to_right_text_is_untouched() {
|
||||
let l = text_layout("Plain text, 123 (and more).");
|
||||
assert!(l.glyphs.iter().all(|g| !g.rtl) && l.lines.iter().all(|l| !l.rtl));
|
||||
let bytes: Vec<_> = l.glyphs.iter().map(|g| g.byte).collect();
|
||||
assert!(bytes.windows(2).all(|w| w[0] < w[1]));
|
||||
}
|
||||
|
||||
/// Text drawn in visual order (from a PDF) comes back in logical order, with the direction that
|
||||
/// shows it as it was drawn; left-to-right text is left alone.
|
||||
#[test]
|
||||
fn visual_text_comes_back_in_logical_order() {
|
||||
let logical = |visual: &str| {
|
||||
let (order, rtl) = logical_order(visual)?;
|
||||
let chars: Vec<char> = visual.chars().collect();
|
||||
Some((order.iter().filter_map(|&i| chars.get(i)).collect::<String>(), rtl))
|
||||
};
|
||||
assert_eq!(logical("םולש"), Some(("שלום".to_string(), true)));
|
||||
assert_eq!(logical("123 םולש"), Some(("שלום 123".to_string(), true)));
|
||||
assert_eq!(logical("abc םולש"), Some(("abc שלום".to_string(), false)));
|
||||
assert_eq!(logical("plain text"), None);
|
||||
// Laid out with that direction, the logical text shows as drawn.
|
||||
for visual in ["123 םולש", "abc םולש"] {
|
||||
let (text, rtl) = logical(visual).unwrap();
|
||||
let mut t = TextObject::point(Point::ZERO, &text, CharStyle::default());
|
||||
t.para.direction = Some(if rtl { vectorcraft_doc::ParaDirection::RightToLeft } else { vectorcraft_doc::ParaDirection::LeftToRight });
|
||||
let l = layout(FontDb::global(), &t);
|
||||
let shown: String = l.glyphs.iter().filter_map(|g| text.get(g.byte..)?.chars().next()).collect();
|
||||
assert_eq!(shown, visual);
|
||||
}
|
||||
}
|
||||
@@ -261,3 +261,106 @@ fn japanese_numbers_keep_their_own_cells_down_the_column() {
|
||||
assert!(inks.center().x.abs() < 2.8, "{block} centred on the column: {inks:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Upright glyphs take their cell from the font's vertical metrics: the advance down the column,
|
||||
/// and where the glyph hangs from its vertical origin (VORG, or the glyph's top and top side
|
||||
/// bearing). Fonts without them keep one em and the em box centre.
|
||||
#[test]
|
||||
fn upright_glyphs_follow_the_fonts_vertical_metrics() {
|
||||
use crate::test_fonts::{VERTICAL_FAMILY, VERTICAL_TALL, vertical_font};
|
||||
for vorg in [false, true] {
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
db.add_font(vertical_font(vorg).unwrap());
|
||||
let text = format!("{VERTICAL_TALL}{VERTICAL_TALL}");
|
||||
let mut t = vertical(&text);
|
||||
t.runs[0].style.font_family = VERTICAL_FAMILY.into();
|
||||
let l = layout(&db, &t);
|
||||
let step = l.glyphs[1].origin.y - l.glyphs[0].origin.y;
|
||||
assert!((step - 28.0).abs() < 0.01, "1.4 em down the column at 20 pt (vorg {vorg}): {step}");
|
||||
// The glyph hangs from its vertical origin, 1.1 em above the baseline: its top is
|
||||
// (1.1 em − its own top) below the top of its cell.
|
||||
let face = db.face(VERTICAL_FAMILY, "Regular").unwrap();
|
||||
let (_, origin) = face.vertical_glyph(face.glyph_for(VERTICAL_TALL)).unwrap();
|
||||
assert!((origin - 1100.0).abs() < 1.0, "vorg {vorg}: {origin}");
|
||||
let top = db.outline(&face, face.glyph_for(VERTICAL_TALL)).bounding_box().y0; // y-down: −yMax
|
||||
let cell_top = l.glyphs[0].origin.y;
|
||||
let expected = cell_top + (1.1 + top / 1000.0) * 20.0;
|
||||
assert!((ink(&l, 0).y0 - expected).abs() < 0.05, "vorg {vorg}: ink top {} vs {expected}", ink(&l, 0).y0);
|
||||
}
|
||||
// The same glyph in the font without vertical metrics: one em, as before.
|
||||
let l = layout(FontDb::global(), &vertical("§§"));
|
||||
assert!((l.glyphs[1].origin.y - l.glyphs[0].origin.y - 20.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
/// Mojikumi (JLREQ 3.1): with Line-end Punctuation Half Width, a closing mark ending a line is set
|
||||
/// half width, a closing mark followed by punctuation loses the space after it, and an opening
|
||||
/// bracket after another loses the space before it; vertical type the same way down the column.
|
||||
/// Needs a font with full-width Japanese punctuation (skipped without one).
|
||||
#[test]
|
||||
fn mojikumi_halves_line_end_and_consecutive_punctuation() {
|
||||
use vectorcraft_doc::Mojikumi;
|
||||
let lay = |text: &str, m: Mojikumi, vertical_type: bool| {
|
||||
let mut t = TextObject::point(Point::ZERO, text, CharStyle { size: 20.0, ..CharStyle::default() });
|
||||
t.xf = Affine::IDENTITY;
|
||||
t.vertical = vertical_type;
|
||||
t.para.mojikumi = m;
|
||||
layout(FontDb::global(), &t)
|
||||
};
|
||||
let solid = lay("一。", Mojikumi::None, false);
|
||||
if (solid.glyphs[1].advance - 20.0).abs() > 2.0 {
|
||||
return; // no font with full-width punctuation here
|
||||
}
|
||||
for vertical_type in [false, true] {
|
||||
let adv = |text: &str, m| lay(text, m, vertical_type).glyphs.iter().map(|g| g.advance).collect::<Vec<_>>();
|
||||
// Line end: 。 half width.
|
||||
assert!((adv("一。", Mojikumi::LineEndHalf)[1] - 10.0).abs() < 0.01, "vertical {vertical_type}");
|
||||
assert!((adv("一。", Mojikumi::None)[1] - 20.0).abs() < 0.01);
|
||||
// 」 before 「: the closing mark's space goes; 「 keeps its own.
|
||||
let a = adv("一」「二", Mojikumi::LineEndHalf);
|
||||
assert!((a[1] - 10.0).abs() < 0.01 && (a[2] - 20.0).abs() < 0.01, "{a:?}");
|
||||
// 「「: the second bracket loses the space before it and is drawn half an em earlier.
|
||||
let a = adv("「「一", Mojikumi::LineEndHalf);
|
||||
assert!((a[0] - 20.0).abs() < 0.01 && (a[1] - 10.0).abs() < 0.01, "{a:?}");
|
||||
let (on, off) = (lay("「「一", Mojikumi::LineEndHalf, vertical_type), lay("「「一", Mojikumi::None, vertical_type));
|
||||
let along = |r: Rect| if vertical_type { r.y0 } else { r.x0 };
|
||||
let moved = along(off.glyphs[1].outline.bounding_box()) - along(on.glyphs[1].outline.bounding_box());
|
||||
assert!((moved - 10.0).abs() < 0.01, "vertical {vertical_type}: the second 「 is drawn half an em earlier ({moved})");
|
||||
// Text without punctuation is untouched.
|
||||
assert_eq!(adv("一二", Mojikumi::LineEndHalf), adv("一二", Mojikumi::None));
|
||||
}
|
||||
}
|
||||
|
||||
/// Mojikumi (JLREQ 3.2.2): a quarter em between Japanese and Latin letters or digits, either way
|
||||
/// round, horizontal and vertical; none inside a tate-chu-yoko block, none left at a line's end, and
|
||||
/// none with Mojikumi None. Needs a font with full-width Japanese (skipped without one).
|
||||
#[test]
|
||||
fn mojikumi_spaces_japanese_from_latin_by_a_quarter_em() {
|
||||
use vectorcraft_doc::Mojikumi;
|
||||
let lay = |text: &str, m: Mojikumi, vertical_type: bool| {
|
||||
let mut t = TextObject::point(Point::ZERO, text, CharStyle { size: 20.0, ..CharStyle::default() });
|
||||
t.xf = Affine::IDENTITY;
|
||||
t.vertical = vertical_type;
|
||||
t.para.mojikumi = m;
|
||||
layout(FontDb::global(), &t)
|
||||
};
|
||||
if (lay("雅", Mojikumi::None, false).glyphs[0].advance - 20.0).abs() > 2.0 {
|
||||
return; // no font with full-width Japanese here
|
||||
}
|
||||
for vertical_type in [false, true] {
|
||||
let extra = |text: &str| -> Vec<f64> {
|
||||
let (on, off) = (lay(text, Mojikumi::LineEndHalf, vertical_type), lay(text, Mojikumi::None, vertical_type));
|
||||
on.glyphs.iter().zip(&off.glyphs).map(|(a, b)| a.advance - b.advance).collect()
|
||||
};
|
||||
// 雅楽 2026 年: after 楽 and after 6.
|
||||
assert_eq!(extra("雅楽2026年").iter().map(|x| (x * 100.0).round() / 100.0).collect::<Vec<_>>(), [0.0, 5.0, 0.0, 0.0, 0.0, 5.0, 0.0]);
|
||||
// Latin first, and nothing after the last character of the line.
|
||||
assert_eq!(extra("AB雅").iter().map(|x| x.round()).collect::<Vec<_>>(), [0.0, 5.0, 0.0]);
|
||||
assert_eq!(extra("雅A").iter().map(|x| x.round()).collect::<Vec<_>>(), [5.0, 0.0]);
|
||||
// Punctuation takes no Japanese–Latin space (the closing bracket isn't at the line's end).
|
||||
assert!(extra("「A」です").iter().all(|x| x.abs() < 0.01), "{:?}", extra("「A」です"));
|
||||
}
|
||||
// A tate-chu-yoko block is set as a Japanese character, with no space inside or around it.
|
||||
let on = lay("第10回", Mojikumi::LineEndHalf, true);
|
||||
let off = lay("第10回", Mojikumi::None, true);
|
||||
assert!(on.glyphs.iter().zip(&off.glyphs).all(|(a, b)| (a.advance - b.advance).abs() < 0.01));
|
||||
}
|
||||
+147
-6
@@ -2,18 +2,23 @@
|
||||
//!
|
||||
//! Direct Selection: click an anchor to select it (Shift toggles), click a segment to select the
|
||||
//! path's anchors on that segment, drag to move selected anchors, drag a direction handle to
|
||||
//! reshape, marquee to select anchors, drag a live rectangle's corner widget to round its corners.
|
||||
//! reshape (Shift keeps it at 45° steps round its anchor, Alt moves it alone; smart guides snap
|
||||
//! it), marquee to select anchors, drag a live rectangle's corner widget to round its corners.
|
||||
//! Group Selection: click selects the leaf; each further click on it adds the next enclosing group.
|
||||
//! Both pick the key objects of a blend. Direct Selection also edits a blend's spine: drag its
|
||||
//! points (a key object on a point moves with it) and, once a point is clicked, its handles; and
|
||||
//! the points of selected gradient meshes and mesh envelopes and their handles ([`MeshEdit`]).
|
||||
//! Dragging a corner or an edge of area type's frame reshapes the type area (`text.reshapeArea`):
|
||||
//! the text reflows at its size.
|
||||
//!
|
||||
//! A press on the stroke of a path that isn't selected as a whole selects that segment's two anchors
|
||||
//! (the fill, or Alt, selects the whole path); dragging the segment bends it if it's curved, else
|
||||
//! moves its anchors.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::hit::hit_test;
|
||||
use vectorcraft_doc::hit::{HitKind, hit_test};
|
||||
use vectorcraft_doc::{AnchorRef, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::{PathData, Point, Rect};
|
||||
|
||||
@@ -34,6 +39,15 @@ enum State {
|
||||
start: Point,
|
||||
began: bool,
|
||||
},
|
||||
/// Dragging a curved segment: it follows the pointer (`path.reshapeSegment`).
|
||||
Segment {
|
||||
id: NodeId,
|
||||
si: usize,
|
||||
seg: usize,
|
||||
t: f64,
|
||||
start: Point,
|
||||
began: bool,
|
||||
},
|
||||
Handle {
|
||||
id: NodeId,
|
||||
si: usize,
|
||||
@@ -77,11 +91,13 @@ pub struct DirectSelectionTool {
|
||||
/// The spine point last clicked (blend, anchor): its handles show and can be dragged.
|
||||
spine: Option<(NodeId, usize)>,
|
||||
mesh: MeshEdit,
|
||||
/// Smart guides of the handle being dragged.
|
||||
guides: Vec<Overlay>,
|
||||
}
|
||||
|
||||
impl DirectSelectionTool {
|
||||
pub fn new(group: bool) -> Self {
|
||||
Self { group, state: State::Idle, spine: None, mesh: MeshEdit::default() }
|
||||
Self { group, state: State::Idle, spine: None, mesh: MeshEdit::default(), guides: vec![] }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,6 +204,27 @@ fn anchors_json(v: &[AnchorRef]) -> Value {
|
||||
Value::Array(v.iter().map(|(s, a)| json!([s, a])).collect())
|
||||
}
|
||||
|
||||
/// A segment of a path under the pointer.
|
||||
struct SegmentHit {
|
||||
si: usize,
|
||||
seg: usize,
|
||||
t: f64,
|
||||
/// The indexes of the segment's two anchors in its subpath.
|
||||
anchors: [usize; 2],
|
||||
curved: bool,
|
||||
}
|
||||
|
||||
/// The segment of path `id` nearest to `p`, where the hit test found its stroke.
|
||||
fn segment_at(cx: &ToolContext, id: NodeId, p: Point) -> Option<SegmentHit> {
|
||||
let pd = cx.doc.node(id)?.path_data()?;
|
||||
let (si, seg, t, ..) = pd.nearest(p)?;
|
||||
let sp = pd.subpaths.get(si)?;
|
||||
let n = sp.anchors.len();
|
||||
let (i0, i1) = (seg.checked_rem(n)?, (seg + 1).checked_rem(n)?);
|
||||
let (a, b) = (sp.anchors.get(i0)?, sp.anchors.get(i1)?);
|
||||
Some(SegmentHit { si, seg, t, anchors: [i0, i1], curved: a.has_out() || b.has_in() })
|
||||
}
|
||||
|
||||
impl Tool for DirectSelectionTool {
|
||||
fn id(&self) -> &'static str {
|
||||
if self.group { "groupSelection" } else { "directSelection" }
|
||||
@@ -264,7 +301,29 @@ impl Tool for DirectSelectionTool {
|
||||
return self.press_type_area(cx, id, anchors, p, ev.mods.shift);
|
||||
}
|
||||
if let Some(h) = hit_test(cx.doc, p, cx.hit_options()) {
|
||||
// Clicking a segment/fill selects the whole leaf path (all anchors).
|
||||
// A segment of a path that isn't selected as a whole: its two anchors get
|
||||
// selected, and dragging it reshapes it if it's curved, else moves it. Alt picks
|
||||
// the whole path (and Alt-drag copies it), as with Group Selection.
|
||||
let whole = cx.selection.contains(h.leaf) && cx.selection.partial(h.leaf).is_none();
|
||||
if !whole
|
||||
&& !ev.mods.shift
|
||||
&& !ev.mods.alt
|
||||
&& matches!(h.kind, HitKind::Stroke | HitKind::Outline)
|
||||
&& let Some(s) = segment_at(cx, h.leaf, p)
|
||||
{
|
||||
let selected = cx.selection.partial(h.leaf).is_some_and(|sel| s.anchors.iter().all(|&ai| sel.contains(&(s.si, ai))));
|
||||
self.state = if s.curved {
|
||||
State::Segment { id: h.leaf, si: s.si, seg: s.seg, t: s.t, start: p, began: false }
|
||||
} else {
|
||||
State::MoveAnchors { start: p, began: false }
|
||||
};
|
||||
if selected {
|
||||
return vec![];
|
||||
}
|
||||
let anchors: Vec<Value> = s.anchors.iter().map(|ai| json!([s.si, ai])).collect();
|
||||
return vec![Action::Exec("select.anchors".into(), json!({"id": h.leaf.0, "anchors": anchors, "mode": "set"}))];
|
||||
}
|
||||
// Clicking the fill selects the whole leaf path (all anchors).
|
||||
self.state = State::MoveObject { start: p, began: false };
|
||||
if ev.mods.shift {
|
||||
return vec![Action::Exec("select.toggle".into(), json!({"id": h.leaf.0}))];
|
||||
@@ -290,6 +349,22 @@ impl Tool for DirectSelectionTool {
|
||||
out.push(Action::Preview("path.moveAnchors".into(), json!({"dx": d.x, "dy": d.y})));
|
||||
out
|
||||
}
|
||||
(PointerKind::Drag, State::Segment { id, si, seg, t, start, began }) => {
|
||||
let mut out = vec![];
|
||||
if !began {
|
||||
if p.distance(start) < cx.tol(3.0) {
|
||||
return out;
|
||||
}
|
||||
out.push(Action::Begin("Reshape".into()));
|
||||
self.state = State::Segment { id, si, seg, t, start, began: true };
|
||||
}
|
||||
let d = p - start;
|
||||
out.push(Action::Preview(
|
||||
"path.reshapeSegment".into(),
|
||||
json!({"id": id.0, "subpath": si, "segment": seg, "t": t, "dx": d.x, "dy": d.y}),
|
||||
));
|
||||
out
|
||||
}
|
||||
(PointerKind::Drag, State::MoveObject { start, began }) => {
|
||||
let mut out = vec![];
|
||||
if !began {
|
||||
@@ -320,9 +395,11 @@ impl Tool for DirectSelectionTool {
|
||||
out
|
||||
}
|
||||
(PointerKind::Drag, State::Handle { id, si, ai, out }) => {
|
||||
let (q, guides) = crate::guides::snap_handle(cx, (id, si, ai), p, ev.mods.shift);
|
||||
self.guides = guides;
|
||||
vec![Action::Preview(
|
||||
"path.setHandle".into(),
|
||||
json!({"id": id.0, "subpath": si, "anchor": ai, "which": if out {"out"} else {"in"}, "x": p.x, "y": p.y, "independent": ev.mods.alt}),
|
||||
json!({"id": id.0, "subpath": si, "anchor": ai, "which": if out {"out"} else {"in"}, "x": q.x, "y": q.y, "independent": ev.mods.alt}),
|
||||
)]
|
||||
}
|
||||
(PointerKind::Drag, State::SpinePoint { id, anchor, from, start, began }) => {
|
||||
@@ -362,13 +439,18 @@ impl Tool for DirectSelectionTool {
|
||||
}
|
||||
(
|
||||
PointerKind::Up,
|
||||
State::MoveAnchors { began, .. } | State::MoveObject { began, .. } | State::SpinePoint { began, .. } | State::TypeArea { began, .. },
|
||||
State::MoveAnchors { began, .. }
|
||||
| State::MoveObject { began, .. }
|
||||
| State::Segment { began, .. }
|
||||
| State::SpinePoint { began, .. }
|
||||
| State::TypeArea { began, .. },
|
||||
) => {
|
||||
self.state = State::Idle;
|
||||
if began { vec![Action::Commit] } else { vec![] }
|
||||
}
|
||||
(PointerKind::Up, State::Handle { .. } | State::SpineHandle { .. }) => {
|
||||
self.state = State::Idle;
|
||||
self.guides.clear();
|
||||
vec![Action::Commit]
|
||||
}
|
||||
(PointerKind::Up, State::Marquee { start, add, .. }) => {
|
||||
@@ -404,6 +486,9 @@ impl Tool for DirectSelectionTool {
|
||||
if !self.group {
|
||||
out.extend(frame_overlays(cx));
|
||||
}
|
||||
if matches!(self.state, State::Handle { .. }) {
|
||||
out.extend(self.guides.iter().cloned());
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
@@ -561,4 +646,60 @@ mod tests {
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0));
|
||||
assert_eq!(a, vec![Action::Exec("select.anchorsMany".into(), json!({"items": [{"id": id.0, "anchors": [[0, 1]]}], "add": false}))]);
|
||||
}
|
||||
/// An open path from (100, 300) to (200, 300) arching up through (150, 262.5).
|
||||
fn arch_doc() -> (vectorcraft_doc::Document, NodeId) {
|
||||
let (mut d, _) = doc_with_rect();
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 300.0), Point::new(200.0, 300.0)], false);
|
||||
sp.anchors[0].h_out = Point::new(100.0, 250.0);
|
||||
sp.anchors[1].h_in = Point::new(200.0, 250.0);
|
||||
d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, PathData::single(sp), vectorcraft_doc::Appearance::default_art())).unwrap();
|
||||
(d, id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_a_curved_segment_reshapes_it_and_a_straight_one_moves_its_anchors() {
|
||||
let (d, arch) = arch_doc();
|
||||
let s = Selection::default();
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = DirectSelectionTool::new(false);
|
||||
// The arch: its two anchors get selected, and the drag bends it.
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 262.5));
|
||||
assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": arch.0, "anchors": [[0, 0], [0, 1]], "mode": "set"}))]);
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 242.5));
|
||||
assert_eq!(a[0], Action::Begin("Reshape".into()));
|
||||
let Action::Preview(cmd, v) = &a[1] else { panic!("{a:?}") };
|
||||
assert_eq!(
|
||||
(cmd.as_str(), v["id"].clone(), v["segment"].clone(), v["dx"].clone(), v["dy"].clone()),
|
||||
("path.reshapeSegment", json!(arch.0), json!(0), json!(0.0), json!(-20.0))
|
||||
);
|
||||
assert!((v["t"].as_f64().unwrap() - 0.5).abs() < 0.01, "{v}");
|
||||
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 242.5)), vec![Action::Commit]);
|
||||
// A straight edge of the rectangle: its two anchors get selected and move.
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 101.0));
|
||||
assert!(matches!(&a[..], [Action::Exec(c, v)] if c == "select.anchors" && v["anchors"] == json!([[0, 0], [0, 1]])), "{a:?}");
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 81.0));
|
||||
assert_eq!(a[1], Action::Preview("path.moveAnchors".into(), json!({"dx": 0.0, "dy": -20.0})));
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 81.0));
|
||||
// Alt on the stroke picks the whole path, and Alt-drag copies it.
|
||||
let alt = Mods { alt: true, ..Default::default() };
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 101.0).with_mods(alt));
|
||||
assert!(matches!(&a[..], [Action::Exec(c, _)] if c == "select.set"), "{a:?}");
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 81.0).with_mods(alt));
|
||||
assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, v)] if c == "object.transform" && v["copy"] == true), "{a:?}");
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 81.0).with_mods(alt));
|
||||
// The fill still selects the whole path, and a path selected as a whole still moves whole.
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 150.0));
|
||||
assert!(matches!(&a[..], [Action::Exec(c, _)] if c == "select.set"), "{a:?}");
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 150.0));
|
||||
let rect = d.layers[0].children().unwrap()[0].id;
|
||||
let mut whole = Selection::default();
|
||||
whole.set([rect]);
|
||||
let cx = crate::testutil::cx(&d, &whole, &p);
|
||||
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 101.0)), vec![]);
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 81.0));
|
||||
assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "object.transform"), "{a:?}");
|
||||
}
|
||||
}
|
||||
@@ -480,11 +480,6 @@ impl PerspectiveGrid {
|
||||
self.extent_right.unwrap_or(self.extent)
|
||||
}
|
||||
|
||||
/// Is the grid drawn (shown, or a perspective tool active)?
|
||||
pub fn shown(&self, tool_id: &str) -> bool {
|
||||
self.visible || matches!(tool_id, "perspectiveGrid" | "perspectiveSelection")
|
||||
}
|
||||
|
||||
/// Page → plane coordinates.
|
||||
pub fn to_plane(&self, plane: Plane, p: Point) -> Option<Point> {
|
||||
self.homography(plane)?.inverse()?.apply(p)
|
||||
@@ -761,16 +756,16 @@ fn in_quad(q: &[Point; 4], p: Point) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Grid overlays (lines, horizon, vanishing points, widget) drawn whenever the grid is visible or
|
||||
/// a perspective tool is active. `tol` = document units per screen pixel.
|
||||
pub fn grid_overlays(doc: &Document, tol: f64, tool_id: &str) -> Vec<Overlay> {
|
||||
grid_overlays_in(doc, tol, tool_id, Some(widget::WidgetPlace::default()))
|
||||
/// Grid overlays (lines, horizon, vanishing points, widget) drawn whenever the grid is visible
|
||||
/// (selecting a perspective tool shows it). `tol` = document units per screen pixel.
|
||||
pub fn grid_overlays(doc: &Document, tol: f64) -> Vec<Overlay> {
|
||||
grid_overlays_in(doc, tol, Some(widget::WidgetPlace::default()))
|
||||
}
|
||||
|
||||
/// [`grid_overlays`] with the Plane Switching Widget at `place` (None: hidden).
|
||||
pub fn grid_overlays_in(doc: &Document, tol: f64, tool_id: &str, place: Option<widget::WidgetPlace>) -> Vec<Overlay> {
|
||||
pub fn grid_overlays_in(doc: &Document, tol: f64, place: Option<widget::WidgetPlace>) -> Vec<Overlay> {
|
||||
let g = PerspectiveGrid::current(doc);
|
||||
if !g.shown(tool_id) {
|
||||
if !g.visible {
|
||||
return vec![];
|
||||
}
|
||||
let mut out = vec![];
|
||||
@@ -1012,6 +1007,10 @@ impl Tool for PerspectiveGridTool {
|
||||
}
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
let g = PerspectiveGrid::current(cx.doc);
|
||||
// The grid's handles hide with it.
|
||||
if !g.visible {
|
||||
return vec![];
|
||||
}
|
||||
let ink = [0x20, 0x20, 0x20];
|
||||
handles(&g, cx.tol(1.0))
|
||||
.into_iter()
|
||||
@@ -1412,7 +1411,7 @@ mod tests {
|
||||
assert_eq!(cmd, "perspective.grid.set");
|
||||
assert_eq!(v["vpLeft"], json!(g.vp_left - 40.0));
|
||||
assert_eq!(v["horizon"], json!(g.horizon + 10.0));
|
||||
assert!(!grid_overlays(&d, 1.0, "selection").is_empty());
|
||||
assert!(!grid_overlays(&d, 1.0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -12,7 +12,7 @@ use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{NodeId, NodeKind};
|
||||
use vectorcraft_geom::{Anchor, AnchorKind, BezPath, Point, SubPath};
|
||||
|
||||
use crate::{Tool, ToolContext};
|
||||
use crate::{Action, Tool, ToolContext};
|
||||
|
||||
pub use anchor::AnchorTool;
|
||||
pub use curvature::CurvatureTool;
|
||||
@@ -96,8 +96,13 @@ fn candidate_paths(cx: &ToolContext) -> Vec<NodeId> {
|
||||
|
||||
/// Nearest anchor within `tol`: (path, subpath, anchor).
|
||||
pub(crate) fn hit_anchor(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, usize)> {
|
||||
anchor_in(cx, candidate_paths(cx), p, tol)
|
||||
}
|
||||
|
||||
/// Nearest anchor within `tol` on the first of `ids` that has one: (path, subpath, anchor).
|
||||
pub(crate) fn anchor_in(cx: &ToolContext, ids: impl IntoIterator<Item = NodeId>, p: Point, tol: f64) -> Option<(NodeId, usize, usize)> {
|
||||
let mut best: Option<((NodeId, usize, usize), f64)> = None;
|
||||
for id in candidate_paths(cx) {
|
||||
for id in ids {
|
||||
let Some(pd) = cx.doc.node(id).and_then(|n| n.path_data()) else { continue };
|
||||
for (si, ai, a) in pd.anchors() {
|
||||
let d = a.p.distance(p);
|
||||
@@ -114,7 +119,13 @@ pub(crate) fn hit_anchor(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId
|
||||
|
||||
/// Nearest point on a path segment within `tol`: (path, subpath, segment, t).
|
||||
pub(crate) fn hit_segment(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, usize, f64)> {
|
||||
for id in candidate_paths(cx) {
|
||||
segment_in(cx, candidate_paths(cx), p, tol)
|
||||
}
|
||||
|
||||
/// Nearest point within `tol` on a segment of the first of `ids` that passes there: (path,
|
||||
/// subpath, segment, t).
|
||||
pub(crate) fn segment_in(cx: &ToolContext, ids: impl IntoIterator<Item = NodeId>, p: Point, tol: f64) -> Option<(NodeId, usize, usize, f64)> {
|
||||
for id in ids {
|
||||
let Some(pd) = cx.doc.node(id).and_then(|n| n.path_data()) else { continue };
|
||||
if let Some((si, seg, t, _, d)) = pd.nearest(p)
|
||||
&& d <= tol
|
||||
@@ -125,6 +136,16 @@ pub(crate) fn hit_segment(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeI
|
||||
None
|
||||
}
|
||||
|
||||
/// Add an anchor where [`hit_segment`] or [`segment_in`] found a segment.
|
||||
pub(crate) fn insert_anchor((id, si, seg, t): (NodeId, usize, usize, f64)) -> Action {
|
||||
Action::Exec("path.insertAnchor".into(), json!({"id": id.0, "subpath": si, "segment": seg, "t": t}))
|
||||
}
|
||||
|
||||
/// Delete the anchor [`hit_anchor`] or [`anchor_in`] found.
|
||||
pub(crate) fn remove_anchor((id, si, ai): (NodeId, usize, usize)) -> Action {
|
||||
Action::Exec("path.removeAnchor".into(), json!({"id": id.0, "subpath": si, "anchor": ai}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -3,15 +3,16 @@
|
||||
//! Add: click a segment to insert an anchor without changing the shape (Alt = delete).
|
||||
//! Delete: click an anchor to remove it, re-fitting the neighbouring curve (Alt = add).
|
||||
//! Anchor Point: click a smooth anchor → corner; drag an anchor → pull out smooth handles; drag a
|
||||
//! handle → move it independently; drag a segment → reshape the curve.
|
||||
//! handle → move it independently (Shift: at 45° steps round its anchor); drag a segment → reshape
|
||||
//! the curve.
|
||||
//! Scissors: click a path to split it there.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::NodeId;
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
use super::{hit_anchor, hit_segment};
|
||||
use crate::{Action, Cursor, Mods, PointerEvent, PointerKind, Tool, ToolContext};
|
||||
use super::{hit_anchor, hit_segment, insert_anchor, remove_anchor};
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext};
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum State {
|
||||
@@ -24,6 +25,8 @@ enum State {
|
||||
pub struct AnchorTool {
|
||||
id: &'static str,
|
||||
state: State,
|
||||
/// Smart guides of the handle being dragged.
|
||||
guides: Vec<Overlay>,
|
||||
}
|
||||
|
||||
impl AnchorTool {
|
||||
@@ -34,21 +37,15 @@ impl AnchorTool {
|
||||
"scissors" => "scissors",
|
||||
_ => "addAnchor",
|
||||
};
|
||||
Self { id, state: State::Idle }
|
||||
Self { id, state: State::Idle, guides: vec![] }
|
||||
}
|
||||
|
||||
fn add(cx: &ToolContext, p: Point) -> Vec<Action> {
|
||||
match hit_segment(cx, p, cx.tol(4.0)) {
|
||||
Some((id, si, seg, t)) => vec![Action::Exec("path.insertAnchor".into(), json!({"id": id.0, "subpath": si, "segment": seg, "t": t}))],
|
||||
None => vec![],
|
||||
}
|
||||
hit_segment(cx, p, cx.tol(4.0)).map(insert_anchor).into_iter().collect()
|
||||
}
|
||||
|
||||
fn delete(cx: &ToolContext, p: Point) -> Vec<Action> {
|
||||
match hit_anchor(cx, p, cx.tol(4.0)) {
|
||||
Some((id, si, ai)) => vec![Action::Exec("path.removeAnchor".into(), json!({"id": id.0, "subpath": si, "anchor": ai}))],
|
||||
None => vec![],
|
||||
}
|
||||
hit_anchor(cx, p, cx.tol(4.0)).map(remove_anchor).into_iter().collect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,10 +131,14 @@ impl Tool for AnchorTool {
|
||||
"path.convertAnchor".into(),
|
||||
json!({"id": id.0, "subpath": si, "anchor": ai, "to": "smooth", "x": p.x, "y": p.y}),
|
||||
),
|
||||
State::Handle { id, si, ai, out, .. } => Action::Preview(
|
||||
"path.setHandle".into(),
|
||||
json!({"id": id.0, "subpath": si, "anchor": ai, "which": if out { "out" } else { "in" }, "x": p.x, "y": p.y, "independent": true}),
|
||||
),
|
||||
State::Handle { id, si, ai, out, .. } => {
|
||||
let (q, guides) = crate::guides::snap_handle(cx, (id, si, ai), p, ev.mods.shift);
|
||||
self.guides = guides;
|
||||
Action::Preview(
|
||||
"path.setHandle".into(),
|
||||
json!({"id": id.0, "subpath": si, "anchor": ai, "which": if out { "out" } else { "in" }, "x": q.x, "y": q.y, "independent": true}),
|
||||
)
|
||||
}
|
||||
State::Reshape { id, si, seg, t, start, .. } => Action::Preview(
|
||||
"path.reshapeSegment".into(),
|
||||
json!({"id": id.0, "subpath": si, "segment": seg, "t": t, "dx": p.x - start.x, "dy": p.y - start.y}),
|
||||
@@ -147,6 +148,7 @@ impl Tool for AnchorTool {
|
||||
out
|
||||
}
|
||||
("anchorPoint", PointerKind::Up) => {
|
||||
self.guides.clear();
|
||||
let st = std::mem::replace(&mut self.state, State::Idle);
|
||||
match st {
|
||||
State::Convert { began: true, .. } | State::Handle { began: true, .. } | State::Reshape { began: true, .. } => {
|
||||
@@ -178,6 +180,9 @@ impl Tool for AnchorTool {
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
|
||||
if matches!(self.state, State::Handle { .. }) { self.guides.clone() } else { vec![] }
|
||||
}
|
||||
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
|
||||
match self.id {
|
||||
"addAnchor" if !m.alt => Cursor::PenAdd,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! Line Segment family drag tools: Arc, Spiral, Rectangular Grid and Polar Grid.
|
||||
//!
|
||||
//! Drag draws (Shift = equal axes / square, Alt = from the centre for arc and grids); a click
|
||||
//! without dragging asks the UI for the options dialog. While dragging, ↑/↓ change the spiral's
|
||||
//! segments, the grid rows or the concentric dividers; ←/→ change grid columns / radial dividers.
|
||||
//! Drag draws (Shift = equal axes / square, Alt = from the centre for arc and grids, Space held
|
||||
//! moves the shape being drawn); a click without dragging asks the UI for the options dialog.
|
||||
//! While dragging, ↑/↓ change the spiral's segments, the grid rows or the concentric dividers; ←/→
|
||||
//! change grid columns / radial dividers.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_geom::{Point, Rect, Vec2};
|
||||
@@ -120,8 +121,9 @@ impl Tool for FamilyTool {
|
||||
vec![]
|
||||
}
|
||||
PointerKind::Drag => {
|
||||
let Some(s) = self.start else { return vec![] };
|
||||
self.last = ev.pos;
|
||||
let Some(mut s) = self.start else { return vec![] };
|
||||
crate::shape::space_moves(&mut s, &mut self.last, ev, self.began);
|
||||
self.start = Some(s);
|
||||
self.mods = ev.mods;
|
||||
let mut out = vec![];
|
||||
if !self.began {
|
||||
@@ -238,7 +240,13 @@ mod tests {
|
||||
);
|
||||
let a = t.key(&cx, ToolKey::Right, Mods::default());
|
||||
assert!(matches!(&a[0], Action::Preview(_, v) if v["columns"] == 6));
|
||||
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 60.0, 40.0)), vec![Action::Commit]);
|
||||
// Space held moves the grid at its size; let go, it grows again from there.
|
||||
let space = Mods { space: true, ..Mods::default() };
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 80.0, 50.0).with_mods(space));
|
||||
assert!(matches!(&a[..], [Action::Preview(_, v)] if v["x"] == 30.0 && v["y"] == 20.0 && v["width"] == 50.0 && v["height"] == 30.0), "{a:?}");
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 90.0, 60.0));
|
||||
assert!(matches!(&a[..], [Action::Preview(_, v)] if v["x"] == 30.0 && v["width"] == 60.0 && v["height"] == 40.0), "{a:?}");
|
||||
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 90.0, 60.0)), vec![Action::Commit]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+223
-22
@@ -2,9 +2,10 @@
|
||||
//! construction lines and labels Illustrator users expect.
|
||||
|
||||
use vectorcraft_doc::hit::{HitOptions, hit_test};
|
||||
use vectorcraft_doc::{Document, NodeId, NodeKind};
|
||||
use vectorcraft_geom::{Point, Rect, Vec2};
|
||||
use vectorcraft_doc::{Document, NodeId, NodeKind, OrientedBox};
|
||||
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
|
||||
|
||||
use crate::bbox::Handle;
|
||||
use crate::{Overlay, ToolContext};
|
||||
|
||||
pub const MAGENTA: [u8; 3] = [0xff, 0x3d, 0xfc];
|
||||
@@ -15,6 +16,7 @@ enum Kind {
|
||||
Center,
|
||||
Edge,
|
||||
Artboard,
|
||||
Bleed,
|
||||
}
|
||||
|
||||
impl Kind {
|
||||
@@ -24,6 +26,7 @@ impl Kind {
|
||||
Kind::Center => "center",
|
||||
Kind::Edge => "path",
|
||||
Kind::Artboard => "artboard",
|
||||
Kind::Bleed => "bleed",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,6 +41,16 @@ pub struct Targets {
|
||||
|
||||
impl Targets {
|
||||
pub fn collect(doc: &Document, exclude: &[NodeId], visible: Option<Rect>) -> Self {
|
||||
Self::collect_inner(doc, exclude, None, visible)
|
||||
}
|
||||
|
||||
/// Targets for dragging artboard `index`: everything except that artboard and `exclude` (the
|
||||
/// art that moves along with it).
|
||||
pub fn for_artboard(doc: &Document, index: usize, exclude: &[NodeId]) -> Self {
|
||||
Self::collect_inner(doc, exclude, Some(index), None)
|
||||
}
|
||||
|
||||
fn collect_inner(doc: &Document, exclude: &[NodeId], skip_artboard: Option<usize>, visible: Option<Rect>) -> Self {
|
||||
let mut t = Targets::default();
|
||||
let excluded = |id: NodeId| exclude.iter().any(|e| doc.ancestry(id).is_some_and(|a| a.contains(e)));
|
||||
let add_rect = |t: &mut Targets, r: Rect, kind: Kind| {
|
||||
@@ -46,12 +59,18 @@ impl Targets {
|
||||
t.xs.push((p.x, p, kind));
|
||||
t.ys.push((p.y, p, kind));
|
||||
}
|
||||
t.points.push((c, Kind::Center));
|
||||
t.xs.push((c.x, c, Kind::Center));
|
||||
t.ys.push((c.y, c, Kind::Center));
|
||||
// A bleed shares its artboard's centre.
|
||||
if kind != Kind::Bleed {
|
||||
t.points.push((c, Kind::Center));
|
||||
t.xs.push((c.x, c, Kind::Center));
|
||||
t.ys.push((c.y, c, Kind::Center));
|
||||
}
|
||||
};
|
||||
for ab in &doc.artboards {
|
||||
for (_, ab) in doc.artboards.iter().enumerate().filter(|(i, _)| Some(*i) != skip_artboard) {
|
||||
add_rect(&mut t, ab.rect, Kind::Artboard);
|
||||
if doc.setup.has_bleed() {
|
||||
add_rect(&mut t, doc.setup.bleed_rect(ab.rect), Kind::Bleed);
|
||||
}
|
||||
}
|
||||
let mut budget = 20_000usize;
|
||||
doc.walk(|n| {
|
||||
@@ -123,30 +142,49 @@ impl Targets {
|
||||
(out, ov)
|
||||
}
|
||||
|
||||
/// Snap a dragged point that carries others along at `offsets` (a resize handle and the
|
||||
/// bleed edge beyond it): onto an anchor or centre near the point itself, otherwise into line
|
||||
/// with targets, each axis on whichever of them comes nearest.
|
||||
pub fn snap_point_with(&self, p: Point, offsets: &[Vec2], tol: f64) -> (Point, Vec<Overlay>) {
|
||||
if self.points.iter().any(|(q, _)| q.distance(p) <= tol) {
|
||||
return self.snap_point(p, tol);
|
||||
}
|
||||
let rects: Vec<Rect> = std::iter::once(Vec2::ZERO).chain(offsets.iter().copied()).map(|o| Rect::from_points(p + o, p + o)).collect();
|
||||
let (adj, mut ov) = self.snap_rects(&rects, tol);
|
||||
let out = p + adj;
|
||||
if !ov.is_empty() {
|
||||
ov.push(Overlay::Label { p: out, text: "align".into(), color: MAGENTA });
|
||||
}
|
||||
(out, ov)
|
||||
}
|
||||
|
||||
/// Snap a moving rectangle (selection bounds after a move by `d`): tries its corners/edges/centre.
|
||||
pub fn snap_rect(&self, r: Rect, tol: f64) -> (Vec2, Vec<Overlay>) {
|
||||
let c = r.center();
|
||||
let xs = [r.x0, c.x, r.x1];
|
||||
let ys = [r.y0, c.y, r.y1];
|
||||
let best_x = xs
|
||||
.iter()
|
||||
.flat_map(|x| self.xs.iter().map(move |(t, from, _)| (t - x, *from, *x)))
|
||||
.filter(|(d, _, _)| d.abs() <= tol)
|
||||
.min_by(|a, b| a.0.abs().total_cmp(&b.0.abs()));
|
||||
let best_y = ys
|
||||
.iter()
|
||||
.flat_map(|y| self.ys.iter().map(move |(t, from, _)| (t - y, *from, *y)))
|
||||
.filter(|(d, _, _)| d.abs() <= tol)
|
||||
.min_by(|a, b| a.0.abs().total_cmp(&b.0.abs()));
|
||||
self.snap_rects(&[r], tol)
|
||||
}
|
||||
|
||||
/// Snap rectangles that move together (an artboard and its bleed): the edge or centre of any
|
||||
/// of them nearest a target, per axis. Returns the shift and the guides.
|
||||
pub fn snap_rects(&self, rects: &[Rect], tol: f64) -> (Vec2, Vec<Overlay>) {
|
||||
let nearest = |targets: &[(f64, Point, Kind)], along: fn(&Rect) -> [f64; 3]| {
|
||||
rects
|
||||
.iter()
|
||||
.flat_map(|r| along(r).into_iter().map(move |v| (v, *r)))
|
||||
.flat_map(|(v, r)| targets.iter().map(move |(t, from, _)| (t - v, *from, v, r)))
|
||||
.filter(|(d, ..)| d.abs() <= tol)
|
||||
.min_by(|a, b| a.0.abs().total_cmp(&b.0.abs()))
|
||||
};
|
||||
let best_x = nearest(&self.xs, |r| [r.x0, r.center().x, r.x1]);
|
||||
let best_y = nearest(&self.ys, |r| [r.y0, r.center().y, r.y1]);
|
||||
let mut d = Vec2::ZERO;
|
||||
let mut ov = vec![];
|
||||
if let Some((dx, from, x)) = best_x {
|
||||
if let Some((dx, from, x, r)) = best_x {
|
||||
d.x = dx;
|
||||
let x = x + dx;
|
||||
let (y0, y1) = (from.y.min(r.y0), from.y.max(r.y1));
|
||||
ov.push(Overlay::Line { a: Point::new(x, y0), b: Point::new(x, y1), color: MAGENTA, dashed: false });
|
||||
}
|
||||
if let Some((dy, from, y)) = best_y {
|
||||
if let Some((dy, from, y, r)) = best_y {
|
||||
d.y = dy;
|
||||
let y = y + dy;
|
||||
let (x0, x1) = (from.x.min(r.x0), from.x.max(r.x1));
|
||||
@@ -154,10 +192,108 @@ impl Targets {
|
||||
}
|
||||
(d, ov)
|
||||
}
|
||||
|
||||
/// Snap a bounding-box resize. `a` is the scale [`crate::bbox::scale_for_drag`] gave for
|
||||
/// dragging `handle` of the box `bx` (about the centre when `from_center`), in the box's own
|
||||
/// frame, and so is the result. The corners and the side that handle moves land on the nearest
|
||||
/// target within `tol` on the page, whatever the box's angle. A handle with one way to go (a
|
||||
/// side, or a proportional corner) slides along it until one of them is on a target.
|
||||
pub fn snap_scale(&self, bx: &OrientedBox, handle: Handle, a: Affine, proportional: bool, from_center: bool, tol: f64) -> (Affine, Vec<Overlay>) {
|
||||
type Hit = Option<(f64, Point)>;
|
||||
let r = bx.rect;
|
||||
let origin = if from_center { r.center() } else { handle.opposite().pos(r) };
|
||||
let (d0, [sx, _, _, sy, _, _]) = (handle.pos(r) - origin, a.as_coeffs());
|
||||
let (to_doc, to_local) = (bx.to_doc(), bx.to_doc().inverse());
|
||||
// A scale that collapses or flips the box is no snap.
|
||||
let valid = |n: f64, s: f64| n.is_finite() && n * s > 0.0;
|
||||
let nearest = |v: f64, ts: &[(f64, Point, Kind)]| -> Hit {
|
||||
ts.iter().map(|(t, from, _)| (*t, *from)).filter(|(t, _)| (t - v).abs() <= tol).min_by(|p, q| (p.0 - v).abs().total_cmp(&(q.0 - v).abs()))
|
||||
};
|
||||
let (mut nx, mut ny) = (sx, sy);
|
||||
let (mut hit_x, mut hit_y): (Hit, Hit) = (None, None);
|
||||
if handle.is_corner() && !proportional {
|
||||
// The corner goes anywhere: it takes the target's x, its y, or both.
|
||||
let p = to_doc * (a * handle.pos(r));
|
||||
let (hx, hy) = (nearest(p.x, &self.xs), nearest(p.y, &self.ys));
|
||||
for (tx, ty) in [(hx, hy), (hx, None), (None, hy)] {
|
||||
if tx.is_none() && ty.is_none() {
|
||||
continue;
|
||||
}
|
||||
let q = to_local * Point::new(tx.map_or(p.x, |h| h.0), ty.map_or(p.y, |h| h.0));
|
||||
let (mut cx, mut cy) = ((q.x - origin.x) / d0.x, (q.y - origin.y) / d0.y);
|
||||
// Square to the page, the axis without a target stays exactly where it was.
|
||||
if bx.angle == 0.0 {
|
||||
(cx, cy) = (if tx.is_some() { cx } else { sx }, if ty.is_some() { cy } else { sy });
|
||||
}
|
||||
if valid(cx, sx) && valid(cy, sy) {
|
||||
(nx, ny, hit_x, hit_y) = (cx, cy, tx, ty);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// One parameter `s`: the scale is `c + s·e` on each axis.
|
||||
let (ax, _) = handle.axes();
|
||||
let (e, c, s0) = match (handle.is_corner(), ax, proportional) {
|
||||
(true, _, _) => ((sx.signum(), sy.signum()), (0.0, 0.0), sx.abs()),
|
||||
(false, true, true) => ((1.0, sx.signum()), (0.0, 0.0), sx),
|
||||
(false, true, false) => ((1.0, 0.0), (0.0, 1.0), sx),
|
||||
(false, false, true) => ((sy.signum(), 1.0), (0.0, 0.0), sy),
|
||||
(false, false, false) => ((0.0, 1.0), (1.0, 0.0), sy),
|
||||
};
|
||||
// The points that may land on a target: the handle, and the two ends of its side.
|
||||
let i = handle as usize;
|
||||
let ends = if handle.is_corner() { [None, None] } else { [Handle::ALL.get((i + 1) % 8), Handle::ALL.get((i + 7) % 8)] };
|
||||
// How far the handle travels per unit of `s`: a snap may not drag it far from the
|
||||
// pointer, as it would for a target the side runs almost along.
|
||||
let travel = (e.0 * d0.x).hypot(e.1 * d0.y);
|
||||
let mut best: Option<(f64, f64, bool, (f64, Point))> = None;
|
||||
for h in std::iter::once(handle).chain(ends.into_iter().flatten().copied()) {
|
||||
let v = h.pos(r) - origin;
|
||||
let base = to_doc * (origin + Vec2::new(c.0 * v.x, c.1 * v.y));
|
||||
let dir = (to_doc * Point::new(e.0 * v.x, e.1 * v.y)).to_vec2();
|
||||
let p = base + dir * s0;
|
||||
for (is_x, at, b, d, ts) in [(true, p.x, base.x, dir.x, &self.xs), (false, p.y, base.y, dir.y, &self.ys)] {
|
||||
if d.abs() <= 1e-9 {
|
||||
continue;
|
||||
}
|
||||
let Some(hit) = nearest(at, ts) else { continue };
|
||||
let s = (hit.0 - b) / d;
|
||||
let moved = (s - s0).abs() * travel;
|
||||
if valid(s, s0) && moved <= 2.0 * tol && best.is_none_or(|k| moved < k.1) {
|
||||
best = Some((s, moved, is_x, hit));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((s, _, is_x, hit)) = best {
|
||||
(nx, ny) = (c.0 + s * e.0, c.1 + s * e.1);
|
||||
if is_x {
|
||||
hit_x = Some(hit);
|
||||
} else {
|
||||
hit_y = Some(hit);
|
||||
}
|
||||
}
|
||||
}
|
||||
let out = Affine::translate(origin.to_vec2()) * Affine::scale_non_uniform(nx, ny) * Affine::translate(-origin.to_vec2());
|
||||
// The lines run from the target's own point across the resized box, on the page.
|
||||
let nr = (to_doc * out).transform_rect_bbox(r);
|
||||
let mut ov = vec![];
|
||||
if let Some((x, from)) = hit_x {
|
||||
ov.push(Overlay::Line { a: Point::new(x, from.y.min(nr.y0)), b: Point::new(x, from.y.max(nr.y1)), color: MAGENTA, dashed: false });
|
||||
}
|
||||
if let Some((y, from)) = hit_y {
|
||||
ov.push(Overlay::Line { a: Point::new(from.x.min(nr.x0), y), b: Point::new(from.x.max(nr.x1), y), color: MAGENTA, dashed: false });
|
||||
}
|
||||
(out, ov)
|
||||
}
|
||||
}
|
||||
|
||||
/// Snap `p` for a drawing tool when smart guides (or grid snapping) are on.
|
||||
pub fn snap_draw(cx: &ToolContext, p: Point, exclude: &[NodeId]) -> (Point, Vec<Overlay>) {
|
||||
snap_with(cx, p, || Targets::collect(cx.doc, exclude, None))
|
||||
}
|
||||
|
||||
/// Snap `p` to pixels or the grid when they are on, else to the smart guide `targets`.
|
||||
fn snap_with(cx: &ToolContext, p: Point, targets: impl FnOnce() -> Targets) -> (Point, Vec<Overlay>) {
|
||||
if cx.snap_to_pixel {
|
||||
return (Point::new(p.x.round(), p.y.round()), vec![]);
|
||||
}
|
||||
@@ -168,7 +304,28 @@ pub fn snap_draw(cx: &ToolContext, p: Point, exclude: &[NodeId]) -> (Point, Vec<
|
||||
if !cx.smart_guides {
|
||||
return (p, vec![]);
|
||||
}
|
||||
Targets::collect(cx.doc, exclude, None).snap_point(p, cx.tol(5.0))
|
||||
targets().snap_point(p, cx.tol(5.0))
|
||||
}
|
||||
|
||||
/// Where a dragged direction handle of anchor `ai` of subpath `si` of path `id` goes for the
|
||||
/// pointer at `p`: with Shift at a multiple of 45° (from the Constrain Angle) round its anchor,
|
||||
/// else snapped as a drawn point is (smart guides, the grid, pixels).
|
||||
pub fn snap_handle(cx: &ToolContext, (id, si, ai): (NodeId, usize, usize), p: Point, shift: bool) -> (Point, Vec<Overlay>) {
|
||||
let path = cx.doc.node(id).and_then(|n| n.path_data());
|
||||
if shift && let Some(a) = path.and_then(|pd| pd.subpaths.get(si)?.anchors.get(ai)) {
|
||||
return (a.p + vectorcraft_geom::constrain_angle_from(p - a.p, 45.0, cx.constrain_angle), vec![]);
|
||||
}
|
||||
snap_with(cx, p, || {
|
||||
// The path's bounds move with the handle, so they would chase it: its anchors (which stay
|
||||
// put) are the targets instead, the handle lining up with them too.
|
||||
let mut t = Targets::collect(cx.doc, &[id], None);
|
||||
for (_, _, a) in path.into_iter().flat_map(|pd| pd.anchors()).take(20_000) {
|
||||
t.points.push((a.p, Kind::Anchor));
|
||||
t.xs.push((a.p.x, a.p, Kind::Anchor));
|
||||
t.ys.push((a.p.y, a.p, Kind::Anchor));
|
||||
}
|
||||
t
|
||||
})
|
||||
}
|
||||
|
||||
/// Snap a picked point (a transform tool's reference point) to the nearest anchor or centre of the
|
||||
@@ -189,6 +346,16 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::testutil::*;
|
||||
|
||||
#[test]
|
||||
fn artboard_bleed_is_a_target() {
|
||||
let (mut d, _) = doc_with_rect();
|
||||
let t = Targets::collect(&d, &[], None);
|
||||
assert_eq!(t.snap_point(Point::new(512.0, 300.0), 4.0).0.x, 512.0, "no bleed, nothing near");
|
||||
d.setup.bleed = [10.0; 4];
|
||||
let t = Targets::collect(&d, &[], None);
|
||||
assert_eq!(t.snap_point(Point::new(512.0, 300.0), 4.0).0.x, 510.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snaps_to_anchor_and_alignment() {
|
||||
let (d, _) = doc_with_rect();
|
||||
@@ -209,4 +376,38 @@ mod tests {
|
||||
assert_eq!(dv.x, -2.0);
|
||||
assert!(!ov.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_snap_lands_the_moved_edges_on_targets() {
|
||||
use crate::bbox::scale_for_drag;
|
||||
let (d, _) = doc_with_rect();
|
||||
// The document's rect is 100..200: a box beside it is resized against it.
|
||||
let t = Targets::collect(&d, &[], None);
|
||||
let r = Rect::new(250.0, 100.0, 300.0, 150.0);
|
||||
let snap = |h: Handle, p: Point, shift: bool, alt: bool| {
|
||||
let (a, ov) = t.snap_scale(&OrientedBox::aligned(r), h, scale_for_drag(r, h, p, shift, alt), shift, alt, 4.0);
|
||||
(a.transform_rect_bbox(r), ov)
|
||||
};
|
||||
// The bottom edge lands on the neighbour's bottom: same height.
|
||||
let (nr, ov) = snap(Handle::Bottom, Point::new(275.0, 197.0), false, false);
|
||||
assert_eq!(nr, Rect::new(250.0, 100.0, 300.0, 200.0));
|
||||
assert_eq!(ov.len(), 1);
|
||||
// A corner snaps each axis on its own: y to the neighbour, x stays free.
|
||||
let (nr, ov) = snap(Handle::BottomRight, Point::new(330.0, 202.0), false, false);
|
||||
assert_eq!(nr, Rect::new(250.0, 100.0, 330.0, 200.0));
|
||||
assert_eq!(ov.len(), 1);
|
||||
// Proportional: the snapped axis carries the other one.
|
||||
let (nr, _) = snap(Handle::BottomRight, Point::new(340.0, 198.0), true, false);
|
||||
assert_eq!(nr, Rect::new(250.0, 100.0, 350.0, 200.0));
|
||||
// From the centre the dragged edge still lands on the target.
|
||||
let (nr, _) = snap(Handle::Bottom, Point::new(275.0, 198.0), false, true);
|
||||
assert_eq!(nr, Rect::new(250.0, 50.0, 300.0, 200.0));
|
||||
// Out of reach, and the fixed edge's own target: untouched.
|
||||
let (nr, ov) = snap(Handle::Bottom, Point::new(275.0, 180.0), false, false);
|
||||
assert_eq!(nr, Rect::new(250.0, 100.0, 300.0, 180.0));
|
||||
assert!(ov.is_empty());
|
||||
let (nr, ov) = snap(Handle::Bottom, Point::new(275.0, 102.0), false, false);
|
||||
assert_eq!(nr, Rect::new(250.0, 100.0, 300.0, 102.0));
|
||||
assert!(ov.is_empty());
|
||||
}
|
||||
}
|
||||
Loaded 100 of 182 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user