Compare commits
133
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 | ||
|
|
a92905a816 | ||
|
|
6f54843d5a | ||
|
|
5623f90ef7 | ||
|
|
681d18a08f | ||
|
|
52e1c0eca0 | ||
|
|
fcaeaa74a7 | ||
|
|
7e0ed6b183 | ||
|
|
6f9b160038 | ||
|
|
dcbe59dae9 | ||
|
|
d370f0a542 | ||
|
|
e5e09d5d48 | ||
|
|
d9136c46b9 | ||
|
|
a40260a441 | ||
|
|
ef167a1345 |
No files matched your search
@@ -46,7 +46,7 @@ jobs:
|
||||
}
|
||||
- name: XML, desktop entry, AppStream, Flatpak manifest
|
||||
run: |
|
||||
xmllint --noout packaging/windows/vectorcraft.wxs packaging/windows/vectorcraft.en-us.wxl packaging/macos/*.plist* packaging/linux/*.xml*
|
||||
xmllint --noout packaging/windows/vectorcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml*
|
||||
# Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon.
|
||||
if grep -oE '<Icon Id="[^"]*"' packaging/windows/vectorcraft.wxs | grep -vE '\.(ico|exe)"$'; then
|
||||
echo "::error::Icon Id must end in .ico or .exe"; exit 1
|
||||
|
||||
@@ -148,9 +148,7 @@ jobs:
|
||||
run: cargo xtask version set "$VECTORCRAFT_VERSION"
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: |
|
||||
dotnet tool install --global wix --version 5.0.2
|
||||
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
|
||||
run: dotnet tool install --global wix --version 5.0.2
|
||||
- name: Build, sign, package
|
||||
shell: pwsh
|
||||
env:
|
||||
|
||||
@@ -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:
|
||||
@@ -45,9 +45,7 @@ jobs:
|
||||
key: windows-arm64-package
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: |
|
||||
dotnet tool install --global wix --version 5.0.2
|
||||
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
|
||||
run: dotnet tool install --global wix --version 5.0.2
|
||||
- name: Build and package (unsigned)
|
||||
shell: pwsh
|
||||
env:
|
||||
|
||||
@@ -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>
|
||||
@@ -172,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:**
|
||||
@@ -216,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) |
|
||||
|
||||
@@ -244,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.
|
||||
@@ -251,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).
|
||||
|
||||
+37
-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, the 2026-10-07 issue fixes and the Layers panel rework; 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; hybrid-graphics laptops render on the power-saving GPU by default (#306); 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,42 +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.
|
||||
- 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) 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).
|
||||
- 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.
|
||||
- **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, 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, 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.
|
||||
@@ -136,11 +141,11 @@ 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.
|
||||
@@ -152,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** |
|
||||
@@ -184,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 |
|
||||
@@ -192,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 | 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 |
|
||||
@@ -214,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
|
||||
@@ -248,6 +274,7 @@ fn app_icon() -> egui::IconData {
|
||||
/// 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) {
|
||||
@@ -258,6 +285,7 @@ fn power_preference(pref: Option<&str>, env: Option<eframe::wgpu::PowerPreferenc
|
||||
}
|
||||
|
||||
/// "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)
|
||||
}
|
||||
@@ -283,9 +311,11 @@ 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 mut options = eframe::NativeOptions {
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("VectorCraft")
|
||||
.with_inner_size(window::DEFAULT_SIZE)
|
||||
@@ -297,11 +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()
|
||||
};
|
||||
if let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
|
||||
create.power_preference = power;
|
||||
}
|
||||
#[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,
|
||||
@@ -323,22 +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")]
|
||||
@@ -348,7 +391,7 @@ fn main() -> eframe::Result {
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, feature = "wgpu"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use eframe::wgpu::PowerPreference;
|
||||
@@ -367,6 +410,35 @@ mod tests {
|
||||
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() {
|
||||
|
||||
@@ -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 |
+103
-9
@@ -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).
|
||||
@@ -869,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
|
||||
}
|
||||
@@ -891,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) {
|
||||
@@ -989,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();
|
||||
|
||||
+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}`).
|
||||
|
||||
@@ -228,24 +228,10 @@ fn all_layers(d: &Document) -> Vec<&Node> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The next unused `Layer N` name, counting sublayers too.
|
||||
pub(crate) fn next_layer_name(d: &Document) -> String {
|
||||
let layers = all_layers(d);
|
||||
let taken: HashSet<&str> = layers.iter().filter_map(|l| l.name.as_deref()).collect();
|
||||
let mut i = layers.len() + 1;
|
||||
loop {
|
||||
let name = format!("Layer {i}");
|
||||
if !taken.contains(name.as_str()) {
|
||||
return name;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(|| next_layer_name(d));
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -144,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(()) }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<_>>() }))
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -250,6 +250,9 @@ pub struct ToolContext<'a> {
|
||||
pub slices_hidden: bool,
|
||||
/// View → Lock Slices: the Slice Selection tool leaves locked slices alone.
|
||||
pub slices_locked: bool,
|
||||
/// General → Disable Auto Add/Delete is off: the Pen adds an anchor on a selected path's
|
||||
/// segment and deletes one of its anchors.
|
||||
pub auto_add_delete: bool,
|
||||
/// The document window (none headless): screen-fixed widgets sit in it.
|
||||
pub screen: Option<ScreenFrame>,
|
||||
/// Where the Plane Switching Widget sits (Perspective Grid Options); None while it's hidden.
|
||||
@@ -322,6 +325,8 @@ pub enum Cursor {
|
||||
PenDelete,
|
||||
PenClose,
|
||||
PenContinue,
|
||||
/// Over the last anchor of the path being drawn: a click retracts its outgoing handle.
|
||||
PenConvert,
|
||||
Text,
|
||||
Hand,
|
||||
HandGrab,
|
||||
@@ -518,6 +523,7 @@ pub(crate) mod testutil {
|
||||
paste_plain_text: false,
|
||||
slices_hidden: false,
|
||||
slices_locked: false,
|
||||
auto_add_delete: true,
|
||||
screen: None,
|
||||
plane_widget: Some(Default::default()),
|
||||
}
|
||||
|
||||
+277
-12
@@ -1,10 +1,15 @@
|
||||
//! The Pen tool (P).
|
||||
//!
|
||||
//! Click adds a corner anchor; click-drag adds a smooth anchor with symmetric handles (Alt-drag
|
||||
//! breaks the handles); Shift constrains to 45°. Clicking the first anchor closes the path.
|
||||
//! Click adds a corner anchor; click-drag adds a smooth anchor with symmetric handles (Alt breaks
|
||||
//! them: the incoming handle stays where it was when Alt went down and only the outgoing one
|
||||
//! follows the pointer; Space held moves the anchor, handles and all); Shift constrains to 45°.
|
||||
//! Clicking the first anchor closes the path. Clicking the last one retracts its outgoing handle, so
|
||||
//! the next segment leaves it as a corner; dragging from it pulls a new one out on its own.
|
||||
//! Enter/Esc (or switching tools) ends the path. Clicking the end of a selected open path continues
|
||||
//! it. The rubber-band preview shows the next segment. On a selected blend's spine a click adds a
|
||||
//! point (on a point no key object sits on: deletes it).
|
||||
//! it. The rubber-band preview shows the next segment. Auto Add/Delete: between paths, a click on a
|
||||
//! segment of a selected path adds an anchor there and a click on one of its anchors deletes it
|
||||
//! (Shift held or General → Disable Auto Add/Delete starts a new path instead). On a selected
|
||||
//! blend's spine a click adds a point (on a point no key object sits on: deletes it).
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::{NodeId, NodeKind};
|
||||
@@ -18,6 +23,13 @@ pub struct PenTool {
|
||||
drawing: bool,
|
||||
drag: Option<(Point, bool)>,
|
||||
hover: Option<Point>,
|
||||
/// The incoming handle of the anchor being dragged out, as last previewed.
|
||||
in_h: Point,
|
||||
/// The pointer at the last button-down or drag: Space held moves the anchor as far as it moves.
|
||||
last: Point,
|
||||
/// The last anchor of the path being drawn, while a click retracts its outgoing handle or a
|
||||
/// drag pulls a new one out: (path, subpath, anchor, its position).
|
||||
handle: Option<(NodeId, usize, usize, Point)>,
|
||||
}
|
||||
|
||||
/// The open path the pen is extending: the single selected open path.
|
||||
@@ -37,12 +49,28 @@ fn active_path(cx: &ToolContext) -> Option<(NodeId, Point, Point, Point)> {
|
||||
Some((id, first, last.p, last.h_out))
|
||||
}
|
||||
|
||||
/// The last anchor of `id`'s last subpath: (subpath, anchor).
|
||||
fn last_anchor(cx: &ToolContext, id: NodeId) -> Option<(usize, usize)> {
|
||||
let pd = cx.doc.node(id)?.path_data()?;
|
||||
let si = pd.subpaths.len().checked_sub(1)?;
|
||||
let ai = pd.subpaths.get(si)?.anchors.len().checked_sub(1)?;
|
||||
Some((si, ai))
|
||||
}
|
||||
|
||||
/// Preview the outgoing handle of anchor `ai` of subpath `si` at `h`, the incoming one left alone.
|
||||
fn set_out_handle(id: NodeId, si: usize, ai: usize, h: Point) -> Action {
|
||||
Action::Preview(
|
||||
"path.setHandle".into(),
|
||||
json!({"id": id.0, "subpath": si, "anchor": ai, "which": "out", "x": h.x, "y": h.y, "independent": true}),
|
||||
)
|
||||
}
|
||||
|
||||
impl Tool for PenTool {
|
||||
fn id(&self) -> &'static str {
|
||||
"pen"
|
||||
}
|
||||
fn busy(&self) -> bool {
|
||||
self.drag.is_some()
|
||||
self.drag.is_some() || self.handle.is_some()
|
||||
}
|
||||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||
let exclude: Vec<vectorcraft_doc::NodeId> = if self.drawing { cx.selection.objects.clone() } else { vec![] };
|
||||
@@ -58,15 +86,25 @@ impl Tool for PenTool {
|
||||
vec![]
|
||||
}
|
||||
PointerKind::Down => {
|
||||
self.last = ev.pos;
|
||||
if let Some((id, first, last, _)) = active {
|
||||
if ev.mods.shift {
|
||||
p = last + vectorcraft_geom::constrain_angle(p - last, 45.0);
|
||||
}
|
||||
if p.distance(first) <= tol {
|
||||
let end = last_anchor(cx, id);
|
||||
// A one-anchor path doesn't close on itself: its anchor is the last one too.
|
||||
if p.distance(first) <= tol && end.is_some_and(|(_, ai)| ai > 0) {
|
||||
self.drag = Some((first, true));
|
||||
return vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))];
|
||||
}
|
||||
if p.distance(last) <= tol
|
||||
&& let Some((si, ai)) = end
|
||||
{
|
||||
self.handle = Some((id, si, ai, last));
|
||||
return vec![Action::Begin("Convert Anchor Point".into()), set_out_handle(id, si, ai, last)];
|
||||
}
|
||||
self.drag = Some((p, false));
|
||||
self.in_h = p;
|
||||
return vec![Action::Begin("Pen".into()), Action::Preview("path.appendAnchor".into(), json!({"id": id.0, "x": p.x, "y": p.y}))];
|
||||
}
|
||||
if let Some(acts) = spine_click(cx, p, tol) {
|
||||
@@ -82,12 +120,30 @@ impl Tool for PenTool {
|
||||
}
|
||||
return vec![];
|
||||
}
|
||||
if let Some(act) = auto_add_delete(cx, ev.pos, ev.mods, tol) {
|
||||
return vec![act];
|
||||
}
|
||||
self.drawing = true;
|
||||
self.drag = Some((p, false));
|
||||
vec![Action::Begin("Pen".into()), Action::Preview("path.create".into(), json!({"anchors": [{"x": p.x, "y": p.y}]}))]
|
||||
}
|
||||
PointerKind::Drag => {
|
||||
let Some((a, closing)) = self.drag else { return vec![] };
|
||||
if let Some((id, si, ai, a)) = self.handle {
|
||||
let mut h = ev.pos;
|
||||
if ev.mods.shift {
|
||||
h = a + vectorcraft_geom::constrain_angle(ev.pos - a, 45.0);
|
||||
}
|
||||
return vec![set_out_handle(id, si, ai, h)];
|
||||
}
|
||||
let Some((mut a, closing)) = self.drag else { return vec![] };
|
||||
// Space held moves the anchor being placed, handles and all.
|
||||
if ev.mods.space && !closing {
|
||||
let d = ev.pos - self.last;
|
||||
a += d;
|
||||
self.in_h += d;
|
||||
self.drag = Some((a, closing));
|
||||
}
|
||||
self.last = ev.pos;
|
||||
let mut out_h = ev.pos;
|
||||
if ev.mods.shift {
|
||||
out_h = a + vectorcraft_geom::constrain_angle(ev.pos - a, 45.0);
|
||||
@@ -107,13 +163,19 @@ impl Tool for PenTool {
|
||||
json!({"id": id.0, "in": [2.0 * a.x - out_h.x, 2.0 * a.y - out_h.y], "independent": alt}),
|
||||
)];
|
||||
}
|
||||
let in_h = if alt { a } else { a - (out_h - a) };
|
||||
// Alt pressed mid-drag keeps the curve already shaped into the anchor: only the
|
||||
// outgoing handle follows. Held from the start, the incoming handle stays retracted.
|
||||
let in_h = if alt { self.in_h } else { a - (out_h - a) };
|
||||
self.in_h = in_h;
|
||||
vec![Action::Preview(
|
||||
"path.appendAnchor".into(),
|
||||
json!({"id": id.0, "x": a.x, "y": a.y, "in": [in_h.x, in_h.y], "out": [out_h.x, out_h.y]}),
|
||||
)]
|
||||
}
|
||||
PointerKind::Up => {
|
||||
if self.handle.take().is_some() {
|
||||
return vec![Action::Commit];
|
||||
}
|
||||
let Some((_, closing)) = self.drag.take() else { return vec![] };
|
||||
if closing {
|
||||
self.drawing = false;
|
||||
@@ -128,6 +190,7 @@ impl Tool for PenTool {
|
||||
ToolKey::Enter | ToolKey::Escape => {
|
||||
self.drawing = false;
|
||||
self.drag = None;
|
||||
self.handle = None;
|
||||
vec![]
|
||||
}
|
||||
_ => vec![],
|
||||
@@ -138,7 +201,7 @@ impl Tool for PenTool {
|
||||
vec![]
|
||||
}
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
if !self.drawing || self.drag.is_some() {
|
||||
if !self.drawing || self.drag.is_some() || self.handle.is_some() {
|
||||
return vec![];
|
||||
}
|
||||
let (Some((id, _, last, out)), Some(h)) = (active_path(cx), self.hover) else { return vec![] };
|
||||
@@ -152,7 +215,7 @@ impl Tool for PenTool {
|
||||
let c = cx.doc.layer_color(id);
|
||||
vec![Overlay::Path { path: bp, color: c, width: 1.0, dashed: false }]
|
||||
}
|
||||
fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor {
|
||||
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
|
||||
if !self.drawing {
|
||||
match spine_click(cx, p, cx.tol(5.0)).as_deref() {
|
||||
Some([Action::Exec(c, _)]) if c == "object.blend.spine.removeAnchor" => return Cursor::PenDelete,
|
||||
@@ -160,18 +223,47 @@ impl Tool for PenTool {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some((_, first, last, _)) = active_path(cx) {
|
||||
if self.drawing && p.distance(first) <= cx.tol(5.0) {
|
||||
if let Some((id, first, last, _)) = active_path(cx) {
|
||||
let one = last_anchor(cx, id).is_some_and(|(_, ai)| ai == 0);
|
||||
if self.drawing && p.distance(first) <= cx.tol(5.0) && !one {
|
||||
return Cursor::PenClose;
|
||||
}
|
||||
if self.drawing && p.distance(last) <= cx.tol(5.0) {
|
||||
return Cursor::PenConvert;
|
||||
}
|
||||
if !self.drawing && (p.distance(last) <= cx.tol(5.0) || p.distance(first) <= cx.tol(5.0)) {
|
||||
return Cursor::PenContinue;
|
||||
}
|
||||
}
|
||||
if !self.drawing {
|
||||
match auto_add_delete(cx, p, m, cx.tol(5.0)) {
|
||||
Some(Action::Exec(c, _)) if c == "path.removeAnchor" => return Cursor::PenDelete,
|
||||
Some(_) => return Cursor::PenAdd,
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
Cursor::Pen
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto Add/Delete: what a click at `p` does to a selected path. On one of its anchors it deletes
|
||||
/// it; else, where one of its segments passes within `tol`, it adds an anchor. None when the
|
||||
/// preference turns it off, Shift is held, or no selected path is there.
|
||||
fn auto_add_delete(cx: &ToolContext, p: Point, m: Mods, tol: f64) -> Option<Action> {
|
||||
if !cx.auto_add_delete || m.shift {
|
||||
return None;
|
||||
}
|
||||
let paths = || {
|
||||
cx.selection
|
||||
.objects
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&id| cx.doc.is_editable(id) && cx.doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Path { guide: false, .. })))
|
||||
};
|
||||
let anchor = crate::draw2::anchor_in(cx, paths(), p, tol).map(crate::draw2::remove_anchor);
|
||||
anchor.or_else(|| crate::draw2::segment_in(cx, paths(), p, tol).map(crate::draw2::insert_anchor))
|
||||
}
|
||||
|
||||
/// A click on a selected blend's spine: delete the point under `p` when no key object sits on it
|
||||
/// (a key's point does nothing), else add one where the spine passes within `tol`.
|
||||
fn spine_click(cx: &ToolContext, p: Point, tol: f64) -> Option<Vec<Action>> {
|
||||
@@ -210,6 +302,134 @@ mod tests {
|
||||
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 10.0, 10.0)), vec![Action::Commit]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_pressed_mid_drag_keeps_the_incoming_handle() {
|
||||
let (mut d, _) = doc_with_rect();
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let line = vectorcraft_geom::shapes::line(Point::new(10.0, 300.0), Point::new(60.0, 300.0));
|
||||
d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, line, vectorcraft_doc::Appearance::default_art())).unwrap();
|
||||
let mut s = Selection::default();
|
||||
s.set([id]);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = PenTool { drawing: true, ..PenTool::default() };
|
||||
let alt = Mods { alt: true, ..Mods::default() };
|
||||
let handles = |acts: Vec<Action>| match acts.as_slice() {
|
||||
[Action::Preview(c, v)] if c == "path.appendAnchor" => (v["in"].clone(), v["out"].clone()),
|
||||
other => panic!("not an anchor preview: {other:?}"),
|
||||
};
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 400.0, 420.0));
|
||||
let drag = PointerEvent::new(PointerKind::Drag, 450.0, 420.0);
|
||||
assert_eq!(handles(t.pointer(&cx, &drag)), (json!([350.0, 420.0]), json!([450.0, 420.0])));
|
||||
// Alt goes down: the curve shaped so far stays, and only the outgoing handle moves on.
|
||||
let drag = PointerEvent::new(PointerKind::Drag, 450.0, 470.0).with_mods(alt);
|
||||
assert_eq!(handles(t.pointer(&cx, &drag)), (json!([350.0, 420.0]), json!([450.0, 470.0])));
|
||||
let drag = PointerEvent::new(PointerKind::Drag, 400.0, 480.0).with_mods(alt);
|
||||
assert_eq!(handles(t.pointer(&cx, &drag)), (json!([350.0, 420.0]), json!([400.0, 480.0])));
|
||||
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 400.0, 480.0).with_mods(alt)), vec![Action::Commit]);
|
||||
// Alt held from the start: the new anchor gets an outgoing handle only.
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 300.0, 440.0).with_mods(alt));
|
||||
let drag = PointerEvent::new(PointerKind::Drag, 340.0, 440.0).with_mods(alt);
|
||||
assert_eq!(handles(t.pointer(&cx, &drag)), (json!([300.0, 440.0]), json!([340.0, 440.0])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_moves_the_anchor_being_placed() {
|
||||
let (mut d, _) = doc_with_rect();
|
||||
let space = Mods { space: true, ..Mods::default() };
|
||||
// The first anchor of a new path.
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
let cx0 = cx(&d, &s, &p);
|
||||
let mut t = PenTool::default();
|
||||
t.pointer(&cx0, &PointerEvent::new(PointerKind::Down, 400.0, 420.0));
|
||||
t.pointer(&cx0, &PointerEvent::new(PointerKind::Drag, 450.0, 420.0));
|
||||
let created = |acts: Vec<Action>| match acts.as_slice() {
|
||||
[Action::Preview(c, v)] if c == "path.create" => v["anchors"][0].clone(),
|
||||
other => panic!("not a create preview: {other:?}"),
|
||||
};
|
||||
let a = created(t.pointer(&cx0, &PointerEvent::new(PointerKind::Drag, 450.0, 380.0).with_mods(space)));
|
||||
assert_eq!(a, json!({"x": 400.0, "y": 380.0, "out": [450.0, 380.0], "in": [350.0, 380.0]}), "moved, handles and all");
|
||||
// Space released: the drag shapes the handles round the anchor's new place.
|
||||
let a = created(t.pointer(&cx0, &PointerEvent::new(PointerKind::Drag, 460.0, 380.0)));
|
||||
assert_eq!(a, json!({"x": 400.0, "y": 380.0, "out": [460.0, 380.0], "in": [340.0, 380.0]}));
|
||||
// An anchor added to an open path, its handles broken by Alt: the kept incoming handle
|
||||
// moves with it.
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let line = vectorcraft_geom::shapes::line(Point::new(10.0, 300.0), Point::new(60.0, 300.0));
|
||||
d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, line, vectorcraft_doc::Appearance::default_art())).unwrap();
|
||||
let mut s = Selection::default();
|
||||
s.set([id]);
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = PenTool { drawing: true, ..PenTool::default() };
|
||||
let alt = Mods { alt: true, ..Mods::default() };
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 400.0, 420.0));
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 450.0, 420.0));
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 450.0, 470.0).with_mods(alt));
|
||||
let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 450.0, 450.0).with_mods(Mods { space: true, ..alt }));
|
||||
assert_eq!(
|
||||
acts,
|
||||
vec![Action::Preview(
|
||||
"path.appendAnchor".into(),
|
||||
json!({"id": id.0, "x": 400.0, "y": 400.0, "in": [350.0, 400.0], "out": [450.0, 450.0]})
|
||||
)]
|
||||
);
|
||||
}
|
||||
|
||||
/// A document with an open path of `anchors`, selected, being drawn by a Pen.
|
||||
fn drawing(anchors: vectorcraft_geom::SubPath) -> (vectorcraft_doc::Document, NodeId, Selection) {
|
||||
let (mut d, _) = doc_with_rect();
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let path = vectorcraft_geom::PathData::single(anchors);
|
||||
d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, path, vectorcraft_doc::Appearance::default_art())).unwrap();
|
||||
let mut s = Selection::default();
|
||||
s.set([id]);
|
||||
(d, id, s)
|
||||
}
|
||||
|
||||
fn set_out(id: NodeId, ai: usize, x: f64, y: f64) -> Action {
|
||||
Action::Preview("path.setHandle".into(), json!({"id": id.0, "subpath": 0, "anchor": ai, "which": "out", "x": x, "y": y, "independent": true}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_the_last_anchor_retracts_its_handle_and_dragging_pulls_a_new_one() {
|
||||
// The last anchor is smooth: handles at 40 and 80 either side of (60, 300).
|
||||
let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(10.0, 300.0), Point::new(60.0, 300.0)], false);
|
||||
sp.anchors[1].h_in = Point::new(40.0, 300.0);
|
||||
sp.anchors[1].h_out = Point::new(80.0, 300.0);
|
||||
let (d, id, s) = drawing(sp);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = PenTool { drawing: true, ..PenTool::default() };
|
||||
assert_eq!(t.cursor(&cx, Point::new(61.0, 301.0), Mods::default()), Cursor::PenConvert);
|
||||
// A click retracts the outgoing handle and adds no anchor.
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 61.0, 301.0));
|
||||
assert_eq!(a, vec![Action::Begin("Convert Anchor Point".into()), set_out(id, 1, 60.0, 300.0)]);
|
||||
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 61.0, 301.0)), vec![Action::Commit]);
|
||||
// A drag pulls a new outgoing handle out.
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 60.0, 300.0));
|
||||
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 70.0, 280.0)), vec![set_out(id, 1, 70.0, 280.0)]);
|
||||
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 70.0, 280.0)), vec![Action::Commit]);
|
||||
// Elsewhere a click still adds an anchor, and the first anchor still closes the path.
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 200.0, 400.0));
|
||||
assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "path.appendAnchor"), "{a:?}");
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 200.0, 400.0));
|
||||
assert_eq!(t.cursor(&cx, Point::new(10.0, 300.0), Mods::default()), Cursor::PenClose);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_one_anchor_path_does_not_close_on_itself() {
|
||||
let (d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(10.0, 300.0)], false));
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = PenTool { drawing: true, ..PenTool::default() };
|
||||
assert_eq!(t.cursor(&cx, Point::new(10.0, 300.0), Mods::default()), Cursor::PenConvert);
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 300.0));
|
||||
assert_eq!(a, vec![Action::Begin("Convert Anchor Point".into()), set_out(id, 0, 10.0, 300.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_a_selected_blend_spine_adds_a_point() {
|
||||
let (mut d, _) = doc_with_rect();
|
||||
@@ -236,4 +456,49 @@ mod tests {
|
||||
// A key's own point is left alone (the click snaps to the spine line).
|
||||
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 110.0, 310.0)), vec![]);
|
||||
}
|
||||
|
||||
fn click(t: &mut PenTool, cx: &ToolContext, x: f64, y: f64, m: Mods) -> Vec<Action> {
|
||||
let acts = t.pointer(cx, &PointerEvent::new(PointerKind::Down, x, y).with_mods(m));
|
||||
t.pointer(cx, &PointerEvent::new(PointerKind::Up, x, y).with_mods(m));
|
||||
acts
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_a_selected_path_adds_or_deletes_an_anchor() {
|
||||
let (mut d, _) = doc_with_rect();
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let pts = [Point::new(300.0, 400.0), Point::new(350.0, 300.0), Point::new(400.0, 400.0)];
|
||||
let path = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&pts, false));
|
||||
d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, path, vectorcraft_doc::Appearance::default_art())).unwrap();
|
||||
let mut s = Selection::default();
|
||||
s.set([id]);
|
||||
let p = paint();
|
||||
let off = ToolContext { auto_add_delete: false, ..cx(&d, &s, &p) };
|
||||
let cx = cx(&d, &s, &p);
|
||||
let none = Mods::default();
|
||||
let mut t = PenTool::default();
|
||||
// On a segment: add an anchor there.
|
||||
assert_eq!(t.cursor(&cx, Point::new(325.0, 351.0), none), Cursor::PenAdd);
|
||||
let a = click(&mut t, &cx, 325.0, 351.0, none);
|
||||
assert!(matches!(&a[..], [Action::Exec(c, v)] if c == "path.insertAnchor" && v["id"] == id.0 && v["segment"] == 0), "{a:?}");
|
||||
// On an anchor: delete it.
|
||||
assert_eq!(t.cursor(&cx, Point::new(350.0, 302.0), none), Cursor::PenDelete);
|
||||
let a = click(&mut t, &cx, 350.0, 302.0, none);
|
||||
assert_eq!(a, vec![Action::Exec("path.removeAnchor".into(), json!({"id": id.0, "subpath": 0, "anchor": 1}))]);
|
||||
// An end still continues the path.
|
||||
assert_eq!(t.cursor(&cx, Point::new(400.0, 400.0), none), Cursor::PenContinue);
|
||||
// Shift, or the preference turned off, starts a new path instead.
|
||||
let shift = Mods { shift: true, ..Mods::default() };
|
||||
assert_eq!(t.cursor(&cx, Point::new(325.0, 351.0), shift), Cursor::Pen);
|
||||
assert_eq!(t.cursor(&off, Point::new(350.0, 302.0), none), Cursor::Pen);
|
||||
let a = click(&mut t, &off, 350.0, 302.0, none);
|
||||
assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "path.create"), "{a:?}");
|
||||
// While a path is being drawn, a click goes on drawing it.
|
||||
let a = click(&mut t, &cx, 325.0, 351.0, none);
|
||||
assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "path.appendAnchor"), "{a:?}");
|
||||
let mut t = PenTool::default();
|
||||
let a = click(&mut t, &cx, 325.0, 351.0, shift);
|
||||
assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "path.create"), "{a:?}");
|
||||
}
|
||||
}
|
||||
@@ -136,6 +136,8 @@ impl Tool for SelectionTool {
|
||||
let areas = cx.selection.objects.iter().any(is_area);
|
||||
// Only area type: the step resizes type areas; anything else scales.
|
||||
let label = if areas && cx.selection.objects.iter().all(is_area) { "Resize Type Area" } else { "Scale" };
|
||||
// Smart Guides align what the handle moves with the other objects.
|
||||
self.targets = cx.smart_guides.then(|| crate::guides::Targets::collect(cx.doc, &cx.selection.objects, None));
|
||||
self.state = State::Scaling { handle, bx, areas };
|
||||
return vec![Action::Begin(label.into())];
|
||||
}
|
||||
@@ -205,7 +207,11 @@ impl Tool for SelectionTool {
|
||||
}
|
||||
(PointerKind::Drag, State::Scaling { handle, bx, areas }) => {
|
||||
// Scale in the box's own frame: along the objects' axes when it is rotated.
|
||||
let a = scale_for_drag(bx.rect, handle, bx.to_local(p), m.shift, m.alt);
|
||||
let mut a = scale_for_drag(bx.rect, handle, bx.to_local(p), m.shift, m.alt);
|
||||
self.guides.clear();
|
||||
if let Some(t) = &self.targets {
|
||||
(a, self.guides) = t.snap_scale(&bx, handle, a, m.shift, m.alt, cx.tol(5.0));
|
||||
}
|
||||
let nr = a.transform_rect_bbox(bx.rect);
|
||||
self.measure = Some((p, cx.size_label(nr.width(), nr.height())));
|
||||
let mut params = json!({ "matrix": matrix_json(bx.conjugate(a)), "copy": false });
|
||||
@@ -246,6 +252,8 @@ impl Tool for SelectionTool {
|
||||
(PointerKind::Up, State::Scaling { .. } | State::Rotating { .. }) => {
|
||||
self.state = State::Idle;
|
||||
self.measure = None;
|
||||
self.guides.clear();
|
||||
self.targets = None;
|
||||
vec![Action::Commit]
|
||||
}
|
||||
(PointerKind::Up, State::Marquee { start, add, .. }) => {
|
||||
@@ -437,6 +445,76 @@ mod tests {
|
||||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 300.0, 300.0)), vec![Action::Commit]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_drag_snaps_to_the_other_objects_with_smart_guides() {
|
||||
use vectorcraft_doc::{Appearance, Node};
|
||||
let (mut d, _) = doc_with_rect();
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let r = Rect::new(250.0, 100.0, 300.0, 150.0);
|
||||
d.insert(Some(l), 1, Node::path(id, vectorcraft_geom::shapes::rectangle(r), Appearance::default_art())).unwrap();
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let p = paint();
|
||||
let mut c = cx(&d, &s, &p);
|
||||
let height = |a: &[Action]| {
|
||||
let Action::Preview(_, v) = &a[0] else { panic!("{a:?}") };
|
||||
v["matrix"][3].as_f64().unwrap() * 50.0
|
||||
};
|
||||
// The bottom handle, dragged near the neighbour's bottom edge (y = 200), lands on it.
|
||||
let mut t = SelectionTool::default();
|
||||
t.pointer(&c, &ev(PointerKind::Down, 275.0, 150.0));
|
||||
let a = t.pointer(&c, &ev(PointerKind::Drag, 275.0, 197.0));
|
||||
assert!((height(&a) - 100.0).abs() < 1e-9, "{a:?}");
|
||||
assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Line { .. })));
|
||||
t.pointer(&c, &ev(PointerKind::Up, 275.0, 197.0));
|
||||
assert!(!t.overlays(&c).iter().any(|o| matches!(o, Overlay::Line { .. })));
|
||||
// Smart Guides off: the edge follows the pointer.
|
||||
c.smart_guides = false;
|
||||
t.pointer(&c, &ev(PointerKind::Down, 275.0, 150.0));
|
||||
let a = t.pointer(&c, &ev(PointerKind::Drag, 275.0, 197.0));
|
||||
assert!((height(&a) - 97.0).abs() < 1e-9, "{a:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_drag_of_a_turned_box_snaps_on_the_page() {
|
||||
use vectorcraft_doc::{Appearance, Node};
|
||||
let matrix = |a: &[Action]| {
|
||||
let Action::Preview(_, v) = &a[0] else { panic!("{a:?}") };
|
||||
let m: Vec<f64> = v["matrix"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect();
|
||||
Affine::new([m[0], m[1], m[2], m[3], m[4], m[5]])
|
||||
};
|
||||
// A 50 pt square beside the 100..200 rect, turned about its centre (275, 125).
|
||||
for (turn, low) in [(90.0_f64, 150.0), (45.0, 125.0 + 25.0 * std::f64::consts::SQRT_2)] {
|
||||
let (mut d, _) = doc_with_rect();
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let r = Rect::new(250.0, 100.0, 300.0, 150.0);
|
||||
d.insert(Some(l), 1, Node::path(id, vectorcraft_geom::shapes::rectangle(r), Appearance::default_art())).unwrap();
|
||||
let c = Point::new(275.0, 125.0);
|
||||
d.node_mut(id)
|
||||
.unwrap()
|
||||
.transform(Affine::translate(c.to_vec2()) * Affine::rotate(turn.to_radians()) * Affine::translate(-c.to_vec2()), false);
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let bx = selection_box(&cx).unwrap();
|
||||
assert_ne!(bx.angle, 0.0);
|
||||
// The handle at the lowest point of the shape: a side at 90°, a corner at 45°.
|
||||
let grab = Point::new(275.0, low);
|
||||
assert!(Handle::ALL.iter().any(|h| (bx.to_doc() * h.pos(bx.rect)).distance(grab) < 1e-6), "{bx:?}");
|
||||
let mut t = SelectionTool::default();
|
||||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, grab.x, grab.y)), vec![Action::Begin("Scale".into())]);
|
||||
// Dragged near the neighbour's bottom edge (y = 200), it lands on it.
|
||||
let a = t.pointer(&cx, &ev(PointerKind::Drag, 275.0, 197.0));
|
||||
let landed = matrix(&a) * grab;
|
||||
assert!((landed.y - 200.0).abs() < 1e-6 && (landed.x - 275.0).abs() < 1e-6, "{turn}: {landed:?}");
|
||||
assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Line { .. })));
|
||||
t.pointer(&cx, &ev(PointerKind::Up, 275.0, 197.0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_drag_on_area_type_resizes_its_frame() {
|
||||
let (d, text) = doc_with_area_type();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Shape tools: Rectangle, Rounded Rectangle, Ellipse, Polygon, Star, Line Segment.
|
||||
//!
|
||||
//! Drag draws (Shift constrains to square/circle/45°, Alt draws from the centre); a click without a
|
||||
//! drag asks the UI for the size dialog (like Illustrator). ↑/↓ during a polygon/star drag change the
|
||||
//! side/point count.
|
||||
//! Drag draws (Shift constrains to square/circle/45°, Alt draws from the centre, Space held moves
|
||||
//! the shape being drawn); a click without a drag asks the UI for the size dialog (like
|
||||
//! Illustrator). ↑/↓ during a polygon/star drag change the side/point count.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
@@ -92,6 +92,15 @@ impl ShapeTool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Space held once a shape drag has begun moves the shape being drawn instead of sizing it: `start`
|
||||
/// moves as far as the pointer did since `last`, which becomes the pointer.
|
||||
pub(crate) fn space_moves(start: &mut Point, last: &mut Point, ev: &PointerEvent, began: bool) {
|
||||
if ev.mods.space && began {
|
||||
*start += ev.pos - *last;
|
||||
}
|
||||
*last = ev.pos;
|
||||
}
|
||||
|
||||
/// The rectangle a drag from `start` to `p` draws: Shift makes it a square, Alt draws it from its
|
||||
/// centre.
|
||||
pub(crate) fn drag_rect(start: Point, p: Point, m: Mods) -> Rect {
|
||||
@@ -120,11 +129,12 @@ impl Tool for ShapeTool {
|
||||
vec![]
|
||||
}
|
||||
PointerKind::Drag => {
|
||||
let Some(s) = self.start else { return vec![] };
|
||||
let Some(mut s) = self.start else { return vec![] };
|
||||
let (pos, g) = crate::guides::snap_draw(cx, ev.pos, &[]);
|
||||
self.guides = g;
|
||||
let ev = &PointerEvent { pos, ..*ev };
|
||||
self.last = ev.pos;
|
||||
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 {
|
||||
@@ -239,6 +249,31 @@ mod tests {
|
||||
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 50.0, 30.0)), vec![Action::Commit]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_moves_the_shape_being_drawn() {
|
||||
let (d, _) = doc_with_rect();
|
||||
let s = Selection::default();
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let space = Mods { space: true, ..Default::default() };
|
||||
let mut t = ShapeTool::new("rectangle");
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 50.0));
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 120.0));
|
||||
// Space held: the 100 × 70 rectangle follows the pointer.
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 250.0, 170.0).with_mods(space));
|
||||
assert_eq!(a.last(), Some(&Action::Preview("shape.rectangle".into(), json!({"x": 150.0, "y": 100.0, "width": 100.0, "height": 70.0}))));
|
||||
// Let go of Space: it grows again, from where it was moved to.
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 270.0, 190.0));
|
||||
assert_eq!(a.last(), Some(&Action::Preview("shape.rectangle".into(), json!({"x": 150.0, "y": 100.0, "width": 120.0, "height": 90.0}))));
|
||||
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 270.0, 190.0)), vec![Action::Commit]);
|
||||
// A star (drawn from its centre) moves its centre.
|
||||
let mut t = ShapeTool::new("star");
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 50.0));
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 80.0, 50.0));
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 90.0, 60.0).with_mods(space));
|
||||
assert!(matches!(a.last(), Some(Action::Preview(_, v)) if v["cx"] == 60.0 && v["cy"] == 60.0), "{a:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_square_alt_center() {
|
||||
let t = ShapeTool::new("ellipse");
|
||||
|
||||
@@ -486,21 +486,41 @@ impl Tool for TypeTool {
|
||||
}
|
||||
ToolKey::Left => {
|
||||
let to = if a != b && !mods.shift {
|
||||
a
|
||||
if lay.glyphs.iter().any(|g| g.rtl)
|
||||
&& vectorcraft_text::caret_position(&lay, a).0.x > vectorcraft_text::caret_position(&lay, b).0.x
|
||||
{
|
||||
b
|
||||
} else {
|
||||
a
|
||||
}
|
||||
} else if word {
|
||||
edit::prev_word(&text, self.caret)
|
||||
} else {
|
||||
edit::prev_char(&text, self.caret)
|
||||
if lay.glyphs.iter().any(|g| g.rtl) {
|
||||
vectorcraft_text::caret_horizontal(&lay, self.caret, false)
|
||||
} else {
|
||||
edit::prev_char(&text, self.caret)
|
||||
}
|
||||
};
|
||||
self.move_to(to, mods.shift)
|
||||
}
|
||||
ToolKey::Right => {
|
||||
let to = if a != b && !mods.shift {
|
||||
b
|
||||
if lay.glyphs.iter().any(|g| g.rtl)
|
||||
&& vectorcraft_text::caret_position(&lay, a).0.x > vectorcraft_text::caret_position(&lay, b).0.x
|
||||
{
|
||||
a
|
||||
} else {
|
||||
b
|
||||
}
|
||||
} else if word {
|
||||
edit::next_word(&text, self.caret)
|
||||
} else {
|
||||
edit::next_char(&text, self.caret)
|
||||
if lay.glyphs.iter().any(|g| g.rtl) {
|
||||
vectorcraft_text::caret_horizontal(&lay, self.caret, true)
|
||||
} else {
|
||||
edit::next_char(&text, self.caret)
|
||||
}
|
||||
};
|
||||
self.move_to(to, mods.shift)
|
||||
}
|
||||
|
||||
@@ -319,3 +319,19 @@ fn arrows_follow_the_columns_of_vertical_type() {
|
||||
key(&mut tool, &d, ToolKey::Up, Mods::default());
|
||||
assert_eq!(tool.caret, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hebrew_typing_and_visual_arrows_keep_logical_text() {
|
||||
let (mut d, id, mut tool) = editing("");
|
||||
let snap = d.clone();
|
||||
let (sel, p) = (Selection::default(), paint());
|
||||
let actions = tool.text_input(&cx(&d, &sel, &p), "שלום");
|
||||
apply(&mut d, &snap, &actions);
|
||||
assert_eq!(tool.caret, "שלום".len());
|
||||
key(&mut tool, &d, ToolKey::Right, Mods::default());
|
||||
assert_eq!(tool.caret, 6, "Right moves toward the logical start in Hebrew");
|
||||
key(&mut tool, &d, ToolKey::Left, Mods::default());
|
||||
assert_eq!(tool.caret, 8);
|
||||
let NodeKind::Text(t) = &d.node(id).unwrap().kind else { panic!() };
|
||||
assert_eq!(t.plain_text(), "שלום");
|
||||
}
|
||||
@@ -1,22 +1,46 @@
|
||||
//! Artboard tool (Shift+O).
|
||||
//!
|
||||
//! Click an artboard to make it active (dashed bounds, 8 handles and its name). Drag inside moves
|
||||
//! it (with its artwork when the `moveArt` option is on, Shift constrains), drag a handle resizes it
|
||||
//! it (with its artwork when the `moveArt` option is on, Shift constrains; Alt moves a copy and
|
||||
//! leaves the artboard where it was), drag a handle resizes it
|
||||
//! (Shift proportional, Alt from centre), drag on the pasteboard draws a new artboard, Delete removes
|
||||
//! the active one and Escape returns to the Selection tool.
|
||||
//! the active one and Escape returns to the Selection tool. Moving and resizing snap like drawing
|
||||
//! does: to whole pixels, to the grid, or with Smart Guides to other artboards, their bleed and
|
||||
//! objects (never to the dragged artboard or the art moving with it); the artboard's own bleed edges
|
||||
//! snap too.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_geom::{Point, Rect, Vec2};
|
||||
|
||||
use super::{BLUE, polygon, rect_corners};
|
||||
use crate::bbox::{Handle, hit_handle, move_delta, scale_for_drag};
|
||||
use crate::guides::Targets;
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Drag {
|
||||
Move { index: usize, start: Point, began: bool },
|
||||
Resize { index: usize, handle: Handle, rect: Rect, began: bool },
|
||||
Create { start: Point, cur: Point },
|
||||
/// `rect` is the artboard as it was when the drag started: during the drag the document
|
||||
/// already shows the preview, so it can't be read back from there. `copy`: Alt was held at the
|
||||
/// last drag event (the step under way is a duplicate).
|
||||
Move {
|
||||
index: usize,
|
||||
start: Point,
|
||||
rect: Rect,
|
||||
began: bool,
|
||||
copy: bool,
|
||||
},
|
||||
/// `grab` is the handle's offset from the pointer, so the handle itself follows and snaps.
|
||||
Resize {
|
||||
index: usize,
|
||||
handle: Handle,
|
||||
rect: Rect,
|
||||
grab: Vec2,
|
||||
began: bool,
|
||||
},
|
||||
Create {
|
||||
start: Point,
|
||||
cur: Point,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct ArtboardTool {
|
||||
@@ -26,11 +50,17 @@ pub struct ArtboardTool {
|
||||
pub move_art: bool,
|
||||
drag: Option<Drag>,
|
||||
preview: Option<Rect>,
|
||||
/// Smart Guide targets, gathered when a move or resize starts.
|
||||
targets: Option<Targets>,
|
||||
/// The targets for an Alt-drag copy: the original artboard and its art stay where they are.
|
||||
copy_targets: Option<Targets>,
|
||||
/// Smart Guides shown while dragging.
|
||||
guides: Vec<Overlay>,
|
||||
}
|
||||
|
||||
impl Default for ArtboardTool {
|
||||
fn default() -> Self {
|
||||
Self { active: 0, move_art: true, drag: None, preview: None }
|
||||
Self { active: 0, move_art: true, drag: None, preview: None, targets: None, copy_targets: None, guides: vec![] }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +72,80 @@ impl ArtboardTool {
|
||||
fn active_rect(&self, cx: &ToolContext) -> Option<Rect> {
|
||||
cx.doc.artboards.get(self.active).map(|a| a.rect)
|
||||
}
|
||||
|
||||
/// Gather the Smart Guide targets for dragging artboard `index` (call before the first preview,
|
||||
/// while the document still shows where everything started).
|
||||
fn begin_snapping(&mut self, cx: &ToolContext, index: usize, art_moves: bool) {
|
||||
self.guides.clear();
|
||||
self.targets = cx.smart_guides.then(|| {
|
||||
let art = match cx.doc.artboards.get(index) {
|
||||
Some(a) if art_moves => cx.doc.art_on_artboard(a.rect),
|
||||
_ => vec![],
|
||||
};
|
||||
Targets::for_artboard(cx.doc, index, &art)
|
||||
});
|
||||
// Nothing to skip: no artboard has this index.
|
||||
self.copy_targets = cx.smart_guides.then(|| Targets::for_artboard(cx.doc, usize::MAX, &[]));
|
||||
}
|
||||
|
||||
/// Snap a dragged handle: to whole pixels, the grid, or Smart Guides (in that order, as when
|
||||
/// drawing). With Smart Guides the bleed edge `bleed` beyond the handle may snap instead.
|
||||
fn snap_handle(&mut self, cx: &ToolContext, p: Point, bleed: Vec2) -> Point {
|
||||
self.guides.clear();
|
||||
if cx.snap_to_pixel {
|
||||
return Point::new(p.x.round(), p.y.round());
|
||||
}
|
||||
if cx.snap_to_grid {
|
||||
return vectorcraft_geom::snap::snap_point_to_grid(p, grid_step(cx));
|
||||
}
|
||||
let Some(t) = &self.targets else { return p };
|
||||
let offsets: &[Vec2] = if bleed == Vec2::ZERO { &[] } else { &[bleed] };
|
||||
let (q, ov) = t.snap_point_with(p, offsets, cx.tol(5.0));
|
||||
self.guides = ov;
|
||||
q
|
||||
}
|
||||
|
||||
/// Snap a move (or an Alt-drag `copy`) of `rect` by `d`: its top-left to whole pixels or the
|
||||
/// grid, or any of its edges and centre to Smart Guides. Returns the snapped delta.
|
||||
fn snap_move(&mut self, cx: &ToolContext, rect: Rect, d: Vec2, copy: bool) -> Vec2 {
|
||||
self.guides.clear();
|
||||
let tl = Point::new(rect.x0, rect.y0);
|
||||
if cx.snap_to_pixel {
|
||||
return Vec2::new((tl.x + d.x).round() - tl.x, (tl.y + d.y).round() - tl.y);
|
||||
}
|
||||
if cx.snap_to_grid {
|
||||
return vectorcraft_geom::snap::snap_point_to_grid(tl + d, grid_step(cx)) - tl;
|
||||
}
|
||||
let Some(t) = (if copy { &self.copy_targets } else { &self.targets }) else { return d };
|
||||
let moved = rect + d;
|
||||
let (adj, ov) = if cx.doc.setup.has_bleed() {
|
||||
t.snap_rects(&[moved, cx.doc.setup.bleed_rect(moved)], cx.tol(5.0))
|
||||
} else {
|
||||
t.snap_rect(moved, cx.tol(5.0))
|
||||
};
|
||||
self.guides = ov;
|
||||
d + adj
|
||||
}
|
||||
|
||||
fn end_drag(&mut self) {
|
||||
self.drag = None;
|
||||
self.preview = None;
|
||||
self.targets = None;
|
||||
self.copy_targets = None;
|
||||
self.guides.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the bleed edge lies beyond a handle, along the axes the handle moves.
|
||||
fn bleed_offset(cx: &ToolContext, rect: Rect, handle: Handle) -> Vec2 {
|
||||
let d = handle.pos(cx.doc.setup.bleed_rect(rect)) - handle.pos(rect);
|
||||
let (ax, ay) = handle.axes();
|
||||
Vec2::new(if ax { d.x } else { 0.0 }, if ay { d.y } else { 0.0 })
|
||||
}
|
||||
|
||||
/// The grid's snapping step (gridline spacing over subdivisions), as `guides::snap_draw` uses.
|
||||
fn grid_step(cx: &ToolContext) -> f64 {
|
||||
cx.doc.grid.spacing / cx.doc.grid.subdivisions.max(1) as f64
|
||||
}
|
||||
|
||||
impl Tool for ArtboardTool {
|
||||
@@ -61,39 +165,52 @@ impl Tool for ArtboardTool {
|
||||
if let Some(r) = self.active_rect(cx)
|
||||
&& let Some(handle) = hit_handle(r, p, cx.tol(5.0))
|
||||
{
|
||||
self.drag = Some(Drag::Resize { index: self.active, handle, rect: r, began: false });
|
||||
self.drag = Some(Drag::Resize { index: self.active, handle, rect: r, grab: handle.pos(r) - p, began: false });
|
||||
return vec![];
|
||||
}
|
||||
if let Some(i) = (0..cx.doc.artboards.len()).rev().find(|i| cx.doc.artboards[*i].rect.contains(p)) {
|
||||
if let Some(i) = cx.doc.artboard_at(p)
|
||||
&& let Some(a) = cx.doc.artboards.get(i)
|
||||
{
|
||||
self.active = i;
|
||||
self.drag = Some(Drag::Move { index: i, start: p, began: false });
|
||||
self.drag = Some(Drag::Move { index: i, start: p, rect: a.rect, began: false, copy: false });
|
||||
} else {
|
||||
let (p, _) = crate::guides::snap_draw(cx, p, &[]);
|
||||
self.drag = Some(Drag::Create { start: p, cur: p });
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
(PointerKind::Drag, Some(Drag::Move { index, start, began })) => {
|
||||
(PointerKind::Drag, Some(Drag::Move { index, start, rect, began, copy })) => {
|
||||
let mut out = vec![];
|
||||
let label = |copy: bool| if copy { "Duplicate Artboard" } else { "Move Artboard" };
|
||||
if !began {
|
||||
if p.distance(start) < cx.tol(3.0) {
|
||||
return out;
|
||||
}
|
||||
out.push(Action::Begin("Move Artboard".into()));
|
||||
out.push(Action::Begin(label(m.alt).into()));
|
||||
self.begin_snapping(cx, index, self.move_art);
|
||||
} else if m.alt != copy {
|
||||
// Alt pressed or released mid-drag: start over as a copy or a move.
|
||||
out.push(Action::Cancel);
|
||||
out.push(Action::Begin(label(m.alt).into()));
|
||||
}
|
||||
self.drag = Some(Drag::Move { index, start, began: true });
|
||||
let d = move_delta(start, p, m.shift);
|
||||
self.preview = cx.doc.artboards.get(index).map(|a| a.rect + d);
|
||||
out.push(Action::Preview("artboard.move".into(), json!({ "index": index, "dx": d.x, "dy": d.y, "moveArt": self.move_art })));
|
||||
self.drag = Some(Drag::Move { index, start, rect, began: true, copy: m.alt });
|
||||
let d = self.snap_move(cx, rect, move_delta(start, p, m.shift), m.alt);
|
||||
self.preview = Some(rect + d);
|
||||
out.push(Action::Preview(
|
||||
"artboard.move".into(),
|
||||
json!({ "index": index, "dx": d.x, "dy": d.y, "moveArt": self.move_art, "copy": m.alt }),
|
||||
));
|
||||
out
|
||||
}
|
||||
(PointerKind::Drag, Some(Drag::Resize { index, handle, rect, began })) => {
|
||||
(PointerKind::Drag, Some(Drag::Resize { index, handle, rect, grab, began })) => {
|
||||
let mut out = vec![];
|
||||
if !began {
|
||||
out.push(Action::Begin("Resize Artboard".into()));
|
||||
self.begin_snapping(cx, index, false);
|
||||
}
|
||||
self.drag = Some(Drag::Resize { index, handle, rect, began: true });
|
||||
let nr = scale_for_drag(rect, handle, p, m.shift, m.alt).transform_rect_bbox(rect);
|
||||
self.drag = Some(Drag::Resize { index, handle, rect, grab, began: true });
|
||||
let h = self.snap_handle(cx, p + grab, bleed_offset(cx, rect, handle));
|
||||
let nr = scale_for_drag(rect, handle, h, m.shift, m.alt).transform_rect_bbox(rect);
|
||||
self.preview = Some(nr);
|
||||
out.push(Action::Preview("artboard.setProps".into(), rect_json(index, nr)));
|
||||
out
|
||||
@@ -109,10 +226,16 @@ impl Tool for ArtboardTool {
|
||||
vec![]
|
||||
}
|
||||
(PointerKind::Up, Some(d)) => {
|
||||
self.drag = None;
|
||||
self.preview = None;
|
||||
self.end_drag();
|
||||
match d {
|
||||
Drag::Move { began: true, .. } | Drag::Resize { began: true, .. } => vec![Action::Commit],
|
||||
Drag::Move { began: true, copy, .. } => {
|
||||
// The copy, last in the previewed document, becomes the active artboard.
|
||||
if copy {
|
||||
self.active = cx.doc.artboards.len().saturating_sub(1);
|
||||
}
|
||||
vec![Action::Commit]
|
||||
}
|
||||
Drag::Resize { began: true, .. } => vec![Action::Commit],
|
||||
Drag::Create { start, cur } => {
|
||||
let r = Rect::from_points(start, cur);
|
||||
if r.width() < cx.tol(3.0) || r.height() < cx.tol(3.0) {
|
||||
@@ -127,7 +250,7 @@ impl Tool for ArtboardTool {
|
||||
}
|
||||
}
|
||||
(PointerKind::DoubleClick, _) => {
|
||||
self.drag = None;
|
||||
self.end_drag();
|
||||
match cx.doc.artboards.get(self.active) {
|
||||
Some(a) => {
|
||||
let mut v = rect_json(self.active, a.rect);
|
||||
@@ -153,8 +276,7 @@ impl Tool for ArtboardTool {
|
||||
}
|
||||
ToolKey::Escape => {
|
||||
let busy = matches!(self.drag, Some(Drag::Move { began: true, .. } | Drag::Resize { began: true, .. }));
|
||||
self.drag = None;
|
||||
self.preview = None;
|
||||
self.end_drag();
|
||||
if busy { vec![Action::Cancel] } else { vec![Action::SwitchTool("selection".into())] }
|
||||
}
|
||||
_ => vec![],
|
||||
@@ -188,6 +310,9 @@ impl Tool for ArtboardTool {
|
||||
text: cx.size_label(r.width(), r.height()),
|
||||
});
|
||||
}
|
||||
if self.drag.is_some() {
|
||||
o.extend(self.guides.iter().cloned());
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
@@ -219,8 +344,7 @@ impl Tool for ArtboardTool {
|
||||
|
||||
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
|
||||
let busy = matches!(self.drag, Some(Drag::Move { began: true, .. } | Drag::Resize { began: true, .. }));
|
||||
self.drag = None;
|
||||
self.preview = None;
|
||||
self.end_drag();
|
||||
if busy { vec![Action::Cancel] } else { vec![] }
|
||||
}
|
||||
}
|
||||
@@ -235,6 +359,140 @@ mod tests {
|
||||
PointerEvent::new(kind, x, y)
|
||||
}
|
||||
|
||||
/// [`doc_with_rect`] (artboard 1 at 0–500, a rectangle at 100–200) plus Artboard 2 at x 600–800,
|
||||
/// y 0–200.
|
||||
fn two_boards() -> vectorcraft_doc::Document {
|
||||
let (mut d, _) = doc_with_rect();
|
||||
let mut ab = d.artboards[0].clone();
|
||||
ab.rect = Rect::new(600.0, 0.0, 800.0, 200.0);
|
||||
ab.name = "Artboard 2".into();
|
||||
d.artboards.push(ab);
|
||||
d
|
||||
}
|
||||
|
||||
fn preview_params(a: &[Action]) -> Value {
|
||||
a.iter()
|
||||
.find_map(|a| match a {
|
||||
Action::Preview(_, p) => Some(p.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Drag artboard 2 from (700, 100) by (dx, dy) and return the preview's params.
|
||||
fn drag_board2(cx: &ToolContext, t: &mut ArtboardTool, dx: f64, dy: f64) -> Value {
|
||||
t.pointer(cx, &ev(PointerKind::Down, 700.0, 100.0));
|
||||
preview_params(&t.pointer(cx, &ev(PointerKind::Drag, 700.0 + dx, 100.0 + dy)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_follow_the_artboard_while_the_document_shows_the_preview() {
|
||||
let mut d = two_boards();
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
let mut t = ArtboardTool::default();
|
||||
assert_eq!(drag_board2(&cx(&d, &s, &p), &mut t, 10.0, 0.0)["dx"], 10.0);
|
||||
// The engine applies the preview to the document, as it does during a drag.
|
||||
d.artboards[1].rect = Rect::new(610.0, 0.0, 810.0, 200.0);
|
||||
let cx = cx(&d, &s, &p);
|
||||
assert_eq!(preview_params(&t.pointer(&cx, &ev(PointerKind::Drag, 720.0, 100.0)))["dx"], 20.0);
|
||||
let corners: Vec<Point> = t
|
||||
.overlays(&cx)
|
||||
.iter()
|
||||
.filter_map(|o| match o {
|
||||
Overlay::Anchor { p, .. } => Some(*p),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(corners[0], Point::new(620.0, 0.0), "the handles sit on the moved artboard, not 10 pt past it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_snaps_to_other_artboards_with_smart_guides() {
|
||||
let d = two_boards();
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
let mut t = ArtboardTool::default();
|
||||
let mut c = cx(&d, &s, &p);
|
||||
// Left edge 3 pt right of artboard 1's right edge (500) → lands on it.
|
||||
assert_eq!(drag_board2(&c, &mut t, -97.0, 0.0)["dx"], -100.0);
|
||||
assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Line { color, .. } if *color == crate::guides::MAGENTA)));
|
||||
t.pointer(&c, &ev(PointerKind::Up, 603.0, 100.0));
|
||||
assert!(!t.overlays(&c).iter().any(|o| matches!(o, Overlay::Line { .. })), "guides go away on release");
|
||||
c.smart_guides = false;
|
||||
assert_eq!(drag_board2(&c, &mut t, -97.0, 0.0)["dx"], -97.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_never_snaps_to_itself_or_the_art_it_carries() {
|
||||
let mut d = two_boards();
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let art = vectorcraft_geom::shapes::rectangle(Rect::new(650.0, 50.0, 660.0, 60.0));
|
||||
d.insert(Some(l), 0, vectorcraft_doc::Node::path(id, art, vectorcraft_doc::Appearance::default_art())).unwrap();
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
let c = cx(&d, &s, &p);
|
||||
let mut t = ArtboardTool::default();
|
||||
// Its own old left edge (600) isn't a target.
|
||||
assert_eq!(drag_board2(&c, &mut t, 4.0, 0.0)["dx"], 4.0);
|
||||
t.pointer(&c, &ev(PointerKind::Up, 704.0, 100.0));
|
||||
// The rectangle moves along: its left edge (650) isn't a target either…
|
||||
assert_eq!(drag_board2(&c, &mut t, 52.0, 0.0)["dx"], 52.0);
|
||||
t.pointer(&c, &ev(PointerKind::Up, 752.0, 100.0));
|
||||
// …unless it stays behind.
|
||||
t.move_art = false;
|
||||
assert_eq!(drag_board2(&c, &mut t, 52.0, 0.0)["dx"], 50.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_snaps_to_grid_and_pixels() {
|
||||
let d = two_boards();
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
let mut t = ArtboardTool::default();
|
||||
let mut c = cx(&d, &s, &p);
|
||||
c.snap_to_grid = true;
|
||||
// Grid step 72 / 8 = 9: x 610 → 612, y 4 → 0.
|
||||
let v = drag_board2(&c, &mut t, 10.0, 4.0);
|
||||
assert_eq!((v["dx"].as_f64(), v["dy"].as_f64()), (Some(12.0), Some(0.0)));
|
||||
t.pointer(&c, &ev(PointerKind::Up, 710.0, 104.0));
|
||||
c.snap_to_grid = false;
|
||||
c.snap_to_pixel = true;
|
||||
let v = drag_board2(&c, &mut t, 10.4, 3.6);
|
||||
assert_eq!((v["dx"].as_f64(), v["dy"].as_f64()), (Some(10.0), Some(4.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resizing_snaps_the_dragged_handle() {
|
||||
let d = two_boards();
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
let mut t = ArtboardTool { active: 1, ..Default::default() };
|
||||
let c = cx(&d, &s, &p);
|
||||
// Left handle to 3 pt off artboard 1's right edge.
|
||||
t.pointer(&c, &ev(PointerKind::Down, 600.0, 100.0));
|
||||
let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 503.0, 100.0)));
|
||||
assert_eq!((v["x"].as_f64(), v["width"].as_f64()), (Some(500.0), Some(300.0)));
|
||||
t.pointer(&c, &ev(PointerKind::Up, 503.0, 100.0));
|
||||
// The handle, not the pointer, follows: grabbed 2 pt left of it, dragged 10 pt.
|
||||
t.pointer(&c, &ev(PointerKind::Down, 798.0, 100.0));
|
||||
let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 808.0, 100.0)));
|
||||
assert_eq!(v["width"].as_f64(), Some(210.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bleed_edges_snap_to_bleed_edges() {
|
||||
let mut d = two_boards();
|
||||
d.setup.bleed = [10.0; 4];
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
let c = cx(&d, &s, &p);
|
||||
let mut t = ArtboardTool::default();
|
||||
// Artboard 2's bleed (left edge 590) lands on artboard 1's bleed (right edge 510): dragged
|
||||
// 77 pt left it's 3 pt off, nothing else is within reach.
|
||||
assert_eq!(drag_board2(&c, &mut t, -77.0, 0.0)["dx"], -80.0);
|
||||
t.pointer(&c, &ev(PointerKind::Up, 623.0, 100.0));
|
||||
// Resizing: the left handle's bleed edge snaps the same way.
|
||||
t.pointer(&c, &ev(PointerKind::Down, 600.0, 100.0));
|
||||
let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 523.0, 100.0)));
|
||||
assert_eq!((v["x"].as_f64(), v["width"].as_f64()), (Some(520.0), Some(280.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_resize_create_delete() {
|
||||
let (mut d, _) = doc_with_rect();
|
||||
@@ -251,7 +509,7 @@ mod tests {
|
||||
assert_eq!(t.active, 1);
|
||||
let a = t.pointer(&cx, &ev(PointerKind::Drag, 710.0, 120.0));
|
||||
assert_eq!(a[0], Action::Begin("Move Artboard".into()));
|
||||
assert_eq!(a[1], Action::Preview("artboard.move".into(), json!({"index": 1, "dx": 10.0, "dy": 20.0, "moveArt": true})));
|
||||
assert_eq!(a[1], Action::Preview("artboard.move".into(), json!({"index": 1, "dx": 10.0, "dy": 20.0, "moveArt": true, "copy": false})));
|
||||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 710.0, 120.0)), vec![Action::Commit]);
|
||||
assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == "02 - Artboard 2")));
|
||||
// Handle drag resizes.
|
||||
@@ -270,4 +528,42 @@ mod tests {
|
||||
assert_eq!(t.key(&cx, ToolKey::Delete, Mods::default()), vec![Action::Exec("artboard.delete".into(), json!({"index": 1}))]);
|
||||
assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::SwitchTool("selection".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_alt_drag_copy_snaps_to_the_artboard_it_leaves_behind() {
|
||||
let d = two_boards();
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
let c = cx(&d, &s, &p);
|
||||
let alt = Mods { alt: true, ..Mods::default() };
|
||||
let mut t = ArtboardTool::default();
|
||||
// A copy of artboard 2 dragged 203 pt right: its left edge 3 pt off the original's right
|
||||
// edge (800), which stays put and is a target.
|
||||
t.pointer(&c, &ev(PointerKind::Down, 700.0, 100.0).with_mods(alt));
|
||||
let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 903.0, 100.0).with_mods(alt)));
|
||||
assert_eq!((v["dx"].as_f64(), v["copy"].as_bool()), (Some(200.0), Some(true)));
|
||||
// Released Alt: a plain move, and the artboard's own old edge is no target.
|
||||
let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 903.0, 100.0)));
|
||||
assert_eq!((v["dx"].as_f64(), v["copy"].as_bool()), (Some(203.0), Some(false)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_drag_moves_a_copy_of_the_artboard() {
|
||||
let (d, _) = doc_with_rect();
|
||||
let s = Selection::default();
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let alt = Mods { alt: true, ..Mods::default() };
|
||||
let moved =
|
||||
|dx: f64, copy: bool| Action::Preview("artboard.move".into(), json!({"index": 0, "dx": dx, "dy": 0.0, "moveArt": true, "copy": copy}));
|
||||
let mut t = ArtboardTool::default();
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 100.0, 100.0).with_mods(alt));
|
||||
let a = t.pointer(&cx, &ev(PointerKind::Drag, 150.0, 100.0).with_mods(alt));
|
||||
assert_eq!(a, vec![Action::Begin("Duplicate Artboard".into()), moved(50.0, true)]);
|
||||
// Alt released mid-drag: a plain move after all, and back.
|
||||
let a = t.pointer(&cx, &ev(PointerKind::Drag, 160.0, 100.0));
|
||||
assert_eq!(a, vec![Action::Cancel, Action::Begin("Move Artboard".into()), moved(60.0, false)]);
|
||||
let a = t.pointer(&cx, &ev(PointerKind::Drag, 170.0, 100.0).with_mods(alt));
|
||||
assert_eq!(a, vec![Action::Cancel, Action::Begin("Duplicate Artboard".into()), moved(70.0, true)]);
|
||||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 170.0, 100.0).with_mods(alt)), vec![Action::Commit]);
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,9 @@ pub struct FreeTransformTool {
|
||||
/// The current (distorted) quad while dragging corners.
|
||||
quad: Option<[Point; 4]>,
|
||||
measure: Option<(Point, String)>,
|
||||
/// Smart Guides for a scale: the other objects' edges, and the lines of the current snap.
|
||||
targets: Option<crate::guides::Targets>,
|
||||
guides: Vec<Overlay>,
|
||||
}
|
||||
|
||||
/// Shear for dragging side `h` of `r` by `d` (about the opposite side, or the centre with Alt).
|
||||
@@ -178,6 +181,7 @@ impl FreeTransformTool {
|
||||
fn preview(&mut self, cx: &ToolContext, d: Drag, p: Point, m: Mods) -> Action {
|
||||
let shift = m.shift || self.constrain;
|
||||
let delta = p - d.start;
|
||||
self.guides.clear();
|
||||
let xf = match d.op {
|
||||
Op::Corners(h, mode) => {
|
||||
let q = distort_quad(d.rect, h, delta, mode);
|
||||
@@ -192,7 +196,10 @@ impl FreeTransformTool {
|
||||
Affine::translate(v)
|
||||
}
|
||||
Op::Scale(h) => {
|
||||
let a = scale_for_drag(d.rect, h, p, shift, m.alt);
|
||||
let mut a = scale_for_drag(d.rect, h, p, shift, m.alt);
|
||||
if let Some(t) = &self.targets {
|
||||
(a, self.guides) = t.snap_scale(&vectorcraft_doc::OrientedBox::aligned(d.rect), h, a, shift, m.alt, cx.tol(5.0));
|
||||
}
|
||||
let nr = a.transform_rect_bbox(d.rect);
|
||||
self.measure = Some((p, cx.size_label(nr.width(), nr.height())));
|
||||
a
|
||||
@@ -225,6 +232,8 @@ impl Tool for FreeTransformTool {
|
||||
self.drag = None;
|
||||
let Some(r) = selection_bounds(cx) else { return vec![] };
|
||||
if let Some(op) = self.classify(cx, r, p, ev.mods) {
|
||||
self.targets =
|
||||
(cx.smart_guides && op.handle().is_some()).then(|| crate::guides::Targets::collect(cx.doc, &cx.selection.objects, None));
|
||||
self.drag = Some(Drag { op, rect: r, start: p, began: false });
|
||||
}
|
||||
vec![]
|
||||
@@ -259,6 +268,8 @@ impl Tool for FreeTransformTool {
|
||||
let d = self.drag.take();
|
||||
self.quad = None;
|
||||
self.measure = None;
|
||||
self.guides.clear();
|
||||
self.targets = None;
|
||||
if d.is_some_and(|d| d.began) { vec![Action::Commit] } else { vec![] }
|
||||
}
|
||||
_ => vec![],
|
||||
@@ -270,6 +281,8 @@ impl Tool for FreeTransformTool {
|
||||
self.drag = None;
|
||||
self.quad = None;
|
||||
self.measure = None;
|
||||
self.guides.clear();
|
||||
self.targets = None;
|
||||
return vec![Action::Cancel];
|
||||
}
|
||||
vec![]
|
||||
@@ -290,6 +303,7 @@ impl Tool for FreeTransformTool {
|
||||
o.push(Overlay::Anchor { p: a.midpoint(b), color: BLUE, filled: false, size: 6.0 });
|
||||
}
|
||||
}
|
||||
o.extend(self.guides.iter().cloned());
|
||||
if let Some((p, t)) = &self.measure {
|
||||
o.push(Overlay::Measure { p: *p + Vec2::new(cx.tol(12.0), cx.tol(12.0)), text: t.clone() });
|
||||
}
|
||||
@@ -332,6 +346,8 @@ impl Tool for FreeTransformTool {
|
||||
|
||||
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
|
||||
self.quad = None;
|
||||
self.guides.clear();
|
||||
self.targets = None;
|
||||
if self.drag.take().is_some_and(|d| d.began) { vec![Action::Cancel] } else { vec![] }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
//!
|
||||
//! The reference point defaults to the centre of the selection. A click sets it (shown as a cyan
|
||||
//! target); a drag transforms about it with a live preview (Shift constrains to 45° / uniform, Alt
|
||||
//! at release makes a copy). Alt-click sets the point and opens the tool's dialog; double-click
|
||||
//! or Return opens the dialog too. A clicked or Alt-clicked point snaps to the nearest anchor or
|
||||
//! centre (Snap to Point / Smart Guides), labelled while hovering, so transforms pivot exactly
|
||||
//! on a corner.
|
||||
//! at release makes a copy: Alt-drag). Alt-click (the release of a click made with Alt) sets the
|
||||
//! point and opens the tool's dialog; double-click or Return opens the dialog too. A clicked or
|
||||
//! Alt-clicked point snaps to the nearest anchor or centre (Snap to Point / Smart Guides), labelled
|
||||
//! while hovering, so transforms pivot exactly on a corner.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::NodeId;
|
||||
@@ -61,6 +61,8 @@ struct Drag {
|
||||
pick: Point,
|
||||
origin: Point,
|
||||
began: bool,
|
||||
/// Alt was down at the press: a click opens the dialog, a drag transforms a copy.
|
||||
alt: bool,
|
||||
bounds: Rect,
|
||||
last: Affine,
|
||||
}
|
||||
@@ -169,13 +171,10 @@ impl Tool for TransformTool {
|
||||
let Some(bounds) = selection_bounds(cx) else { return vec![] };
|
||||
self.hover.clear();
|
||||
let (pick, _) = snap_pick(cx, p);
|
||||
if ev.mods.alt {
|
||||
self.origin = Some((pick, cx.selection.objects.clone()));
|
||||
self.drag = None;
|
||||
return vec![self.dialog(pick)];
|
||||
}
|
||||
// Alt: decided on release. A click sets the point and opens the dialog; a drag
|
||||
// transforms a copy about the reference point as it was.
|
||||
let origin = self.reference_point(cx).unwrap_or(bounds.center());
|
||||
self.drag = Some(Drag { start: p, pick, origin, began: false, bounds, last: Affine::IDENTITY });
|
||||
self.drag = Some(Drag { start: p, pick, origin, began: false, alt: ev.mods.alt, bounds, last: Affine::IDENTITY });
|
||||
vec![]
|
||||
}
|
||||
PointerKind::Drag => {
|
||||
@@ -202,9 +201,9 @@ impl Tool for TransformTool {
|
||||
// The Alt state at release decides whether the result is a copy.
|
||||
vec![Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(d.last), "copy": ev.mods.alt })), Action::Commit]
|
||||
} else {
|
||||
// A click sets the reference point.
|
||||
// A click sets the reference point; with Alt, it opens the dialog too.
|
||||
self.origin = Some((d.pick, cx.selection.objects.clone()));
|
||||
vec![]
|
||||
if d.alt { vec![self.dialog(d.pick)] } else { vec![] }
|
||||
}
|
||||
}
|
||||
PointerKind::DoubleClick => {
|
||||
@@ -357,9 +356,10 @@ mod tests {
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = TransformTool::new(TransformKind::Reflect);
|
||||
let a = t.pointer(&cx, &ev(PointerKind::Down, 120.0, 130.0).with_mods(alt()));
|
||||
// The release decides: a click with Alt opens the dialog…
|
||||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 120.0, 130.0).with_mods(alt())), vec![]);
|
||||
let a = t.pointer(&cx, &ev(PointerKind::Up, 120.0, 130.0).with_mods(alt()));
|
||||
assert_eq!(a, vec![Action::Dialog("reflect".into(), json!({"axis": "vertical", "origin": [120.0, 130.0]}))]);
|
||||
t.pointer(&cx, &ev(PointerKind::Up, 120.0, 130.0).with_mods(alt()));
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0));
|
||||
t.pointer(&cx, &ev(PointerKind::Drag, 120.0, 200.0));
|
||||
let up = t.pointer(&cx, &ev(PointerKind::Up, 120.0, 200.0).with_mods(alt()));
|
||||
@@ -373,6 +373,28 @@ mod tests {
|
||||
assert!(matches!(&t.key(&cx, ToolKey::Enter, Mods::default())[0], Action::Dialog(k, v) if k == "rotate" && v["angle"] == 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_drag_transforms_a_copy_and_leaves_the_dialog_shut() {
|
||||
let (d, id) = doc_with_rect();
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
for kind in [TransformKind::Rotate, TransformKind::Reflect, TransformKind::Scale, TransformKind::Shear] {
|
||||
let mut t = TransformTool::new(kind);
|
||||
// Alt down at the press, then a drag: no dialog, and the preview and the release copy.
|
||||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 260.0, 150.0).with_mods(alt())), vec![], "{kind:?}");
|
||||
let a = t.pointer(&cx, &ev(PointerKind::Drag, 300.0, 180.0).with_mods(alt()));
|
||||
assert_eq!(a[0], Action::Begin(kind.label().into()), "{kind:?}");
|
||||
assert!(matches!(&a[1], Action::Preview(c, v) if c == "object.transform" && v["copy"] == true), "{kind:?}: {a:?}");
|
||||
let up = t.pointer(&cx, &ev(PointerKind::Up, 300.0, 180.0).with_mods(alt()));
|
||||
assert!(matches!(&up[0], Action::Preview(_, v) if v["copy"] == true), "{kind:?}: {up:?}");
|
||||
assert_eq!(up[1], Action::Commit);
|
||||
// The reference point is the one it was (the centre), not the press point.
|
||||
assert_eq!(t.reference_point(&cx), Some(Point::new(150.0, 150.0)), "{kind:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_click_and_click_snap_the_reference_point_to_anchors_and_centres() {
|
||||
let (d, id) = doc_with_rect();
|
||||
@@ -382,7 +404,8 @@ mod tests {
|
||||
let mut c = cx(&d, &s, &p);
|
||||
// Alt-click 3 px from the top-left corner: the reflect pivots exactly on the corner.
|
||||
let mut t = TransformTool::new(TransformKind::Reflect);
|
||||
let a = t.pointer(&c, &ev(PointerKind::Down, 102.0, 98.0).with_mods(alt()));
|
||||
t.pointer(&c, &ev(PointerKind::Down, 102.0, 98.0).with_mods(alt()));
|
||||
let a = t.pointer(&c, &ev(PointerKind::Up, 102.0, 98.0).with_mods(alt()));
|
||||
assert_eq!(a, vec![Action::Dialog("reflect".into(), json!({"axis": "vertical", "origin": [100.0, 100.0]}))]);
|
||||
// A plain click near the bottom-right corner, then near the centre.
|
||||
let mut t = TransformTool::new(TransformKind::Rotate);
|
||||
@@ -400,7 +423,8 @@ mod tests {
|
||||
// Snapping off: the point stays where it was clicked.
|
||||
c.snap_to_point = false;
|
||||
c.smart_guides = false;
|
||||
let a = t.pointer(&c, &ev(PointerKind::Down, 102.0, 98.0).with_mods(alt()));
|
||||
t.pointer(&c, &ev(PointerKind::Down, 102.0, 98.0).with_mods(alt()));
|
||||
let a = t.pointer(&c, &ev(PointerKind::Up, 102.0, 98.0).with_mods(alt()));
|
||||
assert!(matches!(&a[0], Action::Dialog(_, v) if v["origin"] == json!([102.0, 98.0])));
|
||||
}
|
||||
|
||||
|
||||
@@ -399,7 +399,7 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon {
|
||||
Cursor::ResizeNwSe => C::ResizeNwSe,
|
||||
Cursor::ResizeNeSw => C::ResizeNeSw,
|
||||
Cursor::Rotate => C::Alias,
|
||||
Cursor::Pen | Cursor::PenAdd | Cursor::PenDelete | Cursor::PenClose | Cursor::PenContinue => C::Crosshair,
|
||||
Cursor::Pen | Cursor::PenAdd | Cursor::PenDelete | Cursor::PenClose | Cursor::PenContinue | Cursor::PenConvert => C::Crosshair,
|
||||
Cursor::Text => C::Text,
|
||||
Cursor::Hand => C::Grab,
|
||||
Cursor::HandGrab => C::Grabbing,
|
||||
@@ -560,7 +560,7 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
|
||||
if matches!(d, Drag::TempSelect | Drag::Art { temp: true })
|
||||
&& let Some(prev) = ui.data(|dd| dd.get_temp::<String>(temp_tool_id()))
|
||||
{
|
||||
app.select_tool(&prev);
|
||||
app.restore_tool(&prev);
|
||||
}
|
||||
}
|
||||
Drag::Pan { .. } | Drag::RotateView { .. } => {}
|
||||
@@ -614,6 +614,14 @@ fn drag_art_out(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, p: Pos
|
||||
/// Send a pointer event to the active tool and act on UI requests (dialogs, tool switches).
|
||||
pub fn dispatch(app: &mut VectorcraftApp, ev: &PointerEvent, view: vectorcraft_engine::ViewInfo) {
|
||||
let r = app.session.pointer(ev, view);
|
||||
// The artboard the Artboard tool makes active is the active one (the navigator's, the
|
||||
// Artboards panel's).
|
||||
if app.session.tool_id() == "artboard"
|
||||
&& let Some(i) = app.session.tool_options()["active"].as_u64().and_then(|i| usize::try_from(i).ok())
|
||||
&& let Some(v) = app.view_mut()
|
||||
{
|
||||
v.artboard = i;
|
||||
}
|
||||
apply_requests(app, r);
|
||||
}
|
||||
|
||||
@@ -958,6 +966,10 @@ fn panel_drop(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, xf: &Xf)
|
||||
let add = ui.input(|i| i.modifiers.alt);
|
||||
("graphicStyle.apply", json!({"name": name, "ids": [hit.top_object(st.isolation).0], "add": add}))
|
||||
}
|
||||
// A brush from the Brushes panel: the path it lands on takes it (a compound path as a whole).
|
||||
widgets::PanelDrag::Brush { name, .. } => {
|
||||
("brush.apply", json!({"name": name, "ids": [vectorcraft_tools::xform::paint_owner(&st.doc, hit.leaf).0]}))
|
||||
}
|
||||
// Art dragged back onto the canvas: its move was already dropped. (A symbol was placed
|
||||
// above.)
|
||||
widgets::PanelDrag::Art(_) | widgets::PanelDrag::Symbol(_) => return,
|
||||
@@ -1412,6 +1424,9 @@ fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
|
||||
let is_group = first.as_ref().is_some_and(|f| matches!(f.kind, NodeKind::Group { .. }));
|
||||
let is_text = first.as_ref().is_some_and(|f| matches!(f.kind, NodeKind::Text(_)));
|
||||
let mut items: Vec<(&str, &str, &str)> = vec![]; // (label, icon, command)
|
||||
if !st.selection.anchors.is_empty() {
|
||||
items.push((tl!("Remove Anchor Points"), "dc-pen-delete", "path.removeAnchors"));
|
||||
}
|
||||
if n > 1 {
|
||||
items.push((tl!("Group"), "group", "object.group"));
|
||||
items.push((tl!("Unite"), "squares-unite", "object.pathfinder.unite"));
|
||||
@@ -1655,6 +1670,50 @@ mod tests {
|
||||
assert!((after.center.x - (before.center.x - 30.0 / before.zoom)).abs() < 1e-6, "{before:?} → {after:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_pressed_mid_drag_moves_what_the_tool_draws() {
|
||||
use egui::{Event, Modifiers};
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
app.ui.view.smart_guides = false;
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||||
let space = |pressed| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE };
|
||||
let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
|
||||
// Press at `a`, drag to `b`, then hold Space while moving on by `by` and let go of both.
|
||||
let draw = |app: &mut VectorcraftApp, a: Pos2, b: Pos2, by: egui::Vec2| {
|
||||
frame(app, &ctx, vec![Event::PointerMoved(a)]);
|
||||
frame(app, &ctx, vec![button(a, true)]);
|
||||
frame(app, &ctx, vec![Event::PointerMoved(b)]);
|
||||
frame(app, &ctx, vec![space(true)]);
|
||||
frame(app, &ctx, vec![Event::PointerMoved(b + by)]);
|
||||
frame(app, &ctx, vec![space(false)]);
|
||||
frame(app, &ctx, vec![button(b + by, false)]);
|
||||
};
|
||||
let near = |p: Point, q: Point| p.distance(q) < 1e-3;
|
||||
let at = app.canvas_rect.unwrap().center() - vec2(100.0, 60.0);
|
||||
let center = app.view().unwrap().center;
|
||||
// The rectangle moves at its size instead of growing, and the view doesn't pan.
|
||||
app.select_tool("rectangle");
|
||||
draw(&mut app, at, at + vec2(80.0, 40.0), vec2(30.0, 20.0));
|
||||
assert_eq!(app.view().unwrap().center, center, "Space during a drag doesn't pan");
|
||||
let st = app.session.active().unwrap();
|
||||
let r = st.doc.node(st.selection.objects[0]).unwrap().path_data().unwrap().bounds().unwrap();
|
||||
assert!(
|
||||
near(Point::new(r.x0, r.y0), xf.to_doc(at + vec2(30.0, 20.0))) && near(Point::new(r.x1, r.y1), xf.to_doc(at + vec2(110.0, 60.0))),
|
||||
"{r:?}"
|
||||
);
|
||||
// The Pen's anchor being dragged out moves, handles and all.
|
||||
app.session.execute("select.none", &json!({})).unwrap();
|
||||
app.select_tool("pen");
|
||||
let a = at + vec2(0.0, 120.0);
|
||||
draw(&mut app, a, a + vec2(40.0, 0.0), vec2(20.0, 10.0));
|
||||
let st = app.session.active().unwrap();
|
||||
let anchor = st.doc.node(st.selection.objects[0]).unwrap().path_data().unwrap().subpaths[0].anchors[0];
|
||||
assert!(near(anchor.p, xf.to_doc(a + vec2(20.0, 10.0))) && near(anchor.h_out, xf.to_doc(a + vec2(60.0, 10.0))), "{anchor:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_clicking_type_with_the_selection_tool_puts_the_caret_there() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
@@ -1717,6 +1776,34 @@ mod tests {
|
||||
assert!((c - xf.to_doc(at)).hypot() < 1e-6, "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_brush_dropped_on_a_path_is_applied_to_it() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
let id =
|
||||
app.session.execute("path.create", &json!({"anchors": [{"x": 100, "y": 150}, {"x": 300, "y": 150}]})).unwrap()["id"].as_u64().unwrap();
|
||||
app.session.execute("select.none", &json!({})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||||
let brush = |app: &VectorcraftApp| {
|
||||
let st = app.session.active().unwrap();
|
||||
st.doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.stroke().and_then(|s| s.brush.clone())
|
||||
};
|
||||
let drop = |app: &mut VectorcraftApp, at: Point| {
|
||||
let at = xf.to_screen(at);
|
||||
egui::DragAndDrop::set_payload(&ctx, widgets::PanelDrag::Brush { name: "Arrow".into(), def: json!({}) });
|
||||
let up = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() };
|
||||
frame(app, &ctx, vec![egui::Event::PointerMoved(at), up]);
|
||||
};
|
||||
// Off the path: nothing.
|
||||
drop(&mut app, Point::new(200.0, 250.0));
|
||||
assert_eq!(brush(&app), None);
|
||||
// On it: the path's stroke takes the brush.
|
||||
drop(&mut app, Point::new(200.0, 150.0));
|
||||
assert_eq!(brush(&app).as_deref(), Some("Arrow"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn middle_drag_pans_the_view_with_any_tool() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
|
||||
@@ -138,6 +138,9 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
_ => tl!(crate::panels::appearance::object_label(app)),
|
||||
};
|
||||
ui.label(egui::RichText::new(label).font(theme::semibold(12.0)).color(t.text));
|
||||
if anchor_mode && widgets::icon_button(ui, "dc-pen-delete", "Remove Anchor Points", false, 24.0).clicked() {
|
||||
crate::menus::invoke(app, "path.removeAnchors", json!({}));
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
crate::place::control_bar_details(app, ui);
|
||||
crate::toolbar::control_bar_options(app, ui);
|
||||
|
||||
Loaded 100 of 166 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user