mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-15 03:36:51 -04:00
Compare commits
115
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fd342563d | ||
|
|
8a31fda88f | ||
|
|
c4e139af19 | ||
|
|
389f75fa7b | ||
|
|
34eb19a5ab | ||
|
|
0c57b1d5d2 | ||
|
|
5d847ffc88 | ||
|
|
2a52b2901f | ||
|
|
7f4f7bd8d2 | ||
|
|
2729483851 | ||
|
|
5226d5a728 | ||
|
|
dec085e1e5 | ||
|
|
eaf96c60ae | ||
|
|
85bbc35176 | ||
|
|
3ce51a3487 | ||
|
|
a031ff6c7b | ||
|
|
0d748500d6 | ||
|
|
d47857a20f | ||
|
|
c9236ed3f7 | ||
|
|
2480a505cb | ||
|
|
231a97dd51 | ||
|
|
1819921e03 | ||
|
|
06226bcb73 | ||
|
|
20eaecd624 | ||
|
|
653945c8f8 | ||
|
|
bfde4dd918 | ||
|
|
a71348821a | ||
|
|
4f60296e77 | ||
|
|
98ccc7980b | ||
|
|
1639666a34 | ||
|
|
47af13c3d4 | ||
|
|
1da3c0c052 | ||
|
|
b19731b307 | ||
|
|
2d95ac1694 | ||
|
|
68241453dd | ||
|
|
39099dcc57 | ||
|
|
9ea392e6ee | ||
|
|
fffec4a714 | ||
|
|
adfe6e3820 | ||
|
|
935a588aa2 | ||
|
|
212fb9994a | ||
|
|
f5111045a9 | ||
|
|
c3e0ca3dfa | ||
|
|
50dbe4c10e | ||
|
|
ec41d72e0d | ||
|
|
63026402a1 | ||
|
|
a785b53e7b | ||
|
|
1217d58ca1 | ||
|
|
c912ef59d1 | ||
|
|
af505b197a | ||
|
|
f043a1a392 | ||
|
|
d01d7b2ef0 | ||
|
|
dc6e8a4253 | ||
|
|
327737b21a | ||
|
|
a7a4d952ea | ||
|
|
621e1a6640 | ||
|
|
c5274beb23 | ||
|
|
611f6855ef | ||
|
|
75c587c8a0 | ||
|
|
10f1c56a86 | ||
|
|
ab49f6af8b | ||
|
|
51e24f2944 | ||
|
|
2048084396 | ||
|
|
cee73d03a3 | ||
|
|
3ee9543b3f | ||
|
|
c183c47df3 | ||
|
|
2d2e0c6992 | ||
|
|
2972e2e6bf | ||
|
|
02e93902b3 | ||
|
|
d7339804e6 | ||
|
|
33d6d38ec8 | ||
|
|
53f8516cc6 | ||
|
|
db39545a85 | ||
|
|
aa17dd7d2c | ||
|
|
b26612142f | ||
|
|
a7279231cb | ||
|
|
77667945b1 | ||
|
|
faa3678a3f | ||
|
|
2ddbd9293f | ||
|
|
f5bf327447 | ||
|
|
1622a70f85 | ||
|
|
cd59b9cc94 | ||
|
|
1b3cf83e04 | ||
|
|
580ada65d5 | ||
|
|
7abce8426a | ||
|
|
0eb1e463b9 | ||
|
|
0b0ccfb35a | ||
|
|
0abb4157c9 | ||
|
|
96344f0761 | ||
|
|
a2530621fc | ||
|
|
d93e4e9cf1 | ||
|
|
a22bced150 | ||
|
|
a9724275cc | ||
|
|
04e714e9dd | ||
|
|
adad531adb | ||
|
|
c94d38e53c | ||
|
|
340200a6ec | ||
|
|
d5bc4ceeee | ||
|
|
a3316d2383 | ||
|
|
c6486ea50d | ||
|
|
145ae2e877 | ||
|
|
f76c834066 | ||
|
|
d23973de4f | ||
|
|
bd55d66072 | ||
|
|
9ea05d5d45 | ||
|
|
b5d25c1102 | ||
|
|
c11b516d34 | ||
|
|
2bfb0887d8 | ||
|
|
b2800fb529 | ||
|
|
1b761c68c1 | ||
|
|
683b1e9845 | ||
|
|
a0438f73f9 | ||
|
|
b23a402df8 | ||
|
|
fd52ca32c7 | ||
|
|
ed14640756 |
No files matched your search
@@ -377,11 +377,91 @@ jobs:
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
linux-riscv64:
|
||||
name: Linux riscv64
|
||||
needs: version
|
||||
# Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release
|
||||
# from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's
|
||||
# riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the
|
||||
# CLI is smoke-tested under QEMU (package.sh runs `vectorcraft-cli --version` through $EMULATOR).
|
||||
runs-on: ubuntu-24.04
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
env:
|
||||
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
# Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
|
||||
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: riscv64gc-unknown-linux-gnu
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-linux-riscv64
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$VECTORCRAFT_VERSION"
|
||||
- name: Install cross-compilation dependencies
|
||||
run: |
|
||||
. /etc/os-release
|
||||
sudo dpkg --add-architecture riscv64
|
||||
# Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports.
|
||||
if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then
|
||||
sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources
|
||||
fi
|
||||
if [ -f /etc/apt/sources.list ]; then
|
||||
sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list
|
||||
sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list
|
||||
fi
|
||||
for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do
|
||||
echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse"
|
||||
done | sudo tee /etc/apt/sources.list.d/riscv64.list
|
||||
sudo apt-get update
|
||||
# The riscv64 variants of the linux job's -dev packages.
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \
|
||||
libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \
|
||||
libxi-dev:riscv64 libgl1-mesa-dev:riscv64 \
|
||||
desktop-file-utils appstream
|
||||
- name: Build and package (tar.gz)
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc
|
||||
CC_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-gcc
|
||||
CXX_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-g++
|
||||
AR_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-ar
|
||||
PKG_CONFIG_SYSROOT_DIR: /
|
||||
PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig
|
||||
PKG_CONFIG_ALLOW_CROSS: "1"
|
||||
CROSS_ARCH: riscv64
|
||||
CROSS_TARGET: riscv64gc-unknown-linux-gnu
|
||||
CROSS_COMPILE: riscv64-linux-gnu-
|
||||
EMULATOR: qemu-riscv64
|
||||
QEMU_LD_PREFIX: /usr/riscv64-linux-gnu
|
||||
run: packaging/linux/package.sh --formats tar
|
||||
- name: Check the package
|
||||
run: |
|
||||
tar -tzf dist/release/vectorcraft-*-linux-riscv64.tar.gz \
|
||||
| grep -E '/bin/vectorcraft$|/bin/vectorcraft-cli$|/share/applications/ai\.storyteller\.vectorcraft\.desktop$'
|
||||
file target/riscv64gc-unknown-linux-gnu/release/vectorcraft | grep -F 'RISC-V'
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-linux-riscv64
|
||||
path: dist/release/*.tar.gz
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
name: Draft GitHub Release
|
||||
# Needs the signed jobs, so a branch dry run (where the environment refuses them) never
|
||||
# drafts a release.
|
||||
needs: [version, macos, windows, linux, flatpak, freebsd, web]
|
||||
needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web]
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
permissions:
|
||||
|
||||
Generated
+144
-29
@@ -200,6 +200,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "arboard"
|
||||
version = "3.6.1"
|
||||
@@ -713,6 +719,16 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||
|
||||
[[package]]
|
||||
name = "colored"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.8"
|
||||
@@ -790,6 +806,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
@@ -861,6 +886,19 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
|
||||
|
||||
[[package]]
|
||||
name = "dify"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90ce0fb972943b4e88cd03b8f92953df0c71bb05e0bde8e5b684895d808013cc"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"colored",
|
||||
"getopts",
|
||||
"image",
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
@@ -1066,6 +1104,27 @@ dependencies = [
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_kittest"
|
||||
version = "0.36.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5"
|
||||
dependencies = [
|
||||
"dify",
|
||||
"eframe",
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
"image",
|
||||
"kittest",
|
||||
"log",
|
||||
"open",
|
||||
"pollster 1.0.1",
|
||||
"serde",
|
||||
"tempfile",
|
||||
"toml",
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.18.0"
|
||||
@@ -1438,6 +1497,15 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getopts"
|
||||
version = "0.2.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
|
||||
dependencies = [
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
@@ -1940,6 +2008,25 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-docker"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-wsl"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
|
||||
dependencies = [
|
||||
"is-docker",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.15.0"
|
||||
@@ -2067,6 +2154,16 @@ version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
|
||||
|
||||
[[package]]
|
||||
name = "kittest"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_consumer 0.35.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "krilla"
|
||||
version = "0.8.2"
|
||||
@@ -2119,6 +2216,12 @@ dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
@@ -2167,9 +2270,9 @@ checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4"
|
||||
|
||||
[[package]]
|
||||
name = "linesweeper"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c19728333c060c6569a53c9a0e56c4be0df52cb4e6e07a8fbe16084cecce769"
|
||||
checksum = "a25df1b0c5432893f41bba817150e323703ee5f6d974f3a6808ac7fd7f046d18"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"kurbo 0.13.1",
|
||||
@@ -2740,6 +2843,16 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc"
|
||||
dependencies = [
|
||||
"is-wsl",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "orbclient"
|
||||
version = "0.3.55"
|
||||
@@ -4320,7 +4433,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"block2 0.6.2",
|
||||
@@ -4350,7 +4463,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-affinity"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
@@ -4361,7 +4474,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-brush"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde",
|
||||
@@ -4373,7 +4486,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cad"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4389,7 +4502,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cli"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"objc2-core-foundation",
|
||||
@@ -4410,7 +4523,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-color"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"moxcms",
|
||||
@@ -4422,7 +4535,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-doc"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"jpeg-encoder",
|
||||
@@ -4439,7 +4552,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-effects"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde_json",
|
||||
@@ -4454,7 +4567,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-engine"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
@@ -4489,7 +4602,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-eps"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4509,7 +4622,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-format"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"proptest",
|
||||
@@ -4523,7 +4636,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-geom"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"proptest",
|
||||
@@ -4535,7 +4648,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-mcp"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4554,7 +4667,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-metafile"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4568,7 +4681,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pathops"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"linesweeper",
|
||||
@@ -4580,7 +4693,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pdf"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"flate2",
|
||||
@@ -4609,7 +4722,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-plugins"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4623,7 +4736,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-render"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"gif",
|
||||
@@ -4648,7 +4761,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-svg"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4672,7 +4785,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-testkit"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4689,7 +4802,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-text"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"harfrust 0.13.3",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4707,14 +4820,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-three-d"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"vectorcraft-geom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-tools"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4729,7 +4842,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-trace"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4742,10 +4855,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-ui-egui"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"egui",
|
||||
"egui_extras",
|
||||
"egui_kittest",
|
||||
"image",
|
||||
"log",
|
||||
"serde",
|
||||
@@ -4773,7 +4888,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-web"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5883,7 +5998,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+3
-2
@@ -4,13 +4,14 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
repository = "https://github.com/storytold/vectorcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
# internal
|
||||
vectorcraft-geom = { path = "crates/geom" }
|
||||
vectorcraft-color = { path = "crates/color" }
|
||||
@@ -61,7 +62,7 @@ usvg = "0.48"
|
||||
krilla = "0.8"
|
||||
skrifa = "0.47"
|
||||
harfrust = "0.13"
|
||||
linesweeper = "0.4"
|
||||
linesweeper = "0.5"
|
||||
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||
# Zstandard decoder (pure Rust, builds for wasm32 too): the editing copy an EPS carries of its art.
|
||||
ruzstd = { version = "0.9", default-features = false, features = ["std"] }
|
||||
|
||||
+5
-1
@@ -1,5 +1,9 @@
|
||||
# VectorCraft Roadmap
|
||||
|
||||
- UI.5: Shared docking adds tab reordering, docking into other groups, dock splits and resizable floating panel groups. Panel positions and return locations persist with workspaces; the native icon rail and Tools panel remain available.
|
||||
|
||||
- UI.4: Dock and floating-panel headers use shared accessible tabs with bounded overflow. Widget-state theme application uses `craft-ui`; all four palettes, font stacks and preferences remain app-owned.
|
||||
|
||||
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
@@ -123,7 +127,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). |
|
||||
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). Build scripts leave a generated source they wouldn't change untouched, so the next build doesn't compile most of the workspace again where file times lose their sub-second part (#1051). A fill colour recolours the gradient mesh point last clicked with the Mesh tool or Direct Selection, and a Mesh tool click on a mesh's point selects that mesh (#1052). The All Tools drawer stays two tools wide instead of filling a wide window (#1054). Text Wrap's Make, Release and Options refuse a list of object ids with a malformed or unknown id instead of skipping it (#1055). A freeform gradient line with a malformed point index is refused instead of being shortened to the indices that are valid (#1056). The Corners dialog (`ui.corners`) refuses a malformed object id or a listed anchor that isn't a corner instead of editing the selection or the other corners (#1057). New View refuses a centre that isn't exactly [x, y] and a rotation that isn't a number, instead of trimming the one and saving the other as 0° (#1058). The command sweeps try junk scale, spacing and artboard values too, which they had skipped since before those bugs were fixed (#1059). EPS and PostScript `arc` and `arcn` with huge angles no longer hang the import, and an arc whose angles are whole turns apart is empty, as the PostScript reference defines it (#1062). Align and DisLine truncated
|
||||
| 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) |
|
||||
| 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day |
|
||||
| 2026-10-08 | v0.5.0–v0.7.0; community contributions: Pen and shape-tool modifiers, Layers, Artboards, Japanese composition, Hebrew/Arabic type, interface languages, saved selections |
|
||||
|
||||
@@ -189,7 +189,42 @@ fn read_prefs() -> Option<vectorcraft_ui_egui::UiState> {
|
||||
return None;
|
||||
}
|
||||
let bytes = prefs_path().and_then(|p| std::fs::read(p).ok()).or_else(|| legacy_prefs_path().and_then(|p| std::fs::read(p).ok()))?;
|
||||
serde_json::from_slice(&bytes).ok()
|
||||
decode_saved_prefs(&bytes)
|
||||
}
|
||||
|
||||
/// Recover only optional persisted docking metadata. Runtime UiState and command decoding
|
||||
/// remains strict, and an unreadable workspace layout cannot discard unrelated preferences.
|
||||
fn decode_saved_prefs(bytes: &[u8]) -> Option<vectorcraft_ui_egui::UiState> {
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
fn recover(object: &mut Map<String, Value>, hidden_key: &str) {
|
||||
if let Some(layout) = object.get("docking")
|
||||
&& serde_json::from_value::<vectorcraft_ui_egui::UiState>(json!({"docking": layout})).is_err()
|
||||
{
|
||||
object.remove("docking");
|
||||
}
|
||||
if let Some(hidden) = object.get_mut(hidden_key) {
|
||||
if let Value::Object(entries) = hidden {
|
||||
entries.retain(|panel, location| {
|
||||
serde_json::from_value::<vectorcraft_ui_egui::UiState>(json!({"docking_hidden": {panel: location}})).is_ok()
|
||||
});
|
||||
} else {
|
||||
object.remove(hidden_key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut value: Value = serde_json::from_slice(bytes).ok()?;
|
||||
let object = value.as_object_mut()?;
|
||||
recover(object, "docking_hidden");
|
||||
if let Some(Value::Array(workspaces)) = object.get_mut("custom_workspaces") {
|
||||
for workspace in workspaces {
|
||||
if let Some(object) = workspace.as_object_mut() {
|
||||
recover(object, "dockingHidden");
|
||||
}
|
||||
}
|
||||
}
|
||||
serde_json::from_value(value).ok()
|
||||
}
|
||||
|
||||
fn load_prefs(app: &mut VectorcraftApp, saved: Option<vectorcraft_ui_egui::UiState>) {
|
||||
@@ -649,6 +684,51 @@ mod tests {
|
||||
vectorcraft_ui_egui::UiState { engine_prefs, ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_saved_docking_keeps_preferences_and_every_workspace() {
|
||||
use serde_json::json;
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.run("window.panel.float", json!({"panel":"layers"})).unwrap();
|
||||
app.ui.toolbar = false;
|
||||
app.ui.engine_prefs = json!({"interfaceLanguage":"fr", "uiBrightness":"light"});
|
||||
app.ui.custom_workspaces =
|
||||
vec![vectorcraft_ui_egui::workspaces::capture(&app.ui, "Future"), vectorcraft_ui_egui::workspaces::capture(&app.ui, "Valid")];
|
||||
let original = serde_json::to_value(&app.ui).unwrap();
|
||||
let valid_location = original["docking_hidden"]["layers"].clone();
|
||||
assert!(valid_location.is_object());
|
||||
for invalid_layout in [json!({"root":{"FutureNode":{}},"floating":[]}), json!(false)] {
|
||||
for invalid_hidden in [json!({"layers":{"placement":{"FuturePlacement":{}}},"swatches":valid_location}), json!(["malformed"])] {
|
||||
let mut raw = original.clone();
|
||||
raw["docking"] = invalid_layout.clone();
|
||||
raw["docking_hidden"] = invalid_hidden.clone();
|
||||
raw["custom_workspaces"][0]["docking"] = invalid_layout.clone();
|
||||
raw["custom_workspaces"][0]["dockingHidden"] = invalid_hidden.clone();
|
||||
assert!(serde_json::from_value::<vectorcraft_ui_egui::UiState>(raw.clone()).is_err(), "runtime serde stays strict");
|
||||
let loaded = decode_saved_prefs(&serde_json::to_vec(&raw).unwrap()).unwrap();
|
||||
assert_eq!(loaded.engine_prefs, app.ui.engine_prefs);
|
||||
assert!(!loaded.toolbar);
|
||||
assert!(loaded.docking.is_none());
|
||||
assert!(!loaded.docking_hidden.contains_key("layers"));
|
||||
if invalid_hidden.is_object() {
|
||||
assert_eq!(serde_json::to_value(loaded.docking_hidden.get("swatches")).unwrap(), valid_location);
|
||||
} else {
|
||||
assert!(loaded.docking_hidden.is_empty());
|
||||
}
|
||||
assert_eq!(loaded.custom_workspaces.len(), 2);
|
||||
assert_eq!(loaded.custom_workspaces[0].name, "Future");
|
||||
assert!(loaded.custom_workspaces[0].docking.is_none());
|
||||
assert!(!loaded.custom_workspaces[0].docking_hidden.contains_key("layers"));
|
||||
assert_eq!(serde_json::to_value(&loaded.custom_workspaces[1]).unwrap(), original["custom_workspaces"][1]);
|
||||
}
|
||||
}
|
||||
let roundtrip = decode_saved_prefs(&serde_json::to_vec(&original).unwrap()).unwrap();
|
||||
assert_eq!(serde_json::to_value(roundtrip).unwrap(), original);
|
||||
assert!(
|
||||
decode_saved_prefs(&serde_json::to_vec(&json!({"toolbar":"invalid"})).unwrap()).is_none(),
|
||||
"unrelated fields retain normal strict parsing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_system_title_bar_preference_keeps_the_window_decorations() {
|
||||
// Preferences › User Interface › System Title Bar gives the window back its system
|
||||
|
||||
@@ -4,21 +4,15 @@
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[path = "profile.rs"]
|
||||
mod profile;
|
||||
|
||||
/// The settings file `ui.json` of the app `name`: in a folder named `name` in ~/Library/Application
|
||||
/// Support (macOS) or %APPDATA% (Windows), or `lower` in $XDG_CONFIG_HOME or ~/.config (Linux and
|
||||
/// BSD).
|
||||
pub fn prefs_path_for(name: &str, lower: &str) -> Option<PathBuf> {
|
||||
let base = if cfg!(target_os = "macos") {
|
||||
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support").join(name))
|
||||
} else if cfg!(windows) {
|
||||
std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join(name))
|
||||
} else {
|
||||
std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(std::path::PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config")))
|
||||
.map(|c| c.join(lower))
|
||||
};
|
||||
base.map(|b| b.join("ui.json"))
|
||||
let legacy = name == "DrawCraft" || lower == "drawcraft";
|
||||
profile::prefs_path(legacy)
|
||||
}
|
||||
|
||||
/// Have the font scans read VectorCraft's own Fonts folder, next to the settings, which
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
//! Native profile paths. Isolated profiles never migrate preferences from a user's old profile.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub fn prefs_path(legacy: bool) -> Option<PathBuf> {
|
||||
prefs_path_with(std::env::consts::OS, legacy, |key| std::env::var_os(key))
|
||||
}
|
||||
|
||||
fn prefs_path_with(os: &str, legacy: bool, env: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
|
||||
if let Some(root) = env("VECTORCRAFT_CONFIG_DIR") {
|
||||
// Never fall back to DrawCraft's real profile when the isolated file does not exist.
|
||||
return (!legacy && !root.is_empty()).then(|| PathBuf::from(root).join("ui.json"));
|
||||
}
|
||||
let (name, lower) = if legacy { ("DrawCraft", "drawcraft") } else { ("VectorCraft", "vectorcraft") };
|
||||
let base = match os {
|
||||
"macos" => env("HOME").map(|p| PathBuf::from(p).join("Library/Application Support").join(name)),
|
||||
"windows" => env("APPDATA").map(|p| PathBuf::from(p).join(name)),
|
||||
_ => env("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| env("HOME").map(|p| PathBuf::from(p).join(".config"))).map(|p| p.join(lower)),
|
||||
};
|
||||
base.map(|p| p.join("ui.json"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn environment(key: &str) -> Option<OsString> {
|
||||
match key {
|
||||
"HOME" => Some("/user".into()),
|
||||
"APPDATA" => Some("/roaming".into()),
|
||||
"XDG_CONFIG_HOME" => Some("/xdg-config".into()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isolated_profile_has_no_legacy_fallback_on_any_platform() {
|
||||
for os in ["macos", "windows", "linux"] {
|
||||
let env = |key: &str| {
|
||||
if key == "VECTORCRAFT_CONFIG_DIR" { Some("/isolated".into()) } else { environment(key) }
|
||||
};
|
||||
assert_eq!(prefs_path_with(os, false, env), Some("/isolated/ui.json".into()));
|
||||
assert_eq!(prefs_path_with(os, true, env), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_override_does_not_fall_back_to_user_files() {
|
||||
let env = |key: &str| if key == "VECTORCRAFT_CONFIG_DIR" { Some(OsString::new()) } else { environment(key) };
|
||||
assert_eq!(prefs_path_with("macos", false, env), None);
|
||||
assert_eq!(prefs_path_with("macos", true, env), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unset_override_keeps_platform_paths_and_legacy_migration() {
|
||||
assert_eq!(prefs_path_with("macos", false, environment), Some("/user/Library/Application Support/VectorCraft/ui.json".into()));
|
||||
assert_eq!(prefs_path_with("macos", true, environment), Some("/user/Library/Application Support/DrawCraft/ui.json".into()));
|
||||
assert_eq!(prefs_path_with("windows", false, environment), Some("/roaming/VectorCraft/ui.json".into()));
|
||||
assert_eq!(prefs_path_with("linux", false, environment), Some("/xdg-config/vectorcraft/ui.json".into()));
|
||||
assert_eq!(
|
||||
prefs_path_with("linux", true, |key| if key == "HOME" { environment(key) } else { None }),
|
||||
Some("/user/.config/drawcraft/ui.json".into())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,9 @@
|
||||
# To remove your names, delete your entry (or set fields empty) in a PR. The next
|
||||
# contributors.py run, and the next build, drop them.
|
||||
|
||||
[people.lollinoo]
|
||||
sync_github_name = true
|
||||
|
||||
[people.echelon]
|
||||
real_name = "Brandon Thomas"
|
||||
display_name = "Brandon Thomas"
|
||||
@@ -58,3 +61,7 @@ sync_github_name = false
|
||||
real_name = "Fabio William Conceição"
|
||||
display_name = "Messhias"
|
||||
sync_github_name = false
|
||||
|
||||
[people.KattKnight]
|
||||
display_name = "KattKnight (ayla.dsc)"
|
||||
sync_github_name = false
|
||||
@@ -129,7 +129,7 @@ fn default_ratio(h: &reader::Header) -> (Unit, f64) {
|
||||
|
||||
/// The name of `$INSUNITS` code `code`.
|
||||
fn units_name(code: i64) -> &'static str {
|
||||
const NAMES: [&str; 22] = [
|
||||
const NAMES: [&str; 25] = [
|
||||
"Unitless",
|
||||
"Inches",
|
||||
"Feet",
|
||||
@@ -152,6 +152,9 @@ fn units_name(code: i64) -> &'static str {
|
||||
"Light years",
|
||||
"Parsecs",
|
||||
"US survey feet",
|
||||
"US survey inches",
|
||||
"US survey yards",
|
||||
"US survey miles",
|
||||
];
|
||||
usize::try_from(code).ok().and_then(|i| NAMES.get(i)).copied().unwrap_or("Unitless")
|
||||
}
|
||||
|
||||
@@ -66,6 +66,8 @@ pub(crate) struct Build<'d, 'a> {
|
||||
/// The unit of `$INSUNITS` code `code` in points (0, unitless: none).
|
||||
pub(crate) fn insunits_points(code: i64) -> Option<f64> {
|
||||
let m = 72.0 / 0.0254;
|
||||
// The US survey foot: exactly 1200/3937 m.
|
||||
let survey_ft = m * 1200.0 / 3937.0;
|
||||
Some(match code {
|
||||
1 => 72.0,
|
||||
2 => 864.0,
|
||||
@@ -87,7 +89,10 @@ pub(crate) fn insunits_points(code: i64) -> Option<f64> {
|
||||
18 => m * 1.495_978_707e11,
|
||||
19 => m * 9.460_730_472_580_8e15,
|
||||
20 => m * 3.085_677_581e16,
|
||||
21 => m * 1200.0 / 3937.0,
|
||||
21 => survey_ft,
|
||||
22 => survey_ft / 12.0,
|
||||
23 => survey_ft * 3.0,
|
||||
24 => survey_ft * 5280.0,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -128,6 +128,20 @@ fn units_choose_the_default_scale() {
|
||||
let um = info(&drawing(&[(9, "$INSUNITS"), (70, "13")], &[], &[], LINE)).unwrap();
|
||||
assert_eq!((um.unit, um.units.as_str()), (Unit::Millimeters, "Microns"));
|
||||
assert!((um.scale - 1000.0).abs() < 1e-6);
|
||||
// #1011: US survey inches, yards and miles (from the survey foot, 1200/3937 m), not unitless.
|
||||
let survey_ft = 72.0 / 0.0254 * 1200.0 / 3937.0;
|
||||
for (code, name, pt) in [
|
||||
("21", "US survey feet", survey_ft),
|
||||
("22", "US survey inches", survey_ft / 12.0),
|
||||
("23", "US survey yards", survey_ft * 3.0),
|
||||
("24", "US survey miles", survey_ft * 5280.0),
|
||||
] {
|
||||
let i = info(&drawing(&[(9, "$INSUNITS"), (70, code)], &[], &[], LINE)).unwrap();
|
||||
assert_eq!((i.unit, i.units.as_str()), (Unit::Millimeters, name));
|
||||
assert!((i.scale * pt - 72.0 / 25.4).abs() < 1e-9, "{name}: 1 mm is {} units", i.scale);
|
||||
}
|
||||
let survey_in = open(&drawing(&[(9, "$INSUNITS"), (70, "22")], &[], &[], LINE), &ImportOptions::default()).document;
|
||||
assert!((survey_in.artboards[0].rect.width() - 7200.0144000288).abs() < 1e-6, "{:?}", survey_in.artboards[0].rect);
|
||||
let imperial = info(&drawing(&[(9, "$MEASUREMENT"), (70, "0")], &[], &[], LINE)).unwrap();
|
||||
assert_eq!((imperial.unit, imperial.scale), (Unit::Inches, 1.0));
|
||||
// A ratio: 1 cm = 10 units is 1:1 in millimetres again.
|
||||
|
||||
+73
-1
@@ -402,12 +402,17 @@ pub struct Guide {
|
||||
/// and moves, is copied and is deleted with it. None: a canvas guide, across the whole canvas.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub artboard: Option<u32>,
|
||||
/// The layer (or sublayer) it is on: it shows, hides and locks with that layer and is listed
|
||||
/// in it in the Layers panel, and goes when the layer is deleted. None: on no layer (guides
|
||||
/// from before guides had layers, and from files that don't say), always there.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub layer: Option<NodeId>,
|
||||
}
|
||||
|
||||
impl Guide {
|
||||
/// A canvas guide.
|
||||
pub fn new(vertical: bool, pos: f64) -> Self {
|
||||
Self { vertical, pos, artboard: None }
|
||||
Self { vertical, pos, artboard: None, layer: None }
|
||||
}
|
||||
|
||||
/// Moved by `d` (a vertical guide across, a horizontal one down).
|
||||
@@ -990,6 +995,73 @@ impl Document {
|
||||
let r = self.artboards.iter().find(|a| Some(a.id) == g.artboard)?.rect;
|
||||
Some(if g.vertical { (r.y0, r.y1) } else { (r.x0, r.x1) })
|
||||
}
|
||||
/// The layer (or sublayer) ruler guide `g` is on, if it is on one this document has.
|
||||
fn guide_layer(&self, g: &Guide) -> Option<NodeId> {
|
||||
g.layer.filter(|l| self.node(*l).is_some_and(Node::is_layer))
|
||||
}
|
||||
/// Is ruler guide `g` shown: on no layer, or on a layer that (with the layers around it) is
|
||||
/// visible? A guide whose layer is gone shows as one on no layer.
|
||||
pub fn guide_shown(&self, g: &Guide) -> bool {
|
||||
self.guide_layer(g).is_none_or(|l| self.is_visible(l))
|
||||
}
|
||||
/// Can ruler guide `g` be picked, moved and deleted: shown, and its layer unlocked?
|
||||
pub fn guide_editable(&self, g: &Guide) -> bool {
|
||||
self.guide_layer(g).is_none_or(|l| self.is_editable(l))
|
||||
}
|
||||
/// Put the ruler guides on no layer this document has (from files saved before guides had
|
||||
/// layers, or of layers merged into `layer`) on layer `layer`. Returns how many.
|
||||
pub fn rehome_guides(&mut self, layer: NodeId) -> usize {
|
||||
let lost: Vec<bool> = self.guides.iter().map(|g| self.guide_layer(g).is_none()).collect();
|
||||
let mut n = 0;
|
||||
for (g, lost) in self.guides.iter_mut().zip(lost) {
|
||||
if lost {
|
||||
g.layer = Some(layer);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
/// Put the ruler guides on no layer (from files saved before guides had layers), or on one that
|
||||
/// is gone, on the top visible, unlocked layer ([`Self::default_layer`]). Returns how many.
|
||||
pub fn adopt_guides(&mut self) -> usize {
|
||||
self.default_layer().map_or(0, |home| self.rehome_guides(home))
|
||||
}
|
||||
/// Delete the ruler guides whose layer was deleted (they go with it), keeping `sel`'s guides in
|
||||
/// step. Guides on no layer stay. Returns how many went.
|
||||
pub fn drop_guides_of_deleted_layers(&mut self, sel: &mut Selection) -> usize {
|
||||
let gone: Vec<bool> = self.guides.iter().map(|g| g.layer.is_some() && self.guide_layer(g).is_none()).collect();
|
||||
self.retain_guides(sel, |i, _| !gone.get(i).copied().unwrap_or(false))
|
||||
}
|
||||
/// Copy the ruler guides of layer `from` and of its sublayers onto their copies in `copy` (made
|
||||
/// from `from` by [`Self::reid`]: the same tree with fresh ids), as duplicating a layer copies
|
||||
/// the objects on it. Returns how many.
|
||||
pub fn copy_layer_guides(&mut self, from: &Node, copy: &Node) -> usize {
|
||||
let layers = |n: &Node| {
|
||||
let mut out = vec![];
|
||||
n.walk(&mut |c| {
|
||||
if c.is_layer() {
|
||||
out.push(c.id);
|
||||
}
|
||||
});
|
||||
out
|
||||
};
|
||||
let pairs: Vec<(NodeId, NodeId)> = layers(from).into_iter().zip(layers(copy)).collect();
|
||||
let copies: Vec<Guide> = self
|
||||
.guides
|
||||
.iter()
|
||||
.filter_map(|g| {
|
||||
let to = pairs.iter().find(|(a, _)| Some(*a) == g.layer)?.1;
|
||||
Some(Guide { layer: Some(to), ..g.clone() })
|
||||
})
|
||||
.collect();
|
||||
let n = copies.len();
|
||||
self.guides.extend(copies);
|
||||
n
|
||||
}
|
||||
/// The ruler guides on layer `layer` (not its sublayers'), by index.
|
||||
pub fn guides_on(&self, layer: NodeId) -> impl Iterator<Item = (usize, &Guide)> + '_ {
|
||||
self.guides.iter().enumerate().filter(move |(_, g)| g.layer == Some(layer))
|
||||
}
|
||||
/// Does ruler guide `g` run past `p` (up to `tol` beyond its ends)?
|
||||
pub fn guide_passes(&self, g: &Guide, p: Point, tol: f64) -> bool {
|
||||
let along = if g.vertical { p.y } else { p.x };
|
||||
|
||||
@@ -29,8 +29,9 @@ pub struct Selection {
|
||||
/// clears them.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub slices: Vec<NodeId>,
|
||||
/// Ruler guides selected with a selection tool, as indexes into [`Document::guides`]. They are
|
||||
/// selected on their own: any change to the object selection clears them.
|
||||
/// Ruler guides selected with a selection tool, as indexes into [`Document::guides`]. A new
|
||||
/// selection ([`Self::set`]) clears them; Shift-adding or -removing objects keeps them, so
|
||||
/// guides and objects can be selected together, in either order.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub guides: Vec<usize>,
|
||||
}
|
||||
@@ -66,7 +67,6 @@ impl Selection {
|
||||
pub fn add(&mut self, id: NodeId) {
|
||||
self.target = None;
|
||||
self.slices.clear();
|
||||
self.guides.clear();
|
||||
if !self.objects.contains(&id) {
|
||||
self.objects.push(id);
|
||||
}
|
||||
@@ -74,7 +74,6 @@ impl Selection {
|
||||
pub fn remove(&mut self, id: NodeId) {
|
||||
self.target = None;
|
||||
self.slices.clear();
|
||||
self.guides.clear();
|
||||
self.objects.retain(|x| *x != id);
|
||||
self.anchors.remove(&id);
|
||||
if self.key == Some(id) {
|
||||
@@ -99,6 +98,15 @@ impl Selection {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Add ruler guide `i` to the selection, or take it out, keeping the selected objects (a
|
||||
/// Shift-click on a guide).
|
||||
pub fn toggle_guide(&mut self, i: usize) {
|
||||
if let Some(k) = self.guides.iter().position(|g| *g == i) {
|
||||
self.guides.remove(k);
|
||||
} else {
|
||||
self.guides.push(i);
|
||||
}
|
||||
}
|
||||
pub fn toggle(&mut self, id: NodeId) {
|
||||
if self.contains(id) { self.remove(id) } else { self.add(id) }
|
||||
}
|
||||
@@ -117,7 +125,8 @@ impl Selection {
|
||||
self.target = None;
|
||||
}
|
||||
self.slices.retain(|id| doc.is_slice(*id));
|
||||
self.guides.retain(|i| *i < doc.guides.len());
|
||||
// Guides on a hidden or locked layer can't stay selected.
|
||||
self.guides.retain(|i| doc.guides.get(*i).is_some_and(|g| doc.guide_editable(g)));
|
||||
}
|
||||
/// Target `id` (see [`Selection::target`]): a layer gets its visible, unlocked art selected
|
||||
/// (the art of its sublayers too), anything else is selected itself.
|
||||
@@ -180,8 +189,16 @@ mod tests {
|
||||
s.set_guides([2, 0, 2]);
|
||||
assert_eq!((s.objects.clone(), s.guides.clone()), (vec![], vec![2, 0]));
|
||||
assert!(s.has_objects_or_guides() && s.is_empty());
|
||||
// Shift-adding an object keeps them; a new selection doesn't.
|
||||
s.add(NodeId(1));
|
||||
assert_eq!(s.guides, vec![2, 0]);
|
||||
s.set([NodeId(1)]);
|
||||
assert!(s.guides.is_empty());
|
||||
// Toggled, a guide joins (or leaves) the selected objects.
|
||||
s.toggle_guide(0);
|
||||
assert_eq!((s.objects.clone(), s.guides.clone()), (vec![NodeId(1)], vec![0]));
|
||||
s.toggle_guide(0);
|
||||
assert_eq!((s.objects.clone(), s.guides.clone()), (vec![NodeId(1)], vec![]));
|
||||
// Pruning drops guides the document no longer has.
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
d.guides.push(crate::Guide::new(true, 10.0));
|
||||
@@ -190,6 +207,36 @@ mod tests {
|
||||
assert_eq!(s.guides, vec![0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guides_show_and_lock_with_their_layer() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let layer = d.layers[0].id;
|
||||
let g = crate::Guide { layer: Some(layer), ..crate::Guide::new(true, 10.0) };
|
||||
assert!(d.guide_shown(&g) && d.guide_editable(&g));
|
||||
d.node_mut(layer).unwrap().locked = true;
|
||||
assert!(d.guide_shown(&g) && !d.guide_editable(&g), "locked: shown, not editable");
|
||||
d.node_mut(layer).unwrap().visible = false;
|
||||
assert!(!d.guide_shown(&g) && !d.guide_editable(&g), "hidden");
|
||||
// On no layer, or a layer that's gone: always there.
|
||||
assert!(d.guide_shown(&crate::Guide::new(false, 5.0)));
|
||||
let gone = crate::Guide { layer: Some(NodeId(9999)), ..crate::Guide::new(true, 1.0) };
|
||||
assert!(d.guide_shown(&gone) && d.guide_editable(&gone));
|
||||
d.guides.push(g);
|
||||
assert_eq!(d.guides_on(layer).count(), 1);
|
||||
// Guides on no layer, or a gone one, are adopted by the top visible, unlocked layer.
|
||||
d.node_mut(layer).unwrap().visible = true;
|
||||
d.node_mut(layer).unwrap().locked = false;
|
||||
d.guides.push(crate::Guide::new(false, 5.0));
|
||||
d.guides.push(gone);
|
||||
assert_eq!(d.adopt_guides(), 2);
|
||||
assert!(d.guides.iter().all(|g| g.layer == Some(layer)));
|
||||
assert_eq!(d.adopt_guides(), 0, "once");
|
||||
// A guide saved before guides had layers reads as one on no layer.
|
||||
let old: crate::Guide = serde_json::from_str(r#"{"vertical": true, "pos": 5.0}"#).unwrap();
|
||||
assert_eq!(old.layer, None);
|
||||
assert!(!serde_json::to_string(&crate::Guide::new(true, 5.0)).unwrap().contains("layer"), "nothing new written");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_changes_clear_the_target() {
|
||||
let mut s = Selection::default();
|
||||
|
||||
@@ -95,6 +95,11 @@ pub struct CharStyle {
|
||||
/// their line line up with it.
|
||||
#[serde(default, rename = "charAlign", skip_serializing_if = "crate::skip::is_default")]
|
||||
pub char_align: CharAlign,
|
||||
/// Proportional Metrics (Character panel menu, East Asian options): full-width glyphs take the
|
||||
/// font's own proportional widths, OpenType `palt` (#966). Vertical type sets them as before
|
||||
/// for now.
|
||||
#[serde(default, rename = "proportionalMetrics", skip_serializing_if = "crate::skip::is_default")]
|
||||
pub proportional_metrics: bool,
|
||||
}
|
||||
|
||||
/// Where a character smaller than the largest on its line lines up with it: on the Roman
|
||||
@@ -205,6 +210,7 @@ impl Default for CharStyle {
|
||||
stroke_dash: None,
|
||||
position: CharPosition::Normal,
|
||||
char_align: CharAlign::RomanBaseline,
|
||||
proportional_metrics: false,
|
||||
small_caps: None,
|
||||
}
|
||||
}
|
||||
@@ -954,7 +960,49 @@ pub struct TextObject {
|
||||
pub cached_baselines: Vec<(Point, Point)>,
|
||||
}
|
||||
|
||||
/// Conversion from stored character attributes to document-space type controls.
|
||||
/// Point size follows the transformed em-height axis; horizontal scale is the ratio of
|
||||
/// the two transformed em axes. Rotation, reflection and shear remain in the affine.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct TextStyleScale {
|
||||
pub points: f64,
|
||||
pub horizontal: f64,
|
||||
}
|
||||
|
||||
impl TextObject {
|
||||
/// None for collapsed or unrepresentable transforms, whose type dimensions cannot
|
||||
/// be edited by compensating the stored attributes.
|
||||
pub fn style_scale(&self) -> Option<TextStyleScale> {
|
||||
let [a, b, c, d, e, f] = self.xf.as_coeffs();
|
||||
if ![a, b, c, d, e, f].iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let (sx, sy) = (a.hypot(b), c.hypot(d));
|
||||
let determinant = (a / sx) * (d / sy) - (b / sx) * (c / sy);
|
||||
let horizontal = sx / sy;
|
||||
if !sy.is_finite() || sy <= 0.0 || !horizontal.is_finite() || horizontal <= 0.0 || !determinant.is_finite() || determinant.abs() <= 1e-12 {
|
||||
return None;
|
||||
}
|
||||
// Avoid a pure rotation turning otherwise identical fields into mixed values.
|
||||
let unit = |v: f64| if (v - 1.0).abs() <= 1e-12 { 1.0 } else { v };
|
||||
Some(TextStyleScale { points: unit(sy), horizontal: unit(horizontal) })
|
||||
}
|
||||
|
||||
/// Character attributes as presented by controls (stored runs stay local for layout
|
||||
/// and rich-text clipboard operations). Explicit vertical character scale stays separate.
|
||||
pub fn effective_char_style(&self, style: &CharStyle) -> Option<CharStyle> {
|
||||
let scale = self.style_scale()?;
|
||||
let mut style = style.clone();
|
||||
style.size *= scale.points;
|
||||
style.leading = style.leading.map(|v| v * scale.points);
|
||||
style.baseline_shift *= scale.points;
|
||||
style.h_scale *= scale.horizontal;
|
||||
if ![style.size, style.leading.unwrap_or(0.0), style.baseline_shift, style.h_scale].iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
Some(style)
|
||||
}
|
||||
|
||||
pub fn point(origin: Point, text: &str, style: CharStyle) -> Self {
|
||||
Self {
|
||||
vertical: false,
|
||||
@@ -1190,6 +1238,19 @@ impl TextObject {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn character_controls_reject_collapsed_and_nonfinite_transforms() {
|
||||
let mut t = TextObject::point(Point::ZERO, "Text", CharStyle::default());
|
||||
for matrix in [[0.0; 6], [1.0, 1.0, 2.0, 2.0, 0.0, 0.0], [f64::INFINITY, 0.0, 0.0, 1.0, 0.0, 0.0], [1.0, 0.0, 0.0, 1.0, f64::NAN, 0.0]] {
|
||||
t.xf = Affine::new(matrix);
|
||||
assert!(t.effective_char_style(&t.first_style()).is_none());
|
||||
}
|
||||
t.xf = Affine::rotate(0.83);
|
||||
assert_eq!(t.effective_char_style(&t.first_style()).unwrap().size, 12.0);
|
||||
t.xf = Affine::scale(1e308);
|
||||
assert!(t.effective_char_style(&t.first_style()).is_none(), "overflowing point size");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_text_basics() {
|
||||
let t = TextObject::point(Point::new(10.0, 20.0), "Hello", CharStyle::default());
|
||||
|
||||
@@ -219,11 +219,13 @@ fn weighted_stroke(d: &mut Document, id: NodeId) -> Result<&mut vectorcraft_doc:
|
||||
Ok(st)
|
||||
}
|
||||
|
||||
/// The `index` param, checked against the points of `prof`.
|
||||
/// The `index` param (none when absent or null), checked against the points of `prof`: a malformed
|
||||
/// one is refused like one out of range, never wrapped round or taken as missing.
|
||||
fn point_index(p: &Value, prof: &WidthProfile, cmd: &str) -> Result<Option<usize>> {
|
||||
match p.get("index").and_then(Value::as_u64).map(|v| v as usize) {
|
||||
Some(i) if i >= prof.points.len() => Err(bad(cmd, "no such width point")),
|
||||
i => Ok(i),
|
||||
let Some(v) = p.get("index").filter(|v| !v.is_null()) else { return Ok(None) };
|
||||
match point_indices(&json!([v])).and_then(|i| i.first().copied()) {
|
||||
Some(i) if i < prof.points.len() => Ok(Some(i)),
|
||||
_ => Err(bad(cmd, "no such width point")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,11 +325,13 @@ fn width_point_copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn width_point_remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "stroke.widthPoint.remove";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let mut indices: Vec<usize> = match (p.get("indices").and_then(Value::as_array), p.get("index").and_then(Value::as_u64)) {
|
||||
(Some(a), _) => a.iter().filter_map(Value::as_u64).map(|i| i as usize).collect(),
|
||||
(None, Some(i)) => vec![i as usize],
|
||||
let indices = match (p.get("indices"), p.get("index")) {
|
||||
(Some(a), _) => point_indices(a),
|
||||
(None, Some(i)) => point_indices(&json!([i])),
|
||||
(None, None) => return Err(bad(C, "missing index")),
|
||||
};
|
||||
// A malformed index fails the whole delete instead of being dropped while the others go.
|
||||
let mut indices = indices.ok_or_else(|| bad(C, "no such width point"))?;
|
||||
indices.sort_unstable();
|
||||
indices.dedup();
|
||||
s.edit("Delete Width Point", |d, _| {
|
||||
|
||||
@@ -160,7 +160,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add Guide",
|
||||
[],
|
||||
None,
|
||||
"{vertical: bool, pos: pt (x for vertical, y for horizontal), artboard?: index (an artboard guide: it runs across that artboard only and moves, is copied and is deleted with it; default a canvas guide, across the whole canvas)} → {index}",
|
||||
"{vertical: bool, pos: pt (x for vertical, y for horizontal), artboard?: index (an artboard guide: it runs across that artboard only and moves, is copied and is deleted with it; default a canvas guide, across the whole canvas), layer?: id (the layer it is on, shown, hidden, locked and deleted with it; default the current layer)} → {index}",
|
||||
has_doc,
|
||||
guide_add
|
||||
),
|
||||
@@ -169,7 +169,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Guides",
|
||||
[],
|
||||
None,
|
||||
"{} → [{index, vertical, pos, selected, artboard?: index (an artboard guide's)}…] the ruler guides",
|
||||
"{} → [{index, vertical, pos, selected, artboard?: index (an artboard guide's), layer?: id (the layer it is on), shown, editable (its layer shown and unlocked)}…] the ruler guides",
|
||||
has_doc,
|
||||
guide_list
|
||||
),
|
||||
@@ -178,7 +178,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Select Guides",
|
||||
[],
|
||||
None,
|
||||
"{indexes: [index…], toggle?: bool} select ruler guides on their own (deselecting the art); toggle adds or removes them instead → {selected: [index…]}",
|
||||
"{indexes: [index…], toggle?: bool} select ruler guides on their own (deselecting the art); toggle adds or removes them instead, keeping the selected art (a Shift-click on a guide) → {selected: [index…]}",
|
||||
guides_unlocked,
|
||||
guide_select
|
||||
),
|
||||
@@ -200,6 +200,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
guides_unlocked,
|
||||
guide_move
|
||||
),
|
||||
cmd!(
|
||||
"guide.setLayer",
|
||||
"Move Guide to Layer",
|
||||
[],
|
||||
None,
|
||||
"{index?, indexes?: [index…], layer: id} put ruler guide `index` (or guides `indexes`), else the selected guides, on layer `layer` (a layer or sublayer), as dragging their rows onto that layer's row in the Layers panel does → {count}",
|
||||
guides_unlocked,
|
||||
guide_set_layer
|
||||
),
|
||||
cmd!("file.closeAll", "Close All", ["File"], Some("Cmd+Alt+W"), "{} → {closed}", has_doc, close_all),
|
||||
cmd!(
|
||||
"file.documentColorMode",
|
||||
@@ -507,8 +516,15 @@ fn guide_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let st = s.doc()?;
|
||||
let layer = match id_param(p, "layer") {
|
||||
Some(l) if st.doc.node(l).is_some_and(Node::is_layer) => Some(l),
|
||||
Some(_) => return Err(bad("guide.add", "layer must name a layer or sublayer")),
|
||||
// Where new art goes (refused when no layer is shown and unlocked), or that group's layer.
|
||||
None => st.target_parent()?.and_then(|p| if st.doc.node(p).is_some_and(Node::is_layer) { Some(p) } else { st.doc.layer_containing(p) }),
|
||||
};
|
||||
let i = s.edit("New Guide", |d, _| {
|
||||
d.guides.push(Guide { artboard, ..Guide::new(vertical, pos) });
|
||||
d.guides.push(Guide { artboard, layer, ..Guide::new(vertical, pos) });
|
||||
Ok(d.guides.len() - 1)
|
||||
})?;
|
||||
Ok(json!({ "index": i }))
|
||||
@@ -521,6 +537,11 @@ fn guide_list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
if let Some(ab) = st.doc.artboards.iter().position(|a| Some(a.id) == g.artboard) {
|
||||
r["artboard"] = json!(ab);
|
||||
}
|
||||
if let Some(l) = g.layer {
|
||||
r["layer"] = json!(l.0);
|
||||
}
|
||||
r["shown"] = json!(st.doc.guide_shown(g));
|
||||
r["editable"] = json!(st.doc.guide_editable(g));
|
||||
r
|
||||
};
|
||||
Ok(Value::Array(st.doc.guides.iter().enumerate().map(row).collect()))
|
||||
@@ -535,19 +556,26 @@ fn guide_index(s: &Session, v: Option<&Value>, cmd: &str) -> Result<usize> {
|
||||
Ok(i)
|
||||
}
|
||||
|
||||
/// [`guide_index`] of a guide that can be moved or deleted: not on a hidden or locked layer.
|
||||
fn editable_guide_index(s: &Session, v: Option<&Value>, cmd: &str) -> Result<usize> {
|
||||
let i = guide_index(s, v, cmd)?;
|
||||
let d = &s.doc()?.doc;
|
||||
if !d.guides.get(i).is_some_and(|g| d.guide_editable(g)) {
|
||||
return Err(EngineError::Other("the guide is on a hidden or locked layer".into()));
|
||||
}
|
||||
Ok(i)
|
||||
}
|
||||
|
||||
fn guide_select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let list = p.get("indexes").and_then(Value::as_array).ok_or_else(|| bad("guide.select", "missing indexes"))?;
|
||||
let picked = list.iter().map(|v| guide_index(s, Some(v), "guide.select")).collect::<Result<Vec<_>>>()?;
|
||||
if bool_or(p, "toggle", false) {
|
||||
let mut now = s.doc()?.selection.guides.clone();
|
||||
for i in picked {
|
||||
if let Some(k) = now.iter().position(|g| *g == i) {
|
||||
now.remove(k);
|
||||
} else {
|
||||
now.push(i);
|
||||
// Added to (or taken from) what is selected, the art included.
|
||||
s.select(|_, sel| {
|
||||
for i in picked {
|
||||
sel.toggle_guide(i);
|
||||
}
|
||||
}
|
||||
s.select(|_, sel| sel.set_guides(now))?;
|
||||
})?;
|
||||
} else {
|
||||
s.select(|_, sel| sel.set_guides(picked))?;
|
||||
}
|
||||
@@ -557,7 +585,7 @@ fn guide_select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// Delete ruler guide `index`, else the selected guides, in one undo step → {count}.
|
||||
pub(crate) fn guide_remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let gone: Vec<usize> = match p.get("index") {
|
||||
Some(v) => vec![guide_index(s, Some(v), "guide.remove")?],
|
||||
Some(v) => vec![editable_guide_index(s, Some(v), "guide.remove")?],
|
||||
None => s.doc()?.selection.guides.clone(),
|
||||
};
|
||||
if gone.is_empty() {
|
||||
@@ -571,10 +599,39 @@ pub(crate) fn guide_remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "count": gone.len() }))
|
||||
}
|
||||
|
||||
/// Put ruler guide `index`, else the selected guides, on layer `layer`, in one undo step.
|
||||
fn guide_set_layer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "guide.setLayer";
|
||||
let d = &s.doc()?.doc;
|
||||
let layer =
|
||||
id_param(p, "layer").filter(|l| d.node(*l).is_some_and(Node::is_layer)).ok_or_else(|| bad(C, "layer must name a layer or sublayer"))?;
|
||||
// As objects dragged in the Layers panel: never onto a locked layer.
|
||||
if super::layer::locked_within(d, layer) {
|
||||
return Err(EngineError::Other("the layer or group is locked".into()));
|
||||
}
|
||||
let moving: Vec<usize> = match (p.get("index"), p.get("indexes").and_then(Value::as_array)) {
|
||||
(Some(v), _) => vec![editable_guide_index(s, Some(v), C)?],
|
||||
(None, Some(list)) => list.iter().take(10_000).map(|v| editable_guide_index(s, Some(v), C)).collect::<Result<Vec<_>>>()?,
|
||||
(None, None) => s.doc()?.selection.guides.clone(),
|
||||
};
|
||||
if moving.is_empty() {
|
||||
return Err(bad(C, "no guide selected (or give an index)"));
|
||||
}
|
||||
s.edit("Move Guide to Layer", |d, _| {
|
||||
for i in &moving {
|
||||
if let Some(g) = d.guides.get_mut(*i) {
|
||||
g.layer = Some(layer);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "count": moving.len() }))
|
||||
}
|
||||
|
||||
/// Put ruler guide `index` at `pos`, else move (or copy) the selected guides, in one undo step.
|
||||
pub(crate) fn guide_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(v) = p.get("index") {
|
||||
let i = guide_index(s, Some(v), "guide.move")?;
|
||||
let i = editable_guide_index(s, Some(v), "guide.move")?;
|
||||
let pos = f64_req(p, "pos", "guide.move")?;
|
||||
s.edit("Move Guide", |d, _| {
|
||||
if let Some(g) = d.guides.get_mut(i) {
|
||||
|
||||
@@ -268,6 +268,8 @@ fn clear(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return super::docmenu::guide_remove(s, &json!({}));
|
||||
}
|
||||
let st = s.doc()?;
|
||||
// Guides selected with the art go with it (not when ids are given).
|
||||
let with_guides = explicit.is_none();
|
||||
let targets = explicit.unwrap_or_else(|| st.selection.in_paint_order(&st.doc));
|
||||
// Keep the selected ids themselves, without promoting compound members. Delete a selected
|
||||
// ancestor just once; its selected descendants leave with it. Layers themselves are kept.
|
||||
@@ -284,6 +286,10 @@ fn clear(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for id in &ids {
|
||||
d.remove(*id)?;
|
||||
}
|
||||
if with_guides {
|
||||
let gone = sel.guides.clone();
|
||||
d.retain_guides(sel, |i, _| !gone.contains(&i));
|
||||
}
|
||||
sel.clear();
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
@@ -108,6 +108,15 @@ showpage
|
||||
let inner = outer.children().map(|c| (c.len(), c[1].children().map(Vec::len))).unwrap();
|
||||
assert!(matches!(outer.kind, vectorcraft_doc::NodeKind::Group { clip: false, .. }), "{:?}", outer.kind);
|
||||
assert_eq!(inner, (2, Some(2)));
|
||||
// One that names the prolog defining its operators without including it (as other apps write
|
||||
// the format) opens with the layers its program has (#1027).
|
||||
let named = b"%!PS-Adobe-3.0\n%%BoundingBox: 0 0 100 100\n%%DocumentNeededResources: procset Adobe_IllustratorA_AI3 1.0 0\n%AI3_TemplateBox: 50 50 50 50\n%%EndComments\n\
|
||||
%%BeginProlog\n%%IncludeResource: procset Adobe_IllustratorA_AI3 1.0 0\n%%EndProlog\n%%BeginSetup\nAdobe_IllustratorA_AI3 /initialize get exec\n%%EndSetup\n\
|
||||
%AI5_BeginLayer\n1 1 1 1 0 0 2 0 0 0 Lb\n(PLAN::Walls) Ln\n0 0 0 1 K\n0 i 1 w\n10 10 m\n90 90 L\nS\nLB\n%AI5_EndLayer--\n%%PageTrailer\n%%Trailer\n%%EOF\n";
|
||||
let r = open(&mut s, "cad.ai", named).unwrap();
|
||||
assert_eq!(r["warnings"].as_array().map_or(0, Vec::len), 0, "{r}");
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
assert_eq!((d.layers.len(), d.layers[0].name.as_deref(), objects(&s)), (1, Some("PLAN::Walls"), 1));
|
||||
// A .ait one opens as a new untitled document.
|
||||
let r = open(&mut s, "legacy.ait", ps).unwrap();
|
||||
assert!(r["title"].as_str().unwrap().starts_with("Untitled"), "{r}");
|
||||
|
||||
@@ -148,9 +148,12 @@ pub(crate) fn parse_freeform(v: &Value) -> std::result::Result<Freeform, String>
|
||||
point_fields(pv, &mut pt, Affine::IDENTITY).map_err(|e| format!("freeform point {i}: {e}"))?;
|
||||
f.points.push(pt);
|
||||
}
|
||||
for l in v.get("lines").and_then(Value::as_array).into_iter().flatten() {
|
||||
let ix = l.as_array().map(|a| a.iter().filter_map(Value::as_u64).map(|i| i as usize).collect()).unwrap_or_default();
|
||||
f.add_line(ix)?;
|
||||
if let Some(lines) = v.get("lines") {
|
||||
let lines = lines.as_array().ok_or("`freeform.lines` must be an array")?;
|
||||
for (i, l) in lines.iter().enumerate() {
|
||||
let ix = point_indices(l).ok_or_else(|| format!("freeform line {i} must be an array of point indices"))?;
|
||||
f.add_line(ix).map_err(|e| format!("freeform line {i}: {e}"))?;
|
||||
}
|
||||
}
|
||||
if let Some(m) = str_param(v, "mode") {
|
||||
f.mode = parse_mode(m)?;
|
||||
@@ -279,11 +282,7 @@ fn delete_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn add_line(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "paint.freeform.addLine";
|
||||
let points: Vec<usize> = p
|
||||
.get("points")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|a| a.iter().map(|v| v.as_u64().map(|i| i as usize)).collect())
|
||||
.ok_or_else(|| bad(C, "`points` must be an array of point indices"))?;
|
||||
let points = p.get("points").and_then(point_indices).ok_or_else(|| bad(C, "`points` must be an array of point indices"))?;
|
||||
let line = edit(s, p, C, |f, _| f.add_line(points.clone()))?;
|
||||
Ok(json!({ "line": line }))
|
||||
}
|
||||
@@ -323,3 +322,24 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
v["selected"] = json!(selected);
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A line with an index that isn't a point's is refused, not shortened to the indices that are.
|
||||
#[test]
|
||||
fn malformed_freeform_lines_are_not_silently_shortened() {
|
||||
let points = json!([
|
||||
{"at": [0, 0], "color": "#ff0000"},
|
||||
{"at": [50, 0], "color": "#00ff00"},
|
||||
{"at": [100, 0], "color": "#0000ff"}
|
||||
]);
|
||||
for lines in [json!([[0, "broken", 2]]), json!([[0, -1, 2]]), json!([[0, 1.5, 2]]), json!([{"points": [0, 1]}]), json!("not an array")] {
|
||||
let value = json!({"points": points, "lines": lines, "mode": "lines"});
|
||||
assert!(parse_freeform(&value).is_err(), "invalid lines accepted: {value}");
|
||||
}
|
||||
let good = parse_freeform(&json!({"points": points, "lines": [[0, 1, 2]], "mode": "lines"})).unwrap();
|
||||
assert_eq!(good.lines, vec![vec![0, 1, 2]]);
|
||||
}
|
||||
}
|
||||
@@ -221,7 +221,7 @@ pub(crate) fn accepts(d: &Document, parent: Option<NodeId>, n: &Node) -> bool {
|
||||
}
|
||||
|
||||
/// Whether `id` or a container around it is locked.
|
||||
fn locked_within(d: &Document, id: NodeId) -> bool {
|
||||
pub(crate) fn locked_within(d: &Document, id: NodeId) -> bool {
|
||||
d.ancestry(id).unwrap_or_default().iter().any(|a| d.node(*a).is_some_and(|n| n.locked))
|
||||
}
|
||||
|
||||
@@ -462,6 +462,8 @@ fn delete_rows(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for id in &ids {
|
||||
d.remove(*id)?;
|
||||
}
|
||||
// The guides on the deleted layers go with them.
|
||||
d.drop_guides_of_deleted_layers(sel);
|
||||
sel.prune(d);
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -487,6 +489,7 @@ fn duplicate_rows(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut c = d.reid(&n);
|
||||
if n.is_layer() {
|
||||
c.name = Some(format!("{} copy", n.display_name()));
|
||||
d.copy_layer_guides(&n, &c);
|
||||
}
|
||||
out.push(d.insert(par, idx + 1, c)?);
|
||||
}
|
||||
@@ -679,7 +682,11 @@ fn move_rows(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for id in &ids {
|
||||
nodes.push(if copy {
|
||||
let n = d.node(*id).cloned().ok_or(EngineError::NoNode(*id))?;
|
||||
d.reid(&n)
|
||||
let c = d.reid(&n);
|
||||
if n.is_layer() {
|
||||
d.copy_layer_guides(&n, &c);
|
||||
}
|
||||
c
|
||||
} else {
|
||||
Arc::unwrap_or_clone(d.remove(*id)?)
|
||||
});
|
||||
|
||||
@@ -263,6 +263,8 @@ fn merge_into(d: &mut Document, into: NodeId, others: &[NodeId]) -> Result<()> {
|
||||
all.extend(taken);
|
||||
all.sort_by(|a, b| keys.get(&a.id).cmp(&keys.get(&b.id)));
|
||||
*ch = all;
|
||||
// The merged layers' guides go to it too.
|
||||
d.rehome_guides(into);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -311,6 +313,8 @@ fn flatten(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for h in &hidden {
|
||||
d.remove(*h)?;
|
||||
}
|
||||
// Hidden layers are discarded with their guides.
|
||||
d.drop_guides_of_deleted_layers(sel);
|
||||
merge_into(d, into, &others)?;
|
||||
sel.prune(d);
|
||||
Ok(())
|
||||
|
||||
@@ -316,6 +316,11 @@ pub(crate) fn checked_ids_param(s: &Session, p: &Value, key: &str, command: &str
|
||||
values.iter().map(|value| checked_id(s, value, command)).collect()
|
||||
}
|
||||
|
||||
/// The objects a command acts on: `ids` when given (every one checked), else the selected objects.
|
||||
pub(crate) fn checked_ids_or_selection(s: &Session, p: &Value, command: &str) -> Result<Vec<NodeId>> {
|
||||
if p.get("ids").is_some() { checked_ids_param(s, p, "ids", command) } else { edit::selected_roots(s) }
|
||||
}
|
||||
|
||||
pub(crate) fn checked_id(s: &Session, value: &Value, command: &str) -> Result<NodeId> {
|
||||
let id = value.as_u64().map(NodeId).ok_or_else(|| bad(command, format!("invalid object id {value}: expected a non-negative integer")))?;
|
||||
if s.doc()?.doc.node(id).is_none() {
|
||||
@@ -335,6 +340,12 @@ pub(crate) fn finite_numbers<const N: usize>(v: &Value) -> Option<[f64; N]> {
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// `v` as a list of point indices: `None` unless it is an array of non-negative integers, so no
|
||||
/// malformed index is dropped or wrapped round.
|
||||
pub(crate) fn point_indices(v: &Value) -> Option<Vec<usize>> {
|
||||
v.as_array()?.iter().map(|i| usize::try_from(i.as_u64()?).ok()).collect()
|
||||
}
|
||||
|
||||
pub(crate) fn point_param(p: &Value, key: &str) -> Option<Point> {
|
||||
let a = p.get(key)?.as_array()?;
|
||||
Some(Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?))
|
||||
|
||||
+179
-23
@@ -251,17 +251,26 @@ pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec<NodeId>, xf
|
||||
let areas = bool_or(p, "typeAreas", false);
|
||||
let mut sc = if Scaling::factor(xf).is_some() { scaling(s, p) } else { Scaling::default() };
|
||||
sc.patterns = transform_patterns(s, p, &ids)?;
|
||||
let ids = s.edit(label, |d, sel| {
|
||||
let targets = if copy { duplicate_in(d, sel, &ids, Affine::IDENTITY)? } else { ids.clone() };
|
||||
for id in &targets {
|
||||
if let Some(n) = d.node_mut(*id)
|
||||
&& !(areas && resize_type_area(n, xf))
|
||||
{
|
||||
n.transform(xf, sc);
|
||||
// A transform of ids that name no object, or an identity transform without a copy, moves
|
||||
// nothing: the document and its undo history stay as they are, and Transform Again repeats it.
|
||||
// With a copy, the reply lists the copies, and there are none.
|
||||
let d = &s.doc()?.doc;
|
||||
let none = ids.iter().all(|id| d.node(*id).is_none());
|
||||
let ids = if none || (!copy && xf == Affine::IDENTITY) {
|
||||
if copy { vec![] } else { ids }
|
||||
} else {
|
||||
s.edit(label, |d, sel| {
|
||||
let targets = if copy { duplicate_in(d, sel, &ids, Affine::IDENTITY)? } else { ids.clone() };
|
||||
for id in &targets {
|
||||
if let Some(n) = d.node_mut(*id)
|
||||
&& !(areas && resize_type_area(n, xf))
|
||||
{
|
||||
n.transform(xf, sc);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(targets)
|
||||
})?;
|
||||
Ok(targets)
|
||||
})?
|
||||
};
|
||||
let st = s.doc_mut()?;
|
||||
if st.interaction.is_none() {
|
||||
st.last_transform = Some((xf, copy));
|
||||
@@ -300,10 +309,19 @@ fn nudge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let dx = f64_or(p, "dx", 0.0) * k;
|
||||
let dy = f64_or(p, "dy", 0.0) * k;
|
||||
if !s.doc()?.selection.anchors.is_empty() {
|
||||
// When the anchors move by 0, the document and its undo history stay as they are.
|
||||
if dx == 0.0 && dy == 0.0 {
|
||||
return ok();
|
||||
}
|
||||
return super::path::move_anchors(s, &json!({ "dx": dx, "dy": dy }));
|
||||
}
|
||||
if s.doc()?.selection.is_empty() {
|
||||
return super::docmenu::guide_move(s, &json!({ "dx": dx, "dy": dy, "copy": bool_or(p, "copy", false) }));
|
||||
let copy = bool_or(p, "copy", false);
|
||||
// When guides move by 0 without a copy, the document and its undo history stay as they are.
|
||||
if dx == 0.0 && dy == 0.0 && !copy {
|
||||
return ok();
|
||||
}
|
||||
return super::docmenu::guide_move(s, &json!({ "dx": dx, "dy": dy, "copy": copy }));
|
||||
}
|
||||
let ids = selected_roots(s)?;
|
||||
apply_transform(s, "Move", ids, Affine::translate((dx, dy)), p)
|
||||
@@ -386,7 +404,9 @@ fn arrange(s: &mut Session, how: Arrange) -> Result<Value> {
|
||||
Arrange::Backward => "Send Backward",
|
||||
Arrange::Back => "Send to Back",
|
||||
};
|
||||
s.edit(label, |d, _| {
|
||||
// When nothing changes order, the document and its undo history stay as they are.
|
||||
super::overprint::edit_counted(s, label, |d| {
|
||||
let mut moved = 0;
|
||||
// Arrange within each parent independently (selections may span layers/groups).
|
||||
let mut by_parent: Vec<(Option<vectorcraft_doc::NodeId>, Vec<vectorcraft_doc::NodeId>)> = vec![];
|
||||
for id in &ids {
|
||||
@@ -411,10 +431,11 @@ fn arrange(s: &mut Session, how: Arrange) -> Result<Value> {
|
||||
};
|
||||
if to != idx {
|
||||
d.move_node(*id, par, to)?;
|
||||
moved += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
Ok(moved)
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
@@ -424,6 +445,11 @@ fn send_to_current_layer(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
// Ids are reused after undo: the remembered current layer must still be a layer.
|
||||
let st = s.doc()?;
|
||||
let layer = st.current_layer().or_else(|| st.doc.default_layer()).ok_or_else(|| EngineError::Other("no current layer".into()))?;
|
||||
// When the selected objects are already the topmost in the current layer, in paint order, the
|
||||
// document and its undo history stay as they are.
|
||||
if st.doc.children(Some(layer)).is_some_and(|c| c.iter().rev().map(|n| n.id).take(ids.len()).eq(ids.iter().rev().copied())) {
|
||||
return ok();
|
||||
}
|
||||
s.edit("Send to Current Layer", |d, _| {
|
||||
for id in &ids {
|
||||
d.move_node(*id, Some(layer), usize::MAX)?;
|
||||
@@ -457,6 +483,11 @@ pub(crate) fn group_nodes(d: &mut Document, ids: &[NodeId]) -> Result<NodeId> {
|
||||
|
||||
fn ungroup(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
// With no group selected, the document, the selection and the undo history stay as they are.
|
||||
let d = &s.doc()?.doc;
|
||||
if !ids.iter().any(|id| d.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Group { .. }))) {
|
||||
return ok();
|
||||
}
|
||||
s.edit("Ungroup", |d, sel| {
|
||||
let mut new_sel = vec![];
|
||||
for id in &ids {
|
||||
@@ -492,6 +523,13 @@ fn ungroup(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
fn lock(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
// With every selected object already locked, the document and its undo history stay as they
|
||||
// are, and the objects are deselected.
|
||||
let d = &s.doc()?.doc;
|
||||
if ids.iter().all(|id| d.node(*id).is_none_or(|n| n.locked)) {
|
||||
s.select(|_, sel| sel.clear())?;
|
||||
return ok();
|
||||
}
|
||||
s.edit("Lock", |d, sel| {
|
||||
for id in &ids {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
@@ -516,6 +554,10 @@ fn collect_flag(d: &Document, f: impl Fn(&Node) -> bool) -> Vec<NodeId> {
|
||||
|
||||
fn unlock_all(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = collect_flag(&s.doc()?.doc, |n| n.locked);
|
||||
// With nothing locked, the document, the selection and the undo history stay as they are.
|
||||
if ids.is_empty() {
|
||||
return Ok(json!({ "count": 0 }));
|
||||
}
|
||||
s.edit("Unlock All", |d, sel| {
|
||||
for id in &ids {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
@@ -530,6 +572,13 @@ fn unlock_all(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
fn hide(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
// With every selected object already hidden, the document and its undo history stay as they
|
||||
// are, and the objects are deselected.
|
||||
let d = &s.doc()?.doc;
|
||||
if ids.iter().all(|id| d.node(*id).is_none_or(|n| !n.visible)) {
|
||||
s.select(|_, sel| sel.clear())?;
|
||||
return ok();
|
||||
}
|
||||
s.edit("Hide", |d, sel| {
|
||||
for id in &ids {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
@@ -544,6 +593,10 @@ fn hide(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
fn show_all(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = collect_flag(&s.doc()?.doc, |n| !n.visible);
|
||||
// With nothing hidden, the document, the selection and the undo history stay as they are.
|
||||
if ids.is_empty() {
|
||||
return Ok(json!({ "count": 0 }));
|
||||
}
|
||||
s.edit("Show All", |d, sel| {
|
||||
for id in &ids {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
@@ -602,6 +655,12 @@ fn compound_make(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
fn compound_release(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
// With no compound path selected, the document, the selection and the undo history stay as
|
||||
// they are.
|
||||
let d = &s.doc()?.doc;
|
||||
if !ids.iter().any(|id| d.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. }))) {
|
||||
return ok();
|
||||
}
|
||||
s.edit("Release Compound Path", |d, sel| {
|
||||
let mut out = vec![];
|
||||
for id in &ids {
|
||||
@@ -678,6 +737,11 @@ fn clip_make(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
fn clip_release(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
// With no clip group selected, the document and its undo history stay as they are.
|
||||
let d = &s.doc()?.doc;
|
||||
if !ids.iter().any(|id| d.node(*id).is_some_and(Node::clips)) {
|
||||
return ok();
|
||||
}
|
||||
s.edit("Release Clipping Mask", |d, _| {
|
||||
for id in &ids {
|
||||
release_clip(d, *id);
|
||||
@@ -785,6 +849,27 @@ fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
// When every object already has every value given, the document and its undo history stay as
|
||||
// they are.
|
||||
let flag = |k: &str| p.get(k).and_then(Value::as_bool);
|
||||
let has_values = |n: &Node| {
|
||||
str_param(p, "name").is_none_or(|v| n.name.as_deref() == Some(v).filter(|v| !v.is_empty()))
|
||||
&& flag("visible").is_none_or(|v| n.visible == v)
|
||||
&& flag("locked").is_none_or(|v| n.locked == v)
|
||||
&& opacity.is_none_or(|v| n.opacity == v)
|
||||
&& blend.is_none_or(|b| n.blend == b)
|
||||
&& flag("isolate").is_none_or(|v| n.isolate == v)
|
||||
&& knockout.is_none_or(|k| n.knockout == k)
|
||||
&& flag("knockoutShape").is_none_or(|v| n.knockout_shape == v)
|
||||
&& data
|
||||
.iter()
|
||||
.flatten()
|
||||
.all(|(k, v)| n.attrs.as_ref().and_then(|a| a.data.iter().find(|(key, _)| key == k)).map(|(_, now)| now.as_str()) == v.as_deref())
|
||||
};
|
||||
let d = &s.doc()?.doc;
|
||||
if ids.iter().all(|id| d.node(*id).is_some_and(has_values)) {
|
||||
return ok();
|
||||
}
|
||||
s.edit("Object Properties", |d, _| {
|
||||
for id in &ids {
|
||||
let n = d.node_mut(*id).ok_or(EngineError::NoNode(*id))?;
|
||||
@@ -864,6 +949,11 @@ fn items_bounds(d: &Document, ids: &[NodeId], preview: bool) -> Vec<(NodeId, Rec
|
||||
ids.iter().filter_map(|id| Some((*id, d.bounds_of(&[*id], preview)?))).collect()
|
||||
}
|
||||
|
||||
/// Is every move in `moves` a rounding error (1e-9 pt or less on both axes)?
|
||||
fn moves_nothing(moves: &[(NodeId, Vec2)]) -> bool {
|
||||
moves.iter().all(|(_, dv)| dv.x.abs() <= 1e-9 && dv.y.abs() <= 1e-9)
|
||||
}
|
||||
|
||||
/// What `object.align` aligns to: `to`, else the key object when there is one, else the
|
||||
/// selection.
|
||||
fn align_to<'a>(s: &Session, p: &'a Value) -> Option<&'a str> {
|
||||
@@ -886,13 +976,22 @@ fn reference_rect(s: &Session, p: &Value, ids: &[NodeId], preview: bool) -> Resu
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the selected root (one of `ids`) that is the key object or contains it, such as the
|
||||
/// compound path or selected group the key is in. Align to the key object and Distribute Spacing
|
||||
/// with a spacing keep it in place.
|
||||
fn key_root(s: &Session, ids: &[NodeId]) -> Option<NodeId> {
|
||||
let st = s.doc().ok()?;
|
||||
let ancestry = st.doc.ancestry(st.selection.key?)?;
|
||||
ids.iter().copied().find(|id| ancestry.contains(id))
|
||||
}
|
||||
|
||||
fn align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
let preview = preview_bounds(s, p, "object.align")?;
|
||||
let r = reference_rect(s, p, &ids, preview)?;
|
||||
let h = str_param(p, "horizontal");
|
||||
let v = str_param(p, "vertical");
|
||||
let key = s.doc()?.selection.key;
|
||||
let key = key_root(s, &ids);
|
||||
let moves: Vec<(NodeId, Vec2)> = {
|
||||
let d = &s.doc()?.doc;
|
||||
items_bounds(d, &ids, preview)
|
||||
@@ -915,6 +1014,10 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
// When nothing moves, the document and its undo history stay as they are.
|
||||
if moves_nothing(&moves) {
|
||||
return ok();
|
||||
}
|
||||
s.edit("Align", |d, _| {
|
||||
for (id, dv) in &moves {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
@@ -965,6 +1068,10 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(*id, if horiz { Vec2::new(delta, 0.0) } else { Vec2::new(0.0, delta) })
|
||||
})
|
||||
.collect();
|
||||
// When nothing moves, the document and its undo history stay as they are.
|
||||
if moves_nothing(&moves) {
|
||||
return ok();
|
||||
}
|
||||
s.edit("Distribute", |d, _| {
|
||||
for (id, dv) in &moves {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
@@ -1002,12 +1109,16 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
deltas.push((*id, pos - start(r)));
|
||||
pos += size(r) + gap;
|
||||
}
|
||||
// With a spacing (aligning to a key object) the key object stays where it is: the others
|
||||
// are spaced from it.
|
||||
let key = s.doc()?.selection.key;
|
||||
// With a spacing (aligning to a key object) the key object stays where it is, with the
|
||||
// compound path or selected group it is in: the others are spaced from it.
|
||||
let key = key_root(s, &ids);
|
||||
let fixed = spacing.and_then(|_| deltas.iter().find(|(id, _)| Some(*id) == key)).map_or(0.0, |k| k.1);
|
||||
let moves: Vec<(NodeId, Vec2)> =
|
||||
deltas.into_iter().map(|(id, d)| (id, if horiz { Vec2::new(d - fixed, 0.0) } else { Vec2::new(0.0, d - fixed) })).collect();
|
||||
// When nothing moves, the document and its undo history stay as they are.
|
||||
if moves_nothing(&moves) {
|
||||
return ok();
|
||||
}
|
||||
s.edit("Distribute Spacing", |d, _| {
|
||||
for (id, dv) in &moves {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
@@ -1057,12 +1168,23 @@ fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let new_rp = scale * gp + (rp - gp) * f(sx, sy);
|
||||
// x, y place the reference point on the page.
|
||||
let page = frame.to_doc() * rp;
|
||||
// When every value given is the current one, the transform is the identity. Computing it can
|
||||
// leave a rounding error in it on a rotated object or with Use Preview Bounds.
|
||||
let current = |k: &str, now: f64| p.get(k).and_then(Value::as_f64).is_none_or(|x| x == now);
|
||||
if current("x", page.x) && current("y", page.y) && current("width", v.width()) && current("height", v.height()) {
|
||||
return apply_transform(s, "Transform", ids, Affine::IDENTITY, p);
|
||||
}
|
||||
let to = frame.to_local(Point::new(f64_or(p, "x", page.x), f64_or(p, "y", page.y)));
|
||||
apply_transform(s, "Transform", ids, frame.conjugate(Affine::translate(to - new_rp) * scale), p)
|
||||
}
|
||||
|
||||
fn expand_shape(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
// With no live shape selected, the document and its undo history stay as they are.
|
||||
let d = &s.doc()?.doc;
|
||||
if !ids.iter().any(|id| d.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Path { live: Some(_), .. }))) {
|
||||
return ok();
|
||||
}
|
||||
s.edit("Expand Shape", |d, _| {
|
||||
for id in &ids {
|
||||
if let Some(NodeKind::Path { live, .. }) = d.node_mut(*id).map(|n| &mut n.kind) {
|
||||
@@ -1138,6 +1260,45 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// A polygon's Polygon Properties: its angle, radius or side length, Make Sides Equal.
|
||||
let (polygon_angle, polygon_radius, side) = (angle("polygonAngle")?, length("polygonRadius")?, length("sideLength")?);
|
||||
let equal = p.get("makeSidesEqual").and_then(Value::as_bool) == Some(true);
|
||||
// An ellipse's pie becomes `pie_of(pie)`. An end of 0 is a full turn (Illustrator shows a whole
|
||||
// ellipse as 0° to 360°).
|
||||
let end_of = |a: f64| if a == 0.0 { 360.0 } else { a };
|
||||
let pie_of = |pie: (f64, f64)| {
|
||||
let pie = (pie_start.unwrap_or(pie.0), pie_end.map_or(pie.1, end_of));
|
||||
if invert { (pie.1.rem_euclid(360.0), end_of(pie.0)) } else { pie }
|
||||
};
|
||||
// When every value given is the current one, the document and its undo history stay as they
|
||||
// are.
|
||||
let st = s.doc()?;
|
||||
let has_values = |id: &NodeId| {
|
||||
let Some(NodeKind::Path { path, live, .. }) = st.doc.node(*id).map(|n| &n.kind) else { return true };
|
||||
match live {
|
||||
Some(l @ LiveShape::Polygon { sides: now, .. })
|
||||
if !(sides.is_none_or(|n| n.clamp(3, 1000) == u64::from(*now))
|
||||
&& (!equal || l.polygon_sides_equal())
|
||||
&& polygon_angle.is_none_or(|a| l.polygon_angle() == Some(a))
|
||||
&& side.and_then(|len| l.polygon_radius_for_side(len)).or(polygon_radius).is_none_or(|r| l.polygon_radius() == Some(r))) =>
|
||||
{
|
||||
return false;
|
||||
}
|
||||
Some(LiveShape::Ellipse { pie, .. }) if pie_of(*pie) != *pie => return false,
|
||||
_ => {}
|
||||
}
|
||||
if radius.is_none() && kind.is_none() {
|
||||
return true;
|
||||
}
|
||||
let Some(c) = LiveCorners::new(path, live.as_ref()) else { return true };
|
||||
let picked = corners_of(*id).unwrap_or_else(|| c.picked(st.selection.anchors.get(id)));
|
||||
// Setting corners folds a scale left in a rectangle's transform into its size, which
|
||||
// changes it.
|
||||
live.as_ref().is_none_or(|l| l.folded() == *l)
|
||||
&& picked
|
||||
.iter()
|
||||
.all(|k| *k < c.base.anchor_count() && radius.is_none_or(|r| c.radius(*k) == r.max(0.0)) && kind.is_none_or(|kd| c.kind(*k) == kd))
|
||||
};
|
||||
if ids.iter().all(has_values) {
|
||||
return ok();
|
||||
}
|
||||
s.edit("Live Shape", |d, sel| {
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Path { path, live, .. }) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
@@ -1163,12 +1324,7 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
&& (pie_start.is_some() || pie_end.is_some() || invert)
|
||||
{
|
||||
if let LiveShape::Ellipse { pie, .. } = l {
|
||||
// An end of 0 is a full turn (Illustrator shows a whole ellipse as 0° to 360°).
|
||||
let end_of = |a: f64| if a == 0.0 { 360.0 } else { a };
|
||||
*pie = (pie_start.unwrap_or(pie.0), pie_end.map_or(pie.1, end_of));
|
||||
if invert {
|
||||
*pie = (pie.1.rem_euclid(360.0), end_of(pie.0));
|
||||
}
|
||||
*pie = pie_of(*pie);
|
||||
}
|
||||
*path = l.to_path();
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Fill",
|
||||
[],
|
||||
None,
|
||||
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray)} sets the selection's fill and the default (new art fits a gradient to itself)",
|
||||
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only",
|
||||
has_doc,
|
||||
|s, p| set_paint(s, p, true)
|
||||
),
|
||||
@@ -375,10 +375,41 @@ fn apply_to_proxy(s: &mut Session, p: &Value, paint: Paint) -> Result<Value> {
|
||||
/// remember the paint.
|
||||
fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result<Value> {
|
||||
let cmd = if fill { "paint.setFill" } else { "paint.setStroke" };
|
||||
// Characters selected with the Type tool take the paint, not the whole type object (#1063).
|
||||
if p.get("ids").is_none()
|
||||
&& p.get("item").is_none()
|
||||
&& let Some((id, a, b)) = super::textedit::editing_range(s)?
|
||||
&& a != b
|
||||
{
|
||||
s.edit(if fill { "Fill Color" } else { "Stroke Color" }, |d, _| {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) };
|
||||
let (xf, lb) = (t.xf, t.local_bounds());
|
||||
vectorcraft_text::edit::style_range(&mut t.runs, a, b, |st| {
|
||||
if fill {
|
||||
st.fill = place_run_paint(&paint, p, xf, lb);
|
||||
} else {
|
||||
if !paint.is_none() {
|
||||
run_stroke_weight(st);
|
||||
}
|
||||
st.stroke = place_run_paint(&paint, p, xf, vectorcraft_doc::appearance::stroke_paint_bounds(lb, st.stroke_width));
|
||||
}
|
||||
});
|
||||
super::typecmd::refresh_bounds(t);
|
||||
Ok(())
|
||||
})?;
|
||||
if bool_or(p, "focus", true) {
|
||||
s.fill_active = fill;
|
||||
}
|
||||
s.remember_paint(&paint);
|
||||
return ok();
|
||||
}
|
||||
// Resolve the requested targets before mutating defaults or the active proxy.
|
||||
let item = item_target(s, p, cmd)?.of_kind(s, fill);
|
||||
let selected = item.targets(s, p)?;
|
||||
let ids = super::graph::paint_targets(&s.doc()?.doc, &selected);
|
||||
// The gradient mesh point last clicked with the Mesh tool or Direct Selection: a solid fill
|
||||
// recolours just that point of its mesh.
|
||||
let mesh_point = if fill && matches!(paint, Paint::Solid { .. }) { focused_mesh_point(s) } else { None };
|
||||
let label = if fill { "Fill Color" } else { "Stroke Color" };
|
||||
edit_items_then(
|
||||
s,
|
||||
@@ -388,6 +419,15 @@ fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result<V
|
||||
label,
|
||||
fill,
|
||||
|n, index| {
|
||||
if index.is_none()
|
||||
&& let Paint::Solid { color, .. } = &paint
|
||||
&& let Some((_, i)) = mesh_point.filter(|(id, _)| *id == n.id)
|
||||
&& let NodeKind::Mesh(m) = &mut n.kind
|
||||
&& let Some(q) = m.points.get_mut(i)
|
||||
{
|
||||
q.color = *color;
|
||||
return Ok(());
|
||||
}
|
||||
if index.is_none()
|
||||
&& let NodeKind::Text(t) = &mut n.kind
|
||||
{
|
||||
@@ -429,6 +469,14 @@ fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result<V
|
||||
ok()
|
||||
}
|
||||
|
||||
/// The mesh point the active tool has focused (its `meshPoint` option: the Mesh tool's or Direct
|
||||
/// Selection's point last clicked).
|
||||
fn focused_mesh_point(s: &Session) -> Option<(NodeId, usize)> {
|
||||
let o = s.tool_options();
|
||||
let pt = o.get("meshPoint")?;
|
||||
Some((NodeId(pt.get("id")?.as_u64()?), usize::try_from(pt.get("index")?.as_u64()?).ok()?))
|
||||
}
|
||||
|
||||
/// A type run's stroke; a painted stroke on a run without one gets 1 pt.
|
||||
fn set_run_stroke(style: &mut CharStyle, paint: Paint) {
|
||||
if !paint.is_none() {
|
||||
|
||||
@@ -43,7 +43,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 (±1296), hyphenate?: bool, mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
|
||||
"{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 (±1296), hyphenate?: bool, mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths, `palt`; horizontal type), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
|
||||
has_doc,
|
||||
set_format
|
||||
),
|
||||
@@ -185,14 +185,7 @@ const MAX_PARA: f64 = 1296.0;
|
||||
|
||||
fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.setFormat";
|
||||
let ids: Vec<NodeId> = {
|
||||
let ids = targets(s, p)?;
|
||||
let d = &s.doc()?.doc;
|
||||
ids.into_iter().filter(|id| matches!(d.node(*id).map(|n| &n.kind), Some(NodeKind::Text(_)))).collect()
|
||||
};
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "no text objects selected"));
|
||||
}
|
||||
let ids = super::typecmd::text_targets(s, p, C)?;
|
||||
let num = |k: &str| p.get(k).and_then(Value::as_f64);
|
||||
let flag = |k: &str| p.get(k).and_then(Value::as_bool);
|
||||
let kerning = match p.get("kerning") {
|
||||
@@ -222,6 +215,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"direction",
|
||||
"leadingModel",
|
||||
"charAlign",
|
||||
"proportionalMetrics",
|
||||
"burasagari",
|
||||
"composer",
|
||||
];
|
||||
@@ -285,15 +279,17 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.edit("Character", |d, _| {
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
let baseline_shift = num("baselineShift").map(|v| super::typecmd::local_type_value(t, v, (-1296.0, 1296.0), false, C)).transpose()?;
|
||||
let h_scale = num("hScale").map(|v| super::typecmd::local_type_value(t, v, (1.0, 10000.0), true, C)).transpose()?;
|
||||
super::typecmd::style_chars(t, range, |st| {
|
||||
if let Some(k) = kerning {
|
||||
st.kerning = k;
|
||||
}
|
||||
if let Some(v) = num("baselineShift") {
|
||||
st.baseline_shift = v.clamp(-1296.0, 1296.0);
|
||||
if let Some(v) = baseline_shift {
|
||||
st.baseline_shift = v;
|
||||
}
|
||||
if let Some(v) = num("hScale") {
|
||||
st.h_scale = v.clamp(1.0, 10000.0);
|
||||
if let Some(v) = h_scale {
|
||||
st.h_scale = v;
|
||||
}
|
||||
if let Some(v) = num("vScale") {
|
||||
st.v_scale = v.clamp(1.0, 10000.0);
|
||||
@@ -319,6 +315,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(v) = char_align {
|
||||
st.char_align = v;
|
||||
}
|
||||
if let Some(v) = flag("proportionalMetrics") {
|
||||
st.proportional_metrics = v;
|
||||
}
|
||||
});
|
||||
let span = super::typecmd::para_span(range, t);
|
||||
t.edit_paras(span, |para| {
|
||||
|
||||
@@ -500,7 +500,7 @@ pub(crate) fn refresh(before: &Document, d: &mut Document) -> Result<()> {
|
||||
fn candidates(s: &Session, p: &Value, points: &[Point], tolerance: f64) -> Result<Vec<NodeId>> {
|
||||
let st = s.doc()?;
|
||||
let area = points.iter().fold(Rect::new(f64::MAX, f64::MAX, f64::MIN, f64::MIN), |b, p| b.union_pt(*p)).inflate(tolerance, tolerance);
|
||||
let supplied = p.get("ids").and_then(Value::as_array).map(|v| v.iter().filter_map(Value::as_u64).map(NodeId).collect::<Vec<_>>());
|
||||
let supplied = p.get("ids").map(|_| checked_ids_param(s, p, "ids", "shaper.scribble")).transpose()?;
|
||||
let expand = supplied.is_none() && st.selection.objects.len() < 2;
|
||||
let ids = supplied.unwrap_or_else(|| if st.selection.objects.len() > 1 { st.selection.objects.clone() } else { st.doc.selectable_art() });
|
||||
let eligible: Vec<NodeId> = ids
|
||||
|
||||
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Character",
|
||||
[],
|
||||
None,
|
||||
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\"} style a character range (runs are split at the range ends) → {id, runs}",
|
||||
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths, `palt`; horizontal type)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs}",
|
||||
has_doc,
|
||||
set_range_style
|
||||
),
|
||||
@@ -36,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Get Text Range",
|
||||
[],
|
||||
None,
|
||||
"{id, start?, end?} → {text, runs: [{text, style}], length}",
|
||||
"{id, start?, end?, effective?: bool = false} → {text, runs: [{text, style}], length}; effective uses document-point size/leading/baseline shift and transformed horizontal scale; default returns stored local styles for rich-text copying",
|
||||
has_doc,
|
||||
get_range
|
||||
),
|
||||
@@ -174,7 +174,12 @@ fn get_range(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let t = text_ref(s, id)?;
|
||||
let len = edit::runs_len(&t.runs);
|
||||
let (a, b) = range_of(p, len);
|
||||
let runs = edit::slice_runs(&t.runs, a, b);
|
||||
let mut runs = edit::slice_runs(&t.runs, a, b);
|
||||
if p.get("effective").and_then(Value::as_bool).unwrap_or(false) {
|
||||
for run in &mut runs {
|
||||
run.style = t.effective_char_style(&run.style).ok_or_else(|| bad("text.getRange", "text transform is collapsed or unrepresentable"))?;
|
||||
}
|
||||
}
|
||||
let text: String = runs.iter().map(|r| r.text.as_str()).collect();
|
||||
Ok(json!({"text": text, "runs": runs, "length": len}))
|
||||
}
|
||||
@@ -203,6 +208,7 @@ pub(crate) struct CharChange {
|
||||
position: Option<vectorcraft_doc::CharPosition>,
|
||||
small_caps: Option<Option<f64>>,
|
||||
char_align: Option<vectorcraft_doc::CharAlign>,
|
||||
proportional_metrics: Option<bool>,
|
||||
}
|
||||
|
||||
/// `features: ["dlig", "-liga", …]` → the canonical tag list (differences from the defaults).
|
||||
@@ -286,6 +292,7 @@ impl CharChange {
|
||||
position,
|
||||
small_caps,
|
||||
char_align: char_align_param(p, cmd)?,
|
||||
proportional_metrics: flag("proportionalMetrics"),
|
||||
};
|
||||
if c.size.is_some_and(|v| v <= 0.0) {
|
||||
return Err(bad(cmd, "size must be positive"));
|
||||
@@ -314,6 +321,16 @@ impl CharChange {
|
||||
&& self.position.is_none()
|
||||
&& self.small_caps.is_none()
|
||||
&& self.char_align.is_none()
|
||||
&& self.proportional_metrics.is_none()
|
||||
}
|
||||
|
||||
fn localize(&mut self, t: &TextObject, cmd: &str) -> Result<()> {
|
||||
use super::typecmd::local_type_value;
|
||||
self.size = self.size.map(|v| local_type_value(t, v, (0.1, 1296.0), false, cmd)).transpose()?;
|
||||
self.leading = self.leading.map(|v| v.map(|v| local_type_value(t, v, (0.1, 5000.0), false, cmd)).transpose()).transpose()?;
|
||||
self.baseline_shift = self.baseline_shift.map(|v| local_type_value(t, v, (-1296.0, 1296.0), false, cmd)).transpose()?;
|
||||
self.h_scale = self.h_scale.map(|v| local_type_value(t, v, (1.0, 10000.0), true, cmd)).transpose()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn apply(&self, st: &mut CharStyle) {
|
||||
@@ -338,10 +355,10 @@ impl CharChange {
|
||||
st.font_version = None;
|
||||
}
|
||||
if let Some(v) = self.size {
|
||||
st.size = v.clamp(0.1, 1296.0);
|
||||
st.size = v;
|
||||
}
|
||||
if let Some(v) = self.leading {
|
||||
st.leading = v.map(|l| l.clamp(0.1, 5000.0));
|
||||
st.leading = v;
|
||||
}
|
||||
if let Some(v) = self.tracking {
|
||||
st.tracking = v.clamp(-1000.0, 10000.0);
|
||||
@@ -350,10 +367,10 @@ impl CharChange {
|
||||
st.kerning = v.map(|k| k.clamp(-1000.0, 10000.0));
|
||||
}
|
||||
if let Some(v) = self.baseline_shift {
|
||||
st.baseline_shift = v.clamp(-1296.0, 1296.0);
|
||||
st.baseline_shift = v;
|
||||
}
|
||||
if let Some(v) = self.h_scale {
|
||||
st.h_scale = v.clamp(1.0, 10000.0);
|
||||
st.h_scale = v;
|
||||
}
|
||||
if let Some(v) = self.v_scale {
|
||||
st.v_scale = v.clamp(1.0, 10000.0);
|
||||
@@ -391,6 +408,9 @@ impl CharChange {
|
||||
if let Some(v) = self.char_align {
|
||||
st.char_align = v;
|
||||
}
|
||||
if let Some(v) = self.proportional_metrics {
|
||||
st.proportional_metrics = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -445,12 +465,13 @@ fn set_range_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => return Err(bad(C, "missing `id`: give one, or select one type object")),
|
||||
},
|
||||
};
|
||||
let change = CharChange::parse(p, C, &s.doc()?.doc.setup)?;
|
||||
let mut change = CharChange::parse(p, C, &s.doc()?.doc.setup)?;
|
||||
if change.is_empty() {
|
||||
return Err(bad(C, "nothing to change"));
|
||||
}
|
||||
let protect = s.prefs.missing_glyph_protection && (change.font.is_some() || change.style.is_some());
|
||||
let t = text_ref(s, id)?;
|
||||
change.localize(&t, C)?;
|
||||
let (a, b) = range_of(p, edit::runs_len(&t.runs));
|
||||
let n = s.edit("Character", |d, _| {
|
||||
let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?;
|
||||
@@ -542,7 +563,7 @@ enum Step {
|
||||
}
|
||||
|
||||
/// The Type tool's selected text while it edits (its typing session ends first: one undo step).
|
||||
fn editing_range(s: &mut Session) -> Result<Option<(NodeId, usize, usize)>> {
|
||||
pub(crate) fn editing_range(s: &mut Session) -> Result<Option<(NodeId, usize, usize)>> {
|
||||
let o = s.tool_options();
|
||||
let Some(id) = o.get("editing").and_then(Value::as_u64).map(NodeId) else { return Ok(None) };
|
||||
if super::edit::typing_in_progress(s) {
|
||||
@@ -605,13 +626,27 @@ fn type_step(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.edit("Character", |d, _| {
|
||||
for &(id, a, b) in &ranges {
|
||||
let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?;
|
||||
edit::style_range(&mut t.runs, a, b, |st| match step {
|
||||
Step::Size => st.size = (st.size + inc).clamp(0.1, 1296.0),
|
||||
Step::Leading => st.leading = Some((st.effective_leading() + inc).clamp(0.1, 5000.0)),
|
||||
Step::Tracking => st.tracking = (st.tracking + inc).clamp(-1000.0, 10000.0),
|
||||
Step::Kerning => st.kerning = Some((st.kerning.unwrap_or(0.0) + inc).clamp(-1000.0, 10000.0)),
|
||||
Step::BaselineShift => st.baseline_shift = (st.baseline_shift + inc).clamp(-1296.0, 1296.0),
|
||||
});
|
||||
let points = if matches!(step, Step::Size | Step::Leading | Step::BaselineShift) {
|
||||
t.style_scale().ok_or_else(|| bad(C, "text transform is collapsed or unrepresentable"))?.points
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let mut runs = edit::slice_runs(&t.runs, a, b);
|
||||
for run in &mut runs {
|
||||
let st = &mut run.style;
|
||||
match step {
|
||||
Step::Size => st.size = super::typecmd::local_type_value(t, st.size * points + inc, (0.1, 1296.0), false, C)?,
|
||||
Step::Leading => {
|
||||
st.leading = Some(super::typecmd::local_type_value(t, st.effective_leading() * points + inc, (0.1, 5000.0), false, C)?);
|
||||
}
|
||||
Step::Tracking => st.tracking = (st.tracking + inc).clamp(-1000.0, 10000.0),
|
||||
Step::Kerning => st.kerning = Some((st.kerning.unwrap_or(0.0) + inc).clamp(-1000.0, 10000.0)),
|
||||
Step::BaselineShift => {
|
||||
st.baseline_shift = super::typecmd::local_type_value(t, st.baseline_shift * points + inc, (-1296.0, 1296.0), false, C)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
edit::replace_range_styled(&mut t.runs, a, b, &runs);
|
||||
refresh_bounds(t);
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -8,7 +8,6 @@ use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind, TextKind, TextWrap, WrapShape};
|
||||
use vectorcraft_geom::{PathData, shapes};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::typecmd::refresh_bounds;
|
||||
use super::*;
|
||||
|
||||
@@ -36,19 +35,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
fn targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
|
||||
match p.get("ids").and_then(Value::as_array) {
|
||||
Some(a) => Ok(a.iter().filter_map(Value::as_u64).map(NodeId).collect()),
|
||||
None => selected_roots(s),
|
||||
}
|
||||
}
|
||||
|
||||
fn wrap_param(p: &Value, base: TextWrap) -> TextWrap {
|
||||
TextWrap { offset: f64_or(p, "offset", base.offset).clamp(-1000.0, 1000.0), invert: bool_or(p, "invert", base.invert) }
|
||||
}
|
||||
|
||||
fn make(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
let ids = checked_ids_or_selection(s, p, "object.textWrap.make")?;
|
||||
let w = wrap_param(p, TextWrap::default());
|
||||
s.edit("Make Text Wrap", |d, _| {
|
||||
for id in &ids {
|
||||
@@ -66,7 +58,7 @@ fn make(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn release(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids: Vec<NodeId> = {
|
||||
let d = &s.doc()?.doc;
|
||||
targets(s, p)?.into_iter().filter(|i| d.node(*i).is_some_and(|n| n.wrap.is_some())).collect()
|
||||
checked_ids_or_selection(s, p, "object.textWrap.release")?.into_iter().filter(|i| d.node(*i).is_some_and(|n| n.wrap.is_some())).collect()
|
||||
};
|
||||
if ids.is_empty() {
|
||||
return Err(EngineError::Other("Text Wrap: select a wrap object".into()));
|
||||
@@ -83,7 +75,7 @@ fn release(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
let ids = checked_ids_or_selection(s, p, "object.textWrap.options")?;
|
||||
let current = {
|
||||
let d = &s.doc()?.doc;
|
||||
ids.iter().find_map(|i| d.node(*i).and_then(|n| n.wrap)).unwrap_or_default()
|
||||
@@ -212,6 +204,35 @@ mod tests {
|
||||
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).lines.iter().map(|l| l.avail).collect()
|
||||
}
|
||||
|
||||
/// Invalid explicit ids must not turn a requested multi-object edit into a partial one.
|
||||
#[test]
|
||||
fn text_wrap_commands_reject_malformed_target_lists_without_editing() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap();
|
||||
let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 80, "height": 80})).unwrap()["id"].as_u64().unwrap();
|
||||
let other = s.execute("shape.rectangle", &json!({"x": 120, "y": 10, "width": 80, "height": 80})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
|
||||
let before = s.doc().unwrap().history.undo.len();
|
||||
for command in ["object.textWrap.make", "object.textWrap.release", "object.textWrap.options"] {
|
||||
for ids in [json!([id, "invalid"]), json!([id, 99999]), json!({"not": "an array"})] {
|
||||
let e = s.execute(command, &json!({"ids": ids, "offset": 12})).unwrap_err().to_string();
|
||||
assert!(e.contains("id") || e.contains("array"), "{command}: {e}");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), before, "{command} added history on error");
|
||||
assert!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().wrap.is_none());
|
||||
assert!(s.doc().unwrap().doc.node(NodeId(other)).unwrap().wrap.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
s.execute("object.textWrap.make", &json!({"ids": [id]})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().wrap.is_some());
|
||||
assert!(s.doc().unwrap().doc.node(NodeId(other)).unwrap().wrap.is_none());
|
||||
s.execute("object.textWrap.options", &json!({"ids": [id], "offset": 12})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().wrap.unwrap().offset, 12.0);
|
||||
s.execute("object.textWrap.release", &json!({"ids": [id]})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().wrap.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn area_type_wraps_around_objects_above_and_follows_edits() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -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?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, features?: [\"dlig\", \"-liga\", …] OpenType, start?: byte, end?: byte} (with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text)",
|
||||
"{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, start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text)",
|
||||
has_doc,
|
||||
set_style
|
||||
),
|
||||
@@ -289,6 +289,22 @@ pub(crate) fn text_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<Node
|
||||
Ok(t)
|
||||
}
|
||||
|
||||
/// Clamp in the units shown to the user, then compensate the text's affine. Clamping
|
||||
/// the local result would make small sizes impossible after enlarging an object.
|
||||
pub(crate) fn local_type_value(t: &TextObject, value: f64, limits: (f64, f64), horizontal: bool, cmd: &str) -> Result<f64> {
|
||||
if !value.is_finite() {
|
||||
return Err(bad(cmd, "type dimension must be finite"));
|
||||
}
|
||||
let scale = t.style_scale().ok_or_else(|| bad(cmd, "text transform is collapsed or unrepresentable"))?;
|
||||
let local = value.clamp(limits.0, limits.1) / if horizontal { scale.horizontal } else { scale.points };
|
||||
// Bound local dimensions too: an extreme inverse scale can produce a finite size
|
||||
// whose leading, glyph advances or layout bounds overflow before the edit's geometry check.
|
||||
if !local.is_finite() || local.abs() > crate::MAX_COORD || (value != 0.0 && local == 0.0) {
|
||||
return Err(bad(cmd, "type dimension cannot be represented with this text transform"));
|
||||
}
|
||||
Ok(local)
|
||||
}
|
||||
|
||||
/// Optional `start`/`end` byte offsets of a text command: the character range it styles and the
|
||||
/// paragraphs it touches. Without either, the command applies to all the text.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -415,6 +431,8 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.edit("Character", |d, _| {
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
let size = size.map(|v| local_type_value(t, v, (0.1, 1296.0), false, C)).transpose()?;
|
||||
let leading = leading.map(|v| v.map(|v| local_type_value(t, v, (0.1, 5000.0), false, C)).transpose()).transpose()?;
|
||||
let before = protect.then(|| t.runs.clone());
|
||||
style_chars(t, range, |st| {
|
||||
if let Some(f) = &font {
|
||||
@@ -428,7 +446,7 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
st.font_version = None;
|
||||
}
|
||||
if let Some(v) = size {
|
||||
st.size = v.clamp(0.1, 1296.0);
|
||||
st.size = v;
|
||||
}
|
||||
if let Some(l) = leading {
|
||||
st.leading = l;
|
||||
@@ -841,6 +859,34 @@ mod area_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Proportional Metrics (#966): a character attribute, saved only when on.
|
||||
#[test]
|
||||
fn proportional_metrics_is_a_character_attribute_saved_only_when_on() {
|
||||
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 runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => t.runs.clone(),
|
||||
_ => panic!("text"),
|
||||
};
|
||||
assert!(!runs(&s)[0].style.proportional_metrics);
|
||||
assert!(serde_json::to_value(&runs(&s)[0].style).unwrap().get("proportionalMetrics").is_none());
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"proportionalMetrics": true})).unwrap();
|
||||
assert!(runs(&s).iter().all(|r| r.style.proportional_metrics));
|
||||
assert_eq!(serde_json::to_value(&runs(&s)[0].style).unwrap()["proportionalMetrics"], true);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(!runs(&s)[0].style.proportional_metrics);
|
||||
// Characters selected with the Type tool: only the range takes it (雅 is 3 bytes).
|
||||
s.execute("text.setRangeStyle", &json!({"id": id, "start": 3, "end": 6, "proportionalMetrics": true})).unwrap();
|
||||
let on: Vec<_> = runs(&s).iter().map(|r| (r.text.clone(), r.style.proportional_metrics)).collect();
|
||||
assert_eq!(on, [("雅".to_string(), false), ("楽".to_string(), true)]);
|
||||
// Styles written without it (documents from before it) read as off.
|
||||
let old = serde_json::to_value(&runs(&s)[0].style).unwrap();
|
||||
assert!(old.get("proportionalMetrics").is_none());
|
||||
assert!(!serde_json::from_value::<vectorcraft_doc::CharStyle>(old).unwrap().proportional_metrics);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_type_is_composed_with_line_end_half_width_punctuation() {
|
||||
use vectorcraft_doc::Mojikumi;
|
||||
|
||||
@@ -49,11 +49,6 @@ fn names_param(p: &Value, cmd: &str, fallback: Option<String>, nothing: &str) ->
|
||||
}
|
||||
}
|
||||
|
||||
/// The objects a bind acts on: `ids` when given (every one checked), else the selection.
|
||||
fn target_ids(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
|
||||
if p.get("ids").is_some() { checked_ids_param(s, p, "ids", cmd) } else { selected_roots(s) }
|
||||
}
|
||||
|
||||
/// Refuse what a variable of `kind` can't drive: art that is locked or on a hidden or locked
|
||||
/// layer is not the document's to rewrite, and a text variable only drives type.
|
||||
fn check_bindable(doc: &vectorcraft_doc::Document, ids: &[NodeId], kind: VariableKind, cmd: &str) -> Result<()> {
|
||||
@@ -203,7 +198,7 @@ fn list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
fn bind(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "variable.bind";
|
||||
let name = named(str_param(p, "variable"), "variable", C)?;
|
||||
let ids = target_ids(s, p, C)?;
|
||||
let ids = checked_ids_or_selection(s, p, C)?;
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "nothing to bind"));
|
||||
}
|
||||
@@ -218,7 +213,7 @@ fn bind(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn unbind(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "variable.unbind";
|
||||
let ids = target_ids(s, p, C)?;
|
||||
let ids = checked_ids_or_selection(s, p, C)?;
|
||||
let name = defined_variable(s, p, C)?;
|
||||
let n = s.edit("Unbind Variable", |d, _| {
|
||||
let mut n = 0;
|
||||
|
||||
@@ -55,12 +55,16 @@ fn list_views(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
fn new_view(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "view.saved.new";
|
||||
let center = p.get("center").and_then(Value::as_array).and_then(|a| Some(Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?)));
|
||||
let center = center.ok_or_else(|| bad(C, "missing center [x, y]"))?;
|
||||
let [x, y] = p.get("center").and_then(finite_numbers::<2>).ok_or_else(|| bad(C, "center must be [x, y]"))?;
|
||||
let zoom = f64_req(p, "zoom", C)?;
|
||||
if !(zoom.is_finite() && zoom > 0.0) {
|
||||
return Err(bad(C, "zoom must be positive"));
|
||||
}
|
||||
// Given, the rotation is a number: nothing malformed falls back to 0°.
|
||||
let rotation = match p.get("rotation") {
|
||||
Some(r) => r.as_f64().filter(|r| r.is_finite()).ok_or_else(|| bad(C, "rotation must be a number of degrees"))?,
|
||||
None => 0.0,
|
||||
};
|
||||
let st = s.doc()?;
|
||||
if st.doc.views.len() >= MAX_VIEWS {
|
||||
return Err(bad(C, format!("a document keeps at most {MAX_VIEWS} views")));
|
||||
@@ -72,7 +76,7 @@ fn new_view(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if st.doc.views.iter().any(|v| v.name == name) {
|
||||
return Err(bad(C, format!("a view named `{name}` exists")));
|
||||
}
|
||||
let view = SavedView { name: name.clone(), center, zoom, rotation: f64_or(p, "rotation", 0.0) };
|
||||
let view = SavedView { name: name.clone(), center: Point::new(x, y), zoom, rotation };
|
||||
let index = s.edit("New View", |d, _| {
|
||||
d.views.push(view);
|
||||
Ok(d.views.len() - 1)
|
||||
@@ -108,6 +112,29 @@ mod tests {
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// Malformed dimensions are rejected rather than saving an arbitrary or partial viewport.
|
||||
#[test]
|
||||
fn saved_views_require_exact_finite_coordinates_and_rotation() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let before = s.doc().unwrap().history.undo.len();
|
||||
for params in [
|
||||
json!({"center": [10], "zoom": 2}),
|
||||
json!({"center": [10, 20, 30], "zoom": 2}),
|
||||
json!({"center": [10, "bad"], "zoom": 2}),
|
||||
json!({"center": [10, 20], "zoom": 2, "rotation": "bad"}),
|
||||
json!({"center": [10, 20], "zoom": 2, "rotation": null}),
|
||||
] {
|
||||
assert!(s.execute("view.saved.new", ¶ms).is_err(), "accepted {params}");
|
||||
assert!(s.doc().unwrap().doc.views.is_empty());
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), before);
|
||||
}
|
||||
s.execute("view.saved.new", &json!({"center": [10, 20], "zoom": 2, "rotation": -15})).unwrap();
|
||||
let views = &s.doc().unwrap().doc.views;
|
||||
assert_eq!(views.len(), 1);
|
||||
assert_eq!(views[0].rotation, -15.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_rename_delete_views() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -1041,6 +1041,8 @@ impl Session {
|
||||
/// Add a document and make it active.
|
||||
pub fn add_document(&mut self, mut doc: Document, path: Option<String>) -> usize {
|
||||
Self::refresh_text_bounds(&mut doc);
|
||||
// Guides from files saved before guides had layers go on a layer.
|
||||
doc.adopt_guides();
|
||||
self.reset_tool_for_doc_switch();
|
||||
let mut st = DocState::new(doc, path);
|
||||
st.history.limit = self.prefs.history_states as usize;
|
||||
@@ -1056,6 +1058,7 @@ impl Session {
|
||||
return false;
|
||||
}
|
||||
Self::refresh_text_bounds(&mut doc);
|
||||
doc.adopt_guides();
|
||||
if self.active == Some(index) {
|
||||
// Pending tool work (typing, a drag) belongs to the content being thrown away.
|
||||
self.reset_tool_for_doc_switch();
|
||||
@@ -1321,6 +1324,11 @@ impl Session {
|
||||
{
|
||||
let st = self.doc_mut()?;
|
||||
let Some(it) = &st.interaction else { return Err(EngineError::Other("no interaction in progress".into())) };
|
||||
// Undoing the previous preview counts as a change, so views redraw even when the command
|
||||
// below returns without an edit.
|
||||
if !Arc::ptr_eq(&st.doc, &it.doc) || st.selection != it.selection {
|
||||
st.revision += 1;
|
||||
}
|
||||
st.doc = it.doc.clone();
|
||||
st.selection = it.selection.clone();
|
||||
}
|
||||
@@ -1629,6 +1637,8 @@ mod tests_recolor;
|
||||
#[cfg(test)]
|
||||
mod tests_recovery;
|
||||
#[cfg(test)]
|
||||
mod tests_reflecthit;
|
||||
#[cfg(test)]
|
||||
mod tests_registration;
|
||||
#[cfg(test)]
|
||||
mod tests_save;
|
||||
@@ -1681,6 +1691,8 @@ mod tests_transparencygrid;
|
||||
#[cfg(test)]
|
||||
mod tests_typearea;
|
||||
#[cfg(test)]
|
||||
mod tests_typescale;
|
||||
#[cfg(test)]
|
||||
mod tests_units;
|
||||
#[cfg(test)]
|
||||
mod tests_variables;
|
||||
|
||||
@@ -52,6 +52,67 @@ fn group_ungroup() {
|
||||
assert_eq!(s.doc().unwrap().selection.len(), 2);
|
||||
}
|
||||
|
||||
/// Makes 10 pt squares at (0, 90) and (50, 100) into a compound path or group with the commands in
|
||||
/// `make`, each run on the selection (the last one makes `outer`), draws a 10 pt square `r` at
|
||||
/// (200, 0), selects the second square and `r`, and sets the selection's `key` to that square
|
||||
/// → (session, key, outer, r).
|
||||
fn key_inside(make: &[&str]) -> (Session, NodeId, NodeId, NodeId) {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 90.0, 10.0, 10.0);
|
||||
let key = rect(&mut s, 50.0, 100.0, 10.0, 10.0);
|
||||
s.execute("select.set", &json!({"ids": [a.0, key.0]})).unwrap();
|
||||
let mut outer = key;
|
||||
for cmd in make {
|
||||
outer = NodeId(s.execute(cmd, &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
}
|
||||
let r = rect(&mut s, 200.0, 0.0, 10.0, 10.0);
|
||||
s.execute("select.set", &json!({"ids": [key.0, r.0]})).unwrap();
|
||||
s.select(|_, sel| sel.key = Some(key)).unwrap();
|
||||
(s, key, outer, r)
|
||||
}
|
||||
|
||||
/// Aligns the selection left to its key object and, after Undo, spaces it 5 pt apart vertically
|
||||
/// → the top-left corners of `outer` and `r` after each.
|
||||
fn aligned_then_spaced(s: &mut Session, outer: NodeId, r: NodeId) -> [((f64, f64), (f64, f64)); 2] {
|
||||
let at = |s: &Session, id| {
|
||||
let b = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap();
|
||||
(b.x0, b.y0)
|
||||
};
|
||||
s.execute("object.align", &json!({"horizontal": "left", "bounds": "geometric"})).unwrap();
|
||||
let aligned = (at(s, outer), at(s, r));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("object.distributeSpacing", &json!({"axis": "vertical", "spacing": 5, "bounds": "geometric"})).unwrap();
|
||||
[aligned, (at(s, outer), at(s, r))]
|
||||
}
|
||||
|
||||
/// Align and Distribute Spacing move a compound path as one object. With one of its members as the
|
||||
/// key object, the compound path stays where it is: Align moves the others to the member's edge,
|
||||
/// and Distribute Spacing spaces them from the compound path.
|
||||
#[test]
|
||||
fn a_compound_path_that_contains_the_key_object_stays_in_place() {
|
||||
let (mut s, key, outer, r) = key_inside(&["object.compoundPath.make"]);
|
||||
assert_eq!(s.doc().unwrap().selection.key, Some(key));
|
||||
let [aligned, spaced] = aligned_then_spaced(&mut s, outer, r);
|
||||
assert_eq!(aligned, ((0.0, 90.0), (50.0, 0.0)), "it stays, the other moved to the key");
|
||||
assert_eq!(spaced, ((0.0, 90.0), (200.0, 75.0)), "5 pt apart, it stays");
|
||||
}
|
||||
|
||||
/// The key object stays the key when the group that contains it is added to the selection, by
|
||||
/// `select.add` or by the Group Selection tool's click on a selected member. Align and Distribute
|
||||
/// Spacing move that group as one object, and the group stays where it is, also when the key is in
|
||||
/// a group inside it.
|
||||
#[test]
|
||||
fn a_selected_group_that_contains_the_key_object_stays_in_place() {
|
||||
for make in [&["object.group"][..], &["object.group", "object.group"]] {
|
||||
let (mut s, key, outer, r) = key_inside(make);
|
||||
s.execute("select.add", &json!({"ids": [outer.0]})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.key, Some(key), "{make:?}");
|
||||
let [aligned, spaced] = aligned_then_spaced(&mut s, outer, r);
|
||||
assert_eq!(aligned, ((0.0, 90.0), (50.0, 0.0)), "{make:?}: it stays, the other moved to the key");
|
||||
assert_eq!(spaced, ((0.0, 90.0), (200.0, 75.0)), "{make:?}: 5 pt apart, it stays");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrange_order() {
|
||||
let mut s = session();
|
||||
@@ -330,6 +391,426 @@ fn selection_tool_drag_is_one_undo_step() {
|
||||
assert_eq!(s.doc().unwrap().doc.node(a).unwrap().geometric_bounds().unwrap().x0, 100.0);
|
||||
}
|
||||
|
||||
/// How many steps there are to undo and to redo, and whether the document has unsaved changes.
|
||||
fn history(s: &Session) -> (usize, usize, bool) {
|
||||
let st = s.doc().unwrap();
|
||||
(st.history.undo.len(), st.history.redo.len(), st.is_dirty())
|
||||
}
|
||||
|
||||
/// Select `ids` with a move of them left to redo, and mark the document saved.
|
||||
fn select_with_a_step_to_redo(s: &mut Session, ids: &[NodeId]) {
|
||||
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
|
||||
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.doc_mut().unwrap().mark_saved();
|
||||
}
|
||||
|
||||
/// An Align that moves nothing (one object on the artboard's left edge, a pair already aligned, a
|
||||
/// pair that an earlier Align left a rounding error apart) records no undo step: the step to redo
|
||||
/// stays, the document stays unmodified, and the journal, which the Actions panel records from,
|
||||
/// still has the command.
|
||||
#[test]
|
||||
fn an_align_that_moves_nothing_records_no_undo_step() {
|
||||
let mut s = session();
|
||||
let x0 = |s: &Session, id| s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0;
|
||||
let left = json!({"horizontal": "left"});
|
||||
let a = rect(&mut s, 0.0, 50.0, 40.0, 40.0);
|
||||
select_with_a_step_to_redo(&mut s, &[a]);
|
||||
let before = history(&s);
|
||||
assert_eq!((before.1, before.2), (1, false), "a step to redo, and nothing unsaved");
|
||||
// Object › Align sends no `to`, and the Align panel aligns one object to the artboard.
|
||||
for p in [left.clone(), json!({"horizontal": "left", "to": "artboard"})] {
|
||||
s.execute("object.align", &p).unwrap();
|
||||
assert_eq!(history(&s), before, "one object and no key: {p}");
|
||||
assert_eq!(s.journal.last().unwrap(), &("object.align".to_string(), p));
|
||||
}
|
||||
let b = rect(&mut s, 0.0, 200.0, 80.0, 20.0);
|
||||
select_with_a_step_to_redo(&mut s, &[a, b]);
|
||||
let before = history(&s);
|
||||
s.execute("object.align", &left).unwrap();
|
||||
assert_eq!(history(&s), before, "a pair already aligned");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!((x0(&s, a), x0(&s, b)), (5.0, 5.0), "Redo brings back the move");
|
||||
// An Align that moves something is one undo step, and it clears Redo.
|
||||
let c = rect(&mut s, 120.0, 300.0, 20.0, 20.0);
|
||||
select_with_a_step_to_redo(&mut s, &[a, c]);
|
||||
let undo = history(&s).0;
|
||||
s.execute("object.align", &left).unwrap();
|
||||
assert_eq!(history(&s), (undo + 1, 0, true));
|
||||
assert_eq!(x0(&s, c), 5.0);
|
||||
// Aligning a rectangle at 51.8 to one at 10.7 leaves it a rounding error off 10.7, and a
|
||||
// second Align records no step.
|
||||
let d = rect(&mut s, 10.7, 400.0, 20.0, 20.0);
|
||||
let e = rect(&mut s, 51.8, 450.0, 20.0, 20.0);
|
||||
s.execute("select.set", &json!({"ids": [d.0, e.0]})).unwrap();
|
||||
s.execute("object.align", &left).unwrap();
|
||||
assert!(x0(&s, e) != 10.7 && (x0(&s, e) - 10.7).abs() < 1e-12, "{}", x0(&s, e));
|
||||
s.doc_mut().unwrap().mark_saved();
|
||||
let before = history(&s);
|
||||
s.execute("object.align", &left).unwrap();
|
||||
assert_eq!(history(&s), before, "a pair the first Align aligned");
|
||||
}
|
||||
|
||||
/// Distribute and Distribute Spacing over objects already evenly spaced move nothing and record no
|
||||
/// undo step either.
|
||||
#[test]
|
||||
fn a_distribute_that_moves_nothing_records_no_undo_step() {
|
||||
let mut s = session();
|
||||
// The rectangles are 20 pt wide and 30 pt apart.
|
||||
let ids = [0.0, 50.0, 100.0].map(|x| rect(&mut s, x, 0.0, 20.0, 20.0));
|
||||
select_with_a_step_to_redo(&mut s, &ids);
|
||||
let before = history(&s);
|
||||
let calls = [
|
||||
("object.distribute", json!({"horizontal": "left"})),
|
||||
("object.distribute", json!({"horizontal": "center"})),
|
||||
("object.distribute", json!({"horizontal": "right"})),
|
||||
("object.distributeSpacing", json!({"axis": "horizontal"})),
|
||||
("object.distributeSpacing", json!({"axis": "horizontal", "spacing": 30})),
|
||||
];
|
||||
for (id, p) in calls {
|
||||
s.execute(id, &p).unwrap();
|
||||
assert_eq!(history(&s), before, "{id} {p}");
|
||||
assert_eq!(s.journal.last().unwrap(), &(id.to_string(), p));
|
||||
}
|
||||
// Distribute Spacing keeps the key object still and spaces the others from it.
|
||||
s.execute("select.key", &json!({"id": ids[1].0})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.key, Some(ids[1]));
|
||||
s.execute("object.distributeSpacing", &json!({"axis": "horizontal", "spacing": 30})).unwrap();
|
||||
assert_eq!(history(&s), before, "spaced 30 pt from the key object");
|
||||
// After the middle one moves 10 pt right, Distribute moves it back in one undo step.
|
||||
s.execute("select.set", &json!({"ids": [ids[1].0]})).unwrap();
|
||||
s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": ids.map(|i| i.0)})).unwrap();
|
||||
let undo = history(&s).0;
|
||||
s.execute("object.distribute", &json!({"horizontal": "left"})).unwrap();
|
||||
assert_eq!(history(&s).0, undo + 1);
|
||||
assert_eq!(s.doc().unwrap().doc.node(ids[1]).unwrap().geometric_bounds().unwrap().x0, 50.0);
|
||||
// For rectangles 30.1 pt apart, the gap Distribute Spacing computes moves the third one by a
|
||||
// rounding error.
|
||||
let ids = [0.1, 50.2, 100.3].map(|x| rect(&mut s, x, 100.0, 20.0, 20.0));
|
||||
select_with_a_step_to_redo(&mut s, &ids);
|
||||
let before = history(&s);
|
||||
s.execute("object.distributeSpacing", &json!({"axis": "horizontal"})).unwrap();
|
||||
assert_eq!(history(&s), before, "30.1 pt apart");
|
||||
// Distributing these three leaves the middle one a rounding error off halfway between the
|
||||
// others, and a second Distribute records no step.
|
||||
let ids = [40.045, 433.482, 1935.587].map(|x| rect(&mut s, x, 200.0, 20.0, 20.0));
|
||||
s.execute("select.set", &json!({"ids": ids.map(|i| i.0)})).unwrap();
|
||||
s.execute("object.distribute", &json!({"horizontal": "left"})).unwrap();
|
||||
s.doc_mut().unwrap().mark_saved();
|
||||
let before = history(&s);
|
||||
s.execute("object.distribute", &json!({"horizontal": "left"})).unwrap();
|
||||
assert_eq!(history(&s), before, "three objects the first Distribute spaced");
|
||||
}
|
||||
|
||||
/// Move by 0 pt, Rotate and Shear by 0°, Scale to 100%, an identity Transform, Set Bounds with the
|
||||
/// current position and size, a Nudge of 0 (of objects, anchors or ruler guides), a Transform with
|
||||
/// no object to transform and a Selection tool drag that ends where it started record no undo
|
||||
/// step. Transform Again then repeats the identity, and a Nudge of 0 of a live rectangle's anchor
|
||||
/// leaves the rectangle a live shape.
|
||||
#[test]
|
||||
fn a_transform_that_moves_nothing_records_no_undo_step() {
|
||||
let mut s = session();
|
||||
// The Nudges of a guide and of anchors below use this ruler guide and this plain path.
|
||||
s.execute("guide.add", &json!({"vertical": true, "pos": 600})).unwrap();
|
||||
let made = s.execute("path.create", &json!({"anchors": [{"x": 300, "y": 300}, {"x": 400, "y": 300}]})).unwrap();
|
||||
let path = NodeId(made["id"].as_u64().unwrap());
|
||||
let a = rect(&mut s, 10.0, 20.0, 40.0, 30.0);
|
||||
select_with_a_step_to_redo(&mut s, &[a]);
|
||||
let before = history(&s);
|
||||
let calls = [
|
||||
("object.move", json!({"dx": 0, "dy": 0})),
|
||||
("object.rotate", json!({"angle": 0})),
|
||||
("object.scale", json!({"sx": 100})),
|
||||
("object.shear", json!({"angle": 0})),
|
||||
("object.transform", json!({"matrix": [1, 0, 0, 1, 0, 0]})),
|
||||
("object.nudge", json!({"dx": 0, "dy": 0})),
|
||||
("object.setBounds", json!({})),
|
||||
("object.transformAgain", json!({})),
|
||||
];
|
||||
for (id, p) in calls {
|
||||
assert_eq!(s.execute(id, &p).unwrap(), json!({"ids": [a.0]}), "{id} {p}");
|
||||
assert_eq!(history(&s), before, "{id} {p}");
|
||||
assert_eq!(s.journal.last().unwrap(), &(id.to_string(), p));
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().last_transform, Some((Affine::IDENTITY, false)));
|
||||
// A Nudge of 0 with an anchor of the rectangle direct-selected keeps it a live shape.
|
||||
let is_live = |s: &Session| matches!(s.doc().unwrap().doc.node(a).unwrap().kind, NodeKind::Path { live: Some(_), .. });
|
||||
assert!(is_live(&s), "a rectangle is a live shape");
|
||||
s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0]]})).unwrap();
|
||||
s.execute("object.nudge", &json!({"dx": 0, "dy": 0})).unwrap();
|
||||
assert!(is_live(&s), "a Nudge of 0 keeps it a live shape");
|
||||
assert_eq!(history(&s), before, "an anchor of the live rectangle, then object.nudge");
|
||||
// A Nudge of 0 with anchors direct-selected or with only a ruler guide selected, and a
|
||||
// Transform with nothing selected or with ids that name no object, with or without a copy,
|
||||
// record no undo step either.
|
||||
let calls = [
|
||||
("select.anchors", json!({"id": path.0, "anchors": [[0, 0]]}), "object.nudge", json!({"dx": 0, "dy": 0})),
|
||||
("guide.select", json!({"indexes": [0]}), "object.nudge", json!({"dx": 0, "dy": 0})),
|
||||
("select.set", json!({"ids": []}), "object.transform", json!({"matrix": [1, 0, 0, 1, 5, 0]})),
|
||||
("select.set", json!({"ids": []}), "object.transform", json!({"matrix": [1, 0, 0, 1, 5, 0], "copy": true})),
|
||||
("select.set", json!({"ids": [a.0]}), "object.transform", json!({"matrix": [1, 0, 0, 1, 5, 0], "ids": [999999]})),
|
||||
("select.set", json!({"ids": [a.0]}), "object.transform", json!({"matrix": [1, 0, 0, 1, 5, 0], "ids": [999999], "copy": true})),
|
||||
];
|
||||
for (select, sp, id, p) in calls {
|
||||
s.execute(select, &sp).unwrap();
|
||||
s.execute(id, &p).unwrap();
|
||||
assert_eq!(history(&s), before, "{select} {sp}, then {id} {p}");
|
||||
}
|
||||
// A copy of no object lists no copies and keeps the selection.
|
||||
let p = json!({"matrix": [1, 0, 0, 1, 5, 0], "ids": [999999], "copy": true});
|
||||
assert_eq!(s.execute("object.transform", &p).unwrap(), json!({"ids": []}));
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [a]);
|
||||
// A Selection tool drag that ends where it started records no undo step either. Back at the
|
||||
// start, the revision advances, so views redraw the object where it was.
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("selection", v).unwrap();
|
||||
let x0 = |s: &Session| s.doc().unwrap().doc.node(a).unwrap().geometric_bounds().unwrap().x0;
|
||||
let revision = |s: &Session| s.doc().unwrap().revision;
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 30.0, 35.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 60.0, 35.0), v).unwrap();
|
||||
assert_ne!(x0(&s), 10.0, "the preview moves it");
|
||||
let moved = revision(&s);
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 30.0, 35.0), v).unwrap();
|
||||
assert_eq!(x0(&s), 10.0);
|
||||
assert!(revision(&s) > moved, "views redraw it back at the start");
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 30.0, 35.0), v).unwrap();
|
||||
assert_eq!(history(&s), before, "a drag back to the start");
|
||||
assert_eq!(s.journal.last().unwrap().0, "object.transform");
|
||||
// With a copy, a Move by 0 pt duplicates the object in one undo step.
|
||||
let count = |s: &Session| s.doc().unwrap().doc.layers[0].children().unwrap().len();
|
||||
let n = count(&s);
|
||||
s.execute("object.move", &json!({"dx": 0, "dy": 0, "copy": true})).unwrap();
|
||||
assert_eq!(history(&s), (before.0 + 1, 0, true));
|
||||
assert_eq!(count(&s), n + 1);
|
||||
}
|
||||
|
||||
/// Arrange commands that leave the stacking order as it is record no undo step: Bring to Front and
|
||||
/// Bring Forward on the front objects, Send to Back and Send Backward on the back ones, and Send to
|
||||
/// Current Layer on objects already on top of the current layer.
|
||||
#[test]
|
||||
fn an_arrange_that_changes_no_order_records_no_undo_step() {
|
||||
let mut s = session();
|
||||
let ids = [0.0, 30.0, 60.0].map(|x| rect(&mut s, x, 0.0, 20.0, 20.0));
|
||||
let order = |s: &Session| s.doc().unwrap().doc.layers[0].children().unwrap().iter().map(|n| n.id).collect::<Vec<_>>();
|
||||
let calls = [
|
||||
(vec![ids[2]], "object.arrange.bringToFront"),
|
||||
(vec![ids[2]], "object.arrange.bringForward"),
|
||||
(vec![ids[0]], "object.arrange.sendToBack"),
|
||||
(vec![ids[0]], "object.arrange.sendBackward"),
|
||||
(vec![ids[2]], "object.arrange.sendToCurrentLayer"),
|
||||
(vec![ids[1], ids[2]], "object.arrange.bringToFront"),
|
||||
(vec![ids[1], ids[2]], "object.arrange.bringForward"),
|
||||
(vec![ids[0], ids[1]], "object.arrange.sendToBack"),
|
||||
(vec![ids[0], ids[1]], "object.arrange.sendBackward"),
|
||||
(vec![ids[1], ids[2]], "object.arrange.sendToCurrentLayer"),
|
||||
];
|
||||
for (sel, c) in calls {
|
||||
select_with_a_step_to_redo(&mut s, &sel);
|
||||
let before = history(&s);
|
||||
s.execute(c, &json!({})).unwrap();
|
||||
assert_eq!(history(&s), before, "{c} {sel:?}");
|
||||
assert_eq!(order(&s), ids, "{c} {sel:?}");
|
||||
}
|
||||
// Bring to Front on the back object is one undo step, and it clears Redo.
|
||||
select_with_a_step_to_redo(&mut s, &[ids[0]]);
|
||||
let undo = history(&s).0;
|
||||
s.execute("object.arrange.bringToFront", &json!({})).unwrap();
|
||||
assert_eq!(history(&s), (undo + 1, 0, true));
|
||||
assert_eq!(order(&s), [ids[1], ids[2], ids[0]]);
|
||||
}
|
||||
|
||||
/// Ungroup, Release Compound Path and Release Clipping Mask with none of their objects selected,
|
||||
/// and Expand Shape with no live shape selected, record no undo step and keep the selection and its
|
||||
/// key object.
|
||||
#[test]
|
||||
fn ungroup_release_and_expand_shape_with_nothing_to_act_on_record_no_undo_step() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0);
|
||||
let b = rect(&mut s, 40.0, 0.0, 20.0, 20.0);
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
// Expand Shape makes the two rectangles plain paths in one undo step.
|
||||
let undo = history(&s).0;
|
||||
s.execute("object.expandShape", &json!({})).unwrap();
|
||||
assert_eq!(history(&s).0, undo + 1);
|
||||
select_with_a_step_to_redo(&mut s, &[a, b]);
|
||||
s.execute("select.key", &json!({"id": a.0})).unwrap();
|
||||
let (before, selection) = (history(&s), s.doc().unwrap().selection.clone());
|
||||
assert_eq!(selection.key, Some(a));
|
||||
for c in ["object.ungroup", "object.compoundPath.release", "object.clippingMask.release", "object.expandShape"] {
|
||||
s.execute(c, &json!({})).unwrap();
|
||||
assert_eq!(history(&s), before, "{c}");
|
||||
assert_eq!(s.doc().unwrap().selection, selection, "{c}");
|
||||
assert_eq!(s.journal.last().unwrap().0, c);
|
||||
}
|
||||
// Ungroup with a group selected is one undo step.
|
||||
s.execute("object.group", &json!({})).unwrap();
|
||||
let undo = history(&s).0;
|
||||
s.execute("object.ungroup", &json!({})).unwrap();
|
||||
assert_eq!(history(&s).0, undo + 1);
|
||||
}
|
||||
|
||||
/// Unlock All with nothing locked and Show All with nothing hidden record no undo step and keep the
|
||||
/// selection. Lock with every selected object already locked and Hide with every one already
|
||||
/// hidden record no undo step and deselect them.
|
||||
#[test]
|
||||
fn lock_hide_unlock_all_and_show_all_with_nothing_to_do_record_no_undo_step() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0);
|
||||
select_with_a_step_to_redo(&mut s, &[a]);
|
||||
let before = history(&s);
|
||||
for c in ["object.unlockAll", "object.showAll"] {
|
||||
assert_eq!(s.execute(c, &json!({})).unwrap(), json!({"count": 0}), "{c}");
|
||||
assert_eq!(history(&s), before, "{c}");
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [a], "{c}");
|
||||
}
|
||||
// With the object locked or hidden, each is one undo step that selects it.
|
||||
for (off, on) in [("object.lock", "object.unlockAll"), ("object.hide", "object.showAll")] {
|
||||
s.execute(off, &json!({})).unwrap();
|
||||
let undo = history(&s).0;
|
||||
assert_eq!(s.execute(on, &json!({})).unwrap(), json!({"count": 1}), "{on}");
|
||||
assert_eq!(history(&s).0, undo + 1, "{on}");
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [a], "{on}");
|
||||
}
|
||||
// Object Properties locks (then hides) a new rectangle `b`, and `select.set` selects it.
|
||||
// Lock (then Hide) deselects it and records no undo step. With `a` selected too, it is one
|
||||
// undo step.
|
||||
for (c, props) in [("object.lock", json!({"locked": true})), ("object.hide", json!({"visible": false}))] {
|
||||
let b = rect(&mut s, 40.0, 0.0, 20.0, 20.0);
|
||||
s.execute("object.setProps", &props).unwrap();
|
||||
select_with_a_step_to_redo(&mut s, &[a]);
|
||||
s.execute("select.set", &json!({"ids": [b.0]})).unwrap();
|
||||
let before = history(&s);
|
||||
s.execute(c, &json!({})).unwrap();
|
||||
assert_eq!(history(&s), before, "{c}");
|
||||
assert!(s.doc().unwrap().selection.objects.is_empty(), "{c}");
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
s.execute(c, &json!({})).unwrap();
|
||||
assert_eq!(history(&s), (before.0 + 1, 0, true), "{c} with a");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Set Bounds with the current position and size records no undo step on a rotated object, with
|
||||
/// and without Use Preview Bounds, and on an unrotated object with Use Preview Bounds. In these
|
||||
/// cases computing the transform can leave a rounding error in it.
|
||||
#[test]
|
||||
fn set_bounds_with_the_current_position_and_size_records_no_undo_step() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0, 20.0, 40.0, 30.0);
|
||||
s.execute("stroke.set", &json!({"weight": 5})).unwrap();
|
||||
s.execute("object.rotate", &json!({"angle": 30})).unwrap();
|
||||
let c = rect(&mut s, 0.1, 0.05, 10.1, 6.06);
|
||||
s.execute("stroke.set", &json!({"weight": 0.3})).unwrap();
|
||||
select_with_a_step_to_redo(&mut s, &[a]);
|
||||
let before = history(&s);
|
||||
for (id, preview) in [(a, false), (a, true), (c, true)] {
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("prefs.set", &json!({"key": "usePreviewBounds", "value": preview})).unwrap();
|
||||
// The Transform panel's values: X and Y place the reference point, W and H are the sides.
|
||||
let b = s.transform_box(&[id]).unwrap();
|
||||
let (rp, w, h) = (b.reference_point(4), b.rect.width(), b.rect.height());
|
||||
for p in [json!({}), json!({"x": rp.x, "y": rp.y, "width": w, "height": h}), json!({"width": w, "proportional": true})] {
|
||||
assert_eq!(s.execute("object.setBounds", &p).unwrap(), json!({"ids": [id.0]}), "{p}");
|
||||
assert_eq!(history(&s), before, "{id:?}, Use Preview Bounds {preview}: {p}");
|
||||
assert_eq!(s.journal.last().unwrap().0, "object.setBounds");
|
||||
}
|
||||
}
|
||||
// A new width is one undo step, and it clears Redo.
|
||||
s.execute("object.setBounds", &json!({"width": 50})).unwrap();
|
||||
assert_eq!(history(&s), (before.0 + 1, 0, true));
|
||||
}
|
||||
|
||||
/// Object Properties with the values the objects already have records no undo step: every
|
||||
/// property, data already set or already absent, and no object. A value that differs on one of
|
||||
/// the objects is one undo step, and an id that names no object is still an error.
|
||||
#[test]
|
||||
fn object_properties_already_set_record_no_undo_step() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0);
|
||||
let b = rect(&mut s, 40.0, 0.0, 20.0, 20.0);
|
||||
let set = json!({"ids": [a.0], "name": "A", "opacity": 50, "blend": "Multiply", "knockout": "on", "data": {"pivot": "1,2"}});
|
||||
s.execute("object.setProps", &set).unwrap();
|
||||
select_with_a_step_to_redo(&mut s, &[a, b]);
|
||||
let before = history(&s);
|
||||
let defaults = json!({
|
||||
"id": b.0, "name": "", "visible": true, "locked": false, "opacity": 100, "blend": "Normal", "isolate": false,
|
||||
"knockout": "neutral", "knockoutShape": false, "data": {"pivot": null}
|
||||
});
|
||||
let calls =
|
||||
[set, defaults, json!({"id": a.0, "data": {"data-pivot": "1,2", "other": null}}), json!({"visible": true}), json!({}), json!({"ids": []})];
|
||||
for p in calls {
|
||||
s.execute("object.setProps", &p).unwrap();
|
||||
assert_eq!(history(&s), before, "{p}");
|
||||
assert_eq!(s.journal.last().unwrap(), &("object.setProps".to_string(), p));
|
||||
}
|
||||
// `a` already has 50% opacity, and `b` does not.
|
||||
s.execute("object.setProps", &json!({"opacity": 50})).unwrap();
|
||||
assert_eq!(history(&s), (before.0 + 1, 0, true));
|
||||
assert!(s.execute("object.setProps", &json!({"ids": [999999], "visible": true})).is_err());
|
||||
}
|
||||
|
||||
/// Live Shape Properties that a shape already has record no undo step: a polygon's sides, angle,
|
||||
/// radius, side length and equal sides, an ellipse's pie, Invert Pie on a whole ellipse, and the
|
||||
/// radius and kind its corners have (given corners, the Direct-Selected one, or all). A value that
|
||||
/// differs is one undo step. Setting the radius of a rectangle that kept a scale in its transform
|
||||
/// is one undo step too, because it folds the scale into the rectangle's size.
|
||||
#[test]
|
||||
fn live_shape_properties_already_set_record_no_undo_step() {
|
||||
let mut s = session();
|
||||
let id = |v: Value| NodeId(v["id"].as_u64().unwrap());
|
||||
let r = rect(&mut s, 0.0, 0.0, 100.0, 60.0);
|
||||
let polygon = id(s.execute("shape.polygon", &json!({"cx": 300, "cy": 100, "radius": 50, "sides": 6})).unwrap());
|
||||
let ellipse = id(s.execute("shape.ellipse", &json!({"x": 0, "y": 200, "width": 80, "height": 40})).unwrap());
|
||||
let star = id(s.execute("shape.star", &json!({"cx": 300, "cy": 300, "radius1": 60, "radius2": 30})).unwrap());
|
||||
// The rectangle's corners get 8 pt radii, its top-right corner gets 12 pt, and the polygon gets
|
||||
// 30 pt sides.
|
||||
for p in [json!({"id": r.0, "radius": 8}), json!({"id": r.0, "corners": [1], "radius": 12}), json!({"id": polygon.0, "sideLength": 30})] {
|
||||
s.execute("object.setLiveShape", &p).unwrap();
|
||||
}
|
||||
let NodeKind::Path { live: Some(l), .. } = &s.doc().unwrap().doc.node(polygon).unwrap().kind else { panic!("not a live polygon") };
|
||||
let (angle, radius) = (l.polygon_angle().unwrap(), l.polygon_radius().unwrap());
|
||||
select_with_a_step_to_redo(&mut s, &[r, polygon, ellipse, star]);
|
||||
let before = history(&s);
|
||||
let calls = [
|
||||
json!({"id": polygon.0, "sides": 6, "polygonAngle": angle, "polygonRadius": radius, "makeSidesEqual": true}),
|
||||
json!({"id": polygon.0, "sideLength": 30, "kind": "round"}),
|
||||
json!({"id": ellipse.0, "pieStart": 0, "pieEnd": 360}),
|
||||
json!({"id": ellipse.0, "invertPie": true}),
|
||||
json!({"id": r.0, "corners": [0, 2, 3], "radius": 8, "kind": "round"}),
|
||||
json!({"items": [{"id": r.0, "corners": [1]}], "radius": 12}),
|
||||
json!({"id": star.0, "radius": 0}),
|
||||
];
|
||||
for p in calls {
|
||||
s.execute("object.setLiveShape", &p).unwrap();
|
||||
assert_eq!(history(&s), before, "{p}");
|
||||
assert_eq!(s.journal.last().unwrap(), &("object.setLiveShape".to_string(), p));
|
||||
}
|
||||
// With an anchor of the top-left corner direct-selected, the radius applies to that corner.
|
||||
s.execute("select.anchors", &json!({"id": r.0, "anchors": [[0, 0]]})).unwrap();
|
||||
s.execute("object.setLiveShape", &json!({"radius": 8})).unwrap();
|
||||
assert_eq!(history(&s), before, "the Direct-Selected corner");
|
||||
for p in [json!({"id": polygon.0, "sides": 7}), json!({"id": ellipse.0, "pieEnd": 90}), json!({"radius": 9})] {
|
||||
let undo = history(&s).0;
|
||||
s.execute("object.setLiveShape", &p).unwrap();
|
||||
assert_eq!(history(&s).0, undo + 1, "{p}");
|
||||
}
|
||||
// The rectangle is now 50 × 60 pt with 10 pt corners, stretched to 100 × 60 pt by its
|
||||
// transform, as files from before #291 saved it. Setting the radius its corners show makes the
|
||||
// corners circles.
|
||||
{
|
||||
let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
let NodeKind::Path { path, live: Some(live), .. } = &mut d.node_mut(r).unwrap().kind else { panic!("not a live rectangle") };
|
||||
let xf = Affine::scale_non_uniform(2.0, 1.0);
|
||||
*live = vectorcraft_doc::LiveShape::Rectangle { w: 50.0, h: 60.0, radii: [10.0; 4], kinds: Default::default(), xf };
|
||||
*path = live.to_path();
|
||||
}
|
||||
let shown = vectorcraft_doc::LiveCorners::of(s.doc().unwrap().doc.node(r).unwrap()).unwrap().radius(0);
|
||||
let undo = history(&s).0;
|
||||
s.execute("object.setLiveShape", &json!({"id": r.0, "radius": shown})).unwrap();
|
||||
assert_eq!(history(&s).0, undo + 1, "a stretched rectangle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rectangle_tool_draws() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -870,6 +870,10 @@ fn the_pen_continues_any_open_path_and_joins_another() {
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
|
||||
assert_eq!(s.cursor(Point::new(300.0, 200.0), none, v), Cursor::PenJoin);
|
||||
assert_eq!(s.cursor(Point::new(350.0, 200.0), none, v), Cursor::Pen, "the middle of the other path");
|
||||
// #1009: Use Precise Cursors makes the join pointer a crosshair too.
|
||||
s.execute("prefs.set", &json!({"key": "usePreciseCursors", "value": true})).unwrap();
|
||||
assert_eq!(s.cursor(Point::new(300.0, 200.0), none, v), Cursor::Crosshair);
|
||||
s.execute("prefs.set", &json!({"key": "usePreciseCursors", "value": false})).unwrap();
|
||||
assert_eq!(gesture(&mut s, &[(300.0, 200.0)], none), 1);
|
||||
let pts = |s: &Session, id| path(s, id).subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|x| (x.p.x, x.p.y))).collect::<Vec<_>>();
|
||||
assert_eq!(pts(&s, a), [(100.0, 100.0), (200.0, 100.0), (300.0, 200.0), (400.0, 200.0)]);
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::Node;
|
||||
use vectorcraft_tools::{PointerEvent, PointerKind};
|
||||
|
||||
use super::*;
|
||||
use crate::tooling::ViewInfo;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
@@ -99,6 +101,38 @@ fn edit_colors_reach_mesh_points() {
|
||||
assert_eq!(mesh_colors(&s, m), pts, "a mesh's points are fill colours");
|
||||
}
|
||||
|
||||
/// Clicking a gradient mesh's point with the Mesh tool or Direct Selection picks it: a solid fill
|
||||
/// colour then recolours that point alone (#1050).
|
||||
#[test]
|
||||
fn a_clicked_mesh_point_takes_the_fill_colour() {
|
||||
let mut s = session();
|
||||
let m = mesh(&mut s);
|
||||
let before = mesh_colors(&s, m);
|
||||
let v = ViewInfo::default();
|
||||
let click = |s: &mut Session, i: usize| {
|
||||
let NodeKind::Mesh(g) = &s.doc().unwrap().doc.node(m).unwrap().kind else { panic!("not a mesh") };
|
||||
let q = g.points[i].p;
|
||||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(&PointerEvent::new(kind, q.x, q.y), v).unwrap();
|
||||
}
|
||||
};
|
||||
// The Mesh tool selects the mesh whose point it clicks.
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
s.select_tool("mesh", v).unwrap();
|
||||
click(&mut s, 1);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [m]);
|
||||
s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap();
|
||||
let after = mesh_colors(&s, m);
|
||||
assert_eq!(after[1].to_hex(), "#0000ff", "the clicked point takes the colour");
|
||||
assert_eq!((after[0], after[2], after[3]), (before[0], before[2], before[3]), "the other points keep theirs");
|
||||
s.select_tool("directSelection", v).unwrap();
|
||||
click(&mut s, 2);
|
||||
s.execute("paint.setFill", &json!({"color": "#ffff00"})).unwrap();
|
||||
let after = mesh_colors(&s, m);
|
||||
assert_eq!((after[1].to_hex(), after[2].to_hex()), ("#0000ff".to_string(), "#ffff00".to_string()));
|
||||
assert_eq!((after[0], after[3]), (before[0], before[3]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_moves_a_mesh_to_the_blended_colour() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -785,7 +785,11 @@ fn ruler_guides_select_move_and_delete() {
|
||||
assert_eq!(s.execute("guide.select", &json!({"indexes": [0]})).unwrap()["selected"], json!([0]));
|
||||
assert!(selected(&s).is_empty());
|
||||
assert_eq!(s.execute("guide.select", &json!({"indexes": [1], "toggle": true})).unwrap()["selected"], json!([0, 1]));
|
||||
assert_eq!(s.execute("guide.list", &json!({})).unwrap()[1], json!({"index": 1, "vertical": false, "pos": 50.0, "selected": true}));
|
||||
let layer = s.doc().unwrap().current_layer().unwrap().0;
|
||||
assert_eq!(
|
||||
s.execute("guide.list", &json!({})).unwrap()[1],
|
||||
json!({"index": 1, "vertical": false, "pos": 50.0, "selected": true, "layer": layer, "shown": true, "editable": true})
|
||||
);
|
||||
assert!(s.execute("guide.select", &json!({"indexes": [7]})).is_err());
|
||||
// A move takes each selected guide along its own axis, in one undo step.
|
||||
let n = undo_len(&s);
|
||||
@@ -823,6 +827,40 @@ fn ruler_guides_select_move_and_delete() {
|
||||
assert!(matches!(s.execute("edit.clear", &json!({})), Err(EngineError::Disabled(..))));
|
||||
}
|
||||
|
||||
/// Shift selects ruler guides and art together, in either order; Delete takes both in one step.
|
||||
#[test]
|
||||
fn guides_and_art_are_shift_selected_together() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0, 10.0, 40.0, 20.0);
|
||||
let b = rect(&mut s, 70.0, 50.0, 60.0, 30.0);
|
||||
s.execute("guide.add", &json!({"vertical": true, "pos": 200})).unwrap();
|
||||
s.execute("guide.add", &json!({"vertical": false, "pos": 300})).unwrap();
|
||||
// Art first, then a Shift-click on a guide: the art stays selected.
|
||||
sel(&mut s, &[a]);
|
||||
s.execute("guide.select", &json!({"indexes": [0], "toggle": true})).unwrap();
|
||||
assert_eq!((selected(&s), selected_guides(&s)), (vec![a], vec![0]));
|
||||
// A guide first, then Shift-added art: the guide stays selected.
|
||||
s.execute("guide.select", &json!({"indexes": [1]})).unwrap();
|
||||
s.execute("select.add", &json!({"ids": [b.0]})).unwrap();
|
||||
assert_eq!((selected(&s), selected_guides(&s)), (vec![b], vec![1]));
|
||||
// A new selection drops them.
|
||||
sel(&mut s, &[a]);
|
||||
assert!(selected_guides(&s).is_empty());
|
||||
// Delete takes the art and the guides, in one step; undo brings both back.
|
||||
s.execute("guide.select", &json!({"indexes": [1], "toggle": true})).unwrap();
|
||||
let n = undo_len(&s);
|
||||
s.execute("edit.clear", &json!({})).unwrap();
|
||||
assert_eq!((guides(&s), undo_len(&s)), (vec![(true, 200.0)], n + 1));
|
||||
assert!(s.doc().unwrap().doc.node(a).is_none() && s.doc().unwrap().doc.node(b).is_some());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(guides(&s).len(), 2);
|
||||
// Ids given: only those go, the guides stay.
|
||||
s.execute("guide.select", &json!({"indexes": [0]})).unwrap();
|
||||
s.execute("select.add", &json!({"ids": [b.0]})).unwrap();
|
||||
s.execute("edit.clear", &json!({"ids": [b.0]})).unwrap();
|
||||
assert_eq!(guides(&s).len(), 2);
|
||||
}
|
||||
|
||||
/// #451: an artboard guide (`guide.add {artboard}`) runs across its artboard only, and is moved,
|
||||
/// copied and deleted with it; canvas guides stay put.
|
||||
#[test]
|
||||
@@ -832,11 +870,12 @@ fn artboard_guides_go_with_their_artboard() {
|
||||
s.execute("guide.add", &json!({"vertical": true, "pos": 1000, "artboard": 1})).unwrap();
|
||||
s.execute("guide.add", &json!({"vertical": false, "pos": 100})).unwrap();
|
||||
let list = |s: &mut Session| s.execute("guide.list", &json!({})).unwrap();
|
||||
let layer = s.doc().unwrap().current_layer().unwrap().0;
|
||||
assert_eq!(
|
||||
list(&mut s),
|
||||
json!([
|
||||
{"index": 0, "vertical": true, "pos": 1000.0, "selected": false, "artboard": 1},
|
||||
{"index": 1, "vertical": false, "pos": 100.0, "selected": false},
|
||||
{"index": 0, "vertical": true, "pos": 1000.0, "selected": false, "artboard": 1, "layer": layer, "shown": true, "editable": true},
|
||||
{"index": 1, "vertical": false, "pos": 100.0, "selected": false, "layer": layer, "shown": true, "editable": true},
|
||||
])
|
||||
);
|
||||
let spans = |s: &Session| {
|
||||
@@ -875,7 +914,10 @@ fn artboard_guides_go_with_their_artboard() {
|
||||
let n = undo_len(&s);
|
||||
s.execute("artboard.delete", &json!({"index": 0})).unwrap();
|
||||
assert_eq!((guides(&s).len(), selected_guides(&s)), (3, vec![0, 1]));
|
||||
assert_eq!(list(&mut s)[0], json!({"index": 0, "vertical": false, "pos": 100.0, "selected": true}));
|
||||
assert_eq!(
|
||||
list(&mut s)[0],
|
||||
json!({"index": 0, "vertical": false, "pos": 100.0, "selected": true, "layer": layer, "shown": true, "editable": true})
|
||||
);
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!((guides(&s).len(), selected_guides(&s)), (4, vec![1, 2]));
|
||||
@@ -917,12 +959,120 @@ fn guides_dragged_out_of_a_ruler_snap_and_take_the_artboard_tool_s_artboard() {
|
||||
s.set_tool_option("active", &json!(1));
|
||||
s.ruler_guide(false, &ev(PointerKind::Drag, 1000.0, 50.0), true, v).unwrap();
|
||||
s.ruler_guide(false, &ev(PointerKind::Up, 1000.0, 50.0), true, v).unwrap();
|
||||
let layer = s.doc().unwrap().current_layer().unwrap().0;
|
||||
assert_eq!(
|
||||
s.execute("guide.list", &json!({})).unwrap()[1],
|
||||
json!({"index": 1, "vertical": false, "pos": 50.0, "selected": false, "artboard": 1})
|
||||
json!({"index": 1, "vertical": false, "pos": 50.0, "selected": false, "artboard": 1, "layer": layer, "shown": true, "editable": true})
|
||||
);
|
||||
}
|
||||
|
||||
/// Guides saved before guides had layers are put on a layer when the document opens.
|
||||
#[test]
|
||||
fn guides_from_older_files_are_put_on_a_layer() {
|
||||
let mut s = Session::new();
|
||||
let mut d = vectorcraft_doc::Document::new(400.0, 300.0);
|
||||
d.guides.push(vectorcraft_doc::Guide::new(true, 50.0));
|
||||
d.guides.push(vectorcraft_doc::Guide::new(false, 80.0));
|
||||
s.add_document(d, None);
|
||||
let st = s.doc().unwrap();
|
||||
let top = st.doc.default_layer();
|
||||
assert!(top.is_some() && st.doc.guides.iter().all(|g| g.layer == top), "{:?}", st.doc.guides);
|
||||
}
|
||||
|
||||
/// Ruler guides are layer objects: made on the current layer, hidden, locked and deleted with it,
|
||||
/// and moved to another layer.
|
||||
#[test]
|
||||
fn ruler_guides_live_on_layers() {
|
||||
let mut s = session();
|
||||
let first = s.doc().unwrap().current_layer().unwrap();
|
||||
s.execute("guide.add", &json!({"vertical": true, "pos": 200})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.guides[0].layer, Some(first), "on the current layer");
|
||||
let second = id_of(&s.execute("layer.new", &json!({"name": "Guides"})).unwrap());
|
||||
s.execute("guide.add", &json!({"vertical": false, "pos": 300})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.guides[1].layer, Some(second), "the new layer is current");
|
||||
assert!(s.execute("guide.add", &json!({"vertical": true, "pos": 1, "layer": 99999})).is_err());
|
||||
// Hidden or locked with its layer.
|
||||
s.execute("layer.setProps", &json!({"ids": [second.0], "visible": false})).unwrap();
|
||||
assert_eq!(s.execute("guide.list", &json!({})).unwrap()[1]["shown"], false);
|
||||
s.execute("layer.setProps", &json!({"ids": [second.0], "visible": true, "locked": true})).unwrap();
|
||||
let row = s.execute("guide.list", &json!({})).unwrap()[1].clone();
|
||||
assert_eq!((row["shown"].clone(), row["editable"].clone()), (json!(true), json!(false)));
|
||||
s.execute("layer.setProps", &json!({"ids": [second.0], "locked": false})).unwrap();
|
||||
// Moved to the first layer, then back.
|
||||
s.execute("guide.setLayer", &json!({"index": 1, "layer": first.0})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.guides[1].layer, Some(first));
|
||||
s.execute("guide.setLayer", &json!({"index": 1, "layer": second.0})).unwrap();
|
||||
assert!(s.execute("guide.setLayer", &json!({"index": 1, "layer": 99999})).is_err());
|
||||
// Several at once, as dragging their rows onto a layer's row does.
|
||||
s.execute("guide.setLayer", &json!({"indexes": [0, 1], "layer": first.0})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.guides.iter().all(|g| g.layer == Some(first)));
|
||||
s.execute("guide.setLayer", &json!({"indexes": [1], "layer": second.0})).unwrap();
|
||||
assert!(s.execute("guide.setLayer", &json!({"indexes": [7], "layer": first.0})).is_err());
|
||||
// Deleting a layer deletes its guides; undo brings them back.
|
||||
s.execute("layer.delete", &json!({"ids": [second.0]})).unwrap();
|
||||
assert_eq!(guides(&s), [(true, 200.0)]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(guides(&s).len(), 2);
|
||||
}
|
||||
|
||||
/// Guides on a hidden or locked layer leave the selection and can't be moved, deleted or moved to
|
||||
/// another layer; new guides skip a locked current layer as new art does; no guide goes onto a
|
||||
/// locked layer.
|
||||
#[test]
|
||||
fn guides_on_hidden_or_locked_layers_are_left_alone() {
|
||||
let mut s = session();
|
||||
let first = s.doc().unwrap().current_layer().unwrap();
|
||||
s.execute("guide.add", &json!({"vertical": true, "pos": 200})).unwrap();
|
||||
let second = id_of(&s.execute("layer.new", &json!({})).unwrap());
|
||||
s.execute("guide.add", &json!({"vertical": false, "pos": 300})).unwrap();
|
||||
s.execute("guide.select", &json!({"indexes": [0, 1]})).unwrap();
|
||||
s.execute("layer.setProps", &json!({"ids": [first.0], "locked": true})).unwrap();
|
||||
assert_eq!(selected_guides(&s), [1], "the locked layer's guide is deselected");
|
||||
assert_eq!(s.execute("guide.select", &json!({"indexes": [0]})).unwrap()["selected"], json!([]), "and can't be selected");
|
||||
assert!(s.execute("guide.move", &json!({"index": 0, "pos": 50})).is_err());
|
||||
assert!(s.execute("guide.remove", &json!({"index": 0})).is_err());
|
||||
assert!(s.execute("guide.setLayer", &json!({"index": 0, "layer": second.0})).is_err());
|
||||
assert!(s.execute("guide.setLayer", &json!({"index": 1, "layer": first.0})).is_err(), "not onto a locked layer");
|
||||
s.execute("layer.setProps", &json!({"ids": [second.0], "visible": false})).unwrap();
|
||||
assert!(selected_guides(&s).is_empty(), "the hidden layer's guide is deselected");
|
||||
// Both layers hidden or locked: a new guide is refused, as new art is.
|
||||
assert!(s.execute("guide.add", &json!({"vertical": true, "pos": 10})).is_err());
|
||||
s.execute("layer.setProps", &json!({"ids": [second.0], "visible": true, "locked": true})).unwrap();
|
||||
s.execute("layer.setProps", &json!({"ids": [first.0], "locked": false})).unwrap();
|
||||
// The current layer locked: the new guide goes on the top shown, unlocked layer.
|
||||
s.execute("guide.add", &json!({"vertical": true, "pos": 20})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.guides[2].layer, Some(first));
|
||||
assert_eq!(guides(&s).len(), 3);
|
||||
}
|
||||
|
||||
/// Guides follow their layers through Duplicate, Merge Selected and Flatten Artwork, in one undo
|
||||
/// step each.
|
||||
#[test]
|
||||
fn guides_go_with_their_layers_when_duplicated_merged_and_flattened() {
|
||||
let mut s = session();
|
||||
let first = s.doc().unwrap().current_layer().unwrap();
|
||||
s.execute("guide.add", &json!({"vertical": true, "pos": 200})).unwrap();
|
||||
let second = id_of(&s.execute("layer.new", &json!({})).unwrap());
|
||||
s.execute("guide.add", &json!({"vertical": false, "pos": 300})).unwrap();
|
||||
let on = |s: &Session, l: NodeId| s.doc().unwrap().doc.guides_on(l).map(|(_, g)| (g.vertical, g.pos)).collect::<Vec<_>>();
|
||||
// Duplicate Layer copies its guides onto the copy.
|
||||
let copy = s.execute("layer.duplicate", &json!({"ids": [second.0]})).unwrap()["ids"][0].as_u64().map(NodeId).unwrap();
|
||||
assert_eq!((on(&s, second), on(&s, copy)), (vec![(false, 300.0)], vec![(false, 300.0)]));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(guides(&s).len(), 2);
|
||||
// Merged into the first layer, the second layer's guide goes with its art.
|
||||
s.execute("layer.merge", &json!({"ids": [second.0, first.0]})).unwrap();
|
||||
assert_eq!(on(&s, first), [(true, 200.0), (false, 300.0)]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!((on(&s, first), on(&s, second)), (vec![(true, 200.0)], vec![(false, 300.0)]));
|
||||
// Flattened into the first layer, a hidden layer is discarded with its guides.
|
||||
s.execute("layer.setProps", &json!({"ids": [second.0], "visible": false})).unwrap();
|
||||
s.execute("layer.flatten", &json!({"id": first.0})).unwrap();
|
||||
assert_eq!(guides(&s), [(true, 200.0)]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(guides(&s).len(), 2);
|
||||
}
|
||||
|
||||
/// #451: art moved with the Selection tool lands flush on the artboard's edges and centre (Smart
|
||||
/// Guides).
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
//! A reflected object is still clicked by its fill, not only by its outline.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_geom::Point;
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn id_of(v: &Value) -> NodeId {
|
||||
NodeId(v["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
/// A click with the Selection tool at (x, y): what it selects.
|
||||
fn click(s: &mut Session, x: f64, y: f64) -> Vec<NodeId> {
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("selection", v).unwrap();
|
||||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(&PointerEvent::new(kind, x, y).with_mods(Mods::default()), v).unwrap();
|
||||
}
|
||||
s.doc().unwrap().selection.objects.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reflected_shape_is_clicked_by_its_fill() {
|
||||
const HEART: &str = "M200 290C190 280 110 230 110 170C110 140 130 120 155 120C175 120 192 132 200 148C208 132 225 120 245 120C270 120 290 140 290 170C290 230 210 280 200 290Z";
|
||||
let makers: [(&str, Value); 5] = [
|
||||
("shape.rectangle", json!({"x": 150, "y": 150, "width": 100, "height": 100})),
|
||||
("shape.ellipse", json!({"x": 150, "y": 150, "width": 100, "height": 100})),
|
||||
("shape.polygon", json!({"cx": 200, "cy": 200, "radius": 60, "sides": 5})),
|
||||
("shape.star", json!({"cx": 200, "cy": 200, "radius1": 70, "radius2": 40, "points": 5})),
|
||||
("path.create", json!({"d": HEART})),
|
||||
];
|
||||
for (cmd, params) in makers {
|
||||
for axis in ["horizontal", "vertical"] {
|
||||
let mut s = session();
|
||||
let id = id_of(&s.execute(cmd, ¶ms).unwrap());
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("object.reflect", &json!({"axis": axis})).unwrap();
|
||||
let b = s.doc().unwrap().doc.bounds_of(&[id], false).unwrap();
|
||||
s.execute("select.set", &json!({"ids": []})).unwrap();
|
||||
let c = b.center();
|
||||
assert_eq!(click(&mut s, c.x, c.y), vec![id], "{cmd} reflected {axis}: a click in its fill at {c:?} (bounds {b:?})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hearts as SVG files bring them: a plain path, a compound path, inside a group, under a
|
||||
/// transform. Reflected with the button and with a bounding-box handle dragged across.
|
||||
#[test]
|
||||
fn a_reflected_imported_heart_is_clicked_by_its_fill() {
|
||||
const D: &str = "M200 290C190 280 110 230 110 170C110 140 130 120 155 120C175 120 192 132 200 148C208 132 225 120 245 120C270 120 290 140 290 170C290 230 210 280 200 290Z";
|
||||
let files = [
|
||||
format!(r##"<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400"><path fill="#e2384d" d="{D}"/></svg>"##),
|
||||
format!(
|
||||
r##"<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400"><path fill="#e2384d" fill-rule="evenodd" d="{D} M190 200h20v20h-20z"/></svg>"##
|
||||
),
|
||||
format!(r##"<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400"><g><g fill="#e2384d"><path d="{D}"/></g></g></svg>"##),
|
||||
format!(
|
||||
r##"<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400"><g transform="translate(10 0) scale(1.2)"><path fill="#e2384d" transform="matrix(1 0 0 1 -20 -20)" d="{D}"/></g></svg>"##
|
||||
),
|
||||
format!(
|
||||
r##"<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400" viewBox="0 0 200 200"><path style="fill:#e2384d;stroke:none" d="{D}" transform="scale(0.5)"/></svg>"##
|
||||
),
|
||||
];
|
||||
for (i, svg) in files.iter().enumerate() {
|
||||
for how in ["horizontal", "vertical", "handle"] {
|
||||
let mut s = Session::new();
|
||||
s.add_document(vectorcraft_svg::import(svg).unwrap(), None);
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
let ids = s.doc().unwrap().selection.objects.clone();
|
||||
assert!(!ids.is_empty(), "file {i}: nothing imported");
|
||||
let b = s.doc().unwrap().doc.bounds_of(&ids, false).unwrap();
|
||||
if how == "handle" {
|
||||
// Drag the top handle down past the bottom: a top-to-bottom flip.
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("selection", v).unwrap();
|
||||
let (from, to) = (Point::new(b.center().x, b.y0), Point::new(b.center().x, b.y1 + b.height()));
|
||||
for (kind, p) in
|
||||
[(PointerKind::Down, from), (PointerKind::Drag, Point::new(from.x, b.center().y)), (PointerKind::Drag, to), (PointerKind::Up, to)]
|
||||
{
|
||||
s.pointer(&PointerEvent::new(kind, p.x, p.y).with_mods(Mods::default()), v).unwrap();
|
||||
}
|
||||
} else {
|
||||
s.execute("object.reflect", &json!({"axis": how})).unwrap();
|
||||
}
|
||||
let b = s.doc().unwrap().doc.bounds_of(&ids, false).unwrap();
|
||||
s.execute("select.set", &json!({"ids": []})).unwrap();
|
||||
// Inside the heart away from the compound's square hole: a third of the way down.
|
||||
let c = Point::new(b.center().x, if how == "horizontal" || how == "handle" { b.y1 - b.height() * 0.3 } else { b.y0 + b.height() * 0.3 });
|
||||
let picked = click(&mut s, c.x, c.y);
|
||||
assert_eq!(picked, ids, "file {i} flipped by {how}: a click in its fill at {c:?} (bounds {b:?})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Still selected after the flip, a drag from inside the fill moves it.
|
||||
#[test]
|
||||
fn a_reflected_selected_shape_drags_by_its_fill() {
|
||||
const HEART: &str = "M200 290C190 280 110 230 110 170C110 140 130 120 155 120C175 120 192 132 200 148C208 132 225 120 245 120C270 120 290 140 290 170C290 230 210 280 200 290Z";
|
||||
let makers: [(&str, Value); 4] = [
|
||||
("shape.rectangle", json!({"x": 150, "y": 150, "width": 100, "height": 100})),
|
||||
("shape.polygon", json!({"cx": 200, "cy": 200, "radius": 60, "sides": 3})),
|
||||
("shape.star", json!({"cx": 200, "cy": 200, "radius1": 70, "radius2": 40, "points": 5})),
|
||||
("path.create", json!({"d": HEART})),
|
||||
];
|
||||
for (cmd, params) in makers {
|
||||
for axis in ["horizontal", "vertical"] {
|
||||
let mut s = session();
|
||||
let id = id_of(&s.execute(cmd, ¶ms).unwrap());
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("object.reflect", &json!({"axis": axis})).unwrap();
|
||||
let b = s.doc().unwrap().doc.bounds_of(&[id], false).unwrap();
|
||||
let c = b.center();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("selection", v).unwrap();
|
||||
for (kind, x) in
|
||||
[(PointerKind::Down, c.x), (PointerKind::Drag, c.x + 20.0), (PointerKind::Drag, c.x + 40.0), (PointerKind::Up, c.x + 40.0)]
|
||||
{
|
||||
s.pointer(&PointerEvent::new(kind, x, c.y).with_mods(Mods::default()), v).unwrap();
|
||||
}
|
||||
let after = s.doc().unwrap().doc.bounds_of(&[id], false).unwrap();
|
||||
assert!((after.x0 - b.x0 - 40.0).abs() < 1e-6, "{cmd} reflected {axis}: a drag from {c:?} left it at {after:?} (was {b:?})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An open path (an anchor deleted from a closed one) still fills as if closed, and its fill is
|
||||
/// clicked on both sides of that closing line, reflected or not.
|
||||
#[test]
|
||||
fn an_open_filled_path_is_clicked_by_its_fill_either_way_round() {
|
||||
// The sliver of a strawberry, open, its closing line down its left side.
|
||||
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"><path fill="#c22a3f" d="M86.311 69.612C100.544 78.261 110.506 88.64 110.506 100.748C110.506 111.127 100.544 118.046 86.311 118.046C93.214 102.045 91.407 102.403 91.407 97.61"/></svg>"##;
|
||||
for axis in [None, Some("vertical"), Some("horizontal")] {
|
||||
let mut s = Session::new();
|
||||
s.add_document(vectorcraft_svg::import(svg).unwrap(), None);
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
let ids = s.doc().unwrap().selection.objects.clone();
|
||||
if let Some(axis) = axis {
|
||||
s.execute("object.reflect", &json!({"axis": axis})).unwrap();
|
||||
}
|
||||
let b = s.doc().unwrap().doc.bounds_of(&ids, false).unwrap();
|
||||
s.execute("select.set", &json!({"ids": []})).unwrap();
|
||||
// The middle of the sliver's widest part, well inside it.
|
||||
let x = if axis == Some("vertical") { b.x0 + b.width() * 0.4 } else { b.x0 + b.width() * 0.6 };
|
||||
assert_eq!(click(&mut s, x, b.center().y), ids, "reflected {axis:?}: a click in the fill (bounds {b:?})");
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,22 @@ fn stroke(s: &Session, id: NodeId, x: f64, y: f64) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// An invalid explicit Shaper target must never silently change which shapes get edited.
|
||||
#[test]
|
||||
fn shaper_scribble_rejects_malformed_explicit_targets() {
|
||||
let mut s = session();
|
||||
let a = rectangle(&mut s, 0.0, "#ff0000");
|
||||
let untouched = s.doc().unwrap().doc.layers.clone();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
for ids in [json!([a.0, "bad"]), json!([a.0, 999999]), json!({"id": a.0}), json!("bad")] {
|
||||
let err = s.execute("shaper.scribble", &json!({"points": [[25, 20], [35, 30]], "ids": ids}));
|
||||
assert!(err.is_err(), "malformed targets were accepted");
|
||||
assert_eq!(s.doc().unwrap().doc.layers, untouched);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
}
|
||||
s.execute("shaper.scribble", &json!({"points": [[25, 20], [35, 30]], "ids": [a.0]})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shaper_merges_with_origin_color_keeps_live_originals_and_undoes_once() {
|
||||
for reverse in [false, true] {
|
||||
|
||||
@@ -563,3 +563,35 @@ fn automatic_alignment_updates_during_edits_and_can_be_overridden() {
|
||||
legacy["para"]["justify"] = json!("Left");
|
||||
assert_eq!(serde_json::from_value::<TextObject>(legacy).unwrap().para.justify, vectorcraft_doc::Justify::Left);
|
||||
}
|
||||
|
||||
/// #1063: a fill (or stroke) chosen while the Type tool has characters selected colours just
|
||||
/// those characters, as one undo step; the rest of the type keeps its colour.
|
||||
#[test]
|
||||
fn a_fill_chosen_with_characters_selected_colours_only_them() {
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("type", v).unwrap();
|
||||
click(&mut s, 50.0, 300.0);
|
||||
let id = s.doc().unwrap().selection.objects[0];
|
||||
s.tool_text("Sample Text", v).unwrap();
|
||||
let before = obj(&s, id).runs[0].style.fill.clone();
|
||||
s.set_tool_option("select", &json!({"start": 0, "end": 6}));
|
||||
s.execute("paint.setFill", &json!({"color": "#00a000"})).unwrap();
|
||||
let t = obj(&s, id);
|
||||
assert_eq!(t.runs.len(), 2);
|
||||
assert_eq!(t.runs[0].text, "Sample");
|
||||
assert_eq!(t.runs[0].style.fill, Paint::solid(vectorcraft_color::Color::rgb8(0, 160, 0)));
|
||||
assert_eq!(t.runs[1].text, " Text");
|
||||
assert_eq!(t.runs[1].style.fill, before);
|
||||
// A stroke goes on the selected characters only, with a weight to show.
|
||||
s.execute("paint.setStroke", &json!({"color": "#ff0000"})).unwrap();
|
||||
let t = obj(&s, id);
|
||||
assert!(t.runs[0].style.stroke_width > 0.0 && !t.runs[0].style.stroke.is_none());
|
||||
assert!(t.runs[1].style.stroke.is_none());
|
||||
// One undo step each.
|
||||
let labels = undo_labels(&s);
|
||||
assert_eq!(&labels[labels.len() - 2..], ["Fill Color", "Stroke Color"]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(obj(&s, id).runs.iter().all(|r| r.style.fill == before));
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
//! #1034: Character sizes and horizontal scales account for the object's affine transform.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{NodeId, NodeKind, TextObject};
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn create(s: &mut Session, size: f64) -> NodeId {
|
||||
NodeId(s.execute("text.create", &json!({"x": 40, "y": 60, "text": "Axis label", "size": size})).unwrap()["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn text(s: &Session, id: NodeId) -> TextObject {
|
||||
match &s.doc().unwrap().doc.node(id).unwrap().kind {
|
||||
NodeKind::Text(t) => (**t).clone(),
|
||||
_ => panic!("not text"),
|
||||
}
|
||||
}
|
||||
|
||||
fn em(t: &TextObject, run: usize) -> (f64, f64) {
|
||||
let st = &t.runs[run].style;
|
||||
let origin = t.xf * Point::ZERO;
|
||||
((t.xf * Point::new(st.size * st.h_scale / 100.0, 0.0) - origin).hypot(), (t.xf * Point::new(0.0, st.size * st.v_scale / 100.0) - origin).hypot())
|
||||
}
|
||||
|
||||
fn near(actual: f64, expected: f64) {
|
||||
assert!((actual - expected).abs() < 1e-8, "{actual} != {expected}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaled_font_size_setter_sets_document_points_and_undoes() {
|
||||
let mut s = session();
|
||||
let id = create(&mut s, 12.0);
|
||||
s.execute("object.scale", &json!({"sx": 200})).unwrap();
|
||||
s.execute("object.scale", &json!({"sx": 150})).unwrap();
|
||||
let before = text(&s, id);
|
||||
near(em(&before, 0).1, 36.0);
|
||||
s.execute("text.setStyle", &json!({"size": 10})).unwrap();
|
||||
let after = text(&s, id);
|
||||
near(em(&after, 0).1, 10.0);
|
||||
assert_eq!(after.xf, before.xf, "font editing preserves placement and rotation");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(text(&s, id), before);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(text(&s, id), after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_font_size_setter_handles_different_accumulated_scales() {
|
||||
let mut s = session();
|
||||
let a = create(&mut s, 12.0);
|
||||
s.execute("object.scale", &json!({"sx": 200})).unwrap();
|
||||
let b = create(&mut s, 18.0);
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
s.execute("object.group", &json!({})).unwrap();
|
||||
s.execute("object.group", &json!({})).unwrap();
|
||||
s.execute("object.scale", &json!({"sx": 200})).unwrap();
|
||||
near(em(&text(&s, a), 0).1, 48.0);
|
||||
near(em(&text(&s, b), 0).1, 36.0);
|
||||
s.execute("text.setStyle", &json!({"size": 10})).unwrap();
|
||||
near(em(&text(&s, a), 0).1, 10.0);
|
||||
near(em(&text(&s, b), 0).1, 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaled_range_size_and_keyboard_steps_use_document_points() {
|
||||
let mut s = session();
|
||||
let id = create(&mut s, 12.0);
|
||||
s.execute("object.transform", &json!({"matrix": [2, 0, 0, 2, 0, 0]})).unwrap();
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 4, "size": 10})).unwrap();
|
||||
near(em(&text(&s, id), 0).1, 10.0);
|
||||
near(em(&text(&s, id), 1).1, 24.0);
|
||||
s.prefs.type_size_increment = 2.0;
|
||||
s.execute("type.step", &json!({"attribute": "size", "id": id.0, "start": 0, "end": 4})).unwrap();
|
||||
near(em(&text(&s, id), 0).1, 12.0);
|
||||
near(em(&text(&s, id), 1).1, 24.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizontal_scale_reset_removes_object_stretch_including_groups() {
|
||||
for grouped in [false, true] {
|
||||
let mut s = session();
|
||||
let id = create(&mut s, 12.0);
|
||||
if grouped {
|
||||
s.execute("object.group", &json!({})).unwrap();
|
||||
}
|
||||
s.execute("object.scale", &json!({"sx": 200, "sy": 100})).unwrap();
|
||||
let before = text(&s, id);
|
||||
s.execute("text.setFormat", &json!({"hScale": 100, "vScale": 100})).unwrap();
|
||||
let after = text(&s, id);
|
||||
near(em(&after, 0).0, 12.0);
|
||||
near(em(&after, 0).1, 12.0);
|
||||
assert_eq!(after.xf, before.xf);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_reflected_nonuniform_range_edits_preserve_the_affine() {
|
||||
let mut s = session();
|
||||
let id = create(&mut s, 12.0);
|
||||
// A quarter turn, reflected in the baseline axis: horizontal ×2, vertical ×3.
|
||||
s.execute("object.transform", &json!({"matrix": [0, -2, -3, 0, 0, 0]})).unwrap();
|
||||
let before = text(&s, id);
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "size": 10, "hScale": 100, "vScale": 100})).unwrap();
|
||||
let after = text(&s, id);
|
||||
near(em(&after, 0).0, 10.0);
|
||||
near(em(&after, 0).1, 10.0);
|
||||
assert_eq!(after.xf, before.xf);
|
||||
// The native document representation retains both the compensation and the affine.
|
||||
let bytes = vectorcraft_format::save_file(&s.doc().unwrap().doc);
|
||||
let reopened = vectorcraft_format::load(&bytes).unwrap();
|
||||
let NodeKind::Text(t) = &reopened.node(id).unwrap().kind else { panic!("not text") };
|
||||
near(em(t, 0).0, 10.0);
|
||||
near(em(t, 0).1, 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_range_query_leaves_raw_runs_available_for_clipboard() {
|
||||
let mut s = session();
|
||||
let id = create(&mut s, 12.0);
|
||||
s.execute("object.scale", &json!({"sx": 300, "sy": 200})).unwrap();
|
||||
let query = |s: &mut Session, effective| -> Value {
|
||||
s.execute("text.getRange", &json!({"id": id.0, "effective": effective})).unwrap()["runs"][0]["style"].clone()
|
||||
};
|
||||
let effective = query(&mut s, true);
|
||||
assert_eq!(effective["size"], 24.0);
|
||||
assert_eq!(effective["h_scale"], 150.0);
|
||||
let raw = query(&mut s, false);
|
||||
assert_eq!(raw["size"], 12.0);
|
||||
assert_eq!(raw["h_scale"], 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaled_point_units_and_small_sizes_are_converted_before_local_storage() {
|
||||
let mut s = session();
|
||||
let id = create(&mut s, 12.0);
|
||||
s.execute("object.scale", &json!({"sx": 200})).unwrap();
|
||||
s.execute("text.setStyle", &json!({"size": 0.1, "leading": 14})).unwrap();
|
||||
let t = text(&s, id);
|
||||
near(em(&t, 0).1, 0.1);
|
||||
near(t.runs[0].style.leading.unwrap() * 2.0, 14.0);
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "size": 12, "baselineShift": 4, "leading": 16})).unwrap();
|
||||
let t = text(&s, id);
|
||||
near(t.runs[0].style.baseline_shift * 2.0, 4.0);
|
||||
near(t.runs[0].style.leading.unwrap() * 2.0, 16.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn singular_text_size_edit_returns_an_error_and_rolls_back_the_group() {
|
||||
let mut s = session();
|
||||
let a = create(&mut s, 12.0);
|
||||
let b = create(&mut s, 18.0);
|
||||
s.execute("object.transform", &json!({"matrix": [0, 0, 0, 0, 0, 0]})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
s.execute("object.group", &json!({})).unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let history = s.doc().unwrap().history.undo.len();
|
||||
assert!(s.execute("text.setStyle", &json!({"size": 10})).is_err());
|
||||
assert_eq!(s.doc().unwrap().doc, before);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), history);
|
||||
assert!(s.execute("text.setRangeStyle", &json!({"id": b.0, "size": 10})).is_err());
|
||||
assert!(s.execute("text.setFormat", &json!({"id": b.0, "hScale": 100})).is_err());
|
||||
// Non-dimensional edits remain possible, including on imported collapsed text.
|
||||
s.execute("text.setStyle", &json!({"fill": "#123456"})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sheared_text_keeps_its_affine_and_accepts_exact_em_dimensions() {
|
||||
let mut s = session();
|
||||
let id = create(&mut s, 12.0);
|
||||
s.execute("object.transform", &json!({"matrix": [2, 0, 1, 2, 0, 0]})).unwrap();
|
||||
let before = text(&s, id);
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "size": 10, "hScale": 100})).unwrap();
|
||||
let after = text(&s, id);
|
||||
near(em(&after, 0).0, 10.0);
|
||||
near(em(&after, 0).1, 10.0);
|
||||
assert_eq!(after.xf, before.xf);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn area_frame_resize_reflows_at_same_size_and_object_scaling_reports_scaled_size() {
|
||||
let mut s = session();
|
||||
let id = NodeId(
|
||||
s.execute("text.create", &json!({"x": 40, "y": 60, "text": "Area label", "size": 12, "area": {"width": 120, "height": 80}})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap(),
|
||||
);
|
||||
s.execute("object.transform", &json!({"matrix": [2, 0, 0, 2, 0, 0], "typeAreas": true})).unwrap();
|
||||
near(em(&text(&s, id), 0).1, 12.0);
|
||||
s.execute("object.scale", &json!({"sx": 200})).unwrap();
|
||||
near(em(&text(&s, id), 0).1, 24.0);
|
||||
s.execute("text.setStyle", &json!({"size": 10})).unwrap();
|
||||
near(em(&text(&s, id), 0).1, 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_based_keyboard_increments_and_format_fields_follow_document_units() {
|
||||
let mut s = session();
|
||||
let id = create(&mut s, 12.0);
|
||||
s.execute("object.scale", &json!({"sx": 200})).unwrap();
|
||||
s.execute("text.setStyle", &json!({"leading": 14})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"baselineShift": 4})).unwrap();
|
||||
s.prefs.type_size_increment = 2.0;
|
||||
s.prefs.baseline_shift_increment = 3.0;
|
||||
s.execute("type.step", &json!({"attribute": "leading"})).unwrap();
|
||||
s.execute("type.step", &json!({"attribute": "baselineShift"})).unwrap();
|
||||
let t = text(&s, id);
|
||||
near(t.runs[0].style.leading.unwrap() * 2.0, 16.0);
|
||||
near(t.runs[0].style.baseline_shift * 2.0, 7.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiny_finite_transform_rejects_unsafe_compensation_without_history_changes() {
|
||||
let mut s = session();
|
||||
let id = create(&mut s, 12.0);
|
||||
s.execute("object.transform", &json!({"matrix": [1e-306, 0, 0, 1e-306, 0, 0]})).unwrap();
|
||||
let before = text(&s, id);
|
||||
let history = s.doc().unwrap().history.undo.len();
|
||||
let error = s.execute("text.setStyle", &json!({"size": 160})).unwrap_err();
|
||||
assert!(matches!(error, EngineError::BadParams { .. }), "reject before attempting unsafe layout: {error}");
|
||||
assert_eq!(text(&s, id), before);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), history);
|
||||
}
|
||||
@@ -30,6 +30,51 @@ fn dark(s: &Session, x: u32, y: u32) -> bool {
|
||||
vectorcraft_render::Renderer::new().render(&doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &opts).pixel(x, y)[0] < 128
|
||||
}
|
||||
|
||||
/// Invalid entries in an explicit multi-point delete must not delete the valid entries.
|
||||
#[test]
|
||||
fn invalid_width_point_indices_leave_the_entire_profile_unchanged() {
|
||||
let (mut s, id) = line_session();
|
||||
set(&mut s, json!({"id": id.0, "t": 0.5, "left": 10, "right": 10}));
|
||||
let before = points(&s, id);
|
||||
let history = s.doc().unwrap().history.undo.len();
|
||||
for bad in [
|
||||
json!({"indices": [1, "bad"]}),
|
||||
json!({"indices": [-1, 1]}),
|
||||
json!({"indices": [1.5, 1]}),
|
||||
json!({"indices": {"point": 1}}),
|
||||
json!({"index": "bad"}),
|
||||
] {
|
||||
let mut params = bad.as_object().unwrap().clone();
|
||||
params.insert("id".into(), json!(id.0));
|
||||
let err = s.execute("stroke.widthPoint.remove", &serde_json::Value::Object(params));
|
||||
assert!(err.is_err(), "accepted malformed width point indices");
|
||||
assert_eq!(points(&s, id), before);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), history);
|
||||
}
|
||||
s.execute("stroke.widthPoint.remove", &json!({"id": id.0, "indices": [1]})).unwrap();
|
||||
assert_eq!(points(&s, id).len(), before.len() - 1);
|
||||
}
|
||||
|
||||
/// Setting or copying a width point with a malformed `index` is refused like one out of range: it
|
||||
/// never adds a point instead, or wraps a huge index round. A null `index` is no index (a new point).
|
||||
#[test]
|
||||
fn a_malformed_width_point_index_is_refused_by_set_and_copy() {
|
||||
let (mut s, id) = line_session();
|
||||
set(&mut s, json!({"id": id.0, "t": 0.5, "left": 10, "right": 10}));
|
||||
let before = points(&s, id);
|
||||
let history = s.doc().unwrap().history.undo.len();
|
||||
for index in [json!("bad"), json!(-1), json!(1.5), json!([1]), json!(u64::MAX), json!(before.len())] {
|
||||
for cmd in ["stroke.widthPoint.set", "stroke.widthPoint.copy"] {
|
||||
let r = s.execute(cmd, &json!({"id": id.0, "index": index, "t": 0.3, "left": 4, "right": 4}));
|
||||
assert!(r.is_err(), "{cmd} took index {index}");
|
||||
assert_eq!(points(&s, id), before, "{cmd} with index {index}");
|
||||
}
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), history);
|
||||
set(&mut s, json!({"id": id.0, "index": null, "t": 0.3, "left": 4, "right": 4}));
|
||||
assert_eq!(points(&s, id).len(), before.len() + 1, "a null index adds a point");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_point_moved_onto_another_makes_a_discontinuous_point_that_renders_a_step() {
|
||||
let (mut s, id) = line_session();
|
||||
|
||||
@@ -81,23 +81,6 @@ fn every_command_without_a_document() {
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
/// Known bugs (see the `#[ignore]` repro tests below): (command, param key) pairs skipped by the
|
||||
/// junk sweep so the rest of the sweep stays meaningful.
|
||||
const KNOWN_BUGS: &[(&str, &str)] = &[
|
||||
("object.scale", "sx"),
|
||||
("object.transformEach", "scaleH"),
|
||||
("object.transformEach", "scaleV"),
|
||||
("object.distributeSpacing", "spacing"),
|
||||
("artboard.setProps", "x"),
|
||||
("artboard.setProps", "y"),
|
||||
("artboard.setProps", "width"),
|
||||
("artboard.setProps", "height"),
|
||||
];
|
||||
|
||||
fn known_bug(id: &str, p: &Value) -> bool {
|
||||
KNOWN_BUGS.iter().any(|(c, k)| *c == id && p.get(*k).is_some())
|
||||
}
|
||||
|
||||
/// Regression: huge column counts used to panic with `capacity overflow`; in every layout and
|
||||
/// order (#681), with huge and negative spacing too.
|
||||
#[test]
|
||||
@@ -157,9 +140,6 @@ fn every_command_with_junk_params() {
|
||||
cases.truncate(12);
|
||||
}
|
||||
for p in &cases {
|
||||
if known_bug(c.id, p) {
|
||||
continue;
|
||||
}
|
||||
calls += 1;
|
||||
if let Some(f) = probe(fx, c.id, p) {
|
||||
failures.push(f);
|
||||
@@ -494,9 +474,6 @@ fn fuzzed_calls_keep_documents_serializable() {
|
||||
let k = if k == "path" { continue } else { k };
|
||||
let mut s = Fixture::Multi.session();
|
||||
let p = json!({ k.clone(): v });
|
||||
if known_bug(c.id, &p) {
|
||||
continue;
|
||||
}
|
||||
let ok = catch_quiet(|| s.execute(c.id, &p)).map(|r| r.is_ok()).unwrap_or(false);
|
||||
if ok && let Err(e) = catch_quiet(|| check_all(&mut s)).unwrap_or_else(|m| Err(format!("panic in checks: {m}"))) {
|
||||
failures.push(format!("{} {p}: {e}", c.id));
|
||||
|
||||
@@ -1280,11 +1280,17 @@ proptest! {
|
||||
|
||||
/// Hostile PostScript programs: operators in any order with any operands, opened and placed;
|
||||
/// as files in the legacy Illustrator format too (the creator line is what turns on its
|
||||
/// documented `u` … `U` groups), the groups unbalanced, deep and among clips.
|
||||
/// documented `u` … `U` groups), the groups unbalanced, deep and among clips; in a layer, which
|
||||
/// the editing data reader reads from the program too.
|
||||
#[test]
|
||||
fn eps_hostile_programs_never_panic(tokens in prop::collection::vec(arb_ps_token(), 0..60), illustrator in any::<bool>()) {
|
||||
fn eps_hostile_programs_never_panic(tokens in prop::collection::vec(arb_ps_token(), 0..60), illustrator in any::<bool>(), layers in any::<bool>()) {
|
||||
let head = if illustrator { "%%Creator: Adobe Illustrator(R) 8.0\n%%EndComments\n/u {} def /U {} def" } else { "%%EndComments" };
|
||||
let ps = format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 200\n{head}\n{}\nshowpage\n", tokens.join(" "));
|
||||
let (open, close) = if layers {
|
||||
("/Lb {10 {pop} repeat} def /Ln {pop} def /LB {} def\n%AI5_BeginLayer\n1 1 1 1 0 0 -1 0 0 0 Lb\n(L) Ln\n", "\nLB\n%AI5_EndLayer--")
|
||||
} else {
|
||||
("", "")
|
||||
};
|
||||
let ps = format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 200\n{head}\n{open}{}{close}\nshowpage\n", tokens.join(" "));
|
||||
survive("hostile PostScript", || vectorcraft_engine::cmd::fileio::load("x.eps", ps.as_bytes()).ok().map(|l| l.doc))?;
|
||||
let r = catch_quiet(|| {
|
||||
let mut s = rich_session();
|
||||
@@ -1702,6 +1708,9 @@ fn ascii85(data: &[u8]) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// A pattern's definition in editing data: its name and tile, its art on `%_` lines, its end.
|
||||
const PATTERN: &str = "%AI3_BeginPattern: (Dots)\n(Dots) 0 0 6 6\n%_0 A\n%_0 Ae\n%_u\n%_0 0 m\n%_0 6 L\n%_6 6 L\n%_6 0 L\n%_n\n%_0 0 0 1 K\n%_1 J 1 w\n%_3 3 m\n%_3 3 L\n%_S\n%_U\n%_9 () XW\nE\n%AI3_EndPattern\n";
|
||||
|
||||
/// The editing data of a file with every sort of object the layers reader knows, and some it doesn't.
|
||||
fn editing_text() -> String {
|
||||
let square = |x: u32| format!("0 0 1 0 k\n{x} 10 m\n{} 10 L\n{} 14 L\n{x} 14 L\nf", x + 4, x + 4);
|
||||
@@ -1724,13 +1733,16 @@ fn editing_text() -> String {
|
||||
"0 Xw".into(),
|
||||
"1 0 0 0 1 0 Bg".into(),
|
||||
"0 1 w 2 J 0 j 4 M [3 2]0 d 1 D".into(),
|
||||
"0 0 0 1 (Black global) 0.4 0 Xk".into(),
|
||||
"(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p".into(),
|
||||
square(60),
|
||||
]
|
||||
.join("\n");
|
||||
let layer = |name: &str, visible: u8, body: &str| {
|
||||
format!("%AI5_BeginLayer\n{visible} 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n({name}) Ln\n{body}\nLB\n%AI5_EndLayer--\n")
|
||||
};
|
||||
format!(
|
||||
"%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%HiResBoundingBox: 0 0 100 100\n%AI3_Cropmarks: 0 0 100 100\n{}{}%%Trailer\n",
|
||||
"%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%HiResBoundingBox: 0 0 100 100\n%AI3_Cropmarks: 0 0 100 100\n{PATTERN}{}{}%%Trailer\n",
|
||||
layer("One", 1, &format!("{body}\n{}", layer("Sub", 1, &square(70)))),
|
||||
layer("Two", 0, &square(80))
|
||||
)
|
||||
@@ -1872,6 +1884,35 @@ proptest! {
|
||||
}
|
||||
}
|
||||
|
||||
/// A `.ai` in PostScript form as other apps write it: the procsets that define its operators named,
|
||||
/// not included, so its program can't be run and its layers are read from it.
|
||||
const NAMED_PROLOG_AI: &str = "%!PS-Adobe-3.0\n%%BoundingBox: 0 0 200 100\n%%DocumentNeededResources: procset Adobe_packedarray 2.0 0\n%AI3_TemplateBox: 288 384 288 384\n%%EndComments\n\
|
||||
%%BeginProlog\n%%IncludeResource: procset Adobe_packedarray 2.0 0\nAdobe_packedarray /initialize get exec\n%%EndProlog\n%%BeginSetup\n%AI5_Begin_NonPrinting\nNp\n%AI5_End_NonPrinting--\n%%EndSetup\n\
|
||||
%AI5_BeginLayer\n1 1 1 1 0 0 -1 191 63 255 Lb\n(A::B) Ln\n0 A\n0 R\n0 0 0 1 K\n0 i 1 J 1 j 0.8 w 4 M [2 1]0 d\n0 D\n10 10 m\n90 10 L\n90 90 L\n10 90 L\n10 10 L\nS\n\
|
||||
u\n0.5 g\n20 20 m\n40 20 L\n40 40 L\nf\nU\n%AI5_BeginLayer\n0 1 1 1 0 0 3 0 0 0 Lb\n(Sub) Ln\n110 10 m\n190 90 L\nS\nLB\n%AI5_EndLayer--\nLB\n%AI5_EndLayer--\n\
|
||||
%%PageTrailer\ngsave annotatepage grestore showpage\n%%Trailer\n%%EOF\n";
|
||||
|
||||
/// The fixture, as it is, opens with its layer and sublayer.
|
||||
#[test]
|
||||
fn the_named_prolog_fixture_opens_with_its_layers() {
|
||||
let l = vectorcraft_engine::cmd::fileio::load("x.ai", NAMED_PROLOG_AI.as_bytes()).unwrap();
|
||||
let names: Vec<_> = l.doc.layers.iter().map(|l| l.name.clone()).collect();
|
||||
assert_eq!(names, [Some("A::B".to_string())], "{:?}", l.warnings);
|
||||
assert_eq!(l.doc.layers[0].children().unwrap().len(), 3);
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(config())]
|
||||
|
||||
/// A `.ai` in PostScript form whose prolog is named, not included, damaged: read from its
|
||||
/// layers, or refused.
|
||||
#[test]
|
||||
fn ps_form_ai_never_panics(cut in 0usize..1_200, edits in prop::collection::vec((0usize..1_200, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', '[', ']', '%', 'u', 'U', 'L', 'b', 'B', 'i', 'A', 'K'])), 0..12)) {
|
||||
let text = mutate_text(NAMED_PROLOG_AI, cut, &edits);
|
||||
survive("mutated PostScript-form .ai", || vectorcraft_engine::cmd::fileio::load("x.ai", text.as_bytes()).ok().map(|l| l.doc))?;
|
||||
}
|
||||
}
|
||||
|
||||
/// The operators, operands and section comments of hostile editing data.
|
||||
fn arb_ai_token() -> impl Strategy<Value = String> {
|
||||
prop_oneof![
|
||||
@@ -1980,6 +2021,16 @@ fn arb_ai_token() -> impl Strategy<Value = String> {
|
||||
"/Binary : /ASCII85Decode ,",
|
||||
"~>",
|
||||
"p",
|
||||
"P",
|
||||
"(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p",
|
||||
"(Dots) 0 0 1 1 0 0 0 0 0 [0 0 0 0 1e308 0] P",
|
||||
"Xk",
|
||||
"XK",
|
||||
"0 0 0 1 (Global) 0.4 0 Xk",
|
||||
"1 0.5 0 (Global) 0 1 XK",
|
||||
"E",
|
||||
"\n%AI3_BeginPattern: (Dots)\n(Dots) 0 0 6 6\n",
|
||||
"\n%AI3_EndPattern\n",
|
||||
"To",
|
||||
"frobnicate",
|
||||
"\n%AI5_BeginLayer\n",
|
||||
|
||||
@@ -81,6 +81,16 @@ impl<'a> Lexer<'a> {
|
||||
rest.get(..n).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Where the next token is looked for.
|
||||
pub fn offset(&self) -> usize {
|
||||
self.pos
|
||||
}
|
||||
|
||||
/// The data from `from` to `to` (empty where that isn't in it).
|
||||
pub fn slice(&self, from: usize, to: usize) -> &'a [u8] {
|
||||
self.src.get(from..to).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Skip past the next `needle` (the end of data that isn't tokens) → what was skipped.
|
||||
pub fn skip_past(&mut self, needle: &[u8]) -> &'a [u8] {
|
||||
let rest = self.rest();
|
||||
|
||||
@@ -27,7 +27,7 @@ use std::io::Read as _;
|
||||
use super::lex::find;
|
||||
use crate::ps;
|
||||
|
||||
pub use read::{Structure, read};
|
||||
pub use read::{Structure, artboards, read};
|
||||
pub(crate) use read::{slot_frame, slot_of};
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -52,6 +52,11 @@ pub fn eps_data(ps: &[u8]) -> Option<Result<Vec<u8>, String>> {
|
||||
Some(eps_section(section))
|
||||
}
|
||||
|
||||
/// Does the EPS `ps` carry editing data ([`eps_data`] isn't `None`)?
|
||||
pub fn has_eps_data(ps: &[u8]) -> bool {
|
||||
find(ps, EPS_BEGIN).is_some()
|
||||
}
|
||||
|
||||
/// The editing data between the EPS markers: header comments, then the data (encoded or not).
|
||||
fn eps_section(section: &[u8]) -> Result<Vec<u8>, String> {
|
||||
let mut header = Vec::new();
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
//!
|
||||
//! - Colours: `g`/`G` grey (1 is white), `k`/`K` CMYK, `x`/`X` a named CMYK colour at a tint
|
||||
//! (`c m y k (name) tint`; tint 0 is the full colour), `Xa`/`XA` RGB (`c m y k r g b`, the CMYK
|
||||
//! values its equivalent), `Xx`/`XX` a named colour of either model
|
||||
//! (`c m y k [r g b] (name) tint type`). Lower case paints fills, upper case strokes.
|
||||
//! values its equivalent), `Xx`/`XX` a named colour of any model
|
||||
//! (`c m y k [r g b | L a b] (name) tint type`, type 0 CMYK, 1 RGB, 2 Lab), `Xk`/`XK` a global
|
||||
//! process colour (its operands as `Xx`'s: a global swatch that isn't an ink of its own). Lower
|
||||
//! case paints fills, upper case strokes.
|
||||
//! - Gradient definitions: `(name) type stops Bd`, its stops as `colour style [opacity 6]
|
||||
//! midpoint ramp Bs` (style 0 grey, 1 CMYK, 2 RGB with its CMYK, 3 and 4 named colours), `BD`.
|
||||
//! - A gradient on an object: `[flag] Bb` (1: the stroke), `flag (name) x y angle length a b c d
|
||||
@@ -28,6 +30,8 @@ pub(super) struct Named {
|
||||
/// The full colour.
|
||||
pub color: Color,
|
||||
pub tint: f32,
|
||||
/// An ink of its own (a spot colour), not a global process colour.
|
||||
pub spot: bool,
|
||||
}
|
||||
|
||||
/// The paint of a colour operator's operands (`op`: its name, lower case or not), with the named
|
||||
@@ -48,12 +52,13 @@ pub(super) fn color_op(op: &str, vals: &[V]) -> Option<(Paint, Option<Named>)> {
|
||||
}
|
||||
"x" => named(vals, Some(false)),
|
||||
"xx" => named(vals, None),
|
||||
"xk" => named(vals, None).map(|(p, n)| (p, n.map(|n| Named { spot: false, ..n }))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `c m y k (name) tint x` or `c m y k [r g b] (name) tint type Xx` (type 1: RGB); `rgb` forces
|
||||
/// the model.
|
||||
/// `c m y k (name) tint x` or `c m y k [r g b | L a b] (name) tint type Xx` (type 1: RGB, 2: Lab);
|
||||
/// `rgb` forces RGB or CMYK.
|
||||
fn named(vals: &[V], rgb: Option<bool>) -> Option<(Paint, Option<Named>)> {
|
||||
let at = vals.iter().rposition(|v| matches!(v, V::Str(_)))?;
|
||||
let name = vals.get(at)?.text()?;
|
||||
@@ -62,9 +67,14 @@ fn named(vals: &[V], rgb: Option<bool>) -> Option<(Paint, Option<Named>)> {
|
||||
let unit = |v: f32| v.clamp(0.0, 1.0);
|
||||
let tint = 1.0 - unit(*after.first()?);
|
||||
let n = before.len();
|
||||
let color = if rgb.unwrap_or(after.get(1).is_some_and(|t| *t == 1.0)) {
|
||||
let kind = after.get(1).copied();
|
||||
let color = if rgb.unwrap_or(kind == Some(1.0)) {
|
||||
let [r, g, b] = before.get(n.checked_sub(3)?..)? else { return None };
|
||||
Color::rgb(unit(*r), unit(*g), unit(*b))
|
||||
} else if rgb.is_none() && kind == Some(2.0) {
|
||||
// Type 2: a colour defined in Lab, its L a b values after the CMYK equivalent (#1032).
|
||||
let [l, a, b] = before.get(n.checked_sub(3)?..)? else { return None };
|
||||
Color::lab(l.clamp(0.0, 100.0), a.clamp(-128.0, 127.0), b.clamp(-128.0, 127.0))
|
||||
} else {
|
||||
// The CMYK values come first (an RGB equivalent may follow them).
|
||||
let from = if n >= 7 { n - 7 } else { n.checked_sub(4)? };
|
||||
@@ -72,7 +82,7 @@ fn named(vals: &[V], rgb: Option<bool>) -> Option<(Paint, Option<Named>)> {
|
||||
Color::cmyk(unit(*c), unit(*m), unit(*y), unit(*k))
|
||||
};
|
||||
let paint = Paint::Solid { color: color.tinted(tint), swatch: Some(name.clone()), tint };
|
||||
Some((paint, Some(Named { name, color, tint })))
|
||||
Some((paint, Some(Named { name, color, tint, spot: true })))
|
||||
}
|
||||
|
||||
/// The colour of a gradient stop's colour values and style.
|
||||
@@ -162,7 +172,7 @@ pub(super) struct Instance {
|
||||
hilight: Vec2,
|
||||
}
|
||||
|
||||
fn affine(n: &[f64]) -> Option<Affine> {
|
||||
pub(super) fn affine(n: &[f64]) -> Option<Affine> {
|
||||
let [a, b, c, d, e, f] = n else { return None };
|
||||
let m = Affine::new([*a, *b, *c, *d, *e, *f]);
|
||||
(m.as_coeffs().iter().all(|v| v.is_finite())).then_some(m)
|
||||
@@ -247,6 +257,30 @@ mod tests {
|
||||
assert!((named.tint - 0.6).abs() < 1e-6);
|
||||
assert!(matches!(p, Paint::Solid { swatch: Some(_), .. }));
|
||||
assert!(color_op("k", &nums(&[1.0])).is_none());
|
||||
// A global process colour: CMYK (type 0) or RGB (type 1), not an ink.
|
||||
let mut v = nums(&[0.0, 0.0, 0.0, 1.0]);
|
||||
v.extend([V::Str(b"K100".to_vec()), V::Num(0.4), V::Num(0.0)]);
|
||||
let (p, named) = color_op("XK", &v).unwrap();
|
||||
let named = named.unwrap();
|
||||
assert_eq!((named.name.as_str(), named.color, named.spot), ("K100", Color::cmyk(0.0, 0.0, 0.0, 1.0), false));
|
||||
assert!((named.tint - 0.6).abs() < 1e-6);
|
||||
assert!(
|
||||
matches!(p, Paint::Solid { swatch: Some(s), tint, color: Color::Cmyk { k, .. } } if s == "K100" && (tint - 0.6).abs() < 1e-6 && (k - 0.6).abs() < 1e-6)
|
||||
);
|
||||
let mut v = nums(&[0.0, 0.0, 0.0, 0.0, 1.0, 0.5, 0.0]);
|
||||
v.extend([V::Str(b"Orange".to_vec()), V::Num(0.0), V::Num(1.0)]);
|
||||
let (p, _) = color_op("Xk", &v).unwrap();
|
||||
assert_eq!(p.color(), Some(Color::rgb(1.0, 0.5, 0.0)));
|
||||
// A spot colour defined in Lab (type 2): its L a b values, not its CMYK equivalent (#1032).
|
||||
let mut v = nums(&[0.52, 0.98, 0.28, 0.1, 32.9412, 45.0, -18.0]);
|
||||
v.extend([V::Str(b"Plum Ink".to_vec()), V::Num(0.0), V::Num(2.0)]);
|
||||
let (p, named) = color_op("Xx", &v).unwrap();
|
||||
let named = named.unwrap();
|
||||
assert_eq!((named.color, named.spot, named.tint), (Color::lab(32.9412, 45.0, -18.0), true, 1.0));
|
||||
assert_eq!(p.color(), Some(Color::lab(32.9412, 45.0, -18.0)));
|
||||
v[8] = V::Num(0.25);
|
||||
let (_, named) = color_op("XX", &v).unwrap();
|
||||
assert_eq!(named.map(|n| (n.color, n.tint)), Some((Color::lab(32.9412, 45.0, -18.0), 0.75)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -2,23 +2,31 @@
|
||||
//!
|
||||
//! - Art space has y up; the first artboard's top left corner is the document's origin.
|
||||
//! - Layers: `%AI5_BeginLayer`, `visible preview unlocked printing dimmed … colorIndex r g b …
|
||||
//! dimPercent … Lb`, `(name) Ln`, its art, `LB`.
|
||||
//! dimPercent … Lb` (ten operands in the legacy format, without the one before the colour
|
||||
//! index), `(name) Ln`, its art, `LB`.
|
||||
//! - Groups `u` … `U`, compound paths `*u` … `*U`, clipping groups `q` … `Q` (the path painted
|
||||
//! with `W` clips); a group's transparency and name follow its end.
|
||||
//! - Paths: `m`, `l`/`L`, `c`/`C`, `v`/`V`, `y`/`Y`, painted by `N n F f S s B b` (lower case
|
||||
//! closes), `(op) *` for a guide. Stroke `w J j M d`, fill rule `XR`, overprint `O`/`R`.
|
||||
//! - State: hidden `Xw`, locked `A`, transparency `mode opacity isolate knockout shape Xy`.
|
||||
//! - Names: an `/ArtDictionary` after an object, its `AI10_ArtUID` the name's XML id.
|
||||
//! - Artboards: the `ArtboardArray` of the `/Document` dictionary, else `%AI3_Cropmarks`.
|
||||
//! - Artboards: the `ArtboardArray` of the `/Document` dictionary, else `%AI3_Cropmarks`, else
|
||||
//! the art size around the template box's centre, else (a file in the legacy format another
|
||||
//! app wrote) the bounding box.
|
||||
//! - Images: `%AI5_BeginRaster`, the colour space `XN`, `[matrix] bounds w h bits type alpha …`
|
||||
//! and the samples after `XI`.
|
||||
//! - Art with an appearance the format can't write as plain art (several fills or strokes,
|
||||
//! effects, brushes) comes first as that look, drawn (a group), then the object itself on `%_`
|
||||
//! lines and `1 (style) XW`: the drawn look is kept, named after the object.
|
||||
//! - Patterns: `%AI3_BeginPattern: (name)`, `(name) llx lly urx ury` (the tile, in the pattern's
|
||||
//! space, y up), the tile's art (on `%_` lines in newer files), `E`, `%AI3_EndPattern`; each is
|
||||
//! read by a reader of its own and becomes a pattern swatch. A fill or stroke with one:
|
||||
//! `(name) x y scaleX scaleY angle reflect axis shear shearAxis [matrix] p` (`P`), the matrix
|
||||
//! taking the pattern's space to art space (the other operands are what it is made of).
|
||||
//!
|
||||
//! Sections the reader doesn't need (patterns, brushes, symbols, styles, swatches, filters) are
|
||||
//! skipped; type, symbols, pattern fills, placed files, legacy type and operators it doesn't know
|
||||
//! are errors.
|
||||
//! Sections the reader doesn't need (brushes, symbols, styles, swatches, filters) are skipped;
|
||||
//! type, symbols, fills with a pattern it can't read, placed files, legacy type and operators it
|
||||
//! doesn't know are errors.
|
||||
|
||||
use std::collections::{BTreeSet, HashMap, HashSet};
|
||||
use std::rc::Rc;
|
||||
@@ -30,7 +38,7 @@ use vectorcraft_color::swatch::Swatch;
|
||||
use vectorcraft_color::{BlendMode, Paint};
|
||||
use vectorcraft_doc::{
|
||||
Appearance, AppearanceItem, Artboard, ColorMode, Dash, Document, FillLayer, ImageBlob, ImageObject, Knockout, LayerColor, LineCap, LineJoin,
|
||||
Node, NodeId, NodeKind, StrokeLayer,
|
||||
Node, NodeId, NodeKind, PatternDef, StrokeLayer,
|
||||
};
|
||||
use vectorcraft_geom::{FillRule, PathData};
|
||||
|
||||
@@ -51,6 +59,10 @@ const MAX_POINTS: usize = 4_000_000;
|
||||
const MAX_PIXELS: u64 = 1 << 25;
|
||||
/// Largest side of the document and its artboards (points).
|
||||
const MAX_SIDE: f64 = 1e6;
|
||||
/// Art space (y up) → a pattern's space in the document's terms (y down).
|
||||
const FLIP: Affine = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]);
|
||||
/// Most patterns read (others can't fill art).
|
||||
const MAX_PATTERNS: usize = 4096;
|
||||
|
||||
/// What the editing data holds: the document of its art, and where its art space is.
|
||||
#[derive(Debug)]
|
||||
@@ -94,6 +106,15 @@ pub fn read(data: &[u8]) -> Result<Structure, String> {
|
||||
r.finish()
|
||||
}
|
||||
|
||||
/// The artboards (art space) of the editing data `data` that [`read`] can't read as a whole, as far
|
||||
/// as it gets: where to put the pages of its PDF part instead (#1068).
|
||||
pub fn artboards(data: &[u8]) -> Vec<Rect> {
|
||||
let mut r = Reader::new(data);
|
||||
// Artboards read before an error still say where they are.
|
||||
let _ = r.run();
|
||||
r.artboards.into_iter().map(|(_, rect)| rect).collect()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct GState {
|
||||
fill: Paint,
|
||||
@@ -197,6 +218,16 @@ struct Frame {
|
||||
clips: Vec<usize>,
|
||||
}
|
||||
|
||||
/// What a reader of a pattern's definition has of its head, `(name) llx lly urx ury`: the operands
|
||||
/// before its first operator.
|
||||
#[derive(Debug, Default)]
|
||||
struct PatternHead {
|
||||
taken: bool,
|
||||
name: String,
|
||||
/// The tile in the pattern's space (y up).
|
||||
tile: Option<Rect>,
|
||||
}
|
||||
|
||||
/// An image's operands, from `XN` and before `XI`.
|
||||
#[derive(Debug, Default)]
|
||||
struct Raster {
|
||||
@@ -260,6 +291,10 @@ struct Reader<'a> {
|
||||
/// The artboard's size and centre in files without artboards or crop marks.
|
||||
art_size: Option<(f64, f64)>,
|
||||
template_center: Option<(f64, f64)>,
|
||||
/// This reader reads a pattern's definition (see [`Reader::define_pattern`]).
|
||||
pattern: Option<PatternHead>,
|
||||
/// The tile's top left (y down) of each pattern read, by name.
|
||||
pattern_tiles: HashMap<String, Point>,
|
||||
}
|
||||
|
||||
/// Operators that change nothing the reader keeps.
|
||||
@@ -281,6 +316,8 @@ const IGNORED: &[&str] = &[
|
||||
"Xm",
|
||||
"XP",
|
||||
"Np",
|
||||
// Flatness (`i`): how finely curves print.
|
||||
"i",
|
||||
"TE",
|
||||
"TZ",
|
||||
"Xs",
|
||||
@@ -377,6 +414,8 @@ impl<'a> Reader<'a> {
|
||||
slot_names: HashMap::new(),
|
||||
art_size: None,
|
||||
template_center: None,
|
||||
pattern: None,
|
||||
pattern_tiles: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -429,6 +468,12 @@ impl<'a> Reader<'a> {
|
||||
let line = String::from_utf8_lossy(c);
|
||||
let line = line.trim_end();
|
||||
let m = marker(line);
|
||||
// A pattern's definition (one inside another's isn't read).
|
||||
if m == "BeginPattern" && self.pattern.is_none() {
|
||||
let section = self.section("EndPattern")?;
|
||||
self.define_pattern(section);
|
||||
return Ok(());
|
||||
}
|
||||
if let Some((_, end)) = SKIPPED.iter().find(|(b, _)| *b == m) {
|
||||
return self.skip_to(end);
|
||||
}
|
||||
@@ -481,10 +526,18 @@ impl<'a> Reader<'a> {
|
||||
/// Skip to the comment with marker `end` (sections of the same kind nested in it count, and
|
||||
/// versioned content in an alternate).
|
||||
fn skip_to(&mut self, end: &str) -> Result<(), String> {
|
||||
self.section(end).map(|_| ())
|
||||
}
|
||||
|
||||
/// [`Self::skip_to`] → what was skipped, up to the comment that ends it.
|
||||
fn section(&mut self, end: &str) -> Result<&'a [u8], String> {
|
||||
let begin = SKIPPED.iter().find(|(_, e)| *e == end).map_or("", |(b, _)| *b);
|
||||
let begin = if end == "End_Versioned_Content" { "Begin_Content_if_version_gt" } else { begin };
|
||||
let start = self.lex.offset();
|
||||
let mut depth = 0usize;
|
||||
while let Some(t) = self.lex.next_token() {
|
||||
loop {
|
||||
let before = self.lex.offset();
|
||||
let Some(t) = self.lex.next_token() else { break };
|
||||
if let Tok::Comment(c) = t.tok {
|
||||
let line = String::from_utf8_lossy(c);
|
||||
let m = marker(&line);
|
||||
@@ -492,7 +545,7 @@ impl<'a> Reader<'a> {
|
||||
depth += 1;
|
||||
} else if m == end {
|
||||
if depth == 0 {
|
||||
return Ok(());
|
||||
return Ok(self.lex.slice(start, before));
|
||||
}
|
||||
depth -= 1;
|
||||
}
|
||||
@@ -501,6 +554,58 @@ impl<'a> Reader<'a> {
|
||||
Err(format!("its section ending with `%{end}` doesn't end"))
|
||||
}
|
||||
|
||||
/// A pattern's definition (`section`: what follows `%AI3_BeginPattern`), read by a reader of its
|
||||
/// own into a pattern swatch, its art in the pattern's space (y down). A definition it can't
|
||||
/// read whole (an operator it doesn't know, type, unbalanced groups, no tile) isn't kept: art
|
||||
/// filled with it can't be read.
|
||||
fn define_pattern(&mut self, section: &[u8]) {
|
||||
if self.pattern_tiles.len() >= MAX_PATTERNS {
|
||||
return;
|
||||
}
|
||||
let mut sub = Reader::new(section);
|
||||
sub.pattern = Some(PatternHead::default());
|
||||
// The gradients defined so far, lent (and those it defines kept).
|
||||
sub.gradients = std::mem::take(&mut self.gradients);
|
||||
sub.nodes = self.nodes;
|
||||
let read = sub.open(Kind::Group, false).and_then(|()| sub.run());
|
||||
self.nodes = sub.nodes;
|
||||
self.gradients = std::mem::take(&mut sub.gradients);
|
||||
let whole = read.is_ok() && sub.unsupported.is_empty() && sub.unknown.is_empty() && sub.frames.len() == 1;
|
||||
let (Some(head), Some(frame)) = (sub.pattern.take(), sub.frames.pop()) else { return };
|
||||
let Some(tile) = head.tile.filter(|t| whole && sane(*t) && t.width() > 0.0 && t.height() > 0.0 && !frame.children.is_empty()) else {
|
||||
return;
|
||||
};
|
||||
let art: Vec<Arc<Node>> = frame.children.iter().map(|n| Arc::new(self.doc.reid(n))).collect();
|
||||
let mut def = PatternDef::new(&head.name, art);
|
||||
def.tile = FLIP.transform_rect_bbox(tile);
|
||||
if self.pattern_tiles.insert(def.name.clone(), def.tile.origin()).is_some() {
|
||||
self.doc.patterns.retain(|p| p.name != def.name);
|
||||
}
|
||||
self.doc.patterns.push(def);
|
||||
for (key, blob) in sub.doc.images {
|
||||
self.doc.images.entry(key).or_insert(blob);
|
||||
}
|
||||
self.spots.extend(sub.spots);
|
||||
self.cmyk_colors += sub.cmyk_colors;
|
||||
self.rgb_colors += sub.rgb_colors;
|
||||
for w in &sub.warnings {
|
||||
self.warn(w);
|
||||
}
|
||||
}
|
||||
|
||||
/// In a pattern's definition, the operands of its first operator start with its head,
|
||||
/// `(name) llx lly urx ury`: taken off them.
|
||||
fn take_head(&mut self, mut vals: Vec<V>) -> Vec<V> {
|
||||
let Some(head) = self.pattern.as_mut().filter(|h| !h.taken) else { return vals };
|
||||
head.taken = true;
|
||||
let Some(at) = vals.iter().position(|v| matches!(v, V::Str(_))) else { return vals };
|
||||
let tile = Self::nums(vals.get(at + 1..at + 5).unwrap_or_default());
|
||||
let ([x0, y0, x1, y1], Some(name)) = (tile.as_slice(), vals.get(at).and_then(V::text)) else { return vals };
|
||||
head.name = name;
|
||||
head.tile = Some(Rect::new(*x0, *y0, *x1, *y1));
|
||||
vals.split_off(at + 5)
|
||||
}
|
||||
|
||||
// ---- frames ----------------------------------------------------------------------------
|
||||
|
||||
fn open(&mut self, kind: Kind, hidden: bool) -> Result<(), String> {
|
||||
@@ -682,6 +787,7 @@ impl<'a> Reader<'a> {
|
||||
self.after_object = false;
|
||||
}
|
||||
let vals = self.operands();
|
||||
let vals = self.take_head(vals);
|
||||
if let Some(d) = self.gradient_def.as_mut() {
|
||||
match w {
|
||||
"Bs" => d.stop(&vals, &mut self.spots),
|
||||
@@ -728,8 +834,10 @@ impl<'a> Reader<'a> {
|
||||
self.paint(&op, hidden, true)?;
|
||||
}
|
||||
// Colours.
|
||||
"g" | "G" | "k" | "K" | "x" | "X" | "Xa" | "XA" | "Xx" | "XX" => self.color(w, &vals),
|
||||
"p" | "P" => self.unreadable("pattern fills"),
|
||||
"g" | "G" | "k" | "K" | "x" | "X" | "Xa" | "XA" | "Xx" | "XX" | "Xk" | "XK" => self.color(w, &vals),
|
||||
"p" | "P" => self.pattern_paint(w, &vals),
|
||||
// A pattern's definition ends.
|
||||
"E" if self.pattern.is_some() => self.done = true,
|
||||
"O" => self.gs.overprint_fill = Self::nums(&vals).last() == Some(&1.0),
|
||||
"R" => self.gs.overprint_stroke = Self::nums(&vals).last() == Some(&1.0),
|
||||
"XR" => self.gs.rule = if Self::nums(&vals).last() == Some(&1.0) { FillRule::EvenOdd } else { FillRule::NonZero },
|
||||
@@ -856,6 +964,9 @@ impl<'a> Reader<'a> {
|
||||
|
||||
/// A text object (`/AI11Text`): an empty group standing for it, named after its story.
|
||||
fn text_slot(&mut self, o: &Obj) -> Result<(), String> {
|
||||
if self.pattern.is_some() {
|
||||
self.unreadable("type in a pattern");
|
||||
}
|
||||
let index = |key: &str| o.nums(key).first().copied().filter(|v| (0.0..f64::from(u32::MAX)).contains(v)).map(|v| v as u32);
|
||||
let (story, frame) = (index("StoryIndex"), index("FrameIndex").unwrap_or(0));
|
||||
let gs = self.gs.clone();
|
||||
@@ -893,7 +1004,7 @@ impl<'a> Reader<'a> {
|
||||
self.warn(NON_NATIVE_ART_UNREAD);
|
||||
return Ok(());
|
||||
}
|
||||
let target = self.ensure_space().transform_rect_bbox(art_box);
|
||||
let target = self.space().transform_rect_bbox(art_box);
|
||||
let opts = vectorcraft_pdf::ImportOptions { layers: false, ..Default::default() };
|
||||
let read = match vectorcraft_pdf::import_with_report(&pdf, &opts) {
|
||||
Ok(r) => r,
|
||||
@@ -1001,7 +1112,8 @@ impl<'a> Reader<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `… Lb`: a layer's options.
|
||||
/// `… Lb`: a layer's options. The legacy format's has ten operands, its colour index one place
|
||||
/// earlier (`visible preview unlocked printing dimmed masks colorIndex r g b`).
|
||||
fn layer_attrs(&mut self, vals: &[V]) {
|
||||
let n = Self::nums(vals);
|
||||
let Some(Frame { kind: Kind::Layer(a), .. }) = self.frames.last_mut() else { return };
|
||||
@@ -1012,15 +1124,44 @@ impl<'a> Reader<'a> {
|
||||
a.printable = flag(3, true);
|
||||
let dim_pct = n.get(12).copied().filter(|v| v.is_finite()).unwrap_or(50.0).clamp(0.0, 100.0);
|
||||
a.dim = flag(4, false).then_some(dim_pct.round() as u8);
|
||||
let index = n.get(7).copied().unwrap_or(0.0);
|
||||
let rgb = |i: usize| n.get(i).map_or(0, |v| v.clamp(0.0, 255.0) as u8);
|
||||
let at = if n.len() <= 10 { 6 } else { 7 };
|
||||
let index = n.get(at).copied().unwrap_or(0.0);
|
||||
let rgb = |i: usize| n.get(at + i).map_or(0, |v| v.clamp(0.0, 255.0) as u8);
|
||||
a.color = Some(if (0.0..vectorcraft_doc::LAYER_COLORS.len() as f64).contains(&index) && index.fract() == 0.0 {
|
||||
LayerColor::Preset(index as u8)
|
||||
} else {
|
||||
LayerColor::Custom([rgb(8), rgb(9), rgb(10)])
|
||||
LayerColor::Custom([rgb(1), rgb(2), rgb(3)])
|
||||
});
|
||||
}
|
||||
|
||||
/// `(name) x y scaleX scaleY angle reflect axis shear shearAxis [matrix] p` (`P`: the stroke's):
|
||||
/// the pattern swatch `name`, its space taken to art space by the matrix. A pattern this hasn't
|
||||
/// read can't be drawn.
|
||||
fn pattern_paint(&mut self, w: &str, vals: &[V]) {
|
||||
// In the setup it paints nothing (and the art's space isn't fixed yet).
|
||||
if self.frames.is_empty() {
|
||||
return;
|
||||
}
|
||||
let name = vals.iter().find_map(|v| if let V::Str(_) = v { v.text() } else { None });
|
||||
let Some(origin) = name.as_deref().and_then(|n| self.pattern_tiles.get(n)).map(|p| p.to_vec2()) else {
|
||||
self.unreadable("pattern fills");
|
||||
return;
|
||||
};
|
||||
let matrix = vals.iter().rev().find_map(|v| if let V::Arr(a) = v { paint::affine(&Self::nums(a)) } else { None });
|
||||
// The tile's art (y down from the tile's top left) → the pattern's space → art space.
|
||||
let xf = self.space() * matrix.unwrap_or(Affine::IDENTITY) * FLIP * Affine::translate(origin);
|
||||
if xf.determinant().abs() < 1e-12 || !xf.as_coeffs().iter().all(|v| v.is_finite()) {
|
||||
self.unreadable("pattern fills");
|
||||
return;
|
||||
}
|
||||
let p = Paint::Pattern { pattern: name.unwrap_or_default(), xf };
|
||||
if w == "p" {
|
||||
self.gs.fill = p;
|
||||
} else {
|
||||
self.gs.stroke = p;
|
||||
}
|
||||
}
|
||||
|
||||
fn color(&mut self, w: &str, vals: &[V]) {
|
||||
let Some((p, named)) = paint::color_op(w, vals) else { return };
|
||||
match p.color() {
|
||||
@@ -1031,7 +1172,7 @@ impl<'a> Reader<'a> {
|
||||
if let Some(n) = named {
|
||||
self.spots.push(n);
|
||||
}
|
||||
if w.chars().all(|c| c.is_ascii_lowercase()) || w == "Xa" || w == "Xx" {
|
||||
if w.ends_with(|c: char| c.is_ascii_lowercase()) {
|
||||
self.gs.fill = p;
|
||||
} else {
|
||||
self.gs.stroke = p;
|
||||
@@ -1165,7 +1306,7 @@ impl<'a> Reader<'a> {
|
||||
if op.chars().next().is_some_and(|c| c.is_ascii_lowercase()) {
|
||||
close(&mut bp);
|
||||
}
|
||||
let to_doc = self.ensure_space();
|
||||
let to_doc = self.space();
|
||||
let path = PathData::from_bezpath(&(to_doc * bp));
|
||||
let gs = self.gs.clone();
|
||||
let (fill, stroke) = match op.to_ascii_lowercase().as_str() {
|
||||
@@ -1200,7 +1341,7 @@ impl<'a> Reader<'a> {
|
||||
}
|
||||
|
||||
fn set_instance(&mut self, i: &Instance) {
|
||||
let to_doc = self.ensure_space();
|
||||
let to_doc = self.space();
|
||||
match i.paint(&self.gradients, to_doc) {
|
||||
Some(p) if i.stroke => self.pending_stroke = Some(p),
|
||||
Some(p) => self.pending_fill = Some(p),
|
||||
@@ -1286,9 +1427,8 @@ impl<'a> Reader<'a> {
|
||||
}
|
||||
// The matrix maps pixels (y down) into art space with its y flipped.
|
||||
let [a, b, c, d, tx, ty] = m.as_slice() else { return Ok(()) };
|
||||
let to_doc = self.ensure_space();
|
||||
let flip = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]);
|
||||
let xf = to_doc * flip * Affine::new([*a, *b, *c, *d, *tx, -*ty]);
|
||||
let to_doc = self.space();
|
||||
let xf = to_doc * FLIP * Affine::new([*a, *b, *c, *d, *tx, -*ty]);
|
||||
if !xf.as_coeffs().iter().all(|v| v.is_finite()) || xf.determinant().abs() < 1e-12 {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -1314,18 +1454,36 @@ impl<'a> Reader<'a> {
|
||||
(sane(r) && w > 0.0 && h > 0.0).then_some(r)
|
||||
}
|
||||
|
||||
/// The first artboard: the `/Document`'s first, else the crop marks, else [`Self::sized_board`].
|
||||
fn first_board(&self) -> Option<Rect> {
|
||||
self.artboards.first().map(|(_, r)| *r).or(self.cropmarks).or_else(|| self.sized_board())
|
||||
}
|
||||
|
||||
/// The page of a file that says nothing of its artboard (the legacy format as other apps write
|
||||
/// it): its art's bounding box.
|
||||
fn bbox_board(&self) -> Option<Rect> {
|
||||
let [x0, y0, x1, y1] = self.hires.or(self.bbox)?;
|
||||
Some(Rect::new(x0, y0, x1, y1)).filter(|r| r.width() > 0.0 && r.height() > 0.0)
|
||||
}
|
||||
|
||||
/// Art space → the document (fixed when the art starts, from the artboards read by then).
|
||||
fn ensure_space(&mut self) -> Affine {
|
||||
if let Some(m) = self.to_doc {
|
||||
return m;
|
||||
}
|
||||
let first = self.artboards.first().map(|(_, r)| *r).or(self.cropmarks).or_else(|| self.sized_board());
|
||||
let first = self.first_board().or_else(|| self.bbox_board());
|
||||
let top_left = first.map_or(Point::new(0.0, 792.0), |r| Point::new(r.x0, r.y1));
|
||||
let m = Affine::new([1.0, 0.0, 0.0, -1.0, -top_left.x, top_left.y]);
|
||||
self.to_doc = Some(m);
|
||||
m
|
||||
}
|
||||
|
||||
/// Art space → where the art being read goes: the document, or while a pattern's definition is
|
||||
/// read, the pattern's space (y down).
|
||||
fn space(&mut self) -> Affine {
|
||||
if self.pattern.is_some() { FLIP } else { self.ensure_space() }
|
||||
}
|
||||
|
||||
fn warn(&mut self, w: &str) {
|
||||
if !self.warnings.iter().any(|x| x == w) && self.warnings.len() < 64 {
|
||||
self.warnings.push(w.to_string());
|
||||
@@ -1345,11 +1503,11 @@ impl<'a> Reader<'a> {
|
||||
return Err("it has no layers".into());
|
||||
}
|
||||
let to_doc = self.ensure_space();
|
||||
let artboard = self.artboards.first().map(|(_, r)| *r).or(self.cropmarks).or_else(|| self.sized_board());
|
||||
let artboard = self.first_board();
|
||||
drop_commented_copies(&mut self.doc.layers, &self.hidden_ids);
|
||||
let mut doc = std::mem::replace(&mut self.doc, Document::new(1.0, 1.0));
|
||||
let boards: Vec<(String, Rect)> = if self.artboards.is_empty() {
|
||||
self.cropmarks.or_else(|| self.sized_board()).map(|r| vec![("Artboard 1".to_string(), r)]).unwrap_or_default()
|
||||
artboard.or_else(|| self.bbox_board()).map(|r| vec![("Artboard 1".to_string(), r)]).unwrap_or_default()
|
||||
} else {
|
||||
self.artboards.clone()
|
||||
};
|
||||
@@ -1374,7 +1532,12 @@ impl<'a> Reader<'a> {
|
||||
}
|
||||
for n in &self.spots {
|
||||
if doc.swatch(&n.name).is_none() {
|
||||
doc.swatches.push(Swatch { name: n.name.clone(), paint: Paint::solid(n.color), global: true, spot: true });
|
||||
doc.swatches.push(Swatch { name: n.name.clone(), paint: Paint::solid(n.color), global: true, spot: n.spot });
|
||||
}
|
||||
}
|
||||
for name in doc.patterns.iter().map(|p| p.name.clone()).collect::<Vec<_>>() {
|
||||
if doc.swatch(&name).is_none() {
|
||||
doc.swatches.push(Swatch { paint: Paint::Pattern { pattern: name.clone(), xf: Affine::IDENTITY }, name, global: false, spot: false });
|
||||
}
|
||||
}
|
||||
let mut warnings = self.warnings;
|
||||
|
||||
@@ -132,6 +132,92 @@ fn paths_colours_and_strokes() {
|
||||
assert!(a[4].appearance.fill().is_none() && a[4].appearance.stroke().is_some());
|
||||
}
|
||||
|
||||
/// `Xk` and `XK` are global process colours: a name, a tint and whether the colour is RGB, as
|
||||
/// `Xx` has them (#1025). They come in as global swatches that aren't inks, linked at their tint.
|
||||
#[test]
|
||||
fn global_process_colours_are_global_swatches_at_their_tint() {
|
||||
let art_ = format!("0 0 0 1 (Black global) 0.4 0 Xk\n0 0 0 0 1 0.5 0 (Brand orange) 0 1 XK\n{}b\n", rect_path(0.0, 0.0, 10.0, 10.0));
|
||||
let doc = read_ok(&layer(&art_)).doc;
|
||||
let a = art(&doc);
|
||||
let Paint::Solid { swatch, tint, color } = fill(&a[0]) else { panic!("{:?}", fill(&a[0])) };
|
||||
assert_eq!(swatch.as_deref(), Some("Black global"));
|
||||
assert!((tint - 0.6).abs() < 1e-6 && matches!(color, Color::Cmyk { k, .. } if (k - 0.6).abs() < 1e-6), "{tint} {color:?}");
|
||||
let stroke = a[0].appearance.stroke().unwrap().paint.clone();
|
||||
assert!(
|
||||
matches!(&stroke, Paint::Solid { swatch: Some(s), tint, color } if s == "Brand orange" && *tint == 1.0 && *color == Color::rgb(1.0, 0.5, 0.0)),
|
||||
"{stroke:?}"
|
||||
);
|
||||
for name in ["Black global", "Brand orange"] {
|
||||
let sw = doc.swatch(name).unwrap();
|
||||
assert!(sw.global && !sw.spot, "{sw:?}");
|
||||
}
|
||||
assert_eq!(doc.swatch("Black global").unwrap().paint.color(), Some(Color::cmyk(0.0, 0.0, 0.0, 1.0)), "the swatch is the full colour");
|
||||
}
|
||||
|
||||
/// A pattern's definition (`%AI3_BeginPattern`): its name and tile (8 points square here, y up),
|
||||
/// then its art on `%_` lines (a group of the tile's unpainted box and a dot, a round-capped
|
||||
/// stroke of no length), `E`.
|
||||
const DOTS: &str = "%AI3_BeginPattern: (Dots)\n(Dots) 0 0 8 8\n%_0 A\n%_0 Xw\n%_0 Ae\n%_u\n%_0 O\n%_0 8 m\n%_8 8 L\n%_8 0 L\n%_0 0 L\n%_0 8 L\n%_n\n\
|
||||
%_0 R\n%_0 0 0 1 K\n%_1 J 0 j 2 w 4 M []0 d\n%_4 4 m\n%_4 4 L\n%_S\n%_U\n%_/ArtDictionary :\n%_;\n%_\n%_9 () XW\nE\n%AI3_EndPattern\n";
|
||||
|
||||
/// Patterns are read into pattern swatches, and `p` fills with one (#1025): the art keeps its
|
||||
/// layers, the fill places the tiles by its matrix.
|
||||
#[test]
|
||||
fn patterns_are_pattern_swatches_and_fill_with_them() {
|
||||
let sub = |name: &str, art: &str| format!("%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n({name}) Ln\n{art}LB\n%AI5_EndLayer--\n");
|
||||
let body = format!(
|
||||
"{DOTS}%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(Ground) Ln\n{}{}LB\n%AI5_EndLayer--\n",
|
||||
sub("Lawn", &format!("(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}f\n", rect_path(10.0, 20.0, 70.0, 80.0))),
|
||||
sub("Paving", &format!("(Dots) 10 20 2 2 0 0 0 0 0 [2 0 0 2 10 20] P\n{}S\n", rect_path(90.0, 20.0, 150.0, 80.0))),
|
||||
);
|
||||
let s = read_ok(&body);
|
||||
let doc = &s.doc;
|
||||
let ground = &doc.layers[0];
|
||||
let subs: Vec<_> = ground.children().unwrap().iter().map(|l| l.name.clone().unwrap_or_default()).collect();
|
||||
assert_eq!(subs, ["Lawn", "Paving"]);
|
||||
let def = doc.pattern("Dots").unwrap();
|
||||
assert_eq!(def.tile, Rect::new(0.0, -8.0, 8.0, 0.0), "the tile, y down");
|
||||
assert_eq!(def.art.len(), 1, "the tile's group");
|
||||
let tile_box = &def.art[0].children().unwrap()[0];
|
||||
assert_eq!(tile_box.geometric_bounds(), Some(def.tile), "the tile's box is where the tile is");
|
||||
assert_eq!(def.art[0].children().unwrap().len(), 2);
|
||||
assert!(matches!(&doc.swatch("Dots").unwrap().paint, Paint::Pattern { pattern, .. } if pattern == "Dots"));
|
||||
let lawn = &ground.children().unwrap()[0].children().unwrap()[0];
|
||||
let Paint::Pattern { pattern, xf } = fill(lawn) else { panic!("{:?}", fill(lawn)) };
|
||||
assert_eq!(pattern, "Dots");
|
||||
// The tile's top left (0, 8) in art space is (0, 92) on the page (crop marks 100 high); a dot
|
||||
// centre (4, 4) is at (4, 96).
|
||||
assert_eq!(xf * Point::ZERO, Point::new(0.0, 92.0));
|
||||
assert_eq!(xf * Point::new(4.0, 4.0), Point::new(4.0, 96.0));
|
||||
// A stroke with it, scaled twice and moved by (10, 20): the tile's top left is at (10, 36) in
|
||||
// art space.
|
||||
let paving = &ground.children().unwrap()[1].children().unwrap()[0];
|
||||
let Some(Paint::Pattern { xf, .. }) = paving.appearance.stroke().map(|st| st.paint.clone()) else { panic!() };
|
||||
assert_eq!(xf * Point::ZERO, Point::new(10.0, 64.0));
|
||||
assert!(s.warnings.is_empty(), "{:?}", s.warnings);
|
||||
}
|
||||
|
||||
/// A pattern whose definition it can't read whole isn't kept: a fill with it is something the file
|
||||
/// has that isn't read, where it shows. Its definition costs nothing else.
|
||||
#[test]
|
||||
fn a_pattern_it_cant_read_is_an_error_only_where_it_fills() {
|
||||
let unread = "%AI3_BeginPattern: (Old)\n(Old) 0 0 8 8 [\n(0 O 0 R 0 g) @\n(\n0 0 m 8 8 L S\n) &\n] E\n%AI3_EndPattern\n\
|
||||
%AI3_BeginPattern: (Odd)\n(Odd) 0 0 8 8\n%_U\nE\n%AI3_EndPattern\n%AI3_BeginPattern: (Flat)\n(Flat) 0 0 8 0\n%_0 0 m\n%_8 0 L\n%_S\nE\n%AI3_EndPattern\n";
|
||||
let s = read_ok(&format!("{unread}{}", layer(&(rect_path(0.0, 0.0, 1.0, 1.0) + "f\n"))));
|
||||
assert!(s.doc.patterns.is_empty() && s.doc.swatches.iter().all(|w| !matches!(w.paint, Paint::Pattern { .. })));
|
||||
for name in ["Old", "Odd", "Flat"] {
|
||||
let e = read(
|
||||
stream(&format!("{unread}{}", layer(&format!("({name}) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}f\n", rect_path(0.0, 0.0, 1.0, 1.0)))))
|
||||
.as_bytes(),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(e.contains("pattern fills"), "{name}: {e}");
|
||||
}
|
||||
// Type in a pattern isn't read either.
|
||||
let typed = "%AI3_BeginPattern: (Words)\n(Words) 0 0 8 8\n%_/AI11Text :\n%_0 /StoryIndex ,\n%_;\nE\n%AI3_EndPattern\n";
|
||||
assert!(read_ok(&format!("{typed}{}", layer(""))).doc.patterns.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compound_paths_and_clipping() {
|
||||
let art_ = format!(
|
||||
|
||||
@@ -339,6 +339,12 @@ fn turn(a: f64, b: f64) -> f64 {
|
||||
if d > std::f64::consts::PI { d - std::f64::consts::TAU } else { d }
|
||||
}
|
||||
|
||||
/// The angle from `a` counterclockwise to `b` (degrees, 0 to 360), worked out from what is left
|
||||
/// of each after whole turns.
|
||||
fn ccw_turn(a: f64, b: f64) -> f64 {
|
||||
(b % 360.0 - a % 360.0).rem_euclid(360.0)
|
||||
}
|
||||
|
||||
impl Interp<'_> {
|
||||
/// User space → document space.
|
||||
pub fn xf(&self) -> Affine {
|
||||
@@ -436,18 +442,19 @@ impl Interp<'_> {
|
||||
|
||||
/// `arc` / `arcn`: from angle `a1` to `a2` (degrees, counter-clockwise or clockwise).
|
||||
fn arc(&mut self, c: Point, r: f64, a1: f64, a2: f64, ccw: bool) -> Res {
|
||||
let (a1, mut a2) = (a1.to_radians(), a2.to_radians());
|
||||
let tau = std::f64::consts::TAU;
|
||||
if ccw {
|
||||
while a2 < a1 {
|
||||
a2 += tau;
|
||||
}
|
||||
// `arc` raises `a2` by multiples of 360 until it is at least `a1`, and `arcn` lowers it
|
||||
// until it is at most `a1` (PLRM 3rd ed., `arc` and `arcn` in chapter 8). Those sweeps,
|
||||
// and the first endpoint, are worked out from what is left of each angle after whole turns.
|
||||
let sweep = if ccw && a2 < a1 {
|
||||
ccw_turn(a1, a2)
|
||||
} else if !ccw && a2 > a1 {
|
||||
-ccw_turn(a2, a1)
|
||||
} else {
|
||||
while a2 > a1 {
|
||||
a2 -= tau;
|
||||
}
|
||||
}
|
||||
let sweep = (a2 - a1).clamp(-tau * 4.0, tau * 4.0);
|
||||
a2 - a1
|
||||
};
|
||||
let tau = std::f64::consts::TAU;
|
||||
let sweep = sweep.to_radians().clamp(-tau * 4.0, tau * 4.0);
|
||||
let a1 = (a1 % 360.0).to_radians();
|
||||
let start = c + Vec2::new(r * a1.cos(), r * a1.sin());
|
||||
self.append_user(std::iter::once(PathEl::MoveTo(start)))?;
|
||||
let arc = kurbo::Arc::new(c, Vec2::new(r, r), a1, sweep, 0.0);
|
||||
|
||||
@@ -16,14 +16,18 @@
|
||||
//! 3–8, with the prolog that defines its operators), the group operators `u` … `U` (nested) come in
|
||||
//! as groups. The format's published specification documents its header comments (`%AI…`,
|
||||
//! `%%Creator`) and these operators: Adobe Illustrator File Format Specification, version 7.0
|
||||
//! (1998), listed by PRONOM at https://www.nationalarchives.gov.uk/PRONOM/fmt/423.
|
||||
//! (1998), listed by PRONOM at https://www.nationalarchives.gov.uk/PRONOM/fmt/423. A file in that
|
||||
//! format whose program has its layers (`%AI5_BeginLayer`) comes in through them, as `native` reads
|
||||
//! an Illustrator EPS's editing copy (#1027).
|
||||
//!
|
||||
//! The page is the file's `%%BoundingBox` (`%%HiResBoundingBox` when it has one), the first page
|
||||
//! of a PostScript file without one. A file whose program can't be read (an operator the
|
||||
//! interpreter doesn't know, an error, a limit reached) or that draws nothing comes in as its
|
||||
//! preview (a TIFF image, a Windows metafile or an EPSI bitmap), with a warning that names the
|
||||
//! error, the operator and the procedures it ran in; without a preview, what was drawn before the
|
||||
//! error is kept (with that warning), else the file is refused.
|
||||
//! interpreter doesn't know, an error, a limit reached) or that draws nothing comes in from its
|
||||
//! layers when its program has them (a `.ai` in PostScript form whose prolog is named but not
|
||||
//! included, as other apps write it), else as its preview (a TIFF image, a Windows metafile or an
|
||||
//! EPSI bitmap), with a warning that names the error, the operator and the procedures it ran in;
|
||||
//! without a preview, what was drawn before the error is kept (with that warning), else the file
|
||||
//! is refused.
|
||||
|
||||
mod ai;
|
||||
mod ate;
|
||||
@@ -201,6 +205,13 @@ pub fn import_with(bytes: &[u8], editing_data: bool) -> Result<Imported, String>
|
||||
(Ok(()), false) => "it draws nothing VectorCraft's PostScript reader can show".to_string(),
|
||||
(Err(e), _) => reason(e, fault.as_ref()),
|
||||
};
|
||||
// A file in the legacy Illustrator format whose program can't be run (a `.ai` that names the
|
||||
// prolog defining its operators without including it): its layers, which the program has.
|
||||
let why = match editing_data.then(|| native::program_alone(ps)).flatten() {
|
||||
Some(Ok((document, warnings))) => return Ok(Imported { document, warnings, preview: false }),
|
||||
Some(Err(e)) => format!("{why}; its layers couldn't be read either ({e})"),
|
||||
None => why,
|
||||
};
|
||||
if let Some(p) = preview::preview(bytes, ps, frame) {
|
||||
let warnings = vec![format!("this file's PostScript couldn't be read ({why}): {}", p.what)];
|
||||
return Ok(Imported { document: p.document, warnings, preview: true });
|
||||
@@ -214,7 +225,7 @@ pub fn import_with(bytes: &[u8], editing_data: bool) -> Result<Imported, String>
|
||||
out.warn("the file draws nothing");
|
||||
Ok(finish(out))
|
||||
}
|
||||
Err(e) => Err(format!("this PostScript file can't be read ({}): save it as PDF or SVG in the app that made it", reason(&e, fault.as_ref()))),
|
||||
Err(_) => Err(format!("this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it")),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
//! The layers of an Illustrator EPS or `.ai`.
|
||||
//!
|
||||
//! Besides the page it prints, such a file carries the app's own copy of the art: an EPS after its
|
||||
//! `%%EOF`, a `.ai` in its `AIPrivateData` streams (see `ai` for the containers). It has what the
|
||||
//! page doesn't: the layers with their names and options, the groups, compound paths and clipping
|
||||
//! groups, the objects' names, the hidden objects and layers, the art outside the artboards and
|
||||
//! every artboard. `ai` reads that structure into a document, with the colours, gradients,
|
||||
//! transparency and images of the art.
|
||||
//! `%%EOF`, a `.ai` in its `AIPrivateData` streams (see `ai` for the containers). A file in the
|
||||
//! legacy format (a `.ai` in PostScript form: versions 3 to 8, and the apps that still write it)
|
||||
//! is that copy itself: its program has the layers, and the prolog it names defines the operators
|
||||
//! for printing (other apps name it without including it, so their files can't be run).
|
||||
//!
|
||||
//! The copy has what the page doesn't: the layers with their names and options, the groups,
|
||||
//! compound paths and clipping groups, the objects' names, the hidden objects and layers, the art
|
||||
//! outside the artboards and every artboard. `ai` reads that structure into a document, with the
|
||||
//! colours, gradients, transparency and images of the art.
|
||||
//!
|
||||
//! Text objects are slots in that document: their characters, fonts and places are in the file's
|
||||
//! text document (see `ate`). Type that shows comes from the page, which draws it, into the slot
|
||||
@@ -21,6 +25,7 @@
|
||||
//!
|
||||
//! Where a file doesn't match what this module expects, it is left alone and its page is imported.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -991,12 +996,13 @@ fn compare(frame: &Frame<'_>, view: &mut PageView, layered: &Document) -> Result
|
||||
}))
|
||||
}
|
||||
|
||||
/// The EPS `visible` (read as its page) read through its editing copy instead: its layers (the
|
||||
/// The EPS `visible` (read as its page) read through its editing copy instead (its private data, or
|
||||
/// its program in the legacy format, see [`editing_copy`]): its layers (the
|
||||
/// hidden ones too), groups, compound paths, clipping groups and artboards, with the text and the
|
||||
/// strokes around it of the page. When the file has no editing copy, `visible` as it was; when it
|
||||
/// has one that can't be used, `visible` with a warning that says why.
|
||||
pub(super) fn layered(ps: &[u8], visible: Imported) -> Imported {
|
||||
let Some(data) = ai::eps_data(ps) else { return visible };
|
||||
let Some(data) = editing_copy(ps) else { return visible };
|
||||
let result = data.and_then(|data| build(&data, Some(&visible.document), Page::Art, false));
|
||||
match result {
|
||||
Ok((document, mut warnings)) => {
|
||||
@@ -1023,7 +1029,7 @@ pub(super) fn layered(ps: &[u8], visible: Imported) -> Imported {
|
||||
/// its PDF part).
|
||||
pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec<String>, outlined: bool) -> (Document, Vec<String>) {
|
||||
let Ok(data) = ai::decode_private(private) else { return (visible, warnings) };
|
||||
if !data.windows(15).any(|w| w == b"%AI5_BeginLayer") {
|
||||
if !has_layers(&data) {
|
||||
return (visible, warnings);
|
||||
}
|
||||
match build(&data, Some(&visible), Page::Artboard, outlined) {
|
||||
@@ -1035,11 +1041,53 @@ pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec<String>, outl
|
||||
Err(why) => {
|
||||
let mut all = warnings;
|
||||
all.push(format!("{LAYERS_UNREAD} from its editing data ({why}): they come from its PDF part"));
|
||||
let mut visible = visible;
|
||||
place_pages(&ai::artboards(&data), &mut visible);
|
||||
(visible, all)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The pages of `doc` (a `.ai` read as its PDF part: a page per artboard, in a row) moved, with
|
||||
/// the art on each, to where `boards` (the artboards of its editing data, in art space) are, so
|
||||
/// the artboards keep their layout when the layers can't be read (#1068). Left as they are unless
|
||||
/// there are as many artboards as pages, of the same sizes.
|
||||
fn place_pages(boards: &[Rect], doc: &mut Document) {
|
||||
let (Some(first), true) = (boards.first(), boards.len() == doc.artboards.len() && boards.len() > 1) else { return };
|
||||
if boards.iter().zip(&doc.artboards).any(|(b, a)| (b.width() - a.rect.width()).abs() > 0.5 || (b.height() - a.rect.height()).abs() > 0.5) {
|
||||
return;
|
||||
}
|
||||
// Art space (y up) → the document, the first artboard's top left at the origin, as `read` has it.
|
||||
let to_doc = Affine::new([1.0, 0.0, 0.0, -1.0, -first.x0, first.y1]);
|
||||
let pages: Vec<Rect> = doc.artboards.iter().map(|a| a.rect).collect();
|
||||
let moves: Vec<Vec2> = boards.iter().zip(&pages).map(|(b, page)| to_doc.transform_rect_bbox(*b).origin() - page.origin()).collect();
|
||||
for layer in &mut doc.layers {
|
||||
place_art(Arc::make_mut(layer), &pages, &moves);
|
||||
}
|
||||
for (a, v) in doc.artboards.iter_mut().zip(&moves) {
|
||||
a.rect = a.rect + *v;
|
||||
}
|
||||
}
|
||||
|
||||
/// The objects in `layer` (and its sublayers) moved by the move of the page they are on.
|
||||
fn place_art(layer: &mut Node, pages: &[Rect], moves: &[Vec2]) {
|
||||
let Some(children) = layer.children_mut() else { return };
|
||||
for child in children {
|
||||
if matches!(child.kind, NodeKind::Layer { .. }) {
|
||||
place_art(Arc::make_mut(child), pages, moves);
|
||||
continue;
|
||||
}
|
||||
let Some(r) = child.visual_bounds() else { continue };
|
||||
// The page it is on: the one with most of it (pages keep only the art that reaches into
|
||||
// them), else the nearest.
|
||||
let fit = |p: &Rect| (p.intersect(r).area().max(0.0), -(p.center() - r.center()).hypot());
|
||||
let Some((i, _)) = pages.iter().map(fit).enumerate().max_by(|(_, a), (_, b)| a.0.total_cmp(&b.0).then(a.1.total_cmp(&b.1))) else { continue };
|
||||
if let Some(v) = moves.get(i).filter(|v| **v != Vec2::ZERO) {
|
||||
Arc::make_mut(child).transform(Affine::translate(*v), false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `.ai` saved without its PDF part (its pages show only a placeholder) from its editing copy
|
||||
/// alone (`private`, the joined `AIPrivateData` streams) → the document and the import's notes.
|
||||
pub fn ai_alone(private: &[u8]) -> Result<(Document, Vec<String>), String> {
|
||||
@@ -1047,6 +1095,27 @@ pub fn ai_alone(private: &[u8]) -> Result<(Document, Vec<String>), String> {
|
||||
build(&data, None, Page::Artboard, false)
|
||||
}
|
||||
|
||||
/// Does the editing data (or a program in the legacy format) have layers?
|
||||
fn has_layers(data: &[u8]) -> bool {
|
||||
data.windows(15).any(|w| w == b"%AI5_BeginLayer")
|
||||
}
|
||||
|
||||
/// The editing copy of the art in the PostScript `ps`: an EPS's private data, else the program
|
||||
/// itself when it has layers (the legacy format). `None` without one.
|
||||
fn editing_copy(ps: &[u8]) -> Option<Result<Cow<'_, [u8]>, String>> {
|
||||
match ai::eps_data(ps) {
|
||||
Some(data) => Some(data.map(Cow::Owned)),
|
||||
None => has_layers(ps).then_some(Ok(Cow::Borrowed(ps))),
|
||||
}
|
||||
}
|
||||
|
||||
/// A file in the legacy format whose page can't be read (a `.ai` in PostScript form that names its
|
||||
/// prolog without including it) from its layers alone (`ps`: its program, which has them) → the
|
||||
/// document and the import's notes. `None` when the layers aren't in the program.
|
||||
pub(super) fn program_alone(ps: &[u8]) -> Option<Result<(Document, Vec<String>), String>> {
|
||||
(!ai::has_eps_data(ps) && has_layers(ps)).then(|| build(ps, None, Page::Art, false))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -640,6 +640,42 @@ fn hostile_executable_files_and_type_loops_are_refused() {
|
||||
blue("currentfile cvx exec currentfile flushfile 0 0 1 setrgbcolor");
|
||||
}
|
||||
|
||||
/// Reads `body` as [`read`] does, on a thread of its own, and fails the test when reading takes
|
||||
/// more than 20 s.
|
||||
fn read_soon(body: &str) -> crate::Imported {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let ps = eps(body);
|
||||
std::thread::spawn(move || tx.send(import(&ps)).ok());
|
||||
rx.recv_timeout(std::time::Duration::from_secs(20)).unwrap_or_else(|_| panic!("reading {body} did not finish")).unwrap()
|
||||
}
|
||||
|
||||
/// `arc` raises its second angle by multiples of 360 until it is at least the first, and `arcn`
|
||||
/// lowers it until it is at most the first (PLRM 3rd ed., `arc` and `arcn` in chapter 8), however
|
||||
/// large the angles: the arc runs between the angles' remainders after whole turns and ends at
|
||||
/// the second angle, the new current point.
|
||||
#[test]
|
||||
fn arcs_with_huge_angles_end_at_their_second_angle() {
|
||||
// `at(deg)` is the point at `deg` degrees on the circle of radius 10 around (50, 50), in
|
||||
// document space.
|
||||
let at = |deg: f64| Point::new(50.0 + 10.0 * deg.to_radians().cos(), 50.0 - 10.0 * deg.to_radians().sin());
|
||||
// Each program is listed with the remainders of its first and second angles after whole turns.
|
||||
for (body, first, second) in [
|
||||
("50 50 10 1e18 0 arc", 280.0, 0.0),
|
||||
("50 50 10 1e308 0 arc", 296.0, 0.0),
|
||||
("50 50 10 0 1e18 arcn", 0.0, 280.0),
|
||||
("50 50 10 0 1e308 arcn", 0.0, 296.0),
|
||||
] {
|
||||
let r = read_soon(&format!("newpath {body} currentpoint stroke 1 1 rectfill"));
|
||||
let o = objects(&r.document);
|
||||
assert_eq!(o.len(), 2, "{body}: {:?}", r.warnings);
|
||||
// The arc lies within one quarter of the circle, so its endpoints span its bounds.
|
||||
let (a, b) = (at(first), at(second));
|
||||
assert!(near(bounds(&o[0]), Rect::from_points(a, b)), "{body}: {:?}", bounds(&o[0]));
|
||||
// The second object is the square drawn at the current point.
|
||||
assert!(near(bounds(&o[1]), Rect::new(b.x, b.y - 1.0, b.x + 1.0, b.y)), "{body}: {:?}", bounds(&o[1]));
|
||||
}
|
||||
}
|
||||
|
||||
#[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");
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::clipnest::MAX_NEST;
|
||||
use vectorcraft_doc::{Node, NodeKind};
|
||||
use vectorcraft_doc::{LayerColor, LineCap, Node, NodeKind};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
use crate::import::import;
|
||||
|
||||
@@ -80,6 +81,80 @@ fn other_postscript_keeps_its_own_u() {
|
||||
assert_eq!(read("%%Creator: some app\n", "", &body).0, "p p");
|
||||
}
|
||||
|
||||
/// A `.ai` in PostScript form as other apps write it (#1027): its header and prolog name the
|
||||
/// procsets that define its operators (by the DSC resource comments) without including them, so
|
||||
/// its program can't be run. Its layers come from the program itself: names as they are, the
|
||||
/// legacy format's ten-operand `Lb`, flatness `i`, the bounding box as the page.
|
||||
const NAMED_PROLOG: &str = "%!PS-Adobe-3.0\n%%Creator: a CAD app\n%%BoundingBox: 0 0 200 100\n\
|
||||
%%DocumentNeededResources: procset Adobe_packedarray 2.0 0\n%%+ procset Adobe_IllustratorA_AI3 1.0 0\n\
|
||||
%AI3_TemplateBox: 288 384 288 384\n%%EndComments\n%%BeginProlog\n%%IncludeResource: procset Adobe_packedarray 2.0 0\n\
|
||||
Adobe_packedarray /initialize get exec\n%%IncludeResource: procset Adobe_IllustratorA_AI3 1.0 0\n%%EndProlog\n\
|
||||
%%BeginSetup\nAdobe_IllustratorA_AI3 /initialize get exec\n%%EndSetup\n\
|
||||
%AI5_BeginLayer\n1 1 1 1 0 0 -1 0 0 0 Lb\n(SECTION::Cut) Ln\n0 A\n0 R\n0 0 0 1 K\n0 i 1 J 1 j 0.8 w 4 M []0 d\n0 D\n\
|
||||
10 10 m\n90 10 L\n90 90 L\n10 90 L\n10 10 L\nS\nLB\n%AI5_EndLayer--\n\
|
||||
%AI5_BeginLayer\n1 1 1 1 0 0 -1 191 63 255 Lb\n(SECTION::Hatch) Ln\n0 A\n0 R\n0 0 0 1 K\n0.1 w\n110 10 m\n190 90 L\nS\nLB\n%AI5_EndLayer--\n\
|
||||
%%PageTrailer\ngsave annotatepage grestore showpage\n%%Trailer\nAdobe_IllustratorA_AI3 /terminate get exec\n%%EOF\n";
|
||||
|
||||
#[test]
|
||||
fn a_postscript_ai_that_names_its_prolog_without_including_it_opens_with_its_layers() {
|
||||
let r = import(NAMED_PROLOG.as_bytes()).unwrap();
|
||||
assert!(r.warnings.is_empty() && !r.preview, "{:?}", r.warnings);
|
||||
let d = &r.document;
|
||||
let names: Vec<_> = d.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect();
|
||||
assert_eq!(names, ["SECTION::Cut", "SECTION::Hatch"]);
|
||||
let colors: Vec<_> = d.layers.iter().map(|l| if let NodeKind::Layer { color, .. } = l.kind { Some(color) } else { None }).collect();
|
||||
assert_eq!(colors, [Some(LayerColor::Custom([0, 0, 0])), Some(LayerColor::Custom([191, 63, 255]))]);
|
||||
assert_eq!(d.artboards[0].rect, Rect::new(0.0, 0.0, 200.0, 100.0), "the page is the bounding box");
|
||||
let square = &d.layers[0].children().unwrap()[0];
|
||||
assert_eq!(square.geometric_bounds(), Some(Rect::new(10.0, 10.0, 90.0, 90.0)));
|
||||
let stroke = square.appearance.stroke().unwrap();
|
||||
assert_eq!((stroke.width, stroke.cap), (0.8, LineCap::Round));
|
||||
assert_eq!(tree(d.layers[1].children().unwrap()), "p");
|
||||
}
|
||||
|
||||
/// A `.ai` in PostScript form whose layers can't be read either says why, after what stopped its
|
||||
/// program.
|
||||
#[test]
|
||||
fn a_postscript_ai_whose_layers_cant_be_read_says_why() {
|
||||
let damaged = NAMED_PROLOG.replace("0 D\n", "0 D\n1 2 frobnicate\n");
|
||||
let e = import(damaged.as_bytes()).unwrap_err();
|
||||
assert!(e.contains("`Adobe_packedarray`") && e.contains("its layers couldn't be read either") && e.contains("`frobnicate`"), "{e}");
|
||||
}
|
||||
|
||||
/// The editing text of a newer file saved as PostScript (no prolog at all, a non-printing setup):
|
||||
/// it opens with its sublayers.
|
||||
#[test]
|
||||
fn newer_editing_text_as_postscript_opens_with_its_sublayers() {
|
||||
let layer =
|
||||
|name: &str, art: &str| format!("%AI5_BeginLayer\n1 1 1 1 0 0 1 -1 79 128 255 0 50 0 Lb\n({name}) Ln\n0 A\n0 Xw\n{art}LB\n%AI5_EndLayer--\n");
|
||||
let ground = layer("Ground", &(layer("Lawn", "0 0 0 0.3 k\n10 20 m\n70 20 L\n70 80 L\n10 80 L\nf\n") + &layer("Paving", "")));
|
||||
let text = format!(
|
||||
"%!PS-Adobe-3.0\n%%Creator: a script\n%%BoundingBox: 0 0 250 100\n%AI5_FileFormat 14.0\n%AI3_Cropmarks: 0 0 250 100\n%%EndComments\n\
|
||||
%%BeginProlog\n%%EndProlog\n%%BeginSetup\n%AI5_Begin_NonPrinting\nNp\n%AI5_End_NonPrinting--\n%%EndSetup\n{ground}%%PageTrailer\n%%Trailer\n%%EOF\n"
|
||||
);
|
||||
let r = import(text.as_bytes()).unwrap();
|
||||
let d = &r.document;
|
||||
assert_eq!(d.layers.len(), 1, "{:?}", r.warnings);
|
||||
let subs: Vec<_> = d.layers[0].children().unwrap().iter().map(|l| l.name.clone().unwrap_or_default()).collect();
|
||||
assert_eq!(subs, ["Lawn", "Paving"]);
|
||||
}
|
||||
|
||||
/// A file in the legacy format whose prolog is included (here one of our own) runs as its page, and
|
||||
/// comes in through the layers its program has when they look like the page.
|
||||
#[test]
|
||||
fn a_legacy_file_with_its_prolog_comes_in_through_its_layers() {
|
||||
let prolog = "/Lb {10 {pop} repeat} def /Ln {pop} def /LB {} def /A {pop} def /k {setcmykcolor} def";
|
||||
let body = format!("%AI5_BeginLayer\n1 1 1 1 0 0 0 79 128 255 Lb\n(Back) Ln\n0 A\n0 0 0 1 k\n{} LB\n%AI5_EndLayer--\n", tri(10));
|
||||
let file = String::from_utf8(ai(AI8, "", &body)).unwrap().replace("%%EndProlog", &format!("{prolog}\n%%EndProlog"));
|
||||
let r = import(file.as_bytes()).unwrap();
|
||||
let names: Vec<_> = r.document.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect();
|
||||
assert_eq!(names, ["Back"], "{:?}", r.warnings);
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
// Without the editing data, it is its page.
|
||||
let page = crate::import::import_with(file.as_bytes(), false).unwrap();
|
||||
assert_eq!(page.document.layers[0].name.as_deref(), Some("Layer 1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unbalanced_and_runaway_groups_are_read_safely() {
|
||||
// Ends without a beginning end nothing; groups left open hold the rest.
|
||||
|
||||
@@ -237,6 +237,49 @@ fn an_ai_file_that_cant_be_read_through_its_editing_data_keeps_its_pdf_part() {
|
||||
assert!(w.iter().any(|w| w.contains("different page")), "{w:?}");
|
||||
}
|
||||
|
||||
/// A `.ai` whose layers can't be read comes in as its PDF part, a page per artboard in a row; its
|
||||
/// pages still go where its editing data puts its artboards, with their art (#1068).
|
||||
#[test]
|
||||
fn an_ai_file_read_as_its_pdf_part_keeps_its_artboard_layout() {
|
||||
// Two 100-point artboards, the second below the first (art space, y up).
|
||||
let board = |x0: i32, y0: i32| {
|
||||
format!(
|
||||
"%_/Dictionary :\n%_{x0} {} /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_{} {y0} /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n",
|
||||
y0 + 100,
|
||||
x0 + 100
|
||||
)
|
||||
};
|
||||
let boards = |list: &[(i32, i32)]| {
|
||||
let all: String = list.iter().map(|(x, y)| board(*x, *y)).collect();
|
||||
format!("%_/Document :\n%_/Dictionary :\n%_/Array :\n{all}%_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n")
|
||||
};
|
||||
// An operator this doesn't know, so the layers aren't read.
|
||||
let unread = |list: &[(i32, i32)]| native(&(boards(list) + &layer("Art", true, &(square(10, 10) + "\n1 Zz"))));
|
||||
// The PDF part: the pages side by side, 20 points apart, a square on each.
|
||||
let mut pdf = pdf_part(10.0, 10.0);
|
||||
let second = pdf_part(130.0, 10.0).layers[0].children().unwrap()[0].clone();
|
||||
let mut second = (*second).clone();
|
||||
second.id = pdf.alloc_id();
|
||||
let layer_id = pdf.layers[0].id;
|
||||
pdf.insert(Some(layer_id), 1, second).unwrap();
|
||||
let mut page = pdf.artboards[0].clone();
|
||||
(page.id, page.rect) = (2, vectorcraft_geom::Rect::new(120.0, 0.0, 220.0, 100.0));
|
||||
pdf.artboards.push(page);
|
||||
let squares = |d: &Document| d.layers[0].children().unwrap().iter().map(|n| n.visual_bounds().unwrap().origin()).collect::<Vec<_>>();
|
||||
let (d, w) = crate::layered_ai(&private(&unread(&[(0, 0), (0, -150)])), pdf.clone(), vec![], false);
|
||||
assert!(w.iter().any(|w| w.contains("layers weren't read")), "{w:?}");
|
||||
let rects: Vec<_> = d.artboards.iter().map(|a| a.rect).collect();
|
||||
assert_eq!(rects, [vectorcraft_geom::Rect::new(0.0, 0.0, 100.0, 100.0), vectorcraft_geom::Rect::new(0.0, 150.0, 100.0, 250.0)]);
|
||||
let moved = squares(&d);
|
||||
assert!((moved[0] - squares(&pdf)[0]).hypot() < 1e-6, "the first page's art stays: {moved:?}");
|
||||
assert!((moved[1] - vectorcraft_geom::Point::new(10.0, 160.0)).hypot() < 1e-6, "the second's goes with its page: {moved:?}");
|
||||
// Artboards that don't match the pages leave them in a row.
|
||||
for list in [&[(0, 0)][..], &[(0, 0), (0, -150), (200, 0)]] {
|
||||
let (d, _) = crate::layered_ai(&private(&unread(list)), pdf.clone(), vec![], false);
|
||||
assert_eq!(d.artboards[1].rect, pdf.artboards[1].rect, "{list:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gray_is_a_grayscale_colour() {
|
||||
// `g` is a gray: 0.25 is 75 % of the black ink, as the page's own CMYK black says; the colour
|
||||
@@ -543,6 +586,33 @@ fn an_ai_file_without_its_pdf_part_reads_its_layers_and_makes_its_type() {
|
||||
assert!(crate::ai_alone(b"%AI24_ZStandard_Data nothing").is_err());
|
||||
}
|
||||
|
||||
/// A global process colour (`Xk`) or a pattern fill on a sublayer no longer sends a `.ai` to its
|
||||
/// PDF part, which has only the top-level layers (#1025).
|
||||
#[test]
|
||||
fn a_global_colour_or_a_pattern_fill_keeps_the_sublayers_of_an_ai_file() {
|
||||
// The PDF part's yellow square, in a global yellow at full tint, on a sublayer; a hidden
|
||||
// sublayer with a pattern fill (the page doesn't have it).
|
||||
let yellow = "0 0 1 0 (Yellow global) 0 0 Xk\n10 10 m\n14 10 L\n14 14 L\n10 14 L\nf";
|
||||
let dots = "%AI3_BeginPattern: (Dots)\n(Dots) 0 0 4 4\n%_0 0 0 1 k\n%_1 1 m\n%_3 1 L\n%_3 3 L\n%_1 3 L\n%_f\nE\n%AI3_EndPattern\n";
|
||||
let patterned = "(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n50 50 m\n70 50 L\n70 70 L\n50 70 L\nf";
|
||||
let ground = layer("Ground", true, &(layer("Lawn", true, yellow) + &layer("Paving", false, patterned)));
|
||||
let art = native(&format!("{dots}{ground}"));
|
||||
let (d, warnings) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false);
|
||||
assert_eq!(names(&d), ["Ground"], "{warnings:?}");
|
||||
let subs: Vec<_> = d.layers[0].children().unwrap().iter().map(|l| (l.name.clone().unwrap_or_default(), kinds(l))).collect();
|
||||
assert_eq!(subs, [("Lawn".to_string(), vec!["path"]), ("Paving".to_string(), vec!["path"])]);
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
assert!(d.swatch("Yellow global").is_some_and(|s| s.global && !s.spot));
|
||||
assert!(d.pattern("Dots").is_some() && d.swatch("Dots").is_some());
|
||||
// Without a PDF part, the pattern fill shows.
|
||||
let shown = native(&format!("{dots}{}", layer("Ground", true, &layer("Lawn", true, patterned))));
|
||||
let (d, notes) = crate::ai_alone(&private(&shown)).unwrap();
|
||||
let lawn = &d.layers[0].children().unwrap()[0];
|
||||
assert_eq!((lawn.name.as_deref(), kinds(lawn)), (Some("Lawn"), vec!["path"]), "{notes:?}");
|
||||
let fill = lawn.children().unwrap()[0].appearance.fill().map(|f| f.paint.clone());
|
||||
assert!(matches!(&fill, Some(vectorcraft_color::Paint::Pattern { pattern, .. }) if pattern == "Dots"), "{fill:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_object_the_page_doesnt_draw_makes_the_file_come_in_as_its_page() {
|
||||
// A 20-point square that the page doesn't draw: 4% of the page, and an object all the same.
|
||||
|
||||
+70
-2
@@ -1,18 +1,60 @@
|
||||
//! Hit testing primitives.
|
||||
|
||||
use kurbo::{BezPath, ParamCurveNearest, Point, Rect, Shape};
|
||||
use std::borrow::Cow;
|
||||
|
||||
use kurbo::{BezPath, ParamCurveNearest, PathEl, Point, Rect, Shape};
|
||||
|
||||
use crate::path::{FillRule, PathData};
|
||||
|
||||
/// Is `p` inside the filled area of `path` under `rule`? Open subpaths are implicitly closed (as when filled).
|
||||
pub fn fill_contains(path: &BezPath, rule: FillRule, p: Point) -> bool {
|
||||
let w = path.winding(p);
|
||||
let w = closed(path).winding(p);
|
||||
match rule {
|
||||
FillRule::NonZero => w != 0,
|
||||
FillRule::EvenOdd => w % 2 != 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// `path` with each open subpath closed by a straight line back to its start, the edge its fill
|
||||
/// has. kurbo's winding leaves that edge out, so a point beside it counted as outside the fill:
|
||||
/// an open path lost its fill clicks on that side (a reflection moved the edge to the other one).
|
||||
fn closed(path: &BezPath) -> Cow<'_, BezPath> {
|
||||
let mut open = false;
|
||||
let mut needs = false;
|
||||
for el in path.elements() {
|
||||
match el {
|
||||
PathEl::MoveTo(_) => {
|
||||
needs |= open;
|
||||
open = true;
|
||||
}
|
||||
PathEl::ClosePath => open = false,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if !(needs || open) {
|
||||
return Cow::Borrowed(path);
|
||||
}
|
||||
let mut out = BezPath::new();
|
||||
let mut open = false;
|
||||
for &el in path.elements() {
|
||||
match el {
|
||||
PathEl::MoveTo(_) => {
|
||||
if open {
|
||||
out.close_path();
|
||||
}
|
||||
open = true;
|
||||
}
|
||||
PathEl::ClosePath => open = false,
|
||||
_ => {}
|
||||
}
|
||||
out.push(el);
|
||||
}
|
||||
if open {
|
||||
out.close_path();
|
||||
}
|
||||
Cow::Owned(out)
|
||||
}
|
||||
|
||||
/// Distance from `p` to the nearest point on the path outline.
|
||||
pub fn distance_to_outline(path: &BezPath, p: Point) -> f64 {
|
||||
path.segments().map(|s| s.nearest(p, 1e-9).distance_sq).fold(f64::INFINITY, f64::min).sqrt()
|
||||
@@ -77,6 +119,32 @@ mod tests {
|
||||
assert!(fill_contains(&bp, FillRule::EvenOdd, Point::new(1.0, 5.0)));
|
||||
}
|
||||
|
||||
/// An open path fills as if closed by a straight line back to its start, and is hit there,
|
||||
/// whichever side that line falls on (a reflected open path lost its fill clicks).
|
||||
#[test]
|
||||
fn open_paths_fill_as_closed_on_either_side() {
|
||||
// A filled sliver left open by a deleted anchor; its closing line runs down its left side.
|
||||
let open = BezPath::from_svg(
|
||||
"M86.311 69.612C100.544 78.261 110.506 88.64 110.506 100.748C110.506 111.127 100.544 118.046 86.311 118.046C93.214 102.045 91.407 102.403 91.407 97.61",
|
||||
)
|
||||
.unwrap();
|
||||
let mut closed = open.clone();
|
||||
closed.close_path();
|
||||
for p in [Point::new(100.0, 100.0), Point::new(95.0, 85.0), Point::new(92.0, 112.0)] {
|
||||
assert!(closed.winding(p) != 0, "{p:?} is inside the closed sliver");
|
||||
assert!(fill_contains(&open, FillRule::NonZero, p), "{p:?}: the open sliver's fill");
|
||||
assert!(fill_contains(&open, FillRule::EvenOdd, p), "{p:?}: even-odd too");
|
||||
}
|
||||
assert!(!fill_contains(&open, FillRule::NonZero, Point::new(80.0, 100.0)), "left of the closing line");
|
||||
// Mirrored, the closing line is on the other side: still filled.
|
||||
let flipped = kurbo::Affine::new([-1.0, 0.0, 0.0, 1.0, 200.0, 0.0]) * open.clone();
|
||||
assert!(fill_contains(&flipped, FillRule::NonZero, Point::new(100.0, 100.0)));
|
||||
// Two open subpaths: each closes on its own.
|
||||
let mut two = open.clone();
|
||||
two.extend(kurbo::Affine::translate((0.0, 100.0)) * open.clone());
|
||||
assert!(fill_contains(&two, FillRule::NonZero, Point::new(100.0, 100.0)) && fill_contains(&two, FillRule::NonZero, Point::new(100.0, 200.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marquee() {
|
||||
let p = shapes::ellipse(Rect::new(0.0, 0.0, 10.0, 10.0));
|
||||
|
||||
@@ -235,8 +235,9 @@ pub fn polar_grid(r: Rect, concentric: u32, radial: u32) -> Vec<PathData> {
|
||||
let f = i as f64 / (concentric + 1) as f64;
|
||||
out.push(ellipse(Rect::from_center_size(c, (r.width() * f, r.height() * f))));
|
||||
}
|
||||
for i in 0..radial.max(1) {
|
||||
let a = -PI / 2.0 + TAU * i as f64 / radial.max(1) as f64;
|
||||
// No dividers at 0 (the loop never divides by it).
|
||||
for i in 0..radial {
|
||||
let a = -PI / 2.0 + TAU * i as f64 / radial as f64;
|
||||
out.push(line(c, Point::new(c.x + r.width() / 2.0 * a.cos(), c.y + r.height() / 2.0 * a.sin())));
|
||||
}
|
||||
out
|
||||
@@ -402,6 +403,10 @@ mod tests {
|
||||
fn grids() {
|
||||
assert_eq!(rectangular_grid(Rect::new(0.0, 0.0, 10.0, 10.0), 5, 5, true).len(), 11);
|
||||
assert_eq!(polar_grid(Rect::new(0.0, 0.0, 10.0, 10.0), 5, 5).len(), 11);
|
||||
// #1008: no radial dividers is none, only the outer ellipse without concentric ones.
|
||||
for (radial, len) in [(0, 1), (1, 2), (2, 3)] {
|
||||
assert_eq!(polar_grid(Rect::new(0.0, 0.0, 10.0, 10.0), 0, radial).len(), len, "{radial}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -235,12 +235,16 @@ pub(crate) struct Tidy {
|
||||
keep: Vec<Point>,
|
||||
/// Input curves (with inflated bounds) used to rebuild split pieces exactly.
|
||||
curves: Vec<(kurbo::Rect, CubicBez)>,
|
||||
/// `curves` indices by the left edge of their bounds, and the widest bounds: the curves whose
|
||||
/// bounds can hold a point, without testing them all.
|
||||
by_x: Vec<usize>,
|
||||
max_width: f64,
|
||||
}
|
||||
|
||||
impl Tidy {
|
||||
/// Merge any smooth joint.
|
||||
pub(crate) fn free(precision: f64) -> Self {
|
||||
Self { precision, keep: Vec::new(), curves: Vec::new() }
|
||||
Self { precision, ..Default::default() }
|
||||
}
|
||||
/// Keep every on-curve point of `paths`.
|
||||
pub(crate) fn keeping<'a>(precision: f64, paths: impl IntoIterator<Item = &'a BezPath>) -> Self {
|
||||
@@ -257,7 +261,10 @@ impl Tidy {
|
||||
}
|
||||
}
|
||||
keep.sort_by(|a, b| a.x.total_cmp(&b.x));
|
||||
Self { precision, keep, curves }
|
||||
let mut by_x: Vec<usize> = (0..curves.len()).collect();
|
||||
by_x.sort_by(|&i, &j| curves[i].0.x0.total_cmp(&curves[j].0.x0));
|
||||
let max_width = curves.iter().map(|(r, _)| r.width()).fold(0.0, f64::max);
|
||||
Self { precision, keep, curves, by_x, max_width }
|
||||
}
|
||||
fn is_kept(&self, p: Point) -> bool {
|
||||
let tol = 1e-6 * (1.0 + p.x.abs().max(p.y.abs()));
|
||||
@@ -322,10 +329,11 @@ impl Arrangement {
|
||||
pub(crate) fn contours_to_path(c: &Contours, idx: impl IntoIterator<Item = ContourIdx>, tidy: &Tidy) -> PathData {
|
||||
let mut subpaths = Vec::new();
|
||||
for i in idx {
|
||||
if is_sliver(&c[i].path, tidy.precision) {
|
||||
let path = tidy.repair(&c[i].path);
|
||||
if is_sliver(&path, tidy.precision) {
|
||||
continue;
|
||||
}
|
||||
if let Some(sp) = tidy_bezpath(&c[i].path, tidy) {
|
||||
if let Some(sp) = tidy_bezpath(&path, tidy) {
|
||||
subpaths.push(sp);
|
||||
}
|
||||
}
|
||||
@@ -515,6 +523,84 @@ fn exact_merge(run: &[Seg], tidy: &Tidy) -> Option<CubicBez> {
|
||||
None
|
||||
}
|
||||
|
||||
impl Tidy {
|
||||
/// The input curves whose bounds hold both `a` and `b`.
|
||||
fn curves_around(&self, a: Point, b: Point) -> impl Iterator<Item = &CubicBez> {
|
||||
let x = a.x.min(b.x);
|
||||
let hi = self.by_x.partition_point(|&i| self.curves[i].0.x0 <= x);
|
||||
let lo = self.by_x[..hi].partition_point(|&i| self.curves[i].0.x0 < x - self.max_width);
|
||||
self.by_x[lo..hi].iter().map(|&i| &self.curves[i]).filter(move |(r, _)| r.contains(a) && r.contains(b)).map(|(_, c)| c)
|
||||
}
|
||||
|
||||
/// `bp` with each curve piece that strays from the one input curve through both its ends
|
||||
/// rebuilt as that curve's exact sub-curve. linesweeper 0.5 can emit a piece that doesn't
|
||||
/// follow its curve; out and back between two points of one curve, such pieces are a lens
|
||||
/// instead of nothing (issue #1028). A piece on a curve, or whose ends two different input
|
||||
/// curves pass through (a real lens between them), is left as it is.
|
||||
pub(crate) fn repair(&self, bp: &BezPath) -> BezPath {
|
||||
use kurbo::{ParamCurve, ParamCurveNearest, PathEl};
|
||||
if self.curves.is_empty() {
|
||||
return bp.clone();
|
||||
}
|
||||
let mut out = BezPath::new();
|
||||
let mut cur = Point::ZERO;
|
||||
let mut start = Point::ZERO;
|
||||
for el in bp.iter() {
|
||||
let piece = match el {
|
||||
PathEl::QuadTo(c, p) => Some(kurbo::QuadBez::new(cur, c, p).raise()),
|
||||
PathEl::CurveTo(c1, c2, p) => Some(CubicBez::new(cur, c1, c2, p)),
|
||||
_ => None,
|
||||
};
|
||||
match (el, piece) {
|
||||
(_, Some(raw)) => {
|
||||
let (a, b) = (raw.p0, raw.p3);
|
||||
let scale = 1.0 + a.x.abs().max(a.y.abs()).max(b.x.abs()).max(b.y.abs());
|
||||
let tol = 1e-6 * scale;
|
||||
let mut subs: Vec<CubicBez> = vec![];
|
||||
let mut follows = false;
|
||||
for c in self.curves_around(a, b) {
|
||||
let (na, nb) = (c.nearest(a, 1e-12), c.nearest(b, 1e-12));
|
||||
if na.distance_sq.sqrt() > tol || nb.distance_sq.sqrt() > tol || (na.t - nb.t).abs() < 1e-12 {
|
||||
continue;
|
||||
}
|
||||
if [0.25, 0.5, 0.75].iter().all(|&t| c.nearest(raw.eval(t), 1e-12).distance_sq.sqrt() <= tol * 10.0) {
|
||||
follows = true;
|
||||
break;
|
||||
}
|
||||
let sub = if na.t < nb.t {
|
||||
c.subsegment(na.t..nb.t)
|
||||
} else {
|
||||
let r = c.subsegment(nb.t..na.t);
|
||||
CubicBez::new(r.p3, r.p2, r.p1, r.p0)
|
||||
};
|
||||
subs.push(CubicBez::new(a, sub.p1, sub.p2, b));
|
||||
}
|
||||
let same = |p: &CubicBez, q: &CubicBez| p.p1.distance(q.p1) <= tol * 10.0 && p.p2.distance(q.p2) <= tol * 10.0;
|
||||
match subs.split_first() {
|
||||
Some((sub, rest)) if !follows && rest.iter().all(|q| same(sub, q)) => out.curve_to(sub.p1, sub.p2, b),
|
||||
_ => out.push(el),
|
||||
}
|
||||
cur = b;
|
||||
}
|
||||
(PathEl::MoveTo(p), None) => {
|
||||
out.push(el);
|
||||
(cur, start) = (p, p);
|
||||
}
|
||||
(PathEl::LineTo(p), None) => {
|
||||
out.push(el);
|
||||
cur = p;
|
||||
}
|
||||
(PathEl::ClosePath, None) => {
|
||||
out.push(el);
|
||||
cur = start;
|
||||
}
|
||||
(_, None) => out.push(el),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve self-intersections / overlaps of a single filled path into simple, consistently
|
||||
/// oriented contours (outer contours and holes).
|
||||
pub fn normalize(path: &PathData, rule: FillRule) -> PathData {
|
||||
@@ -592,3 +678,57 @@ pub fn area(path: &PathData, rule: FillRule) -> f64 {
|
||||
Err(_) => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use kurbo::ParamCurve;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A quarter-circle-like arc, the input curve the pieces below are cut from.
|
||||
fn arc() -> CubicBez {
|
||||
CubicBez::new((0.0, 0.0), (0.0, 55.0), (45.0, 100.0), (100.0, 100.0))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stray_piece_out_and_back_along_one_curve_is_no_outline() {
|
||||
// Issue #1028: linesweeper 0.5 handed back a contour running out along an input curve and
|
||||
// back by a piece that doesn't follow it, which refit into a visible lens.
|
||||
let c = arc();
|
||||
let tidy =
|
||||
Tidy::keeping(DEFAULT_PRECISION, [&BezPath::from_vec(vec![kurbo::PathEl::MoveTo(c.p0), kurbo::PathEl::CurveTo(c.p1, c.p2, c.p3)])]);
|
||||
let out = c.subsegment(0.2..0.6);
|
||||
let mut contour = BezPath::new();
|
||||
contour.move_to(out.p0);
|
||||
contour.curve_to(out.p1, out.p2, out.p3);
|
||||
// back to the start, bulging off the curve and overshooting the end like the reported piece
|
||||
contour.curve_to(out.p0 + (out.p0 - out.p3) * 0.15 + kurbo::Vec2::new(4.0, -3.0), out.p1 + kurbo::Vec2::new(3.0, -2.0), out.p0);
|
||||
contour.close_path();
|
||||
assert!(!is_sliver(&contour, tidy.precision), "the stray piece makes a lens");
|
||||
let fixed = tidy.repair(&contour);
|
||||
assert!(is_sliver(&fixed, tidy.precision), "{}", fixed.to_svg());
|
||||
// a piece on its curve is left exactly as it is
|
||||
let mut on = BezPath::new();
|
||||
on.move_to(out.p0);
|
||||
on.curve_to(out.p1, out.p2, out.p3);
|
||||
on.line_to(out.p0);
|
||||
on.close_path();
|
||||
assert_eq!(tidy.repair(&on), on);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lens_between_two_curves_through_the_same_points_stays() {
|
||||
// Two input curves meeting at both ends bound a real face: neither piece is "repaired"
|
||||
// onto the other.
|
||||
let lower = CubicBez::new((0.0, 0.0), (30.0, 20.0), (70.0, 20.0), (100.0, 0.0));
|
||||
let upper = CubicBez::new((0.0, 0.0), (30.0, -20.0), (70.0, -20.0), (100.0, 0.0));
|
||||
let input = |c: CubicBez| BezPath::from_vec(vec![kurbo::PathEl::MoveTo(c.p0), kurbo::PathEl::CurveTo(c.p1, c.p2, c.p3)]);
|
||||
let tidy = Tidy::keeping(DEFAULT_PRECISION, [&input(lower), &input(upper)]);
|
||||
let mut lens = BezPath::new();
|
||||
lens.move_to(lower.p0);
|
||||
lens.curve_to(lower.p1, lower.p2, lower.p3);
|
||||
lens.curve_to(upper.p2, upper.p1, upper.p0);
|
||||
lens.close_path();
|
||||
assert_eq!(tidy.repair(&lens), lens);
|
||||
}
|
||||
}
|
||||
@@ -192,4 +192,29 @@ mod tests {
|
||||
// Ring 16·20 − 8·12 = 224, plus the bar outside it (10·4) and inside its hole (6·4).
|
||||
assert!((area - (224.0 + 40.0 + 24.0)).abs() < 1e-6, "{area}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn miter_offset_finishes_where_the_sweep_used_to_loop() {
|
||||
// Issue #1028: with linesweeper 0.4 the sweep over this miter offset never finished and
|
||||
// its memory grew without bound, freezing the app. A synthetic outline (found by search)
|
||||
// with the same failure as the reported glyph; the exact coordinates matter.
|
||||
const OUTLINE: &str = "M74.359137297823,82.83738194552835 L88.097432485874,103.04979803227248 \
|
||||
L67.06936731196308,105.95779886823588 L53.29382873396223,98.53943925153727 \
|
||||
L29.684713319694094,94.17996020306241 L41.65480824807079,75.639672165115 \
|
||||
Q47.491034578751446,52.12555333014565 46.157302824282816,55.67193079127421 \
|
||||
Q69.08365151786553,53.9608681393331 68.37499772324333,51.17242466680129 \
|
||||
Q74.4136309339628,75.13284223919301 71.3309887459222,73.69128869058812 Z";
|
||||
let path = PathData::from_bezpath(&BezPath::from_svg(OUTLINE).unwrap());
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let input = path.clone();
|
||||
std::thread::spawn(move || tx.send(offset_path(&input, 1.8350459854022054, Join::Miter, 4.0)));
|
||||
let Ok(out) = rx.recv_timeout(std::time::Duration::from_secs(30)) else {
|
||||
// the stuck sweep keeps allocating on its thread: end the test binary now
|
||||
eprintln!("Offset Path didn't finish in 30 s (issue #1028)");
|
||||
std::process::exit(101);
|
||||
};
|
||||
let (before, after) = (crate::area(&path, FillRule::NonZero), crate::area(&out, FillRule::NonZero));
|
||||
assert_eq!(out.subpaths.len(), 1);
|
||||
assert!(after > before, "{before} → {after}");
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ use vectorcraft_geom::{FillRule, PathData};
|
||||
|
||||
use crate::import_color::{Colors, Native};
|
||||
use crate::import_mask::{MaskSpec, contains, is_rectangle, luminance, mask_spec, white_cover};
|
||||
use crate::import_scan::{MAX_NESTING, Ocgs, Scan, all_on, hides_forms, scan_page, tag_key};
|
||||
use crate::import_scan::{FontNames, MAX_NESTING, Ocgs, Scan, all_on, hides_forms, scan_page, tag_key};
|
||||
use crate::import_shading::{clipped, extend_clip, fold_stop_opacity, mesh_shading, shading_gradient};
|
||||
use crate::import_text::{Families, LineFacts, Look, Placement, TextLine, Upright};
|
||||
use crate::{CropTo, ImportOptions, ImportReport, PdfError, TextAs};
|
||||
@@ -692,8 +692,9 @@ struct Builder<'p> {
|
||||
/// Mask keys of CMYK images whose mask turned out to hide nothing.
|
||||
opaque: HashSet<u128>,
|
||||
warnings: Vec<String>,
|
||||
/// Fonts by cache key → base font name (from [`scan_page`]).
|
||||
fonts: HashMap<u128, String>,
|
||||
/// Fonts by cache key → base font name and whether the file carries its glyphs (from
|
||||
/// [`scan_page`]).
|
||||
fonts: FontNames,
|
||||
font_names: HashMap<u128, FontInfo>,
|
||||
families: Option<Families>,
|
||||
missing_fonts: Vec<String>,
|
||||
@@ -1346,7 +1347,7 @@ impl<'p> Builder<'p> {
|
||||
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 name = self.fonts.get(&key).map(|(n, _)| n.clone()).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));
|
||||
// A subset's six-letter tag.
|
||||
let ps = name.as_deref().map(|n| match n.split_once('+') {
|
||||
@@ -1429,7 +1430,12 @@ impl<'p> Builder<'p> {
|
||||
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 {
|
||||
// A font the file doesn't carry is drawn with a stand-in: its glyphs can't tell which
|
||||
// style of the installed family it is, so the name decides.
|
||||
let embedded = self.fonts.get(&key).is_none_or(|(_, e)| *e);
|
||||
if let Some(face) = &info.face
|
||||
&& embedded
|
||||
{
|
||||
let decided = match self.matched.get(&key) {
|
||||
Some(m) => m.clone(),
|
||||
// A glyph that can't be compared (several characters, a vertical form) decides
|
||||
|
||||
@@ -389,12 +389,16 @@ fn alt_model(o: &Object<'_>) -> Option<Alt> {
|
||||
Object::Array(a) => {
|
||||
let mut it = a.flex_iter();
|
||||
match it.next::<Name<'_>>()?.as_str() {
|
||||
"ICCBased" => match it.next::<Stream<'_>>()?.dict().get::<u8>(b"N")? {
|
||||
1 => Some(Alt::Gray),
|
||||
3 => Some(Alt::Rgb),
|
||||
4 => Some(Alt::Cmyk),
|
||||
_ => None,
|
||||
},
|
||||
"ICCBased" => {
|
||||
let profile = it.next::<Stream<'_>>()?;
|
||||
match profile.dict().get::<u8>(b"N")? {
|
||||
1 => Some(Alt::Gray),
|
||||
3 if lab_profile(&profile) => Some(Alt::Lab),
|
||||
3 => Some(Alt::Rgb),
|
||||
4 => Some(Alt::Cmyk),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
"Lab" => Some(Alt::Lab),
|
||||
"CalRGB" => Some(Alt::Rgb),
|
||||
"CalGray" => Some(Alt::Gray),
|
||||
@@ -406,6 +410,27 @@ fn alt_model(o: &Object<'_>) -> Option<Alt> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Is the ICC profile of an `ICCBased` space a Lab one (its alternate space `Lab`, else its header's
|
||||
/// data colour space `Lab `)? Spot colours defined in Lab come with such a profile as their
|
||||
/// alternate space (#1032), and their tint transforms give Lab values.
|
||||
fn lab_profile(profile: &Stream<'_>) -> bool {
|
||||
use std::io::Read;
|
||||
if let Some(Object::Array(a)) = profile.dict().get::<Object<'_>>(b"Alternate") {
|
||||
return a.flex_iter().next::<Name<'_>>().is_some_and(|n| n.as_str() == "Lab");
|
||||
}
|
||||
// The header's first 20 bytes, without decoding the rest of the profile.
|
||||
let raw = profile.raw_data();
|
||||
let mut head = vec![];
|
||||
match profile.filters().as_slice() {
|
||||
[] => head.extend(raw.iter().take(20)),
|
||||
[hayro_syntax::Filter::FlateDecode] => {
|
||||
let _ = flate2::read::ZlibDecoder::new(&*raw).take(20).read_to_end(&mut head); // a damaged profile reads as not Lab
|
||||
}
|
||||
_ => return false,
|
||||
}
|
||||
head.get(16..20) == Some(b"Lab ")
|
||||
}
|
||||
|
||||
/// An alternate-space colour (the tint transform's output) as a VectorCraft colour.
|
||||
fn alt_color(alt: Alt, v: &[f32]) -> Option<Color> {
|
||||
let c = |i: usize| v.get(i).copied().filter(|x| x.is_finite());
|
||||
|
||||
@@ -248,7 +248,7 @@ struct Walker<'w, 'p> {
|
||||
/// The groups that are off for the interpreter (none once [`all_on`] turned them on).
|
||||
off: HashSet<ObjectIdentifier>,
|
||||
xref: &'p hayro_syntax::xref::XRef,
|
||||
fonts: &'w mut HashMap<u128, String>,
|
||||
fonts: &'w mut FontNames,
|
||||
visible: Vec<bool>,
|
||||
ops: usize,
|
||||
max_ops: usize,
|
||||
@@ -296,7 +296,7 @@ impl Walker<'_, '_> {
|
||||
if let Some(f) = res.fonts.get::<Dict<'_>>(key.as_ref())
|
||||
&& let Some(name) = f.get::<Name<'_>>(b"BaseFont")
|
||||
{
|
||||
self.fonts.entry(f.cache_key()).or_insert_with(|| String::from_utf8_lossy(name.as_ref()).into_owned());
|
||||
self.fonts.entry(f.cache_key()).or_insert_with(|| (String::from_utf8_lossy(name.as_ref()).into_owned(), embeds_glyphs(&f)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -372,13 +372,32 @@ impl Walker<'_, '_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The fonts of a document by cache key: base font name, and whether the file carries the glyphs
|
||||
/// ([`embeds_glyphs`]).
|
||||
pub(crate) type FontNames = HashMap<u128, (String, bool)>;
|
||||
|
||||
/// Whether font `f` carries its own glyphs: a font program in its descriptor (a composite font's in
|
||||
/// its descendant's), or a Type 3 font's glyph procedures. A font that doesn't is drawn with a
|
||||
/// stand-in, whose glyphs say nothing about which installed face the document means.
|
||||
fn embeds_glyphs(f: &Dict<'_>) -> bool {
|
||||
if f.get::<Name<'_>>(b"Subtype").is_some_and(|s| s.as_ref() == b"Type3") {
|
||||
return true;
|
||||
}
|
||||
// A font program is a stream, so always an indirect object.
|
||||
let program = |d: &Dict<'_>| {
|
||||
d.get::<Dict<'_>>(b"FontDescriptor").is_some_and(|fd| [&b"FontFile"[..], b"FontFile2", b"FontFile3"].iter().any(|k| fd.get_ref(*k).is_some()))
|
||||
};
|
||||
program(f) || f.get::<Array<'_>>(b"DescendantFonts").and_then(|a| a.iter::<Dict<'_>>().next()).is_some_and(|d| program(&d))
|
||||
}
|
||||
|
||||
/// Walk `page` as the interpreter will: `all_on` when [`all_on`] turned every group on. Fonts
|
||||
/// found are noted in `fonts` (their cache key → base font name).
|
||||
pub(crate) fn scan_page(page: &Page<'_>, ocgs: &mut Ocgs, all_on: bool, fonts: &mut HashMap<u128, String>) -> Scan {
|
||||
/// found are noted in `fonts` (their cache key → base font name, and whether the file carries its
|
||||
/// glyphs).
|
||||
pub(crate) fn scan_page(page: &Page<'_>, ocgs: &mut Ocgs, all_on: bool, fonts: &mut FontNames) -> Scan {
|
||||
walk_page(page, ocgs, all_on, fonts).out
|
||||
}
|
||||
|
||||
fn walk_page<'w, 'p>(page: &Page<'p>, ocgs: &'w mut Ocgs, all_on: bool, fonts: &'w mut HashMap<u128, String>) -> Walker<'w, 'p> {
|
||||
fn walk_page<'w, 'p>(page: &Page<'p>, ocgs: &'w mut Ocgs, all_on: bool, fonts: &'w mut FontNames) -> Walker<'w, 'p> {
|
||||
let off = if all_on { HashSet::new() } else { ocgs.off.clone() };
|
||||
let mut w = Walker { ocgs, off, xref: page.xref(), fonts, visible: vec![], ops: 0, max_ops: max_ops(), out: Scan::default(), hidden_form: false };
|
||||
w.walk(page.typed_operations(), page.resources(), 0);
|
||||
|
||||
@@ -215,3 +215,33 @@ fn colour_spaces_are_read_from_the_interpreters_debug_form() {
|
||||
assert_eq!((space, comps), ("ColorSpace(DeviceCmyk)", vec![0.101960786, 0.2, 0.3, 0.4]));
|
||||
assert_eq!(crate::import_color::split_color("Color { color_space: X, components: [NaN], opacity: 1.0 }"), None);
|
||||
}
|
||||
|
||||
/// Spot colours defined in Lab can have an ICC Lab profile as their alternate space, `[/ICCBased
|
||||
/// profile]` with 3 components: the tint transform gives Lab values, which are not RGB (#1032). The
|
||||
/// profile is told by its `Alternate` (`[/Lab …]`), else by its header's data colour space.
|
||||
#[test]
|
||||
fn spot_colours_with_an_icc_lab_alternate_keep_their_lab_values() {
|
||||
let f = first_extra(1);
|
||||
// A profile's first 20 bytes: size, CMM, version, class, then the data colour space.
|
||||
let profile = |head: &str, alternate: &str| format!("<< /N 3 {alternate} /Length {} >>\nstream\n{head}\nendstream", head.len());
|
||||
let lab_header = profile("0000none0000spacLab XYZ ", "");
|
||||
let lab_alternate = profile("0000none0000spacXYZ XYZ ", "/Alternate [/Lab << /WhitePoint [0.9642 1 0.8249] /Range [-128 127 -128 127] >>]");
|
||||
let rgb = profile("0000none0000mntrRGB XYZ ", "");
|
||||
let tint = |c1: &str| format!("<< /FunctionType 2 /Domain [0 1] /C0 [100 0 0] /C1 [{c1}] /N 1 >>");
|
||||
let spaces = format!(
|
||||
"/CS0 [/Separation /Plum [/ICCBased {f} 0 R] {}] /CS1 [/Separation /Sky [/ICCBased {} 0 R] {}] /CS2 [/Separation /Leafy [/ICCBased {} 0 R] << /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [0.5 0.9 0.1] /N 1 >>]",
|
||||
tint("32.9412 45 -18"),
|
||||
f + 1,
|
||||
tint("51 -26 -45"),
|
||||
f + 2
|
||||
);
|
||||
let page = page_with(&spaces, &[("CS0", "1"), ("CS1", "0.5"), ("CS2", "1")]);
|
||||
let d = import(&pdf_with(&[page], &[&lab_header, &lab_alternate, &rgb], None)).unwrap();
|
||||
let f = fills(&d);
|
||||
assert_eq!(solid(&f[0]).1, Some("Plum"));
|
||||
assert!(near(d.swatch("Plum").unwrap().paint.color().unwrap(), Color::lab(32.941, 45.0, -18.0)), "{:?}", d.swatch("Plum"));
|
||||
assert_eq!((solid(&f[1]).1, solid(&f[1]).2), (Some("Sky"), 0.5));
|
||||
assert!(near(d.swatch("Sky").unwrap().paint.color().unwrap(), Color::lab(51.0, -26.0, -45.0)), "{:?}", d.swatch("Sky"));
|
||||
// An RGB profile stays RGB.
|
||||
assert!(near(d.swatch("Leafy").unwrap().paint.color().unwrap(), Color::rgb(0.5, 0.9, 0.1)), "{:?}", d.swatch("Leafy"));
|
||||
}
|
||||
@@ -977,7 +977,7 @@ impl Renderer {
|
||||
fn isolation_path(&mut self, f: &Frame) -> Option<(NodeId, Vec<NodeId>)> {
|
||||
let isolated = f.opts.isolated.filter(|_| !self.isolation_settled)?;
|
||||
let doc = std::ptr::from_ref(f.doc) as usize;
|
||||
if !self.isolation.as_ref().is_some_and(|(stamp, d, id, _)| (*stamp, *d, *id) == (self.stamp, doc, isolated)) {
|
||||
if self.isolation.as_ref().is_none_or(|(stamp, d, id, _)| (*stamp, *d, *id) != (self.stamp, doc, isolated)) {
|
||||
self.isolation = Some((self.stamp, doc, isolated, f.doc.ancestry(isolated).unwrap_or_default()));
|
||||
}
|
||||
let path = self.isolation.as_ref().map(|(.., path)| path.clone()).filter(|p| !p.is_empty())?;
|
||||
|
||||
+13
-9
@@ -109,15 +109,19 @@ pub(crate) fn type_props(st: &CharStyle, len: &dyn Fn(f64) -> String) -> Props {
|
||||
// Manual kerning replaces the font's pair kerning.
|
||||
p.push(("font-kerning", "none".into()));
|
||||
}
|
||||
if !st.features.is_empty() {
|
||||
let v: Vec<String> = st
|
||||
.features
|
||||
.iter()
|
||||
.map(|t| match t.strip_prefix('-') {
|
||||
Some(off) => format!("\"{off}\" 0"),
|
||||
None => format!("\"{t}\" 1"),
|
||||
})
|
||||
.collect();
|
||||
let mut v: Vec<String> = st
|
||||
.features
|
||||
.iter()
|
||||
.map(|t| match t.strip_prefix('-') {
|
||||
Some(off) => format!("\"{off}\" 0"),
|
||||
None => format!("\"{t}\" 1"),
|
||||
})
|
||||
.collect();
|
||||
// Proportional Metrics: `palt` (SVG writes vertical type as outlines).
|
||||
if st.proportional_metrics {
|
||||
v.push("\"palt\" 1".into());
|
||||
}
|
||||
if !v.is_empty() {
|
||||
p.push(("font-feature-settings", v.join(", ")));
|
||||
}
|
||||
match (st.underline, st.strikethrough) {
|
||||
|
||||
+11
-10
@@ -47,8 +47,8 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec<S
|
||||
usvg::Tree::from_str(&src, &opt).map_err(|e| SvgError::Parse(e.to_string()))?
|
||||
};
|
||||
let size = tree.size();
|
||||
let (kx, ky) = units.k;
|
||||
let mut doc = Document::new(size.width() as f64 * kx, size.height() as f64 * ky);
|
||||
let k = units.k;
|
||||
let mut doc = Document::new(size.width() as f64 * k, size.height() as f64 * k);
|
||||
doc.units = units.unit;
|
||||
let mut im = Importer {
|
||||
doc,
|
||||
@@ -68,7 +68,7 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec<S
|
||||
data: found.data,
|
||||
sublayers: found.sublayers,
|
||||
sublayer_groups: HashSet::new(),
|
||||
screen: (kx * ky).sqrt(),
|
||||
screen: k,
|
||||
};
|
||||
if im.files.nested_text() {
|
||||
im.warn("text inside an SVG image isn't imported".into());
|
||||
@@ -76,7 +76,7 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec<S
|
||||
|
||||
// usvg wraps everything in an id-less group carrying the viewBox transform when needed.
|
||||
let mut top = tree.root();
|
||||
let mut base = Affine::scale_non_uniform(kx, ky) * aff(top.transform());
|
||||
let mut base = Affine::scale(k) * aff(top.transform());
|
||||
if let Some(g) = im.wrapper(top) {
|
||||
base *= aff(g.transform());
|
||||
top = g;
|
||||
@@ -212,12 +212,14 @@ pub(super) const DEFAULT_FONT_SIZE: f64 = 12.0;
|
||||
/// A pixel (and a unitless user unit) is a point, as we export, and absolute lengths keep their
|
||||
/// physical size at 72 pt per inch: `font-size="12pt"` is 12 pt, `1in` is 72 pt. A root size in
|
||||
/// absolute units (`width="210mm"`) is that physical size, and its user units are CSS pixels of it
|
||||
/// (96 per inch, 0.75 pt each), so a drawing without a `viewBox` keeps its proportions.
|
||||
/// (96 per inch, 0.75 pt each), so a drawing without a `viewBox` keeps its proportions. With one
|
||||
/// side absolute and the other not (`width="96pt" height="96"`), both are: the unitless side is
|
||||
/// CSS pixels too, so the drawing isn't stretched (#1010).
|
||||
struct RootUnits {
|
||||
/// Pixels per inch usvg (and the text pass) convert absolute lengths at.
|
||||
dpi: f64,
|
||||
/// Points per user unit of the root along x and y.
|
||||
k: (f64, f64),
|
||||
/// Points per user unit of the root, along both axes.
|
||||
k: f64,
|
||||
/// Document units: those of the root `width` (pixels when it has none).
|
||||
unit: Unit,
|
||||
}
|
||||
@@ -228,8 +230,7 @@ impl RootUnits {
|
||||
use svgtypes::LengthUnit as U;
|
||||
let physical = |u: Option<U>| matches!(u, Some(U::In | U::Cm | U::Mm | U::Pt | U::Pc));
|
||||
let (w, h) = (unit("width"), unit("height"));
|
||||
let k = |u| if physical(u) { PT_PER_IN / CSS_PX_PER_IN } else { 1.0 };
|
||||
let dpi = if physical(w) || physical(h) { CSS_PX_PER_IN } else { PT_PER_IN };
|
||||
let (dpi, k) = if physical(w) || physical(h) { (CSS_PX_PER_IN, PT_PER_IN / CSS_PX_PER_IN) } else { (PT_PER_IN, 1.0) };
|
||||
let unit = match w {
|
||||
Some(U::Mm) => Unit::Millimeters,
|
||||
Some(U::Cm) => Unit::Centimeters,
|
||||
@@ -238,7 +239,7 @@ impl RootUnits {
|
||||
Some(U::Pc) => Unit::Picas,
|
||||
_ => Unit::Pixels,
|
||||
};
|
||||
Self { dpi, k: (k(w), k(h)), unit }
|
||||
Self { dpi, k, unit }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,8 +41,9 @@
|
||||
//! * One CSS pixel (user unit) is one point, as on export, and absolute lengths (`in`, `cm`, `mm`,
|
||||
//! `pt`, `pc`) keep their physical size (72 pt per inch): `font-size="12pt"` is 12 pt. A root
|
||||
//! `width`/`height` in absolute units keeps its physical size, so a 210 mm SVG opens on a 210 mm
|
||||
//! artboard, its user units being CSS pixels of it (96 per inch). The document's units follow the
|
||||
//! unit of the root `width` (pixels when it has none).
|
||||
//! artboard, its user units being CSS pixels of it (96 per inch), the other side's too when only
|
||||
//! one is absolute. The document's units follow the unit of the root `width` (pixels when it has
|
||||
//! none).
|
||||
//! * `<mask>` imports as a luminance opacity mask; a mask we exported keeps its options and art
|
||||
//! (its `data-vectorcraft-mask="noclip invert"` lists the options that differ from clipping and
|
||||
//! not inverted). Nested clip paths (`<clipPath clip-path>`) are clip groups in clip groups.
|
||||
|
||||
@@ -811,6 +811,31 @@ fn import_absolute_units_keep_their_physical_size() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #1010: a root with one absolute side and one unitless or px side opens as if both were
|
||||
/// absolute (CSS pixels at 96 per inch), so nothing is stretched and the viewport keeps its size.
|
||||
#[test]
|
||||
fn import_mixed_root_units_scale_both_sides_alike() {
|
||||
let rect = |d: &Document| art(d)[0].geometric_bounds().unwrap();
|
||||
for (size, board) in [
|
||||
(r#"width="96pt" height="96""#, Rect::new(0.0, 0.0, 96.0, 72.0)),
|
||||
(r#"width="96pt" height="96px""#, Rect::new(0.0, 0.0, 96.0, 72.0)),
|
||||
(r#"width="96pt" height="72pt""#, Rect::new(0.0, 0.0, 96.0, 72.0)),
|
||||
(r#"width="128" height="72pt""#, Rect::new(0.0, 0.0, 96.0, 72.0)),
|
||||
] {
|
||||
let svg =
|
||||
|extra: &str| format!(r##"<svg xmlns="http://www.w3.org/2000/svg" {size} {extra}><rect width="32" height="32" fill="#f00"/></svg>"##);
|
||||
let d = import(&svg("")).unwrap();
|
||||
assert!(close_rect(d.artboards[0].rect, board, 1e-6), "{size}: {:?}", d.artboards[0].rect);
|
||||
assert!(close_rect(rect(&d), Rect::new(0.0, 0.0, 24.0, 24.0), 1e-6), "{size}: {:?}", rect(&d));
|
||||
// With a viewBox: fitted uniformly (xMidYMid meet), unless preserveAspectRatio is none.
|
||||
let d = import(&svg(r#"viewBox="0 0 32 32""#)).unwrap();
|
||||
assert!(close_rect(d.artboards[0].rect, board, 1e-6), "{size}: {:?}", d.artboards[0].rect);
|
||||
assert!(close_rect(rect(&d), Rect::new(12.0, 0.0, 84.0, 72.0), 1e-6), "{size} viewBox: {:?}", rect(&d));
|
||||
let d = import(&svg(r#"viewBox="0 0 32 32" preserveAspectRatio="none""#)).unwrap();
|
||||
assert!(close_rect(rect(&d), board, 1e-6), "{size} none: {:?}", rect(&d));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_document_roundtrip_is_stable() {
|
||||
// export → import → export produces identical SVG.
|
||||
|
||||
@@ -105,6 +105,20 @@ fn rich_text_writes_spacing_shift_and_scale() {
|
||||
assert_eq!(attr(tags[1], "y"), attr(tags[0], "y"));
|
||||
}
|
||||
|
||||
/// Proportional Metrics (#966) is written as the `palt` feature, beside the character's own.
|
||||
#[test]
|
||||
fn proportional_metrics_write_palt() {
|
||||
let proportional = CharStyle { proportional_metrics: true, ..style(12.0) };
|
||||
let both = CharStyle { proportional_metrics: true, features: vec!["dlig".into()], ..style(12.0) };
|
||||
let d = area_doc(vec![run("雅楽 ", style(12.0)), run("「あ」 ", proportional), run("ab", both)], 280.0, Justify::Left);
|
||||
let svg = export(&d, &ExportOptions::default());
|
||||
let tags = tspans(&svg);
|
||||
assert_eq!(tags.len(), 3, "{svg}");
|
||||
assert!(!tags[0].contains("palt"), "{}", tags[0]);
|
||||
assert!(tags[1].contains("font-feature-settings:"palt" 1"), "{}", tags[1]);
|
||||
assert!(tags[2].contains("dlig") && tags[2].contains("palt"), "{}", tags[2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fewer_tspans_writes_one_per_line() {
|
||||
let bold = CharStyle { font_style: "Bold".into(), ..style(12.0) };
|
||||
|
||||
@@ -51,6 +51,12 @@ fn main() {
|
||||
}
|
||||
let src = format!("pub static CRAFT_FONTS: &[CraftFont] = &[\n{fonts}];\npub static WEB_FONTS: &[WebFont] = &[\n{web}];\n");
|
||||
let out = PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("craft_fonts.rs");
|
||||
// Unchanged output keeps its mtime. Rewritten just before this crate compiles, it can read as
|
||||
// newer than that compile where cargo's recorded times lose their sub-second part (ecryptfs
|
||||
// home directories), and the next build would compile this crate and everything above it again.
|
||||
if std::fs::read_to_string(&out).is_ok_and(|old| old == src) {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = std::fs::write(&out, src) {
|
||||
println!("cargo::error=writing {}: {e}", out.display());
|
||||
}
|
||||
|
||||
@@ -160,6 +160,23 @@ impl OtFeatures {
|
||||
// and digits itself (they would lie upside down, and tate-chu-yoko break).
|
||||
v.push(f(b"vert", true));
|
||||
}
|
||||
if self.proportional(st) {
|
||||
v.push(f(b"palt", true));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Does text in style `st` take proportional widths (`palt`)? Proportional Metrics in
|
||||
/// horizontal type; vertical type (`vpal`) needs vertical shaping, so it sets as before.
|
||||
pub(crate) fn proportional(&self, st: &CharStyle) -> bool {
|
||||
st.proportional_metrics && !self.vertical
|
||||
}
|
||||
|
||||
/// The harfrust features for text in style `st` with full-width glyphs on their full widths:
|
||||
/// [`Self::resolve`] without `palt`.
|
||||
pub(crate) fn resolve_fixed_width(&self, st: &CharStyle) -> Vec<Feature> {
|
||||
let mut v = self.resolve(st);
|
||||
v.retain(|x| x.tag != Tag::new(b"palt"));
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,15 +200,21 @@ pub fn layout_with(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLay
|
||||
}
|
||||
|
||||
/// Shrink Text to Fit for text that overflows at full size: the largest scale in `min..1` at which
|
||||
/// it fits, searched by bisection over the first run's size in steps of 0.1 pt (so its scaled size
|
||||
/// it fits, searched by bisection over the first run's document size in steps of 0.1 pt (so its scaled size
|
||||
/// is a whole number of tenths of a point, the same every time). Down at `min` it lays out there,
|
||||
/// still overflowing. None (keep the full-size layout) when the text has no usable size.
|
||||
fn shrink_to_fit(db: &FontDb, t: &TextObject, opts: &LayoutOptions, min: f64) -> Option<TextLayout> {
|
||||
let size = t.runs.first().map(|r| r.style.size).filter(|s| s.is_finite() && *s >= 0.1)?;
|
||||
// Exact size edits after object scaling may store a local size below 0.1. Fit in the
|
||||
// same document points as the Character controls, independent of that representation.
|
||||
let points = t.style_scale()?.points;
|
||||
let size = t.runs.first().map(|r| r.style.size * points).filter(|s| s.is_finite() && *s >= 0.1)?;
|
||||
if !(min.is_finite() && min < 1.0) {
|
||||
return None;
|
||||
}
|
||||
let tenths = size * 10.0;
|
||||
if !tenths.is_finite() {
|
||||
return None;
|
||||
}
|
||||
// Candidate sizes k/10 pt for k in lo..hi: `hi` (full size, rounded down) is known to overflow
|
||||
// unless it is below the full size; `lo` is the smallest allowed.
|
||||
let mut lo = (tenths * min).ceil().max(1.0);
|
||||
@@ -234,7 +240,7 @@ fn shrink_to_fit(db: &FontDb, t: &TextObject, opts: &LayoutOptions, min: f64) ->
|
||||
}
|
||||
// Invariant: everything at `hi` or above overflows; `lo` fits or is the floor.
|
||||
while hi - lo > 1.0 && passes + 1 < SHRINK_PASSES {
|
||||
let mid = ((lo + hi) * 0.5).floor();
|
||||
let mid = (lo + (hi - lo) * 0.5).floor();
|
||||
let l = at(mid);
|
||||
passes += 1;
|
||||
if l.overflow {
|
||||
@@ -381,7 +387,8 @@ 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() {
|
||||
// A glyph Proportional Metrics re-spaced has no empty half left to take off (#966).
|
||||
if punct(g.ch)? != kind || g.tcy.is_some() || g.proportional {
|
||||
return None;
|
||||
}
|
||||
let em = g.face.units_per_em();
|
||||
@@ -1170,6 +1177,7 @@ mod wrapping_tests {
|
||||
tcy: None,
|
||||
inline: None,
|
||||
lead: 0.0,
|
||||
proportional: false,
|
||||
level: unicode_bidi::Level::ltr(),
|
||||
})
|
||||
.collect()
|
||||
|
||||
+44
-24
@@ -49,6 +49,9 @@ pub(crate) struct SGlyph {
|
||||
/// 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,
|
||||
/// Proportional Metrics changed the glyph's advance (`palt`): it has its proportional width,
|
||||
/// and Japanese composition takes no more space off it (#966).
|
||||
pub proportional: bool,
|
||||
/// Resolved Unicode bidi embedding level (logical source order).
|
||||
pub level: Level,
|
||||
/// An inline graphic ([`vectorcraft_doc::TextRun::inline`]) standing in for a glyph.
|
||||
@@ -117,6 +120,7 @@ pub(crate) fn hyphen_glyph(g: &SGlyph) -> SGlyph {
|
||||
h.len = 0;
|
||||
h.ch = '-';
|
||||
h.inline = None;
|
||||
h.proportional = false;
|
||||
h
|
||||
}
|
||||
|
||||
@@ -422,6 +426,7 @@ fn inline_glyph(face: &Arc<FontFace>, st: &CharStyle, art: &InlineArt, run: usiz
|
||||
ch: text.get(range.clone()).and_then(|s| s.chars().next()).unwrap_or(INLINE_CHAR),
|
||||
tcy: None,
|
||||
lead: 0.0,
|
||||
proportional: false,
|
||||
// U+FFFC is a bidi neutral (ON): its level, resolved from its neighbours, places it in
|
||||
// RTL text like any other glyph once the line is reordered.
|
||||
level,
|
||||
@@ -470,34 +475,48 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
|
||||
let first_char = |byte: usize| text[byte..].chars().next().unwrap_or(' ');
|
||||
|
||||
let mut raw: Vec<(u32, u32, i32, i32, i32)> = Vec::with_capacity(text_seg.len()); // gid, cluster, xadv, xoff, yoff
|
||||
// Proportional Metrics: which glyphs `palt` gave another advance than their full width.
|
||||
let mut proportional = vec![];
|
||||
let shaped = face.hb().map(|hb| {
|
||||
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
|
||||
let mut buf = UnicodeBuffer::new();
|
||||
for (i, c) in text_seg.char_indices() {
|
||||
let cl = (range.start + i) as u32;
|
||||
if upper {
|
||||
for u in c.to_uppercase() {
|
||||
buf.add(u, cl);
|
||||
let shape = |feats: &[Feature]| {
|
||||
let mut buf = UnicodeBuffer::new();
|
||||
for (i, c) in text_seg.char_indices() {
|
||||
let cl = (range.start + i) as u32;
|
||||
if upper {
|
||||
for u in c.to_uppercase() {
|
||||
buf.add(u, cl);
|
||||
}
|
||||
} else {
|
||||
buf.add(c, cl);
|
||||
}
|
||||
} else {
|
||||
buf.add(c, cl);
|
||||
}
|
||||
}
|
||||
buf.set_direction(if level.is_rtl() { Direction::RightToLeft } else { Direction::LeftToRight });
|
||||
buf.guess_segment_properties();
|
||||
// Harfrust keeps this Unicode context for joining/positional shaping, but does not emit
|
||||
// glyphs for it. This retains context at real style/font segmentation boundaries without
|
||||
// shaping across those boundaries or claiming cross-style ligatures/mark attachment.
|
||||
if let Some(pre) = text.get(context.start..range.start) {
|
||||
buf.set_pre_context(pre);
|
||||
}
|
||||
if let Some(post) = text.get(range.end..context.end) {
|
||||
buf.set_post_context(post);
|
||||
}
|
||||
let feats: Vec<Feature> = feats.resolve(st);
|
||||
let gb = shaper.shape(buf, ShapeOptions::new().features(&feats));
|
||||
for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) {
|
||||
raw.push((info.glyph_id, info.cluster, pos.x_advance, pos.x_offset, pos.y_offset));
|
||||
buf.set_direction(if level.is_rtl() { Direction::RightToLeft } else { Direction::LeftToRight });
|
||||
buf.guess_segment_properties();
|
||||
// Harfrust keeps this Unicode context for joining/positional shaping, but does not emit
|
||||
// glyphs for it. This retains context at real style/font segmentation boundaries without
|
||||
// shaping across those boundaries or claiming cross-style ligatures/mark attachment.
|
||||
if let Some(pre) = text.get(context.start..range.start) {
|
||||
buf.set_pre_context(pre);
|
||||
}
|
||||
if let Some(post) = text.get(range.end..context.end) {
|
||||
buf.set_post_context(post);
|
||||
}
|
||||
let gb = shaper.shape(buf, ShapeOptions::new().features(feats));
|
||||
gb.glyph_infos()
|
||||
.iter()
|
||||
.zip(gb.glyph_positions())
|
||||
.map(|(info, pos)| (info.glyph_id, info.cluster, pos.x_advance, pos.x_offset, pos.y_offset))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
raw = shape(&feats.resolve(st));
|
||||
if feats.proportional(st) {
|
||||
// `palt` is positioning only: the same glyphs set on their full widths tell which
|
||||
// advances it changed (kerning and the rest change both alike).
|
||||
let full = shape(&feats.resolve_fixed_width(st));
|
||||
if full.len() == raw.len() && full.iter().zip(&raw).all(|(a, b)| a.0 == b.0) {
|
||||
proportional = full.iter().zip(&raw).map(|(a, b)| a.2 != b.2).collect();
|
||||
}
|
||||
}
|
||||
});
|
||||
if shaped.is_none() {
|
||||
@@ -553,6 +572,7 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
|
||||
ch,
|
||||
tcy: None,
|
||||
lead: 0.0,
|
||||
proportional: proportional.get(gi).copied().unwrap_or(false),
|
||||
level: *level,
|
||||
inline: None,
|
||||
});
|
||||
|
||||
@@ -54,6 +54,19 @@ fn shrink_text_makes_overflowing_text_fit() {
|
||||
assert_eq!(layout(db(), &t).fit_scale, l.fit_scale);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaled_area_shrink_accepts_small_local_sizes_and_uses_document_tenths() {
|
||||
// A 14 pt story represented in a scaled local coordinate system. Its local size is
|
||||
// below 0.1, as happens when setting an exact size after enlarging a text group.
|
||||
let mut t = area(COPY, 0.014, Rect::new(0.0, 0.0, 0.2, 0.09), shrink(30.0));
|
||||
t.xf = Affine::scale(1000.0);
|
||||
assert!(at_size(&t, 0.014).overflow);
|
||||
let l = layout(db(), &t);
|
||||
assert!(l.fit_scale < 1.0 && l.fit_scale >= 0.3, "{}", l.fit_scale);
|
||||
let document_size = 14.0 * l.fit_scale;
|
||||
assert!((document_size * 10.0 - (document_size * 10.0).round()).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shrink_text_respects_its_minimum() {
|
||||
let t = area(COPY, 14.0, Rect::new(0.0, 0.0, 120.0, 40.0), shrink(90.0));
|
||||
|
||||
@@ -749,3 +749,48 @@ fn layout_10k_area_text_is_fast() {
|
||||
assert!(per < budget, "{justify:?} {composer:?}: {per} ms");
|
||||
}
|
||||
}
|
||||
|
||||
/// Proportional Metrics (#966): in horizontal type full-width glyphs take the font's proportional
|
||||
/// widths (`palt`), and Japanese composition takes nothing more off the punctuation `palt`
|
||||
/// re-spaced (the flush bracket at a paragraph's start, consecutive punctuation, half width at a
|
||||
/// line's end); punctuation it leaves full width is trimmed as before. Vertical type sets as
|
||||
/// before. Needs craft-fonts' Shippori Mincho, whose `palt` covers kana and most brackets but not
|
||||
/// 〘〙 (skipped without it).
|
||||
#[test]
|
||||
fn proportional_metrics_set_full_width_glyphs_on_their_proportional_widths() {
|
||||
use vectorcraft_doc::Mojikumi;
|
||||
if db().face("Shippori Mincho", "Regular").is_none_or(|f| f.family != "Shippori Mincho") {
|
||||
eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)");
|
||||
return;
|
||||
}
|
||||
let st = |on: bool| CharStyle { size: 20.0, font_family: "Shippori Mincho".into(), proportional_metrics: on, ..CharStyle::default() };
|
||||
// Right-aligned in a frame ten ems wide, so a line's end trim moves the line.
|
||||
let lay = |text: &str, on: bool, m: Mojikumi, vertical: bool| {
|
||||
let mut t = area(text, st(on), Rect::new(0.0, 0.0, 200.0, 200.0), Justify::Right);
|
||||
t.vertical = vertical;
|
||||
t.para.mojikumi = m;
|
||||
layout(db(), &t)
|
||||
};
|
||||
let adv = |l: &TextLayout| l.glyphs.iter().map(|g| g.advance).collect::<Vec<_>>();
|
||||
let x0 = |l: &TextLayout| l.glyphs.iter().map(|g| g.outline.bounding_box().x0).collect::<Vec<_>>();
|
||||
let near = |a: &[f64], b: &[f64]| a.len() == b.len() && a.iter().zip(b).all(|(a, b)| (a - b).abs() < 0.01);
|
||||
let off = adv(&lay("「あ漢」", false, Mojikumi::None, false));
|
||||
let on = adv(&lay("「あ漢」", true, Mojikumi::None, false));
|
||||
assert!(off.iter().all(|a| (a - 20.0).abs() < 0.01), "{off:?}");
|
||||
assert!(on[0] < 15.0 && on[1] < 19.0 && on[3] < 15.0, "kana and brackets narrower: {on:?}");
|
||||
assert!((on[2] - 20.0).abs() < 0.01, "the kanji keeps its em: {on:?}");
|
||||
// Re-spaced punctuation: Line-end Punctuation Half Width changes nothing, at the paragraph's
|
||||
// start (「), between punctuation (」、) or at the line's end (」).
|
||||
for text in ["「あ」、漢", "漢「あ」"] {
|
||||
let (half, none) = (lay(text, true, Mojikumi::LineEndHalf, false), lay(text, true, Mojikumi::None, false));
|
||||
assert!(near(&adv(&half), &adv(&none)), "{text}: {:?} {:?}", adv(&half), adv(&none));
|
||||
assert!(near(&x0(&half), &x0(&none)), "{text}: {:?} {:?}", x0(&half), x0(&none));
|
||||
}
|
||||
// Punctuation `palt` leaves full width: flush at the paragraph's start, half width at the end.
|
||||
let (half, none) = (lay("〘あ〙", true, Mojikumi::LineEndHalf, false), lay("〘あ〙", true, Mojikumi::None, false));
|
||||
assert!((none.glyphs[0].advance - 20.0).abs() < 0.01 && (half.glyphs[0].advance - 10.0).abs() < 0.01, "{:?}", adv(&half));
|
||||
let moved = x0(&half)[1] - x0(&none)[1];
|
||||
assert!((moved - 10.0).abs() < 0.01, "〙's empty half goes past the line's end: あ moved {moved}");
|
||||
// Vertical type sets as before.
|
||||
assert!(near(&adv(&lay("「あ漢」", true, Mojikumi::None, true)), &adv(&lay("「あ漢」", false, Mojikumi::None, true))));
|
||||
}
|
||||
@@ -15,7 +15,8 @@
|
||||
//! Both pick the key objects of a blend, and click or drag ruler guides ([`crate::rulerguide`]).
|
||||
//! 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
|
||||
//! and mesh envelopes and their handles ([`MeshEdit`]; a solid fill colour recolours a gradient
|
||||
//! mesh's point last clicked). Dragging a corner or an edge of area
|
||||
//! type's frame reshapes the type area (`text.reshapeArea`): the text reflows at its size.
|
||||
//! Dragging the brackets of selected type on a path moves or flips it ([`crate::pathtype`]).
|
||||
//!
|
||||
@@ -291,6 +292,9 @@ impl Tool for DirectSelectionTool {
|
||||
fn busy(&self) -> bool {
|
||||
!matches!(self.state, State::Idle) || self.guide.busy()
|
||||
}
|
||||
fn options(&self) -> Value {
|
||||
self.mesh.options()
|
||||
}
|
||||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||
if let Some(out) = self.guide.pointer(cx, ev) {
|
||||
return out;
|
||||
|
||||
@@ -372,7 +372,13 @@ impl Targets {
|
||||
/// tools): they pull a point into line where they run.
|
||||
fn with_rulers(mut self, cx: &ToolContext) -> Self {
|
||||
if cx.guides {
|
||||
self.rulers = cx.doc.guides.iter().map(|g| Ruler { vertical: g.vertical, pos: g.pos, span: cx.doc.guide_span(g) }).collect();
|
||||
self.rulers = cx
|
||||
.doc
|
||||
.guides
|
||||
.iter()
|
||||
.filter(|g| cx.doc.guide_shown(g))
|
||||
.map(|g| Ruler { vertical: g.vertical, pos: g.pos, span: cx.doc.guide_span(g) })
|
||||
.collect();
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
@@ -425,6 +425,11 @@ pub enum Cursor {
|
||||
ResizeNwSe,
|
||||
ResizeNeSw,
|
||||
Rotate,
|
||||
/// The rotate cursor beside a bounding box, bowed toward the pointer's side of the box: off
|
||||
/// its centre in direction `0..8` (0 straight up, then clockwise in 45° steps). Its arc wraps
|
||||
/// the corner it is at, so at the top right it opens toward the bottom left. Make it with
|
||||
/// [`Cursor::rotate_about`].
|
||||
RotateToward(u8),
|
||||
/// Over a Live Corners widget (drag to round the corners).
|
||||
CornerRadius,
|
||||
Pen,
|
||||
@@ -476,6 +481,18 @@ pub enum Cursor {
|
||||
}
|
||||
|
||||
impl Cursor {
|
||||
/// The rotate cursor for the pointer at `p` turning art about `center`: [`Cursor::RotateToward`]
|
||||
/// the pointer's side of it, or the plain [`Cursor::Rotate`] on the centre itself.
|
||||
pub fn rotate_about(center: Point, p: Point) -> Self {
|
||||
let d = p - center;
|
||||
if !(d.x.is_finite() && d.y.is_finite()) || d.hypot() < 1e-9 {
|
||||
return Cursor::Rotate;
|
||||
}
|
||||
// y points down the page: atan2(x, -y) is 0 straight up and grows clockwise.
|
||||
let octant = (d.x.atan2(-d.y) / std::f64::consts::FRAC_PI_4).round() as i64;
|
||||
Cursor::RotateToward(octant.rem_euclid(8) as u8)
|
||||
}
|
||||
|
||||
/// General › Use Precise Cursors: the drawing tools' pointers (the Pen's in every state, the
|
||||
/// Eyedropper's, the Slice and Blend tools') become a plain crosshair at the hotspot.
|
||||
pub fn precise(self) -> Self {
|
||||
@@ -485,6 +502,7 @@ impl Cursor {
|
||||
| Cursor::PenDelete
|
||||
| Cursor::PenClose
|
||||
| Cursor::PenContinue
|
||||
| Cursor::PenJoin
|
||||
| Cursor::PenConvert
|
||||
| Cursor::Eyedropper
|
||||
| Cursor::Slice
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
//! the click); click inside a mesh or a mesh envelope → `object.mesh.addLine` (a gradient mesh's
|
||||
//! new point takes the current fill colour unless Shift); drag a mesh point, or a handle of the
|
||||
//! point last clicked → `object.mesh.movePoint` previews ([`MeshEdit`]); Alt-click a mesh point →
|
||||
//! `object.mesh.deletePoint`.
|
||||
//! `object.mesh.deletePoint`. Clicking a gradient mesh's point selects the mesh, and the next
|
||||
//! solid fill colour recolours just that point.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_color::Paint;
|
||||
@@ -183,7 +184,14 @@ impl Tool for MeshTool {
|
||||
self.edit.unfocus();
|
||||
return vec![Action::Exec("object.mesh.deletePoint".into(), json!({"id": g.id.0, "index": g.index}))];
|
||||
}
|
||||
return self.edit.press(g);
|
||||
// A gradient mesh's point is picked for the next fill colour, so its mesh is
|
||||
// what gets selected.
|
||||
let mut out = vec![];
|
||||
if cx.selection.objects != [g.id] && cx.doc.node(g.id).is_some_and(|n| matches!(n.kind, NodeKind::Mesh(_))) {
|
||||
out.push(Action::Exec("select.set".into(), json!({"ids": [g.id.0]})));
|
||||
}
|
||||
out.extend(self.edit.press(g));
|
||||
return out;
|
||||
}
|
||||
self.edit.unfocus();
|
||||
let Some((top, leaf)) = hit_top(cx, p) else { return vec![] };
|
||||
@@ -236,6 +244,9 @@ impl Tool for MeshTool {
|
||||
None => Cursor::PenAdd,
|
||||
}
|
||||
}
|
||||
fn options(&self) -> serde_json::Value {
|
||||
self.edit.options()
|
||||
}
|
||||
fn busy(&self) -> bool {
|
||||
self.edit.dragging()
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
//! Dragging mesh points and their handles on the canvas, shared by the Mesh tool and Direct
|
||||
//! Selection: gradient meshes and mesh envelopes alike (`object.mesh.movePoint`). Clicking a
|
||||
//! point focuses it: its handles show and can be dragged too.
|
||||
//! point focuses it: its handles show and can be dragged too, and on a gradient mesh a solid fill
|
||||
//! colour recolours it (the tool's `meshPoint` option).
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use serde_json::json;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::live::{GradientMesh, envelope_grid};
|
||||
use vectorcraft_doc::{EnvelopeKind, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::Point;
|
||||
@@ -91,6 +92,12 @@ impl MeshEdit {
|
||||
self.focus = None;
|
||||
}
|
||||
|
||||
/// `{meshPoint: {id, index}|null}`: the focused point, which a solid fill colour recolours
|
||||
/// (`paint.setFill` on a gradient mesh).
|
||||
pub fn options(&self) -> Value {
|
||||
json!({"meshPoint": self.focus.map(|(id, index)| json!({"id": id.0, "index": index}))})
|
||||
}
|
||||
|
||||
/// The focused point's handles (lines and ends).
|
||||
pub fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
let Some((id, index)) = self.focus else { return vec![] };
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
//! selected guides (Alt copies them). A dragged guide snaps ([`GuideSnap`]): with Shift to the
|
||||
//! ruler's ticks, else to whole pixels, the grid, with Smart Guides the art's and the artboards'
|
||||
//! edges, centres and anchors, or with Snap to Point the anchors. Dropped off the canvas onto its
|
||||
//! ruler, a moved guide is deleted. Hidden or locked guides (View › Guides) can't be picked.
|
||||
//! ruler, a moved guide is deleted. Hidden or locked guides (View › Guides), and guides on a hidden
|
||||
//! or locked layer, can't be picked.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_geom::Point;
|
||||
@@ -24,7 +25,7 @@ pub fn guide_at(cx: &ToolContext, p: Point) -> Option<usize> {
|
||||
.guides
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, g)| cx.doc.guide_passes(g, p, tol))
|
||||
.filter(|(_, g)| cx.doc.guide_editable(g) && cx.doc.guide_passes(g, p, tol))
|
||||
.map(|(i, g)| (i, off(g.pos, g.vertical)))
|
||||
.filter(|(_, d)| *d <= tol)
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1).then(b.0.cmp(&a.0)))
|
||||
|
||||
@@ -444,7 +444,7 @@ impl Tool for SelectionTool {
|
||||
|
||||
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
|
||||
match self.state {
|
||||
State::Rotating { .. } => return Cursor::Rotate,
|
||||
State::Rotating { center, .. } => return Cursor::rotate_about(center, p),
|
||||
State::Scaling { handle, bx, .. } => return handle_cursor(handle, bx.angle),
|
||||
State::Moving { began: true, .. } => return Cursor::Arrow,
|
||||
State::Corner(_) => return Cursor::CornerRadius,
|
||||
@@ -468,7 +468,7 @@ impl Tool for SelectionTool {
|
||||
{
|
||||
match box_hit(cx, &bx, p) {
|
||||
Some(BoxHit::Handle(h)) => return handle_cursor(h, bx.angle),
|
||||
Some(BoxHit::Rotate) => return Cursor::Rotate,
|
||||
Some(BoxHit::Rotate) => return Cursor::rotate_about(bx.center(), p),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
@@ -588,6 +588,24 @@ mod tests {
|
||||
assert!(t.pointer(&cx, &ev(PointerKind::DoubleClick, 160.0, 150.0)).is_empty());
|
||||
}
|
||||
|
||||
/// Just outside each corner of the box the rotate cursor bows toward that corner, its arc
|
||||
/// opening toward the box: at the top right it opens to the bottom left.
|
||||
#[test]
|
||||
fn the_rotate_cursor_wraps_the_corner_it_is_at() {
|
||||
let (d, id) = doc_with_rect();
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let t = SelectionTool::default();
|
||||
// The box runs from (100, 100) to (200, 200); 10 pt out from each corner, diagonally.
|
||||
for ((x, y), octant) in [((93.0, 93.0), 7), ((207.0, 93.0), 1), ((207.0, 207.0), 3), ((93.0, 207.0), 5)] {
|
||||
assert_eq!(t.cursor(&cx, Point::new(x, y), Mods::default()), Cursor::RotateToward(octant), "at ({x}, {y})");
|
||||
}
|
||||
assert_eq!(Cursor::rotate_about(Point::new(1.0, 1.0), Point::new(1.0, 1.0)), Cursor::Rotate, "on the centre");
|
||||
assert_eq!(Cursor::rotate_about(Point::ORIGIN, Point::new(0.0, -5.0)), Cursor::RotateToward(0), "straight up");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drag_moves_with_single_undo() {
|
||||
let (d, id) = doc_with_rect();
|
||||
|
||||
@@ -43,7 +43,7 @@ fn snap(cx: &ToolContext, p: Point) -> (Point, Vec<Overlay>) {
|
||||
cx.doc
|
||||
.guides
|
||||
.iter()
|
||||
.filter(|g| g.vertical == vertical && (g.pos - v).abs() <= tol && cx.doc.guide_passes(g, p, tol))
|
||||
.filter(|g| g.vertical == vertical && (g.pos - v).abs() <= tol && cx.doc.guide_shown(g) && cx.doc.guide_passes(g, p, tol))
|
||||
.min_by(|a, b| (a.pos - v).abs().total_cmp(&(b.pos - v).abs()))
|
||||
};
|
||||
if let Some(g) = nearest(true, p.x) {
|
||||
|
||||
@@ -313,7 +313,7 @@ impl Tool for FreeTransformTool {
|
||||
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
|
||||
let Some(r) = selection_bounds(cx) else { return Cursor::Arrow };
|
||||
match self.drag.map(|d| d.op).or_else(|| self.classify(cx, r, p, m)) {
|
||||
Some(Op::Rotate) => Cursor::Rotate,
|
||||
Some(Op::Rotate) => Cursor::rotate_about(r.center(), p),
|
||||
Some(Op::Move) => Cursor::Move,
|
||||
Some(Op::Scale(h) | Op::Shear(h)) => match h {
|
||||
Handle::Top | Handle::Bottom => Cursor::ResizeV,
|
||||
|
||||
@@ -9,6 +9,7 @@ description = "VectorCraft egui frontend (menus, panels, tools and canvas); swap
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
craft-ui = { workspace = true }
|
||||
vectorcraft-geom = { workspace = true }
|
||||
vectorcraft-color = { workspace = true }
|
||||
vectorcraft-doc = { workspace = true }
|
||||
@@ -33,6 +34,7 @@ serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
|
||||
vectorcraft-testkit = { workspace = true }
|
||||
wat = { workspace = true }
|
||||
|
||||
|
||||
@@ -21,7 +21,13 @@ fn main() {
|
||||
let people = read(&dir.join("people.toml"), "contributors are shown by username only", |t| t.parse::<toml::Table>().map_err(|e| e.to_string()))
|
||||
.unwrap_or_default();
|
||||
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("credits.rs");
|
||||
if let Err(e) = std::fs::write(&out, generate(&json, &people)) {
|
||||
let src = generate(&json, &people);
|
||||
// Unchanged output keeps its mtime, so the next build doesn't compile this crate again
|
||||
// (see crates/text/build.rs).
|
||||
if std::fs::read_to_string(&out).is_ok_and(|old| old == src) {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = std::fs::write(&out, src) {
|
||||
println!("cargo::warning=writing {}: {e}", out.display());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,7 +378,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
// Selected guides in the selection colour.
|
||||
let picked = LineLook { color: t.selection, ..look };
|
||||
let selected: &[usize] = app.session.active().map_or(&[], |st| &st.selection.guides);
|
||||
for (i, g) in doc.guides.iter().enumerate() {
|
||||
// Guides on a hidden layer hide with it.
|
||||
for (i, g) in doc.guides.iter().enumerate().filter(|(_, g)| doc.guide_shown(g)) {
|
||||
let at = |along: f64| xf.to_screen(if g.vertical { Point::new(g.pos, along) } else { Point::new(along, g.pos) });
|
||||
// An artboard guide runs across its artboard, a canvas guide across the window.
|
||||
let (a, b) = match doc.guide_span(g) {
|
||||
@@ -487,7 +488,7 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon {
|
||||
Cursor::ResizeV => C::ResizeVertical,
|
||||
Cursor::ResizeNwSe => C::ResizeNwSe,
|
||||
Cursor::ResizeNeSw => C::ResizeNeSw,
|
||||
Cursor::Rotate => C::Alias,
|
||||
Cursor::Rotate | Cursor::RotateToward(_) => C::Alias,
|
||||
Cursor::Pen | Cursor::PenAdd | Cursor::PenDelete | Cursor::PenClose | Cursor::PenContinue | Cursor::PenJoin | Cursor::PenConvert => {
|
||||
C::Crosshair
|
||||
}
|
||||
|
||||
+179
-12
@@ -47,13 +47,7 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
}
|
||||
ui.add_space(2.0);
|
||||
// With the macOS menu bar the menus are at the top of the screen instead.
|
||||
let menus_end = if app.services.native_menu.is_some() {
|
||||
let full = ui.max_rect();
|
||||
ui.painter().text(full.center(), egui::Align2::CENTER_CENTER, "VectorCraft", egui::FontId::proportional(13.5), t.text);
|
||||
ui.cursor().min.x
|
||||
} else {
|
||||
menus::menu_bar(app, ui)
|
||||
};
|
||||
let menus_end = if app.services.native_menu.is_some() { ui.cursor().min.x } else { menus::menu_bar(app, ui) };
|
||||
// The right-side group fills the space after the menus from the right; when it runs
|
||||
// short, Discord goes first (it is also under Help), then the search icon (the palette
|
||||
// stays under its shortcut), then the workspace switcher narrows.
|
||||
@@ -97,6 +91,14 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
ui.add_space(10.0);
|
||||
crate::community::discord_button(app, ui, false);
|
||||
}
|
||||
if app.services.native_menu.is_some() {
|
||||
let title = ui.painter().layout_no_wrap("VectorCraft".into(), egui::FontId::proportional(13.5), t.text);
|
||||
let title_rect = egui::Rect::from_center_size(full.center(), title.size());
|
||||
// Keep the title centered only while the interactive groups leave room for it.
|
||||
if title_rect.left() >= menus_end + 8.0 && title_rect.right() <= ui.min_rect().left() - 8.0 {
|
||||
ui.painter().galley(title_rect.min, title, t.text);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
if custom {
|
||||
@@ -153,7 +155,9 @@ pub enum AnchorControls {
|
||||
|
||||
/// Which anchor controls show for the selection and the active tool.
|
||||
pub fn anchor_controls(app: &VectorcraftApp) -> AnchorControls {
|
||||
let Some(st) = app.session.active() else { return AnchorControls::None };
|
||||
let Some(st) = app.session.active() else {
|
||||
return AnchorControls::None;
|
||||
};
|
||||
if !st.selection.anchors.is_empty() {
|
||||
return AnchorControls::All;
|
||||
}
|
||||
@@ -229,7 +233,9 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let first = sel.first().and_then(|id| st.doc.node(*id)).cloned();
|
||||
let anchors = anchor_controls(app);
|
||||
let label = match &first {
|
||||
Some(_) if sel.len() == 1 && anchors != AnchorControls::None => tl!("Anchor Point"),
|
||||
Some(_) if sel.len() == 1 && anchors != AnchorControls::None => {
|
||||
tl!("Anchor Point")
|
||||
}
|
||||
Some(vectorcraft_doc::Node { kind: NodeKind::Image(im), .. }) if sel.len() == 1 => {
|
||||
if im.link.is_some() {
|
||||
tl!("Linked File")
|
||||
@@ -237,7 +243,9 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
tl!("Embedded")
|
||||
}
|
||||
}
|
||||
Some(n) if sel.len() == 1 && crate::panels::image_trace::is_trace(n) => tl!("Image Tracing"),
|
||||
Some(n) if sel.len() == 1 && crate::panels::image_trace::is_trace(n) => {
|
||||
tl!("Image Tracing")
|
||||
}
|
||||
_ => tl!(crate::panels::appearance::object_label(app)),
|
||||
};
|
||||
ui.label(egui::RichText::new(label).font(theme::semibold(12.0)).color(t.text));
|
||||
@@ -571,11 +579,12 @@ pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let more = app.background.jobs.len() - 1;
|
||||
let label = crate::i18n::msg(&job.label);
|
||||
let text = if more > 0 { format!("{label}… (+{more})") } else { format!("{label}…") };
|
||||
ui.label(egui::RichText::new(text).size(11.0).color(t.text));
|
||||
ui.add(egui::Spinner::new().size(12.0).color(t.accent));
|
||||
ui.add(egui::Label::new(egui::RichText::new(text).size(11.0).color(t.text)).truncate());
|
||||
} else if !app.ui.status.is_empty() {
|
||||
// The message stays English in `ui.status` (agents and tests read it).
|
||||
ui.label(egui::RichText::new(crate::i18n::msg(&app.ui.status)).size(11.0).color(t.text));
|
||||
let message = crate::i18n::msg(&app.ui.status);
|
||||
ui.add(egui::Label::new(egui::RichText::new(message).size(11.0).color(t.text)).truncate());
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1074,3 +1083,161 @@ mod tests {
|
||||
assert_eq!(app.last_window_title, "foo.svg \u{2014} VectorCraft");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod visual_regressions {
|
||||
use super::*;
|
||||
|
||||
fn scaled_input(size: egui::Vec2, scale: f32) -> egui::RawInput {
|
||||
let mut input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), ..Default::default() };
|
||||
input.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(scale);
|
||||
input
|
||||
}
|
||||
|
||||
struct NativeMenus;
|
||||
impl crate::native_menu::Backend for NativeMenus {
|
||||
fn sync(&mut self, _: &crate::native_menu::MenuBar) {}
|
||||
fn drain(&mut self) -> Vec<crate::native_menu::Event> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_chrome_visual_fixtures() {
|
||||
let Some(directory) = std::env::var_os("CRAFT_UI_VISUAL_FIXTURES").map(std::path::PathBuf::from) else {
|
||||
return;
|
||||
};
|
||||
std::fs::create_dir_all(&directory).unwrap();
|
||||
for brightness in theme::Brightness::ALL {
|
||||
for width in [800.0, 1280.0] {
|
||||
for scale in [1.0, 1.5, 2.0] {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.services.native_menu = Some(crate::native_menu::NativeMenu::new(Box::new(NativeMenus)));
|
||||
app.ui.status = format!("Saved /Users/example/Documents/{}/illustration.svg", "long-folder-name/".repeat(10));
|
||||
let mut ready = false;
|
||||
let mut harness =
|
||||
egui_kittest::Harness::builder().with_size(vec2(width, 200.0)).with_pixels_per_point(scale).wgpu().build_ui_state(
|
||||
move |ui, app: &mut VectorcraftApp| {
|
||||
if !ready {
|
||||
egui_extras::install_image_loaders(ui.ctx());
|
||||
theme::install_fonts(ui.ctx());
|
||||
theme::apply(ui.ctx(), brightness);
|
||||
ready = true;
|
||||
return;
|
||||
}
|
||||
app_bar(app, ui);
|
||||
status_bar(app, ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
harness.input_mut().max_texture_side = Some(8192);
|
||||
harness.run_steps(4);
|
||||
let stem = format!("vectorcraft-chrome-{brightness:?}-{width}-{scale}x");
|
||||
harness.render().unwrap().save(directory.join(format!("{stem}.png"))).unwrap();
|
||||
let mut regions = serde_json::Map::new();
|
||||
for id in ["app_bar", "status_bar"] {
|
||||
let rect = egui::containers::panel::PanelState::load(&harness.ctx, egui::Id::new(id)).unwrap().outer_rect;
|
||||
regions.insert(id.into(), json!([rect.left(), rect.top(), rect.width(), rect.height()]));
|
||||
}
|
||||
let dynamic: Vec<_> = harness
|
||||
.output()
|
||||
.shapes
|
||||
.iter()
|
||||
.filter_map(|shape| match &shape.shape {
|
||||
egui::Shape::Text(text) if text.galley.job.text.contains(" fps") => {
|
||||
let r = text.visual_bounding_rect();
|
||||
Some(json!([r.left(), r.top(), r.width(), r.height()]))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
std::fs::write(
|
||||
directory.join(format!("{stem}.json")),
|
||||
serde_json::to_vec_pretty(&json!({
|
||||
"screen": [width, 200.0], "scale": scale, "regions": regions, "native_dynamic_text_regions": dynamic
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_title_does_not_overlap_header_controls() {
|
||||
for brightness in theme::Brightness::ALL {
|
||||
for width in [800.0, 1280.0] {
|
||||
for scale in [1.0, 1.5, 2.0] {
|
||||
let ctx = egui::Context::default();
|
||||
egui_extras::install_image_loaders(&ctx);
|
||||
theme::install_fonts(&ctx);
|
||||
theme::apply(&ctx, brightness);
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.services.native_menu = Some(crate::native_menu::NativeMenu::new(Box::new(NativeMenus)));
|
||||
let mut output = ctx.run_ui(scaled_input(vec2(width, 640.0), scale), |ui| app_bar(&mut app, ui));
|
||||
output.textures_delta.clear();
|
||||
assert_eq!(ctx.content_rect().size(), vec2(width, 640.0));
|
||||
assert_eq!(ctx.pixels_per_point(), scale);
|
||||
let texts: Vec<_> = output
|
||||
.shapes
|
||||
.iter()
|
||||
.filter_map(|shape| match &shape.shape {
|
||||
egui::Shape::Text(text) => Some(text),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let title = texts.iter().find(|text| text.galley.job.text == "VectorCraft");
|
||||
if width >= 1280.0 {
|
||||
assert!(title.is_some(), "the title remains visible when the header has room");
|
||||
}
|
||||
if let Some(title) = title {
|
||||
for text in texts.iter().filter(|text| text.galley.job.text != "VectorCraft") {
|
||||
assert!(
|
||||
!title.visual_bounding_rect().intersects(text.visual_bounding_rect()),
|
||||
"{width}: title overlaps {:?}",
|
||||
text.galley.job.text
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_status_stays_between_navigation_and_performance_readouts() {
|
||||
for brightness in theme::Brightness::ALL {
|
||||
for width in [800.0, 1280.0] {
|
||||
for scale in [1.0, 1.5, 2.0] {
|
||||
let ctx = egui::Context::default();
|
||||
egui_extras::install_image_loaders(&ctx);
|
||||
theme::install_fonts(&ctx);
|
||||
theme::apply(&ctx, brightness);
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.ui.status = format!("Saved /Users/example/Documents/{}/illustration.svg", "long-folder-name/".repeat(10));
|
||||
let mut output = ctx.run_ui(scaled_input(vec2(width, 640.0), scale), |ui| status_bar(&mut app, ui));
|
||||
output.textures_delta.clear();
|
||||
assert_eq!(ctx.content_rect().size(), vec2(width, 640.0));
|
||||
assert_eq!(ctx.pixels_per_point(), scale);
|
||||
let texts: Vec<_> = output
|
||||
.shapes
|
||||
.iter()
|
||||
.filter_map(|shape| match &shape.shape {
|
||||
egui::Shape::Text(text) => Some(text),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let status = texts.iter().find(|text| text.galley.job.text.starts_with("Saved ")).expect("status stays visible");
|
||||
assert_eq!(status.galley.rows.len(), 1, "status is one line");
|
||||
assert!(status.galley.elided, "long status retains the full text for the label tooltip");
|
||||
let bounds = status.visual_bounding_rect();
|
||||
assert!(bounds.left() >= 0.0 && bounds.right() <= width, "{width}: {bounds:?}");
|
||||
for text in texts.iter().filter(|text| !text.galley.job.text.starts_with("Saved ")) {
|
||||
assert!(!bounds.intersects(text.visual_bounding_rect()), "{width}: status overlaps {:?}", text.galley.job.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,17 +113,21 @@ fn double_arrow(p: &mut Ink, o: Pos2, dir: egui::Vec2) {
|
||||
}
|
||||
|
||||
/// A curved double arrow over the hotspot: an arc with an arrowhead at each end, pointing on along
|
||||
/// the arc (#701).
|
||||
fn rotate(p: &mut Ink, o: Pos2) {
|
||||
/// the arc (#701). The arc bows up, or toward `octant` (0 up, then clockwise in 45° steps): beside a
|
||||
/// bounding box it wraps the corner the pointer is at.
|
||||
fn rotate(p: &mut Ink, o: Pos2, octant: u8) {
|
||||
const R: f32 = 8.0;
|
||||
let (a0, a1) = (std::f32::consts::PI * 0.12, std::f32::consts::PI * 0.88);
|
||||
let at = |a: f32| o + vec2(a.cos() * R, -a.sin() * R);
|
||||
// Clockwise on screen (y down) by whole octants.
|
||||
let (s, c) = (f32::from(octant % 8) * std::f32::consts::FRAC_PI_4).sin_cos();
|
||||
let turn = |v: Vec2| vec2(v.x * c - v.y * s, v.x * s + v.y * c);
|
||||
let at = |a: f32| o + turn(vec2(a.cos() * R, -a.sin() * R));
|
||||
let pts: Vec<Pos2> = (0..=12).map(|i| at(a0 + (a1 - a0) * i as f32 / 12.0)).collect();
|
||||
p.add(Shape::line(pts.clone(), Stroke::new(3.2, HALO)));
|
||||
p.add(Shape::line(pts, Stroke::new(1.3, INK)));
|
||||
// Each head points away from the arc, along its tangent there.
|
||||
for (a, away) in [(a0, -1.0f32), (a1, 1.0)] {
|
||||
let t = vec2(-a.sin(), -a.cos()) * away;
|
||||
let t = turn(vec2(-a.sin(), -a.cos()) * away);
|
||||
let n = vec2(-t.y, t.x);
|
||||
let (end, tip) = (at(a) - t * 1.5, at(a) + t * 4.5);
|
||||
poly(p, vec![tip, end + n * 1.8, end - n * 1.8], INK, INK);
|
||||
@@ -269,7 +273,8 @@ fn glyph(c: Cursor, p: Pos2) -> Option<Vec<Shape>> {
|
||||
Cursor::ResizeV => double_arrow(ink, p, vec2(0.0, 1.0)),
|
||||
Cursor::ResizeNwSe => double_arrow(ink, p, vec2(1.0, 1.0)),
|
||||
Cursor::ResizeNeSw => double_arrow(ink, p, vec2(1.0, -1.0)),
|
||||
Cursor::Rotate => rotate(ink, p),
|
||||
Cursor::Rotate => rotate(ink, p, 0),
|
||||
Cursor::RotateToward(octant) => rotate(ink, p, octant),
|
||||
Cursor::CornerRadius => corner_radius(ink, p),
|
||||
Cursor::Pen => pen(ink, p, "*"),
|
||||
Cursor::PenAdd => pen(ink, p, "+"),
|
||||
@@ -432,7 +437,7 @@ mod tests {
|
||||
use crate::VectorcraftApp;
|
||||
|
||||
/// Every cursor with a glyph.
|
||||
const GLYPHS: [Cursor; 33] = [
|
||||
const GLYPHS: [Cursor; 34] = [
|
||||
Cursor::Arrow,
|
||||
Cursor::ArrowHollow,
|
||||
Cursor::Move,
|
||||
@@ -442,6 +447,7 @@ mod tests {
|
||||
Cursor::ResizeNwSe,
|
||||
Cursor::ResizeNeSw,
|
||||
Cursor::Rotate,
|
||||
Cursor::RotateToward(1),
|
||||
Cursor::CornerRadius,
|
||||
Cursor::Pen,
|
||||
Cursor::PenAdd,
|
||||
|
||||
@@ -10,11 +10,16 @@ use crate::state::Dialog;
|
||||
use crate::theme::Tokens;
|
||||
use crate::{VectorcraftApp, icons, widgets};
|
||||
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("All Tools").into(), body, ok: None, min_width: 560.0, ..DialogSpec::FORM };
|
||||
/// A compact drawer two tools wide, however wide the window (#1018); the dialog frame narrows it
|
||||
/// further in a narrow window.
|
||||
pub(super) const SPEC: DialogSpec =
|
||||
DialogSpec { heading: |_| tl!("All Tools").into(), body, ok: None, min_width: WIDTH, max_width: Some(WIDTH), ..DialogSpec::FORM };
|
||||
|
||||
/// A tool's cell: its icon and name.
|
||||
const CELL: egui::Vec2 = vec2(176.0, 28.0);
|
||||
const ICON: f32 = 20.0;
|
||||
/// The drawer's width: two cells, the gap between them and room for the scroll bar.
|
||||
const WIDTH: f32 = 2.0 * CELL.x + 24.0;
|
||||
|
||||
/// The heading a tool is listed under: its Basic toolbar group, else one for the tools only the
|
||||
/// Advanced toolbar shows.
|
||||
@@ -107,4 +112,26 @@ mod tests {
|
||||
assert!(heads.contains(&"Symbols") && heads.contains(&"Graph"), "{heads:?}");
|
||||
assert!(!heads.contains(&"More"), "every tool has a named heading: {heads:?}");
|
||||
}
|
||||
|
||||
/// The drawer stays two tools wide in a wide window and fits a narrow one (#1018).
|
||||
#[test]
|
||||
fn all_tools_dialog_has_a_bounded_width() {
|
||||
use egui::{Pos2, RawInput, Rect};
|
||||
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.ui.dialog = Some(Dialog::new("allTools", serde_json::json!({})));
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
crate::theme::apply(&ctx, Default::default());
|
||||
for width in [1280.0, 430.0] {
|
||||
let screen = Rect::from_min_size(Pos2::ZERO, vec2(width, 900.0));
|
||||
for _ in 0..6 {
|
||||
let mut out = ctx.run_ui(RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| super::super::show(&mut app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
let rect = ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", "allTools")))).expect("drawer is shown");
|
||||
assert!(rect.width() <= width, "drawer exceeds viewport: {rect:?}");
|
||||
assert!(rect.width() < 3.0 * CELL.x, "drawer wider than two tools: {rect:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
//! The Color Picker (double-click a Fill/Stroke proxy, or `ui.colorPicker`): a colour field with a
|
||||
//! channel slider for H, S, B, R, G or B; HSB, RGB, Lab and CMYK fields and hex; Only Web Colors;
|
||||
//! the out-of-gamut and out-of-web warnings (click to correct); New/Original chips; and a Color
|
||||
//! Swatches list. OK applies the colour to its proxy through `paint.setFill` / `paint.setStroke`.
|
||||
//! Swatches list. With art selected the colour shows on it live as it is picked; OK keeps it as one
|
||||
//! undo step through `paint.setFill` / `paint.setStroke`, Cancel puts the art back.
|
||||
//!
|
||||
//! Fields agents set with `ui.dialog.set`: `hex` (`"00FF00"`) or `color` (any colour the paint
|
||||
//! commands take), `channel` (`hue`, `saturation`, `brightness`, `red`, `green`, `blue`), `webOnly`
|
||||
@@ -15,6 +16,7 @@ use vectorcraft_doc::ColorMode;
|
||||
use vectorcraft_engine::cmd::color_value;
|
||||
|
||||
use super::DialogSpec;
|
||||
use super::form::PREVIEWED;
|
||||
use crate::panels::color::{GAMUT_WARNING, WEB_WARNING, gamut_fix, hex_digits, is_web_safe, parse_hex, warning_chip, web_safe};
|
||||
use crate::panels::{c32, color_json};
|
||||
use crate::state::Dialog;
|
||||
@@ -23,8 +25,15 @@ use crate::{VectorcraftApp, widgets};
|
||||
|
||||
pub(super) const KIND: &str = "colorPicker";
|
||||
|
||||
pub(super) const SPEC: DialogSpec =
|
||||
DialogSpec { heading: |_| tl!("Color Picker").into(), body, confirm, min_width: 600.0, max_width: Some(640.0), ..DialogSpec::FORM };
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec {
|
||||
heading: |_| tl!("Color Picker").into(),
|
||||
body,
|
||||
confirm,
|
||||
preview: true,
|
||||
min_width: 600.0,
|
||||
max_width: Some(640.0),
|
||||
..DialogSpec::FORM
|
||||
};
|
||||
|
||||
/// Side of the colour field (and height of the channel slider).
|
||||
const FIELD: f32 = 240.0;
|
||||
@@ -242,9 +251,39 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
d.fields.insert("swatches".into(), json!(!swatches));
|
||||
}
|
||||
store(d, &pick.unwrap_or(p).snapped(d.bool("webOnly")));
|
||||
preview(app, d);
|
||||
false
|
||||
}
|
||||
|
||||
/// Show the picked colour on the selected art while the picker is open: each new colour replaces
|
||||
/// the last preview. Nothing previews until the colour first changes, nor without a selection
|
||||
/// (the colour then only becomes the default paint, on OK).
|
||||
fn preview(app: &mut VectorcraftApp, d: &mut Dialog) {
|
||||
let (cmd, params) = paint_command(app, d);
|
||||
let first = !app.session.in_interaction();
|
||||
let unchanged = d.fields.get(PREVIEWED) == Some(¶ms) || first && d.fields.get("original").and_then(color_value) == Some(picked(d).color);
|
||||
let selected = app.session.active().is_some_and(|st| !st.selection.objects.is_empty());
|
||||
if unchanged || !selected {
|
||||
return;
|
||||
}
|
||||
let _ = app.session.begin_interaction(if d.bool("stroke") { "Stroke Color" } else { "Fill Color" });
|
||||
if let Err(e) = app.session.preview(cmd, ¶ms) {
|
||||
app.status(e.to_string());
|
||||
}
|
||||
d.fields.insert(PREVIEWED.into(), params);
|
||||
}
|
||||
|
||||
/// The command that applies the picked colour to the proxy, and its params (as CMYK in a CMYK
|
||||
/// document).
|
||||
fn paint_command(app: &VectorcraftApp, d: &Dialog) -> (&'static str, Value) {
|
||||
let mut c = picked(d).color;
|
||||
if matches!(c, Color::Rgb { .. }) && app.session.active().is_some_and(|s| s.doc.color_mode == ColorMode::Cmyk) {
|
||||
let [cy, m, y, k] = c.to_cmyk_managed(cms::active().settings().intent);
|
||||
c = Color::cmyk(cy, m, y, k);
|
||||
}
|
||||
(if d.bool("stroke") { "paint.setStroke" } else { "paint.setFill" }, json!({ "color": color_json(&c) }))
|
||||
}
|
||||
|
||||
/// The New (top) and Original (bottom) chips. Returns true when Original is clicked.
|
||||
fn new_original_chips(ui: &mut Ui, new: &Color, original: &Color) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
@@ -363,15 +402,10 @@ fn swatch_list(app: &VectorcraftApp, ui: &mut Ui, current: &Color) -> Option<Col
|
||||
chosen
|
||||
}
|
||||
|
||||
/// OK: apply the colour to the proxy (as CMYK in a CMYK document).
|
||||
/// OK: apply the colour to the proxy, keeping the live preview as one undo step.
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let mut c = picked(d).color;
|
||||
if matches!(c, Color::Rgb { .. }) && app.session.active().is_some_and(|s| s.doc.color_mode == ColorMode::Cmyk) {
|
||||
let [cy, m, y, k] = c.to_cmyk_managed(cms::active().settings().intent);
|
||||
c = Color::cmyk(cy, m, y, k);
|
||||
}
|
||||
let cmd = if d.bool("stroke") { "paint.setStroke" } else { "paint.setFill" };
|
||||
super::run_and_close(app, cmd, json!({ "color": color_json(&c) }))
|
||||
let (cmd, params) = paint_command(app, d);
|
||||
super::form::commit_preview(app, cmd, params)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -453,6 +487,34 @@ mod tests {
|
||||
assert_eq!(gamut_fix(&mut app, &ctx, "picker-gamut", &Color::rgb8(128, 128, 128)), None);
|
||||
}
|
||||
|
||||
/// The selected art takes each colour as it is picked; OK keeps the last as one undo step and
|
||||
/// Cancel puts the art back.
|
||||
#[test]
|
||||
fn the_selection_shows_the_colour_live() {
|
||||
let mut app = app();
|
||||
let undo = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len();
|
||||
let before = undo(&app);
|
||||
app.run("ui.colorPicker", json!({})).unwrap();
|
||||
frame(&mut app);
|
||||
assert!(!app.session.in_interaction(), "nothing previews before the colour changes");
|
||||
for hex in ["ff0000", "00ff00"] {
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!(hex));
|
||||
frame(&mut app);
|
||||
assert_eq!(fill_hex(&app), format!("#{hex}"), "live, before OK");
|
||||
}
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert_eq!(fill_hex(&app), "#00ff00");
|
||||
assert_eq!(undo(&app), before + 1, "one step");
|
||||
// Cancel: back to the colour it had.
|
||||
app.run("ui.colorPicker", json!({})).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("0000ff"));
|
||||
frame(&mut app);
|
||||
assert_eq!(fill_hex(&app), "#0000ff");
|
||||
super::super::cancel(&mut app);
|
||||
assert_eq!(fill_hex(&app), "#00ff00");
|
||||
assert_eq!(undo(&app), before + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_set_hex_and_confirm_fills() {
|
||||
let mut app = app();
|
||||
|
||||
@@ -27,8 +27,8 @@ pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Corners").int
|
||||
/// `corners` (default: the selected corners), filled in with their kind and radius.
|
||||
pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||||
let st = app.session.active().ok_or("no document")?;
|
||||
let id = match p.get("id").and_then(Value::as_u64) {
|
||||
Some(id) => NodeId(id),
|
||||
let id = match p.get("id") {
|
||||
Some(v) => NodeId(v.as_u64().ok_or_else(|| format!("object ids are non-negative integers, not {v}"))?),
|
||||
None => match st.selection.objects[..] {
|
||||
[id] => id,
|
||||
_ => return Err("select one path, or give its `id`".into()),
|
||||
@@ -37,11 +37,15 @@ pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||||
let Some(live) = st.doc.node(id).and_then(LiveCorners::of) else {
|
||||
return Err("Corners edits the corners of a path".into());
|
||||
};
|
||||
let corners: BTreeSet<usize> = match p.get("corners").and_then(Value::as_array) {
|
||||
Some(a) => a.iter().filter_map(Value::as_u64).filter_map(|k| usize::try_from(k).ok()).filter(|k| live.corner(*k).is_some()).collect(),
|
||||
let corners: BTreeSet<usize> = match p.get("corners") {
|
||||
Some(v) => v
|
||||
.as_array()
|
||||
.and_then(|a| a.iter().map(|k| usize::try_from(k.as_u64()?).ok()).collect())
|
||||
.ok_or_else(|| format!("`corners` must be an array of anchor indices, not {v}"))?,
|
||||
None => live.picked(st.selection.partial(id)),
|
||||
};
|
||||
if corners.is_empty() {
|
||||
// Every corner given is one of the path's: none is left out of the edit.
|
||||
if corners.is_empty() || !corners.iter().all(|k| live.corner(*k).is_some()) {
|
||||
return Err("give corners of the path: anchors between two straight sides".into());
|
||||
}
|
||||
let (radius, kind) = live.style(&corners);
|
||||
@@ -149,6 +153,25 @@ mod tests {
|
||||
v
|
||||
}
|
||||
|
||||
/// Explicit UI-command parameters cannot silently fall back to the selection or drop corners.
|
||||
#[test]
|
||||
fn malformed_explicit_corner_targets_never_open_a_partial_edit() {
|
||||
let (mut app, id) = app();
|
||||
for params in [
|
||||
json!({"id": "invalid"}),
|
||||
json!({"id": -1}),
|
||||
json!({"id": id, "corners": [0, "bad", 2]}),
|
||||
json!({"id": id, "corners": [0, 1.5]}),
|
||||
json!({"id": id, "corners": [0, 9]}),
|
||||
json!({"id": id, "corners": "bad"}),
|
||||
] {
|
||||
assert!(app.run("ui.corners", params.clone()).is_err(), "accepted {params}");
|
||||
assert!(app.ui.dialog.is_none(), "opened a partial edit for {params}");
|
||||
}
|
||||
app.run("ui.corners", json!({"id": id, "corners": [0, 2]})).unwrap();
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().fields["corners"], json!([0, 2]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_clicking_a_direct_selected_corner_edits_it_alone() {
|
||||
let (mut app, id) = app();
|
||||
|
||||
@@ -353,8 +353,10 @@ fn artboards(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
ui.painter().rect_filled(r, 2.0, egui::Color32::WHITE);
|
||||
}
|
||||
}
|
||||
if let Some(b) = boards.get_mut(i) {
|
||||
ui.checkbox(b, name);
|
||||
if let Some(b) = boards.get_mut(i)
|
||||
&& crate::widgets::check_name(ui, name, *b)
|
||||
{
|
||||
*b ^= true;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -399,10 +401,10 @@ fn assets(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
ui.horizontal(|ui| {
|
||||
let (r, _) = ui.allocate_exact_size(egui::vec2(46.0, 46.0), egui::Sense::hover());
|
||||
crate::panels::asset_export::paint_thumb(ui, st, a, r);
|
||||
let mut checked = on.contains(&a.id);
|
||||
if ui.checkbox(&mut checked, a.name.as_str()).changed() {
|
||||
let checked = on.contains(&a.id);
|
||||
if crate::widgets::check_name(ui, &a.name, checked) {
|
||||
on.retain(|id| *id != a.id);
|
||||
if checked {
|
||||
if !checked {
|
||||
on.push(a.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,8 +320,8 @@ pub(super) fn schema_fields(ui: &mut egui::Ui, d: &mut Dialog, specs: &[(String,
|
||||
crate::widgets::range_field(ui, ("plugin-int", k), x, *min as f64..=*max as f64, "", 0, 100.0).map(|x| json!(x as i64))
|
||||
}
|
||||
ParamSpec::Bool { .. } => {
|
||||
let mut b = cur.as_bool().unwrap_or(false);
|
||||
ui.checkbox(&mut b, "").changed().then(|| json!(b))
|
||||
let b = cur.as_bool().unwrap_or(false);
|
||||
crate::widgets::check(ui, "", b, true).then(|| json!(!b))
|
||||
}
|
||||
ParamSpec::Choice { options, .. } => {
|
||||
let labels: Vec<&str> = options.iter().map(String::as_str).collect();
|
||||
|
||||
@@ -54,9 +54,16 @@ fn fields_of(n: &Node) -> serde_json::Map<String, Value> {
|
||||
/// the current layer), filled in from the first.
|
||||
pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||||
let st = app.session.active().ok_or("no document open")?;
|
||||
let ids: Vec<NodeId> = match (p.get("ids").and_then(Value::as_array), p.get("id").and_then(Value::as_u64)) {
|
||||
(Some(a), _) => a.iter().filter_map(Value::as_u64).map(NodeId).collect(),
|
||||
(None, Some(id)) => vec![NodeId(id)],
|
||||
// Rows given are used as given: a malformed or unknown id fails rather than being skipped or
|
||||
// the dialog opening on the highlighted rows instead.
|
||||
let row = |v: &Value| -> Result<NodeId, String> {
|
||||
let id = NodeId(v.as_u64().ok_or_else(|| format!("object ids are non-negative integers, not {v}"))?);
|
||||
st.doc.node(id).map(|_| id).ok_or_else(|| "no such layer or object".to_string())
|
||||
};
|
||||
let ids: Vec<NodeId> = match (p.get("ids"), p.get("id")) {
|
||||
(Some(Value::Array(a)), _) => a.iter().map(row).collect::<Result<_, _>>()?,
|
||||
(Some(v), _) => return Err(format!("`ids` must be an array of object ids, not {v}")),
|
||||
(None, Some(v)) => vec![row(v)?],
|
||||
(None, None) => {
|
||||
let rows = st.highlighted_rows();
|
||||
if rows.is_empty() { st.current_layer().into_iter().collect() } else { rows }
|
||||
@@ -252,6 +259,21 @@ mod tests {
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v);
|
||||
}
|
||||
|
||||
/// Explicit object ids must not silently disappear or fall back to the highlighted row.
|
||||
#[test]
|
||||
fn invalid_layer_options_targets_do_not_open_a_partial_dialog() {
|
||||
let mut app = app();
|
||||
let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
|
||||
for params in
|
||||
[json!({"ids": [id, "invalid"]}), json!({"ids": [id, 999999]}), json!({"ids": "invalid"}), json!({"id": "invalid"}), json!({"id": null})]
|
||||
{
|
||||
assert!(app.run("ui.layerOptions", params.clone()).is_err(), "accepted {params}");
|
||||
assert!(app.ui.dialog.is_none(), "opened partial options for {params}");
|
||||
}
|
||||
app.run("ui.layerOptions", json!({"ids": [id]})).unwrap();
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().fields["ids"], json!([id]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_options_edit_the_layer_and_ok_is_one_undo_step() {
|
||||
let mut app = app();
|
||||
|
||||
@@ -93,14 +93,15 @@ fn controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
super::form::check(ui, d, "__showAxis", tl!("Show axis"));
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
let mut visible = d.fields.get("expandVisibleOnly").and_then(Value::as_bool).unwrap_or(true);
|
||||
let response = ui.allocate_ui_with_layout(
|
||||
let visible = d.fields.get("expandVisibleOnly").and_then(Value::as_bool).unwrap_or(true);
|
||||
let tip = tl!("Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces.");
|
||||
let toggled = ui.allocate_ui_with_layout(
|
||||
vec2((ui.available_width() - 30.0).max(100.0), 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
ui.checkbox(&mut visible, egui::RichText::new(tl!("Keep visible surfaces only when expanding")).size(12.0))
|
||||
widgets::check_tip(ui, tl!("Keep visible surfaces only when expanding"), visible, true, tip)
|
||||
},
|
||||
).inner;
|
||||
if response.on_hover_text(tl!("Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces.")).changed() {
|
||||
d.fields.insert("expandVisibleOnly".into(), json!(visible));
|
||||
if toggled {
|
||||
d.fields.insert("expandVisibleOnly".into(), json!(!visible));
|
||||
}
|
||||
if reset_dot(ui, "expandVisibleOnly", !visible, "true") {
|
||||
d.fields.insert("expandVisibleOnly".into(), json!(true));
|
||||
@@ -111,9 +112,9 @@ fn controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
|
||||
fn lighting_controls(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
ui.horizontal(|ui| {
|
||||
let mut shade = d.bool("shade");
|
||||
if ui.checkbox(&mut shade, tl!("Shading")).changed() {
|
||||
d.fields.insert("shade".into(), json!(shade));
|
||||
let shade = d.bool("shade");
|
||||
if widgets::check(ui, tl!("Shading"), shade, true) {
|
||||
d.fields.insert("shade".into(), json!(!shade));
|
||||
}
|
||||
ui.add_space((ui.available_width() - 24.0).max(0.0));
|
||||
if reset_dot(ui, "shade", !shade, "true") {
|
||||
|
||||
@@ -145,9 +145,9 @@ fn option_row(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, o: &Forma
|
||||
let value = d.fields.get(o.name).cloned().unwrap_or(Value::Null);
|
||||
if o.ty == "boolean" {
|
||||
ui.label("");
|
||||
let mut on = value.as_bool().unwrap_or(false);
|
||||
if ui.checkbox(&mut on, label.trim_end_matches(':')).on_hover_text(o.description).changed() {
|
||||
d.fields.insert(o.name.into(), json!(on));
|
||||
let on = value.as_bool().unwrap_or(false);
|
||||
if widgets::check_tip(ui, label.trim_end_matches(':'), on, true, o.description) {
|
||||
d.fields.insert(o.name.into(), json!(!on));
|
||||
}
|
||||
ui.end_row();
|
||||
if o.name == "separateArtboards" {
|
||||
|
||||
@@ -202,9 +202,11 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
label(ui, &v.name);
|
||||
let key = value_key(&v.name);
|
||||
match v.kind {
|
||||
VariableKind::Text => text(ui, d, &key, 220.0),
|
||||
VariableKind::Text => {
|
||||
text(ui, d, &key, 220.0);
|
||||
}
|
||||
VariableKind::Visibility => checkbox(ui, d, &key),
|
||||
};
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
@@ -224,13 +226,11 @@ fn text(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32) -> egui::Respo
|
||||
}
|
||||
|
||||
/// A checkbox for `key`, written back as it is toggled.
|
||||
fn checkbox(ui: &mut egui::Ui, d: &mut Dialog, key: &str) -> egui::Response {
|
||||
let mut b = d.bool(key);
|
||||
let r = ui.checkbox(&mut b, "");
|
||||
if r.changed() {
|
||||
d.fields.insert(key.into(), json!(b));
|
||||
fn checkbox(ui: &mut egui::Ui, d: &mut Dialog, key: &str) {
|
||||
let b = d.bool(key);
|
||||
if widgets::check(ui, "", b, true) {
|
||||
d.fields.insert(key.into(), json!(!b));
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// A dropdown over the document's names `options` (shown as they are, never translated) for
|
||||
|
||||
+221
-29
@@ -1,5 +1,6 @@
|
||||
//! The right dock: Properties | Layers | Libraries tabs, plus the collapsed icon-panel column
|
||||
//! whose panels pop out to the left. The double arrow at the top of the dock collapses the tabbed
|
||||
//! whose panels pop out to the left, and left of it the floating sets docked as columns
|
||||
//! ([`floating::docked_columns`]). The double arrow at the top of the dock collapses the tabbed
|
||||
//! group to icons at the top of the icon column (`window.collapseDock`); the arrow above the column
|
||||
//! then expands it again.
|
||||
|
||||
@@ -23,6 +24,12 @@ fn column_left_id() -> egui::Id {
|
||||
egui::Id::new("dock-icon-column-left")
|
||||
}
|
||||
|
||||
/// Where the dock's left edge was drawn last: the icon column's, or the leftmost dock column's
|
||||
/// (egui temp memory, one frame old). Popped-out panels open left of it.
|
||||
pub(crate) fn dock_left_id() -> egui::Id {
|
||||
egui::Id::new("dock-left")
|
||||
}
|
||||
|
||||
/// Collapse the tabbed group to icons or expand it again (`window.collapseDock`). Expanding while
|
||||
/// one of its panels is popped out shows that panel's tab instead.
|
||||
pub fn set_collapsed(app: &mut VectorcraftApp, collapsed: bool) {
|
||||
@@ -36,7 +43,7 @@ pub fn set_collapsed(app: &mut VectorcraftApp, collapsed: bool) {
|
||||
|
||||
/// The strip along the top of a dock column with its double arrow: » collapses the dock to icons,
|
||||
/// « expands it. The whole strip is the click target (as on the toolbar). True when clicked.
|
||||
fn collapse_header(ui: &mut Ui, collapsed: bool) -> bool {
|
||||
pub(crate) fn collapse_header(ui: &mut Ui, collapsed: bool) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (hdr, resp) = ui.allocate_exact_size(vec2(ui.available_width(), HEADER), Sense::click());
|
||||
ui.painter().rect_filled(hdr, 0.0, t.tab_strip);
|
||||
@@ -59,20 +66,22 @@ fn toggle(app: &mut VectorcraftApp, collapsed: bool) {
|
||||
|
||||
/// One icon of the column: a click pops its panel out (or puts it away); dragged out of the
|
||||
/// column (`column`), the panel floats.
|
||||
fn panel_icon(app: &mut VectorcraftApp, ui: &mut Ui, id: &'static str, label: &str, icon: &str, column: Rect) {
|
||||
fn panel_icon(app: &mut VectorcraftApp, ui: &mut Ui, id: &'static str, label: &str, icon: &str, _column: Rect) {
|
||||
let open = app.ui.open_panel.as_deref() == Some(id);
|
||||
// Its own id: the icons below it move up when it floats, and mustn't take over its drag.
|
||||
let (_, rect) = ui.allocate_space(vec2(30.0, 30.0));
|
||||
let resp = widgets::paint_icon_button(ui, ui.interact(rect, ui.id().with(("panel-icon", id)), Sense::click_and_drag()), icon, label, open);
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, ui.is_enabled(), open, tl!(label)));
|
||||
if resp.clicked() {
|
||||
app.ui.open_panel = if open { None } else { Some(id.to_string()) };
|
||||
}
|
||||
if resp.dragged() && ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| !column.contains(p)) {
|
||||
floating::tear(app, ui.ctx(), &[id], id, floating::strip_grab(ui.ctx(), resp.rect.left()));
|
||||
}
|
||||
crate::panel_docking::legacy_tab(app, ui, id, &resp);
|
||||
}
|
||||
|
||||
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
if crate::panel_docking::show(app, ui) {
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let shown = floating::shown_tab(&app.ui);
|
||||
// Main tabbed group, unless it is collapsed to icons or all its panels float.
|
||||
@@ -148,7 +157,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
});
|
||||
let column = column.response.rect;
|
||||
let dock = group.map_or(column, |g| g.union(column));
|
||||
// Floating sets docked as columns, left of the icon column.
|
||||
let left = floating::docked_columns(app, ui).iter().map(|r| r.left()).fold(column.left(), f32::min);
|
||||
ui.ctx().data_mut(|d| {
|
||||
d.insert_temp(dock_left_id(), left);
|
||||
d.insert_temp(column_left_id(), column.left());
|
||||
d.insert_temp(floating::dock_rect_id(), dock);
|
||||
d.insert_temp(floating::icons_rect_id(), column);
|
||||
@@ -163,36 +175,36 @@ fn tab_strip(app: &mut VectorcraftApp, ui: &mut Ui, active: DockTab) -> bool {
|
||||
let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 33.0), Sense::hover());
|
||||
ui.painter().rect_filled(strip, 0.0, t.tab_strip);
|
||||
ui.painter().line_segment([strip.left_bottom(), strip.right_bottom()], Stroke::new(1.0, t.border));
|
||||
let out = ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| !strip.contains(p));
|
||||
let outside = ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| !strip.contains(p));
|
||||
// What a drag out of the strip floats: the panels, the one shown, where its tab began.
|
||||
let mut tear: Option<(Vec<&'static str>, &'static str, f32)> = None;
|
||||
let mut x = strip.left();
|
||||
for tab in DockTab::ALL {
|
||||
let (id, label, _) = tab.info();
|
||||
if floating::group_of(&app.ui, id).is_some() {
|
||||
continue;
|
||||
}
|
||||
let galley =
|
||||
ui.painter().layout_no_wrap(tl!(label).to_string(), theme::semibold(12.5), if tab == active { t.text_strong } else { t.text_dim });
|
||||
let r = Rect::from_min_size(egui::pos2(x, strip.top()), vec2(galley.size().x + 24.0, strip.height() - 1.0));
|
||||
let resp = ui.interact(r, ui.id().with(("docktab", label)), Sense::click_and_drag());
|
||||
if tab == active {
|
||||
ui.painter().rect_filled(r, 0.0, t.panel);
|
||||
}
|
||||
ui.painter().galley(egui::pos2(r.left() + 12.0, r.center().y - galley.size().y / 2.0), galley, t.text);
|
||||
ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.0, t.border));
|
||||
let tabs: Vec<_> = floating::docked_tabs(&app.ui).collect();
|
||||
let names: Vec<_> = tabs
|
||||
.iter()
|
||||
.map(|tab| {
|
||||
let (id, label, _) = tab.info();
|
||||
(id, label)
|
||||
})
|
||||
.collect();
|
||||
let selected = tabs.iter().position(|&tab| tab == active).unwrap_or(0);
|
||||
let menu = Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0));
|
||||
let area = Rect::from_min_max(strip.min, egui::pos2((menu.left() - 4.0).max(strip.left()), strip.bottom() - 1.0));
|
||||
let out = panel_tabs(ui, "docktab", area, &names, selected, theme::semibold(12.5), true);
|
||||
for (i, resp) in out.responses {
|
||||
let Some(&tab) = tabs.get(i) else { continue };
|
||||
let id = tab.info().0;
|
||||
if resp.clicked() {
|
||||
app.ui.dock_tab = tab;
|
||||
} else if resp.dragged() && out {
|
||||
tear = Some((vec![id], id, r.left()));
|
||||
}
|
||||
x = r.right();
|
||||
crate::panel_docking::legacy_tab(app, ui, id, &resp);
|
||||
}
|
||||
let menu = Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0));
|
||||
let rest = Rect::from_min_max(egui::pos2(x, strip.top()), egui::pos2(menu.left() - 4.0, strip.bottom()));
|
||||
if let Some(tab) = out.picked.and_then(|i| tabs.get(i)).copied() {
|
||||
app.ui.dock_tab = tab;
|
||||
}
|
||||
let rest = Rect::from_min_max(egui::pos2(out.right, strip.top()), egui::pos2(menu.left() - 4.0, strip.bottom()));
|
||||
if rest.width() > 0.0 {
|
||||
let resp = ui.interact(rest, ui.id().with("dock-group-bar"), Sense::drag());
|
||||
if resp.dragged() && out {
|
||||
if resp.dragged() && outside {
|
||||
tear = Some((floating::docked_tabs(&app.ui).map(|t| t.info().0).collect(), active.info().0, strip.left()));
|
||||
}
|
||||
resp.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(tl!("Drag to float the panel group"));
|
||||
@@ -205,6 +217,92 @@ fn tab_strip(app: &mut VectorcraftApp, ui: &mut Ui, active: DockTab) -> bool {
|
||||
floating::group_of(&app.ui, active.info().0).is_some()
|
||||
}
|
||||
|
||||
/// App styling and overflow menus around the shared tab fitting/interaction components. Docking
|
||||
/// and floating groups consume the responses themselves, preserving their different drag rules.
|
||||
pub(crate) struct PanelTabs {
|
||||
pub responses: Vec<(usize, egui::Response)>,
|
||||
pub picked: Option<usize>,
|
||||
pub right: f32,
|
||||
}
|
||||
|
||||
pub(crate) fn panel_tabs(
|
||||
ui: &mut Ui,
|
||||
id: &str,
|
||||
area: Rect,
|
||||
tabs: &[(&str, &str)],
|
||||
selected: usize,
|
||||
font: egui::FontId,
|
||||
separators: bool,
|
||||
) -> PanelTabs {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let labels: Vec<_> = tabs.iter().map(|&(_, label)| tl!(label)).collect();
|
||||
let galleys: Vec<_> = labels
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, label)| ui.painter().layout_no_wrap((*label).to_string(), font.clone(), if i == selected { t.text_strong } else { t.text_dim }))
|
||||
.collect();
|
||||
let natural: Vec<_> = galleys.iter().map(|g| g.size().x + 24.0).collect();
|
||||
let fit = craft_ui::tabs::fit(&natural, selected, area.width(), 40.0, 18.0);
|
||||
let mut out = PanelTabs { responses: Vec::with_capacity(fit.shown.len()), picked: None, right: area.left() };
|
||||
for (i, width) in fit.shown {
|
||||
let Some((key, _)) = tabs.get(i) else { continue };
|
||||
let Some(label) = labels.get(i) else { continue };
|
||||
let Some(galley) = galleys.get(i) else { continue };
|
||||
let rect = Rect::from_min_size(egui::pos2(out.right, area.top()), vec2(width, area.height()));
|
||||
let shrunk = natural.get(i).is_some_and(|natural| *natural > width + 0.5);
|
||||
let galley = if shrunk {
|
||||
let mut job =
|
||||
egui::text::LayoutJob::simple_singleline((*label).to_string(), font.clone(), if i == selected { t.text_strong } else { t.text_dim });
|
||||
job.wrap =
|
||||
egui::text::TextWrapping { max_width: (width - 8.0).max(1.0), max_rows: 1, break_anywhere: true, overflow_character: Some('…') };
|
||||
ui.painter().layout_job(job)
|
||||
} else {
|
||||
galley.clone()
|
||||
};
|
||||
let resp = craft_ui::tabs::Tab::new(ui.id().with((id, key)), label, i == selected)
|
||||
.sense(Sense::click_and_drag())
|
||||
.focus_stroke(Stroke::new(1.0, t.accent))
|
||||
.show_at(ui, rect, |ui, _| {
|
||||
if i == selected {
|
||||
ui.painter().rect_filled(rect, 0.0, t.panel);
|
||||
}
|
||||
ui.painter().with_clip_rect(rect.intersect(ui.clip_rect())).galley(rect.center() - galley.size() / 2.0, galley, t.text);
|
||||
if separators {
|
||||
ui.painter().line_segment([rect.right_top(), rect.right_bottom()], Stroke::new(1.0, t.border));
|
||||
}
|
||||
});
|
||||
out.responses.push((i, if shrunk { resp.on_hover_text(*label) } else { resp }));
|
||||
out.right = rect.right();
|
||||
}
|
||||
if !fit.overflow.is_empty() && fit.overflow_width > 0.0 {
|
||||
let rect = Rect::from_min_size(egui::pos2(out.right, area.top()), vec2(fit.overflow_width, area.height()));
|
||||
let resp = ui.interact(rect, ui.id().with((id, "overflow")), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(rect, 0.0, t.hover);
|
||||
}
|
||||
if resp.has_focus() && rect.width() > 4.0 {
|
||||
ui.painter().rect_stroke(rect.shrink(2.0), 0.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);
|
||||
}
|
||||
icons::paint(ui, "chevrons-right", Rect::from_center_size(rect.center(), vec2(12.0, 12.0)).intersect(rect), t.text);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Panels")));
|
||||
let resp = resp.on_hover_text(tl!("Panels"));
|
||||
egui::Popup::menu(&resp).show(|ui| {
|
||||
widgets::menu_scroll(ui, |ui| {
|
||||
for i in fit.overflow {
|
||||
if let Some(label) = labels.get(i)
|
||||
&& ui.button(*label).clicked()
|
||||
{
|
||||
out.picked = Some(i);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
out.right = rect.right();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A panel popped out next to the icon column: an icon panel, or one of the tabbed group's panels
|
||||
/// while the dock is collapsed. Dragging its title floats it.
|
||||
pub fn floating_panel(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
@@ -226,7 +324,9 @@ pub fn floating_panel(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
} else {
|
||||
screen.right()
|
||||
};
|
||||
let right = column - 6.0;
|
||||
let left = ctx.data(|d| d.get_temp::<f32>(dock_left_id())).filter(|x| x.is_finite() && *x > screen.left() && *x <= column).unwrap_or(column);
|
||||
let right = if app.ui.dock && app.ui.screen_mode < 3 { left } else { column };
|
||||
let right = right - 6.0;
|
||||
// The tabbed group's panels fill their height (Layers) or scroll (Properties): give them one
|
||||
// that keeps the flyout on screen.
|
||||
let width = panel_width(id);
|
||||
@@ -325,6 +425,98 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::toolbar::tests::{wheel, widget_rects};
|
||||
|
||||
#[test]
|
||||
fn tab_headers_fit_narrow_widths_keep_the_selected_tab_and_clip_labels() {
|
||||
let ctx = egui::Context::default();
|
||||
egui_extras::install_image_loaders(&ctx);
|
||||
theme::install_fonts(&ctx);
|
||||
theme::apply(&ctx, theme::Brightness::MediumDark);
|
||||
let names = [("one", "Long panel name"), ("two", "Another long panel name"), ("three", "Layers")];
|
||||
for width in [0.0, 1.0, 5.0, 18.0, 19.0, 80.0, 180.0, 300.0] {
|
||||
for selected in 0..names.len() {
|
||||
let area = Rect::from_min_size(egui::pos2(20.0, 20.0), vec2(width, 32.0));
|
||||
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
let out = panel_tabs(ui, "fit-test", area, &names, selected, theme::semibold(12.5), true);
|
||||
assert!(out.responses.iter().any(|(i, _)| *i == selected), "selected index {selected} stays on the strip");
|
||||
assert!(out.right <= area.right() + 0.001, "{width}: strip ends at {}", out.right);
|
||||
for (_, response) in &out.responses {
|
||||
if response.rect.is_positive() {
|
||||
assert!(area.contains_rect(response.rect), "{width}: {:?}", response.rect);
|
||||
}
|
||||
}
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
for shape in output.shapes.iter().filter(|s| matches!(s.shape, egui::Shape::Text(_))) {
|
||||
assert!(shape.clip_rect.right() <= area.right() + 0.001, "{width}: a label is clipped to its tab");
|
||||
}
|
||||
if width > 0.0 && width < 18.0 {
|
||||
assert!(
|
||||
output.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Rect(rect) if rect.brush.is_some())),
|
||||
"the actual overflow SVG was painted"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overflow_menu_can_activate_a_hidden_panel() {
|
||||
crate::i18n::set_current(crate::i18n::Lang::EN);
|
||||
let ctx = egui::Context::default();
|
||||
ctx.enable_accesskit();
|
||||
egui_extras::install_image_loaders(&ctx);
|
||||
theme::install_fonts(&ctx);
|
||||
theme::apply(&ctx, theme::Brightness::MediumDark);
|
||||
let names = [("properties", "Properties"), ("layers", "Layers"), ("libraries", "Libraries")];
|
||||
let mut selected = 0;
|
||||
let mut time = 0.0;
|
||||
let mut frame = |events| {
|
||||
time += 0.1;
|
||||
let input = egui::RawInput {
|
||||
events,
|
||||
time: Some(time),
|
||||
screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, vec2(400.0, 300.0))),
|
||||
..Default::default()
|
||||
};
|
||||
let mut output = ctx.run_ui(input, |ui| {
|
||||
let area = Rect::from_min_size(egui::pos2(20.0, 20.0), vec2(90.0, 32.0));
|
||||
let out = panel_tabs(ui, "overflow-test", area, &names, selected, theme::semibold(12.5), true);
|
||||
if let Some(picked) = out.picked {
|
||||
selected = picked;
|
||||
}
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
output
|
||||
};
|
||||
let button = |output: &egui::FullOutput, label: &str| {
|
||||
let bounds = output
|
||||
.platform_output
|
||||
.accesskit_update
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|(_, n)| n.label() == Some(label) && n.role() == egui::accesskit::Role::Button)
|
||||
.unwrap()
|
||||
.1
|
||||
.bounds()
|
||||
.unwrap();
|
||||
egui::pos2((bounds.x0 + bounds.x1) as f32 * 0.5, (bounds.y0 + bounds.y1) as f32 * 0.5)
|
||||
};
|
||||
let press =
|
||||
|pos, pressed| egui::Event::PointerButton { pos, pressed, button: egui::PointerButton::Primary, modifiers: egui::Modifiers::NONE };
|
||||
frame(vec![]);
|
||||
let output = frame(vec![]);
|
||||
let overflow = button(&output, "Panels");
|
||||
frame(vec![egui::Event::PointerMoved(overflow), press(overflow, true)]);
|
||||
frame(vec![press(overflow, false)]);
|
||||
let output = frame(vec![]);
|
||||
let libraries = button(&output, "Libraries");
|
||||
frame(vec![egui::Event::PointerMoved(libraries), press(libraries, true)]);
|
||||
frame(vec![press(libraries, false)]);
|
||||
assert_eq!(selected, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_flyout_stays_left_of_the_icon_column() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
|
||||
+1179
-172
File diff suppressed because it is too large.
Load diff
@@ -628,6 +628,7 @@
|
||||
% of all commits % všech commitů
|
||||
Lines +/− Řádky +/−
|
||||
Drag to move Přetažením přesunete
|
||||
Drag to move out of the set Přetažením přesunete mimo sadu
|
||||
Drag to float as a toolbar Přetažením vytvoříte plovoucí panel nástrojů
|
||||
Put back in the toolbar Vrátit do panelu nástrojů
|
||||
Float Panel Plovoucí panel
|
||||
@@ -694,6 +695,14 @@
|
||||
Ocean Ripple… Vlnky na hladině…
|
||||
Rename Data Set Přejmenovat sadu dat
|
||||
Rename Variable Přejmenovat proměnnou
|
||||
# Shared panel docking
|
||||
Activate Panel Aktivovat panel
|
||||
Change Panel Layout Změnit rozložení panelů
|
||||
Close Panel Zavřít panel
|
||||
Move Panel Přesunout panel
|
||||
Group with Seskupit s
|
||||
Resize panels Změnit velikost panelů
|
||||
Resize panel window Změnit velikost okna panelu
|
||||
|
||||
# Interactive Revolve controls.
|
||||
Lighting Osvětlení
|
||||
@@ -731,3 +740,5 @@ lighting Light Světlo
|
||||
Spatter… Rozstřik…
|
||||
Sprayed Strokes… Stříkané tahy…
|
||||
Sumi-e… Sumi-e…
|
||||
Vertical Guide Svislé vodítko
|
||||
Horizontal Guide Vodorovné vodítko
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
Loaded 100 of 140 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user