mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-22 07:04:16 -04:00
Compare commits
457
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2879c206a8 | ||
|
|
e78116e0a2 | ||
|
|
351612c978 | ||
|
|
7930a0456b | ||
|
|
fdac986bb4 | ||
|
|
fcdddadeb8 | ||
|
|
97cd86d461 | ||
|
|
47b1fa52cb | ||
|
|
21dc3bf565 | ||
|
|
6ecf568c5d | ||
|
|
7d677eaa13 | ||
|
|
20a40f5b1b | ||
|
|
e3a67119c1 | ||
|
|
003ec8f373 | ||
|
|
1fd7ab495a | ||
|
|
e7e149a8bd | ||
|
|
4417c4feb7 | ||
|
|
b0e78cf3d8 | ||
|
|
1401051603 | ||
|
|
14a3740e51 | ||
|
|
30c5bd3589 | ||
|
|
e09038041f | ||
|
|
59f63e9161 | ||
|
|
336d860ed3 | ||
|
|
7bc9fd7220 | ||
|
|
cec7d23d96 | ||
|
|
c18069e6ed | ||
|
|
fb8578ddf0 | ||
|
|
546b8846b6 | ||
|
|
24b6486ebd | ||
|
|
d22ccebc9c | ||
|
|
fa4e42762b | ||
|
|
2045fe3de1 | ||
|
|
eb672e8f42 | ||
|
|
82e2c9506b | ||
|
|
fff1acac06 | ||
|
|
8186f9e2e4 | ||
|
|
5f0d809047 | ||
|
|
f19a71367d | ||
|
|
49686460aa | ||
|
|
8ecb22851d | ||
|
|
2aa7412e3a | ||
|
|
18d01ae6ff | ||
|
|
3c5034b3dd | ||
|
|
1168b2373f | ||
|
|
03e867df02 | ||
|
|
0c0267acbf | ||
|
|
3e9c590ee4 | ||
|
|
06f0700155 | ||
|
|
3981e0bd03 | ||
|
|
37f578ff34 | ||
|
|
8f87ad53dd | ||
|
|
67b47c7262 | ||
|
|
1a9509b776 | ||
|
|
5669e02331 | ||
|
|
4285ccdda3 | ||
|
|
c5405479b9 | ||
|
|
df16d21e84 | ||
|
|
133fd73feb | ||
|
|
8ee5f96da5 | ||
|
|
e74b09c86d | ||
|
|
a631a0df6c | ||
|
|
d18135bfcd | ||
|
|
3adf5141e7 | ||
|
|
1651dadf41 | ||
|
|
e4ad256d7d | ||
|
|
c692962a22 | ||
|
|
276eeacae6 | ||
|
|
3eed742639 | ||
|
|
5b1e18f2fc | ||
|
|
baec72c0fe | ||
|
|
a12af589aa | ||
|
|
d1a6e39b7e | ||
|
|
838d81157e | ||
|
|
009384b2e7 | ||
|
|
8776f6c896 | ||
|
|
150467c7f9 | ||
|
|
08afdd101d | ||
|
|
93cdb4c4e3 | ||
|
|
c01eeff7ed | ||
|
|
9ca534ba2e | ||
|
|
a329e499ce | ||
|
|
2217ad5009 | ||
|
|
9745847617 | ||
|
|
403bc4a804 | ||
|
|
7b28595c11 | ||
|
|
0e18cf2a08 | ||
|
|
fe559785c5 | ||
|
|
e91140e743 | ||
|
|
bbd015651d | ||
|
|
b11c9a9c57 | ||
|
|
2f01a1df92 | ||
|
|
0332936393 | ||
|
|
06ad0becf1 | ||
|
|
b2586232ae | ||
|
|
72a6efc814 | ||
|
|
53b75a0acb | ||
|
|
74ee51313d | ||
|
|
6daf74fb6d | ||
|
|
b9309f7985 | ||
|
|
f9759afa30 | ||
|
|
be553fa3cf | ||
|
|
b959cd90da | ||
|
|
8bcebf0e48 | ||
|
|
a6bac3298b | ||
|
|
0b6f3285ab | ||
|
|
d949c3d8b7 | ||
|
|
62fd793603 | ||
|
|
f526671534 | ||
|
|
92a801a04f | ||
|
|
53100a458f | ||
|
|
0ed83870a4 | ||
|
|
e7b78e7150 | ||
|
|
77f39084de | ||
|
|
ab1c386ca4 | ||
|
|
0887872228 | ||
|
|
abe714bbae | ||
|
|
86e24874b1 | ||
|
|
e0525e24ff | ||
|
|
6603256286 | ||
|
|
0131a75fd8 | ||
|
|
e0856dd574 | ||
|
|
2a8efd7aee | ||
|
|
c0c497f4c3 | ||
|
|
14d12ac5bd | ||
|
|
4cd0757512 | ||
|
|
86bd11a271 | ||
|
|
b7ffd4a91e | ||
|
|
5e88acde4d | ||
|
|
582d527612 | ||
|
|
82047aca3e | ||
|
|
9cf044b9f4 | ||
|
|
307bb3b3cc | ||
|
|
c10677f385 | ||
|
|
76f8f68849 | ||
|
|
3e65dd7cf3 | ||
|
|
dc64a0c8e1 | ||
|
|
d15eae5fa5 | ||
|
|
1d72a6f895 | ||
|
|
f8af51a341 | ||
|
|
caabf08f3e | ||
|
|
2f24495ffe | ||
|
|
989768207f | ||
|
|
6bbf30e52a | ||
|
|
1946ffca9c | ||
|
|
3f81c70ade | ||
|
|
f3072fc748 | ||
|
|
0e26b258eb | ||
|
|
877aac130f | ||
|
|
d7026367f7 | ||
|
|
e37fbe3849 | ||
|
|
9739eeb333 | ||
|
|
77f7f02acc | ||
|
|
025ce255ae | ||
|
|
8e69146160 | ||
|
|
7add248dc3 | ||
|
|
cb968353a5 | ||
|
|
08dc614938 | ||
|
|
a070d83be3 | ||
|
|
63b95c8dd3 | ||
|
|
b275eb04d0 | ||
|
|
4650ccbe26 | ||
|
|
4f1e4979fc | ||
|
|
3895694a9b | ||
|
|
e6b58f5142 | ||
|
|
afa1c992bf | ||
|
|
8548cf272d | ||
|
|
d890ab337d | ||
|
|
c1f543dd75 | ||
|
|
dbef720363 | ||
|
|
67fbb3c18d | ||
|
|
a2aa0fc303 | ||
|
|
99220d50e1 | ||
|
|
770213b295 | ||
|
|
f72d5060f2 | ||
|
|
3c2fa5917d | ||
|
|
7ddc8d9c8a | ||
|
|
85baa19884 | ||
|
|
f4000408a2 | ||
|
|
2cdf8465b9 | ||
|
|
8f42ac1892 | ||
|
|
ab2f745cd3 | ||
|
|
7a97db06b7 | ||
|
|
17a1c9835b | ||
|
|
0990c7bbd3 | ||
|
|
41d79128f5 | ||
|
|
eff0baad56 | ||
|
|
f7d1c354fd | ||
|
|
66374d0835 | ||
|
|
e5745c4fc1 | ||
|
|
4eba49ff48 | ||
|
|
67a4b8fd1d | ||
|
|
ae82ed6500 | ||
|
|
40dd19d17a | ||
|
|
8148d024ea | ||
|
|
68c9d440ab | ||
|
|
bde7140396 | ||
|
|
cdb0bbe315 | ||
|
|
32e22891d6 | ||
|
|
14de645441 | ||
|
|
b9c3dd4519 | ||
|
|
b9d370aeff | ||
|
|
69a90f6033 | ||
|
|
b9b681c748 | ||
|
|
78330cfa2f | ||
|
|
187a75ca3f | ||
|
|
13df37f9d1 | ||
|
|
8f71c94473 | ||
|
|
8511d0321b | ||
|
|
1e87e85ab7 | ||
|
|
c5d2297ef1 | ||
|
|
bf785840e5 | ||
|
|
5a3e833ec8 | ||
|
|
5bdd9d539f | ||
|
|
a285efda46 | ||
|
|
d5e44b321e | ||
|
|
6902e1ab3f | ||
|
|
3fc78187ec | ||
|
|
f0bb2760c9 | ||
|
|
a71f8eb56a | ||
|
|
51f8582c25 | ||
|
|
6920adf092 | ||
|
|
1eb9bea82a | ||
|
|
d55b4f77cf | ||
|
|
a8e45f0e2d | ||
|
|
9122ed0a7e | ||
|
|
8b8c164e3f | ||
|
|
4ed644d98d | ||
|
|
26644850fc | ||
|
|
8735dbc6fd | ||
|
|
c266083acd | ||
|
|
a3dc3ca68e | ||
|
|
d82eafce7a | ||
|
|
f3ff11ff45 | ||
|
|
cb33850e9c | ||
|
|
a1372d498a | ||
|
|
b9cc13beff | ||
|
|
b5cadb9412 | ||
|
|
981f28469e | ||
|
|
0072594442 | ||
|
|
d27a356007 | ||
|
|
02a0ad2541 | ||
|
|
71eae2559d | ||
|
|
c61fd7085f | ||
|
|
e9c6c53b2a | ||
|
|
8aef393aab | ||
|
|
c94f93ddc0 | ||
|
|
8b8607db8c | ||
|
|
c600673648 | ||
|
|
63fc56f327 | ||
|
|
da90552ee5 | ||
|
|
d60f3b12ff | ||
|
|
92fa9cbab2 | ||
|
|
8d185babb4 | ||
|
|
0e3ce7744b | ||
|
|
d5a3422dcd | ||
|
|
1d19d84493 | ||
|
|
1686e6748e | ||
|
|
d67290253d | ||
|
|
4064ef8656 | ||
|
|
ffd343f058 | ||
|
|
938fb5bce6 | ||
|
|
f5241fabd0 | ||
|
|
b81d8ccf6c | ||
|
|
ce45d2a79c | ||
|
|
2469a77ad4 | ||
|
|
fd28438c57 | ||
|
|
27b5eb7100 | ||
|
|
38314db4ba | ||
|
|
1aaa81f56b | ||
|
|
d5561d6e02 | ||
|
|
0746519517 | ||
|
|
8fead43ed2 | ||
|
|
4dcd0b6caa | ||
|
|
285a3da81a | ||
|
|
4f30e52368 | ||
|
|
d3751cc07b | ||
|
|
7cd6b5d983 | ||
|
|
8875b3fbca | ||
|
|
2108c3cc99 | ||
|
|
848e7a296a | ||
|
|
101fd55fa0 | ||
|
|
6939a481a4 | ||
|
|
b28e8490c2 | ||
|
|
b7018a3370 | ||
|
|
a6012d86e8 | ||
|
|
27230233cc | ||
|
|
ee61583606 | ||
|
|
92647fa5b6 | ||
|
|
1e3886c053 | ||
|
|
5eea7e4f66 | ||
|
|
2e9870b1d0 | ||
|
|
951f7c2842 | ||
|
|
34ae88292d | ||
|
|
a8fbae1b1f | ||
|
|
3c5d0e7ce4 | ||
|
|
fbcb217789 | ||
|
|
7c80b286c7 | ||
|
|
91fa3af21d | ||
|
|
577239d734 | ||
|
|
39979d4753 | ||
|
|
424a49604c | ||
|
|
47b6b3285d | ||
|
|
ecbbba4f2e | ||
|
|
2a3efa02f2 | ||
|
|
8ac4c9d268 | ||
|
|
d39e02b3fe | ||
|
|
5fd7fd4312 | ||
|
|
57c79dc0b4 | ||
|
|
1fbd35560d | ||
|
|
fdc08e25d6 | ||
|
|
7e423f91e9 | ||
|
|
1ff7ac7557 | ||
|
|
476d228262 | ||
|
|
6961bed99d | ||
|
|
37dfdd5da5 | ||
|
|
743930ac11 | ||
|
|
efdb04db8a | ||
|
|
80685831ac | ||
|
|
3688ad7a46 | ||
|
|
8ca4cd2cbf | ||
|
|
3d2e8bbe82 | ||
|
|
0af1741e24 | ||
|
|
db2bbcf405 | ||
|
|
9bee299dd5 | ||
|
|
0f987812b0 | ||
|
|
6b0d1ed1c0 | ||
|
|
49bdc5cdd4 | ||
|
|
7a8387f467 | ||
|
|
12758df31f | ||
|
|
a76faaa21f | ||
|
|
21a9da7360 | ||
|
|
f31d91bf1a | ||
|
|
52771905e6 | ||
|
|
0ffe0c7c0e | ||
|
|
8689863fbf | ||
|
|
2d0da1472f | ||
|
|
816097c6bf | ||
|
|
1d7ad173aa | ||
|
|
8bc6532928 | ||
|
|
4791c88809 | ||
|
|
06b33b0d2d | ||
|
|
4664907fdc | ||
|
|
35b9d5a645 | ||
|
|
18b472092e | ||
|
|
32a3362e7b | ||
|
|
4bb469563b | ||
|
|
d59733937a | ||
|
|
1799fa533e | ||
|
|
b32017d44f | ||
|
|
0c9b65c931 | ||
|
|
2d230dc31a | ||
|
|
c06c794e5a | ||
|
|
143e4d78e7 | ||
|
|
cb3915d230 | ||
|
|
93913c7906 | ||
|
|
c459285f42 | ||
|
|
e31a39ec40 | ||
|
|
36deb67f20 | ||
|
|
fec2ac402b | ||
|
|
8593c15b90 | ||
|
|
6d4ad17954 | ||
|
|
c401df19d0 | ||
|
|
743801b255 | ||
|
|
f39b01174c | ||
|
|
0f786df95e | ||
|
|
4ee4bff9b6 | ||
|
|
233ad71bd6 | ||
|
|
0e97cf1d4b | ||
|
|
830dea80e0 | ||
|
|
6fc79c33b7 | ||
|
|
24583effe0 | ||
|
|
d21040d746 | ||
|
|
9e44139641 | ||
|
|
314bfe802f | ||
|
|
b795d15d69 | ||
|
|
05b160b32e | ||
|
|
84bd59de36 | ||
|
|
065e237f7f | ||
|
|
adea2d307d | ||
|
|
dfe215ca30 | ||
|
|
bc8e42d8c7 | ||
|
|
d973805269 | ||
|
|
56acb8d3b6 | ||
|
|
0ca6503f09 | ||
|
|
ca0673e0bc | ||
|
|
917dcdeeef | ||
|
|
25d348f391 | ||
|
|
403b335039 | ||
|
|
ddfd08f321 | ||
|
|
f0d3f81f3b | ||
|
|
3b683bbeec | ||
|
|
5527418234 | ||
|
|
38dd835af6 | ||
|
|
2269a58887 | ||
|
|
7bbb63e4bc | ||
|
|
6b06cba32d | ||
|
|
6af7769f08 | ||
|
|
8c21a7710d | ||
|
|
84e2da4bdd | ||
|
|
878d1f231a | ||
|
|
012395d49d | ||
|
|
ef32703362 | ||
|
|
156b492119 | ||
|
|
236e908dc9 | ||
|
|
6dc14c30a9 | ||
|
|
eb50f50599 | ||
|
|
700df5dbe4 | ||
|
|
7fee2c762f | ||
|
|
6cdd63638f | ||
|
|
2df4fede2a | ||
|
|
ee1203d463 | ||
|
|
98a988eaf4 | ||
|
|
e69b25faa6 | ||
|
|
c69463782a | ||
|
|
07e7d14f43 | ||
|
|
17736b6fe3 | ||
|
|
d12f75afcd | ||
|
|
5e01868020 | ||
|
|
6f129beb86 | ||
|
|
a282e4d2bf | ||
|
|
cafe3d658c | ||
|
|
9493e6f91d | ||
|
|
5cb8b1ba07 | ||
|
|
21b4e70150 | ||
|
|
6dfdf643d9 | ||
|
|
ad1e8d1112 | ||
|
|
f46b0f7dc4 | ||
|
|
cc3299dc0f | ||
|
|
467c47b719 | ||
|
|
03ffe790be | ||
|
|
ebc2857549 | ||
|
|
fb13a1e1f1 | ||
|
|
d049e69b95 | ||
|
|
288037543e | ||
|
|
f554c699a1 | ||
|
|
9336111655 | ||
|
|
bb66070841 | ||
|
|
f88242f576 | ||
|
|
b9069e761e | ||
|
|
88088e99bb | ||
|
|
54a8d60dae | ||
|
|
a3adaca19e | ||
|
|
837d2b5667 | ||
|
|
04806a98bf | ||
|
|
c91405dbea | ||
|
|
80b7aee756 | ||
|
|
d838af024b | ||
|
|
e510190867 | ||
|
|
ee6cd6de55 | ||
|
|
f14f8d8eb7 | ||
|
|
3b00334c41 | ||
|
|
e9edc49977 | ||
|
|
f9092edfe3 | ||
|
|
54e43d0a97 | ||
|
|
c5e38a309c | ||
|
|
842656f240 |
No files matched your search
@@ -36,47 +36,4 @@ jobs:
|
||||
with:
|
||||
require-craft-fonts: true
|
||||
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
|
||||
package:
|
||||
name: FreeBSD 14 (package)
|
||||
# The release build takes a while: only for PRs that touch FreeBSD packaging (or dependencies)
|
||||
# and on demand, not on every push to main.
|
||||
if: github.event_name != 'push'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 150
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Japanese, Chinese, Arabic and Thai fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR in the VM).
|
||||
# Bump the commit deliberately; see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
path: craft-fonts
|
||||
- name: Package in a FreeBSD VM
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
mem: 12288
|
||||
prepare: |
|
||||
pkg install -y bash curl git pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
|
||||
run: |
|
||||
. "$HOME/.cargo/env"
|
||||
git config --global --add safe.directory "$PWD"
|
||||
# Build outside the checkout so only dist/ is copied back out of the VM.
|
||||
# The craft-fonts checkout is synced into the VM with the workspace.
|
||||
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
|
||||
export CARGO_TARGET_DIR=/var/tmp/vectorcraft-target CARGO_BUILD_JOBS=4
|
||||
bash packaging/freebsd/package.sh
|
||||
# Every part the package must have, the craft-fonts licences included.
|
||||
tar -tzf dist/release/vectorcraft-*-freebsd-x86_64.tar.gz >/var/tmp/contents.txt
|
||||
for want in '/bin/vectorcraft$' '/bin/vectorcraft-cli$' '/share/applications/.*\.desktop$' '/share/icons/hicolor/' '/share/doc/vectorcraft/NOTICE$' '/share/doc/vectorcraft/OFL-.*\.txt$'; do
|
||||
grep -Eq "$want" /var/tmp/contents.txt || { echo "missing from the package: $want"; exit 1; }
|
||||
done
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: freebsd-package
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
run-package-check: true
|
||||
@@ -18,16 +18,6 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
eval:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
|
||||
- name: nix flake check --no-build
|
||||
run: |
|
||||
if [ -f flake.nix ]; then
|
||||
nix flake check --no-build --all-systems
|
||||
else
|
||||
echo "No flake.nix present in this repository; skipping check."
|
||||
fi
|
||||
nix:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
|
||||
@@ -41,6 +41,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
|
||||
- `vectorcraft_engine::guard` is the last line of defence. Every entry point (commands, tool events, MCP requests, the UI frame, control requests) catches a panic, rolls the document back and reports an internal error. It exists for bugs and doesn't replace `Result`: wasm aborts on panic, so the web app has no safety net.
|
||||
- Untrusted input is fuzzed (`engine/tests/import_fuzz.rs`, `engine/tests/command_sweep.rs`, `format/tests/prop_format.rs`, `mcp/tests/protocol_props.rs`). Extend these when you add an importer, a parser or a protocol method.
|
||||
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, menu path, shortcut, params doc, `enabled`, `run`) + tests. Tools emit commands (Begin/Preview/Commit). UI-only commands live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). The control channel and MCP reach all of them.
|
||||
- **Adding a cursor.** The app decides which cursors exist; whoever draws them works from the list. For a new tool state with a cursor of its own: add the `Cursor` variant (`crates/tools/src/lib.rs`, with a doc comment), return it from the tool's `cursor()` with a tool test, cover it in `cursor_icon` (`crates/ui-egui/src/canvas.rs`), draw it in `crates/ui-egui/src/cursors.rs` or give it a stand-in in `Cursor::stand_in()` until it's drawn, and add its `[[cursor]]` entry to `crates/tools/src/cursors.toml` (when it shows, in plain behaviour terms, and its hotspot; never another app's look). A missing entry only warns (`cargo xtask cursors`): the cursor still works.
|
||||
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd.
|
||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
|
||||
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
||||
|
||||
@@ -41,6 +41,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
|
||||
- `vectorcraft_engine::guard` is the last line of defence. Every entry point (commands, tool events, MCP requests, the UI frame, control requests) catches a panic, rolls the document back and reports an internal error. It exists for bugs and doesn't replace `Result`: wasm aborts on panic, so the web app has no safety net.
|
||||
- Untrusted input is fuzzed (`engine/tests/import_fuzz.rs`, `engine/tests/command_sweep.rs`, `format/tests/prop_format.rs`, `mcp/tests/protocol_props.rs`). Extend these when you add an importer, a parser or a protocol method.
|
||||
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, menu path, shortcut, params doc, `enabled`, `run`) + tests. Tools emit commands (Begin/Preview/Commit). UI-only commands live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). The control channel and MCP reach all of them.
|
||||
- **Adding a cursor.** The app decides which cursors exist; whoever draws them works from the list. For a new tool state with a cursor of its own: add the `Cursor` variant (`crates/tools/src/lib.rs`, with a doc comment), return it from the tool's `cursor()` with a tool test, cover it in `cursor_icon` (`crates/ui-egui/src/canvas.rs`), draw it in `crates/ui-egui/src/cursors.rs` or give it a stand-in in `Cursor::stand_in()` until it's drawn, and add its `[[cursor]]` entry to `crates/tools/src/cursors.toml` (when it shows, in plain behaviour terms, and its hotspot; never another app's look). A missing entry only warns (`cargo xtask cursors`): the cursor still works.
|
||||
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd.
|
||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
|
||||
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
||||
|
||||
Generated
+30
-28
@@ -4507,7 +4507,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"block2 0.6.2",
|
||||
@@ -4542,7 +4542,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-affinity"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
@@ -4553,7 +4553,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-brush"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde",
|
||||
@@ -4565,7 +4565,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cad"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4581,7 +4581,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cli"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"objc2-core-foundation",
|
||||
@@ -4603,7 +4603,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-color"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"moxcms",
|
||||
@@ -4615,7 +4615,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-doc"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"jpeg-encoder",
|
||||
@@ -4632,7 +4632,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-effects"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4648,7 +4648,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-engine"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
@@ -4684,9 +4684,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-eps"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"fax",
|
||||
"flate2",
|
||||
"hayro-ccitt",
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
"ruzstd",
|
||||
@@ -4705,7 +4707,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-format"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"proptest",
|
||||
@@ -4720,7 +4722,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-geom"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"proptest",
|
||||
@@ -4732,7 +4734,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-mcp"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4751,7 +4753,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-metafile"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4765,7 +4767,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pathops"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"linesweeper",
|
||||
@@ -4777,7 +4779,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pdf"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"craft-pdf",
|
||||
@@ -4807,7 +4809,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-plugins"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4821,7 +4823,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-render"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"gif",
|
||||
@@ -4848,14 +4850,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-scriptcheck"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-svg"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4879,7 +4881,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-testkit"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4896,7 +4898,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-text"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"harfrust 0.13.3",
|
||||
"icu_segmenter",
|
||||
@@ -4916,7 +4918,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-three-d"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
@@ -4924,7 +4926,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-tools"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4940,7 +4942,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-trace"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4952,7 +4954,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-ui-egui"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"egui",
|
||||
@@ -4986,7 +4988,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-web"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -6096,7 +6098,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+4
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -79,6 +79,9 @@ flate2 = { version = "1", default-features = false, features = ["rust_backend"]
|
||||
rustix = { version = "1.1", default-features = false }
|
||||
# Zstandard decoder (pure Rust, builds for wasm32 too): the editing copy an EPS carries of its art.
|
||||
ruzstd = { version = "0.9.1", default-features = false, features = ["std"] }
|
||||
# CCITT Group 3 and Group 4 decoder (ITU-T T.4 and T.6): EPS images read through
|
||||
# `CCITTFaxDecode`. The PDF reader's hayro-syntax uses the same version.
|
||||
hayro-ccitt = "0.3"
|
||||
jpeg-encoder = { version = "0.6", default-features = false, features = ["std"] }
|
||||
gif = { version = "0.14", default-features = false, features = ["std", "raii_no_panic"] }
|
||||
# CMYK image samples (vectorcraft-doc's cmyk module): the image crate's own TIFF and JPEG
|
||||
|
||||
@@ -245,7 +245,7 @@ The scores are self-assessed from our code against Illustrator's public document
|
||||
- reliable exchange of Illustrator's own `.ai` files (layer structure in real files; Illustrator's editing data isn't written);
|
||||
- an interaction-fidelity pass covering every tool's modifiers and small behaviours;
|
||||
- 3D and Materials beyond the initial live Revolve (Extrude & Bevel, Inflate, Rotate, materials);
|
||||
- about half of the Photoshop-style raster effects (28 of 57 still missing; Film Grain is available under Artistic) and the Effect Gallery;
|
||||
- about half of the Photoshop-style raster effects (24 of 57 still missing; Cutout, Film Grain, Neon Glow and Poster Edges are available under Artistic, Halftone Pattern under Sketch) and the Effect Gallery;
|
||||
- CJK composition for vertical type (vertical type itself has initial support, with a Japanese interface);
|
||||
- scripting, and the rest of Variables (image and graph kinds, dataset import);
|
||||
- generative AI features.
|
||||
|
||||
+8
-5
@@ -9,7 +9,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (about 160 community PRs of 2026-10-15/16: PDF import crash fixes, Dimension tool, legacy `.ai` save, Intertwine, web pen input, Image Trace fixes, `.ai`/EPS/PDF import fidelity, Bulgarian, Greek, Swedish and Finnish catalogs; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (about 145 community PRs of 2026-10-17: image-decode overflow guards, opaque floating panels, MCP export cancel and progress, editable Illustrator EPS import, scoped `doc_inspect`, Dutch, Danish and Indonesian, Simplified Chinese messages, screen-reader names; headline numbers unchanged; about 160 community PRs of 2026-10-15/16: PDF import crash fixes, Dimension tool, legacy `.ai` save, Intertwine, web pen input, Image Trace fixes, `.ai`/EPS/PDF import fidelity, Bulgarian, Greek, Swedish and Finnish catalogs; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs
|
||||
on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON
|
||||
@@ -29,7 +29,7 @@ blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)).
|
||||
|
||||
| | Value | Kind |
|
||||
|---|---|---|
|
||||
| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 44/50, Photoshop effects 29/57, 3D 4/4 (initial types, no materials) |
|
||||
| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 44/50, Photoshop effects 33/57, 3D 4/4 (initial types, no materials) |
|
||||
| **Feature depth** (weighted by use, scored by behaviour) | **~75%** | estimated |
|
||||
| **To beta** | **~170–270 h** one agent · ~50–80 h with 4–6 agents | estimated |
|
||||
| **To full parity** | **~360–590 h** one agent · ~95–165 h with 4–6 agents | estimated |
|
||||
@@ -76,11 +76,11 @@ The dimension hours overlap (feature rows hold some UI, format and AI work), so
|
||||
| Strokes, brushes, width profiles | 88% | 8–15 |
|
||||
| Appearance, transparency, graphic styles, masks | 97% | 1–2 |
|
||||
| Live vector effects | 85% | 6–10 |
|
||||
| Raster effects (Effect Gallery) — [effects-parity.md](docs/effects-parity.md) | 49% | 23–39 |
|
||||
| Raster effects (Effect Gallery) — [effects-parity.md](docs/effects-parity.md) | 49% | 21–35 |
|
||||
| 3D and Materials | ~25% | 30–55 |
|
||||
| Type core — [type-parity.md](docs/type-parity.md) | 80% | 14–19 |
|
||||
| Type advanced (CJK, spelling, Touch Type) | 50% | 20–28 |
|
||||
| Symbols, blends, envelopes, Repeat, perspective | 85% | 6–10 |
|
||||
| Symbols, blends, envelopes, Repeat, perspective | 87% | 5–9 |
|
||||
| Image Trace, graphs, image tools | 75% | 5–8 |
|
||||
| Layers, artboards, document setup | 78% | 5–8 |
|
||||
| View & navigation | 70% | 9–14 |
|
||||
@@ -123,7 +123,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
2. **`.ai` files from real users** (G9): layer structure, guides, speed: 10–20 h.
|
||||
3. **Launch and stability on real machines** (G10): 20–35 h.
|
||||
4. **Core-tool correctness** (G11: Shape Builder regions, Asset Export borders): 8–15 h.
|
||||
5. **Raster effects** (G2) alongside: 28 filters and the Effect Gallery, 23–39 h.
|
||||
5. **Raster effects** (G2) alongside: 24 filters and the Effect Gallery, 21–35 h.
|
||||
6. **Then:** type core and advanced type (G4), the Illustrator 2026 additions (G13), 3D (G3).
|
||||
|
||||
## Progress log
|
||||
@@ -134,6 +134,9 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-17 | About 145 community PRs landed in six batches, each through the full gate, plus our fixes on top. Crashes and hostile input: an image's RGBA size is checked from its header before decoding, without overflowing, in the renderer, sampling, recoloring and Crop Image (#2442, #2468, #2407), PDF Add Around and huge art are clamped (#2440), graph axis labels past i64, `+` in hex colours and XML ids are handled (#2610, #2470, #2447), Document Raster Effects resolutions open in range (#2579), Batch names stay in their folder (#2504). Regressions: floating panel groups are opaque again while the docked card keeps its rounded corners (#2499), the Wayland pointer returns over panels (#2502). New: MCP cancels long exports, answers while one runs and reports progress (#2546), `doc_inspect` takes ids, kind and paging (#2543), raster export of a page rectangle (#2511), `text.create` returns bounds (#2478), PDF presets and ICC profiles persist (#2547), screen-reader names for widgets (#2611), Expand on Intertwine (#2622, fixes #2540), Duplicate/Copy/Cut of several artboards (#2567), print presets by name (#2607), TSV data sets (#2608), planar TIFF (#2598), Alt-click a swatch onto a gradient stop (#2623), Shift-click ranges in Links, Graphic Styles and Libraries (#2612, #2613, #2618), Space+Shift rotates the view (#2601), Presentation Mode keys (#2583), Small Caps and super/subscript buttons (#2508). Fidelity: editable vector art from Illustrator EPS (#2571), `.ai` Drop Shadow Darkness (#2513), overprint preview with Darken (#2514), Generic CMYK black round-trips (#2503), PNG sRGB chunk (#2519), kerning at a caret (#2518), tab widths in justified area type (#2487), Transform copies keep their own gradients (#2486), smooth Zig Zag (#2475), Live Corners round up to their neighbours (#2484), kerning across style runs (#2554), SVG letter spacing on justified lines (#2574), Dimension tool snapping (#2482), gradient chips (#2495), Transform panel flips and percentages (#2400, #2404). Later the same day: PostScript `.ps` and `.epsf` files open (#2461), EPS images with CCITTFax compression draw (#2445), Illustrator 3 EPS without layers (#2436), `.ai` type opacity and blend modes (#2420), envelope meshes and compound holes in `.ai` (#2410, #2444), CMYK image soft masks in PDF export (#2448), Show Transparency Grid saved with the document (#2488), Save Selection in the Layers panel menu (#2483), Character and Paragraph Reset Panel (#2479, #2438), Cmd/Ctrl+Shift double-click releases a guide (#2467), Shift+Enter keeps a field active (#2431), Shift keeps reshaped handles perpendicular (#2425), emptied type keeps its style (#2464), Smart Blur radius (#2443), freeform gradients recolour (#2397), mesh slivers (#2456), Recolor wheel handles in CMYK (#2454), and six Image Trace fixes (#2387, #2584, #2586–#2589). Fifth batch: Optical Margin Alignment (#2563), Window › Blend panel (#2573), graph column designs (#2406), Live Paint Gap Options (#2424), the SVG Interactivity panel (#2437), RTF placed as area type (#2524), the Touch Type tool (#2469), pattern fills expand into clipped tiles (#2616), Save and Export ask before replacing a file an added extension names (#2619), the Artboard tool's Control bar and Contextual Task Bar (#2559, #2556), Alt-click New Artboard for its options (#2496), Shift-click in Brushes (#2562), the Move dialog remembers the last move (#2542), PDF export uses the document's assigned profiles (#2536), Printer Setup opens the selected printer's preferences on Windows (#2577). Sixth batch: Effect › Artistic › Cutout, Neon Glow and Poster Edges (#2453), View › Rulers › Change to Global / Artboard Rulers (#2416), Symbol Options 9-slice guides (#2512), Create Gradient Mesh on an embedded image (#2538), Rotate View cursors and the Mac trackpad rotation gesture (#2594, #2644), choice parameters as dropdowns in the inline effect editor (#2582), Group Selection picks and moves one path of a compound path (part of #2441), Shift-click ranges in the Artboards panel (#2626), Lock Variables (#2631), a layer dropped under the last row goes to the bottom (#2632), and robustness work: huge selections draw bounded overlays (#2635, #2645, part of #2460), values out of range in presets and files are clamped (#2656), whole-number checks for counts (#2638, #2659), exports warn about overset type (#2628), symbol instances measured by their art (#2614), colour-managed Eyedropper in CMYK documents (#2664). Languages: Dutch, Danish and Indonesian complete (#2393), Simplified Chinese status and error messages (#2578), translated file filters and presets (#2590). Headline numbers unchanged (estimated). |
|
||||
| 2026-10-16 | Symbol Options › Export Type (Graphic or Movie Clip, default Movie Clip) with Registration; `symbol.options` / `symbol.list` (G5).
|
||||
| 2026-10-15 | Cursors: 36 more tool states get cursors of their own. Node editing follows the public manual (Direct Selection's anchor squares, Reshape Segment, Group Selection's plus, the arrow hollow on a snap, the Alt-copy arrowheads), the Anchor Point caret, the Curvature pen and Free Transform's side-shear arrows are drawn; Lasso, the freehand, symbolism and other tools borrow a drawn cursor until theirs is drawn. `crates/tools/src/cursors.toml` lists every cursor for whoever draws them, and `cargo xtask cursors` warns about one missing from it. Scores unchanged. |
|
||||
| 2026-10-16 | About 160 community PRs landed, each through the full gate. Crashes and hostile files: damaged PDF dictionaries and arrays no longer crash import (#2133, #2240, #2244, #2271), area type with huge row or column counts opens (#2109), hostile mesh edges are bounded (#2051). New: Dimension tool (#1788), legacy version 7 `.ai` save (#1794), Object › Intertwine (#2095), Apple Pencil and touch input on the web (#1740), Sample Color from Screen (#2257), Drop Shadow Darkness (#2252), Smart Blur edge modes (#2154), Scribble variations (#2180), Free Distort corners in its dialog (#2134), Rotate View presets (#2106), tritanopia proofing (#2234), Layers panel filters (#2087), Hide Template (#2117), File › Automate Batch through the Actions panel (#1938), dataset and graph CSV (#1929, #1941), Reset Tracking and Kerning (#2274), Alt+Enter copies in Transform fields (#2236), Shift+Tab panels (#2254), Control bar isolation, Recolor and image Replace (#2258, #2079, #2081). Revolve renders smooth with Cap on by default (#1193); Image Trace keeps tiny parts, thin outlines and shared corners, and Grayscale gradients are grey (#2012, #2025, #2263, #2264, #2265, #1879). Import: many `.ai`, EPS and PDF fidelity fixes (#2014–#2016, #2029, #2030, #2044, #2046, #2050–#2052, #2058, #2129, #2199, #2202, #2218), images up to 1 GB decoded (#2060), `.ai`/`.ait`/`.svgz` files paste from a file manager (#2148, #2201). Fixes across fields (arithmetic #2069, #2209, #2022; caret #1403), New Document (#2158, #2176), menus (#2232), shortcuts (#2235, #2146), Transform Each axes (#2227), Paste in Place (#1926), Color Picker tab order (#1937, #1939) and more. Languages: Bulgarian, Greek, Swedish and Finnish complete under the tests, pt-br completed. Details per PR in the PRs; headline numbers unchanged (estimated). |
|
||||
| 2026-10-14 | Brazilian Portuguese (`pt-br`) covers the whole interface and status/error messages under the completeness tests (#1914); Localization 71% → 74% (measured), hours 55–90 → 50–85. |
|
||||
| 2026-10-14 | Direct Selection: deleting an interior anchor of an open path leaves separate surviving runs. Delete/Backspace and history regressions cover the gap; explicit Remove Anchors remains connected. Headline numbers unchanged. |
|
||||
|
||||
@@ -159,6 +159,16 @@ fn main() -> ExitCode {
|
||||
}
|
||||
}
|
||||
|
||||
/// A headless session that remembers what the user keeps between runs: the PDF presets saved, the
|
||||
/// ICC profiles loaded and the plug-ins installed (#2168, #2174, #2541), in the folders next to the preferences that the desktop
|
||||
/// app uses too. `VECTORCRAFT_NO_PREFS` and `VECTORCRAFT_CONFIG_DIR` apply as they do there.
|
||||
fn with_stores(mut h: Headless) -> Headless {
|
||||
prefs_dir::attach_profile_store(&mut h.session);
|
||||
prefs_dir::attach_plugin_store(&mut h.session);
|
||||
prefs_dir::attach_pdf_preset_store(&mut h.session);
|
||||
h
|
||||
}
|
||||
|
||||
fn mcp(args: &[String]) -> Result<(), String> {
|
||||
let mut connect: Option<String> = None;
|
||||
let mut headless = false;
|
||||
@@ -198,14 +208,14 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
||||
eprintln!("vectorcraft-cli: files confined: read root {}, write root {}", show(roots.read_root()), show(roots.write_root()));
|
||||
Box::new(Headless::with_document().with_automation_roots(Some(roots)))
|
||||
} else if headless {
|
||||
Box::new(Headless::with_document())
|
||||
Box::new(with_stores(Headless::with_document()))
|
||||
} else if let Some(addr) = connect {
|
||||
// Explicit address: fail loudly if the app isn't there.
|
||||
Box::new(Remote::connect(&addr).map_err(|e| format!("cannot connect to {addr}: {e}"))?)
|
||||
} else {
|
||||
match Remote::connect(DEFAULT_ADDR) {
|
||||
Ok(r) => Box::new(r),
|
||||
Err(_) => Box::new(Headless::with_document()),
|
||||
Err(_) => Box::new(with_stores(Headless::with_document())),
|
||||
}
|
||||
};
|
||||
eprintln!("vectorcraft-cli: MCP server on stdio ({})", backend.describe());
|
||||
@@ -213,8 +223,8 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
||||
// uses `vectorcraft_mcp` free to bring its own. Silent until a client sends logging/setLevel.
|
||||
vectorcraft_mcp::logging::install();
|
||||
let stdin = std::io::stdin();
|
||||
let stdout = std::io::stdout();
|
||||
Server::new(backend).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string())
|
||||
// The server reads and writes from threads of its own, so it takes the handles, not locks held here.
|
||||
Server::new(backend).serve(std::io::BufReader::new(stdin), std::io::stdout()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn commands() -> Result<(), String> {
|
||||
@@ -327,7 +337,7 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
let mut h = Headless::new();
|
||||
let mut h = with_stores(Headless::new());
|
||||
let mut out = std::io::stdout().lock();
|
||||
// A reader that stopped early (a closed pipe) isn't an error, and the batch goes on: its later
|
||||
// steps (an export…) still run, unprinted.
|
||||
|
||||
@@ -7,10 +7,18 @@ use serde_json::{Value, json};
|
||||
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft-cli");
|
||||
|
||||
/// Returns the path of `name` in `vectorcraft-cli-test-<pid>` in the temp folder. The first call removes
|
||||
/// that folder and creates it again empty.
|
||||
fn tmp(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("vectorcraft-cli-test-{}", std::process::id()));
|
||||
static DIR: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
|
||||
DIR.get_or_init(|| empty_dir(std::env::temp_dir().join(format!("vectorcraft-cli-test-{}", std::process::id())))).join(name)
|
||||
}
|
||||
|
||||
/// Removes `dir` and everything in it, creates it again empty and returns it.
|
||||
fn empty_dir(dir: std::path::PathBuf) -> std::path::PathBuf {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir.join(name)
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -87,7 +95,20 @@ fn mcp_headless_over_stdio() {
|
||||
/// server starts, and the process then reads and writes only inside its roots.
|
||||
#[test]
|
||||
fn mcp_automation_roots() {
|
||||
let base = tmp("roots");
|
||||
automation_roots(&tmp("roots"));
|
||||
}
|
||||
|
||||
/// A run that has the process id of an earlier run finds that run's files and links in its folder.
|
||||
/// `empty_dir`, which `tmp` uses, removes them, so the run can make them again instead of failing
|
||||
/// with "File exists".
|
||||
#[test]
|
||||
fn mcp_automation_roots_in_a_folder_an_earlier_run_left() {
|
||||
let dir = tmp("earlier-run");
|
||||
automation_roots(&dir.join("roots"));
|
||||
automation_roots(&empty_dir(dir).join("roots"));
|
||||
}
|
||||
|
||||
fn automation_roots(base: &std::path::Path) {
|
||||
for d in ["in", "out", "outside"] {
|
||||
std::fs::create_dir_all(base.join(d)).unwrap();
|
||||
}
|
||||
@@ -346,3 +367,97 @@ fn run_with_a_closed_stdout_ends_quietly_and_finishes_the_batch() {
|
||||
let out = Command::new(BIN).args(["run", "--cmd", "not-a-command"]).stdout(Stdio::null()).output().unwrap();
|
||||
assert!(!out.status.success());
|
||||
}
|
||||
|
||||
/// A fresh settings folder for one test (`VECTORCRAFT_CONFIG_DIR`), so no run reads or writes the
|
||||
/// developer's real profile.
|
||||
fn config_dir(name: &str) -> std::path::PathBuf {
|
||||
let dir = tmp(&format!("config-{name}"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
dir
|
||||
}
|
||||
|
||||
/// One `vectorcraft-cli run` with `steps` ((command, params)) against the settings folder `config`;
|
||||
/// the result of each step.
|
||||
fn run_in(config: &std::path::Path, steps: &[(&str, Value)]) -> Vec<Value> {
|
||||
let mut cmd = Command::new(BIN);
|
||||
cmd.arg("run").env("VECTORCRAFT_CONFIG_DIR", config).env_remove("VECTORCRAFT_NO_PREFS");
|
||||
for (id, params) in steps {
|
||||
cmd.args(["--cmd", id, "--params", ¶ms.to_string()]);
|
||||
}
|
||||
let out = cmd.output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
String::from_utf8(out.stdout).unwrap().lines().map(|l| serde_json::from_str::<Value>(l).unwrap()["result"].clone()).collect()
|
||||
}
|
||||
|
||||
/// An ICC file of an RGB profile named `name` (a built-in one with its description replaced by a
|
||||
/// name of the same length).
|
||||
fn icc_file_named(path: &std::path::Path, name: &str) {
|
||||
let builtin = "Display P3";
|
||||
assert_eq!(builtin.len(), name.len());
|
||||
let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::<Vec<u8>>();
|
||||
let mut bytes = vectorcraft_color::cms::icc_bytes(builtin).unwrap().to_vec();
|
||||
let (from, to) = (utf16(builtin), utf16(name));
|
||||
let at = bytes.windows(from.len()).position(|w| w == from).expect("the description in the profile");
|
||||
bytes[at..at + to.len()].copy_from_slice(&to);
|
||||
std::fs::write(path, bytes).unwrap();
|
||||
}
|
||||
|
||||
fn profile_names(settings: &Value) -> Vec<&str> {
|
||||
settings["profiles"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap()).collect()
|
||||
}
|
||||
|
||||
/// #2168, #2174: the PDF presets saved and the ICC profiles loaded in one headless run are there in
|
||||
/// the next run, and gone again once deleted.
|
||||
#[test]
|
||||
fn saved_pdf_presets_and_loaded_profiles_survive_a_restart() {
|
||||
let config = config_dir("restart");
|
||||
let icc = tmp("studio-p3.icc");
|
||||
icc_file_named(&icc, "StudioRGB3");
|
||||
let r = run_in(
|
||||
&config,
|
||||
&[
|
||||
("pdf.preset.save", json!({"name": "Press", "description": "for the printer"})),
|
||||
("color.loadProfile", json!({"path": icc.to_str().unwrap()})),
|
||||
],
|
||||
);
|
||||
assert_eq!(r[1]["name"], "StudioRGB3");
|
||||
|
||||
let r = run_in(&config, &[("pdf.preset.list", json!({})), ("edit.colorSettings", json!({}))]);
|
||||
let presets: Vec<&str> =
|
||||
r[0]["presets"].as_array().unwrap().iter().filter(|p| p["builtIn"] == false).map(|p| p["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(presets, ["Press"]);
|
||||
assert_eq!(r[0]["presets"].as_array().unwrap().iter().find(|p| p["name"] == "Press").unwrap()["description"], "for the printer");
|
||||
assert!(profile_names(&r[1]).contains(&"StudioRGB3"), "{:?}", profile_names(&r[1]));
|
||||
// The profile works as a working space, and the file it came from can go.
|
||||
std::fs::remove_file(&icc).unwrap();
|
||||
let steps = [
|
||||
("edit.colorSettings", json!({"rgb": "StudioRGB3"})),
|
||||
("pdf.preset.delete", json!({"name": "Press"})),
|
||||
("edit.colorSettings", json!({"rgb": vectorcraft_color::cms::SRGB})),
|
||||
("color.removeProfile", json!({"name": "StudioRGB3"})),
|
||||
];
|
||||
let r = run_in(&config, &steps);
|
||||
assert_eq!(r[0]["rgb"], "StudioRGB3");
|
||||
assert_eq!(r[3]["removed"], "StudioRGB3");
|
||||
|
||||
let r = run_in(&config, &[("pdf.preset.list", json!({})), ("edit.colorSettings", json!({}))]);
|
||||
assert!(r[0]["presets"].as_array().unwrap().iter().all(|p| p["builtIn"] == true));
|
||||
assert!(!profile_names(&r[1]).contains(&"StudioRGB3"));
|
||||
}
|
||||
|
||||
/// Runs without preferences (`VECTORCRAFT_NO_PREFS`) keep what they save for the run only, and
|
||||
/// leave nothing in the settings folder.
|
||||
#[test]
|
||||
fn runs_without_preferences_remember_nothing() {
|
||||
let config = config_dir("noprefs");
|
||||
let out = Command::new(BIN)
|
||||
.args(["run", "--cmd", "pdf.preset.save", "--params", r#"{"name":"Press"}"#])
|
||||
.env("VECTORCRAFT_CONFIG_DIR", &config)
|
||||
.env("VECTORCRAFT_NO_PREFS", "1")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
assert!(!config.exists(), "nothing written");
|
||||
let r = run_in(&config, &[("pdf.preset.list", json!({}))]);
|
||||
assert!(r[0]["presets"].as_array().unwrap().iter().all(|p| p["builtIn"] == true));
|
||||
}
|
||||
@@ -8,9 +8,16 @@ use serde_json::Value;
|
||||
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft-cli");
|
||||
|
||||
/// Returns the path of `name` in `vectorcraft-cli-fileio-<pid>` in the temp folder. The first call
|
||||
/// removes that folder and creates it again empty.
|
||||
fn tmp(name: &str) -> String {
|
||||
let dir = std::env::temp_dir().join(format!("vectorcraft-cli-fileio-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
static DIR: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
|
||||
let dir = DIR.get_or_init(|| {
|
||||
let dir = std::env::temp_dir().join(format!("vectorcraft-cli-fileio-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
});
|
||||
dir.join(name).to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
//! Linux Wayland: tell winit where the cursor theme is (#2450).
|
||||
//!
|
||||
//! Without the cursor-shape protocol (GNOME 46 and others) winit loads the pointer from an Xcursor
|
||||
//! theme itself. When that lookup finds nothing, no pointer is ever set and the compositor keeps
|
||||
//! showing its default one, until the canvas hides the system pointer to paint a tool glyph
|
||||
//! (`CursorIcon::None`). Then the arrow never comes back over the panels and menus. The lookup
|
||||
//! fails in a Flatpak sandbox (`XCURSOR_PATH` unset, no cursor theme in the runtime) and where the
|
||||
//! `default` theme is a dangling symlink; the desktop's own setting names a theme that loads.
|
||||
//!
|
||||
//! Where no cursor theme can be found at all (KDE or sway without gsettings), the env fix does
|
||||
//! nothing: [`theme_missing`] tells the canvas to keep the system pointer visible beside the tool glyph.
|
||||
//!
|
||||
//! This reads the desktop's cursor theme and size and sets what the user left unset, once, before
|
||||
//! any thread starts and before the window opens.
|
||||
#![cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Where a Flatpak sandbox mounts the host's cursor themes (user and system).
|
||||
const FLATPAK_HOST_ICONS: [&str; 2] = ["/run/host/user-share/icons", "/run/host/share/icons"];
|
||||
|
||||
/// The desktop's cursor settings (GNOME's `org.gnome.desktop.interface`).
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
pub struct DesktopCursor {
|
||||
pub theme: Option<String>,
|
||||
pub size: Option<u32>,
|
||||
}
|
||||
|
||||
/// The variables to set, given the environment, the host icon folders that exist and the
|
||||
/// desktop's cursor settings. Empty unless this is a Wayland session; variables the user set
|
||||
/// stay as they are.
|
||||
pub fn plan(var: impl Fn(&str) -> Option<OsString>, host_icons: &[&str], desktop: DesktopCursor) -> Vec<(&'static str, OsString)> {
|
||||
let set = |name: &str| var(name).is_some_and(|v| !v.is_empty());
|
||||
if !(set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET")) {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
if !set("XCURSOR_PATH") && !host_icons.is_empty() {
|
||||
out.push(("XCURSOR_PATH", OsString::from(host_icons.join(":"))));
|
||||
}
|
||||
if !set("XCURSOR_THEME")
|
||||
&& let Some(theme) = desktop.theme.filter(|t| !t.is_empty())
|
||||
{
|
||||
out.push(("XCURSOR_THEME", OsString::from(theme)));
|
||||
}
|
||||
if !set("XCURSOR_SIZE")
|
||||
&& let Some(size) = desktop.size.filter(|s| (1..=512).contains(s))
|
||||
{
|
||||
out.push(("XCURSOR_SIZE", OsString::from(size.to_string())));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Xcursor's default search path when `XCURSOR_PATH` is unset (relative entries are in `$HOME`).
|
||||
const DEFAULT_ICON_DIRS: [&str; 5] = ["~/.local/share/icons", "~/.icons", "/usr/share/icons", "/usr/share/pixmaps", "/usr/X11R6/lib/X11/icons"];
|
||||
|
||||
/// The themes `index.theme` lists after `Inherits=`.
|
||||
fn inherits(index: &str) -> Vec<String> {
|
||||
index
|
||||
.lines()
|
||||
.find_map(|l| l.trim().strip_prefix("Inherits="))
|
||||
.map(|v| v.split([',', ';']).map(|t| t.trim().to_owned()).filter(|t| !t.is_empty()).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Can Xcursor load a pointer for the environment's theme (`XCURSOR_THEME`, else `default`)? It does
|
||||
/// when the theme, or one it inherits from, has a `default` or `left_ptr` cursor file in a folder of
|
||||
/// `XCURSOR_PATH` (else Xcursor's default folders). `read` returns a file's text, `exists` tells
|
||||
/// whether a file is there.
|
||||
pub fn theme_usable(
|
||||
var: impl Fn(&str) -> Option<OsString>,
|
||||
home: Option<&Path>,
|
||||
read: impl Fn(&Path) -> Option<String>,
|
||||
exists: impl Fn(&Path) -> bool,
|
||||
) -> bool {
|
||||
let dirs: Vec<PathBuf> = match var("XCURSOR_PATH").filter(|v| !v.is_empty()) {
|
||||
Some(v) => std::env::split_paths(&v).collect(),
|
||||
None => DEFAULT_ICON_DIRS
|
||||
.iter()
|
||||
.filter_map(|d| match d.strip_prefix("~/") {
|
||||
Some(rest) => home.map(|h| h.join(rest)),
|
||||
None => Some(PathBuf::from(d)),
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
let first = var("XCURSOR_THEME").and_then(|v| v.into_string().ok()).filter(|t| !t.is_empty()).unwrap_or_else(|| "default".into());
|
||||
let mut todo = vec![first];
|
||||
let mut seen: Vec<String> = Vec::new();
|
||||
while let Some(theme) = todo.pop() {
|
||||
// Bounded: themes inherit in short chains, and a cycle must end.
|
||||
if seen.contains(&theme) || seen.len() >= 16 {
|
||||
continue;
|
||||
}
|
||||
for dir in &dirs {
|
||||
let base = dir.join(&theme);
|
||||
if ["default", "left_ptr"].iter().any(|c| exists(&base.join("cursors").join(c))) {
|
||||
return true;
|
||||
}
|
||||
if let Some(index) = read(&base.join("index.theme")) {
|
||||
todo.extend(inherits(&index));
|
||||
}
|
||||
}
|
||||
seen.push(theme);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// On this machine, after [`apply`]: is there no cursor theme Xcursor can load? Then winit never
|
||||
/// sets a pointer, and the canvas must not hide the system one (#2450).
|
||||
pub fn theme_missing() -> bool {
|
||||
let home = std::env::var_os("HOME").filter(|h| !h.is_empty()).map(PathBuf::from);
|
||||
!theme_usable(|n| std::env::var_os(n), home.as_deref(), |p| std::fs::read_to_string(p).ok(), |p| p.exists())
|
||||
}
|
||||
|
||||
/// The value of a `gsettings get` line: `'Adwaita'` becomes `Adwaita`, `24` stays `24`.
|
||||
fn gsettings_value(output: &str) -> &str {
|
||||
output.trim().trim_matches('\'')
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn desktop_cursor() -> DesktopCursor {
|
||||
let get = |key: &str| {
|
||||
let out = std::process::Command::new("gsettings").args(["get", "org.gnome.desktop.interface", key]).output().ok()?;
|
||||
out.status.success().then(|| String::from_utf8_lossy(&out.stdout).into_owned())
|
||||
};
|
||||
DesktopCursor {
|
||||
theme: get("cursor-theme").map(|v| gsettings_value(&v).to_owned()),
|
||||
size: get("cursor-size").and_then(|v| gsettings_value(&v).parse().ok()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets `XCURSOR_PATH`, `XCURSOR_THEME` and `XCURSOR_SIZE` where a Wayland session leaves them
|
||||
/// unset. Call first thing in `main`, while the process is single-threaded.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[allow(unsafe_code)]
|
||||
pub fn apply() {
|
||||
let host: Vec<&str> = FLATPAK_HOST_ICONS.iter().copied().filter(|p| std::path::Path::new(p).is_dir()).collect();
|
||||
for (name, value) in plan(|n| std::env::var_os(n), &host, desktop_cursor()) {
|
||||
log::debug!("cursor theme: {name}={value:?}");
|
||||
// SAFETY: called at the top of `main`, before the app starts any thread.
|
||||
unsafe { std::env::set_var(name, value) };
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn env<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<OsString> + 'a {
|
||||
move |name| pairs.iter().find(|(k, _)| *k == name).map(|(_, v)| OsString::from(v))
|
||||
}
|
||||
|
||||
fn adwaita() -> DesktopCursor {
|
||||
DesktopCursor { theme: Some("Adwaita".into()), size: Some(24) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wayland_session_gets_the_desktop_theme_and_the_host_icons() {
|
||||
let got = plan(env(&[("WAYLAND_DISPLAY", "wayland-0")]), &["/run/host/share/icons"], adwaita());
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![
|
||||
("XCURSOR_PATH", OsString::from("/run/host/share/icons")),
|
||||
("XCURSOR_THEME", OsString::from("Adwaita")),
|
||||
("XCURSOR_SIZE", OsString::from("24")),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_the_user_made_and_other_sessions_stay_untouched() {
|
||||
let set = [("WAYLAND_DISPLAY", "wayland-0"), ("XCURSOR_PATH", "/x"), ("XCURSOR_THEME", "Bibata"), ("XCURSOR_SIZE", "32")];
|
||||
assert!(plan(env(&set), &["/run/host/share/icons"], adwaita()).is_empty());
|
||||
assert!(plan(env(&[("DISPLAY", ":0")]), &["/run/host/share/icons"], adwaita()).is_empty());
|
||||
// A theme-less desktop and an absurd size set nothing.
|
||||
let odd = DesktopCursor { theme: Some(String::new()), size: Some(0) };
|
||||
assert!(plan(env(&[("WAYLAND_DISPLAY", "wayland-0")]), &[], odd).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_theme_is_usable_directly_or_through_inherits_and_missing_otherwise() {
|
||||
let files = ["/i/Bibata/cursors/left_ptr", "/i/Breeze/index.theme", "/i/Loop/index.theme"];
|
||||
// Compared as paths, so the joins match on Windows too, where they use `\`.
|
||||
let exists = |p: &Path| files.iter().any(|f| Path::new(f) == p);
|
||||
let read = |p: &Path| {
|
||||
if p == Path::new("/i/Breeze/index.theme") {
|
||||
Some("[Icon Theme]\nInherits=Other, Bibata\n".to_owned())
|
||||
} else if p == Path::new("/i/Loop/index.theme") {
|
||||
Some("Inherits=Loop\n".to_owned())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
let with = |theme: &'static str| {
|
||||
move |n: &str| (n == "XCURSOR_PATH").then(|| OsString::from("/i")).or((n == "XCURSOR_THEME").then(|| OsString::from(theme)))
|
||||
};
|
||||
assert!(theme_usable(with("Bibata"), None, read, exists));
|
||||
assert!(theme_usable(with("Breeze"), None, read, exists));
|
||||
// No such theme, a theme without cursors, an inheritance loop: nothing to load.
|
||||
assert!(!theme_usable(with("Nope"), None, read, exists));
|
||||
assert!(!theme_usable(with("Loop"), None, read, exists));
|
||||
// Unset: Xcursor's default folders and the `default` theme.
|
||||
assert!(!theme_usable(|_| None, Some(Path::new("/home/u")), read, exists));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gsettings_output_is_unquoted() {
|
||||
assert_eq!(gsettings_value("'Adwaita'\n"), "Adwaita");
|
||||
assert_eq!(gsettings_value("24\n"), "24");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
//! macOS: the Format menu on save sheets (#866). A save panel with two or more file types (Save As,
|
||||
//! Save a Copy, Save as Template and the first Save of a new document) is an `NSSavePanel` made here
|
||||
//! instead of by rfd, whose Cocoa backend lists the extensions of every filter but not their names.
|
||||
//! The panel gets a Format menu as its accessory view
|
||||
//! (<https://developer.apple.com/documentation/appkit/nssavepanel/accessoryview>) before
|
||||
//! `beginSheetModalForWindow:completionHandler:`
|
||||
//! (<https://developer.apple.com/documentation/appkit/nssavepanel/beginsheetmodal(for:completionhandler:)>)
|
||||
//! shows it: an accessory set after the sheet began made recent macOS versions dismiss the sheet at
|
||||
//! once (#1800, #1831, #1518). Choosing a format changes the extension in the name field and puts
|
||||
//! the format's extension first in the allowed file types, which AppKit adds to a name typed without
|
||||
//! one. The save command takes the format from the extension, so an extension typed by hand still
|
||||
//! decides it.
|
||||
//!
|
||||
//! The panel retains the accessory, and the completion handler holds the panel until the sheet
|
||||
//! closes; the popup's target is the accessory itself. The accessory holds the panel weakly, so
|
||||
//! closing the sheet releases both. Reading the window from its handle and subclassing `NSView` for
|
||||
//! the menu's action have no safe API; this module allows `unsafe_code`.
|
||||
#![allow(unsafe_code)]
|
||||
|
||||
use std::sync::mpsc::{Receiver, channel};
|
||||
|
||||
use block2::RcBlock;
|
||||
use objc2::rc::{Retained, Weak};
|
||||
use objc2::{DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send, sel};
|
||||
use objc2_app_kit::{NSModalResponse, NSModalResponseOK, NSPopUpButton, NSSavePanel, NSTextField, NSView};
|
||||
use objc2_foundation::{NSArray, NSObjectProtocol, NSPoint, NSRect, NSSize, NSString, NSURL};
|
||||
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
|
||||
use vectorcraft_engine::cmd::fileio;
|
||||
use vectorcraft_ui_egui::{FilePick, i18n};
|
||||
|
||||
type Filter = (&'static str, &'static [&'static str]);
|
||||
|
||||
/// The accessory's height and the room around and between its label and menu, in points.
|
||||
const HEIGHT: f64 = 38.0;
|
||||
const MARGIN: f64 = 12.0;
|
||||
const GAP: f64 = 8.0;
|
||||
/// The Format menu's size, in points.
|
||||
const MENU: NSSize = NSSize::new(260.0, 26.0);
|
||||
|
||||
struct Ivars {
|
||||
panel: Weak<NSSavePanel>,
|
||||
filters: Vec<Filter>,
|
||||
}
|
||||
|
||||
define_class!(
|
||||
// SAFETY: NSView has no extra subclassing requirements; the subclass implements no Drop.
|
||||
#[unsafe(super = NSView)]
|
||||
#[thread_kind = MainThreadOnly]
|
||||
#[ivars = Ivars]
|
||||
#[name = "VectorcraftSaveFormatAccessory"]
|
||||
struct FormatAccessory;
|
||||
|
||||
// SAFETY: NSObjectProtocol has no additional requirements.
|
||||
unsafe impl NSObjectProtocol for FormatAccessory {}
|
||||
|
||||
impl FormatAccessory {
|
||||
// SAFETY: AppKit's target/action sends the popup as the single object argument.
|
||||
#[unsafe(method(chooseFormat:))]
|
||||
fn choose_format(&self, sender: &NSPopUpButton) {
|
||||
let Some(panel) = self.ivars().panel.load() else { return };
|
||||
let Some(index) = usize::try_from(sender.indexOfSelectedItem()).ok() else { return };
|
||||
let Some(extension) = self.ivars().filters.get(index).and_then(|(_, extensions)| extensions.first()) else { return };
|
||||
set_allowed_types(&panel, &self.ivars().filters, index);
|
||||
let name = panel.nameFieldStringValue().to_string();
|
||||
panel.setNameFieldStringValue(&NSString::from_str(&filename(&name, extension, &self.ivars().filters)));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/// The file types a Format menu lists: `filters` without "every file" ones. Empty when fewer than
|
||||
/// two remain, and the panel is then left to rfd.
|
||||
fn menu_formats(filters: &[Filter]) -> Vec<Filter> {
|
||||
let named: Vec<Filter> = filters.iter().copied().filter(|(_, extensions)| !extensions.is_empty() && !extensions.contains(&"*")).collect();
|
||||
if named.len() < 2 { Vec::new() } else { named }
|
||||
}
|
||||
|
||||
/// The menu entry of the suggested name's type, or the first (the request's default).
|
||||
fn initial_format(name: &str, filters: &[Filter]) -> usize {
|
||||
let extension = fileio::extension(name);
|
||||
filters.iter().position(|(_, extensions)| extensions.iter().any(|x| extension.eq_ignore_ascii_case(x))).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// `name` with `extension`: a format's extension (one in the menu, or any other the app reads or
|
||||
/// writes) is replaced, and other dots stay part of the name (a version number).
|
||||
fn filename(name: &str, extension: &str, filters: &[Filter]) -> String {
|
||||
if name.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let has = fileio::extension(name);
|
||||
let known =
|
||||
filters.iter().any(|(_, extensions)| extensions.iter().any(|x| has.eq_ignore_ascii_case(x))) || fileio::format_for_name(name).is_some();
|
||||
if known { std::path::Path::new(name).with_extension(extension).to_string_lossy().into_owned() } else { format!("{name}.{extension}") }
|
||||
}
|
||||
|
||||
/// The extensions a panel with menu `filters` allows, those of entry `selected` first. AppKit adds
|
||||
/// the first one to a name typed without an extension, and a name typed with another of them is
|
||||
/// saved in that format.
|
||||
fn allowed_types(filters: &[Filter], selected: usize) -> Vec<&'static str> {
|
||||
let first = filters.get(selected).map_or(&[][..], |(_, extensions)| *extensions);
|
||||
let others = filters.iter().enumerate().filter(|(i, _)| *i != selected).flat_map(|(_, (_, extensions))| extensions.iter());
|
||||
first.iter().chain(others).copied().collect()
|
||||
}
|
||||
|
||||
/// Set `panel`'s allowed file types ([`allowed_types`]).
|
||||
fn set_allowed_types(panel: &NSSavePanel, filters: &[Filter], selected: usize) {
|
||||
let types: Vec<Retained<NSString>> = allowed_types(filters, selected).into_iter().map(NSString::from_str).collect();
|
||||
// rfd sets the same deprecated property (`add_filters` in its `panel_ffi.rs`).
|
||||
#[allow(deprecated)]
|
||||
panel.setAllowedFileTypes(Some(&NSArray::from_retained_slice(&types)));
|
||||
}
|
||||
|
||||
/// Show the save panel for `pick` as a sheet on `window`, with a Format menu, and answer on the
|
||||
/// receiver with the path picked (none when canceled). `None` when the request has fewer than two
|
||||
/// named file types, or `window` has no AppKit window; nothing is shown then.
|
||||
pub fn begin(window: &winit::window::Window, pick: &FilePick) -> Option<Receiver<Vec<String>>> {
|
||||
let mtm = MainThreadMarker::new()?;
|
||||
let filters = menu_formats(&pick.filters);
|
||||
if filters.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let RawWindowHandle::AppKit(handle) = window.window_handle().ok()?.as_raw() else { return None };
|
||||
// SAFETY: the handle's view is the window's content view, which AppKit keeps alive while the
|
||||
// window is open, and winit hands out the handle only while it is.
|
||||
let view = unsafe { handle.ns_view.cast::<NSView>().as_ref() };
|
||||
let parent = view.window()?;
|
||||
let panel = NSSavePanel::savePanel(mtm);
|
||||
let selected = initial_format(&pick.name, &filters);
|
||||
set_allowed_types(&panel, &filters, selected);
|
||||
if let Some(folder) = &pick.folder {
|
||||
panel.setDirectoryURL(Some(&NSURL::fileURLWithPath_isDirectory(&NSString::from_str(folder), true)));
|
||||
}
|
||||
panel.setExtensionHidden(false);
|
||||
if !pick.name.is_empty() {
|
||||
panel.setNameFieldStringValue(&NSString::from_str(&pick.name));
|
||||
}
|
||||
let accessory = accessory(mtm, &panel, filters, selected);
|
||||
panel.setAccessoryView(Some(&accessory));
|
||||
let (tx, rx) = channel();
|
||||
let answered = panel.clone();
|
||||
let handler = RcBlock::new(move |response: NSModalResponse| {
|
||||
let picked = answered.URL().and_then(|url| url.path()).map(|p| p.to_string());
|
||||
let paths = if response == NSModalResponseOK { picked.into_iter().collect() } else { Vec::new() };
|
||||
// The app gone meanwhile has no use for the answer.
|
||||
let _ = tx.send(paths);
|
||||
});
|
||||
// The panel and its accessory are set up; AppKit keeps the handler (and the panel it holds)
|
||||
// until the sheet closes.
|
||||
panel.beginSheetModalForWindow_completionHandler(&parent, &handler);
|
||||
Some(rx)
|
||||
}
|
||||
|
||||
/// The "Format:" label and menu of `filters`, `selected` chosen, that rename `panel`'s file.
|
||||
fn accessory(mtm: MainThreadMarker, panel: &NSSavePanel, filters: Vec<Filter>, selected: usize) -> Retained<FormatAccessory> {
|
||||
let label = NSTextField::labelWithString(&NSString::from_str(i18n::t("Format:")), mtm);
|
||||
label.sizeToFit();
|
||||
let size = label.frame().size;
|
||||
label.setFrameOrigin(NSPoint::new(MARGIN, (HEIGHT - size.height) / 2.0));
|
||||
let x = MARGIN + size.width + GAP;
|
||||
let popup =
|
||||
NSPopUpButton::initWithFrame_pullsDown(NSPopUpButton::alloc(mtm), NSRect::new(NSPoint::new(x, (HEIGHT - MENU.height) / 2.0), MENU), false);
|
||||
for (name, _) in &filters {
|
||||
popup.addItemWithTitle(&NSString::from_str(i18n::tr_ctx(i18n::current(), "file-filter", name)));
|
||||
}
|
||||
// The filter list comes from the built-in format registry, small enough for NSInteger.
|
||||
if let Ok(index) = isize::try_from(selected) {
|
||||
popup.selectItemAtIndex(index);
|
||||
}
|
||||
let this = FormatAccessory::alloc(mtm).set_ivars(Ivars { panel: Weak::new(panel), filters });
|
||||
let frame = NSRect::new(NSPoint::ZERO, NSSize::new(x + MENU.width + MARGIN, HEIGHT));
|
||||
// SAFETY: NSView's designated initializer is called once on the allocated subclass.
|
||||
let accessory: Retained<FormatAccessory> = unsafe { msg_send![super(this), initWithFrame: frame] };
|
||||
// SAFETY: the accessory holds the popup as a subview and AppKit keeps the accessory while the
|
||||
// sheet is open. The selector has the target/action signature NSControl calls it with.
|
||||
unsafe {
|
||||
popup.setTarget(Some(&accessory));
|
||||
popup.setAction(Some(sel!(chooseFormat:)));
|
||||
}
|
||||
accessory.addSubview(&label);
|
||||
accessory.addSubview(&popup);
|
||||
accessory
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const FILTERS: &[Filter] = &[("VectorCraft", &["vectorcraft"]), ("SVG", &["svg"]), ("PDF", &["pdf"])];
|
||||
|
||||
/// Save, Save As and Save a Copy list every save format with the default first; a panel with one
|
||||
/// named type, or one for every file, gets no menu, and rfd shows it.
|
||||
#[test]
|
||||
fn save_panels_with_several_formats_get_the_menu() {
|
||||
let save = menu_formats(&fileio::save_filters("vectorcraft"));
|
||||
assert!(save.len() >= 2, "{save:?}");
|
||||
assert_eq!(save.first().map(|(_, e)| *e), Some(&["vectorcraft"][..]));
|
||||
assert!(menu_formats(&fileio::save_filters("svg")).first().is_some_and(|(_, e)| e.contains(&"svg")));
|
||||
assert!(menu_formats(&fileio::template_filters("template")).len() >= 2);
|
||||
assert!(menu_formats(&[("PDF", &["pdf"])]).is_empty());
|
||||
assert!(menu_formats(&[("PDF", &["pdf"]), ("All files", &["*"]), ("Any", &[])]).is_empty());
|
||||
assert_eq!(menu_formats(&[("PDF", &["pdf"]), ("All files", &["*"]), ("SVG", &["svg"])]).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_suggested_name_selects_its_format_and_an_unknown_one_the_default() {
|
||||
for (name, index) in [("Untitled-1", 0), ("a b.SVG", 1), ("art.pdf", 2), ("version.2", 0), ("", 0)] {
|
||||
assert_eq!(initial_format(name, FILTERS), index, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choosing_a_format_changes_the_extension_and_keeps_the_name() {
|
||||
for (name, expected) in [
|
||||
("図面 1.vectorcraft", "図面 1.svg"),
|
||||
("a.PDF", "a.svg"),
|
||||
("art", "art.svg"),
|
||||
("art.v2", "art.v2.svg"),
|
||||
("art.svg", "art.svg"),
|
||||
("old.drawcraft", "old.svg"),
|
||||
("photo.png", "photo.svg"),
|
||||
("", ""),
|
||||
] {
|
||||
assert_eq!(filename(name, "svg", FILTERS), expected, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A name typed without an extension gets the chosen format's: AppKit adds the first allowed
|
||||
/// type. The others stay allowed.
|
||||
#[test]
|
||||
fn the_chosen_format_is_the_first_allowed_type() {
|
||||
assert_eq!(allowed_types(FILTERS, 0), ["vectorcraft", "svg", "pdf"]);
|
||||
assert_eq!(allowed_types(FILTERS, 1), ["svg", "vectorcraft", "pdf"]);
|
||||
assert_eq!(allowed_types(FILTERS, 2), ["pdf", "vectorcraft", "svg"]);
|
||||
assert_eq!(allowed_types(FILTERS, 9), ["vectorcraft", "svg", "pdf"]);
|
||||
}
|
||||
|
||||
/// Each entry of the Save As menu gives a name the save command reads as that entry's format.
|
||||
#[test]
|
||||
fn each_menu_entry_names_a_file_the_save_command_writes_in_that_format() {
|
||||
let filters = menu_formats(&fileio::save_filters("vectorcraft"));
|
||||
for (label, extensions) in &filters {
|
||||
let name = filename("Untitled-1.vectorcraft", extensions[0], &filters);
|
||||
assert_eq!(fileio::format_for_name(&name).map(|f| f.label), Some(*label), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The menu's label is translated in every language with complete menus.
|
||||
#[test]
|
||||
fn the_label_is_translated() {
|
||||
for lang in i18n::Lang::all().filter(|l| l.complete_menus()) {
|
||||
assert!(i18n::has(lang, "Format:"), "{}", lang.code());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ mod clipboard;
|
||||
#[cfg(feature = "wgpu")]
|
||||
mod compositor_gpu;
|
||||
mod control_server;
|
||||
mod cursor_theme;
|
||||
#[cfg(feature = "wgpu")]
|
||||
mod gpu;
|
||||
mod logging;
|
||||
@@ -35,6 +36,8 @@ mod mac_fonts;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac_menu;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac_save_format;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac_screen_color;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac_window;
|
||||
@@ -473,7 +476,7 @@ fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Opti
|
||||
})
|
||||
.ok()?;
|
||||
make_folder(&request);
|
||||
// rfd builds the panel and begins the sheet here, on the main thread (as it must).
|
||||
// The panel is built and the sheet is begun below, on the main thread (as it must).
|
||||
let dialog = dialog_for(rfd::AsyncFileDialog::new().parent(window), &request);
|
||||
let path = |f: &rfd::FileHandle| f.path().to_string_lossy().to_string();
|
||||
let answer: SheetAnswer = match request {
|
||||
@@ -485,12 +488,18 @@ fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Opti
|
||||
let picked = dialog.pick_files();
|
||||
Box::pin(async move { picked.await.unwrap_or_default().iter().map(path).collect() })
|
||||
}
|
||||
PickRequest::Save(_) => {
|
||||
// Nothing is added to the sheet once rfd has begun it: an accessory view set then made
|
||||
// recent macOS versions dismiss the sheet at once, so nothing could be saved (#1800).
|
||||
let picked = dialog.save_file();
|
||||
Box::pin(async move { picked.await.iter().map(path).collect() })
|
||||
}
|
||||
// A save panel with two or more named file types is built by `mac_save_format`, which sets
|
||||
// up its Format menu before the sheet begins (#866); rfd shows the others.
|
||||
PickRequest::Save(pick) => match mac_save_format::begin(window, &pick) {
|
||||
// The dialog thread blocks here until the sheet answers.
|
||||
Some(picked) => Box::pin(async move { picked.recv().unwrap_or_default() }),
|
||||
None => {
|
||||
// Nothing is added to the sheet once rfd has begun it: an accessory view set then
|
||||
// made recent macOS versions dismiss the sheet at once (#1800).
|
||||
let picked = dialog.save_file();
|
||||
Box::pin(async move { picked.await.iter().map(path).collect() })
|
||||
}
|
||||
},
|
||||
PickRequest::Folder => {
|
||||
let picked = dialog.pick_folder();
|
||||
Box::pin(async move { picked.await.iter().map(path).collect() })
|
||||
@@ -517,8 +526,10 @@ fn services(parent: Parent, ui: egui::Context) -> Services {
|
||||
start_pick: cfg!(unix).then(|| Box::new(move |request: PickRequest| start_pick(request, &p5, &ui)) as vectorcraft_ui_egui::picks::StartPick),
|
||||
dialogs_are_sheets: cfg!(target_os = "macos"),
|
||||
wayland,
|
||||
no_cursor_theme: wayland && cursor_theme::theme_missing(),
|
||||
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
|
||||
write: Some(Box::new(write_file)),
|
||||
exists: Some(Box::new(|p: &str| std::path::Path::new(p).exists())),
|
||||
// Background Save and Export write from a worker thread.
|
||||
write_shared: Some(std::sync::Arc::new(write_file)),
|
||||
// Native saves stream into the file as they are encoded.
|
||||
@@ -646,6 +657,9 @@ Without a window (converting, scripting, the MCP server), use vectorcraft-cli:
|
||||
";
|
||||
|
||||
fn main() -> std::process::ExitCode {
|
||||
// Before any thread starts: the pointer must find a cursor theme on Wayland (#2450).
|
||||
#[cfg(target_os = "linux")]
|
||||
cursor_theme::apply();
|
||||
// Validate roots before logging, font scans, preferences or the window: a missing value
|
||||
// must not clear an environment root and start the control channel without confinement.
|
||||
let mut control = automation_args::ControlArg::default();
|
||||
@@ -841,8 +855,15 @@ fn main() -> std::process::ExitCode {
|
||||
"VectorCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
let mut app = VectorcraftApp::new(Session::new(), services(cc.winit_window().cloned(), cc.egui_ctx.clone()));
|
||||
let mut session = Session::new();
|
||||
// The profiles loaded before come back before the preferences name any of them.
|
||||
prefs_dir::attach_profile_store(&mut session);
|
||||
prefs_dir::attach_plugin_store(&mut session);
|
||||
let mut app = VectorcraftApp::new(session, services(cc.winit_window().cloned(), cc.egui_ctx.clone()));
|
||||
load_prefs(&mut app, saved);
|
||||
// The saved PDF presets live in their own folder (also read by vectorcraft-cli),
|
||||
// which takes over from the copy the preferences file used to hold.
|
||||
prefs_dir::attach_pdf_preset_store(&mut app.session);
|
||||
// Fit the window to its monitor, or put it back where it was (still hidden).
|
||||
if let Some(w) = cc.winit_window() {
|
||||
app.ui.window = Some(window::restore(w, saved_window));
|
||||
@@ -1023,9 +1044,9 @@ mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Finder offers the app for every file File › Open reads (#295, #354) but Photoshop documents
|
||||
/// (`UNASSOCIATED_EXTS`), takes over no other app's files, and hands them to the app rather than
|
||||
/// to AppKit's document machinery.
|
||||
/// Finder offers the app for every file File › Open reads (#295, #354) but those with an
|
||||
/// extension in `UNASSOCIATED_EXTS`, takes over no other app's files, and hands them to the app
|
||||
/// rather than to AppKit's document machinery.
|
||||
#[test]
|
||||
fn the_macos_bundle_opens_every_readable_format() {
|
||||
let plist = include_str!("../../../packaging/macos/Info.plist.in");
|
||||
|
||||
@@ -24,6 +24,39 @@ pub fn install_fonts() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The folder `name` next to the settings, for what the user keeps: none in a run without
|
||||
/// preferences (`VECTORCRAFT_NO_PREFS`), which keeps nothing.
|
||||
fn store_dir(name: &str) -> Option<PathBuf> {
|
||||
if std::env::var_os("VECTORCRAFT_NO_PREFS").is_some() {
|
||||
return None;
|
||||
}
|
||||
Some(prefs_path_for("VectorCraft", "vectorcraft")?.parent()?.join(name))
|
||||
}
|
||||
|
||||
/// Register the ICC profiles `color.loadProfile` kept in the `Color Profiles` folder (#2174), and
|
||||
/// keep the ones loaded from now on there. Before anything names a profile.
|
||||
pub fn attach_profile_store(session: &mut vectorcraft_engine::Session) {
|
||||
if let Some(dir) = store_dir("Color Profiles") {
|
||||
session.attach_profile_store(dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// Install the plug-ins Install Plug-in kept in the `Plug-ins` folder (#2541), and keep the ones
|
||||
/// installed from now on there.
|
||||
pub fn attach_plugin_store(session: &mut vectorcraft_engine::Session) {
|
||||
if let Some(dir) = store_dir("Plug-ins") {
|
||||
session.attach_plugin_store(dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// Take the saved PDF presets from the `PDF Presets` folder (#2168), and keep the ones saved from
|
||||
/// now on there. After the preferences were restored: the folder is the one place the presets live.
|
||||
pub fn attach_pdf_preset_store(session: &mut vectorcraft_engine::Session) {
|
||||
if let Some(dir) = store_dir("PDF Presets") {
|
||||
session.attach_pdf_preset_store(dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -43,11 +43,32 @@ mod system {
|
||||
let settings = craft_print::Job { printer: job.printer.map(str::to_string), title: job.title.into(), ..Default::default() };
|
||||
craft_print::submit(job.pdf, &settings).map(|note| if note.is_empty() { note } else { format!(" ({note})") }).map_err(|e| e.to_string())
|
||||
}
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn setup_command(printer: Option<&str>, default_printer: Option<&str>) -> Command {
|
||||
let target = printer.map(str::trim).filter(|p| !p.is_empty()).or_else(|| default_printer.map(str::trim).filter(|p| !p.is_empty()));
|
||||
if let Some(name) = target {
|
||||
let mut cmd = Command::new("rundll32.exe");
|
||||
cmd.args(["printui.dll,PrintUIEntry", "/e", "/n", name]);
|
||||
cmd
|
||||
} else {
|
||||
let mut cmd = Command::new("explorer");
|
||||
cmd.arg("ms-settings:printers");
|
||||
cmd
|
||||
}
|
||||
}
|
||||
pub fn setup(printer: Option<&str>) -> Result<(), String> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = printer;
|
||||
Command::new("explorer").arg("ms-settings:printers").spawn().map(|_| ()).map_err(|e| e.to_string())
|
||||
let list = printers();
|
||||
let default = list.iter().find(|p| p.default).or_else(|| list.first()).map(|p| p.name.as_str());
|
||||
// Only an installed printer's own name reaches the command line: `printer` can come
|
||||
// from a command or MCP, and PrintUIEntry reads options from its arguments.
|
||||
let printer = printer.map(str::trim).filter(|p| list.iter().any(|q| q.name == *p));
|
||||
let mut cmd = setup_command(printer, default);
|
||||
match cmd.spawn() {
|
||||
Ok(_) => Ok(()),
|
||||
Err(_) => Command::new("explorer").arg("ms-settings:printers").spawn().map(|_| ()).map_err(|e| e.to_string()),
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
@@ -68,3 +89,41 @@ mod system {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, windows, not(any(feature = "windows7", target_vendor = "win7"))))]
|
||||
mod tests {
|
||||
use super::system::setup_command;
|
||||
use std::ffi::OsStr;
|
||||
|
||||
#[test]
|
||||
fn setup_command_uses_printui_for_specified_printer() {
|
||||
let cmd = setup_command(Some("Office Laser"), None);
|
||||
assert_eq!(cmd.get_program(), "rundll32.exe");
|
||||
let args: Vec<&OsStr> = cmd.get_args().collect();
|
||||
assert_eq!(args, ["printui.dll,PrintUIEntry", "/e", "/n", "Office Laser"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setup_command_falls_back_to_default_printer() {
|
||||
let cmd = setup_command(None, Some("Default Proofer"));
|
||||
assert_eq!(cmd.get_program(), "rundll32.exe");
|
||||
let args: Vec<&OsStr> = cmd.get_args().collect();
|
||||
assert_eq!(args, ["printui.dll,PrintUIEntry", "/e", "/n", "Default Proofer"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setup_command_trims_whitespace_and_skips_empty() {
|
||||
let cmd = setup_command(Some(" "), Some("Default Proofer"));
|
||||
assert_eq!(cmd.get_program(), "rundll32.exe");
|
||||
let args: Vec<&OsStr> = cmd.get_args().collect();
|
||||
assert_eq!(args, ["printui.dll,PrintUIEntry", "/e", "/n", "Default Proofer"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setup_command_falls_back_to_settings_when_no_printer() {
|
||||
let cmd = setup_command(None, None);
|
||||
assert_eq!(cmd.get_program(), "explorer");
|
||||
let args: Vec<&OsStr> = cmd.get_args().collect();
|
||||
assert_eq!(args, ["ms-settings:printers"]);
|
||||
}
|
||||
}
|
||||
+338
-14
@@ -1,29 +1,184 @@
|
||||
//! Native profile paths. Isolated profiles never migrate preferences from a user's old profile.
|
||||
//!
|
||||
//! In order:
|
||||
//! 1. `VECTORCRAFT_CONFIG_DIR`, when set (tests, agents, custom setups).
|
||||
//! 2. **Portable mode** (#1366), as in PhotoCraft: a marker file ([`PORTABLE_MARKERS`]) in the
|
||||
//! install folder puts the settings in `VectorCraftData` beside it, so a portable copy leaves
|
||||
//! nothing in `%APPDATA%`. The install folder is the executable's; on macOS the one holding
|
||||
//! `VectorCraft.app` (nothing is written inside a signed bundle), for an AppImage the one holding
|
||||
//! the `.AppImage` file (`$APPIMAGE`; the executable runs from a read-only mount). The Windows
|
||||
//! portable zip ships with `portable.txt`; the MSI doesn't. The first time `VectorCraftData` is
|
||||
//! created, the settings in the user's profile are copied into it, so a portable zip that gains
|
||||
//! the marker keeps its users' settings. If the folder can't be written (read-only medium,
|
||||
//! Program Files), a warning is logged and the platform folder is used. A portable copy never
|
||||
//! reads the old DrawCraft profile, and a run without preferences (`VECTORCRAFT_NO_PREFS`) never
|
||||
//! looks for the marker.
|
||||
//! 3. The platform convention.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Files in the install folder that switch on portable mode (either one; contents are ignored).
|
||||
pub const PORTABLE_MARKERS: [&str; 2] = ["portable.txt", "VectorCraft.portable"];
|
||||
/// The settings folder created in the install folder in portable mode.
|
||||
pub const PORTABLE_DATA_DIR: &str = "VectorCraftData";
|
||||
|
||||
pub fn prefs_path(legacy: bool) -> Option<PathBuf> {
|
||||
prefs_path_with(std::env::consts::OS, legacy, |key| std::env::var_os(key))
|
||||
prefs_path_with(std::env::consts::OS, legacy, |key| std::env::var_os(key), portable_dir)
|
||||
}
|
||||
|
||||
fn prefs_path_with(os: &str, legacy: bool, env: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
|
||||
/// `portable` is the portable settings folder, if any: only asked for when no override is set and
|
||||
/// preferences are in use.
|
||||
fn prefs_path_with(os: &str, legacy: bool, env: impl Fn(&str) -> Option<OsString>, portable: impl FnOnce() -> Option<PathBuf>) -> 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"));
|
||||
}
|
||||
// A run without preferences reads and writes nothing, so it must not create the portable folder.
|
||||
if env("VECTORCRAFT_NO_PREFS").is_none()
|
||||
&& let Some(dir) = portable()
|
||||
{
|
||||
// A portable copy is self-contained: DrawCraft's old profile is not read into it.
|
||||
return (!legacy).then(|| dir.join("ui.json"));
|
||||
}
|
||||
platform_dir(os, legacy, &env).map(|p| p.join("ui.json"))
|
||||
}
|
||||
|
||||
/// The platform's settings folder of VectorCraft, or of DrawCraft (`legacy`).
|
||||
fn platform_dir(os: &str, legacy: bool, env: &impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
|
||||
let (name, lower) = if legacy { ("DrawCraft", "drawcraft") } else { ("VectorCraft", "vectorcraft") };
|
||||
let base = match os {
|
||||
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)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The portable settings folder of this copy, looked for once per process (it probes the disk, and
|
||||
/// the settings path is asked for many times).
|
||||
fn portable_dir() -> Option<PathBuf> {
|
||||
static DIR: OnceLock<Option<PathBuf>> = OnceLock::new();
|
||||
DIR.get_or_init(|| {
|
||||
let os = std::env::consts::OS;
|
||||
let env = |key: &str| std::env::var_os(key);
|
||||
let install = install_dir(os, &std::env::current_exe().ok()?, &env)?;
|
||||
portable_dir_in(&install, platform_dir(os, false, &env).as_deref()).unwrap_or_else(|warning| {
|
||||
// Only the log: `eprintln!` panics when standard error is a closed pipe, and the logger
|
||||
// keeps early records for the log file.
|
||||
log::warn!("{warning}");
|
||||
None
|
||||
})
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// The folder the user sees the app in, where the portable marker is looked for: the executable's;
|
||||
/// on macOS the one holding `VectorCraft.app` when the executable sits in its `Contents/MacOS` (a
|
||||
/// signed bundle is never written into); on Linux the one holding the `.AppImage` file when
|
||||
/// `$APPIMAGE` names it (the executable itself runs from a read-only mount).
|
||||
fn install_dir(os: &str, exe: &Path, env: &impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
|
||||
let exe_dir = exe.parent()?;
|
||||
let dir = match os {
|
||||
"macos" => app_bundle_parent(exe_dir).unwrap_or(exe_dir).to_path_buf(),
|
||||
"windows" => exe_dir.to_path_buf(),
|
||||
_ => {
|
||||
let appimage = env("APPIMAGE").map(PathBuf::from);
|
||||
let beside = appimage.as_deref().and_then(Path::parent).filter(|p| !p.as_os_str().is_empty());
|
||||
beside.unwrap_or(exe_dir).to_path_buf()
|
||||
}
|
||||
};
|
||||
base.map(|p| p.join("ui.json"))
|
||||
Some(dir)
|
||||
}
|
||||
|
||||
/// The folder holding `X.app` when `exe_dir` is `X.app/Contents/MacOS`.
|
||||
fn app_bundle_parent(exe_dir: &Path) -> Option<&Path> {
|
||||
let contents = exe_dir.parent().filter(|_| exe_dir.file_name() == Some("MacOS".as_ref()))?;
|
||||
let bundle = contents.parent().filter(|_| contents.file_name() == Some("Contents".as_ref()))?;
|
||||
bundle.extension().filter(|ext| *ext == "app").and(bundle.parent())
|
||||
}
|
||||
|
||||
/// `<install_dir>/VectorCraftData` when a portable marker sits in `install_dir`, created if missing;
|
||||
/// `None` without a marker, and the warning to log when there is one but that folder can't be
|
||||
/// written. The first time the folder is created, the settings in `previous` (the user's profile
|
||||
/// folder, when it exists) are copied into it, so a portable zip that gains the marker keeps its
|
||||
/// users' settings. `previous` is never changed; a copy that fails halfway is logged and left as it
|
||||
/// is, and a later start finds the folder and copies nothing.
|
||||
fn portable_dir_in(install_dir: &Path, previous: Option<&Path>) -> Result<Option<PathBuf>, String> {
|
||||
let Some(marker) = PORTABLE_MARKERS.iter().map(|m| install_dir.join(m)).find(|p| p.is_file()) else { return Ok(None) };
|
||||
let data = install_dir.join(PORTABLE_DATA_DIR);
|
||||
let first_start = !data.exists();
|
||||
if let Err(e) = ensure_writable(&data) {
|
||||
return Err(format!(
|
||||
"portable mode ({} found) but {} isn't writable ({e}); settings are kept in the user's settings folder instead",
|
||||
marker.display(),
|
||||
data.display()
|
||||
));
|
||||
}
|
||||
if first_start && let Some(previous) = previous.filter(|p| p.is_dir()) {
|
||||
match copy_dir(previous, &data) {
|
||||
Ok(()) => log::info!("portable mode: copied the settings from {} to {}", previous.display(), data.display()),
|
||||
Err(e) => log::warn!("portable mode: the settings in {} were only partly copied to {}: {e}", previous.display(), data.display()),
|
||||
}
|
||||
}
|
||||
Ok(Some(data))
|
||||
}
|
||||
|
||||
/// Create `dir` and prove a file can be written in it (a folder can exist yet be read-only).
|
||||
fn ensure_writable(dir: &Path) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let probe = dir.join(format!(".write-test-{}", std::process::id()));
|
||||
std::fs::write(&probe, b"")?;
|
||||
// A leftover probe is harmless; don't give up portable mode over it.
|
||||
let _ = std::fs::remove_file(&probe);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Copy the contents of `from` into `to` (which exists), going on past entries that can't be
|
||||
/// copied; the error says how many were skipped and why the first one was.
|
||||
fn copy_dir(from: &Path, to: &Path) -> Result<(), String> {
|
||||
let mut skipped = 0usize;
|
||||
let mut first: Option<String> = None;
|
||||
copy_dir_into(from, to, &mut |why| {
|
||||
skipped += 1;
|
||||
first.get_or_insert(why);
|
||||
});
|
||||
match first {
|
||||
None => Ok(()),
|
||||
Some(why) => Err(format!("{skipped} skipped, the first because {why}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_dir_into(from: &Path, to: &Path, skip: &mut impl FnMut(String)) {
|
||||
let entries = match std::fs::read_dir(from) {
|
||||
Ok(entries) => entries,
|
||||
Err(e) => return skip(format!("{}: {e}", from.display())),
|
||||
};
|
||||
for entry in entries {
|
||||
let entry = match entry {
|
||||
Ok(entry) => entry,
|
||||
Err(e) => {
|
||||
skip(format!("{}: {e}", from.display()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let (src, dst) = (entry.path(), to.join(entry.file_name()));
|
||||
// A symbolic link is copied as what it points to, or skipped when that is a folder.
|
||||
let copied = match entry.file_type() {
|
||||
Ok(kind) if kind.is_dir() => std::fs::create_dir_all(&dst).map(|()| copy_dir_into(&src, &dst, skip)),
|
||||
Ok(_) => std::fs::copy(&src, &dst).map(|_| ()),
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
if let Err(e) = copied {
|
||||
skip(format!("{}: {e}", src.display()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
fn environment(key: &str) -> Option<OsString> {
|
||||
match key {
|
||||
@@ -34,33 +189,202 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// No portable marker in the install folder.
|
||||
fn installed() -> Option<PathBuf> {
|
||||
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);
|
||||
assert_eq!(prefs_path_with(os, false, env, installed), Some("/isolated/ui.json".into()));
|
||||
assert_eq!(prefs_path_with(os, true, env, installed), 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);
|
||||
assert_eq!(prefs_path_with("macos", false, env, installed), None);
|
||||
assert_eq!(prefs_path_with("macos", true, env, installed), 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("macos", false, environment, installed), Some("/user/Library/Application Support/VectorCraft/ui.json".into()));
|
||||
assert_eq!(prefs_path_with("macos", true, environment, installed), Some("/user/Library/Application Support/DrawCraft/ui.json".into()));
|
||||
assert_eq!(prefs_path_with("windows", false, environment, installed), Some("/roaming/VectorCraft/ui.json".into()));
|
||||
assert_eq!(prefs_path_with("linux", false, environment, installed), Some("/xdg-config/vectorcraft/ui.json".into()));
|
||||
assert_eq!(
|
||||
prefs_path_with("linux", true, |key| if key == "HOME" { environment(key) } else { None }),
|
||||
prefs_path_with("linux", true, |key| if key == "HOME" { environment(key) } else { None }, installed),
|
||||
Some("/user/.config/drawcraft/ui.json".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portable_mode_keeps_the_settings_beside_the_executable_and_skips_the_drawcraft_profile() {
|
||||
let portable = || Some(PathBuf::from("/usb/VectorCraft/VectorCraftData"));
|
||||
for os in ["macos", "windows", "linux"] {
|
||||
assert_eq!(prefs_path_with(os, false, environment, portable), Some("/usb/VectorCraft/VectorCraftData/ui.json".into()), "{os}");
|
||||
// A portable copy is self-contained: the one-time read of DrawCraft's old settings is skipped.
|
||||
assert_eq!(prefs_path_with(os, true, environment, portable), None, "{os}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_config_dir_override_wins_over_portable_mode_without_probing_it() {
|
||||
let env = |key: &str| if key == "VECTORCRAFT_CONFIG_DIR" { Some("/isolated".into()) } else { environment(key) };
|
||||
let portable = || -> Option<PathBuf> { panic!("the portable folder is not looked for under VECTORCRAFT_CONFIG_DIR") };
|
||||
assert_eq!(prefs_path_with("windows", false, env, portable), Some("/isolated/ui.json".into()));
|
||||
assert_eq!(prefs_path_with("windows", true, env, portable), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_without_preferences_never_looks_for_the_portable_folder() {
|
||||
let env = |key: &str| if key == "VECTORCRAFT_NO_PREFS" { Some("1".into()) } else { environment(key) };
|
||||
let portable = || -> Option<PathBuf> { panic!("the portable folder is not looked for under VECTORCRAFT_NO_PREFS") };
|
||||
assert_eq!(prefs_path_with("windows", false, env, portable), Some("/roaming/VectorCraft/ui.json".into()));
|
||||
assert_eq!(prefs_path_with("windows", true, env, portable), Some("/roaming/DrawCraft/ui.json".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn on_macos_the_marker_is_looked_for_beside_the_app_bundle() {
|
||||
let bundled = Path::new("/Apps/VectorCraft.app/Contents/MacOS/vectorcraft");
|
||||
assert_eq!(install_dir("macos", bundled, &environment), Some("/Apps".into()));
|
||||
// A bare executable (the CLI zip) and other platforms: the executable's own folder.
|
||||
assert_eq!(install_dir("macos", Path::new("/Apps/vectorcraft-cli"), &environment), Some("/Apps".into()));
|
||||
assert_eq!(install_dir("windows", bundled, &environment), Some("/Apps/VectorCraft.app/Contents/MacOS".into()));
|
||||
assert_eq!(install_dir("linux", bundled, &environment), Some("/Apps/VectorCraft.app/Contents/MacOS".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_appimage_looks_for_the_marker_beside_the_appimage_file() {
|
||||
let mounted = Path::new("/tmp/.mount_vc/usr/bin/vectorcraft");
|
||||
let appimage = |key: &str| if key == "APPIMAGE" { Some("/home/user/Apps/VectorCraft.AppImage".into()) } else { environment(key) };
|
||||
assert_eq!(install_dir("linux", mounted, &appimage), Some("/home/user/Apps".into()));
|
||||
assert_eq!(install_dir("linux", mounted, &environment), Some("/tmp/.mount_vc/usr/bin".into()));
|
||||
let empty = |key: &str| if key == "APPIMAGE" { Some(OsString::new()) } else { environment(key) };
|
||||
assert_eq!(install_dir("linux", mounted, &empty), Some("/tmp/.mount_vc/usr/bin".into()));
|
||||
// The variable means nothing on other platforms.
|
||||
assert_eq!(install_dir("windows", mounted, &appimage), Some("/tmp/.mount_vc/usr/bin".into()));
|
||||
assert_eq!(install_dir("macos", mounted, &appimage), Some("/tmp/.mount_vc/usr/bin".into()));
|
||||
}
|
||||
|
||||
fn temp(tag: &str) -> PathBuf {
|
||||
static N: AtomicUsize = AtomicUsize::new(0);
|
||||
let d = std::env::temp_dir().join(format!("vectorcraft-profile-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn either_marker_beside_the_executable_makes_a_writable_data_folder() {
|
||||
for marker in PORTABLE_MARKERS {
|
||||
let exe = temp("portable");
|
||||
std::fs::write(exe.join(marker), b"").unwrap();
|
||||
let dir = portable_dir_in(&exe, None).unwrap().unwrap();
|
||||
assert_eq!(dir, exe.join(PORTABLE_DATA_DIR), "{marker}");
|
||||
assert!(dir.is_dir(), "the data folder is created");
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "the write probe is removed");
|
||||
let _ = std::fs::remove_dir_all(&exe);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fake_app_bundle_keeps_the_marker_and_the_data_outside_the_bundle() {
|
||||
let folder = temp("bundle");
|
||||
let exe = folder.join("VectorCraft.app/Contents/MacOS/vectorcraft");
|
||||
std::fs::create_dir_all(exe.parent().unwrap()).unwrap();
|
||||
std::fs::write(folder.join("portable.txt"), b"").unwrap();
|
||||
let install = install_dir("macos", &exe, &environment).unwrap();
|
||||
assert_eq!(portable_dir_in(&install, None), Ok(Some(folder.join(PORTABLE_DATA_DIR))));
|
||||
assert!(!exe.parent().unwrap().join(PORTABLE_DATA_DIR).exists(), "nothing is written inside the bundle");
|
||||
// A marker dropped inside the bundle is not looked at.
|
||||
std::fs::remove_file(folder.join("portable.txt")).unwrap();
|
||||
std::fs::write(exe.parent().unwrap().join("portable.txt"), b"").unwrap();
|
||||
let _ = std::fs::remove_dir_all(folder.join(PORTABLE_DATA_DIR));
|
||||
assert_eq!(portable_dir_in(&install, None), Ok(None));
|
||||
let _ = std::fs::remove_dir_all(&folder);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_marker_file_there_is_no_portable_folder() {
|
||||
let exe = temp("installed");
|
||||
assert_eq!(portable_dir_in(&exe, None), Ok(None));
|
||||
// A folder named like the marker is not a marker.
|
||||
std::fs::create_dir_all(exe.join("portable.txt")).unwrap();
|
||||
assert_eq!(portable_dir_in(&exe, None), Ok(None));
|
||||
assert!(!exe.join(PORTABLE_DATA_DIR).exists(), "no data folder without a marker");
|
||||
let _ = std::fs::remove_dir_all(&exe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unwritable_portable_folder_is_reported_instead_of_used() {
|
||||
let exe = temp("unwritable");
|
||||
std::fs::write(exe.join("portable.txt"), b"").unwrap();
|
||||
// A file where the data folder should go: it can't be created, on every platform.
|
||||
std::fs::write(exe.join(PORTABLE_DATA_DIR), b"in the way").unwrap();
|
||||
let warning = portable_dir_in(&exe, None).unwrap_err();
|
||||
assert!(warning.contains("portable") && warning.contains(PORTABLE_DATA_DIR), "{warning}");
|
||||
let _ = std::fs::remove_dir_all(&exe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_start_copies_the_user_profile_into_the_data_folder_once() {
|
||||
let exe = temp("upgrade");
|
||||
std::fs::write(exe.join("portable.txt"), b"").unwrap();
|
||||
let previous = temp("previous-profile");
|
||||
std::fs::write(previous.join("ui.json"), b"{\"theme\":\"dark\"}").unwrap();
|
||||
std::fs::create_dir_all(previous.join("Swatches/Mine")).unwrap();
|
||||
std::fs::write(previous.join("Swatches/Mine/warm.ase"), b"ase").unwrap();
|
||||
|
||||
let data = portable_dir_in(&exe, Some(&previous)).unwrap().unwrap();
|
||||
assert_eq!(data, exe.join(PORTABLE_DATA_DIR));
|
||||
assert_eq!(std::fs::read(data.join("ui.json")).unwrap(), b"{\"theme\":\"dark\"}", "the settings file is copied");
|
||||
assert_eq!(std::fs::read(data.join("Swatches/Mine/warm.ase")).unwrap(), b"ase", "nested folders are copied");
|
||||
assert_eq!(std::fs::read(previous.join("ui.json")).unwrap(), b"{\"theme\":\"dark\"}", "the source is left as it is");
|
||||
assert!(previous.join("Swatches/Mine/warm.ase").is_file(), "the source is left as it is");
|
||||
|
||||
// A second start finds the folder and copies nothing, whatever changed on either side.
|
||||
std::fs::write(data.join("ui.json"), b"{\"theme\":\"light\"}").unwrap();
|
||||
std::fs::write(previous.join("ui.json"), b"{\"theme\":\"newer\"}").unwrap();
|
||||
std::fs::write(previous.join("later.txt"), b"").unwrap();
|
||||
assert_eq!(portable_dir_in(&exe, Some(&previous)), Ok(Some(data.clone())));
|
||||
assert_eq!(std::fs::read(data.join("ui.json")).unwrap(), b"{\"theme\":\"light\"}", "the portable settings are not overwritten");
|
||||
assert!(!data.join("later.txt").exists(), "nothing is copied after the first start");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&exe);
|
||||
let _ = std::fs::remove_dir_all(&previous);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_user_profile_the_data_folder_starts_empty() {
|
||||
let exe = temp("fresh");
|
||||
std::fs::write(exe.join("portable.txt"), b"").unwrap();
|
||||
let missing = exe.join("no-such-profile");
|
||||
let data = portable_dir_in(&exe, Some(&missing)).unwrap().unwrap();
|
||||
assert_eq!(std::fs::read_dir(&data).unwrap().count(), 0);
|
||||
let _ = std::fs::remove_dir_all(&exe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copy_that_fails_halfway_reports_what_it_skipped_and_keeps_the_rest() {
|
||||
let from = temp("copy-from");
|
||||
let to = temp("copy-to");
|
||||
std::fs::write(from.join("kept.txt"), b"kept").unwrap();
|
||||
std::fs::create_dir_all(from.join("blocked")).unwrap();
|
||||
std::fs::write(from.join("blocked/inner.txt"), b"").unwrap();
|
||||
// A file where the sub-folder should go: that entry can't be copied, the others can.
|
||||
std::fs::write(to.join("blocked"), b"in the way").unwrap();
|
||||
let error = copy_dir(&from, &to).unwrap_err();
|
||||
assert!(error.starts_with("1 skipped"), "{error}");
|
||||
assert!(error.contains("blocked"), "{error}");
|
||||
assert_eq!(std::fs::read(to.join("kept.txt")).unwrap(), b"kept");
|
||||
let _ = std::fs::remove_dir_all(&from);
|
||||
let _ = std::fs::remove_dir_all(&to);
|
||||
}
|
||||
}
|
||||
@@ -115,3 +115,14 @@ sync_github_name = false
|
||||
real_name = "Kamil Kwiatkowski"
|
||||
display_name = "Kamil Kwiatkowski"
|
||||
sync_github_name = false
|
||||
|
||||
[people.marcoarnulfo]
|
||||
real_name = "Marco Arnulfo"
|
||||
display_name = "Marco Arnulfo"
|
||||
sync_github_name = false
|
||||
|
||||
[people.theromis]
|
||||
sync_github_name = true
|
||||
|
||||
[people.dennismutuku2005]
|
||||
sync_github_name = true
|
||||
@@ -965,6 +965,41 @@ fn drawings_the_export_writes_read_back() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_control_point_splines_stay_closed_through_export_and_import() {
|
||||
// (explicitly closed, ends where it starts): coincident endpoints alone do not close a path.
|
||||
let cases = [(true, true), (true, false), (false, true), (false, false)];
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let layer = doc.layers[0].id;
|
||||
for (i, &(closed, returns)) in cases.iter().enumerate() {
|
||||
let mut b = kurbo::BezPath::new();
|
||||
b.move_to((20.0, 20.0));
|
||||
b.curve_to((80.0, 0.0), (80.0, 80.0), (20.0, 80.0));
|
||||
if returns {
|
||||
b.curve_to((0.0, 80.0), (0.0, 20.0), (20.0, 20.0));
|
||||
}
|
||||
if closed {
|
||||
b.close_path();
|
||||
}
|
||||
let id = doc.alloc_id();
|
||||
let black = Appearance::basic(Paint::None, Paint::solid(Color::rgb8(0, 0, 0)), 1.0);
|
||||
doc.insert(Some(layer), i, Node::path(id, vectorcraft_geom::PathData::from_bezpath(&b), black)).unwrap();
|
||||
}
|
||||
for version in crate::DxfVersion::ALL.into_iter().filter(|v| *v >= crate::DxfVersion::R13) {
|
||||
let opts = crate::DxfOptions { version, unit: Unit::Points, region: doc.artboards[0].rect, ..crate::DxfOptions::default() };
|
||||
let out = crate::export(&doc, &opts).unwrap();
|
||||
let text = std::str::from_utf8(&out.bytes).unwrap();
|
||||
assert_eq!(text.matches("\nSPLINE\n").count(), cases.len(), "{version:?}: curves are written as SPLINE entities");
|
||||
let back = leaves(&open(&out.bytes, &points()).document);
|
||||
assert_eq!(back.len(), cases.len(), "{version:?}");
|
||||
for (n, &(closed, returns)) in back.iter().zip(&cases) {
|
||||
let path = n.path_data().unwrap();
|
||||
assert_eq!(path.subpaths.len(), 1, "{version:?}");
|
||||
assert_eq!(path.subpaths[0].closed, closed, "{version:?}: closed {closed}, returns {returns}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_lists_layers_layouts_and_version() {
|
||||
let tables = [(0, "TABLE"), (2, "LAYER"), (0, "LAYER"), (2, "0"), (62, "7"), (0, "LAYER"), (2, "Walls"), (62, "1"), (0, "ENDTAB")];
|
||||
|
||||
@@ -171,9 +171,25 @@ impl GenericCmyk {
|
||||
xyz_to_lab(self.to_xyz(cmyk))
|
||||
}
|
||||
|
||||
/// Display sRGB: media-relative (paper shows as white) or absolute (simulate paper colour).
|
||||
pub fn to_srgb(&self, cmyk: [f32; 4], absolute: bool) -> [f32; 3] {
|
||||
xyz_to_srgb(if absolute { self.to_xyz(cmyk) } else { self.to_xyz_rel(cmyk) })
|
||||
/// Display XYZ: media-relative (paper shows as white) or absolute (simulate paper colour).
|
||||
/// With `bpc` (media-relative only) the press black displays as black, undoing the
|
||||
/// compensation [`Self::gamut_map`] applies on the way in, so sRGB black separated to the
|
||||
/// darkest rich black shows as black again.
|
||||
pub fn display_xyz(&self, cmyk: [f32; 4], absolute: bool, bpc: bool) -> [f32; 3] {
|
||||
if absolute {
|
||||
return self.to_xyz(cmyk);
|
||||
}
|
||||
let x = self.to_xyz_rel(cmyk);
|
||||
if !bpc {
|
||||
return x;
|
||||
}
|
||||
let bp = self.black_rel;
|
||||
[0, 1, 2].map(|i| ((x[i] - bp[i]) / (1.0 - bp[i] / D50[i])).max(0.0))
|
||||
}
|
||||
|
||||
/// Display sRGB, see [`Self::display_xyz`].
|
||||
pub fn to_srgb(&self, cmyk: [f32; 4], absolute: bool, bpc: bool) -> [f32; 3] {
|
||||
xyz_to_srgb(self.display_xyz(cmyk, absolute, bpc))
|
||||
}
|
||||
|
||||
/// Lab of the darkest printable black (media-relative).
|
||||
@@ -425,11 +441,12 @@ impl GenericCmyk {
|
||||
self.from_lab(srgb_to_lab(rgb), intent, bpc)
|
||||
}
|
||||
|
||||
/// ΔE2000 between an sRGB colour and its best (relative colorimetric) reproduction.
|
||||
pub fn gamut_error(&self, src: Lab) -> f32 {
|
||||
let target = self.gamut_map(src, Intent::RelativeColorimetric, false);
|
||||
/// ΔE2000 between a colour and its best (relative colorimetric) reproduction as it displays
|
||||
/// (with `bpc`, the press black showing as black).
|
||||
pub fn gamut_error(&self, src: Lab, bpc: bool) -> f32 {
|
||||
let target = self.gamut_map(src, Intent::RelativeColorimetric, bpc);
|
||||
let cmyk = self.separate(target);
|
||||
delta_e2000(self.to_lab_rel(cmyk), target)
|
||||
delta_e2000(xyz_to_lab(self.display_xyz(cmyk, false, bpc)), src)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+35
-10
@@ -257,9 +257,25 @@ pub fn register_icc(bytes: &[u8], name: Option<String>) -> Result<ProfileInfo, C
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// A number that changes whenever [`register_icc`] registers a profile: conversions through a
|
||||
/// profile looked up by name, such as those of a [`ProofTarget::Cmyk`] proof, give the same colors
|
||||
/// as long as the number stays the same.
|
||||
/// Forget a profile [`register_icc`] registered (by its name); `false` when there is none of that
|
||||
/// name (the built-in profiles can't be removed). Settings that name it no longer resolve, so a
|
||||
/// caller checks [`active_settings`] first.
|
||||
pub fn unregister_icc(name: &str) -> bool {
|
||||
let mut u = USER.write().unwrap_or_else(|e| e.into_inner());
|
||||
let before = u.len();
|
||||
u.retain(|x| x.name != name);
|
||||
let removed = u.len() != before;
|
||||
drop(u);
|
||||
if removed {
|
||||
clear_proof_cache();
|
||||
PROFILES_GENERATION.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
/// A number that changes whenever [`register_icc`] registers a profile or [`unregister_icc`]
|
||||
/// removes one: conversions through a profile looked up by name, such as those of a
|
||||
/// [`ProofTarget::Cmyk`] proof, give the same colors as long as the number stays the same.
|
||||
pub fn profiles_generation() -> u64 {
|
||||
PROFILES_GENERATION.load(Ordering::Relaxed)
|
||||
}
|
||||
@@ -346,10 +362,10 @@ impl Cms {
|
||||
}
|
||||
}
|
||||
|
||||
fn cmyk_to_srgb_in(space: &CmykSpace, cmyk: [f32; 4], absolute: bool) -> [f32; 3] {
|
||||
fn cmyk_to_srgb_in(space: &CmykSpace, cmyk: [f32; 4], absolute: bool, bpc: bool) -> [f32; 3] {
|
||||
let cmyk = cmyk.map(|v| v.clamp(0.0, 1.0));
|
||||
match space {
|
||||
CmykSpace::Generic(g) => g.to_srgb(cmyk, absolute),
|
||||
CmykSpace::Generic(g) => g.to_srgb(cmyk, absolute, bpc),
|
||||
CmykSpace::Device => naive_cmyk_to_rgb(cmyk),
|
||||
CmykSpace::Icc(p) => {
|
||||
let i = if absolute { Intent::AbsoluteColorimetric } else { Intent::RelativeColorimetric };
|
||||
@@ -376,7 +392,7 @@ impl Cms {
|
||||
{
|
||||
return v;
|
||||
}
|
||||
Self::cmyk_to_srgb_in(&self.cmyk, cmyk, absolute)
|
||||
Self::cmyk_to_srgb_in(&self.cmyk, cmyk, absolute, self.settings.bpc)
|
||||
}
|
||||
/// CMYK in the working space → working RGB with `intent`. An ICC CMYK profile converts
|
||||
/// directly into the RGB profile: not through display sRGB, which would clip colours a wider
|
||||
@@ -408,6 +424,15 @@ impl Cms {
|
||||
_ => lab::srgb_to_lab(self.cmyk_to_srgb(cmyk, false)),
|
||||
}
|
||||
}
|
||||
/// Lab of working-space CMYK as it displays: [`Self::cmyk_to_lab`] plus, for the built-in
|
||||
/// Generic CMYK, the black-point compensation of [`Self::cmyk_to_srgb`] (its press black shows
|
||||
/// as black). Unclipped, for interpolating tables.
|
||||
pub fn cmyk_display_lab(&self, cmyk: [f32; 4]) -> Lab {
|
||||
match &self.cmyk {
|
||||
CmykSpace::Generic(g) => lab::xyz_to_lab(g.display_xyz(cmyk.map(|v| v.clamp(0.0, 1.0)), false, self.settings.bpc)),
|
||||
_ => self.cmyk_to_lab(cmyk),
|
||||
}
|
||||
}
|
||||
/// Lab → working CMYK with `intent`.
|
||||
pub fn lab_to_cmyk(&self, l: Lab, intent: Intent) -> [f32; 4] {
|
||||
match &self.cmyk {
|
||||
@@ -429,7 +454,7 @@ impl Cms {
|
||||
/// CIE Lab (D50) of any colour.
|
||||
pub fn lab(&self, c: &Color) -> Lab {
|
||||
match *c {
|
||||
Color::Cmyk { c, m, y, k } => self.cmyk_to_lab([c, m, y, k]),
|
||||
Color::Cmyk { c, m, y, k } => self.cmyk_display_lab([c, m, y, k]),
|
||||
Color::Lab { l, a, b } => Lab::new(l, a, b),
|
||||
_ => lab::srgb_to_lab(self.display_rgb(c)),
|
||||
}
|
||||
@@ -490,7 +515,7 @@ impl Cms {
|
||||
Color::Rgb { .. } | Color::Lab { .. } => {
|
||||
let src = self.lab(c);
|
||||
match &self.cmyk {
|
||||
CmykSpace::Generic(g) => g.gamut_error(src),
|
||||
CmykSpace::Generic(g) => g.gamut_error(src, self.settings.bpc),
|
||||
CmykSpace::Device => 0.0,
|
||||
CmykSpace::Icc(_) => {
|
||||
let back = self.cmyk_to_srgb(self.to_cmyk(c, Intent::RelativeColorimetric), false);
|
||||
@@ -523,7 +548,7 @@ impl Cms {
|
||||
ProofTarget::Cmyk(name) => match cmyk_space(name) {
|
||||
Ok(sp) => {
|
||||
let cmyk = Self::srgb_to_cmyk_in(&sp, srgb, proof.intent, self.settings.bpc);
|
||||
Self::cmyk_to_srgb_in(&sp, cmyk, proof.simulate_paper)
|
||||
Self::cmyk_to_srgb_in(&sp, cmyk, proof.simulate_paper, self.settings.bpc)
|
||||
}
|
||||
Err(_) => srgb,
|
||||
},
|
||||
@@ -534,7 +559,7 @@ impl Cms {
|
||||
pub fn proof_cmyk_to_srgb(&self, cmyk: [f32; 4], proof: &ProofSetup) -> [f32; 3] {
|
||||
match &proof.target {
|
||||
ProofTarget::Cmyk(name) => match cmyk_space(name) {
|
||||
Ok(sp) => Self::cmyk_to_srgb_in(&sp, cmyk, proof.simulate_paper),
|
||||
Ok(sp) => Self::cmyk_to_srgb_in(&sp, cmyk, proof.simulate_paper, self.settings.bpc),
|
||||
Err(_) => self.cmyk_to_srgb(cmyk, proof.simulate_paper),
|
||||
},
|
||||
_ => self.cmyk_to_srgb(cmyk, proof.simulate_paper),
|
||||
|
||||
@@ -245,6 +245,20 @@ fn loads_system_cmyk_icc() {
|
||||
assert_eq!(c.cmyk_to_srgb([0.0; 4], false).map(|v| (v * 255.0).round() as u8), [255, 255, 255]);
|
||||
}
|
||||
|
||||
/// With black-point compensation, sRGB black separates to the press's darkest black and that black
|
||||
/// displays as black again (hex 000000 -> C67 M67 Y67 K100 -> 000000, not a dark grey).
|
||||
#[test]
|
||||
fn generic_cmyk_black_round_trips_with_bpc() {
|
||||
let c = cms();
|
||||
let cmyk = c.srgb_to_cmyk([0.0; 3], Intent::RelativeColorimetric);
|
||||
let back = c.cmyk_to_srgb(cmyk, false).map(|v| (v * 255.0).round() as u8);
|
||||
assert_eq!(back, [0, 0, 0], "{cmyk:?}");
|
||||
// The paper still shows as white, and without compensation the press black stays a dark grey.
|
||||
assert_eq!(c.cmyk_to_srgb([0.0; 4], false), [1.0, 1.0, 1.0]);
|
||||
let raw = cms_no_bpc().cmyk_to_srgb(cmyk, false);
|
||||
assert!(raw.iter().all(|v| *v > 0.05), "{raw:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lab_colours_round_trip_through_the_cms() {
|
||||
let c = cms_no_bpc();
|
||||
|
||||
@@ -22,7 +22,8 @@ fn cmyk_profiles_convert_like_the_cms() {
|
||||
assert_eq!(parse(&bytes).color_space, moxcms::DataColorSpace::Cmyk);
|
||||
let p = IccProfile::from_bytes(None, &bytes).unwrap();
|
||||
assert_eq!((p.name.as_str(), p.kind), (name, ProfileKind::Cmyk));
|
||||
let cms = Cms::new(&ColorSettings { cmyk: name.into(), ..Default::default() }).unwrap();
|
||||
// The encoded profile converts without black-point compensation, so compare without it.
|
||||
let cms = Cms::new(&ColorSettings { cmyk: name.into(), bpc: false, ..Default::default() }).unwrap();
|
||||
for cmyk in [[0.0; 4], [1.0, 0.0, 0.0, 0.0], [0.2, 0.7, 0.1, 0.3], [0.0, 0.0, 0.0, 1.0], [0.5, 0.5, 0.5, 0.0]] {
|
||||
let want = cms.cmyk_to_srgb(cmyk, false);
|
||||
let got = p.to_srgb(&cmyk, Intent::RelativeColorimetric).unwrap();
|
||||
|
||||
+21
-2
@@ -181,6 +181,10 @@ impl Color {
|
||||
_ => {}
|
||||
}
|
||||
let h = s.strip_prefix('#').unwrap_or(s);
|
||||
// `from_str_radix` accepts a leading `+`, so check the digits first.
|
||||
if !h.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
let p = |i: usize, n: usize| u8::from_str_radix(h.get(i..i + n)?, 16).ok();
|
||||
match h.len() {
|
||||
3 => Some(Color::rgb8(p(0, 1)? * 17, p(1, 1)? * 17, p(2, 1)? * 17)),
|
||||
@@ -383,7 +387,9 @@ impl Paint {
|
||||
}
|
||||
/// Apply `f` to the colours of this paint that can link to a swatch (a solid colour, each
|
||||
/// gradient stop) with their link and tint; `f` returns true when it changed them. A colour
|
||||
/// left without a link gets tint 1. Returns whether anything changed.
|
||||
/// left without a link gets tint 1. A freeform gradient's points, which it draws from, go
|
||||
/// through `f` too, as unlinked colours (a link `f` gives them is dropped). Returns whether
|
||||
/// anything changed.
|
||||
pub fn map_links(&mut self, f: &mut dyn FnMut(&mut Color, &mut Option<String>, &mut f32) -> bool) -> bool {
|
||||
let mut one = |c: &mut Color, link: &mut Option<String>, tint: &mut f32| {
|
||||
let changed = f(c, link, tint);
|
||||
@@ -394,7 +400,12 @@ impl Paint {
|
||||
};
|
||||
match self {
|
||||
Paint::Solid { color, swatch, tint } => one(color, swatch, tint),
|
||||
Paint::Gradient(g) => g.gradient.stops.iter_mut().fold(false, |ch, s| one(&mut s.color, &mut s.swatch, &mut s.tint) | ch),
|
||||
Paint::Gradient(g) => {
|
||||
let stops = g.gradient.stops.iter_mut().fold(false, |ch, s| one(&mut s.color, &mut s.swatch, &mut s.tint) | ch);
|
||||
let points =
|
||||
g.freeform.iter_mut().flat_map(|ff| ff.points.iter_mut()).fold(false, |ch, p| one(&mut p.color, &mut None, &mut 1.0) | ch);
|
||||
stops | points
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -412,6 +423,14 @@ mod tests {
|
||||
assert_eq!(Color::from_hex("zz"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_rejects_sign_characters() {
|
||||
for bad in ["#+1+2+3", "+f+f+f", "#-1-2-3", "#+12+34+56", "#+f+f+f+f", "#12345+"] {
|
||||
assert_eq!(Color::from_hex(bad), None, "{bad}");
|
||||
}
|
||||
assert_eq!(Color::from_hex("#123"), Color::from_hex("#112233"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hsb_roundtrip() {
|
||||
for hex in ["#ff0000", "#00ff00", "#0000ff", "#336699", "#ffffff", "#000000", "#c0ffee"] {
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
//! Cooperative cancellation and progress for long-running work (exports, flattening, baking).
|
||||
//!
|
||||
//! A host that wants to be able to stop a call (the MCP server, on `notifications/cancelled`)
|
||||
//! runs it inside [`scope`] with a [`CancelToken`]. Code deep below, which has no handle on the
|
||||
//! host, calls [`check`] (or [`is_cancelled`] where it can't return an error) at points where
|
||||
//! stopping is safe, and returns [`Cancelled`] up through the same `Result`s it already has. The
|
||||
//! token is tied to the thread that entered the scope, so concurrent sessions on other threads
|
||||
//! (and tests running in parallel) never see each other's cancellation, and code outside any
|
||||
//! scope is never cancelled.
|
||||
//!
|
||||
//! Work that stops early leaves nothing behind: callers drop whatever was half built and report
|
||||
//! the error, so a cancelled command changes nothing.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
/// A flag one thread raises and another polls. Cloning shares the flag.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct CancelToken(Arc<AtomicBool>);
|
||||
|
||||
impl CancelToken {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Ask for the work holding this token to stop.
|
||||
pub fn cancel(&self) {
|
||||
self.0.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
self.0.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
/// The error of work that stopped because it was cancelled.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Cancelled;
|
||||
|
||||
impl std::fmt::Display for Cancelled {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Cancelled {}
|
||||
|
||||
/// Receives progress: `(done, total, what is being done)`. `total` is 0 when it isn't known.
|
||||
pub type ProgressSink = Rc<dyn Fn(u64, u64, &str)>;
|
||||
|
||||
struct Scope {
|
||||
token: CancelToken,
|
||||
sink: Option<ProgressSink>,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static CURRENT: RefCell<Option<Scope>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Puts the previous scope back, also when the work inside unwinds.
|
||||
struct Restore(Option<Scope>);
|
||||
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
let previous = self.0.take();
|
||||
// `try_with`: during thread teardown the slot may be gone; nothing to restore then.
|
||||
let _ = CURRENT.try_with(|c| *c.borrow_mut() = previous);
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f` with `token` (and an optional progress `sink`) in effect on this thread.
|
||||
pub fn scope<T>(token: &CancelToken, sink: Option<ProgressSink>, f: impl FnOnce() -> T) -> T {
|
||||
let previous = CURRENT.with(|c| c.borrow_mut().replace(Scope { token: token.clone(), sink }));
|
||||
let _restore = Restore(previous);
|
||||
f()
|
||||
}
|
||||
|
||||
/// Has the work running on this thread been cancelled? Always false outside a [`scope`].
|
||||
pub fn is_cancelled() -> bool {
|
||||
CURRENT.with(|c| c.borrow().as_ref().is_some_and(|s| s.token.is_cancelled()))
|
||||
}
|
||||
|
||||
/// Is this thread running inside a [`scope`], so that a cancel can reach it?
|
||||
pub fn is_cancellable() -> bool {
|
||||
CURRENT.with(|c| c.borrow().is_some())
|
||||
}
|
||||
|
||||
/// `Err(Cancelled)` when the work running on this thread has been cancelled.
|
||||
pub fn check() -> Result<(), Cancelled> {
|
||||
if is_cancelled() { Err(Cancelled) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// Report that `done` of `total` steps (0: unknown) are finished. A no-op outside a [`scope`]
|
||||
/// that has a sink.
|
||||
pub fn progress(done: u64, total: u64, what: &str) {
|
||||
// The sink runs outside the borrow, so it may itself call back into this module.
|
||||
let sink = CURRENT.with(|c| c.borrow().as_ref().and_then(|s| s.sink.clone()));
|
||||
if let Some(sink) = sink {
|
||||
sink(done, total, what);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn only_work_inside_the_scope_is_cancelled() {
|
||||
let t = CancelToken::new();
|
||||
assert_eq!(check(), Ok(()));
|
||||
scope(&t, None, || {
|
||||
assert_eq!(check(), Ok(()));
|
||||
t.cancel();
|
||||
assert_eq!(check(), Err(Cancelled));
|
||||
// A nested scope with its own token shadows, then restores.
|
||||
scope(&CancelToken::new(), None, || assert_eq!(check(), Ok(())));
|
||||
assert!(is_cancelled());
|
||||
});
|
||||
assert!(!is_cancelled(), "the scope ended");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_reaches_the_sink_and_the_scope_survives_a_panic() {
|
||||
let seen = Rc::new(RefCell::new(vec![]));
|
||||
let sink: ProgressSink = {
|
||||
let seen = seen.clone();
|
||||
Rc::new(move |d, t, w| seen.borrow_mut().push((d, t, w.to_string())))
|
||||
};
|
||||
let t = CancelToken::new();
|
||||
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
scope(&t, Some(sink), || {
|
||||
progress(1, 3, "page");
|
||||
panic!("boom");
|
||||
})
|
||||
}));
|
||||
assert!(r.is_err());
|
||||
progress(2, 3, "after");
|
||||
assert_eq!(*seen.borrow(), vec![(1, 3, "page".to_string())]);
|
||||
}
|
||||
}
|
||||
+264
-34
@@ -2,10 +2,12 @@
|
||||
//!
|
||||
//! Two encodings hold them: a four-component JPEG, whose samples are stored inverted with Adobe's
|
||||
//! marker (the convention JPEG readers and PDF writers take a CMYK JPEG to follow), and a CMYK
|
||||
//! TIFF (8 or 16 bits, chunky, ink amounts as they are). [`ImageBlob::cmyk_tiff`] writes the
|
||||
//! latter, Deflate-compressed. Every other image decodes to RGB(A) only.
|
||||
//! TIFF (8 or 16 bits, ink amounts as they are, each pixel's inks together or one plane per ink).
|
||||
//! [`ImageBlob::cmyk_tiff`] writes the latter, inks together, Deflate-compressed. Every other image
|
||||
//! decodes to RGB(A) only.
|
||||
|
||||
use std::io::Cursor;
|
||||
use std::sync::Arc;
|
||||
|
||||
use zune_jpeg::zune_core::bytestream::ZCursor;
|
||||
use zune_jpeg::zune_core::colorspace::ColorSpace;
|
||||
@@ -54,25 +56,136 @@ fn is_tiff(bytes: &[u8]) -> bool {
|
||||
bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*")
|
||||
}
|
||||
|
||||
/// The colour type of TIFF `bytes` and its decoder.
|
||||
/// The width and height of the first image of TIFF `bytes` when it has more strips or tiles than
|
||||
/// the `tiff` decoder can count: strips per plane times planes past `u32::MAX`, or tiles per plane
|
||||
/// times planes past `usize::MAX`. A file that stores one plane per channel (PlanarConfiguration 2)
|
||||
/// has a plane for each channel; other files have one. The decoder multiplies these counts without
|
||||
/// a check, which panics where overflow checks are on. Such an image, when it is at least a pixel
|
||||
/// wide, claims at least 2^32 samples.
|
||||
/// Where a tag repeats, the value giving the most strips or tiles counts. `None` for other images
|
||||
/// and for bytes that aren't a TIFF.
|
||||
pub fn oversized_tiff(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
let le = bytes.starts_with(b"II*\0");
|
||||
if !le && !bytes.starts_with(b"MM\0*") {
|
||||
return None;
|
||||
}
|
||||
let u16_at = |at: usize| -> Option<u16> {
|
||||
let b: [u8; 2] = bytes.get(at..at.checked_add(2)?)?.try_into().ok()?;
|
||||
Some(if le { u16::from_le_bytes(b) } else { u16::from_be_bytes(b) })
|
||||
};
|
||||
let u32_at = |at: usize| -> Option<u32> {
|
||||
let b: [u8; 4] = bytes.get(at..at.checked_add(4)?)?.try_into().ok()?;
|
||||
Some(if le { u32::from_le_bytes(b) } else { u32::from_be_bytes(b) })
|
||||
};
|
||||
let ifd = usize::try_from(u32_at(4)?).ok()?;
|
||||
// (tag, the entry's single BYTE, SHORT, LONG or IFD value, which sits in the entry, or its
|
||||
// single LONG8 or IFD8 value that fits in 32 bits, which sits where the entry points)
|
||||
let entry = |i: usize| -> Option<(u16, Option<u32>)> {
|
||||
let e = ifd.checked_add(i.checked_mul(12)?.checked_add(2)?)?;
|
||||
let (ty, count, at) = (u16_at(e.checked_add(2)?)?, u32_at(e.checked_add(4)?)?, e.checked_add(8)?);
|
||||
let value = match (ty, count) {
|
||||
(1, 1) => bytes.get(at).map(|&b| u32::from(b)),
|
||||
(3, 1) => u16_at(at).map(u32::from),
|
||||
(4 | 13, 1) => u32_at(at),
|
||||
(16 | 18, 1) => {
|
||||
let at = usize::try_from(u32_at(at)?).ok()?;
|
||||
let (lo, hi) = (u32_at(at)?, u32_at(at.checked_add(4)?)?);
|
||||
let wide = if le { u64::from(hi) << 32 | u64::from(lo) } else { u64::from(lo) << 32 | u64::from(hi) };
|
||||
u32::try_from(wide).ok()
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
Some((u16_at(e)?, value))
|
||||
};
|
||||
let (mut width, mut height, mut planes, mut planar) = (0u32, 0u32, 1u32, false);
|
||||
let (mut rows, mut tile_w, mut tile_h) = (None::<u32>, None::<u32>, None::<u32>);
|
||||
let least = |was: Option<u32>, v: u32| Some(was.map_or(v, |w| w.min(v)));
|
||||
for i in 0..usize::from(u16_at(ifd)?) {
|
||||
let Some((tag, value)) = entry(i) else { break };
|
||||
let Some(v) = value else { continue };
|
||||
match tag {
|
||||
256 => width = width.max(v),
|
||||
257 => height = height.max(v),
|
||||
277 => planes = planes.max(v),
|
||||
278 => rows = least(rows, v),
|
||||
284 => planar |= v == 2,
|
||||
322 => tile_w = least(tile_w, v),
|
||||
323 => tile_h = least(tile_h, v),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let planes = if planar { u128::from(planes) } else { 1 };
|
||||
let tiles = match (tile_w, tile_h) {
|
||||
(Some(tw @ 1..), Some(th @ 1..)) => u128::from(width.div_ceil(tw)) * u128::from(height.div_ceil(th)) * planes > usize::MAX as u128,
|
||||
_ => false,
|
||||
};
|
||||
let rows = rows.unwrap_or(height);
|
||||
let strips = rows > 0 && u128::from(height.saturating_sub(1) / rows + 1) * planes > u128::from(u32::MAX);
|
||||
(tiles || strips).then_some((width, height))
|
||||
}
|
||||
|
||||
/// The color type of TIFF `bytes` and its decoder; `None` for [`oversized_tiff`] images too.
|
||||
fn tiff(bytes: &[u8]) -> Option<(tiff::ColorType, tiff::decoder::Decoder<Cursor<&[u8]>>)> {
|
||||
if !is_tiff(bytes) {
|
||||
if !is_tiff(bytes) || oversized_tiff(bytes).is_some() {
|
||||
return None;
|
||||
}
|
||||
let mut d = tiff::decoder::Decoder::new(Cursor::new(bytes)).ok()?;
|
||||
Some((d.colortype().ok()?, d))
|
||||
}
|
||||
|
||||
/// Does the TIFF `d` reads store one plane per channel (PlanarConfiguration 2)?
|
||||
fn planar(d: &mut tiff::decoder::Decoder<Cursor<&[u8]>>) -> bool {
|
||||
d.find_tag_unsigned::<u16>(tiff::tags::Tag::PlanarConfiguration).ok().flatten().is_some_and(|p| p != 1)
|
||||
}
|
||||
|
||||
/// The samples of the `n` pixels of the TIFF `d` reads, `per` to a pixel (the number its color
|
||||
/// type has), pixel by pixel and 8 bits each (16-bit samples to their high byte), whether the file
|
||||
/// stores each pixel's samples together or one plane per channel. `None` when they don't decode to
|
||||
/// that many samples, which includes images whose planes together need more than the decoder's
|
||||
/// limit (256 MB of samples).
|
||||
fn read_samples(d: &mut tiff::decoder::Decoder<Cursor<&[u8]>>, n: usize, per: usize) -> Option<Vec<u8>> {
|
||||
let mut buf = tiff::decoder::DecodingResult::U8(Vec::new());
|
||||
let layout = d.read_image_to_buffer(&mut buf).ok()?;
|
||||
let data = match buf {
|
||||
tiff::decoder::DecodingResult::U8(v) => v,
|
||||
tiff::decoder::DecodingResult::U16(v) => v.iter().map(|s| (s >> 8) as u8).collect(),
|
||||
_ => return None,
|
||||
};
|
||||
if Some(data.len()) != n.checked_mul(per) {
|
||||
return None;
|
||||
}
|
||||
if layout.planes <= 1 {
|
||||
return Some(data);
|
||||
}
|
||||
if layout.planes != per {
|
||||
return None;
|
||||
}
|
||||
let planes: Vec<&[u8]> = data.chunks_exact(n).collect();
|
||||
Some((0..n).flat_map(|i| planes.iter().map(move |p| p.get(i).copied().unwrap_or(0))).collect())
|
||||
}
|
||||
|
||||
impl ImageBlob {
|
||||
/// A CMYK TIFF of ink amounts `inks` (Deflate-compressed); `None` when it can't be written.
|
||||
pub fn cmyk_tiff(inks: &Inks) -> Option<Self> {
|
||||
use tiff::encoder::{Compression, TiffEncoder, colortype::CMYK8, compression::DeflateLevel};
|
||||
Self::cmyk_tiff_at(inks, None)
|
||||
}
|
||||
|
||||
/// [`ImageBlob::cmyk_tiff`], declaring the resolution `ppi` (horizontal, vertical pixels per
|
||||
/// inch) when it is given and both are finite and above 0.
|
||||
pub fn cmyk_tiff_at(inks: &Inks, ppi: Option<(f64, f64)>) -> Option<Self> {
|
||||
use tiff::encoder::{Compression, Rational, TiffEncoder, colortype::CMYK8, compression::DeflateLevel};
|
||||
use tiff::tags::{ResolutionUnit, Tag};
|
||||
let mut out = Cursor::new(Vec::new());
|
||||
TiffEncoder::new(&mut out)
|
||||
.ok()?
|
||||
.with_compression(Compression::Deflate(DeflateLevel::Balanced))
|
||||
.write_image::<CMYK8>(inks.width, inks.height, &inks.data)
|
||||
.ok()?;
|
||||
let mut enc = TiffEncoder::new(&mut out).ok()?.with_compression(Compression::Deflate(DeflateLevel::Balanced));
|
||||
let mut img = enc.new_image::<CMYK8>(inks.width, inks.height).ok()?;
|
||||
if let Some((x, y)) = ppi.filter(|(x, y)| [x, y].iter().all(|v| v.is_finite() && **v > 0.0)) {
|
||||
// Ten-thousandths of a pixel per inch.
|
||||
let r = |v: f64| Rational { n: (v * 10_000.0).round() as u32, d: 10_000 };
|
||||
img.encoder().write_tag(Tag::XResolution, r(x)).ok()?;
|
||||
img.encoder().write_tag(Tag::YResolution, r(y)).ok()?;
|
||||
img.encoder().write_tag(Tag::ResolutionUnit, ResolutionUnit::Inch).ok()?;
|
||||
}
|
||||
img.write_data(&inks.data).ok()?;
|
||||
Some(Self::new("image/tiff", out.into_inner()))
|
||||
}
|
||||
|
||||
@@ -88,6 +201,53 @@ impl ImageBlob {
|
||||
let bytes = self.bytes.as_slice();
|
||||
if bytes.starts_with(&[0xFF, 0xD8]) { jpeg_inks(bytes) } else { tiff_inks(bytes) }
|
||||
}
|
||||
|
||||
/// The ink amounts around pixel (`x`, `y`) of a CMYK image, in the pixel space of an image
|
||||
/// object of `object` (width, height) pixels: the average of the `size` × `size` pixels centered
|
||||
/// on it, clipped to the image, as C, M, Y and K bytes. `None` outside the image and for images
|
||||
/// [`Self::cmyk`] doesn't read. The inks of the last CMYK image are kept for the next sample
|
||||
/// (the Eyedropper samples one image click after click), as [`Self::sample_object`] keeps its
|
||||
/// pixels.
|
||||
pub fn sample_cmyk_object(&self, x: f64, y: f64, object: (u32, u32), size: u32) -> Option<[u8; 4]> {
|
||||
thread_local! {
|
||||
static LAST: std::cell::RefCell<Option<(String, Arc<Inks>)>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
if !self.is_cmyk() {
|
||||
return None;
|
||||
}
|
||||
let key = self.content_key();
|
||||
let inks = LAST.with_borrow_mut(|last| match last {
|
||||
Some((k, inks)) if *k == key => Some(inks.clone()),
|
||||
_ => {
|
||||
let inks = Arc::new(self.cmyk()?);
|
||||
*last = Some((key, inks.clone()));
|
||||
Some(inks)
|
||||
}
|
||||
})?;
|
||||
let k = |n: u32, of: u32| n as f64 / of.max(1) as f64;
|
||||
sample_inks(&inks, x * k(inks.width, object.0), y * k(inks.height, object.1), size)
|
||||
}
|
||||
}
|
||||
|
||||
/// [`ImageBlob::sample_cmyk_object`] of `inks` at (`x`, `y`) in their own pixel space.
|
||||
fn sample_inks(inks: &Inks, x: f64, y: f64, size: u32) -> Option<[u8; 4]> {
|
||||
let (w, h) = (i64::from(inks.width), i64::from(inks.height));
|
||||
let (cx, cy) = (x.floor() as i64, y.floor() as i64);
|
||||
if !(0..w).contains(&cx) || !(0..h).contains(&cy) {
|
||||
return None;
|
||||
}
|
||||
let r = i64::from(size.max(1)) / 2;
|
||||
let (mut sum, mut n) = ([0u64; 4], 0u64);
|
||||
for py in (cy - r).max(0)..=(cy + r).min(h - 1) {
|
||||
for px in (cx - r).max(0)..=(cx + r).min(w - 1) {
|
||||
let i = usize::try_from((py * w + px) * 4).ok()?;
|
||||
let p = inks.data.get(i..i + 4)?;
|
||||
sum.iter_mut().zip(p).for_each(|(s, v)| *s += u64::from(*v));
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
let c = |v: u64| ((v + n / 2) / n) as u8;
|
||||
Some(sum.map(c))
|
||||
}
|
||||
|
||||
/// The ink amounts of a four-component JPEG (its samples inverted back; YCCK converted to CMYK
|
||||
@@ -119,34 +279,42 @@ fn jpeg_inks(bytes: &[u8]) -> Option<Inks> {
|
||||
Inks::new(w, h, data)
|
||||
}
|
||||
|
||||
/// The ink amounts of a chunky CMYK TIFF (16-bit samples to 8 bits).
|
||||
/// The ink amounts of a CMYK TIFF (16-bit samples to 8 bits), its inks stored together or one plane
|
||||
/// per ink.
|
||||
fn tiff_inks(bytes: &[u8]) -> Option<Inks> {
|
||||
let (kind, mut d) = tiff(bytes)?;
|
||||
let (w, h) = d.dimensions().ok()?;
|
||||
// Planar samples (one plane per ink) would read as interleaved.
|
||||
let planar = d.find_tag_unsigned::<u16>(tiff::tags::Tag::PlanarConfiguration).ok().flatten().is_some_and(|p| p != 1);
|
||||
if !matches!(kind, tiff::ColorType::CMYK(8 | 16)) || planar || pixels(w, h).is_none() {
|
||||
if !matches!(kind, tiff::ColorType::CMYK(8 | 16)) {
|
||||
return None;
|
||||
}
|
||||
let data = match d.read_image().ok()? {
|
||||
tiff::decoder::DecodingResult::U8(v) => v,
|
||||
tiff::decoder::DecodingResult::U16(v) => v.iter().map(|s| (s >> 8) as u8).collect(),
|
||||
_ => return None,
|
||||
};
|
||||
Inks::new(w, h, data)
|
||||
let n = usize::try_from(pixels(w, h)?).ok()?;
|
||||
Inks::new(w, h, read_samples(&mut d, n, 4)?)
|
||||
}
|
||||
|
||||
/// CMYK TIFF `bytes` as Open and Place store them: as they are, or, when they store one plane per
|
||||
/// ink, which the image decoder doesn't read, their ink amounts written again by
|
||||
/// [`ImageBlob::cmyk_tiff_at`] with the resolution `ppi`. `None` for other images and for CMYK
|
||||
/// TIFFs whose inks [`ImageBlob::cmyk`] doesn't read.
|
||||
pub fn stored_cmyk_tiff(bytes: &[u8], ppi: Option<(f64, f64)>) -> Option<Vec<u8>> {
|
||||
let inks = tiff_inks(bytes)?;
|
||||
let (_, mut d) = tiff(bytes)?;
|
||||
if !planar(&mut d) {
|
||||
return Some(bytes.to_vec());
|
||||
}
|
||||
ImageBlob::cmyk_tiff_at(&inks, ppi).map(|b| Arc::unwrap_or_clone(b.bytes))
|
||||
}
|
||||
|
||||
/// A TIFF whose pixels have samples after their colour, which the image decoder doesn't read at
|
||||
/// all, as RGBA pixels: CMYK with an alpha channel (5 samples), each colour converted by `rgb` (ink
|
||||
/// amounts 0..1 → sRGB 0..1; the inks can't be kept with the transparency, so they show as RGB),
|
||||
/// and grayscale with extra samples, of which the first is the alpha when the file says so (other
|
||||
/// channels are left out). Associated (premultiplied) alpha is divided back out. `None` for other
|
||||
/// images, planar ones and ones larger than [`MAX_CMYK_PIXELS`].
|
||||
/// channels are left out). Associated (premultiplied) alpha is divided back out. The samples may be
|
||||
/// stored together or one plane per channel. `None` for other images and ones larger than
|
||||
/// [`MAX_CMYK_PIXELS`].
|
||||
pub fn extra_samples_tiff_rgba(bytes: &[u8], rgb: impl Fn([f32; 4]) -> [f32; 3]) -> Option<image::RgbaImage> {
|
||||
use tiff::tags::Tag;
|
||||
let (kind, mut d) = tiff(bytes)?;
|
||||
let (w, h) = d.dimensions().ok()?;
|
||||
let planar = d.find_tag_unsigned::<u16>(Tag::PlanarConfiguration).ok().flatten().is_some_and(|p| p != 1);
|
||||
// 0: white is zero, 1: black is zero (the two grayscales).
|
||||
let photometric = d.find_tag_unsigned::<u16>(Tag::PhotometricInterpretation).ok().flatten();
|
||||
// ExtraSamples 1: associated alpha (premultiplied colour), 2: unassociated alpha, 0: not alpha.
|
||||
@@ -159,18 +327,8 @@ pub fn extra_samples_tiff_rgba(bytes: &[u8], rgb: impl Fn([f32; 4]) -> [f32; 3])
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
if planar {
|
||||
return None;
|
||||
}
|
||||
let n = usize::try_from(pixels(w, h)?).ok()?;
|
||||
let data: Vec<u8> = match d.read_image().ok()? {
|
||||
tiff::decoder::DecodingResult::U8(v) => v,
|
||||
tiff::decoder::DecodingResult::U16(v) => v.iter().map(|s| (s >> 8) as u8).collect(),
|
||||
_ => return None,
|
||||
};
|
||||
if Some(data.len()) != n.checked_mul(samples) {
|
||||
return None;
|
||||
}
|
||||
let data = read_samples(&mut d, n, samples)?;
|
||||
let mut out = Vec::with_capacity(n * 4);
|
||||
for p in data.chunks_exact(samples) {
|
||||
let opacity = if alpha { p.get(colour).copied().unwrap_or(u8::MAX) } else { u8::MAX };
|
||||
@@ -246,6 +404,64 @@ mod tests {
|
||||
assert_eq!(gray(0), [255, 255, 255, 255, 51, 51, 51, 255, 0, 0, 0, 255], "a channel that isn't alpha: opaque");
|
||||
}
|
||||
|
||||
/// A header claiming more tiles than can be counted: (2^32 − 1)² pixels in tiles of one pixel,
|
||||
/// times 2 planes.
|
||||
#[test]
|
||||
fn tiffs_with_more_tiles_than_can_be_counted_are_oversized() {
|
||||
let header = |planar: u32| {
|
||||
let mut f = b"II*\0\x08\0\0\0".to_vec();
|
||||
let entries = [(256u16, u32::MAX), (257, u32::MAX), (258, 8), (262, 1), (277, 2), (284, planar), (322, 1), (323, 1), (324, 0), (325, 0)];
|
||||
f.extend(u16::try_from(entries.len()).unwrap().to_le_bytes());
|
||||
for (tag, v) in entries {
|
||||
f.extend([tag.to_le_bytes(), 4u16.to_le_bytes()].concat());
|
||||
f.extend([1u32.to_le_bytes(), v.to_le_bytes()].concat());
|
||||
}
|
||||
f.extend([0; 4]);
|
||||
f
|
||||
};
|
||||
assert_eq!(oversized_tiff(&header(2)), Some((u32::MAX, u32::MAX)));
|
||||
assert_eq!(oversized_tiff(&header(1)), None, "one plane: (2^32 − 1)² tiles can be counted in 64 bits");
|
||||
assert!(tiff(&header(2)).is_none(), "not decoded");
|
||||
}
|
||||
|
||||
/// A header claiming more strips than can be counted: 2^31 + 1 rows, a strip each, times 2
|
||||
/// planes. The decoder counts strips when the file has strip offsets, tile sizes or not, and
|
||||
/// reads a LONG8 value from where the entry points.
|
||||
#[test]
|
||||
fn tiffs_with_more_strips_than_can_be_counted_are_oversized() {
|
||||
// Each entry holds one value of type 3 (SHORT), 4 (LONG) or 16 (LONG8, after the directory).
|
||||
let header = |entries: &[(u16, u16, u32)]| {
|
||||
let after = 8 + 2 + 12 * entries.len() + 4;
|
||||
let mut f = b"II*\0\x08\0\0\0".to_vec();
|
||||
f.extend(u16::try_from(entries.len()).unwrap().to_le_bytes());
|
||||
let mut wide = vec![];
|
||||
for &(tag, ty, v) in entries {
|
||||
f.extend([tag.to_le_bytes(), ty.to_le_bytes()].concat());
|
||||
f.extend(1u32.to_le_bytes());
|
||||
if ty == 16 {
|
||||
f.extend(u32::try_from(after + wide.len()).unwrap().to_le_bytes());
|
||||
wide.extend(u64::from(v).to_le_bytes());
|
||||
} else {
|
||||
f.extend(v.to_le_bytes());
|
||||
}
|
||||
}
|
||||
f.extend([0; 4]);
|
||||
f.extend(wide);
|
||||
f
|
||||
};
|
||||
let tall = 1u32 << 31 | 1;
|
||||
let strips = [(256, 4, 1), (257, 4, tall), (258, 3, 8), (262, 3, 1), (273, 4, 0), (277, 3, 2), (278, 4, 1), (279, 4, 0), (284, 3, 2)];
|
||||
assert_eq!(oversized_tiff(&header(&strips)), Some((1, tall)));
|
||||
let tile_sizes = [&strips[..], &[(322, 4, u32::MAX), (323, 4, u32::MAX)]].concat();
|
||||
assert_eq!(oversized_tiff(&header(&tile_sizes)), Some((1, tall)), "tile sizes without tile offsets");
|
||||
let mut long8 = strips;
|
||||
long8[1] = (257, 16, tall);
|
||||
assert_eq!(oversized_tiff(&header(&long8)), Some((1, tall)), "ImageLength as a LONG8");
|
||||
for h in [header(&tile_sizes), header(&long8)] {
|
||||
assert!(tiff(&h).is_none(), "not decoded");
|
||||
}
|
||||
}
|
||||
|
||||
fn inks() -> Inks {
|
||||
Inks::new(3, 2, vec![0, 40, 100, 0, 255, 0, 0, 0, 0, 255, 0, 0, 0, 0, 255, 0, 0, 0, 0, 255, 12, 34, 56, 78]).unwrap()
|
||||
}
|
||||
@@ -261,6 +477,20 @@ mod tests {
|
||||
assert_eq!(rgb.get_pixel(1, 0).0, [0, 255, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cmyk_image_samples_its_ink_amounts() {
|
||||
let b = ImageBlob::cmyk_tiff(&inks()).unwrap();
|
||||
assert_eq!(b.sample_cmyk_object(0.5, 0.5, (3, 2), 1), Some([0, 40, 100, 0]));
|
||||
// An object of 6 × 4 pixels showing these 3 × 2: (5, 3) is the last pixel.
|
||||
assert_eq!(b.sample_cmyk_object(5.0, 3.0, (6, 4), 1), Some([12, 34, 56, 78]));
|
||||
// 3 × 3 around (1, 0), clipped to the image: all six pixels averaged.
|
||||
assert_eq!(b.sample_cmyk_object(1.5, 0.5, (3, 2), 3), Some([45, 55, 69, 56]));
|
||||
assert_eq!(b.sample_cmyk_object(3.5, 0.5, (3, 2), 1), None, "outside");
|
||||
let mut png = Vec::new();
|
||||
image::RgbaImage::new(2, 2).write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
assert_eq!(ImageBlob::png(png).sample_cmyk_object(0.5, 0.5, (2, 2), 1), None, "an RGB image has no inks");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cmyk_jpeg_reads_back_as_ink_amounts() {
|
||||
// A flat CMYK JPEG: Adobe's inverted samples with its marker.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
use std::borrow::Cow;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use vectorcraft_geom::corners::{Corner, cut_corners, path_corners};
|
||||
use vectorcraft_geom::corners::{Corner, cut_corners, fitted_radii, path_corners};
|
||||
use vectorcraft_geom::shapes::{self, CornerKind};
|
||||
use vectorcraft_geom::{Affine, PathData, Rect};
|
||||
|
||||
@@ -23,6 +23,8 @@ pub struct LiveCorners<'a> {
|
||||
/// The corners that can be cut, in anchor order ([`path_corners`] of `base`).
|
||||
pub corners: Vec<Corner>,
|
||||
radii: Vec<f64>,
|
||||
/// The radius each of `corners` is drawn with ([`fitted_radii`]).
|
||||
drawn: Vec<f64>,
|
||||
kinds: Vec<CornerKind>,
|
||||
/// For each anchor of the path as drawn (per subpath), the anchor of `base` it comes from.
|
||||
sources: Vec<Vec<usize>>,
|
||||
@@ -54,7 +56,9 @@ impl<'a> LiveCorners<'a> {
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
Some(Self { corners: path_corners(&base), base, xf, radii, kinds, sources })
|
||||
let corners = path_corners(&base);
|
||||
let drawn = fitted_radii(&corners, &radii);
|
||||
Some(Self { corners, base, xf, radii, drawn, kinds, sources })
|
||||
}
|
||||
|
||||
/// The Live Corners of a path object.
|
||||
@@ -73,6 +77,11 @@ impl<'a> LiveCorners<'a> {
|
||||
self.radii.get(index).copied().unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// The radius corner `index` is drawn with, in document units: its own, no larger than its
|
||||
/// neighbours leave room for (0 for an anchor that is no corner).
|
||||
pub fn drawn_radius(&self, index: usize) -> f64 {
|
||||
self.corners.binary_search_by_key(&index, |c| c.index).ok().and_then(|i| self.drawn.get(i)).copied().unwrap_or(0.0)
|
||||
}
|
||||
/// The kind corner `index` is cut with.
|
||||
pub fn kind(&self, index: usize) -> CornerKind {
|
||||
self.kinds.get(index).copied().unwrap_or_default()
|
||||
|
||||
+69
-7
@@ -196,6 +196,57 @@ macro_rules! ids {
|
||||
ids!(PiePosition { Even => "even", Ratio => "ratio", Stacked => "stacked" });
|
||||
ids!(PieSort { None => "none", All => "all", First => "first" });
|
||||
|
||||
/// Object › Graph › Column › Column Type: how a column design fills a column or bar. Vertically Scaled stretches it
|
||||
/// along the value axis to the column's length at the column's width; Uniformly Scaled scales it both ways to that
|
||||
/// length, centered on the column; Repeating stacks copies that each stand for [`ColumnDesign::each`]; Sliding scales the
|
||||
/// part above its guide line (in a bar, right of it) in proportion to the column's width and stretches the rest.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ColumnType {
|
||||
#[default]
|
||||
Vertical,
|
||||
Uniform,
|
||||
Repeating,
|
||||
Sliding,
|
||||
}
|
||||
|
||||
/// Object › Graph › Column › For Fractions: a repeating design's last copy, for the part of a value less than one
|
||||
/// design, is cut off (Chop) or scaled down along the value axis (Scale).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Fractions {
|
||||
#[default]
|
||||
Chop,
|
||||
Scale,
|
||||
}
|
||||
|
||||
ids!(ColumnType { Vertical => "vertical", Uniform => "uniform", Repeating => "repeating", Sliding => "sliding" });
|
||||
ids!(Fractions { Chop => "chop", Scale => "scale" });
|
||||
|
||||
/// Object › Graph › Column: the graph design a series' columns or bars are drawn with, and how it fills them.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ColumnDesign {
|
||||
pub design: String,
|
||||
#[serde(skip_serializing_if = "ColumnType::is_default")]
|
||||
pub column_type: ColumnType,
|
||||
/// Repeating: the value one copy of the design stands for (Each Design Represents).
|
||||
pub each: f64,
|
||||
#[serde(skip_serializing_if = "Fractions::is_default")]
|
||||
pub fractions: Fractions,
|
||||
}
|
||||
|
||||
impl ColumnDesign {
|
||||
/// Each Design Represents for a new column design.
|
||||
pub const EACH: f64 = 10.0;
|
||||
}
|
||||
|
||||
impl Default for ColumnDesign {
|
||||
fn default() -> Self {
|
||||
Self { design: String::new(), column_type: ColumnType::Vertical, each: Self::EACH, fractions: Fractions::Chop }
|
||||
}
|
||||
}
|
||||
|
||||
/// Graph Type › Value Axis: which side of the plot the value axis is drawn on.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -345,6 +396,11 @@ pub struct GraphSpec {
|
||||
/// series index, for line, scatter and radar series; `None` and missing entries draw the default square.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub series_markers: Vec<Option<String>>,
|
||||
/// Object › Graph › Column: the graph design each series' columns or bars (and legend swatch) are drawn with, by
|
||||
/// series index, for column, stacked column, bar and stacked bar series; `None` and missing entries draw plain
|
||||
/// columns.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub series_columns: Vec<Option<ColumnDesign>>,
|
||||
/// Graph Type › Tick Marks for the value axis (the left one, or the bottom one of bar graphs) and the right one:
|
||||
/// length, and tick marks per division (0 and 1 = one, at the labels).
|
||||
#[serde(skip_serializing_if = "TickLength::is_short")]
|
||||
@@ -430,17 +486,22 @@ impl GraphSpec {
|
||||
let cells = self.cells();
|
||||
let width = cells.iter().map(Vec::len).max().unwrap_or(0).max(self.series.len());
|
||||
let mut out = String::from(",");
|
||||
out.push_str(&(0..width).map(|s| self.series.get(s).cloned().unwrap_or_default()).collect::<Vec<_>>().join(","));
|
||||
// Quote a label holding a separator, quote or line break (inner quotes doubled), or one with
|
||||
// surrounding spaces; `always` also quotes blank and numeric labels so they stay labels.
|
||||
let field = |c: &str, always: bool| {
|
||||
let special = c.contains([',', '\t', '"', '\n', '\r']) || c != c.trim();
|
||||
if special || (always && (c.trim().is_empty() || c.trim().parse::<f64>().is_ok())) {
|
||||
format!("\"{}\"", c.replace('"', "\"\""))
|
||||
} else {
|
||||
c.to_string()
|
||||
}
|
||||
};
|
||||
out.push_str(&(0..width).map(|s| field(self.series.get(s).map(String::as_str).unwrap_or_default(), false)).collect::<Vec<_>>().join(","));
|
||||
out.push('\n');
|
||||
let labelled = !self.categories.is_empty();
|
||||
for (i, r) in cells.iter().enumerate() {
|
||||
if labelled {
|
||||
let c = self.categories.get(i).map(String::as_str).unwrap_or_default();
|
||||
if c.trim().is_empty() || c.trim().parse::<f64>().is_ok() || c != c.trim() {
|
||||
out.push_str(&format!("\"{c}\""));
|
||||
} else {
|
||||
out.push_str(c);
|
||||
}
|
||||
out.push_str(&field(self.categories.get(i).map(String::as_str).unwrap_or_default(), true));
|
||||
out.push(',');
|
||||
}
|
||||
out.push_str(&r.iter().map(|v| v.map(|v| v.to_string()).unwrap_or_default()).collect::<Vec<_>>().join(","));
|
||||
@@ -474,6 +535,7 @@ impl Default for GraphSpec {
|
||||
right_axis_min: None,
|
||||
right_axis_max: None,
|
||||
series_markers: vec![],
|
||||
series_columns: vec![],
|
||||
label_styles: LabelStyles::default(),
|
||||
first_row_in_front: false,
|
||||
first_column_in_front: false,
|
||||
|
||||
+308
-13
@@ -169,6 +169,80 @@ fn objects_at_repeated(doc: &Document, p: Point, opt: HitOptions, scope: Option<
|
||||
tops
|
||||
}
|
||||
|
||||
/// The most objects [`locked_objects_at`] lists.
|
||||
pub const MAX_LOCKED_AT: usize = 20;
|
||||
|
||||
/// The locked objects under `p`, topmost first, at most [`MAX_LOCKED_AT`]: each object whose own
|
||||
/// lock is on and that a click at `p` would hit if it were not locked. A locked group is listed,
|
||||
/// not what it holds. Hidden objects and everything on hidden, locked and template layers are left
|
||||
/// out. While a pattern is edited only its tile layer is searched, and in isolation mode
|
||||
/// (`opt.scope`) only what is inside the isolated group or object.
|
||||
pub fn locked_objects_at(doc: &Document, p: Point, opt: HitOptions) -> Vec<NodeId> {
|
||||
let symbols = doc.symbol_boxes();
|
||||
let mut out = Vec::new();
|
||||
if let Some(scope) = opt.scope
|
||||
&& let Some(isolated) = doc.node(scope)
|
||||
{
|
||||
if isolated.is_container() {
|
||||
locked_children(isolated, p, opt, &symbols, &mut out);
|
||||
} else if isolated.visible && isolated.locked && locked_hit(isolated, p, opt, &symbols) {
|
||||
out.push(isolated.id);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
for layer in doc.layers.iter().rev() {
|
||||
if !layer.visible || layer.locked || layer.is_template() || doc.pattern_edit.as_ref().is_some_and(|e| e.layer != layer.id) {
|
||||
continue;
|
||||
}
|
||||
let opt = if matches!(layer.kind, NodeKind::Layer { preview: false, .. }) { HitOptions { outline: true, ..opt } } else { opt };
|
||||
locked_children(layer, p, opt, &symbols, &mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// [`locked_objects_at`] through the children of `parent`, front first: a locked child is tested
|
||||
/// whole, and an unlocked layer, group, blend or envelope in Edit Contents is searched inside.
|
||||
fn locked_children(parent: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes, out: &mut Vec<NodeId>) {
|
||||
let Some(children) = parent.children() else { return };
|
||||
if parent.clips() && children.first().is_some_and(|clip| !clip_contains(clip, p)) {
|
||||
return;
|
||||
}
|
||||
for i in hit_order(parent, children, p) {
|
||||
if out.len() >= MAX_LOCKED_AT {
|
||||
return;
|
||||
}
|
||||
let Some(c) = children.get(i) else { continue };
|
||||
if !may_hit(parent, i, c, p, opt, symbols, false) || c.is_layer() && (c.locked || c.is_template()) {
|
||||
continue;
|
||||
}
|
||||
if c.locked {
|
||||
if locked_hit(c, p, opt, symbols) {
|
||||
out.push(c.id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match &c.kind {
|
||||
NodeKind::Layer { preview: false, .. } => locked_children(c, p, HitOptions { outline: true, ..opt }, symbols, out),
|
||||
NodeKind::Layer { .. }
|
||||
| NodeKind::Group { .. }
|
||||
| NodeKind::Intertwine { .. }
|
||||
| NodeKind::Blend { .. }
|
||||
| NodeKind::Envelope { editing: true, .. } => locked_children(c, p, opt, symbols, out),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Would a click at `p` hit the locked object `n` if it were not locked?
|
||||
fn locked_hit(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> bool {
|
||||
let inside = || hit_children(n, p, opt, symbols, &|_| false, &mut Vec::new(), false).is_some();
|
||||
match &n.kind {
|
||||
NodeKind::Group { .. } | NodeKind::Intertwine { .. } | NodeKind::Envelope { editing: true, .. } => inside(),
|
||||
NodeKind::Blend { .. } => inside() || hit_leaf(n, p, opt, symbols).is_some(),
|
||||
_ => hit_leaf(n, p, opt, symbols).is_some(),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`hit_test`] passing over the nodes `skip` names (and their contents). Symbol instances are
|
||||
/// measured by their art ([`Node::geometric_bounds_with`]).
|
||||
fn hit_test_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(NodeId) -> bool, editable_only: bool) -> Option<Hit> {
|
||||
@@ -220,11 +294,17 @@ fn edits_contents(n: &Node) -> bool {
|
||||
/// Is `p` inside the region `clip` clips to ([`Node::clip_shapes`])? Text counts by its frame:
|
||||
/// glyph outlines need the font engine, above this crate.
|
||||
fn clip_contains(clip: &Node, p: Point) -> bool {
|
||||
clip_regions(clip).iter().any(|(bp, rule)| fill_contains(bp, *rule, p))
|
||||
}
|
||||
|
||||
/// The regions `clip` clips to ([`Node::clip_shapes`]), text counted by its frame as in
|
||||
/// [`clip_contains`].
|
||||
fn clip_regions(clip: &Node) -> Vec<(vectorcraft_geom::BezPath, vectorcraft_geom::FillRule)> {
|
||||
let frame = |n: &Node| {
|
||||
let b = n.geometric_bounds()?;
|
||||
Some(Node::path(n.id, vectorcraft_geom::shapes::rectangle(b), Default::default()))
|
||||
};
|
||||
clip.clip_shapes(Some(&frame)).iter().any(|(bp, rule)| fill_contains(bp, *rule, p))
|
||||
clip.clip_shapes(Some(&frame))
|
||||
}
|
||||
|
||||
/// Can a click at `p` hit `c`, the child `i` of `parent`, or something inside it? Not when it is
|
||||
@@ -390,15 +470,39 @@ fn hit_any(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option
|
||||
}
|
||||
}
|
||||
|
||||
/// The path of compound path `n` under `p`, for the Group Selection tool: the one whose outline is
|
||||
/// within `tol`, else the innermost one whose area holds `p` (a hole inside an outer shape, an
|
||||
/// island inside a hole). `None` when `n` isn't a compound path or no part is under `p`.
|
||||
pub fn compound_part_at(n: &Node, p: Point, tol: f64) -> Option<NodeId> {
|
||||
let NodeKind::Compound { children, rule } = &n.kind else { return None };
|
||||
let parts: Vec<_> = children.iter().rev().filter_map(|c| c.path_data().map(|pd| (c.id, pd.to_bezpath(), pd.bounds()))).collect();
|
||||
if let Some((id, ..)) = parts.iter().find(|(_, bp, _)| stroke_contains(bp, 0.0, tol, p)) {
|
||||
return Some(*id);
|
||||
}
|
||||
// The innermost by the size of its box.
|
||||
let size = |b: &Option<Rect>| b.map_or(f64::INFINITY, |b| b.area());
|
||||
parts.iter().filter(|(_, bp, _)| fill_contains(bp, *rule, p)).min_by(|a, b| size(&a.2).total_cmp(&size(&b.2))).map(|(id, ..)| *id)
|
||||
}
|
||||
|
||||
/// Objects (children of layers, or of `scope` in isolation mode) touched by a marquee rect. Symbol
|
||||
/// instances are measured by their art ([`Node::geometric_bounds_with`]).
|
||||
/// instances are measured by their art ([`Node::geometric_bounds_with`]). The art of a clip group
|
||||
/// or of a layer with a clipping mask counts only when the marquee meets the region its clipping
|
||||
/// path clips to, and then only within that region's bounds.
|
||||
pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) -> Vec<NodeId> {
|
||||
let mut out = Vec::new();
|
||||
let tops: Vec<&std::sync::Arc<Node>> = match scope.and_then(|s| doc.node(s)) {
|
||||
Some(s) => match s.children() {
|
||||
Some(c) => c.iter().collect(),
|
||||
// `tops` pairs each object with the part of `r` in which the marquee can touch it.
|
||||
let tops: Vec<(&std::sync::Arc<Node>, Rect)> = match scope.and_then(|s| doc.node(s)) {
|
||||
Some(s) => match (s.children(), marquee_in(s, r)) {
|
||||
(Some(c), Some(r)) => c.iter().map(|c| (c, r)).collect(),
|
||||
(Some(_), None) => vec![],
|
||||
// A single object isolated: only it.
|
||||
None => doc.parent_of(s.id).and_then(|p| doc.node(p)).and_then(Node::children).into_iter().flatten().filter(|c| c.id == s.id).collect(),
|
||||
(None, _) => doc
|
||||
.parent_of(s.id)
|
||||
.and_then(|p| doc.node(p))
|
||||
.and_then(|p| Some((p.children()?, marquee_in(p, r)?)))
|
||||
.into_iter()
|
||||
.flat_map(|(c, r)| c.iter().filter(|c| c.id == s.id).map(move |c| (c, r)))
|
||||
.collect(),
|
||||
},
|
||||
// The objects of the layers, looking through sublayers (hidden, locked and template ones
|
||||
// are left out).
|
||||
@@ -406,18 +510,21 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
|
||||
.layers
|
||||
.iter()
|
||||
.filter(|l| l.visible && !l.locked && !l.is_template() && doc.pattern_edit.as_ref().is_none_or(|e| e.layer == l.id))
|
||||
.flat_map(|l| layer_objects(l))
|
||||
.flat_map(|l| layer_objects(l, r))
|
||||
.collect(),
|
||||
};
|
||||
fn layer_objects(l: &Node) -> Vec<&std::sync::Arc<Node>> {
|
||||
/// The objects of layer `l`, each with the part of `r` that the clipping masks of `l` and of
|
||||
/// the sublayers it is in leave.
|
||||
fn layer_objects(l: &Node, r: Rect) -> Vec<(&std::sync::Arc<Node>, Rect)> {
|
||||
let mut out = vec![];
|
||||
let Some(r) = marquee_in(l, r) else { return out };
|
||||
for c in l.children().into_iter().flatten() {
|
||||
if c.is_layer() {
|
||||
if c.visible && !c.locked && !c.is_template() {
|
||||
out.extend(layer_objects(c));
|
||||
out.extend(layer_objects(c, r));
|
||||
}
|
||||
} else {
|
||||
out.push(c);
|
||||
out.push((c, r));
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -425,9 +532,10 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
|
||||
fn touches(n: &Node, s: &SymbolBoxes, r: Rect) -> bool {
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, .. } => vectorcraft_geom::hit::intersects_rect(path, r),
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => {
|
||||
children.iter().any(|c| c.visible && touches(c, s, r))
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => {
|
||||
marquee_in(n, r).is_some_and(|r| children.iter().any(|c| c.visible && touches(c, s, r)))
|
||||
}
|
||||
NodeKind::Compound { children, .. } => children.iter().any(|c| c.visible && touches(c, s, r)),
|
||||
// A box with no width or no height (an instance of a symbol that draws a straight
|
||||
// line) is touched where the marquee crosses it.
|
||||
_ => n.geometric_bounds_with(s).is_some_and(|b| {
|
||||
@@ -439,6 +547,7 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
|
||||
fn collect_leaves(n: &Node, s: &SymbolBoxes, r: Rect, out: &mut Vec<NodeId>) {
|
||||
match &n.kind {
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => {
|
||||
let Some(r) = marquee_in(n, r) else { return };
|
||||
for c in children {
|
||||
if c.visible && !c.locked {
|
||||
collect_leaves(c, s, r, out);
|
||||
@@ -453,7 +562,7 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
|
||||
}
|
||||
}
|
||||
let symbols = doc.symbol_boxes();
|
||||
for t in tops {
|
||||
for (t, r) in tops {
|
||||
if !t.visible || t.locked {
|
||||
continue;
|
||||
}
|
||||
@@ -466,6 +575,36 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
|
||||
out
|
||||
}
|
||||
|
||||
/// The part of a marquee `r` in which it can touch the children of `parent`. For a clip group or a
|
||||
/// layer with a clipping mask, this is what [`marquee_in_clip`] returns for its clipping path (None
|
||||
/// when the marquee misses it); otherwise it is all of `r`.
|
||||
fn marquee_in(parent: &Node, r: Rect) -> Option<Rect> {
|
||||
match parent.children().and_then(|c| c.first()) {
|
||||
Some(clip) if parent.clips() => marquee_in_clip(clip, r),
|
||||
_ => Some(r),
|
||||
}
|
||||
}
|
||||
|
||||
/// The part of a marquee `r` in which it can touch the art that `clip` clips (the clipping path of
|
||||
/// a clip group or of a layer with a clipping mask): `r` cut to the bounds of the regions `clip`
|
||||
/// clips to ([`clip_regions`]), or `None` when `r` meets none of those regions. Within those
|
||||
/// bounds, art outside a region that is not a rectangle still counts.
|
||||
fn marquee_in_clip(clip: &Node, r: Rect) -> Option<Rect> {
|
||||
use vectorcraft_geom::kurbo::Shape;
|
||||
let regions = clip_regions(clip);
|
||||
// The edge of a region is its outline with each open subpath closed, as `fill_contains` fills it.
|
||||
let meets = |(bp, rule): &(vectorcraft_geom::BezPath, vectorcraft_geom::FillRule)| {
|
||||
let mut edge = vectorcraft_geom::PathData::from_bezpath(bp);
|
||||
edge.subpaths.iter_mut().for_each(|sp| sp.closed = true);
|
||||
fill_contains(bp, *rule, r.center()) || vectorcraft_geom::hit::intersects_rect(&edge, r)
|
||||
};
|
||||
if !regions.iter().any(meets) {
|
||||
return None;
|
||||
}
|
||||
let bounds = regions.iter().map(|(bp, _)| bp.bounding_box()).reduce(|a, b| a.union(b))?;
|
||||
Some(r.intersect(bounds))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -539,6 +678,99 @@ mod tests {
|
||||
assert!(marquee(&d, Rect::new(100.0, 100.0, 105.0, 105.0), None, false).is_empty());
|
||||
}
|
||||
|
||||
/// The art of a 200 pt wide rectangle clipped to its left half (0..100 × 0..100): the mask and
|
||||
/// the art, in that order.
|
||||
fn half_clipped(d: &mut Document) -> (Node, Node) {
|
||||
let mask = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default());
|
||||
let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 100.0)), Appearance::default_art());
|
||||
(mask, art)
|
||||
}
|
||||
|
||||
/// #2345: a marquee touches a clip group only where its art shows. Over the clipped-away
|
||||
/// half alone it selects nothing, and around a rectangle drawn beside the group's mask it
|
||||
/// takes that rectangle alone; reaching into the mask takes the group. The same holds for
|
||||
/// Group Selection's leaves and in isolation mode.
|
||||
#[test]
|
||||
fn a_marquee_leaves_a_clip_group_by_its_clipped_away_art() {
|
||||
let mut d = Document::new(300.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
let (mask, art) = half_clipped(&mut d);
|
||||
let (g, art_id, beside) = (d.alloc_id(), art.id, d.alloc_id());
|
||||
let mut clip = Node::group(g, vec![Arc::new(mask), Arc::new(art)]);
|
||||
clip.set_clips(true);
|
||||
d.insert(Some(l), 0, clip).unwrap();
|
||||
d.insert(Some(l), 1, Node::path(beside, shapes::rectangle(Rect::new(150.0, 80.0, 190.0, 130.0)), Appearance::default_art())).unwrap();
|
||||
// This marquee crosses only the hidden art's top and right edges.
|
||||
let hidden = Rect::new(150.0, -10.0, 210.0, 50.0);
|
||||
assert!(marquee(&d, hidden, None, false).is_empty());
|
||||
assert!(marquee(&d, hidden, None, true).is_empty());
|
||||
assert!(marquee(&d, hidden, Some(g), false).is_empty());
|
||||
assert!(marquee(&d, hidden, Some(art_id), false).is_empty());
|
||||
// This marquee surrounds the rectangle beside and crosses the hidden art's bottom edge.
|
||||
assert_eq!(marquee(&d, Rect::new(140.0, 70.0, 195.0, 140.0), None, false), vec![beside]);
|
||||
// This marquee crosses the shown half's top edge and goes on into the hidden half.
|
||||
let shown = Rect::new(50.0, -10.0, 160.0, 10.0);
|
||||
assert_eq!(marquee(&d, shown, None, false), vec![g]);
|
||||
assert!(marquee(&d, shown, None, true).contains(&art_id));
|
||||
assert_eq!(marquee(&d, shown, Some(g), false).len(), 2, "the mask and the art");
|
||||
}
|
||||
|
||||
/// #2345: in the corner of a diamond mask's box, outside the diamond, a marquee over the
|
||||
/// clipped art's corner leaves the clip group. An open mask clips to its area closed by a
|
||||
/// straight line, and a marquee across that line takes the art it reaches inside.
|
||||
#[test]
|
||||
fn a_marquee_must_meet_the_clipping_path_itself() {
|
||||
// A clip group of a mask through `pts` (closed or open) over the art `art`.
|
||||
let clipped = |pts: &[(f64, f64)], closed: bool, art: Rect| {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
let mut mask = vectorcraft_geom::BezPath::new();
|
||||
mask.move_to(pts[0]);
|
||||
for &p in &pts[1..] {
|
||||
mask.line_to(p);
|
||||
}
|
||||
if closed {
|
||||
mask.close_path();
|
||||
}
|
||||
let mask = Node::path(d.alloc_id(), vectorcraft_geom::PathData::from_bezpath(&mask), Appearance::default());
|
||||
let art = Node::path(d.alloc_id(), shapes::rectangle(art), Appearance::default_art());
|
||||
let g = d.alloc_id();
|
||||
let mut clip = Node::group(g, vec![Arc::new(mask), Arc::new(art)]);
|
||||
clip.set_clips(true);
|
||||
d.insert(Some(l), 0, clip).unwrap();
|
||||
(d, g)
|
||||
};
|
||||
let (d, g) = clipped(&[(50.0, 0.0), (100.0, 50.0), (50.0, 100.0), (0.0, 50.0)], true, Rect::new(0.0, 0.0, 100.0, 100.0));
|
||||
assert!(marquee(&d, Rect::new(-10.0, -10.0, 10.0, 10.0), None, false).is_empty());
|
||||
assert_eq!(marquee(&d, Rect::new(-10.0, 40.0, 10.0, 60.0), None, false), vec![g], "over the diamond's left point");
|
||||
// The mask is the triangle above the line from (0, 0) to (100, 100), and the art lies inside it.
|
||||
let (d, g) = clipped(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)], false, Rect::new(55.0, 40.0, 70.0, 50.0));
|
||||
assert_eq!(marquee(&d, Rect::new(40.0, 45.0, 60.0, 70.0), None, false), vec![g]);
|
||||
}
|
||||
|
||||
/// #2345: the objects of a layer or sublayer with a clipping mask are touched only where the
|
||||
/// mask shows them, as in a clip group.
|
||||
#[test]
|
||||
fn a_marquee_leaves_art_a_layer_clips_away() {
|
||||
for sublayer in [false, true] {
|
||||
let mut d = Document::new(300.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
let beside = d.alloc_id();
|
||||
d.insert(Some(l), 0, Node::path(beside, shapes::rectangle(Rect::new(150.0, 80.0, 190.0, 130.0)), Appearance::default_art())).unwrap();
|
||||
let masked = d.alloc_id();
|
||||
let mut layer = Node::layer(masked, "Masked", crate::LayerColor::Preset(2));
|
||||
layer.set_clips(true);
|
||||
d.insert(sublayer.then_some(l), 9, layer).unwrap();
|
||||
let (mask, art) = half_clipped(&mut d);
|
||||
let (mask_id, art_id) = (mask.id, art.id);
|
||||
d.insert(Some(masked), 0, mask).unwrap();
|
||||
d.insert(Some(masked), 1, art).unwrap();
|
||||
assert!(marquee(&d, Rect::new(150.0, -10.0, 210.0, 50.0), None, false).is_empty(), "sublayer: {sublayer}");
|
||||
assert_eq!(marquee(&d, Rect::new(140.0, 70.0, 195.0, 140.0), None, false), vec![beside], "sublayer: {sublayer}");
|
||||
assert_eq!(marquee(&d, Rect::new(50.0, -10.0, 160.0, 10.0), None, false), vec![mask_id, art_id], "sublayer: {sublayer}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Art in a sublayer is an object of its own: a click or a marquee takes it, not the
|
||||
/// sublayer; hidden, locked and template sublayers block both.
|
||||
#[test]
|
||||
@@ -901,6 +1133,69 @@ world",
|
||||
assert!(hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).is_none());
|
||||
}
|
||||
|
||||
/// Insert `n` locked at the top of `parent`.
|
||||
fn insert_locked(d: &mut Document, parent: NodeId, mut n: Node) -> NodeId {
|
||||
let id = n.id;
|
||||
n.locked = true;
|
||||
d.insert(Some(parent), usize::MAX, n).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
/// `locked_objects_at`: the locked objects under the point, topmost first. A locked group is
|
||||
/// listed rather than its contents, and a locked object in an unlocked group is listed itself.
|
||||
/// Hidden objects, locked layers and the art outside an isolated group are left out.
|
||||
#[test]
|
||||
fn locked_objects_at_lists_the_locked_stack() {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
let rect =
|
||||
|d: &mut Document, x: f64| Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 0.0, x + 50.0, 50.0)), Appearance::default_art());
|
||||
let (a, b) = (rect(&mut d, 0.0), rect(&mut d, 10.0));
|
||||
let (a, b) = (insert_locked(&mut d, l, a), insert_locked(&mut d, l, b));
|
||||
let (mut inner, locked_group) = (rect(&mut d, 20.0), d.alloc_id());
|
||||
inner.locked = true;
|
||||
let inner_id = inner.id;
|
||||
let mut g = Node::group(locked_group, vec![Arc::new(inner)]);
|
||||
g.locked = true;
|
||||
d.insert(Some(l), usize::MAX, g).unwrap();
|
||||
let open_group = d.alloc_id();
|
||||
let mut child = rect(&mut d, 25.0);
|
||||
child.locked = true;
|
||||
let child_id = child.id;
|
||||
let free = rect(&mut d, 25.0);
|
||||
d.insert(Some(l), usize::MAX, Node::group(open_group, vec![Arc::new(child), Arc::new(free)])).unwrap();
|
||||
let at = |d: &Document, x: f64, scope| locked_objects_at(d, Point::new(x, 25.0), HitOptions { scope, ..HitOptions::default() });
|
||||
assert_eq!(at(&d, 30.0, None), [child_id, locked_group, b, a]);
|
||||
assert!(!at(&d, 30.0, None).contains(&inner_id), "the locked group stands for what it holds");
|
||||
assert_eq!(at(&d, 5.0, None), [a]);
|
||||
assert!(at(&d, 150.0, None).is_empty());
|
||||
// Isolated, only the group's own objects are looked at.
|
||||
assert_eq!(at(&d, 30.0, Some(open_group)), [child_id]);
|
||||
// A hidden object is left out, and nothing on a locked or hidden layer is listed.
|
||||
d.node_mut(b).unwrap().visible = false;
|
||||
assert_eq!(at(&d, 30.0, None), [child_id, locked_group, a]);
|
||||
d.node_mut(l).unwrap().locked = true;
|
||||
assert!(at(&d, 30.0, None).is_empty());
|
||||
d.node_mut(l).unwrap().locked = false;
|
||||
d.node_mut(l).unwrap().visible = false;
|
||||
assert!(at(&d, 30.0, None).is_empty());
|
||||
}
|
||||
|
||||
/// `locked_objects_at` lists at most `MAX_LOCKED_AT` objects, the topmost ones.
|
||||
#[test]
|
||||
fn locked_objects_at_stops_at_its_limit() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let l = d.layers[0].id;
|
||||
let ids: Vec<NodeId> = (0..MAX_LOCKED_AT + 5)
|
||||
.map(|_| {
|
||||
let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art());
|
||||
insert_locked(&mut d, l, n)
|
||||
})
|
||||
.collect();
|
||||
let found = locked_objects_at(&d, Point::new(25.0, 25.0), HitOptions::default());
|
||||
assert_eq!(found, ids.iter().rev().take(MAX_LOCKED_AT).copied().collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_sampling_reads_locked_art_and_layers_but_excludes_hidden_art() {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
|
||||
+212
-8
@@ -8,6 +8,7 @@
|
||||
pub mod appearance;
|
||||
pub mod assets;
|
||||
pub mod blend;
|
||||
pub mod cancel;
|
||||
pub mod clipnest;
|
||||
pub mod cmyk;
|
||||
pub mod corners;
|
||||
@@ -20,12 +21,17 @@ pub mod inks;
|
||||
mod inline;
|
||||
pub mod intertwine;
|
||||
pub mod shared;
|
||||
mod symbol_export_type;
|
||||
mod symbol_nine_slice;
|
||||
mod symbol_registration;
|
||||
pub use inline::{SYMBOL_HALF, SYMBOL_SIZES};
|
||||
pub use shared::Shared;
|
||||
pub use symbol_export_type::{SYMBOL_EXPORT_TYPE, SymbolExportType};
|
||||
pub use symbol_nine_slice::{SYMBOL_NINE_SLICE, SymbolNineSlice};
|
||||
pub use symbol_registration::{SYMBOL_REGISTRATION, SymbolRegistration};
|
||||
pub mod links;
|
||||
pub mod live;
|
||||
pub mod live_paint;
|
||||
pub mod marks;
|
||||
mod mem;
|
||||
pub mod metadata;
|
||||
@@ -87,17 +93,18 @@ pub use assets::ExportAsset;
|
||||
pub use corners::LiveCorners;
|
||||
pub use depth::MAX_DEPTH;
|
||||
pub use graph::{
|
||||
GraphDesign, GraphKind, GraphSpec, LabelStyles, MAX_GRAPH_CATEGORIES, MAX_GRAPH_SERIES, PiePosition, PieSort, SeriesPaint, TickLength,
|
||||
ValueAxisSide,
|
||||
ColumnDesign, ColumnType, Fractions, GraphDesign, GraphKind, GraphSpec, LabelStyles, MAX_GRAPH_CATEGORIES, MAX_GRAPH_SERIES, PiePosition,
|
||||
PieSort, SeriesPaint, TickLength, ValueAxisSide,
|
||||
};
|
||||
pub use hit::{Hit, HitKind};
|
||||
pub use links::{LinkInfo, PlacementOptions};
|
||||
pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint};
|
||||
pub use live_paint::{GapOptions, GapSize};
|
||||
pub use metadata::{CopyrightStatus, DocMetadata};
|
||||
pub use node::Knockout;
|
||||
pub use node::Scaling;
|
||||
pub use node::{Extras, ExtrasMut, ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeExtras, NodeId, NodeKind, OpacityMask};
|
||||
pub use node::{ImageMap, ObjectAttributes};
|
||||
pub use node::{ImageMap, MAX_SVG_SCRIPTS, ObjectAttributes, SVG_EVENTS};
|
||||
pub use orient::OrientedBox;
|
||||
pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType};
|
||||
pub use perspective::PerspectiveAttachment;
|
||||
@@ -110,9 +117,9 @@ pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes};
|
||||
pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOptions, SliceSource};
|
||||
pub use style_libs::StyleLibrary;
|
||||
pub use text::{
|
||||
AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, InlineArt, Justify, Kinsoku, LeadingModel,
|
||||
Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef,
|
||||
TextWrap, VerticalAlign, WrapShape,
|
||||
AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt, Justify,
|
||||
Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, TextObject,
|
||||
TextRun, TextStyleDef, TextWrap, VerticalAlign, WrapShape,
|
||||
};
|
||||
pub use trace::TraceView;
|
||||
pub use variables::{DataSet, DataValue, Variable, VariableKind, Variables};
|
||||
@@ -589,6 +596,23 @@ pub struct ImageBlob {
|
||||
pub proxy: Option<Arc<Vec<u8>>>,
|
||||
}
|
||||
|
||||
/// What the rulers measure from (View › Rulers): the active artboard's origin, the default of a
|
||||
/// new document, or one origin for the whole document.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum RulerMode {
|
||||
#[default]
|
||||
Artboard,
|
||||
Global,
|
||||
}
|
||||
|
||||
impl RulerMode {
|
||||
/// The mode of a document saved before the choice existed: global, as its rulers measured.
|
||||
pub fn legacy() -> Self {
|
||||
Self::Global
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Document {
|
||||
pub version: u32,
|
||||
@@ -602,6 +626,15 @@ pub struct Document {
|
||||
/// The rulers' zero point in document coordinates. Moving it changes measurements, never art.
|
||||
#[serde(default, skip_serializing_if = "skip::is_default")]
|
||||
pub ruler_origin: Point,
|
||||
/// View › Rulers › Change to Global / Artboard Rulers: whether the rulers and position fields
|
||||
/// measure from [`Self::ruler_origin`] (global) or from the active artboard's own origin.
|
||||
/// Documents saved before the choice existed measured globally and open that way.
|
||||
#[serde(default = "RulerMode::legacy")]
|
||||
pub rulers: RulerMode,
|
||||
/// Artboard rulers' zero point of each artboard (by [`Artboard::id`]), as an offset from its
|
||||
/// top-left corner; an artboard not listed measures from that corner.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub artboard_ruler_origins: BTreeMap<u32, Vec2>,
|
||||
#[serde(default)]
|
||||
pub color_mode: ColorMode,
|
||||
pub artboards: Vec<Artboard>,
|
||||
@@ -692,6 +725,10 @@ pub struct Document {
|
||||
/// save time only, so changing the view never marks the document modified.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub last_view: Option<SavedView>,
|
||||
/// View → Show Transparency Grid when the document was saved. Written at save time only,
|
||||
/// like [`Document::last_view`]; older files open with the grid hidden.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub transparency_grid: bool,
|
||||
/// The layers, sublayers and groups open in the Layers panel when the document was saved; they
|
||||
/// reopen that way. `None`: the default, only the top-level layers open. Written at save time
|
||||
/// only, like [`Document::last_view`].
|
||||
@@ -724,6 +761,10 @@ pub struct Document {
|
||||
/// export settings are Export for Screens' ([`Document::export_settings`]).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub assets: Vec<ExportAsset>,
|
||||
/// Window › SVG Interactivity › Link JavaScript Files: the JavaScript files (paths or URLs, as
|
||||
/// given) SVG export links with `<script>` elements, in order.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub svg_scripts: Vec<String>,
|
||||
/// Puppet Warp pins on the selected artwork while the tool edits it ([`PuppetPins`]): editing
|
||||
/// state, never saved.
|
||||
#[serde(skip)]
|
||||
@@ -745,6 +786,14 @@ fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Is point `p` on the artboard at `rect`, its edges included, within a millionth of a point? This
|
||||
/// is the rule [`Document::art_on_artboard`] applies to art flush with an edge (#890), where
|
||||
/// `Rect::contains` leaves out the right and bottom edges.
|
||||
pub fn artboard_holds(rect: Rect, p: Point) -> bool {
|
||||
let r = rect.inflate(1e-6, 1e-6);
|
||||
r.x0 <= p.x && p.x <= r.x1 && r.y0 <= p.y && p.y <= r.y1
|
||||
}
|
||||
|
||||
/// Listed nodes by parent: the parent's index path (empty for the top-level layers), then the index
|
||||
/// of each listed child in it, in order, with the child's place in the list.
|
||||
type Places = BTreeMap<Vec<usize>, Vec<(usize, usize)>>;
|
||||
@@ -759,6 +808,27 @@ impl Document {
|
||||
if origin.is_finite() && origin.x.abs() <= MAX_COORD && origin.y.abs() <= MAX_COORD { origin } else { Point::ZERO }
|
||||
}
|
||||
|
||||
/// Where the rulers measure from with artboard `index` active: [`Self::ruler_origin_or_zero`]
|
||||
/// with global rulers; with artboard rulers that artboard's top-left corner moved by its own
|
||||
/// offset (the first artboard's when `index` is out of range, the global zero point when the
|
||||
/// document has no artboard or the result is out of the supported range).
|
||||
pub fn ruler_origin_for(&self, index: usize) -> Point {
|
||||
if self.rulers == RulerMode::Global {
|
||||
return self.ruler_origin_or_zero();
|
||||
}
|
||||
let Some(ab) = self.artboards.get(index).or_else(|| self.artboards.first()) else { return self.ruler_origin_or_zero() };
|
||||
let corner = Point::new(ab.rect.x0, ab.rect.y0);
|
||||
let offset = self.artboard_ruler_origins.get(&ab.id).copied().unwrap_or(Vec2::ZERO);
|
||||
let origin = corner + offset;
|
||||
if origin.is_finite() && origin.x.abs() <= MAX_COORD && origin.y.abs() <= MAX_COORD {
|
||||
origin
|
||||
} else if corner.is_finite() && corner.x.abs() <= MAX_COORD && corner.y.abs() <= MAX_COORD {
|
||||
corner
|
||||
} else {
|
||||
Point::ZERO
|
||||
}
|
||||
}
|
||||
|
||||
/// A new RGB document with one artboard of `size` and one layer ("Layer 1").
|
||||
pub fn new(width: f64, height: f64) -> Self {
|
||||
Self::new_with_mode(width, height, ColorMode::Rgb)
|
||||
@@ -773,6 +843,8 @@ impl Document {
|
||||
template: false,
|
||||
units: Unit::Points,
|
||||
ruler_origin: Point::ZERO,
|
||||
rulers: RulerMode::Artboard,
|
||||
artboard_ruler_origins: BTreeMap::new(),
|
||||
color_mode: mode,
|
||||
artboards: vec![Artboard {
|
||||
id: 1,
|
||||
@@ -810,6 +882,7 @@ impl Document {
|
||||
metadata: DocMetadata::default(),
|
||||
raster_effects: RasterEffectsSettings::default(),
|
||||
last_view: None,
|
||||
transparency_grid: false,
|
||||
layers_open: None,
|
||||
color_profiles: ColorProfiles::default(),
|
||||
export_settings: Default::default(),
|
||||
@@ -818,6 +891,7 @@ impl Document {
|
||||
slices_clip_to_artboard: true,
|
||||
print_setup: None,
|
||||
assets: vec![],
|
||||
svg_scripts: vec![],
|
||||
puppet: None,
|
||||
dimensions: false,
|
||||
index: Default::default(),
|
||||
@@ -1290,9 +1364,24 @@ impl Document {
|
||||
let symbols = self.symbol_boxes();
|
||||
self.layers.iter().fold(None, |acc, l| vectorcraft_geom::union_opt(acc, l.visual_bounds_with(&symbols)))
|
||||
}
|
||||
/// Artboard index containing point `p` (topmost = last).
|
||||
/// Artboard index containing point `p` (topmost = last), its edges included
|
||||
/// ([`Document::artboard_holding`]).
|
||||
pub fn artboard_at(&self, p: Point) -> Option<usize> {
|
||||
self.artboards.iter().rposition(|a| a.rect.contains(p))
|
||||
self.artboard_holding(p, true)
|
||||
}
|
||||
/// Index of the artboard that holds point `p`: the first (the last when `topmost`) that holds it
|
||||
/// inside or on its left or top edge, else the first (the last) that holds it by
|
||||
/// [`artboard_holds`]: on its right or bottom edge, or within a millionth of a point outside an
|
||||
/// edge. On an edge two artboards share, the artboard to the right of it or below it holds the
|
||||
/// point, except at the end of the edge that lies on that artboard's bottom or right edge, such
|
||||
/// as the point where both artboards' bottom edges meet; there the first (the last) holds it.
|
||||
pub fn artboard_holding(&self, p: Point, topmost: bool) -> Option<usize> {
|
||||
let pick = |on: &dyn Fn(Rect) -> bool| {
|
||||
let on = |i: &usize| self.artboards.get(*i).is_some_and(|a| on(a.rect));
|
||||
let mut all = 0..self.artboards.len();
|
||||
if topmost { all.rfind(on) } else { all.find(on) }
|
||||
};
|
||||
pick(&|r| r.contains(p)).or_else(|| pick(&|r| artboard_holds(r, p)))
|
||||
}
|
||||
/// The art that moves with an artboard at `rect`: top-level objects (children of layers and
|
||||
/// sublayers) lying entirely inside it, with symbol instances measured by their art. Locked and
|
||||
@@ -1578,6 +1667,83 @@ impl Document {
|
||||
o.insert(name.into(), serde_json::json!(reg.0));
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the stored registration when a symbol is renamed.
|
||||
pub fn rename_symbol_registration(&mut self, from: &str, to: &str) {
|
||||
if from == to {
|
||||
return;
|
||||
}
|
||||
let reg = self.symbol_registration(from);
|
||||
self.set_symbol_registration(from, SymbolRegistration::CENTER);
|
||||
self.set_symbol_registration(to, reg);
|
||||
}
|
||||
|
||||
/// Symbol Options › Export Type for `name` (default: Movie Clip).
|
||||
pub fn symbol_export_type(&self, name: &str) -> SymbolExportType {
|
||||
self.unknown.get(SYMBOL_EXPORT_TYPE).and_then(|m| m.get(name)).and_then(SymbolExportType::from_value).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Set or clear (when Movie Clip, the default) the export type stored for `name`.
|
||||
pub fn set_symbol_export_type(&mut self, name: &str, kind: SymbolExportType) {
|
||||
if kind.is_default() {
|
||||
self.clear_symbol_export_type(name);
|
||||
return;
|
||||
}
|
||||
let m = self.unknown.entry(SYMBOL_EXPORT_TYPE.to_string()).or_insert_with(|| serde_json::json!({}));
|
||||
let Some(o) = m.as_object_mut() else { return };
|
||||
o.insert(name.into(), serde_json::json!(kind.id()));
|
||||
}
|
||||
|
||||
/// Drop any stored export type for `name` (Movie Clip default).
|
||||
pub fn clear_symbol_export_type(&mut self, name: &str) {
|
||||
let Some(m) = self.unknown.get_mut(SYMBOL_EXPORT_TYPE) else { return };
|
||||
let Some(o) = m.as_object_mut() else { return };
|
||||
o.remove(name);
|
||||
if o.is_empty() {
|
||||
self.unknown.remove(SYMBOL_EXPORT_TYPE);
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the stored export type when a symbol is renamed.
|
||||
pub fn rename_symbol_export_type(&mut self, from: &str, to: &str) {
|
||||
if from == to {
|
||||
return;
|
||||
}
|
||||
let kind = self.symbol_export_type(from);
|
||||
self.clear_symbol_export_type(from);
|
||||
self.set_symbol_export_type(to, kind);
|
||||
}
|
||||
|
||||
/// Symbol Options › 9-slice guides for `name`: `None` when 9-slice scaling is off (default).
|
||||
pub fn symbol_nine_slice(&self, name: &str) -> Option<SymbolNineSlice> {
|
||||
self.unknown.get(SYMBOL_NINE_SLICE).and_then(|m| m.get(name)).and_then(SymbolNineSlice::from_value)
|
||||
}
|
||||
|
||||
/// Turn 9-slice scaling on with `guides`, or off with `None`, for `name`.
|
||||
pub fn set_symbol_nine_slice(&mut self, name: &str, guides: Option<SymbolNineSlice>) {
|
||||
let Some(g) = guides else {
|
||||
let Some(o) = self.unknown.get_mut(SYMBOL_NINE_SLICE).and_then(|m| m.as_object_mut()) else { return };
|
||||
o.remove(name);
|
||||
if o.is_empty() {
|
||||
self.unknown.remove(SYMBOL_NINE_SLICE);
|
||||
}
|
||||
return;
|
||||
};
|
||||
let m = self.unknown.entry(SYMBOL_NINE_SLICE.to_string()).or_insert_with(|| serde_json::json!({}));
|
||||
if let Some(o) = m.as_object_mut() {
|
||||
o.insert(name.into(), g.to_value());
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the stored 9-slice guides when a symbol is renamed.
|
||||
pub fn rename_symbol_nine_slice(&mut self, from: &str, to: &str) {
|
||||
if from == to {
|
||||
return;
|
||||
}
|
||||
let g = self.symbol_nine_slice(from);
|
||||
self.set_symbol_nine_slice(from, None);
|
||||
self.set_symbol_nine_slice(to, g);
|
||||
}
|
||||
}
|
||||
|
||||
fn default_graphic_styles() -> Vec<GraphicStyle> {
|
||||
@@ -1696,6 +1862,44 @@ mod tests {
|
||||
assert_eq!(d.art_on_artboard(Rect::new(0.0, 0.0, 15.0, 15.0), false), vec![id]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artboard_at_counts_the_right_and_bottom_edges() {
|
||||
// Artboard 0 is (0, 0) to (100, 100), artboard 1 is to its right and artboard 2 below it.
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
for rect in [Rect::new(100.0, 0.0, 200.0, 100.0), Rect::new(0.0, 100.0, 100.0, 200.0)] {
|
||||
let mut a = d.artboards[0].clone();
|
||||
a.rect = rect;
|
||||
d.artboards.push(a);
|
||||
}
|
||||
let held = |d: &Document, x: f64, y: f64| (d.artboard_at(Point::new(x, y)), d.artboard_holding(Point::new(x, y), false));
|
||||
// Each artboard's own right and bottom edges, edges with roundoff, and points beyond them.
|
||||
assert_eq!(held(&d, 200.0, 50.0), (Some(1), Some(1)), "right edge");
|
||||
assert_eq!(held(&d, 150.0, 100.0), (Some(1), Some(1)), "bottom edge");
|
||||
assert_eq!(held(&d, 200.0, 100.0), (Some(1), Some(1)), "bottom right corner");
|
||||
assert_eq!(held(&d, 100.0, 150.0), (Some(2), Some(2)), "right edge");
|
||||
assert_eq!(held(&d, 50.0, 200.0 + 1e-9), (Some(2), Some(2)), "roundoff past the bottom edge");
|
||||
assert_eq!(held(&d, -1e-9, 50.0), (Some(0), Some(0)), "roundoff past the left edge");
|
||||
assert_eq!(held(&d, 200.001, 50.0), (None, None));
|
||||
assert_eq!(held(&d, 50.0, 200.001), (None, None));
|
||||
// On an edge two artboards share, the artboard to the right of it or below it holds the
|
||||
// point, in either list order, except at the end of the edge that lies on that artboard's
|
||||
// bottom or right edge.
|
||||
assert_eq!(held(&d, 100.0, 50.0), (Some(1), Some(1)));
|
||||
assert_eq!(held(&d, 50.0, 100.0), (Some(2), Some(2)));
|
||||
// The bottom edges of artboards 0 and 1 meet at (100, 100), which is also artboard 2's top
|
||||
// right corner: the first holds the point, or the last when `topmost`.
|
||||
assert_eq!(held(&d, 100.0, 100.0), (Some(2), Some(0)), "shared corner");
|
||||
d.artboards.swap(0, 1);
|
||||
assert_eq!(held(&d, 100.0, 50.0), (Some(0), Some(0)));
|
||||
// Where two artboards overlap, the topmost holds the point, or the first one when not
|
||||
// `topmost`.
|
||||
let mut inner = d.artboards[1].clone();
|
||||
inner.rect = Rect::new(10.0, 10.0, 90.0, 90.0);
|
||||
d.artboards.push(inner);
|
||||
assert_eq!(held(&d, 50.0, 50.0), (Some(3), Some(1)));
|
||||
assert_eq!(held(&d, 90.0, 50.0), (Some(1), Some(1)), "inside artboard 1, on the right edge of artboard 3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_and_paths() {
|
||||
let (d, a, b) = doc_with_rects();
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
//! Live Paint groups' Gap Options (Object › Live Paint › Gap Options): whether paint stops at small
|
||||
//! gaps between the group's paths, how wide a gap it stops at, and the color gaps preview in.
|
||||
//! A group without options has Gap Detection off.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::node::{LAYER_COLORS, Node};
|
||||
|
||||
/// Paint Stops At: the widest gap Gap Detection closes.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum GapSize {
|
||||
/// Small Gaps: [`GapSize::SMALL`] points.
|
||||
#[default]
|
||||
Small,
|
||||
/// Medium Gaps: [`GapSize::MEDIUM`] points.
|
||||
Medium,
|
||||
/// Large Gaps: [`GapSize::LARGE`] points.
|
||||
Large,
|
||||
/// Custom: this many points, over 0 up to [`GapSize::MAX`].
|
||||
Custom(f64),
|
||||
}
|
||||
|
||||
impl GapSize {
|
||||
pub const SMALL: f64 = 3.0;
|
||||
pub const MEDIUM: f64 = 6.0;
|
||||
pub const LARGE: f64 = 12.0;
|
||||
/// The widest custom gap.
|
||||
pub const MAX: f64 = 72.0;
|
||||
|
||||
/// The preset sizes with their ids, smallest first.
|
||||
pub const PRESETS: [(Self, &'static str); 3] = [(Self::Small, "small"), (Self::Medium, "medium"), (Self::Large, "large")];
|
||||
|
||||
/// The widest gap it closes, in points.
|
||||
pub fn points(self) -> f64 {
|
||||
match self {
|
||||
Self::Small => Self::SMALL,
|
||||
Self::Medium => Self::MEDIUM,
|
||||
Self::Large => Self::LARGE,
|
||||
Self::Custom(pt) => pt,
|
||||
}
|
||||
}
|
||||
|
||||
/// The id of a preset size (`small`, `medium` or `large`); None for a custom size.
|
||||
pub fn id(self) -> Option<&'static str> {
|
||||
Self::PRESETS.iter().find(|(s, _)| *s == self).map(|(_, id)| *id)
|
||||
}
|
||||
|
||||
/// A preset size by its id (any case), or a custom size from a number of points over 0 up to
|
||||
/// [`GapSize::MAX`].
|
||||
pub fn parse(v: &serde_json::Value) -> Option<Self> {
|
||||
match v {
|
||||
serde_json::Value::String(id) => Self::PRESETS.iter().find(|(_, i)| i.eq_ignore_ascii_case(id.trim())).map(|(s, _)| *s),
|
||||
v => v.as_f64().filter(|pt| *pt > 0.0 && *pt <= Self::MAX).map(Self::Custom),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Gap Preview Color for a group that has none set: the layer color Red.
|
||||
pub const DEFAULT_GAP_COLOR: [u8; 3] = LAYER_COLORS[1].1;
|
||||
|
||||
/// A Live Paint group's Gap Options.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct GapOptions {
|
||||
/// Gap Detection: paint stops at gaps up to `size` wide.
|
||||
pub detect: bool,
|
||||
/// Paint Stops At.
|
||||
pub size: GapSize,
|
||||
/// Gap Preview Color: the color gaps show in.
|
||||
pub color: [u8; 3],
|
||||
}
|
||||
|
||||
impl Default for GapOptions {
|
||||
fn default() -> Self {
|
||||
Self { detect: false, size: GapSize::Small, color: DEFAULT_GAP_COLOR }
|
||||
}
|
||||
}
|
||||
|
||||
impl GapOptions {
|
||||
/// The widest gap paint stops at, in points; None with Gap Detection off.
|
||||
pub fn max_gap(&self) -> Option<f64> {
|
||||
self.detect.then(|| self.size.points())
|
||||
}
|
||||
}
|
||||
|
||||
impl Node {
|
||||
/// The Gap Options of a Live Paint group (the defaults when it has none set).
|
||||
pub fn gap_options(&self) -> GapOptions {
|
||||
self.extras().live_paint_gaps.as_deref().copied().unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn sizes_parse_from_ids_and_points() {
|
||||
for (size, id) in GapSize::PRESETS {
|
||||
assert_eq!(GapSize::parse(&json!(id)), Some(size));
|
||||
assert_eq!(GapSize::parse(&json!(id.to_ascii_uppercase())), Some(size));
|
||||
assert_eq!(size.id(), Some(id));
|
||||
}
|
||||
assert_eq!(GapSize::parse(&json!("Medium")).map(GapSize::points), Some(6.0));
|
||||
assert_eq!(GapSize::parse(&json!("large")).map(GapSize::points), Some(12.0));
|
||||
assert_eq!(GapSize::parse(&json!(4.5)), Some(GapSize::Custom(4.5)));
|
||||
assert_eq!(GapSize::Custom(4.5).points(), 4.5);
|
||||
assert_eq!(GapSize::Custom(4.5).id(), None);
|
||||
for bad in [json!(0), json!(-1), json!(72.5), json!("tiny"), json!(null), json!(true)] {
|
||||
assert_eq!(GapSize::parse(&bad), None, "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_round_trip_and_default_to_no_detection() {
|
||||
let mut g = Node::group(crate::NodeId(1), vec![]);
|
||||
assert_eq!(g.gap_options(), GapOptions::default());
|
||||
assert_eq!(g.gap_options().max_gap(), None);
|
||||
let set = GapOptions { detect: true, size: GapSize::Custom(5.0), color: [1, 2, 3] };
|
||||
g.extras_mut().live_paint_gaps = Some(Box::new(set));
|
||||
assert_eq!(g.gap_options().max_gap(), Some(5.0));
|
||||
let text = serde_json::to_string(&g).unwrap();
|
||||
let back: Node = serde_json::from_str(&text).unwrap();
|
||||
assert_eq!(back.gap_options(), set);
|
||||
let bare: GapOptions = serde_json::from_value(json!({"detect": true})).unwrap();
|
||||
assert_eq!(bare, GapOptions { detect: true, ..GapOptions::default() });
|
||||
}
|
||||
}
|
||||
@@ -250,6 +250,7 @@ fn node_bytes(n: &Node, other: Option<&Node>) -> usize {
|
||||
if let Some(e) = n.extras_box() {
|
||||
b += ALLOC + size_of::<NodeExtras>();
|
||||
b += boxed(&e.mask) + boxed(&e.graph) + boxed(&e.shaper) + boxed(&e.attrs) + boxed(&e.slice) + boxed(&e.perspective);
|
||||
b += boxed(&e.dimension) + boxed(&e.live_paint_gaps);
|
||||
b += e.trace.as_ref().map_or(0, |t| ALLOC + size_of::<Value>() + json_bytes(t));
|
||||
b += e.intertwine.as_deref().map_or(0, |a| ALLOC + size_of::<PathData>() + path_bytes(a));
|
||||
}
|
||||
|
||||
@@ -570,6 +570,8 @@ pub struct NodeExtras {
|
||||
/// Object › Intertwine: as a member of an Intertwine group, the area (document coordinates,
|
||||
/// filled non-zero) where this object is drawn over the group's other members.
|
||||
pub intertwine: Option<Box<PathData>>,
|
||||
/// Live Paint group: its Gap Options (Object › Live Paint › Gap Options; `None`: the defaults).
|
||||
pub live_paint_gaps: Option<Box<crate::live_paint::GapOptions>>,
|
||||
}
|
||||
|
||||
/// No extras: what [`Node::extras`] gives for a node without any.
|
||||
@@ -587,6 +589,7 @@ static NO_EXTRAS: NodeExtras = NodeExtras {
|
||||
perspective: None,
|
||||
dimension: None,
|
||||
intertwine: None,
|
||||
live_paint_gaps: None,
|
||||
};
|
||||
|
||||
/// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal
|
||||
@@ -720,6 +723,7 @@ impl Serialize for Node {
|
||||
e.perspective.is_none(),
|
||||
e.dimension.is_none(),
|
||||
e.intertwine.is_none(),
|
||||
e.live_paint_gaps.is_none(),
|
||||
];
|
||||
let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?;
|
||||
let mut skip = skips.into_iter();
|
||||
@@ -757,6 +761,7 @@ impl Serialize for Node {
|
||||
field!("perspective", &e.perspective);
|
||||
field!("dimension", &e.dimension);
|
||||
field!("intertwine", &e.intertwine);
|
||||
field!("live_paint_gaps", &e.live_paint_gaps);
|
||||
st.end()
|
||||
}
|
||||
}
|
||||
@@ -814,6 +819,8 @@ struct NodeWire {
|
||||
dimension: Option<Box<crate::dimension::Dimension>>,
|
||||
#[serde(default)]
|
||||
intertwine: Option<Box<PathData>>,
|
||||
#[serde(default)]
|
||||
live_paint_gaps: Option<Box<crate::live_paint::GapOptions>>,
|
||||
}
|
||||
|
||||
impl From<NodeWire> for Node {
|
||||
@@ -832,6 +839,7 @@ impl From<NodeWire> for Node {
|
||||
perspective: w.perspective,
|
||||
dimension: w.dimension,
|
||||
intertwine: w.intertwine,
|
||||
live_paint_gaps: w.live_paint_gaps,
|
||||
};
|
||||
Node {
|
||||
id: w.id,
|
||||
@@ -1217,6 +1225,13 @@ impl Node {
|
||||
let o = self.appearance.outset() + symbols.map_or(0.0, |s| s.brush_pad(symbol));
|
||||
self.projected(Some(b)).map(|b| b.inflate(o, o))
|
||||
}
|
||||
NodeKind::Text(t) => {
|
||||
// The characters' strokes paint around the glyphs, scaled with the type.
|
||||
let k = Scaling::factor(t.xf).unwrap_or(1.0);
|
||||
let chars = t.runs.iter().map(|r| r.style.stroke_outset()).fold(0.0, f64::max) * k;
|
||||
let o = self.appearance.outset().max(chars);
|
||||
self.geometric(symbols).map(|b| b.inflate(o, o))
|
||||
}
|
||||
_ => {
|
||||
let o = self.appearance.outset();
|
||||
self.geometric(symbols).map(|b| b.inflate(o, o))
|
||||
@@ -1699,8 +1714,41 @@ pub struct ObjectAttributes {
|
||||
/// `("pivot", "100,180")`), kept from import to export (`object.setProps {data}`).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub data: Vec<(String, String)>,
|
||||
/// Window › SVG Interactivity: the JavaScript each event runs, in the panel's order
|
||||
/// (`("onclick", "alert(1)")`, one entry per event of [`SVG_EVENTS`]). SVG export writes them as
|
||||
/// the element's event attributes; nothing in VectorCraft runs them.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub events: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// The events the SVG Interactivity panel attaches JavaScript to, by the names of the SVG event
|
||||
/// attributes that run it.
|
||||
pub const SVG_EVENTS: [&str; 19] = [
|
||||
"onfocusin",
|
||||
"onfocusout",
|
||||
"onactivate",
|
||||
"onmousedown",
|
||||
"onmouseup",
|
||||
"onclick",
|
||||
"onmouseover",
|
||||
"onmousemove",
|
||||
"onmouseout",
|
||||
"onkeydown",
|
||||
"onkeypress",
|
||||
"onkeyup",
|
||||
"onload",
|
||||
"onerror",
|
||||
"onabort",
|
||||
"onunload",
|
||||
"onzoom",
|
||||
"onresize",
|
||||
"onscroll",
|
||||
];
|
||||
|
||||
/// The most JavaScript files a document links (`Document::svg_scripts`): SVG import keeps the
|
||||
/// first ones and warns about the rest, and `svgInteractivity.addFile` refuses another.
|
||||
pub const MAX_SVG_SCRIPTS: usize = 64;
|
||||
|
||||
/// The Attributes panel's Image Map shapes.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
@@ -2015,6 +2063,7 @@ mod tests {
|
||||
e.attrs = Some(Box::default());
|
||||
e.slice = Some(Box::default());
|
||||
e.perspective = Some(Box::new(crate::PerspectiveAttachment::new("left", 1.0)));
|
||||
e.live_paint_gaps = Some(Box::default());
|
||||
}
|
||||
/// The top-level keys of a JSON object, in the order written.
|
||||
struct Keys(Vec<String>);
|
||||
@@ -2056,6 +2105,7 @@ mod tests {
|
||||
"slice",
|
||||
"bbox_angle",
|
||||
"perspective",
|
||||
"live_paint_gaps",
|
||||
];
|
||||
assert_eq!(keys, order);
|
||||
assert_eq!(serde_json::from_value::<Node>(v).unwrap(), n);
|
||||
@@ -2336,6 +2386,25 @@ mod tests {
|
||||
assert_eq!(p.visual_bounds(), Some(Rect::new(-0.5, -0.5, 10.5, 10.5)));
|
||||
}
|
||||
|
||||
/// Type's visual bounds grow by how far its character strokes paint past the glyphs: half the
|
||||
/// weight centred, the whole weight outside, nothing inside; scaled with the type.
|
||||
#[test]
|
||||
fn type_visual_bounds_grow_with_the_character_stroke() {
|
||||
use crate::appearance::StrokeAlign;
|
||||
use crate::text::{CharStyle, TextObject};
|
||||
let style = CharStyle { stroke: vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), stroke_width: 4.0, ..Default::default() };
|
||||
let mut t = TextObject::point(vectorcraft_geom::Point::new(0.0, 0.0), "Ab", style);
|
||||
t.xf = Affine::scale(2.0) * t.xf;
|
||||
let node = |t: &TextObject| Node::new(NodeId(1), NodeKind::Text(Box::new(t.clone())));
|
||||
let g = node(&t).geometric_bounds().unwrap();
|
||||
for (align, grow) in [(StrokeAlign::Center, 4.0), (StrokeAlign::Outside, 8.0), (StrokeAlign::Inside, 0.0)] {
|
||||
t.runs[0].style.stroke_align = align;
|
||||
assert_eq!(node(&t).visual_bounds(), Some(g.inflate(grow, grow)), "{align:?}");
|
||||
}
|
||||
t.runs[0].style.stroke = vectorcraft_color::Paint::None;
|
||||
assert_eq!(node(&t).visual_bounds(), Some(g), "no stroke, no growth");
|
||||
}
|
||||
|
||||
/// A group holding a path, a compound, an image and a nested group, as a file writes it.
|
||||
fn nested_group() -> Node {
|
||||
let path = |id| Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
|
||||
|
||||
@@ -94,26 +94,27 @@ impl Node {
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the overprinting fills and strokes of `a`'s subtree with Multiply (those with a blend
|
||||
/// Draw the overprinting fills and strokes of `a`'s subtree with Darken (those with a blend
|
||||
/// mode of their own keep it), copying only the nodes on the way to them: on screen and on a
|
||||
/// printed plate this approximates their inks printing over the inks below. With `discard_white`
|
||||
/// printed plate this approximates their inks printing over the inks below. Darken keeps the
|
||||
/// stronger ink of each plate (and each additive channel), so a colour overprinting itself stays
|
||||
/// that colour, where Multiply would darken it. With `discard_white`
|
||||
/// (Document Setup → Discard White Overprint) white fills and strokes knock out instead.
|
||||
pub fn multiply_overprints(a: &mut Arc<Node>, discard_white: bool) {
|
||||
pub fn darken_overprints(a: &mut Arc<Node>, discard_white: bool) {
|
||||
if !a.has_overprint() {
|
||||
return;
|
||||
}
|
||||
let n = Arc::make_mut(a);
|
||||
let multiplies =
|
||||
|overprint: bool, blend: BlendMode, paint: &Paint| overprint && blend == BlendMode::Normal && !(discard_white && is_white(paint));
|
||||
let darkens = |overprint: bool, blend: BlendMode, paint: &Paint| overprint && blend == BlendMode::Normal && !(discard_white && is_white(paint));
|
||||
for it in &mut n.appearance.items {
|
||||
match it {
|
||||
crate::AppearanceItem::Fill(l) if multiplies(l.overprint, l.blend, &l.paint) => l.blend = BlendMode::Multiply,
|
||||
crate::AppearanceItem::Stroke(l) if multiplies(l.overprint, l.blend, &l.paint) => l.blend = BlendMode::Multiply,
|
||||
crate::AppearanceItem::Fill(l) if darkens(l.overprint, l.blend, &l.paint) => l.blend = BlendMode::Darken,
|
||||
crate::AppearanceItem::Stroke(l) if darkens(l.overprint, l.blend, &l.paint) => l.blend = BlendMode::Darken,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
multiply_overprints(c, discard_white);
|
||||
darken_overprints(c, discard_white);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -248,9 +248,20 @@ impl PatternDef {
|
||||
let ab = self.art_bounds().unwrap_or(self.tile).union(self.tile) - self.tile.origin().to_vec2();
|
||||
let (sx, sy) = self.tile_type.steps(w, h);
|
||||
// Shifts of brick/hex rows or columns are < one tile.
|
||||
let (i0, i1) = (((region.x0 - ab.x1 - w) / sx).floor() as i64 - 1, ((region.x1 - ab.x0 + w) / sx).ceil() as i64 + 1);
|
||||
let (j0, j1) = (((region.y0 - ab.y1 - h) / sy).floor() as i64 - 1, ((region.y1 - ab.y0 + h) / sy).ceil() as i64 + 1);
|
||||
if (i1 - i0).saturating_mul(j1 - j0) > 250_000 {
|
||||
// A region whose tile indices do not fit in integers (or are not finite) gives none, as does
|
||||
// one with more than 250,000 positions or none.
|
||||
let index = |v: f64| (v.abs() < 9.0e18).then_some(v as i64);
|
||||
let (Some(i0), Some(i1), Some(j0), Some(j1)) = (
|
||||
index(((region.x0 - ab.x1 - w) / sx).floor()),
|
||||
index(((region.x1 - ab.x0 + w) / sx).ceil()),
|
||||
index(((region.y0 - ab.y1 - h) / sy).floor()),
|
||||
index(((region.y1 - ab.y0 + h) / sy).ceil()),
|
||||
) else {
|
||||
return vec![];
|
||||
};
|
||||
let (i0, i1, j0, j1) = (i0 - 1, i1 + 1, j0 - 1, j1 + 1);
|
||||
let (ni, nj) = (i1.saturating_sub(i0), j1.saturating_sub(j0));
|
||||
if ni < 1 || nj < 1 || ni.saturating_mul(nj) > 250_000 {
|
||||
return vec![];
|
||||
}
|
||||
let mut out = vec![];
|
||||
@@ -282,8 +293,15 @@ impl PatternDef {
|
||||
out
|
||||
}
|
||||
/// Instances covering `region` (document space) as plain nodes for a pattern painted with
|
||||
/// `paint_xf` (pattern space → document), fresh-id-less (id 0). Used by exporters and Expand.
|
||||
/// `paint_xf` (pattern space → document), fresh-id-less (id 0). Used by exporters and Distort
|
||||
/// Pattern Fills; Expand uses [`Self::instances_in_with`].
|
||||
pub fn instances_in(&self, paint_xf: Affine, region: Rect) -> Vec<Node> {
|
||||
self.instances_in_with(paint_xf, region, false)
|
||||
}
|
||||
/// [`Self::instances_in`], each instance placed with `scaling` (what scales with `paint_xf`
|
||||
/// besides geometry).
|
||||
pub fn instances_in_with(&self, paint_xf: Affine, region: Rect, scaling: impl Into<crate::node::Scaling>) -> Vec<Node> {
|
||||
let scaling = scaling.into();
|
||||
let inv = paint_xf.inverse();
|
||||
let pr = inv.transform_rect_bbox(region);
|
||||
let mut out = vec![];
|
||||
@@ -291,7 +309,7 @@ impl PatternDef {
|
||||
let m = paint_xf * self.instance_xf(o);
|
||||
for a in &self.art {
|
||||
let mut n = (**a).clone();
|
||||
n.transform(m, false);
|
||||
place_art(&mut n, m, scaling);
|
||||
zero_ids(&mut n);
|
||||
out.push(n);
|
||||
}
|
||||
@@ -300,6 +318,31 @@ impl PatternDef {
|
||||
}
|
||||
}
|
||||
|
||||
/// Transform pattern art `n` by `m` with `scaling`, as a pattern places its art: the opacity masks
|
||||
/// in it move with it, unlinked ones included (the canvas draws a tile's art and masks together).
|
||||
pub fn place_art(n: &mut Node, m: Affine, scaling: impl Into<crate::node::Scaling>) {
|
||||
let scaling = scaling.into();
|
||||
n.transform(m, scaling);
|
||||
place_unlinked_masks(n, m, scaling);
|
||||
}
|
||||
|
||||
/// Transform the art of the unlinked opacity masks of `n` and its descendants by `m`, the masks
|
||||
/// inside mask art included.
|
||||
fn place_unlinked_masks(n: &mut Node, m: Affine, scaling: crate::node::Scaling) {
|
||||
if let Some(mask) = n.extras_if_any().and_then(|e| e.mask.as_mut()) {
|
||||
let linked = mask.linked;
|
||||
let art = Arc::make_mut(&mut mask.art);
|
||||
// A linked mask's art moved with `n`; an unlinked one's moves here.
|
||||
if !linked {
|
||||
art.transform(m, scaling);
|
||||
}
|
||||
place_unlinked_masks(art, m, scaling);
|
||||
}
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
place_unlinked_masks(Arc::make_mut(c), m, scaling);
|
||||
}
|
||||
}
|
||||
|
||||
fn zero_ids(n: &mut Node) {
|
||||
n.id = NodeId(0);
|
||||
if let Some(ch) = n.children_mut() {
|
||||
@@ -670,6 +713,18 @@ mod tests {
|
||||
assert!(close(*d2.offsets_covering(Rect::new(0.0, 0.0, 30.0, 20.0)).last().unwrap(), Vec2::new(20.0, 10.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_region_too_large_to_count_has_no_offsets() {
|
||||
// A pattern scaled by 1e-20 covers a 100 pt square with a region of 1e22 pt.
|
||||
let def = PatternDef::new("p", vec![sq(0.0, 0.0, 10.0)]);
|
||||
assert!(def.offsets_covering(Rect::new(-1e22, -1e22, 1e22, 1e22)).is_empty());
|
||||
assert!(def.offsets_covering(Rect::new(f64::NEG_INFINITY, 0.0, f64::INFINITY, 10.0)).is_empty());
|
||||
// Far out, a region one tile wide and 1e12 pt tall still counts its rows.
|
||||
assert!(def.offsets_covering(Rect::new(1e18, 0.0, 1e18, 1e12)).is_empty());
|
||||
let tiny = Affine::scale(1e-20);
|
||||
assert!(def.instances_in(tiny, Rect::new(0.0, 0.0, 100.0, 100.0)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instances_follow_paint_xf() {
|
||||
let def = PatternDef::new("p", vec![sq(100.0, 100.0, 10.0)]);
|
||||
@@ -679,6 +734,33 @@ mod tests {
|
||||
assert!(n.iter().all(|n| n.id == NodeId(0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instances_carry_unlinked_masks_with_them() {
|
||||
let mut art = (*sq(100.0, 100.0, 10.0)).clone();
|
||||
let mut mask = crate::node::OpacityMask::new((*sq(100.0, 100.0, 5.0)).clone(), true);
|
||||
mask.linked = false;
|
||||
art.extras_mut().mask = Some(Box::new(mask));
|
||||
let def = PatternDef::new("p", vec![Arc::new(art)]);
|
||||
for n in def.instances_in(Affine::translate((3.0, 0.0)), Rect::new(0.0, 0.0, 20.0, 10.0)) {
|
||||
let (b, m) = (n.geometric_bounds().unwrap(), n.extras().mask.as_ref().unwrap());
|
||||
assert_eq!(m.art.geometric_bounds().unwrap().origin(), b.origin(), "the mask moves with its art");
|
||||
assert!(!m.linked, "the mask stays unlinked");
|
||||
}
|
||||
// An unlinked mask inside a linked mask's art moves too.
|
||||
let mut inner = (*sq(100.0, 100.0, 5.0)).clone();
|
||||
let mut nested = crate::node::OpacityMask::new((*sq(100.0, 100.0, 2.0)).clone(), true);
|
||||
nested.linked = false;
|
||||
inner.extras_mut().mask = Some(Box::new(nested));
|
||||
let mut art = (*sq(100.0, 100.0, 10.0)).clone();
|
||||
art.extras_mut().mask = Some(Box::new(crate::node::OpacityMask::new(inner, true)));
|
||||
let def = PatternDef::new("p", vec![Arc::new(art)]);
|
||||
for n in def.instances_in(Affine::translate((3.0, 0.0)), Rect::new(0.0, 0.0, 20.0, 10.0)) {
|
||||
let outer = n.extras().mask.as_ref().unwrap();
|
||||
let nested = outer.art.extras().mask.as_ref().unwrap();
|
||||
assert_eq!(nested.art.geometric_bounds().unwrap().origin(), n.geometric_bounds().unwrap().origin());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_offsets_count() {
|
||||
let mut def = PatternDef::new("p", vec![sq(0.0, 0.0, 10.0)]);
|
||||
|
||||
+124
-5
@@ -11,15 +11,20 @@ use crate::ImageBlob;
|
||||
/// address and aborts when an allocation fails, so it keeps the decoder's own 512 MB.
|
||||
pub const MAX_RASTER_ALLOC: u64 = if cfg!(target_arch = "wasm32") { 512 << 20 } else { 2 << 30 };
|
||||
|
||||
/// The pixels of encoded image `bytes` as RGBA; `None` when they can't be decoded or would take
|
||||
/// more than `max` bytes as RGBA. The size is read from the header first: the decoder's own limit
|
||||
/// is on the file's samples, and a grey image is a quarter of what it becomes here (#1717).
|
||||
/// The pixels of encoded image `bytes` as RGBA; `None` when they can't be decoded, would take
|
||||
/// more than `max` bytes as RGBA, or need more than `max` bytes to decode. The size is read from
|
||||
/// the header first: the decoder's limit is on the file's samples, and a gray image is a quarter
|
||||
/// of what it becomes here (#1717).
|
||||
fn rgba_within(bytes: &[u8], max: u64) -> Option<image::RgbaImage> {
|
||||
let (w, h) = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_dimensions().ok()?;
|
||||
if u64::from(w) * u64::from(h) * 4 > max {
|
||||
if u64::from(w) * u64::from(h) > max / 4 {
|
||||
return None;
|
||||
}
|
||||
Some(image::load_from_memory(bytes).ok()?.to_rgba8())
|
||||
let mut reader = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?;
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_alloc = Some(max);
|
||||
reader.limits(limits);
|
||||
Some(reader.decode().ok()?.into_rgba8())
|
||||
}
|
||||
|
||||
impl ImageBlob {
|
||||
@@ -74,6 +79,59 @@ impl ImageBlob {
|
||||
sample_pixels(&img, x * k(img.width(), object.0), y * k(img.height(), object.1), size)
|
||||
}
|
||||
|
||||
/// The colours of a `rows` × `cols` mesh laid over the image: `(rows + 1) * (cols + 1)` straight
|
||||
/// RGBA samples, row-major, one per grid line crossing (row 0 is the top). Each is the
|
||||
/// alpha-weighted average of the area around the crossing, one grid cell wide and high (a
|
||||
/// quarter of each of the neighbouring cells), not a single pixel. A crossing over transparent
|
||||
/// pixels gets their average colour at alpha 0. `None` when `rows` or `cols` is 0 or the
|
||||
/// image can't be decoded.
|
||||
pub fn grid_samples(&self, rows: u32, cols: u32) -> Option<Vec<[u8; 4]>> {
|
||||
if rows == 0 || cols == 0 {
|
||||
return None;
|
||||
}
|
||||
let img = self.decoded()?;
|
||||
let (w, h) = (img.width() as usize, img.height() as usize);
|
||||
if w == 0 || h == 0 {
|
||||
return None;
|
||||
}
|
||||
// The pixel columns [from, to) around grid line `i` of `n`, at least one pixel wide.
|
||||
let span = |i: u32, n: u32, len: usize| {
|
||||
let at = |k: f64| (k / n as f64 * len as f64).round().clamp(0.0, len as f64) as usize;
|
||||
let (from, to) = (at(i as f64 - 0.5), at(i as f64 + 0.5));
|
||||
let from = from.min(len - 1);
|
||||
(from, to.max(from + 1))
|
||||
};
|
||||
let mut out = Vec::with_capacity((rows as usize + 1) * (cols as usize + 1));
|
||||
for r in 0..=rows {
|
||||
let (y0, y1) = span(r, rows, h);
|
||||
for c in 0..=cols {
|
||||
let (x0, x1) = span(c, cols, w);
|
||||
let (mut sum, mut plain, mut alpha, mut n) = ([0u64; 3], [0u64; 3], 0u64, 0u64);
|
||||
for y in y0..y1 {
|
||||
for x in x0..x1 {
|
||||
let [pr, pg, pb, pa] = img.get_pixel(x as u32, y as u32).0;
|
||||
let a = pa as u64;
|
||||
for (k, v) in [pr, pg, pb].into_iter().enumerate() {
|
||||
sum[k] += v as u64 * a;
|
||||
plain[k] += v as u64;
|
||||
}
|
||||
alpha += a;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
let n = n.max(1);
|
||||
out.push(if alpha == 0 {
|
||||
let m = |k: usize| ((plain[k] + n / 2) / n) as u8;
|
||||
[m(0), m(1), m(2), 0]
|
||||
} else {
|
||||
let m = |k: usize| ((sum[k] + alpha / 2) / alpha) as u8;
|
||||
[m(0), m(1), m(2), ((alpha + n / 2) / n) as u8]
|
||||
});
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The decoded pixels (the last image decoded is kept for the next sample).
|
||||
fn decoded(&self) -> Option<Arc<image::RgbaImage>> {
|
||||
thread_local! {
|
||||
@@ -131,6 +189,35 @@ mod tests {
|
||||
image::load_from_memory(&b.bytes).unwrap().to_rgba8().pixels().map(|p| p.0).collect()
|
||||
}
|
||||
|
||||
/// A GIF whose `w` × `h` screen holds one opaque red pixel at the top left. The file holds no
|
||||
/// other pixels, and the decoder makes them transparent.
|
||||
fn gif_with_one_red_pixel(w: u16, h: u16) -> Vec<u8> {
|
||||
let mut gif = b"GIF89a".to_vec();
|
||||
gif.extend_from_slice(&w.to_le_bytes());
|
||||
gif.extend_from_slice(&h.to_le_bytes());
|
||||
// Flags for a global color table of two colors, the background and aspect bytes, then the
|
||||
// table: red and black.
|
||||
gif.extend_from_slice(&[0x80, 0, 0, 255, 0, 0, 0, 0, 0]);
|
||||
// A 1 × 1 image at (0, 0), its LZW data (clear, color 0, end), and the end of the file.
|
||||
gif.extend_from_slice(&[0x2C, 0, 0, 0, 0, 1, 0, 1, 0, 0, 2, 2, 0x44, 0x01, 0, 0x3B]);
|
||||
gif
|
||||
}
|
||||
|
||||
/// The pixels are decoded within the allowance, not within the decoder's own 512 MB: an RGBA
|
||||
/// image of 11,586 × 11,586 pixels (536,941,584 bytes) is recolored and sampled.
|
||||
#[test]
|
||||
fn pixels_past_the_decoders_own_limit_are_decoded_within_the_allowance() {
|
||||
let (w, h) = (11_586, 11_586);
|
||||
let rgba = u64::from(w) * u64::from(h) * 4;
|
||||
assert!(rgba > 512 << 20);
|
||||
let gif = gif_with_one_red_pixel(w, h);
|
||||
assert!(rgba_within(&gif, rgba - 1).is_none(), "past the allowance");
|
||||
let b = ImageBlob::new("image/gif", gif);
|
||||
assert!(b.map_rgb(|[r, g, bl]| [bl, g, r]).is_some(), "red becomes blue");
|
||||
assert_eq!(b.sample(0.5, 0.5, 1), Some([255, 0, 0, 255]));
|
||||
assert_eq!(b.sample(f64::from(w) - 0.5, f64::from(h) - 0.5, 1), None, "transparent");
|
||||
}
|
||||
|
||||
/// #1717: the RGBA size is checked from the header, before anything is decoded: a grey image
|
||||
/// the decoder's own limit lets through is four times that as RGBA.
|
||||
#[test]
|
||||
@@ -143,6 +230,38 @@ mod tests {
|
||||
assert!(rgba_within(b"not an image", u64::MAX).is_none());
|
||||
}
|
||||
|
||||
/// A little-endian TIFF whose header gives `w` × `h` 8-bit gray pixels in one strip. The file holds
|
||||
/// no pixel data.
|
||||
fn tiff_header(w: u32, h: u32) -> Vec<u8> {
|
||||
let mut out = b"II*\0".to_vec();
|
||||
out.extend(8u32.to_le_bytes());
|
||||
// Each entry is (tag, type: 3 SHORT or 4 LONG, value) and holds one value.
|
||||
let entries: [(u16, u16, u32); 9] =
|
||||
[(256, 4, w), (257, 4, h), (258, 3, 8), (259, 3, 1), (262, 3, 1), (273, 4, 8), (277, 3, 1), (278, 4, h), (279, 4, 0)];
|
||||
out.extend((entries.len() as u16).to_le_bytes());
|
||||
for (tag, kind, value) in entries {
|
||||
out.extend(tag.to_le_bytes());
|
||||
out.extend(kind.to_le_bytes());
|
||||
out.extend(1u32.to_le_bytes());
|
||||
out.extend(value.to_le_bytes());
|
||||
}
|
||||
out.extend(0u32.to_le_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
/// A TIFF's sides go up to 2^32 - 1 pixels: a size whose RGBA bytes don't fit in 64 bits is
|
||||
/// refused like any other past the allowance, and an image within the allowance still decodes.
|
||||
#[test]
|
||||
fn a_size_past_64_bits_as_rgba_is_not_decoded() {
|
||||
let huge = tiff_header(1 << 31, 1 << 31);
|
||||
assert!(rgba_within(&huge, MAX_RASTER_ALLOC).is_none());
|
||||
assert!(rgba_within(&huge, u64::MAX).is_none());
|
||||
let gray = image::GrayImage::from_raw(4, 2, vec![7; 8]).unwrap();
|
||||
let mut png = Vec::new();
|
||||
gray.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
assert_eq!(rgba_within(&png, 35).map(|i| i.dimensions()), Some((4, 2)), "32 bytes as RGBA, within 35");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn samples_a_pixel_or_the_average_around_it() {
|
||||
let b = blob(&[[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [9, 9, 9, 0]], 4, 1);
|
||||
|
||||
@@ -192,6 +192,16 @@ impl Background {
|
||||
|
||||
/// Largest bleed on any side, in points (1 inch).
|
||||
pub const MAX_BLEED: f64 = 72.0;
|
||||
/// Largest superscript or subscript size, and the farthest its baseline moves either way, in
|
||||
/// percent of the font size.
|
||||
pub const MAX_SCRIPT: f64 = 500.0;
|
||||
/// Largest synthesized small capitals size, in percent of the font size.
|
||||
pub const MAX_SMALL_CAPS: f64 = 200.0;
|
||||
|
||||
/// Returns `bleed` with each side kept to 0–[`MAX_BLEED`] points; a side that isn't finite becomes 0.
|
||||
pub fn bleed_in_range(bleed: [f64; 4]) -> [f64; 4] {
|
||||
bleed.map(|b| if b.is_finite() { b.clamp(0.0, MAX_BLEED) } else { 0.0 })
|
||||
}
|
||||
|
||||
/// File → Document Setup.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -274,6 +284,18 @@ impl DocSetup {
|
||||
pub fn flattener(&self) -> &str {
|
||||
self.flattener_preset.as_deref().unwrap_or(DEFAULT_FLATTENER)
|
||||
}
|
||||
/// Moves the bleed and the type proportions into the ranges Document Setup takes, each to the
|
||||
/// nearer end: the bleed by [`bleed_in_range`], superscript and subscript sizes to
|
||||
/// 1–[`MAX_SCRIPT`] and positions to ±[`MAX_SCRIPT`], the small caps size to
|
||||
/// 1–[`MAX_SMALL_CAPS`].
|
||||
pub fn keep_in_range(&mut self) {
|
||||
self.bleed = bleed_in_range(self.bleed);
|
||||
for m in [&mut self.superscript, &mut self.subscript] {
|
||||
m.size = m.size.clamp(1.0, MAX_SCRIPT);
|
||||
m.position = m.position.clamp(-MAX_SCRIPT, MAX_SCRIPT);
|
||||
}
|
||||
self.small_caps_size = self.small_caps_size.clamp(1.0, MAX_SMALL_CAPS);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
//! Symbol Options › Export Type: Graphic or Movie Clip
|
||||
//! ([helpx Symbol Options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html)).
|
||||
//!
|
||||
//! For Illustrator-only work the choice has no canvas effect; it is kept for Animate / Flash /
|
||||
//! HTML5 handoff. Illustrator's default is Movie Clip. Stored under [`SYMBOL_EXPORT_TYPE`] keyed
|
||||
//! by symbol name; the default (Movie Clip) is omitted from the map.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
/// Document unknown key: `{ symbolName: "graphic"|"movieClip" }`.
|
||||
pub const SYMBOL_EXPORT_TYPE: &str = "symbolExportType";
|
||||
|
||||
/// Symbol Options › Export Type.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum SymbolExportType {
|
||||
/// Static / graphic symbol instance (Animate Graphic).
|
||||
Graphic,
|
||||
/// Default: Movie Clip for Animate / game handoff; instances still place the same on the canvas.
|
||||
#[default]
|
||||
MovieClip,
|
||||
}
|
||||
|
||||
impl SymbolExportType {
|
||||
pub const IDS: [&'static str; 2] = ["movieClip", "graphic"];
|
||||
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Self::Graphic => "graphic",
|
||||
Self::MovieClip => "movieClip",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_id(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"graphic" => Some(Self::Graphic),
|
||||
"movieClip" => Some(Self::MovieClip),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// True when this is the document default (nothing stored for the symbol).
|
||||
pub fn is_default(self) -> bool {
|
||||
matches!(self, Self::MovieClip)
|
||||
}
|
||||
|
||||
pub fn from_value(v: &Value) -> Option<Self> {
|
||||
v.as_str().and_then(Self::from_id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//! Symbol Options › Enable Guides for 9-Slice Scaling: four guides that cut a symbol into a 3 × 3
|
||||
//! grid so that, in Animate / Flash handoff, the corners keep their size while the edges and the
|
||||
//! centre stretch
|
||||
//! ([helpx Symbol Options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html)).
|
||||
//!
|
||||
//! The guides are stored as fractions (0..1) of the symbol's natural size, measured from the left
|
||||
//! and top edges and from the right and bottom edges, so they survive resizing the art. Like
|
||||
//! [`crate::SymbolExportType`] this is handoff metadata: instances place the same on the canvas.
|
||||
//! Stored under [`SYMBOL_NINE_SLICE`] keyed by symbol name; symbols without 9-slice have no entry.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Document unknown key: `{ symbolName: [left, top, right, bottom] }`.
|
||||
pub const SYMBOL_NINE_SLICE: &str = "symbolNineSlice";
|
||||
|
||||
/// The four 9-slice guides as fractions of the symbol's natural size.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct SymbolNineSlice {
|
||||
/// Left guide, from the left edge.
|
||||
pub left: f64,
|
||||
/// Top guide, from the top edge.
|
||||
pub top: f64,
|
||||
/// Right guide, from the right edge.
|
||||
pub right: f64,
|
||||
/// Bottom guide, from the bottom edge.
|
||||
pub bottom: f64,
|
||||
}
|
||||
|
||||
impl Default for SymbolNineSlice {
|
||||
/// The centre third of each axis is the stretchy cell.
|
||||
fn default() -> Self {
|
||||
let t = 1.0 / 3.0;
|
||||
Self { left: t, top: t, right: t, bottom: t }
|
||||
}
|
||||
}
|
||||
|
||||
impl SymbolNineSlice {
|
||||
/// Guides from `[left, top, right, bottom]`; `None` unless each is finite, within 0..1, and the
|
||||
/// two on each axis leave a gap between them.
|
||||
pub fn new(left: f64, top: f64, right: f64, bottom: f64) -> Option<Self> {
|
||||
let ok = |v: f64| v.is_finite() && (0.0..=1.0).contains(&v);
|
||||
(ok(left) && ok(top) && ok(right) && ok(bottom) && left + right < 1.0 && top + bottom < 1.0).then_some(Self { left, top, right, bottom })
|
||||
}
|
||||
|
||||
pub fn to_value(self) -> Value {
|
||||
json!([self.left, self.top, self.right, self.bottom])
|
||||
}
|
||||
|
||||
pub fn from_value(v: &Value) -> Option<Self> {
|
||||
let a = v.as_array()?;
|
||||
let n = |i: usize| a.get(i).and_then(Value::as_f64);
|
||||
if a.len() != 4 {
|
||||
return None;
|
||||
}
|
||||
Self::new(n(0)?, n(1)?, n(2)?, n(3)?)
|
||||
}
|
||||
}
|
||||
+72
-5
@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_geom::{Affine, PathData, Point, Rect, Vec2};
|
||||
|
||||
use crate::appearance::{Appearance, AppearanceItem, Dash, FillLayer, LineCap, LineJoin, StrokeLayer};
|
||||
use crate::appearance::{Appearance, AppearanceItem, Dash, FillLayer, LineCap, LineJoin, StrokeAlign, StrokeLayer};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Justify {
|
||||
@@ -84,6 +84,10 @@ pub struct CharStyle {
|
||||
pub stroke_miter_limit: f64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub stroke_dash: Option<Dash>,
|
||||
/// Align Stroke of the character stroke (Stroke panel, with type selected): centred on the
|
||||
/// glyph outlines, inside or outside them, as for object strokes; the type stays live.
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub stroke_align: StrokeAlign,
|
||||
/// Superscript or subscript (Character panel).
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub position: CharPosition,
|
||||
@@ -249,6 +253,7 @@ impl Default for CharStyle {
|
||||
stroke_join: LineJoin::Miter,
|
||||
stroke_miter_limit: 10.0,
|
||||
stroke_dash: None,
|
||||
stroke_align: StrokeAlign::Center,
|
||||
position: CharPosition::Normal,
|
||||
char_align: CharAlign::RomanBaseline,
|
||||
proportional_metrics: false,
|
||||
@@ -266,8 +271,9 @@ impl CharStyle {
|
||||
pub fn has_stroke(&self) -> bool {
|
||||
!self.stroke.is_none() && self.stroke_width > 0.0
|
||||
}
|
||||
/// The character stroke as a stroke layer (paint, weight, cap, join, miter limit, dashes and
|
||||
/// overprint), so type strokes share the object strokes' geometry, rendering and export.
|
||||
/// The character stroke as a stroke layer (paint, weight, cap, join, miter limit, dashes,
|
||||
/// alignment and overprint), so type strokes share the object strokes' geometry, rendering
|
||||
/// and export.
|
||||
pub fn stroke_layer(&self) -> StrokeLayer {
|
||||
StrokeLayer {
|
||||
overprint: self.overprint_stroke,
|
||||
@@ -275,11 +281,13 @@ impl CharStyle {
|
||||
join: self.stroke_join,
|
||||
miter_limit: self.stroke_miter_limit,
|
||||
dash: self.stroke_dash.clone(),
|
||||
align: self.stroke_align,
|
||||
..StrokeLayer::new(self.stroke.clone(), self.stroke_width)
|
||||
}
|
||||
}
|
||||
/// Take the paint, weight, cap, join, miter limit and dashes of `st` as the character stroke
|
||||
/// (the options characters have no use for, such as alignment and arrowheads, are dropped).
|
||||
/// Take the paint, weight, cap, join, miter limit, dashes and alignment of `st` as the
|
||||
/// character stroke (the options characters have no use for, such as arrowheads and width
|
||||
/// profiles, are dropped).
|
||||
pub fn set_stroke_layer(&mut self, st: &StrokeLayer) {
|
||||
self.stroke = st.paint.clone();
|
||||
self.stroke_width = st.width;
|
||||
@@ -287,6 +295,19 @@ impl CharStyle {
|
||||
self.stroke_join = st.join;
|
||||
self.stroke_miter_limit = st.miter_limit;
|
||||
self.stroke_dash = st.dash.clone();
|
||||
self.stroke_align = st.align;
|
||||
}
|
||||
/// How far the character stroke paints past the glyph outlines (text space): half the weight
|
||||
/// centred, the whole weight outside, nothing inside (miter spikes aside).
|
||||
pub fn stroke_outset(&self) -> f64 {
|
||||
if !self.has_stroke() {
|
||||
return 0.0;
|
||||
}
|
||||
match self.stroke_align {
|
||||
StrokeAlign::Center => self.stroke_width / 2.0,
|
||||
StrokeAlign::Outside => self.stroke_width,
|
||||
StrokeAlign::Inside => 0.0,
|
||||
}
|
||||
}
|
||||
/// The character fill as a fill layer, overprinting as the characters do.
|
||||
fn fill_layer(&self) -> FillLayer {
|
||||
@@ -360,6 +381,37 @@ impl Composer {
|
||||
}
|
||||
}
|
||||
|
||||
/// A paragraph's hyphenation options (Paragraph panel menu › Hyphenation…): which words automatic
|
||||
/// hyphenation may break, where, and on how many lines in a row. Saved only when they differ from
|
||||
/// the defaults: words longer than 5 letters, 3 letters on each side of the hyphen, no hyphen
|
||||
/// limit, no hyphenation zone, and capitalized words hyphenated.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct HyphenationOptions {
|
||||
/// Words Longer Than: only words with more letters than this are hyphenated.
|
||||
pub words_longer_than: u32,
|
||||
/// After First: the fewest letters a word keeps before the hyphen.
|
||||
pub after_first: u32,
|
||||
/// Before Last: the fewest letters a word keeps after the hyphen.
|
||||
pub before_last: u32,
|
||||
/// Hyphen Limit: the most consecutive lines that automatic hyphenation may end with a hyphen;
|
||||
/// 0 sets no limit.
|
||||
pub hyphen_limit: u32,
|
||||
/// Hyphenation Zone (pt): with the Single-line Composer, a line whose text before the next
|
||||
/// word already ends within this distance of its right edge breaks before that word instead
|
||||
/// of hyphenating it. 0 allows every hyphenation.
|
||||
pub zone: f64,
|
||||
/// Hyphenate Capitalized Words: when it is off, words that start with a capital letter are not
|
||||
/// hyphenated. Words in capitals only are never hyphenated.
|
||||
pub capitalized: bool,
|
||||
}
|
||||
|
||||
impl Default for HyphenationOptions {
|
||||
fn default() -> Self {
|
||||
Self { words_longer_than: 5, after_first: 3, before_last: 3, hyphen_limit: 0, zone: 0.0, capitalized: true }
|
||||
}
|
||||
}
|
||||
|
||||
/// Paragraph attributes (the Paragraph panel).
|
||||
///
|
||||
/// Saved through [`ParaStyleFile`], which keeps files with text openable by builds from before
|
||||
@@ -374,6 +426,8 @@ pub struct ParaStyle {
|
||||
pub space_before: f64,
|
||||
pub space_after: f64,
|
||||
pub hyphenate: bool,
|
||||
/// The limits automatic hyphenation keeps to (Paragraph panel menu › Hyphenation…).
|
||||
pub hyphenation: HyphenationOptions,
|
||||
/// Line breaking: Single-line or Every-line Composer.
|
||||
pub composer: Composer,
|
||||
/// Tab stops (Tabs panel), sorted by position.
|
||||
@@ -421,6 +475,8 @@ struct ParaStyleFile {
|
||||
space_after: f64,
|
||||
#[serde(default)]
|
||||
hyphenate: bool,
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
hyphenation: HyphenationOptions,
|
||||
#[serde(default, skip_serializing_if = "Composer::is_default")]
|
||||
composer: Composer,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
@@ -454,6 +510,7 @@ impl From<ParaStyle> for ParaStyleFile {
|
||||
space_before,
|
||||
space_after,
|
||||
hyphenate,
|
||||
hyphenation,
|
||||
composer,
|
||||
tabs,
|
||||
style_name,
|
||||
@@ -473,6 +530,7 @@ impl From<ParaStyle> for ParaStyleFile {
|
||||
space_before,
|
||||
space_after,
|
||||
hyphenate,
|
||||
hyphenation,
|
||||
composer,
|
||||
tabs,
|
||||
style_name,
|
||||
@@ -495,6 +553,7 @@ impl From<ParaStyleFile> for ParaStyle {
|
||||
space_before,
|
||||
space_after,
|
||||
hyphenate,
|
||||
hyphenation,
|
||||
composer,
|
||||
tabs,
|
||||
style_name,
|
||||
@@ -513,6 +572,7 @@ impl From<ParaStyleFile> for ParaStyle {
|
||||
space_before,
|
||||
space_after,
|
||||
hyphenate,
|
||||
hyphenation,
|
||||
composer,
|
||||
tabs,
|
||||
style_name,
|
||||
@@ -1007,6 +1067,12 @@ pub struct TextObject {
|
||||
/// Wrap objects above this area type, resolved by the engine after each edit (text space).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub wrap: Vec<WrapShape>,
|
||||
/// Type › Optical Margin Alignment: in every paragraph, Latin punctuation and the slanted
|
||||
/// sides of letters such as A and W at a line's start or end hang outside the margin there
|
||||
/// (point and area type of left-to-right paragraphs; see the layout's `optical_hangs`). Off
|
||||
/// for new type and for documents from before it, and saved only when on.
|
||||
#[serde(default, rename = "opticalMarginAlignment", skip_serializing_if = "crate::skip::is_default")]
|
||||
pub optical_margin: bool,
|
||||
/// Cached layout bounds in text space, filled in by the layout engine (not serialized).
|
||||
#[serde(skip)]
|
||||
pub cached_bounds: Option<Rect>,
|
||||
@@ -1072,6 +1138,7 @@ impl TextObject {
|
||||
path_align: PathAlign::default(),
|
||||
path_spacing: 0.0,
|
||||
wrap: Vec::new(),
|
||||
optical_margin: false,
|
||||
cached_bounds: None,
|
||||
cached_baselines: Vec::new(),
|
||||
}
|
||||
|
||||
@@ -116,6 +116,10 @@ pub struct Variables {
|
||||
pub bindings: BTreeMap<NodeId, BTreeSet<String>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub active_dataset: Option<String>,
|
||||
/// Lock Variables: while set, variables can't be defined, deleted or renamed. Objects can
|
||||
/// still be bound to them and unbound, and datasets still change and apply.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub locked: bool,
|
||||
}
|
||||
|
||||
impl Variables {
|
||||
@@ -184,20 +188,26 @@ impl Document {
|
||||
self.variables.bindings = bindings;
|
||||
}
|
||||
|
||||
/// Whether a variable may read and rewrite the object `id`: it is in the document, neither
|
||||
/// it nor anything above it is locked, and nothing above it is hidden. Its own visibility
|
||||
/// doesn't count, as that is what a visibility variable drives: a row that hid it must be
|
||||
/// able to show it again, and a capture must record it hidden.
|
||||
/// Whether a variable may be bound to the object `id`: it is in the document, neither it nor
|
||||
/// anything above it is locked, and nothing above it is hidden. Its own visibility doesn't
|
||||
/// count, as that is what a visibility variable drives.
|
||||
pub fn variable_target(&self, id: NodeId) -> bool {
|
||||
self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| !n.locked && (n.visible || *i == id))))
|
||||
}
|
||||
|
||||
/// Whether a data set reads and rewrites the bound object `id`: it is in the document and
|
||||
/// neither it nor anything above it is locked. Hidden objects count, and so do objects in a
|
||||
/// hidden layer or group.
|
||||
pub fn dataset_target(&self, id: NodeId) -> bool {
|
||||
self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| !n.locked)))
|
||||
}
|
||||
|
||||
/// What the object `id` holds for the variable of `kind`: its characters, whether it shows,
|
||||
/// or a linked image's path. `None` when the object is gone, is not of that kind, has no
|
||||
/// link path yet (an embedded image), or is locked or on a hidden or locked layer (the
|
||||
/// link path yet (an embedded image), or is locked or in a locked layer or group (the
|
||||
/// document's value is whatever it was left at).
|
||||
pub fn variable_value(&self, id: NodeId, kind: VariableKind) -> Option<DataValue> {
|
||||
if !self.variable_target(id) {
|
||||
if !self.dataset_target(id) {
|
||||
return None;
|
||||
}
|
||||
let node = self.node(id)?;
|
||||
|
||||
+17
-12
@@ -27,7 +27,8 @@ pub fn to_object_name(id: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// The XML id for an object name (the inverse of [`to_object_name`]).
|
||||
/// The XML id for an object name (the inverse of [`to_object_name`]). The empty name stays empty,
|
||||
/// since a bare `_` would read back as a space.
|
||||
pub fn from_object_name(name: &str) -> String {
|
||||
let mut out = String::with_capacity(name.len().saturating_mul(2));
|
||||
for (i, c) in name.chars().enumerate() {
|
||||
@@ -35,17 +36,14 @@ pub fn from_object_name(name: &str) -> String {
|
||||
out.push('_');
|
||||
} else if c == '_' {
|
||||
out.push_str("_x5F_");
|
||||
} else if i == 0 && c.is_ascii_digit() {
|
||||
} else if (i == 0 && !(c.is_alphabetic() || c == ':')) || !(c.is_alphanumeric() || c == '.' || c == '-' || c == ':') {
|
||||
// A first character that cannot start an XML name is escaped, never given a bare `_`
|
||||
// prefix (that would read back as a space).
|
||||
out.push_str(&format!("_x{:X}_", u32::from(c)));
|
||||
} else if c.is_alphanumeric() || c == '.' || c == '-' || c == ':' {
|
||||
out.push(c);
|
||||
} else {
|
||||
out.push_str(&format!("_x{:X}_", u32::from(c)));
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
if out.is_empty() || !starts_name(&out) {
|
||||
out.insert(0, '_');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -66,10 +64,6 @@ pub fn is_valid(id: &str) -> bool {
|
||||
chars.all(|c| c.is_alphanumeric() || matches!(c, '_' | '.' | '-' | ':'))
|
||||
}
|
||||
|
||||
fn starts_name(id: &str) -> bool {
|
||||
id.starts_with(|c: char| c.is_alphabetic() || c == '_' || c == ':')
|
||||
}
|
||||
|
||||
/// `_xHHHH_` at the start of `s`: the character and the escape's length.
|
||||
fn escaped(s: &str) -> Option<(char, usize)> {
|
||||
let body = s.strip_prefix("_x")?;
|
||||
@@ -114,4 +108,15 @@ mod tests {
|
||||
assert!(is_valid("Layer_1") && is_valid("_x31_abc") && is_valid(":root"));
|
||||
assert!(!is_valid("My Rect") && !is_valid("1abc") && !is_valid(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_names_that_cannot_start_an_xml_name() {
|
||||
for name in ["-abc", ".5 mm", "²x", "Ⅷ", "", "1abc", "Layer 1", " lead", "_a", ":a", "#"] {
|
||||
let id = from_object_name(name);
|
||||
assert_eq!(to_object_name(&id), name, "name {name:?} -> id {id:?}");
|
||||
}
|
||||
assert_eq!(from_object_name("-abc"), "_x2D_abc");
|
||||
assert_eq!(from_object_name(".5 mm"), "_x2E_5_mm");
|
||||
assert_eq!(from_object_name("²x"), "_xB2_x");
|
||||
}
|
||||
}
|
||||
@@ -490,4 +490,20 @@ mod tests {
|
||||
assert_eq!(out.appearance.items[0].paint(), p.appearance.items[0].paint());
|
||||
assert!(out.appearance.items[1].effects().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjust_recolours_the_points_of_a_freeform_gradient() {
|
||||
use vectorcraft_doc::color::{Freeform, FreeformPoint, Gradient, GradientKind, GradientPaint};
|
||||
let mut gp = GradientPaint::new(Gradient { kind: GradientKind::Freeform, ..Gradient::default() });
|
||||
let pt = |x, c| FreeformPoint::new(kurbo::Point::new(x, 5.0), c);
|
||||
gp.set_freeform(Freeform { points: vec![pt(2.0, Color::rgb(1.0, 0.0, 0.0)), pt(8.0, Color::rgb(0.0, 0.0, 1.0))], ..Default::default() });
|
||||
let fill = Appearance::basic(Paint::Gradient(Box::new(gp)), Paint::None, 0.0);
|
||||
let mut p = Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), fill);
|
||||
p.appearance.effects.push(Effect { id: "adjust.hueSaturation".into(), params: json!({"hue": 120}), visible: true });
|
||||
let out = adjust(&p, None, &mut |_, _| None).unwrap();
|
||||
let Paint::Gradient(g) = out.appearance.fill_paint() else { panic!("a gradient fill") };
|
||||
// The points are what a freeform gradient draws from, not its stops.
|
||||
let hexes: Vec<String> = g.freeform.as_ref().unwrap().points.iter().map(|p| p.color.to_hex()).collect();
|
||||
assert_eq!(hexes, ["#00ff00", "#ff0000"]);
|
||||
}
|
||||
}
|
||||
+135
-7
@@ -28,7 +28,7 @@ use crate::group::{has_own_paint, paints};
|
||||
use crate::reshape::{as_art, geometry_effects, glyph_outlines, outline_nested_text, reshape_leaves};
|
||||
use crate::{
|
||||
GeomContext, adjust_in_document, apply_geometry_with, crop_marks_art, evaluate_container, has_adjustment, has_crop_marks, has_geometry,
|
||||
has_pathfinder, is_geometry, is_pathfinder, merged_params, needs_outline, outline_text, reshape,
|
||||
has_item_geometry, has_pathfinder, is_geometry, is_pathfinder, merged_params, needs_outline, outline_text, reshape,
|
||||
};
|
||||
|
||||
fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] {
|
||||
@@ -89,7 +89,8 @@ fn path_kind(d: &mut Document, path: PathData, rule: FillRule) -> NodeKind {
|
||||
|
||||
/// `n` with its geometry effects evaluated (`None` = unchanged).
|
||||
fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
|
||||
if !needs_bake(n) {
|
||||
// A cancelled export leaves the rest of the document as it is: the caller drops the result.
|
||||
if !needs_bake(n) || vectorcraft_doc::cancel::is_cancelled() {
|
||||
return None;
|
||||
}
|
||||
// Colour adjustments: the object recoloured (inside it too), then baked as it is.
|
||||
@@ -106,9 +107,10 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
|
||||
let m = evaluate_container(&m).unwrap_or(m);
|
||||
return Some(bake_pieces(d, m));
|
||||
}
|
||||
// Type, images, symbol instances and live objects: reshaped through their outlines. Type that
|
||||
// Type, images, symbol instances and live objects: reshaped through their outlines, and so is
|
||||
// type whose own fills or strokes have geometry effects ([`has_item_geometry`]). Type that
|
||||
// Transform effects only move and copy stays type, moved to each place.
|
||||
if needs_outline(n) && (has_geometry(&n.appearance.effects) || n.projection().is_some()) {
|
||||
if needs_outline(n) && (has_geometry(&n.appearance.effects) || n.projection().is_some() || has_item_geometry(n)) {
|
||||
if let Some(m) = moved_type_group(n) {
|
||||
return Some(bake_pieces(d, m));
|
||||
}
|
||||
@@ -119,6 +121,13 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
|
||||
let m = reshape(n, symbol.as_deref())?;
|
||||
return Some(bake_node(d, &m).unwrap_or(m));
|
||||
}
|
||||
// A path whose Transform effect copies it with a gradient: each copy an object of its own, so
|
||||
// its gradient fits it rather than spanning every copy.
|
||||
if crate::has_gradient_copies(n)
|
||||
&& let Some(m) = reshape(n, None)
|
||||
{
|
||||
return Some(bake_pieces(d, m));
|
||||
}
|
||||
// Groups and layers: Pathfinder, geometry effects and their own fills and strokes (type that
|
||||
// Transform effects only move stays type). The new pieces (results, outlines, copies, the
|
||||
// fills' and strokes' art) get ids of their own.
|
||||
@@ -504,7 +513,8 @@ pub fn expand_art(d: &mut Document, n: &mut Node, stroke_art: StrokeArt) {
|
||||
/// Envelope Options → Distort Appearance: path or compound path `n` as plain filled art that an
|
||||
/// envelope bends as a whole: its geometry effects applied, every visible fill a copy of the path
|
||||
/// painted by that fill and every visible stroke without a brush its filled outline in the
|
||||
/// stroke's paint, opacity and blend mode (a brushed stroke stays a stroke), in paint order, as
|
||||
/// stroke's paint, opacity, blend mode and raster effects (a gradient along or across the stroke:
|
||||
/// its gradient meshes clipped to the outline; a brushed stroke stays a stroke), in paint order, as
|
||||
/// the members of a group keeping `n`'s id, transparency, opacity mask and raster effects. `None`
|
||||
/// when it has neither a stroke to outline nor a geometry effect, so bending first changes nothing.
|
||||
pub fn bake_appearance(n: &Node) -> Option<Node> {
|
||||
@@ -534,8 +544,28 @@ pub fn bake_appearance(n: &Node) -> Option<Node> {
|
||||
clear_item_effects(&mut it);
|
||||
pieces.push(match &it {
|
||||
AppearanceItem::Stroke(st) if st.brush.is_none() => {
|
||||
let fill = FillLayer { opacity: st.opacity, blend: st.blend, ..FillLayer::new(st.paint.clone()) };
|
||||
piece(crate::stroke::outline_region(&ig, rule, st), FillRule::NonZero, AppearanceItem::Fill(fill))
|
||||
let outline = crate::stroke::outline_region(&ig, rule, st);
|
||||
match st.path_gradient() {
|
||||
// A gradient along or across the stroke: its gradient meshes clipped to the outline.
|
||||
Some(g) => {
|
||||
let mut clip = Node::path(n.id, outline, Appearance::default());
|
||||
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
|
||||
*clipping = true;
|
||||
}
|
||||
let meshes = crate::stroke::gradient_meshes(&ig.to_bezpath(), rule, st, &g.gradient);
|
||||
let meshes = meshes.into_iter().map(|m| Node::new(n.id, NodeKind::Mesh(m)));
|
||||
let children = std::iter::once(clip).chain(meshes).map(Arc::new).collect();
|
||||
let mut group = Node::new(n.id, NodeKind::Group { children, clip: true });
|
||||
(group.opacity, group.blend) = (st.opacity, st.blend);
|
||||
group.appearance.effects = st.effects.clone();
|
||||
Arc::new(group)
|
||||
}
|
||||
None => {
|
||||
let effects = st.effects.clone();
|
||||
let fill = FillLayer { opacity: st.opacity, blend: st.blend, effects, ..FillLayer::new(st.paint.clone()) };
|
||||
piece(outline, FillRule::NonZero, AppearanceItem::Fill(fill))
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => piece(ig, rule, it),
|
||||
});
|
||||
@@ -571,6 +601,15 @@ mod tests {
|
||||
use vectorcraft_doc::{CharStyle, Effect, NodeId, TextObject};
|
||||
use vectorcraft_geom::{Point, Rect, shapes};
|
||||
|
||||
/// Type tests need glyph outlines, which only the optional craft-fonts build input provides.
|
||||
fn skip_without_craft_fonts() -> bool {
|
||||
let skip = vectorcraft_text::CRAFT_FONTS.is_empty();
|
||||
if skip {
|
||||
eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)");
|
||||
}
|
||||
skip
|
||||
}
|
||||
|
||||
fn fx(id: &str, params: serde_json::Value) -> Effect {
|
||||
Effect { id: id.into(), params, visible: true }
|
||||
}
|
||||
@@ -618,6 +657,36 @@ mod tests {
|
||||
assert!(ch[0].id != ch[1].id && ch[0].id != NodeId(50));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_baked_stroke_keeps_its_raster_effects() {
|
||||
let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art());
|
||||
if let AppearanceItem::Stroke(s) = &mut n.appearance.items[1] {
|
||||
s.effects.push(fx("path.offsetPath", json!({"offset": 5.0})));
|
||||
s.effects.push(fx("stylize.dropShadow", json!({})));
|
||||
}
|
||||
let m = bake_appearance(&n).unwrap();
|
||||
let ch = m.children().unwrap();
|
||||
let Some(AppearanceItem::Fill(f)) = ch[1].appearance.items.first() else { panic!("the outline is filled") };
|
||||
assert_eq!(f.effects.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["stylize.dropShadow"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_baked_stroke_with_a_gradient_along_it_becomes_meshes_clipped_to_its_outline() {
|
||||
let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art());
|
||||
if let AppearanceItem::Stroke(s) = &mut n.appearance.items[1] {
|
||||
s.width = 10.0;
|
||||
s.paint = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(Default::default())));
|
||||
s.gradient_mode = vectorcraft_doc::StrokeGradientMode::Along;
|
||||
s.opacity = 0.5;
|
||||
}
|
||||
let m = bake_appearance(&n).unwrap();
|
||||
let ch = m.children().unwrap();
|
||||
let NodeKind::Group { children, clip: true } = &ch[1].kind else { panic!("a clip group: {:?}", ch[1].kind) };
|
||||
assert!(matches!(children[0].kind, NodeKind::Path { clipping: true, .. }));
|
||||
assert!(children.len() > 1 && children[1..].iter().all(|c| matches!(c.kind, NodeKind::Mesh(_))), "gradient meshes");
|
||||
assert_eq!(ch[1].opacity, 0.5);
|
||||
}
|
||||
|
||||
/// Black 24 pt "Type" with its baseline at (20, 60).
|
||||
fn type_node(id: u64) -> Node {
|
||||
let style = CharStyle { size: 24.0, fill: Paint::solid(Color::BLACK), ..Default::default() };
|
||||
@@ -646,6 +715,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn type_moved_or_copied_by_a_groups_transform_effect_stays_type() {
|
||||
if skip_without_craft_fonts() {
|
||||
return;
|
||||
}
|
||||
for (params, origins, square) in [
|
||||
(json!({"moveH": 30}), vec![(50.0, 60.0)], Rect::new(30.0, 0.0, 40.0, 10.0)),
|
||||
(json!({"moveH": 200, "copies": 1}), vec![(20.0, 60.0), (220.0, 60.0)], Rect::new(0.0, 0.0, 210.0, 10.0)),
|
||||
@@ -677,6 +749,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn hidden_type_in_a_group_that_keeps_its_type_stays_hidden() {
|
||||
if skip_without_craft_fonts() {
|
||||
return;
|
||||
}
|
||||
let mut g = type_group(vec![fx("distort.transform", json!({"moveH": 200, "copies": 1}))]);
|
||||
let mut hidden = type_node(55);
|
||||
hidden.visible = false;
|
||||
@@ -688,6 +763,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn type_outlined_next_to_type_that_stays_type_keeps_its_look() {
|
||||
if skip_without_craft_fonts() {
|
||||
return;
|
||||
}
|
||||
let mut g = type_group(vec![fx("distort.transform", json!({"moveH": 200, "copies": 1}))]);
|
||||
let mut shadowed = type_node(55);
|
||||
shadowed.opacity = 0.5;
|
||||
@@ -710,6 +788,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn type_in_a_clipping_group_with_a_fill_of_its_own_is_outlined_plain() {
|
||||
if skip_without_craft_fonts() {
|
||||
return;
|
||||
}
|
||||
let mut g = type_group(vec![fx("distort.transform", json!({"moveH": 200, "copies": 1}))]);
|
||||
// The clipping path is a group with a fill of its own, holding type with an effect of its own.
|
||||
let mut moved = type_node(60);
|
||||
@@ -728,6 +809,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn type_with_its_own_transform_effect_copies_stays_type() {
|
||||
if skip_without_craft_fonts() {
|
||||
return;
|
||||
}
|
||||
let mut n = type_node(50);
|
||||
n.opacity = 0.5;
|
||||
n.appearance.effects = vec![fx("distort.transform", json!({"moveV": 50, "copies": 2})), fx("stylize.dropShadow", json!({}))];
|
||||
@@ -774,6 +858,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn type_whose_copies_paint_over_each_other_is_still_outlined() {
|
||||
if skip_without_craft_fonts() {
|
||||
return;
|
||||
}
|
||||
let moves = vec![fx("distort.transform", json!({"moveV": 40, "copies": 1}))];
|
||||
assert_eq!(type_origins(&bake_document(&doc_with(type_group(moves.clone()))).unwrap()).len(), 2);
|
||||
// A 20 pt stroke on the characters reaches the copy below.
|
||||
@@ -789,6 +876,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn type_with_a_look_of_its_own_in_a_moved_group_is_still_outlined() {
|
||||
if skip_without_craft_fonts() {
|
||||
return;
|
||||
}
|
||||
let moves = vec![fx("distort.transform", json!({"moveV": 40, "copies": 1}))];
|
||||
let mut translucent = type_node(50);
|
||||
translucent.opacity = 0.5;
|
||||
@@ -861,6 +951,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn type_whose_copies_hold_too_much_is_still_outlined() {
|
||||
if skip_without_craft_fonts() {
|
||||
return;
|
||||
}
|
||||
let spaced = |spaces: usize| {
|
||||
let mut n = type_node(50);
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
@@ -898,6 +991,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn type_in_a_pathfinder_group_inside_a_moved_group_is_still_outlined() {
|
||||
if skip_without_craft_fonts() {
|
||||
return;
|
||||
}
|
||||
let mut inner = type_group(vec![fx("pathfinder.add", json!({}))]);
|
||||
inner.id = NodeId(53);
|
||||
let mut outer = Node::group(NodeId(54), vec![Arc::new(inner)]);
|
||||
@@ -931,4 +1027,36 @@ mod tests {
|
||||
// An effect that also scales.
|
||||
assert!(transform_offsets(&[json!({"moveH": 10, "scaleH": 50})], &square).is_none());
|
||||
}
|
||||
/// A 10 pt square painted with a red to blue gradient and a Transform effect with 2 copies.
|
||||
fn gradient_copies(id: u64) -> Node {
|
||||
use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop};
|
||||
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
|
||||
let paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind: GradientKind::Linear, stops })));
|
||||
let mut n = Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(paint, Paint::None, 0.0));
|
||||
n.appearance.effects.push(fx("distort.transform", json!({"moveH": 30, "copies": 2})));
|
||||
n
|
||||
}
|
||||
|
||||
/// The paths under `n` that paint a gradient fitted to their own 10 pt box, left to right.
|
||||
fn gradient_pieces(n: &Node) -> Vec<f64> {
|
||||
let mut out = vec![];
|
||||
let mut walk = vec![n];
|
||||
while let Some(n) = walk.pop() {
|
||||
match n.children() {
|
||||
Some(ch) if !matches!(n.kind, NodeKind::Compound { .. }) => walk.extend(ch.iter().map(|c| &**c)),
|
||||
_ => {
|
||||
assert!(matches!(n.appearance.fill_paint(), Paint::Gradient(_)), "{n:?}");
|
||||
out.extend(n.geometric_bounds().map(|b| b.width()));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exporting_a_path_with_a_transform_gives_each_copy_its_own_gradient() {
|
||||
let d = bake_document(&doc_with(gradient_copies(50))).unwrap();
|
||||
// Three squares of their own, not one path spanning all three.
|
||||
assert_eq!(gradient_pieces(d.node(NodeId(50)).unwrap()), vec![10.0; 3]);
|
||||
}
|
||||
}
|
||||
@@ -267,8 +267,20 @@ pub fn twist(path: &PathData, b: Rect, angle: f64) -> PathData {
|
||||
|
||||
const ZIGZAG_ARCLEN_ACCURACY: f64 = 1e-4;
|
||||
|
||||
/// A Zig Zag ridge: where it sits, the path's unit direction there, and the length along the
|
||||
/// path to the ridges before and after it (zero at an open path's ends).
|
||||
struct Ridge {
|
||||
p: Point,
|
||||
dir: Vec2,
|
||||
gap_in: f64,
|
||||
gap_out: f64,
|
||||
}
|
||||
|
||||
/// Zig Zag: `ridges` extra points per segment, evenly spaced along its length and alternately
|
||||
/// displaced along the normal.
|
||||
/// displaced along the normal. Corner points join them with straight lines. Smooth points make a
|
||||
/// wave: every peak and valley is a smooth point whose handles run along the path, a third of the
|
||||
/// way to the next ridge, so each span between them follows a half cosine (an S through the path)
|
||||
/// whatever the size of the ridges (#2343).
|
||||
pub fn zig_zag(path: &PathData, b: Rect, p: &Value) -> PathData {
|
||||
let size = num(p, "size", 10.0).clamp(-1e4, 1e4);
|
||||
let amp = if flag(p, "relative", false) { size / 100.0 * mean_size(b) } else { size };
|
||||
@@ -277,29 +289,33 @@ pub fn zig_zag(path: &PathData, b: Rect, p: &Value) -> PathData {
|
||||
let mut subs = vec![];
|
||||
for sp in &path.subpaths {
|
||||
let segs = sp.segment_count();
|
||||
let mut pts = vec![];
|
||||
let gap = |i: usize| sp.segment(i).arclen(ZIGZAG_ARCLEN_ACCURACY) / (ridges + 1) as f64;
|
||||
let mut pts: Vec<Ridge> = vec![];
|
||||
let mut k = 0usize;
|
||||
for i in 0..segs {
|
||||
let c = sp.segment(i);
|
||||
// Ridges are spaced by length, not by `t`: a line's retracted handles bunch `t` up
|
||||
// near the anchors, and so does any uneven curve.
|
||||
let len = c.arclen(ZIGZAG_ARCLEN_ACCURACY);
|
||||
let step = len / (ridges + 1) as f64;
|
||||
for j in 0..=ridges {
|
||||
let s = j as f64 / (ridges + 1) as f64;
|
||||
let t = if j > 0 && len > 1e-9 { c.inv_arclen(s * len, ZIGZAG_ARCLEN_ACCURACY).clamp(0.0, 1.0) } else { s };
|
||||
let mut gap_in = step;
|
||||
let nrm = if j == 0 {
|
||||
// Average the normals of the segments meeting at the anchor.
|
||||
let prev = if i > 0 {
|
||||
Some(sp.segment(i - 1))
|
||||
Some(i - 1)
|
||||
} else if sp.closed {
|
||||
Some(sp.segment(segs - 1))
|
||||
Some(segs - 1)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
gap_in = prev.map_or(0.0, gap);
|
||||
let n1 = normal_at(&c, 0.0);
|
||||
match prev {
|
||||
Some(pc) => {
|
||||
let s = n1 + normal_at(&pc, 1.0);
|
||||
Some(pi) => {
|
||||
let s = n1 + normal_at(&sp.segment(pi), 1.0);
|
||||
if s.hypot() > 1e-9 { s / s.hypot() } else { n1 }
|
||||
}
|
||||
None => n1,
|
||||
@@ -308,7 +324,7 @@ pub fn zig_zag(path: &PathData, b: Rect, p: &Value) -> PathData {
|
||||
normal_at(&c, t)
|
||||
};
|
||||
let sign = if k.is_multiple_of(2) { 1.0 } else { -1.0 };
|
||||
pts.push(c.eval(t) + nrm * (amp * sign));
|
||||
pts.push(Ridge { p: c.eval(t) + nrm * (amp * sign), dir: Vec2::new(-nrm.y, nrm.x), gap_in, gap_out: step });
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
@@ -316,12 +332,18 @@ pub fn zig_zag(path: &PathData, b: Rect, p: &Value) -> PathData {
|
||||
if segs > 0 {
|
||||
let c = sp.segment(segs - 1);
|
||||
let sign = if k.is_multiple_of(2) { 1.0 } else { -1.0 };
|
||||
pts.push(c.p3 + normal_at(&c, 1.0) * (amp * sign));
|
||||
let nrm = normal_at(&c, 1.0);
|
||||
pts.push(Ridge { p: c.p3 + nrm * (amp * sign), dir: Vec2::new(-nrm.y, nrm.x), gap_in: gap(segs - 1), gap_out: 0.0 });
|
||||
} else {
|
||||
pts.extend(sp.anchors.iter().map(|a| a.p));
|
||||
pts.extend(sp.anchors.iter().map(|a| Ridge { p: a.p, dir: Vec2::ZERO, gap_in: 0.0, gap_out: 0.0 }));
|
||||
}
|
||||
}
|
||||
subs.push(if smooth { catmull_rom(&pts, sp.closed, 1.0) } else { SubPath::polyline(&pts, sp.closed) });
|
||||
subs.push(if smooth {
|
||||
let anchors = pts.iter().map(|r| Anchor::with_handles(r.p, r.p - r.dir * (r.gap_in / 3.0), r.p + r.dir * (r.gap_out / 3.0))).collect();
|
||||
SubPath::new(anchors, sp.closed)
|
||||
} else {
|
||||
SubPath::polyline(&pts.iter().map(|r| r.p).collect::<Vec<_>>(), sp.closed)
|
||||
});
|
||||
}
|
||||
PathData::new(subs)
|
||||
}
|
||||
@@ -293,6 +293,15 @@ mod tests {
|
||||
use vectorcraft_doc::{CharStyle, Effect, NodeId, TextObject};
|
||||
use vectorcraft_geom::{Point, Rect, shapes};
|
||||
|
||||
/// Type tests need glyph outlines, which only the optional craft-fonts build input provides.
|
||||
fn skip_without_craft_fonts() -> bool {
|
||||
let skip = vectorcraft_text::CRAFT_FONTS.is_empty();
|
||||
if skip {
|
||||
eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)");
|
||||
}
|
||||
skip
|
||||
}
|
||||
|
||||
fn sq(id: u64, x: f64, color: Color) -> Arc<Node> {
|
||||
Arc::new(Node::path(
|
||||
NodeId(id),
|
||||
@@ -350,6 +359,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn type_changes_the_result_of_every_pathfinder_effect() {
|
||||
if skip_without_craft_fonts() {
|
||||
return;
|
||||
}
|
||||
let glyphs = crate::outline_text(&type_h()).unwrap();
|
||||
let b = glyphs.geometric_bounds().unwrap();
|
||||
assert!(b.x0 < 100.0 && b.x1 > 100.0 && b.y0 > 0.0 && b.y1 < 100.0, "{b:?}");
|
||||
@@ -377,6 +389,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn pieces_of_type_keep_its_paint_opacity_and_blend_mode() {
|
||||
if skip_without_craft_fonts() {
|
||||
return;
|
||||
}
|
||||
let mut t = type_h();
|
||||
(t.opacity, t.blend) = (0.5, BlendMode::Multiply);
|
||||
let look = |p: &Arc<Node>| (p.appearance.fill_paint(), p.opacity, p.blend);
|
||||
@@ -418,6 +433,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn type_in_a_group_keeps_its_look_when_the_groups_geometry_effects_outline_it() {
|
||||
if skip_without_craft_fonts() {
|
||||
return;
|
||||
}
|
||||
use vectorcraft_color::BlendMode;
|
||||
let art = type_in_group(|t| (t.opacity, t.blend) = (0.5, BlendMode::Multiply));
|
||||
assert!(art.id == NodeId(3) && !matches!(art.kind, NodeKind::Text(_)), "{art:?}");
|
||||
@@ -447,6 +465,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_transform_on_a_group_moves_the_gradients_of_its_type_and_not_those_placed_on_its_paths() {
|
||||
if skip_without_craft_fonts() {
|
||||
return;
|
||||
}
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
|
||||
let ramp = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops });
|
||||
@@ -470,4 +491,32 @@ mod tests {
|
||||
assert!((after - before - 30.0).abs() < 1e-6, "the type's gradient starts at {before}, then {after}");
|
||||
assert_eq!(start(&members[0]), 0.0, "the square's gradient stays where it was placed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_transform_on_a_group_paints_each_copy_with_its_own_gradient() {
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
|
||||
let ramp = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops });
|
||||
let geom = GradientGeom { start: (0.0, 5.0).into(), end: (10.0, 5.0).into(), aspect: 1.0, focal: None };
|
||||
let paints = [Paint::Gradient(Box::new(ramp.clone())), Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }))];
|
||||
for (k, paint) in paints.into_iter().enumerate() {
|
||||
let square =
|
||||
Arc::new(Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(paint, Paint::None, 0.0)));
|
||||
let mut g = Node::group(NodeId(10), vec![square]);
|
||||
let params = serde_json::json!({"moveH": 30, "copies": 2});
|
||||
g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true });
|
||||
let art = evaluate_container(&g).unwrap();
|
||||
let copies = art.children().unwrap()[0].children().expect("the object and its copies are objects of their own");
|
||||
assert_eq!(copies.len(), 3);
|
||||
for (i, c) in copies.iter().enumerate() {
|
||||
// Each copy's own box: an unplaced gradient fits it, a placed one moved with it.
|
||||
assert!((c.geometric_bounds().unwrap().x0 - 30.0 * i as f64).abs() < 1e-6);
|
||||
match c.appearance.fill_paint() {
|
||||
Paint::Gradient(g) if k == 0 => assert!(g.geom.is_none(), "copy {i} fits its own box"),
|
||||
Paint::Gradient(g) => assert!((g.geom.unwrap().start.x - 30.0 * i as f64).abs() < 1e-6, "copy {i}"),
|
||||
p => panic!("a gradient: {p:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
//! - **Geometry effects** (Distort & Transform, Path, Convert to Shape, Round Corners, Scribble,
|
||||
//! Warp) rewrite a path: [`apply_geometry`] evaluates them in stack order.
|
||||
//! - **Raster effects** (Drop Shadow, Inner/Outer Glow, Feather, Gaussian Blur, and the
|
||||
//! Photoshop-style filters of [`pixel`]: Radial Blur, Smart Blur, Color Halftone, Crystallize, Mezzotint, Pointillize,
|
||||
//! Photoshop-style filters of [`pixel`]: Radial Blur, Smart Blur, Color Halftone, Crystallize, Mezzotint, Pointillize, Halftone Pattern,
|
||||
//! Unsharp Mask and Glowing Edges) are described by
|
||||
//! [`raster_effects`] and painted by the renderer; [`outset`] says how far they reach beyond
|
||||
//! the geometry.
|
||||
@@ -69,7 +69,7 @@ pub use live::{expand_live, expand_live_deep, expanded_live_group, text_outliner
|
||||
pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks};
|
||||
pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace};
|
||||
pub use raster::{RasterFx, outset, raster_effects};
|
||||
pub use reshape::{expand_outlined, needs_outline, outline_art, outline_text, reshape};
|
||||
pub use reshape::{expand_outlined, has_gradient_copies, has_item_geometry, needs_outline, outline_art, outline_text, reshape};
|
||||
pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve};
|
||||
pub use solids::{
|
||||
EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SOLIDS, extrude_options, has_solid, inflate_options, is_three_d, solid_art, validate_solid,
|
||||
@@ -149,6 +149,7 @@ fn lengths_of(id: &str) -> Lengths {
|
||||
"texture.mosaicTiles" => always(&["tileSize"]),
|
||||
"texture.patchwork" => always(&["squareSize"]),
|
||||
"texture.stainedGlass" => always(&["cellSize"]),
|
||||
"sketch.halftonePattern" => always(&["size"]),
|
||||
_ => Lengths::default(),
|
||||
}
|
||||
}
|
||||
@@ -165,6 +166,7 @@ const ARTISTIC: &[&str] = &["Effect", "Artistic"];
|
||||
const BRUSH_STROKES: &[&str] = &["Effect", "Brush Strokes"];
|
||||
const DISTORT: &[&str] = &["Effect", "Distort"];
|
||||
const SHARPEN: &[&str] = &["Effect", "Sharpen"];
|
||||
const SKETCH: &[&str] = &["Effect", "Sketch"];
|
||||
const PIXELATE: &[&str] = &["Effect", "Pixelate"];
|
||||
const TEXTURE: &[&str] = &["Effect", "Texture"];
|
||||
const VIDEO: &[&str] = &["Effect", "Video"];
|
||||
@@ -289,6 +291,13 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"{offset: pt (10; negative insets), joins: \"miter\"|\"round\"|\"bevel\", miterLimit: (4)}",
|
||||
json!({"offset": 10.0, "joins": "miter", "miterLimit": 4.0}),
|
||||
),
|
||||
g(
|
||||
"path.outlineObject",
|
||||
"Outline Object",
|
||||
PATH,
|
||||
"{} type is drawn, exported and expanded as its glyph outlines and stays editable, and the effects after it reshape those outlines; paths and images do not change",
|
||||
json!({}),
|
||||
),
|
||||
g(
|
||||
"path.outlineStroke",
|
||||
"Outline Stroke",
|
||||
@@ -327,6 +336,13 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
),
|
||||
r("stylize.feather", "Feather…", STYLIZE, "{radius: pt (5)}", json!({"radius": 5.0})),
|
||||
r("blur.gaussian", "Gaussian Blur…", BLUR, "{radius: pt (5)}", json!({"radius": 5.0})),
|
||||
r(
|
||||
"artistic.cutout",
|
||||
"Cutout…",
|
||||
ARTISTIC,
|
||||
"{levels: 2..8 (4; steps per colour channel: more levels, more flat colours), edgeSimplicity: 0..10 (4; simpler, smoother edges), edgeFidelity: 1..3 (2; keeps more of the edges' detail)} redraws the object as flat pieces of coloured paper, each piece painted in the average colour that falls into it, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
|
||||
json!({"levels": 4.0, "edgeSimplicity": 4.0, "edgeFidelity": 2.0}),
|
||||
),
|
||||
r(
|
||||
"artistic.filmGrain",
|
||||
"Film Grain…",
|
||||
@@ -334,6 +350,20 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"{grain: 0..20 (4; monochrome noise in shadows and midtones), highlightArea: 0..20 (0; widens the bright highlight area), intensity: 0..10 (10; highlight strength and quieter highlight grain)}. Smooth document-space grain follows translations; minification uses approximate footprint filtering. Coverage and pure white are preserved; black takes light speckles, and saturated colour channels may clip. Grain 0 with either highlightArea 0 or intensity 0 leaves the art unchanged. Original visual approximation; chosen VectorCraft defaults.",
|
||||
json!({"grain": 4.0, "highlightArea": 0.0, "intensity": 10.0}),
|
||||
),
|
||||
r(
|
||||
"artistic.neonGlow",
|
||||
"Neon Glow…",
|
||||
ARTISTIC,
|
||||
"{glowSize: -24..24 (5; above 0 the light areas glow, below 0 the dark ones; farther from 0 spreads the glow wider), glowBrightness: 0..50 (15; a stronger glow over dimmer greys), color: \"#rrggbb\" (\"#4da6ff\"; the glow's colour)} redraws the object in greys lit by a coloured glow, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
|
||||
json!({"glowSize": 5.0, "glowBrightness": 15.0, "color": "#4da6ff"}),
|
||||
),
|
||||
r(
|
||||
"artistic.posterEdges",
|
||||
"Poster Edges…",
|
||||
ARTISTIC,
|
||||
"{edgeThickness: 0..10 (2; wider lines), edgeIntensity: 0..10 (1; darker lines, on fainter edges too), posterization: 0..6 (2; posterization + 2 levels per colour channel)} reduces the object's colours and draws black lines on its edges, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
|
||||
json!({"edgeThickness": 2.0, "edgeIntensity": 1.0, "posterization": 2.0}),
|
||||
),
|
||||
r(
|
||||
"blur.radial",
|
||||
"Radial Blur…",
|
||||
@@ -345,7 +375,7 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"blur.smart",
|
||||
"Smart Blur…",
|
||||
BLUR,
|
||||
"{radius: pt 0.1..100 (3), threshold: levels 0.1..100 (25; only colours closer than this blur together, so edges stay sharp), quality: \"low\"|\"medium\"|\"high\" (\"medium\"; 5, 7 or 9 samples across), blurMode: \"normal\"|\"edgeOnly\"|\"overlayEdge\" (\"normal\"; edgeOnly: the edges in white on black, overlayEdge: the edges in white over the blur)}. A pixel is on an edge when one of its four nearest pixels differs from it by more than the threshold in a channel and its channels (alpha included) add up to at least that pixel's; the white and the black keep each pixel's coverage, and the radius changes only the blur",
|
||||
"{radius: pt 0.1..100 (3), threshold: levels 0.1..100 (25; only colours closer than this blur together, so edges stay sharp), quality: \"low\"|\"medium\"|\"high\" (\"medium\"; 5, 7 or 9 samples across), blurMode: \"normal\"|\"edgeOnly\"|\"overlayEdge\" (\"normal\"; edgeOnly: the edges in white on black, overlayEdge: the edges in white over the blur)}. A pixel is on an edge when one of the pixels the search reads within the radius (all of them for a small radius, a grid of samples for a wide one) or one of its four nearest pixels differs from it by more than the threshold in a channel and its channels (alpha included) add up to at least that pixel's, so a step gives a line about as wide as the radius, and at least one pixel wide, on the side with the larger sum; the white and the black keep each pixel's coverage",
|
||||
json!({"radius": 3.0, "threshold": 25.0, "quality": "medium", "blurMode": "normal"}),
|
||||
),
|
||||
r(
|
||||
@@ -432,6 +462,13 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"{amount: % 1..500 (50), radius: pt 0.1..250 (1; σ of the blur edges are found against), threshold: levels 0..255 (0; smaller differences are left alone)}",
|
||||
json!({"amount": 50.0, "radius": 1.0, "threshold": 0.0}),
|
||||
),
|
||||
r(
|
||||
"sketch.halftonePattern",
|
||||
"Halftone Pattern…",
|
||||
SKETCH,
|
||||
"{size: pt 1..12 (1; how far apart the screen's dots, rings or lines are), contrast: 0..50 (5; 50 draws pure black and white), patternType: \"circle\"|\"dot\"|\"line\" (\"dot\"; rings round the object's centre, dots on a square grid, horizontal lines)} redraws the object in black ink on white paper through a halftone screen whose ink covers as much as the colour is dark, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
|
||||
json!({"size": 1.0, "contrast": 5.0, "patternType": "dot"}),
|
||||
),
|
||||
r(
|
||||
"stylize.glowingEdges",
|
||||
"Glowing Edges…",
|
||||
@@ -865,6 +902,9 @@ pub(crate) fn apply_one(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &Geo
|
||||
}
|
||||
vectorcraft_pathops::offset_path(path, off, join(p, "joins"), num(p, "miterLimit", 4.0).clamp(1.0, 500.0))
|
||||
}
|
||||
// Outline Object returns the geometry unchanged. Type reaches every geometry effect as its
|
||||
// glyph outlines (`reshape::reshape`), so type that carries it is drawn as those outlines.
|
||||
"path.outlineObject" => path.clone(),
|
||||
"path.outlineStroke" => {
|
||||
let fallback;
|
||||
let base = match ctx.stroke {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
//! Effect › Artistic. Original filters built from public behaviour documentation:
|
||||
//! Effect › Artistic (Cutout, Film Grain, Neon Glow, Poster Edges). Original filters built from
|
||||
//! public behaviour documentation:
|
||||
//! <https://helpx.adobe.com/illustrator/using/summary-effects.html> and
|
||||
//! <https://helpx.adobe.com/photoshop-elements/using/artistic-filters.html>.
|
||||
//! These are visual approximations, not reproductions of a proprietary algorithm.
|
||||
|
||||
use super::PixelSpace;
|
||||
use super::edges::sobel;
|
||||
use super::pixelate::{Place, cell, rand};
|
||||
use super::texture::{luma, premultiply, straight};
|
||||
use super::{PixelSpace, blur_plane};
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
const SALT_FILM: u64 = 256;
|
||||
@@ -134,3 +136,135 @@ pub(super) fn film_grain(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSp
|
||||
*p = premultiply(c, f32::from(p[3]) / 255.0);
|
||||
});
|
||||
}
|
||||
|
||||
/// The coverage-weighted Gaussian softening of the planes `planes` (premultiplied by `alpha`) by
|
||||
/// `sigma` pixels: each divided by the softened coverage, so transparency doesn't darken edges.
|
||||
fn soften(planes: &mut [Vec<f32>], alpha: &[f32], w: usize, h: usize, sigma: f64) {
|
||||
let mut a = alpha.to_vec();
|
||||
blur_plane(&mut a, w, h, sigma);
|
||||
for plane in planes {
|
||||
for (v, c) in plane.iter_mut().zip(alpha) {
|
||||
*v *= c;
|
||||
}
|
||||
blur_plane(plane, w, h, sigma);
|
||||
for (v, c) in plane.iter_mut().zip(&a) {
|
||||
*v = if *c > 1e-4 { (*v / c).clamp(0.0, 1.0) } else { 0.0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The pixels' straight colour channels and their coverage (0..1), as planes.
|
||||
fn planes(px: &[[u8; 4]]) -> ([Vec<f32>; 3], Vec<f32>) {
|
||||
let colours = px.iter().map(|p| straight(*p)).collect::<Vec<_>>();
|
||||
(std::array::from_fn(|k| colours.iter().map(|c| c[k]).collect()), px.iter().map(|p| f32::from(p[3]) / 255.0).collect())
|
||||
}
|
||||
|
||||
/// Cutout: the object as flat pieces of coloured paper. Its colours, softened as `simplicity`
|
||||
/// (0..10) asks and less as `fidelity` (1..3) keeps detail, fall into `levels` (2..8) steps per
|
||||
/// colour channel; each step's piece is painted in the average colour of what falls into it, so
|
||||
/// colours keep their hue. Slivers (the blends along softened edges) take the nearest large
|
||||
/// piece's colour, so the pieces meet with crisp edges. Coverage is kept.
|
||||
pub(super) fn cutout(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, levels: f64, simplicity: f64, fidelity: f64) {
|
||||
if ![levels, simplicity, fidelity].iter().all(|v| v.is_finite()) || !(space.px.is_finite() && space.px > 0.0) {
|
||||
return;
|
||||
}
|
||||
let levels = levels.round().clamp(2.0, 8.0) as usize;
|
||||
// Simpler edges come from softer colours; more fidelity softens them less.
|
||||
let sigma = 0.6 * simplicity.clamp(0.0, 10.0) * (4.0 - fidelity.clamp(1.0, 3.0)) / 3.0 / space.px;
|
||||
let (mut colour, alpha) = planes(px);
|
||||
soften(&mut colour, &alpha, w, h, sigma);
|
||||
let at = |k: usize| [0, 1, 2].map(|i| colour[i].get(k).copied().unwrap_or(0.0));
|
||||
let piece = |c: [f32; 3]| c.iter().fold(0, |n, v| n * levels + ((v * levels as f32) as usize).min(levels - 1));
|
||||
// Each piece's colour: the coverage-weighted mean of the softened colours in it.
|
||||
let mut sums = vec![[0.0f64; 4]; levels * levels * levels];
|
||||
for (k, a) in alpha.iter().enumerate() {
|
||||
let c = at(k);
|
||||
let Some(sum) = sums.get_mut(piece(c)) else { continue };
|
||||
for (s, v) in sum.iter_mut().zip(c) {
|
||||
*s += f64::from(v * a);
|
||||
}
|
||||
sum[3] += f64::from(*a);
|
||||
}
|
||||
let mean = |sum: &[f64; 4]| [0, 1, 2].map(|i| (sum[i] / sum[3]) as f32);
|
||||
let total: f64 = sums.iter().map(|s| s[3]).sum();
|
||||
let heaviest = sums.iter().map(|s| s[3]).fold(0.0, f64::max);
|
||||
// Pieces of at least 1% of the object (and always the largest one) are kept.
|
||||
let large: Vec<[f32; 3]> = sums.iter().filter(|s| s[3] > 0.0 && (s[3] >= 0.01 * total || s[3] >= heaviest)).map(mean).collect();
|
||||
let paint: Vec<Option<[f32; 3]>> = sums
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let c = (s[3] > 0.0).then(|| mean(s))?;
|
||||
let far = |o: &[f32; 3]| o.iter().zip(c).map(|(a, b)| (a - b) * (a - b)).sum::<f32>();
|
||||
large.iter().min_by(|a, b| far(a).total_cmp(&far(b))).copied()
|
||||
})
|
||||
.collect();
|
||||
for (k, p) in px.iter_mut().enumerate() {
|
||||
if p[3] == 0 {
|
||||
continue;
|
||||
}
|
||||
if let Some(Some(c)) = paint.get(piece(at(k))) {
|
||||
*p = premultiply(*c, f32::from(p[3]) / 255.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Poster Edges: the object's colours posterized to `posterization` + 2 (2..8) levels per
|
||||
/// channel, with black lines drawn on the edges of any of its colour channels and of its outline. `thickness` (0..10) widens the lines and
|
||||
/// `intensity` (0..10) darkens them and lets fainter edges take a line. Coverage is kept.
|
||||
pub(super) fn poster_edges(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, thickness: f64, intensity: f64, posterization: f64) {
|
||||
if ![thickness, intensity, posterization].iter().all(|v| v.is_finite()) || !(space.px.is_finite() && space.px > 0.0) {
|
||||
return;
|
||||
}
|
||||
let steps = (posterization.round().clamp(0.0, 6.0) + 1.0) as f32;
|
||||
// The edges of each premultiplied channel (so the object's outline too), a step of 1 reading 1.
|
||||
let mut edges = vec![0.0f32; px.len()];
|
||||
for k in 0..4 {
|
||||
let plane: Vec<f32> = px.iter().map(|p| f32::from(p[k]) / 255.0).collect();
|
||||
for (e, s) in edges.iter_mut().zip(sobel(&plane, w, h)) {
|
||||
*e = e.max(s / 4.0);
|
||||
}
|
||||
}
|
||||
// Wider lines spread the edges; scale them back up so a spread line stays as dark.
|
||||
let sigma = (0.3 + 0.25 * thickness.clamp(0.0, 10.0)) / space.px;
|
||||
blur_plane(&mut edges, w, h, sigma);
|
||||
let gain = ((0.3 + 0.25 * intensity.clamp(0.0, 10.0)) * (1.0 + 1.5 * sigma.max(0.0))) as f32;
|
||||
for (p, e) in px.iter_mut().zip(edges) {
|
||||
if p[3] == 0 {
|
||||
continue;
|
||||
}
|
||||
let t = ((e * gain - 0.25) / 0.5).clamp(0.0, 1.0);
|
||||
let ink = t * t * (3.0 - 2.0 * t);
|
||||
let c = straight(*p).map(|v| (v * steps).round() / steps * (1.0 - ink));
|
||||
*p = premultiply(c, f32::from(p[3]) / 255.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// How far (document units) Neon Glow's glow spreads at `size` (-24..24).
|
||||
pub(super) fn glow_spread(size: f64) -> f64 {
|
||||
0.25 * size.abs().min(24.0)
|
||||
}
|
||||
|
||||
/// Neon Glow: the object in dimmed greys, lit by `colour` glowing from its light areas (`size`
|
||||
/// above 0) or from its dark areas (below 0), spread as far as |`size`| (0..24) says and as
|
||||
/// strong as `brightness` (0..50). Coverage is kept.
|
||||
pub(super) fn neon_glow(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, size: f64, brightness: f64, colour: [f32; 3]) {
|
||||
if ![size, brightness].iter().all(|v| v.is_finite()) || !(space.px.is_finite() && space.px > 0.0) {
|
||||
return;
|
||||
}
|
||||
let strength = (brightness / 50.0).clamp(0.0, 1.0) as f32;
|
||||
let tones: Vec<f32> = px.iter().map(|p| luma(straight(*p))).collect();
|
||||
let alpha: Vec<f32> = px.iter().map(|p| f32::from(p[3]) / 255.0).collect();
|
||||
let mut glow = [tones.iter().map(|l| if size < 0.0 { 1.0 - l } else { *l }).collect::<Vec<_>>()];
|
||||
soften(&mut glow, &alpha, w, h, glow_spread(size) / space.px);
|
||||
let [glow] = glow;
|
||||
for ((p, tone), g) in px.iter_mut().zip(tones).zip(glow) {
|
||||
if p[3] == 0 {
|
||||
continue;
|
||||
}
|
||||
// The greys dim as the glow brightens, and the glow is screened over them.
|
||||
let base = tone * (1.0 - 0.6 * strength);
|
||||
let lit = (3.0 * g * strength).clamp(0.0, 1.0);
|
||||
let c = colour.map(|v| 1.0 - (1.0 - base) * (1.0 - v.clamp(0.0, 1.0) * lit));
|
||||
*p = premultiply(c, f32::from(p[3]) / 255.0);
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,40 @@ fn close(a: &Px16, b: &Px16, t: i32) -> bool {
|
||||
a.iter().zip(b).all(|(a, b)| (*a as i32 - *b as i32).abs() <= t)
|
||||
}
|
||||
|
||||
/// The offsets Smart Blur looks at around a pixel out to about `r` pixels (up to round(`r`)),
|
||||
/// with the radius of the box blur of the copy they are read from (0: the pixels themselves):
|
||||
/// every pixel of the square of side 2 · round(`r`) + 1, or `samples` × `samples` points spread
|
||||
/// over the square once it is wider than `samples` pixels, read from a copy box-blurred over the
|
||||
/// gaps between them. The callers keep the offsets inside their disc.
|
||||
fn smart_grid(r: f64, samples: u32) -> (Vec<Vec2>, usize) {
|
||||
let n = samples.clamp(3, 15) as f64;
|
||||
let k = r.round();
|
||||
if 2.0 * k < n {
|
||||
let k = k as i32;
|
||||
((-k..=k).flat_map(|dy| (-k..=k).map(move |dx| Vec2::new(dx as f64, dy as f64))).collect(), 0)
|
||||
} else {
|
||||
let gap = 2.0 * r / (n - 1.0);
|
||||
let grid = (0..n as i32).map(|i| -r + gap * i as f64);
|
||||
(grid.clone().flat_map(|dy| grid.clone().map(move |dx| Vec2::new(dx.round(), dy.round()))).collect(), (gap / 2.0).floor() as usize)
|
||||
}
|
||||
}
|
||||
|
||||
/// How far from a pixel Smart Blur's edge modes ([`smart_edges`]) read, in the units of `radius`:
|
||||
/// `radius`, and the box blur of the copy that a wide radius's offsets are read from.
|
||||
pub(super) fn edge_reach(radius: f64, samples: u32) -> f64 {
|
||||
radius + radius / (samples.clamp(3, 15) as f64 - 1.0)
|
||||
}
|
||||
|
||||
/// `src` (rows of `w` pixels) box-blurred with radius `prefilter`, the copy that the offsets of
|
||||
/// [`smart_grid`] are read from; `None` when they are read from `src` itself.
|
||||
fn smart_near(src: &[Px16], w: usize, prefilter: usize) -> Option<Vec<Px16>> {
|
||||
(prefilter > 0).then(|| {
|
||||
let mut b = src.to_vec();
|
||||
box_blur(&mut b, w, prefilter);
|
||||
b
|
||||
})
|
||||
}
|
||||
|
||||
/// Smart Blur (Normal mode): each pixel averaged with itself and the neighbours within `radius`
|
||||
/// pixels whose premultiplied channels all lie within `threshold` levels of its own. Wide radii
|
||||
/// look at `samples` × `samples` neighbours spread over the disc, read from a copy box-blurred
|
||||
@@ -113,28 +147,12 @@ pub(super) fn smart(px: &mut [[u8; 4]], w: usize, h: usize, radius: f64, thresho
|
||||
return;
|
||||
}
|
||||
let r = radius.min(MAX_SMART);
|
||||
let n = samples.clamp(3, 15) as f64;
|
||||
let k = r.round();
|
||||
let (offsets, prefilter): (Vec<Vec2>, usize) = if 2.0 * k < n {
|
||||
let k = k as i32;
|
||||
((-k..=k).flat_map(|dy| (-k..=k).map(move |dx| Vec2::new(dx as f64, dy as f64))).collect(), 0)
|
||||
} else {
|
||||
let gap = 2.0 * r / (n - 1.0);
|
||||
let grid = (0..n as i32).map(|i| -r + gap * i as f64);
|
||||
(grid.clone().flat_map(|dy| grid.clone().map(move |dx| Vec2::new(dx.round(), dy.round()))).collect(), (gap / 2.0).floor() as usize)
|
||||
};
|
||||
let (offsets, prefilter) = smart_grid(r, samples);
|
||||
let offsets: Vec<(isize, isize)> =
|
||||
offsets.into_iter().filter(|o| o.hypot() <= r + 0.5 && *o != Vec2::ZERO).map(|o| (o.x as isize, o.y as isize)).collect();
|
||||
let src = to16(px);
|
||||
let blurred;
|
||||
let near: &[Px16] = if prefilter > 0 {
|
||||
let mut b = src.clone();
|
||||
box_blur(&mut b, w, prefilter);
|
||||
blurred = b;
|
||||
&blurred
|
||||
} else {
|
||||
&src
|
||||
};
|
||||
let blurred = smart_near(&src, w, prefilter);
|
||||
let near = blurred.as_deref().unwrap_or(&src);
|
||||
let t = levels(threshold);
|
||||
for (y, (row, out)) in src.chunks_exact(w).zip(px.chunks_exact_mut(w)).enumerate().take(h) {
|
||||
for (x, (c, o)) in row.iter().zip(out.iter_mut()).enumerate() {
|
||||
@@ -162,33 +180,41 @@ pub(super) fn smart(px: &mut [[u8; 4]], w: usize, h: usize, radius: f64, thresho
|
||||
|
||||
/// Smart Blur's Edge Only (`overlay` false) and Overlay Edge (`overlay` true) modes: the pixels on
|
||||
/// an edge drawn white, over black or over the Normal mode's result ([`smart`]). A pixel is on an
|
||||
/// edge when one of its four nearest neighbors (transparent beyond the raster) has a premultiplied
|
||||
/// channel more than `threshold` levels from its own and its channels add up to at least the
|
||||
/// neighbor's, so a step between two colors gives a line one pixel wide on the side whose channels
|
||||
/// add up to more, and two pixels wide when the sums are equal. The white and the black keep the
|
||||
/// pixel's coverage.
|
||||
/// edge when one of the pixels the search reads within `radius` pixels of it (transparent beyond
|
||||
/// the raster) has a premultiplied channel more than `threshold` levels from its own and its
|
||||
/// channels add up to at least that pixel's. The search reads the Normal mode's offsets ([`smart_grid`]) for the whole
|
||||
/// pixels of `radius`, kept within `radius`, from the pixels and, for a wide radius, from the
|
||||
/// box-blurred copy, and always the four nearest pixels. A straight step between two colors gives
|
||||
/// a line on the side whose channels add up to more, about `radius` wide (the whole pixels of
|
||||
/// `radius` while every pixel within it is read, and at least one pixel), and lines on both sides
|
||||
/// when the sums are equal. The white and the black keep the pixel's coverage.
|
||||
pub(super) fn smart_edges(px: &mut [[u8; 4]], w: usize, h: usize, radius: f64, threshold: f64, samples: u32, overlay: bool) {
|
||||
let src = to16(px);
|
||||
let t = levels(threshold);
|
||||
let sum = |p: &Px16| p.iter().map(|v| u32::from(*v)).sum::<u32>();
|
||||
let at = |x: usize, y: usize, dx: isize, dy: isize| -> Px16 {
|
||||
let (Some(x), Some(y)) = (x.checked_add_signed(dx), y.checked_add_signed(dy)) else { return [0; 4] };
|
||||
if x >= w || y >= h {
|
||||
return [0; 4];
|
||||
let r = if radius.is_nan() { 0.0 } else { radius.min(MAX_SMART) };
|
||||
// Spread over whole pixels, so that the outermost offsets lie within `r` and are kept.
|
||||
let (offsets, prefilter) = smart_grid(r.floor(), samples);
|
||||
let wide: Vec<(isize, isize)> = offsets.into_iter().filter(|o| o.hypot() > 1.0 && o.hypot() <= r).map(|o| (o.x as isize, o.y as isize)).collect();
|
||||
let nearest = [(1, 0), (-1, 0), (0, 1), (0, -1)];
|
||||
let blurred = smart_near(&src, w, prefilter);
|
||||
let mut edge = vec![false; src.len()];
|
||||
for (y, (row, out)) in src.chunks_exact(w).zip(edge.chunks_exact_mut(w)).enumerate().take(h) {
|
||||
for (x, (c, e)) in row.iter().zip(out.iter_mut()).enumerate() {
|
||||
let own = sum(c);
|
||||
let differs = |n: &Px16| !close(n, c, t) && own >= sum(n);
|
||||
// The pixel at the offset and, with `copy`, its blurred copy; transparent beyond the raster.
|
||||
let found = |(dx, dy): &(isize, isize), copy: bool| {
|
||||
let (sx, sy) = (x as isize + dx, y as isize + dy);
|
||||
if sx < 0 || sy < 0 || sx as usize >= w || sy as usize >= h {
|
||||
return differs(&[0; 4]);
|
||||
}
|
||||
let i = sy as usize * w + sx as usize;
|
||||
src.get(i).is_some_and(differs) || (copy && blurred.as_deref().and_then(|b| b.get(i)).is_some_and(differs))
|
||||
};
|
||||
*e = nearest.iter().any(|o| found(o, false)) || wide.iter().any(|o| found(o, true));
|
||||
}
|
||||
src.get(y * w + x).copied().unwrap_or([0; 4])
|
||||
};
|
||||
let edge: Vec<bool> = src
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, c)| {
|
||||
let (x, y) = (i % w.max(1), i / w.max(1));
|
||||
[(1, 0), (-1, 0), (0, 1), (0, -1)].iter().any(|(dx, dy)| {
|
||||
let n = at(x, y, *dx, *dy);
|
||||
!close(&n, c, t) && sum(c) >= sum(&n)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
if overlay {
|
||||
smart(px, w, h, radius, threshold, samples);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Photoshop-style raster effects (Effect › Artistic › Film Grain, Blur › Radial Blur and Smart Blur, Brush Strokes ›
|
||||
//! Photoshop-style raster effects (Effect › Artistic › Cutout, Film Grain, Neon Glow and Poster Edges, Blur › Radial Blur and Smart Blur, Brush Strokes ›
|
||||
//! Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes
|
||||
//! and Sumi-e, Distort › Diffuse Glow, Glass and Ocean Ripple, Pixelate › Color Halftone,
|
||||
//! Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, Stylize › Glowing Edges, Texture
|
||||
//! Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, Sketch › Halftone Pattern, Stylize ›
|
||||
//! Glowing Edges, Texture
|
||||
//! › Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer, and Video ›
|
||||
//! De-Interlace and NTSC Colors): filters over premultiplied RGBA8 pixels.
|
||||
//!
|
||||
@@ -20,6 +21,7 @@ mod edges;
|
||||
mod loaded;
|
||||
mod pixelate;
|
||||
mod sharpen;
|
||||
mod sketch;
|
||||
mod texture;
|
||||
mod video;
|
||||
|
||||
@@ -33,11 +35,15 @@ pub use brushstrokes::{STROKE_DIRECTIONS, StrokeDirection};
|
||||
pub use distort::{GLASS_TEXTURES, GLASS_TEXTURES_LOADED, GlassTexture};
|
||||
pub use loaded::{LOADED, LoadedTexture, TEXTURE_IMAGE, texture};
|
||||
pub use pixelate::{MEZZOTINT_TYPES, Mezzotint};
|
||||
pub use sketch::{HALFTONE_TYPES, HalftonePattern};
|
||||
pub use texture::{GRAIN_TYPES, Grain, LIGHT_DIRECTIONS, Light, TEXTURES, Texture};
|
||||
|
||||
/// The Photoshop-style effect ids (all raster effects, see [`crate::is_raster`]).
|
||||
pub const PIXEL_EFFECTS: [&str; 28] = [
|
||||
pub const PIXEL_EFFECTS: [&str; 32] = [
|
||||
"artistic.cutout",
|
||||
"artistic.filmGrain",
|
||||
"artistic.neonGlow",
|
||||
"artistic.posterEdges",
|
||||
"blur.radial",
|
||||
"blur.smart",
|
||||
"brushStrokes.accentedEdges",
|
||||
@@ -56,6 +62,7 @@ pub const PIXEL_EFFECTS: [&str; 28] = [
|
||||
"pixelate.mezzotint",
|
||||
"pixelate.pointillize",
|
||||
"sharpen.unsharpMask",
|
||||
"sketch.halftonePattern",
|
||||
"stylize.glowingEdges",
|
||||
"texture.craquelure",
|
||||
"texture.grain",
|
||||
@@ -71,18 +78,27 @@ pub const PIXEL_EFFECTS: [&str; 28] = [
|
||||
/// are in document units.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum PixelFx {
|
||||
/// Artistic › Cutout: flat pieces of colour in `levels` (2..8) steps per channel, their
|
||||
/// edges as simple as `simplicity` (0..10) and as faithful as `fidelity` (1..3) say.
|
||||
Cutout { levels: f64, simplicity: f64, fidelity: f64 },
|
||||
/// Artistic › Film Grain: monochrome grain in shadows and midtones, a wider bright
|
||||
/// highlight area (0..20), and tonal intensity (0..10), preserving coverage.
|
||||
FilmGrain { grain: f64, highlight: f64, intensity: f64 },
|
||||
/// Artistic › Neon Glow: dimmed greys lit by `colour` (straight RGB, 0..1) glowing from the
|
||||
/// light areas (`size` above 0) or the dark ones (below 0), spread by |`size`| (-24..24), as
|
||||
/// strong as `brightness` (0..50).
|
||||
NeonGlow { size: f64, brightness: f64, colour: [f32; 3] },
|
||||
/// Artistic › Poster Edges: colours posterized to `posterization` + 2 levels (0..6) with black
|
||||
/// lines on the edges, as thick as `thickness` (0..10) and as dark as `intensity` (0..10).
|
||||
PosterEdges { thickness: f64, intensity: f64, posterization: f64 },
|
||||
/// Blur › Radial Blur around the object's centre: along arcs (spin, an arc of `amount`°) or
|
||||
/// along rays (zoom, the content scaled by up to about ±`amount`/2 %), averaging 2^`passes` samples.
|
||||
RadialBlur { amount: f64, zoom: bool, passes: u32 },
|
||||
/// Blur › Smart Blur (Normal mode): each pixel averaged with the neighbours within `radius`
|
||||
/// whose colour differs from its own by no more than `threshold` levels, over a grid of
|
||||
/// `samples` × `samples` neighbours at most.
|
||||
/// The Edge Only `mode` draws the edges where neighboring pixels differ by more than
|
||||
/// `threshold` levels in white on black, and Overlay Edge draws them in white over the Normal
|
||||
/// result.
|
||||
/// The Edge Only `mode` draws the edges that `threshold` and `radius` find (`blur::smart_edges`)
|
||||
/// in white on black, and Overlay Edge draws them in white over the Normal result.
|
||||
SmartBlur { radius: f64, threshold: f64, samples: u32, mode: SmartBlurMode },
|
||||
/// Brush Strokes › Accented Edges: the edges, about `width` (1..14) wide, accented towards
|
||||
/// white chalk or black ink as `brightness` (0..50) says; `smoothness` (1..15) softens them.
|
||||
@@ -123,6 +139,9 @@ pub enum PixelFx {
|
||||
/// Sharpen › Unsharp Mask: colours pushed away from a Gaussian blur of σ = `radius` by `amount`
|
||||
/// (1 = 100 %), where they differ from it by at least `threshold` levels.
|
||||
UnsharpMask { amount: f64, radius: f64, threshold: f64 },
|
||||
/// Sketch › Halftone Pattern: black ink on white paper through a `kind` screen of cells `size`
|
||||
/// (1..12) apart, its edges as hard as `contrast` (0..50) says.
|
||||
HalftonePattern { size: f64, contrast: f64, kind: HalftonePattern },
|
||||
/// Stylize › Glowing Edges: bright coloured Sobel outlines on black.
|
||||
GlowingEdges { width: f64, brightness: f64, smoothness: f64 },
|
||||
/// Pixelate › Color Halftone: each colour channel screened at its angle (`angles`° of channels
|
||||
@@ -203,6 +222,21 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
|
||||
levels.iter().find(|(name, _)| name.eq_ignore_ascii_case(q)).map_or(default, |(_, v)| *v)
|
||||
};
|
||||
Some(match id {
|
||||
"artistic.cutout" => PixelFx::Cutout {
|
||||
levels: num(p, "levels", 4.0).round().clamp(2.0, 8.0),
|
||||
simplicity: num(p, "edgeSimplicity", 4.0).clamp(0.0, 10.0),
|
||||
fidelity: num(p, "edgeFidelity", 2.0).round().clamp(1.0, 3.0),
|
||||
},
|
||||
"artistic.neonGlow" => PixelFx::NeonGlow {
|
||||
size: num(p, "glowSize", 5.0).clamp(-24.0, 24.0),
|
||||
brightness: num(p, "glowBrightness", 15.0).clamp(0.0, 50.0),
|
||||
colour: crate::raster::color_param(p, vectorcraft_doc::color::Color::rgb(0.3, 0.65, 1.0)).to_rgb(),
|
||||
},
|
||||
"artistic.posterEdges" => PixelFx::PosterEdges {
|
||||
thickness: num(p, "edgeThickness", 2.0).clamp(0.0, 10.0),
|
||||
intensity: num(p, "edgeIntensity", 1.0).clamp(0.0, 10.0),
|
||||
posterization: num(p, "posterization", 2.0).round().clamp(0.0, 6.0),
|
||||
},
|
||||
"artistic.filmGrain" => PixelFx::FilmGrain {
|
||||
grain: num(p, "grain", 4.0).clamp(0.0, 20.0),
|
||||
highlight: num(p, "highlightArea", 0.0).clamp(0.0, 20.0),
|
||||
@@ -277,6 +311,11 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
|
||||
radius: num(p, "radius", 1.0).clamp(0.1, 250.0),
|
||||
threshold: num(p, "threshold", 0.0).clamp(0.0, 255.0),
|
||||
},
|
||||
"sketch.halftonePattern" => PixelFx::HalftonePattern {
|
||||
size: num(p, "size", 1.0).clamp(1.0, 12.0),
|
||||
contrast: num(p, "contrast", 5.0).clamp(0.0, 50.0),
|
||||
kind: HalftonePattern::parse(text(p, "patternType", "dot")),
|
||||
},
|
||||
"stylize.glowingEdges" => PixelFx::GlowingEdges {
|
||||
width: num(p, "edgeWidth", 2.0).clamp(1.0, 14.0),
|
||||
brightness: num(p, "edgeBrightness", 6.0).clamp(0.0, 20.0),
|
||||
@@ -353,9 +392,12 @@ impl PixelFx {
|
||||
// Content shifted outwards reaches past the bounds by as much as the shift.
|
||||
PixelFx::Glass { distortion, .. } => distort::glass_shift(distortion),
|
||||
PixelFx::OceanRipple { size, magnitude } => distort::ripple_shift(size, magnitude),
|
||||
PixelFx::UnsharpMask { .. } => 0.0,
|
||||
PixelFx::UnsharpMask { .. } | PixelFx::HalftonePattern { .. } => 0.0,
|
||||
PixelFx::GlowingEdges { width, smoothness, .. } => width.max(smoothness * 3.0),
|
||||
PixelFx::FilmGrain { .. }
|
||||
| PixelFx::Cutout { .. }
|
||||
| PixelFx::NeonGlow { .. }
|
||||
| PixelFx::PosterEdges { .. }
|
||||
| PixelFx::ColorHalftone { .. }
|
||||
| PixelFx::Mezzotint { .. }
|
||||
| PixelFx::Craquelure { .. }
|
||||
@@ -376,7 +418,8 @@ impl PixelFx {
|
||||
pub fn reach(&self) -> Option<f64> {
|
||||
match *self {
|
||||
PixelFx::RadialBlur { .. } => None,
|
||||
PixelFx::SmartBlur { radius, .. } => Some(radius),
|
||||
PixelFx::SmartBlur { radius, mode: SmartBlurMode::Normal, .. } => Some(radius),
|
||||
PixelFx::SmartBlur { radius, samples, .. } => Some(blur::edge_reach(radius, samples)),
|
||||
PixelFx::AccentedEdges { width, smoothness, .. } => Some(brushstrokes::accent_reach(width, smoothness)),
|
||||
// The strokes, and the softened tones that choose them.
|
||||
PixelFx::AngledStrokes { length, .. } => Some(brushstrokes::stroke_reach(length).max(brushstrokes::TONE_REACH)),
|
||||
@@ -396,7 +439,13 @@ impl PixelFx {
|
||||
// The cells around the pixel's, and the cells around theirs.
|
||||
PixelFx::ColorHalftone { max_radius, .. } => Some(3.0 * std::f64::consts::SQRT_2 * max_radius),
|
||||
PixelFx::Crystallize { cell } => Some(1.5 * cell),
|
||||
PixelFx::FilmGrain { .. } | PixelFx::Mezzotint { .. } => Some(0.0),
|
||||
PixelFx::FilmGrain { .. } | PixelFx::Mezzotint { .. } | PixelFx::HalftonePattern { .. } => Some(0.0),
|
||||
// Cutout's palette comes from the whole object.
|
||||
PixelFx::Cutout { .. } => None,
|
||||
// The softened tones around the pixel.
|
||||
PixelFx::NeonGlow { size, .. } => Some(3.0 * artistic::glow_spread(size)),
|
||||
// The edges' lines, spread round them.
|
||||
PixelFx::PosterEdges { thickness, .. } => Some(3.0 * (0.3 + 0.25 * thickness) + 1.0),
|
||||
// The dots' points, and the softened colour around them.
|
||||
PixelFx::Pointillize { cell } => Some(2.5 * cell),
|
||||
// The softened tones around the pixel.
|
||||
@@ -417,7 +466,12 @@ impl PixelFx {
|
||||
let Some(px) = pixels(data, w, h) else { return };
|
||||
let to_px = |len: f64| len / space.px.max(1e-9);
|
||||
match *self {
|
||||
PixelFx::Cutout { levels, simplicity, fidelity } => artistic::cutout(px, w, h, space, levels, simplicity, fidelity),
|
||||
PixelFx::FilmGrain { grain, highlight, intensity } => artistic::film_grain(px, w, h, space, grain, highlight, intensity),
|
||||
PixelFx::NeonGlow { size, brightness, colour } => artistic::neon_glow(px, w, h, space, size, brightness, colour),
|
||||
PixelFx::PosterEdges { thickness, intensity, posterization } => {
|
||||
artistic::poster_edges(px, w, h, space, thickness, intensity, posterization)
|
||||
}
|
||||
PixelFx::RadialBlur { amount, zoom, passes } => blur::radial(px, w, h, space, amount, zoom, passes),
|
||||
PixelFx::SmartBlur { radius, threshold, samples, mode: SmartBlurMode::Normal } => {
|
||||
blur::smart(px, w, h, to_px(radius), threshold, samples)
|
||||
@@ -439,6 +493,7 @@ impl PixelFx {
|
||||
}
|
||||
PixelFx::OceanRipple { size, magnitude } => distort::ocean_ripple(px, w, h, space, size, magnitude),
|
||||
PixelFx::UnsharpMask { amount, radius, threshold } => sharpen::unsharp(px, w, h, amount, to_px(radius), threshold),
|
||||
PixelFx::HalftonePattern { size, contrast, kind } => sketch::halftone_pattern(px, w, h, space, size, contrast, kind),
|
||||
PixelFx::GlowingEdges { width, brightness, smoothness } => edges::glow(px, w, h, to_px(width), brightness, to_px(smoothness)),
|
||||
PixelFx::ColorHalftone { max_radius, angles } => pixelate::color_halftone(px, w, h, space, max_radius, angles),
|
||||
PixelFx::Crystallize { cell } => pixelate::crystallize(px, w, h, space, cell),
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
//! Effect › Sketch › Halftone Pattern. Original filter built from public behaviour documentation:
|
||||
//! <https://helpx.adobe.com/illustrator/using/summary-effects.html> (Sketch effects redraw in
|
||||
//! black and white; Halftone Pattern simulates a halftone screen while keeping the range of tones)
|
||||
//! and <https://helpx.adobe.com/illustrator/using/effects.html> (only Size depends on the raster
|
||||
//! resolution). A visual approximation, not a reproduction of a proprietary algorithm.
|
||||
|
||||
use super::pixelate::Place;
|
||||
use super::texture::{luma, premultiply, straight};
|
||||
use super::{PixelSpace, pick};
|
||||
|
||||
/// Halftone Pattern's screens, in its Pattern Type menu's order: (label, parameter value).
|
||||
pub const HALFTONE_TYPES: [(&str, &str); 3] = [("Circle", "circle"), ("Dot", "dot"), ("Line", "line")];
|
||||
|
||||
/// A Halftone Pattern screen ([`HALFTONE_TYPES`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum HalftonePattern {
|
||||
/// Rings round the object's centre.
|
||||
Circle,
|
||||
/// Dots on a square grid.
|
||||
Dot,
|
||||
/// Horizontal lines.
|
||||
Line,
|
||||
}
|
||||
|
||||
impl HalftonePattern {
|
||||
const ALL: [HalftonePattern; 3] = [HalftonePattern::Circle, HalftonePattern::Dot, HalftonePattern::Line];
|
||||
|
||||
/// The screen of Pattern Type `value` (a [`HALFTONE_TYPES`] value, any case); Dot otherwise.
|
||||
pub fn parse(value: &str) -> Self {
|
||||
pick(&HALFTONE_TYPES, &Self::ALL, value, HalftonePattern::Dot)
|
||||
}
|
||||
}
|
||||
|
||||
/// Halftone Pattern: the object redrawn in black ink on white paper through a `kind` screen whose
|
||||
/// cells are `size` points apart, anchored at the object's centre. The ink covers as much of a
|
||||
/// cell as the colour is dark; `contrast` (0..50) hardens the screen's soft edges, to pure black
|
||||
/// and white at 50. Coverage is kept.
|
||||
pub(super) fn halftone_pattern(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, size: f64, contrast: f64, kind: HalftonePattern) {
|
||||
if !(size.is_finite() && size > 0.0 && contrast.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
// The width of the screen's soft edge, in tones: 0 is a hard threshold.
|
||||
let soft = (0.5 * (1.0 - contrast / 50.0)).clamp(0.0, 0.5);
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let q = place.at(x, y);
|
||||
let band = |v: f64| (2.0 * ((v / size).rem_euclid(1.0) - 0.5)).abs();
|
||||
// Where in its cell the pixel lies, 0 where the ink starts to 1 where it ends.
|
||||
let screen = match kind {
|
||||
HalftonePattern::Line => band(q.y),
|
||||
HalftonePattern::Circle => band(q.x.hypot(q.y)),
|
||||
HalftonePattern::Dot => 0.5 * (band(q.x).powi(2) + band(q.y).powi(2)),
|
||||
};
|
||||
let dark = f64::from(1.0 - luma(straight(*p))).clamp(0.0, 1.0);
|
||||
let ink = if dark >= 1.0 {
|
||||
1.0
|
||||
} else if soft > 0.0 {
|
||||
// The threshold kept inside [soft / 2, 1 - soft / 2], so white stays white and black black.
|
||||
let threshold = 0.5 * soft + screen * (1.0 - soft);
|
||||
((dark - threshold) / soft + 0.5).clamp(0.0, 1.0)
|
||||
} else if dark > screen {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
*p = premultiply([(1.0 - ink) as f32; 3], f32::from(p[3]) / 255.0);
|
||||
});
|
||||
}
|
||||
@@ -500,6 +500,9 @@ fn outsets_follow_the_object() {
|
||||
assert!((spin.outset(b) - (far - 25.0)).abs() < 1e-9);
|
||||
assert!(fx("blur.radial", json!({"method": "zoom", "amount": 100})).outset(b) > 0.4 * far);
|
||||
assert_eq!(fx("blur.smart", json!({"radius": 4})).outset(b), 4.0);
|
||||
assert_eq!(fx("blur.smart", json!({"radius": 4})).reach(), Some(4.0));
|
||||
// The edge modes also read the copy blurred over a wide radius's samples.
|
||||
assert_eq!(fx("blur.smart", json!({"radius": 4, "quality": "low", "blurMode": "edgeOnly"})).reach(), Some(5.0));
|
||||
assert_eq!(fx("sharpen.unsharpMask", json!({})).outset(b), 0.0);
|
||||
assert_eq!(spin.reach(), None);
|
||||
assert_eq!(fx("sharpen.unsharpMask", json!({"radius": 2})).reach(), Some(6.0));
|
||||
@@ -656,8 +659,8 @@ fn smart_blur_normal_mode_keeps_its_pixels() {
|
||||
|
||||
/// Smart Blur's test image: grays 56 and 64 in a checkerboard for x < 3 and 66 and 74 for x in 3..6
|
||||
/// (steps of 2 or 18 levels at x = 3, under the threshold), gray 200 from x = 6 on (steps of 126 or
|
||||
/// 134 levels). Its edges at threshold 25 are the pixels on the raster's border (transparency is
|
||||
/// beyond it) and column 6, the step's lighter side.
|
||||
/// 134 levels). Its edges at threshold 25 and radius 1 are the pixels on the raster's border
|
||||
/// (transparency is beyond it) and column 6, the step's lighter side.
|
||||
fn smart_blur_steps() -> (usize, usize, Vec<u8>, impl Fn(usize, usize) -> bool) {
|
||||
let (w, h) = (12, 8);
|
||||
let src = image(w, h, |x, y| match x {
|
||||
@@ -672,16 +675,16 @@ fn smart_blur_steps() -> (usize, usize, Vec<u8>, impl Fn(usize, usize) -> bool)
|
||||
fn smart_blur_edge_only_draws_the_edges_white_on_black() {
|
||||
let (w, h, src, edge) = smart_blur_steps();
|
||||
let mut d = src.clone();
|
||||
fx("blur.smart", json!({"blurMode": "edgeOnly"})).apply(&mut d, w, h, &space(w, h));
|
||||
fx("blur.smart", json!({"blurMode": "edgeOnly", "radius": 1})).apply(&mut d, w, h, &space(w, h));
|
||||
let want = image(w, h, |x, y| if edge(x, y) { [255; 4] } else { [0, 0, 0, 255] });
|
||||
assert_eq!(d, want);
|
||||
// The radius changes only the blur.
|
||||
let mut wide = src.clone();
|
||||
fx("blur.smart", json!({"blurMode": "edgeOnly", "radius": 40})).apply(&mut wide, w, h, &space(w, h));
|
||||
assert_eq!(wide, want);
|
||||
// Under one pixel the search still takes the four nearest pixels.
|
||||
let mut narrow = src.clone();
|
||||
fx("blur.smart", json!({"blurMode": "edgeOnly", "radius": 0.1})).apply(&mut narrow, w, h, &space(w, h));
|
||||
assert_eq!(narrow, want);
|
||||
// A threshold under the checkerboard's 8 levels makes its lighter squares edges too.
|
||||
let mut low = src.clone();
|
||||
fx("blur.smart", json!({"blurMode": "edgeOnly", "threshold": 5})).apply(&mut low, w, h, &space(w, h));
|
||||
fx("blur.smart", json!({"blurMode": "edgeOnly", "radius": 1, "threshold": 5})).apply(&mut low, w, h, &space(w, h));
|
||||
for y in 1..h - 1 {
|
||||
for x in 1..6 {
|
||||
let lighter = matches!(at(&src, w, x, y)[0], 64 | 74);
|
||||
@@ -692,7 +695,7 @@ fn smart_blur_edge_only_draws_the_edges_white_on_black() {
|
||||
let (w, h) = (8, 8);
|
||||
let shape = image(w, h, |x, y| if (2..6).contains(&x) && (2..6).contains(&y) { [64, 0, 0, 128] } else { [0; 4] });
|
||||
let mut s = shape.clone();
|
||||
fx("blur.smart", json!({"blurMode": "edgeOnly"})).apply(&mut s, w, h, &space(w, h));
|
||||
fx("blur.smart", json!({"blurMode": "edgeOnly", "radius": 1})).apply(&mut s, w, h, &space(w, h));
|
||||
let ring = |x: usize, y: usize| (2..6).contains(&x) && (2..6).contains(&y) && !((3..5).contains(&x) && (3..5).contains(&y));
|
||||
let inside = |x: usize, y: usize| (3..5).contains(&x) && (3..5).contains(&y);
|
||||
let want = image(w, h, |x, y| match (ring(x, y), inside(x, y)) {
|
||||
@@ -707,10 +710,10 @@ fn smart_blur_edge_only_draws_the_edges_white_on_black() {
|
||||
fn smart_blur_overlay_edge_draws_the_edges_white_over_the_blur() {
|
||||
let (w, h, src, edge) = smart_blur_steps();
|
||||
let mut normal = src.clone();
|
||||
fx("blur.smart", json!({})).apply(&mut normal, w, h, &space(w, h));
|
||||
fx("blur.smart", json!({"radius": 1})).apply(&mut normal, w, h, &space(w, h));
|
||||
assert_ne!(normal, src, "the checkerboard is blurred");
|
||||
let mut d = src.clone();
|
||||
fx("blur.smart", json!({"blurMode": "overlayEdge"})).apply(&mut d, w, h, &space(w, h));
|
||||
fx("blur.smart", json!({"blurMode": "overlayEdge", "radius": 1})).apply(&mut d, w, h, &space(w, h));
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let want = if edge(x, y) { [255; 4] } else { at(&normal, w, x, y) };
|
||||
@@ -726,6 +729,87 @@ fn smart_blur_overlay_edge_draws_the_edges_white_over_the_blur() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The edges #2154 drew at any radius: a pixel is on an edge when one of its four nearest pixels
|
||||
/// (transparent beyond the raster) differs from it by more than `t` levels in a channel and its
|
||||
/// channels add up to at least that pixel's.
|
||||
fn four_neighbor_edges(src: &[u8], w: usize, h: usize, t: u8) -> Vec<bool> {
|
||||
let px = |x: isize, y: isize| if x < 0 || y < 0 || x as usize >= w || y as usize >= h { [0; 4] } else { at(src, w, x as usize, y as usize) };
|
||||
let sum = |p: [u8; 4]| p.iter().map(|v| u32::from(*v)).sum::<u32>();
|
||||
let mut edges = Vec::new();
|
||||
for y in 0..h as isize {
|
||||
for x in 0..w as isize {
|
||||
let c = px(x, y);
|
||||
edges.push([(1, 0), (-1, 0), (0, 1), (0, -1)].iter().any(|(dx, dy)| {
|
||||
let n = px(x + dx, y + dy);
|
||||
c.iter().zip(&n).any(|(a, b)| a.abs_diff(*b) > t) && sum(c) >= sum(n)
|
||||
}));
|
||||
}
|
||||
}
|
||||
edges
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smart_blur_edges_at_radius_one_are_the_four_neighbor_edges() {
|
||||
let (w, h) = (48, 40);
|
||||
let src = image(w, h, |x, y| {
|
||||
let a = if x > 4 && y < 36 { 255 } else { 128 };
|
||||
[((x * 5 + y) as u8).min(a), ((y * 6) as u8).min(a), (((x * y) % 97) as u8).min(a), a]
|
||||
});
|
||||
for t in [5, 10, 25, 60] {
|
||||
let edges = four_neighbor_edges(&src, w, h, t);
|
||||
assert!(edges.contains(&true) && edges.contains(&false), "threshold {t}");
|
||||
let want: Vec<u8> = src.as_chunks::<4>().0.iter().zip(&edges).flat_map(|(p, e)| if *e { [p[3]; 4] } else { [0, 0, 0, p[3]] }).collect();
|
||||
for radius in [0.1, 0.5, 1.0] {
|
||||
let mut d = src.clone();
|
||||
fx("blur.smart", json!({"blurMode": "edgeOnly", "radius": radius, "threshold": t})).apply(&mut d, w, h, &space(w, h));
|
||||
assert_eq!(d, want, "threshold {t}, radius {radius}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smart_blur_radius_widens_the_edge_search() {
|
||||
// Gray 60 left of x = 15 and gray 200 from it on: a step of 140 levels.
|
||||
let (w, h) = (30, 16);
|
||||
let src = image(w, h, |x, _| grey(if x < 15 { 60 } else { 200 }));
|
||||
for radius in [1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5] {
|
||||
let mut d = src.clone();
|
||||
fx("blur.smart", json!({"blurMode": "edgeOnly", "radius": radius})).apply(&mut d, w, h, &space(w, h));
|
||||
// The pixels within `radius` of a darker one: the step's first `r` columns on its lighter
|
||||
// side, and a band `r` wide along the raster's border (transparency is beyond it). Up to
|
||||
// radius 4.5 the search reads no blurred copy: at 4 and 4.5 the medium quality's 7 samples
|
||||
// across are 4/3 pixels apart, under the 2 pixels a box blur of radius 1 needs.
|
||||
let r = radius as usize;
|
||||
let want =
|
||||
image(w, h, |x, y| if x < r || y < r || x >= w - r || y >= h - r || (15..15 + r).contains(&x) { [255; 4] } else { [0, 0, 0, 255] });
|
||||
assert_eq!(d, want, "radius {radius}");
|
||||
}
|
||||
// Overlay Edge draws the same edges over the blur.
|
||||
let mut normal = src.clone();
|
||||
fx("blur.smart", json!({"radius": 3})).apply(&mut normal, w, h, &space(w, h));
|
||||
let mut over = src.clone();
|
||||
fx("blur.smart", json!({"blurMode": "overlayEdge", "radius": 3})).apply(&mut over, w, h, &space(w, h));
|
||||
assert_eq!(at(&over, w, 17, 8), [255; 4]);
|
||||
assert_eq!(at(&over, w, 18, 8), at(&normal, w, 18, 8));
|
||||
// A wide radius reads a grid of samples, also through a blurred copy, and finds the step from
|
||||
// the radius away, on its lighter side only.
|
||||
let (w, h) = (260, 100);
|
||||
let src = image(w, h, |x, _| grey(if x < 100 { 60 } else { 200 }));
|
||||
let mut d = src.clone();
|
||||
fx("blur.smart", json!({"blurMode": "edgeOnly", "radius": 40})).apply(&mut d, w, h, &space(w, h));
|
||||
for (x, edge) in [(100, true), (130, true), (139, true), (170, false), (99, false), (70, false)] {
|
||||
assert_eq!(at(&d, w, x, 50)[0] == 255, edge, "({x}, 50)");
|
||||
}
|
||||
// A dark band narrower than the gaps between the samples is found through the blurred copy:
|
||||
// at x = 120 the samples fall on x = 107, 93 and 80, all light.
|
||||
let src = image(w, h, |x, _| grey(if (100..104).contains(&x) { 60 } else { 200 }));
|
||||
let mut d = src.clone();
|
||||
fx("blur.smart", json!({"blurMode": "edgeOnly", "radius": 40})).apply(&mut d, w, h, &space(w, h));
|
||||
for (x, edge) in [(104, true), (120, true), (102, false)] {
|
||||
assert_eq!(at(&d, w, x, 50)[0] == 255, edge, "band ({x}, 50)");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsharp_mask_overshoots_at_edges() {
|
||||
let (w, h) = (40, 10);
|
||||
@@ -1893,3 +1977,272 @@ fn brush_strokes_follow_the_object_at_any_resolution() {
|
||||
assert_eq!(shifted, one, "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Halftone Pattern of `p` over `w` × `h` pixels of `src`, laid out by `s`.
|
||||
fn halftone(p: serde_json::Value, src: &[u8], w: usize, h: usize, s: &PixelSpace) -> Vec<u8> {
|
||||
let mut out = src.to_vec();
|
||||
fx("sketch.halftonePattern", p).apply(&mut out, w, h, s);
|
||||
out
|
||||
}
|
||||
|
||||
/// The smallest period of `values` (their length when they don't repeat).
|
||||
fn period(values: &[u8]) -> usize {
|
||||
(1..values.len()).find(|p| values.iter().zip(&values[*p..]).all(|(a, b)| a == b)).unwrap_or(values.len())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn halftone_pattern_params_take_defaults_and_stay_in_range() {
|
||||
assert_eq!(fx("sketch.halftonePattern", json!({})), PixelFx::HalftonePattern { size: 1.0, contrast: 5.0, kind: HalftonePattern::Dot });
|
||||
assert_eq!(
|
||||
fx("sketch.halftonePattern", json!({"size": 1e9, "contrast": -3, "patternType": "LINE"})),
|
||||
PixelFx::HalftonePattern { size: 12.0, contrast: 0.0, kind: HalftonePattern::Line }
|
||||
);
|
||||
assert_eq!(
|
||||
fx("sketch.halftonePattern", json!({"size": 0, "contrast": 1e9, "patternType": "circle"})),
|
||||
PixelFx::HalftonePattern { size: 1.0, contrast: 50.0, kind: HalftonePattern::Circle }
|
||||
);
|
||||
for junk in [json!({"patternType": 2}), json!({"patternType": "nonsense"})] {
|
||||
assert_eq!(fx("sketch.halftonePattern", junk), fx("sketch.halftonePattern", json!({})));
|
||||
}
|
||||
let doc = crate::effect_info("sketch.halftonePattern").unwrap().params;
|
||||
let kinds: Vec<HalftonePattern> = HALFTONE_TYPES.iter().map(|(_, v)| HalftonePattern::parse(v)).collect();
|
||||
assert_eq!(kinds, [HalftonePattern::Circle, HalftonePattern::Dot, HalftonePattern::Line]);
|
||||
assert!(HALFTONE_TYPES.iter().all(|(_, v)| doc.contains(&format!("\"{v}\""))), "{doc}");
|
||||
let f = fx("sketch.halftonePattern", json!({"size": 12}));
|
||||
assert_eq!(f.outset(Rect::new(0.0, 0.0, 100.0, 50.0)), 0.0);
|
||||
assert_eq!(f.reach(), Some(0.0));
|
||||
}
|
||||
|
||||
/// Line's rows repeat every Size points: Size pixels at 72 ppi, twice as many at 144 ppi.
|
||||
#[test]
|
||||
fn halftone_line_period_is_the_size_at_any_resolution() {
|
||||
let p = json!({"size": 4, "contrast": 50, "patternType": "line"});
|
||||
let (w, h) = (48, 48);
|
||||
let out = halftone(p.clone(), &image(w, h, |_, _| grey(128)), w, h, &space(w, h));
|
||||
let column: Vec<u8> = (0..h).map(|y| at(&out, w, 0, y)[0]).collect();
|
||||
assert!(column.contains(&0) && column.contains(&255), "{column:?}");
|
||||
assert_eq!(period(&column), 4, "{column:?}");
|
||||
let fine = PixelSpace { to_doc: Affine::scale(0.5), px: 0.5, ..space(w, h) };
|
||||
let out = halftone(p, &image(2 * w, 2 * h, |_, _| grey(128)), 2 * w, 2 * h, &fine);
|
||||
let column: Vec<u8> = (0..2 * h).map(|y| at(&out, 2 * w, 0, y)[0]).collect();
|
||||
assert_eq!(period(&column), 8, "{column:?}");
|
||||
}
|
||||
|
||||
/// Circle draws rings round the object's centre, Line rows that don't change along them, and
|
||||
/// Dot neither.
|
||||
#[test]
|
||||
fn halftone_pattern_type_changes_the_pattern() {
|
||||
let (w, h) = (64, 64);
|
||||
let src = image(w, h, |_, _| grey(100));
|
||||
// The centre on a pixel's centre: pixel (32 + dx, 32 + dy) lies at (dx, dy) from it.
|
||||
let s = PixelSpace { center: Point::new(32.5, 32.5), ..space(w, h) };
|
||||
let draw = |kind: &str| halftone(json!({"size": 8, "contrast": 50, "patternType": kind}), &src, w, h, &s);
|
||||
let (circle, dot, line) = (draw("circle"), draw("dot"), draw("line"));
|
||||
assert!(circle != dot && dot != line && line != circle);
|
||||
// Pixels at the same distance from the centre in different directions.
|
||||
let same_radius: [&[(i64, i64)]; 6] = [
|
||||
&[(5, 0), (3, 4), (-4, 3), (0, -5), (-3, -4)],
|
||||
&[(10, 0), (6, 8), (-8, -6), (0, 10)],
|
||||
&[(13, 0), (5, 12), (-12, 5), (0, -13)],
|
||||
&[(15, 0), (9, 12), (-12, 9)],
|
||||
&[(17, 0), (8, 15), (-15, -8)],
|
||||
&[(25, 0), (7, 24), (-15, 20), (-20, -15), (24, -7)],
|
||||
];
|
||||
let value = |d: &[u8], (dx, dy): (i64, i64)| at(d, w, (32 + dx) as usize, (32 + dy) as usize)[0];
|
||||
let radial = |d: &[u8]| same_radius.iter().all(|ring| ring.iter().all(|q| value(d, *q) == value(d, ring[0])));
|
||||
assert!(radial(&circle), "Circle is the same all round");
|
||||
assert!(!radial(&dot) && !radial(&line));
|
||||
for y in 0..h {
|
||||
assert!((0..w).all(|x| at(&line, w, x, y) == at(&line, w, 0, y)), "row {y} of Line is uniform");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn halftone_contrast_sharpens_the_screen() {
|
||||
let (w, h) = (64, 32);
|
||||
let src = image(w, h, |x, _| grey((x * 4) as u8));
|
||||
let mid = |d: &[u8]| share(d, |p| p[0] != 0 && p[0] != 255);
|
||||
let soft = halftone(json!({"size": 6, "contrast": 0}), &src, w, h, &space(w, h));
|
||||
let hard = halftone(json!({"size": 6, "contrast": 50}), &src, w, h, &space(w, h));
|
||||
assert!(mid(&soft) > 0.3, "{}", mid(&soft));
|
||||
assert_eq!(mid(&hard), 0.0);
|
||||
}
|
||||
|
||||
/// Halftone Pattern redraws in black ink on white paper, whatever the object's colours, and
|
||||
/// keeps its coverage.
|
||||
#[test]
|
||||
fn halftone_draws_in_black_and_white_only() {
|
||||
let (w, h) = (48, 48);
|
||||
let src = image(w, h, |x, y| {
|
||||
let c = [[230u8, 20, 20], [20, 200, 40], [30, 60, 220], [240, 220, 30]][(x / 12) % 4];
|
||||
let a: u16 = if y < 24 { 255 } else { 128 };
|
||||
[c[0], c[1], c[2]].map(|v| (u16::from(v) * a / 255) as u8).into_iter().chain([a as u8]).collect::<Vec<_>>().try_into().unwrap()
|
||||
});
|
||||
for kind in ["circle", "dot", "line"] {
|
||||
let out = halftone(json!({"size": 6, "contrast": 50, "patternType": kind}), &src, w, h, &space(w, h));
|
||||
for (p, q) in out.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0) {
|
||||
assert_eq!(p[3], q[3], "{kind}: coverage kept");
|
||||
assert!(p[0] == p[1] && p[1] == p[2] && (p[0] == 0 || p[0] == p[3]), "{kind}: {p:?} from {q:?}");
|
||||
}
|
||||
assert!(share(&out, |p| p[0] == 0) > 0.1 && share(&out, |p| p[0] == p[3]) > 0.1, "{kind}: ink and paper");
|
||||
let soft = halftone(json!({"size": 6, "contrast": 0, "patternType": kind}), &src, w, h, &space(w, h));
|
||||
assert!(soft.as_chunks::<4>().0.iter().all(|p| p[0] == p[1] && p[1] == p[2]), "{kind}: greys only");
|
||||
for (tone, expect) in [(0, 0), (255, 255)] {
|
||||
let out = halftone(json!({"size": 6, "contrast": 0, "patternType": kind}), &image(w, h, |_, _| grey(tone)), w, h, &space(w, h));
|
||||
assert!(out.as_chunks::<4>().0.iter().all(|p| p[0] == expect), "{kind}: {tone} stays {expect}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The distinct colours of the covered pixels.
|
||||
fn colours(d: &[u8]) -> std::collections::BTreeSet<[u8; 4]> {
|
||||
d.as_chunks::<4>().0.iter().filter(|p| p[3] > 0).copied().collect()
|
||||
}
|
||||
|
||||
/// A left-to-right rainbow ramp, half covered at its bottom rows.
|
||||
fn ramp(w: usize, h: usize) -> Vec<u8> {
|
||||
image(w, h, |x, y| {
|
||||
let t = x * 255 / (w - 1);
|
||||
let a = if y + 4 >= h { 128 } else { 255 };
|
||||
let c = [t, 255 - t, (t * 3) % 256].map(|v| (v * a / 255) as u8);
|
||||
[c[0], c[1], c[2], a as u8]
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artistic_params_take_defaults_and_stay_in_range() {
|
||||
assert_eq!(fx("artistic.cutout", json!({})), PixelFx::Cutout { levels: 4.0, simplicity: 4.0, fidelity: 2.0 });
|
||||
assert_eq!(
|
||||
fx("artistic.cutout", json!({"levels": 1e308, "edgeSimplicity": -5, "edgeFidelity": "NaN"})),
|
||||
PixelFx::Cutout { levels: 8.0, simplicity: 0.0, fidelity: 2.0 }
|
||||
);
|
||||
assert_eq!(fx("artistic.posterEdges", json!({})), PixelFx::PosterEdges { thickness: 2.0, intensity: 1.0, posterization: 2.0 });
|
||||
assert_eq!(
|
||||
fx("artistic.posterEdges", json!({"edgeThickness": 99, "edgeIntensity": -1, "posterization": 6.4})),
|
||||
PixelFx::PosterEdges { thickness: 10.0, intensity: 0.0, posterization: 6.0 }
|
||||
);
|
||||
let PixelFx::NeonGlow { size, brightness, colour } = fx("artistic.neonGlow", json!({})) else { panic!() };
|
||||
assert_eq!((size, brightness), (5.0, 15.0));
|
||||
assert!((colour[0] - 0.3).abs() < 0.01 && (colour[1] - 0.65).abs() < 0.01 && colour[2] == 1.0, "{colour:?}");
|
||||
assert_eq!(
|
||||
fx("artistic.neonGlow", json!({"glowSize": -1e308, "glowBrightness": 80, "color": "#ff0000"})),
|
||||
PixelFx::NeonGlow { size: -24.0, brightness: 50.0, colour: [1.0, 0.0, 0.0] }
|
||||
);
|
||||
for id in ["artistic.cutout", "artistic.neonGlow", "artistic.posterEdges"] {
|
||||
assert_eq!(fx(id, json!({})).outset(Rect::new(0.0, 0.0, 40.0, 30.0)), 0.0, "{id} keeps the object's shape");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cutout_paints_a_few_flat_colours_and_keeps_coverage() {
|
||||
let (w, h) = (64, 24);
|
||||
let src = ramp(w, h);
|
||||
for levels in [2, 3, 5, 8] {
|
||||
let mut out = src.clone();
|
||||
fx("artistic.cutout", json!({"levels": levels})).apply(&mut out, w, h, &space(w, h));
|
||||
let opaque: std::collections::BTreeSet<_> = colours(&out).into_iter().filter(|p| p[3] == 255).collect();
|
||||
assert!(opaque.len() <= levels * levels * levels && opaque.len() >= 2, "{levels} levels: {} colours", opaque.len());
|
||||
assert!(opaque.len() < 64, "flat pieces, not a ramp: {levels} levels, {} colours", opaque.len());
|
||||
assert!(out.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0).all(|(o, s)| o[3] == s[3]));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cutout_simplicity_smooths_the_pieces_edges() {
|
||||
// A noisy grey: simpler edges join the speckles into fewer changes between neighbours.
|
||||
let (w, h) = (48, 48);
|
||||
let src = image(w, h, |x, y| grey(if (x * 7 + y * 13) % 5 < 2 { 60 } else { 190 }));
|
||||
let changes = |simplicity: f64| {
|
||||
let mut out = src.clone();
|
||||
fx("artistic.cutout", json!({"levels": 2, "edgeSimplicity": simplicity, "edgeFidelity": 1})).apply(&mut out, w, h, &space(w, h));
|
||||
out.as_chunks::<4>().0.windows(2).filter(|p| p[0] != p[1]).count()
|
||||
};
|
||||
assert!(changes(10.0) * 4 < changes(0.0), "{} vs {}", changes(10.0), changes(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poster_edges_draw_black_lines_on_edges_and_posterize_the_rest() {
|
||||
let (w, h) = (40, 20);
|
||||
// Two flat halves: a line where they meet, posterized colour away from it.
|
||||
let src = image(w, h, |x, _| if x < w / 2 { [200, 90, 30, 255] } else { [30, 120, 210, 255] });
|
||||
let mut out = src.clone();
|
||||
fx("artistic.posterEdges", json!({"edgeThickness": 2, "edgeIntensity": 10, "posterization": 0})).apply(&mut out, w, h, &space(w, h));
|
||||
let line = at(&out, w, w / 2, h / 2);
|
||||
assert!(line[0] < 40 && line[1] < 40 && line[2] < 40, "a dark line on the edge: {line:?}");
|
||||
// 2 levels per channel: every far pixel is 0 or 255 in each channel.
|
||||
for x in [3, w - 4] {
|
||||
let p = at(&out, w, x, h / 2);
|
||||
assert!(p[..3].iter().all(|v| *v == 0 || *v == 255), "{x}: {p:?}");
|
||||
}
|
||||
// Inside a flat colour, away from the object's outline, there are no lines: it only posterizes.
|
||||
let mut flat = image(w, h, |_, _| [200, 90, 30, 255]);
|
||||
fx("artistic.posterEdges", json!({"edgeIntensity": 10, "posterization": 6})).apply(&mut flat, w, h, &space(w, h));
|
||||
let inside: std::collections::BTreeSet<_> = (6..h - 6).flat_map(|y| (6..w - 6).map(move |x| (x, y))).map(|(x, y)| at(&flat, w, x, y)).collect();
|
||||
assert_eq!(inside.len(), 1, "{inside:?}");
|
||||
assert!(at(&flat, w, 0, h / 2)[0] < 60, "the outline takes a line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poster_edges_thickness_widens_the_lines() {
|
||||
let (w, h) = (60, 10);
|
||||
let src = image(w, h, |x, _| grey(if x < w / 2 { 30 } else { 230 }));
|
||||
let dark = |thickness: f64| {
|
||||
let mut out = src.clone();
|
||||
fx("artistic.posterEdges", json!({"edgeThickness": thickness, "edgeIntensity": 10, "posterization": 6})).apply(&mut out, w, h, &space(w, h));
|
||||
(0..w).filter(|x| at(&out, w, *x, h / 2)[0] < 20 && (*x >= w / 2 || at(&src, w, *x, h / 2)[0] > 20)).count()
|
||||
};
|
||||
assert!(dark(10.0) > dark(0.0), "{} vs {}", dark(10.0), dark(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neon_glow_lights_the_bright_or_the_dark_areas_in_its_colour() {
|
||||
let (w, h) = (40, 20);
|
||||
let src = image(w, h, |x, _| grey(if x < w / 2 { 20 } else { 235 }));
|
||||
let run = |size: f64| {
|
||||
let mut out = src.clone();
|
||||
fx("artistic.neonGlow", json!({"glowSize": size, "glowBrightness": 40, "color": "#ff0000"})).apply(&mut out, w, h, &space(w, h));
|
||||
(at(&out, w, 3, h / 2), at(&out, w, w - 4, h / 2))
|
||||
};
|
||||
// Red glow: the red channel rises above green and blue where it glows.
|
||||
let red = |p: [u8; 4]| i32::from(p[0]) - i32::from(p[1]);
|
||||
let (dark, light) = run(8.0);
|
||||
assert!(red(light) > 60 && red(light) > red(dark) + 40, "light areas glow: {dark:?} {light:?}");
|
||||
let (dark, light) = run(-8.0);
|
||||
assert!(red(dark) > 60 && red(dark) > red(light) + 40, "dark areas glow: {dark:?} {light:?}");
|
||||
// No brightness: dimmed greys only.
|
||||
let mut out = src.clone();
|
||||
fx("artistic.neonGlow", json!({"glowBrightness": 0})).apply(&mut out, w, h, &space(w, h));
|
||||
assert!(out.as_chunks::<4>().0.iter().all(|p| p[0] == p[1] && p[1] == p[2] && p[3] == 255));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artistic_filters_look_the_same_at_any_resolution() {
|
||||
// The same art at 1 and at 2 pixels per point: the filter at the coarse resolution matches
|
||||
// the fine result sampled at the same points.
|
||||
let (w, h) = (48, 32);
|
||||
let art = |x: f64, y: f64| grey(if (x - 24.0).hypot(y - 16.0) < 10.0 { 230 } else { 40 });
|
||||
let coarse = image(w, h, |x, y| art(x as f64 + 0.5, y as f64 + 0.5));
|
||||
let fine = image(2 * w, 2 * h, |x, y| art((x as f64 + 0.5) / 2.0, (y as f64 + 0.5) / 2.0));
|
||||
let mut half = space(2 * w, 2 * h);
|
||||
half.to_doc = Affine::scale(0.5);
|
||||
half.px = 0.5;
|
||||
half.center = Point::new(w as f64 / 2.0, h as f64 / 2.0);
|
||||
for (id, p) in [
|
||||
("artistic.cutout", json!({"edgeSimplicity": 6})),
|
||||
("artistic.neonGlow", json!({"glowSize": 12, "glowBrightness": 30})),
|
||||
("artistic.posterEdges", json!({"edgeThickness": 6, "edgeIntensity": 6})),
|
||||
] {
|
||||
let (mut a, mut b) = (coarse.clone(), fine.clone());
|
||||
fx(id, p.clone()).apply(&mut a, w, h, &space(w, h));
|
||||
fx(id, p).apply(&mut b, 2 * w, 2 * h, &half);
|
||||
let diff: f64 = (0..h)
|
||||
.flat_map(|y| (0..w).map(move |x| (x, y)))
|
||||
.map(|(x, y)| {
|
||||
let (p, q) = (at(&a, w, x, y), at(&b, 2 * w, 2 * x, 2 * y));
|
||||
(0..3).map(|k| f64::from(p[k].abs_diff(q[k]))).sum::<f64>() / 3.0
|
||||
})
|
||||
.sum::<f64>()
|
||||
/ (w * h) as f64;
|
||||
assert!(diff < 12.0, "{id}: mean difference {diff}");
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,9 @@
|
||||
//! (glyph outlines, the evaluated live object, the symbol's art) and [`reshape`] runs each effect
|
||||
//! over all of them as one piece, with the whole art's bounds as the effect's reference box, so a
|
||||
//! warp bends a line of type as a whole. An image is clipped by its reshaped frame. Raster effects
|
||||
//! (shadows, glows, blur, feather) stay on the result for the renderer and exporters.
|
||||
//! (shadows, glows, blur, feather) stay on the result for the renderer and exporters. Type without
|
||||
//! inline graphics whose own fills or strokes have geometry effects is outlined too: each of those
|
||||
//! effects reshapes the whole outline that its fill or stroke paints.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -30,6 +32,25 @@ pub fn needs_outline(n: &Node) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// Is `n` a path or compound path with a visible Transform effect that makes copies and a gradient
|
||||
/// in a fill or stroke? Such an object is drawn through its art ([`outline_art`]) so that each copy
|
||||
/// paints its gradient by itself ([`copy_with_gradients`]), as a group's copies do.
|
||||
pub fn has_gradient_copies(n: &Node) -> bool {
|
||||
matches!(n.kind, NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. })
|
||||
&& (n.appearance.has_unplaced_gradient() || n.appearance.has_placed_gradient())
|
||||
&& geometry_effects(n)
|
||||
.iter()
|
||||
.any(|e| e.id == "distort.transform" && crate::distort::transform_map(Rect::ZERO, &merged_params(&e.id, &e.params), 0).1 > 0)
|
||||
}
|
||||
|
||||
/// Is `n` type without inline graphics that has a fill or stroke of its own that paints and has a
|
||||
/// visible geometry effect? Such an effect reshapes the whole outline that its fill or stroke
|
||||
/// paints ([`outline_text`], which leaves inline graphics out).
|
||||
pub fn has_item_geometry(n: &Node) -> bool {
|
||||
let NodeKind::Text(t) = &n.kind else { return false };
|
||||
t.runs.iter().all(|r| r.inline.is_none()) && n.appearance.items.iter().any(|i| crate::paints(i) && crate::has_geometry(i.effects()))
|
||||
}
|
||||
|
||||
/// The glyph outlines of type `t` in text space with its underline and strikethrough bars: one
|
||||
/// path per run, and all of them as one.
|
||||
pub(crate) fn glyph_outlines(t: &vectorcraft_doc::TextObject) -> (Vec<kurbo::BezPath>, kurbo::BezPath) {
|
||||
@@ -117,6 +138,16 @@ pub fn outline_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
a.transform(*xf, false);
|
||||
Node::group(n.id, vec![Arc::new(a)])
|
||||
}
|
||||
// The object as the one piece the copies of its Transform effect are made from.
|
||||
_ if has_gradient_copies(n) => {
|
||||
let mut one = n.clone();
|
||||
one.opacity = 1.0;
|
||||
one.blend = Default::default();
|
||||
one.isolate = false;
|
||||
one.extras_mut().mask = None;
|
||||
one.appearance.effects.clear();
|
||||
Node::group(n.id, vec![Arc::new(one)])
|
||||
}
|
||||
_ if vectorcraft_doc::live::is_live(n) => vectorcraft_doc::live::expand_deep(n, Some(hook)),
|
||||
_ => return None,
|
||||
};
|
||||
@@ -155,20 +186,27 @@ pub(crate) fn outline_nested_text(n: &mut Node, clipping: bool) {
|
||||
|
||||
/// `n` (see [`needs_outline`]) with its object-level geometry effects and its perspective applied:
|
||||
/// its [`outline_art`] reshaped as one piece, or for an image the image clipped by its reshaped
|
||||
/// frame. The result is a group with `n`'s id, name, visibility, transparency, opacity mask and
|
||||
/// remaining (raster) effects; `None` when `n` has neither a visible geometry effect nor a
|
||||
/// perspective, or has no art (a symbol instance without `symbol_art`).
|
||||
/// frame. Type's own fills and strokes keep their effects on its whole outline. The result is a
|
||||
/// group with `n`'s id, name, visibility, transparency, opacity mask and remaining (raster)
|
||||
/// effects; `None` when `n` has no visible geometry effect, no perspective and is not type with
|
||||
/// a geometry effect on a fill or stroke of its own ([`has_item_geometry`]), or has no art (a
|
||||
/// symbol instance without `symbol_art`).
|
||||
pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
if (geometry_effects(n).is_empty() && n.projection().is_none()) || !needs_outline(n) {
|
||||
if (geometry_effects(n).is_empty() && n.projection().is_none() && !has_item_geometry(n)) || !(needs_outline(n) || has_gradient_copies(n)) {
|
||||
return None;
|
||||
}
|
||||
as_art(n, symbol_art)
|
||||
}
|
||||
|
||||
/// `n` (see [`needs_outline`]) as plain art for Expand Appearance: [`reshape`]d when it has
|
||||
/// `n` (see [`needs_outline`], or a path with [`has_gradient_copies`]) as plain art for Expand
|
||||
/// Appearance: [`reshape`]d when it has
|
||||
/// geometry effects, else type with fills or strokes of its own outlined, in the same form. `None`
|
||||
/// when it has neither.
|
||||
pub fn expand_outlined(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
// A path whose Transform effect copies it with a gradient: each copy an object of its own.
|
||||
if has_gradient_copies(n) {
|
||||
return as_art(n, symbol_art);
|
||||
}
|
||||
let own_paint = matches!(n.kind, NodeKind::Text(_)) && !n.appearance.items.is_empty();
|
||||
if !needs_outline(n) || (geometry_effects(n).is_empty() && !own_paint) {
|
||||
return None;
|
||||
@@ -256,13 +294,21 @@ pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect]) {
|
||||
for e in fx {
|
||||
let Some(bounds) = art.geometric_bounds() else { break };
|
||||
let params = merged_params(&e.id, &e.params);
|
||||
let transform = (e.id == "distort.transform").then(|| crate::distort::transform_map(bounds, ¶ms, seed));
|
||||
for_each_leaf(art, &mut |leaf| {
|
||||
let Some((path, rule)) = leaf_geometry(leaf) else { return };
|
||||
// A gradient paints each copy by itself, not the one piece the copies make together.
|
||||
if let Some((m, copies)) = transform
|
||||
&& copies > 0
|
||||
&& (leaf.appearance.has_unplaced_gradient() || leaf.appearance.has_placed_gradient())
|
||||
{
|
||||
copy_with_gradients(leaf, &path, m, copies);
|
||||
return;
|
||||
}
|
||||
let out = apply_one(&e.id, ¶ms, &path, bounds, &GeomContext { seed, ..GeomContext::of(leaf) });
|
||||
set_geometry(leaf, out, rule);
|
||||
});
|
||||
if e.id == "distort.transform" {
|
||||
let (m, copies) = crate::distort::transform_map(bounds, ¶ms, seed);
|
||||
if let Some((m, copies)) = transform {
|
||||
transform_placed(art, m, copies);
|
||||
}
|
||||
}
|
||||
@@ -271,6 +317,34 @@ pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace `leaf` (a path or compound path with geometry `path`) by a group of it and `copies`
|
||||
/// more, each mapped once more by `m` than the last, as a Transform effect copies an object. Each
|
||||
/// copy is an object of its own, so its gradients fit it (a placed gradient moves with it) rather
|
||||
/// than spanning every copy. The copies stop where [`crate::distort::transform_within`]'s would.
|
||||
fn copy_with_gradients(leaf: &mut Node, path: &PathData, m: Affine, copies: usize) {
|
||||
let each = path.anchor_count();
|
||||
let mut anchors = each;
|
||||
let mut placements: Vec<Arc<Node>> = vec![Arc::new(leaf.clone())];
|
||||
let mut xf = Affine::IDENTITY;
|
||||
for _ in 0..copies {
|
||||
anchors = anchors.saturating_add(each);
|
||||
if anchors > crate::distort::MAX_COPY_ANCHORS {
|
||||
break;
|
||||
}
|
||||
xf *= m;
|
||||
let mut copy = leaf.clone();
|
||||
copy.transform(xf, vectorcraft_doc::Scaling::default());
|
||||
if !copy.geometric_bounds().is_some_and(crate::distort::within_reach) {
|
||||
break;
|
||||
}
|
||||
placements.push(Arc::new(copy));
|
||||
}
|
||||
// The copies become nodes of their own under a group with the object's id and name.
|
||||
let mut g = Node::group(leaf.id, placements);
|
||||
g.name = leaf.name.clone();
|
||||
*leaf = g;
|
||||
}
|
||||
|
||||
/// The bounds of each group of type's outlines under `n` ([`outline_text`]), `n` included, in
|
||||
/// order. A group inside one of them is part of it.
|
||||
fn type_outline_bounds(n: &Node, out: &mut Vec<Option<Rect>>) {
|
||||
|
||||
@@ -510,6 +510,31 @@ fn zigzag_spaces_ridges_evenly_along_a_straight_line() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #2343: Smooth points make a continuous wave, not a zigzag with rounded tips. Every peak and
|
||||
/// valley is a smooth point whose handles run parallel to the line, and the curve between them
|
||||
/// follows a cosine, even where the ridges are much taller than they are apart.
|
||||
#[test]
|
||||
fn zigzag_smooth_is_a_wave() {
|
||||
use vectorcraft_geom::kurbo::ParamCurve;
|
||||
let line = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0)], false));
|
||||
for size in [3.0, 30.0] {
|
||||
let out = run("distort.zigZag", json!({"ridges": 9, "points": "smooth", "size": size}), &line);
|
||||
let sp = &out.subpaths[0];
|
||||
assert_eq!(sp.anchors.len(), 11);
|
||||
for a in &sp.anchors {
|
||||
assert!(close(a.h_in.y, a.p.y, 1e-9) && close(a.h_out.y, a.p.y, 1e-9), "size {size}: handles not level at {a:?}");
|
||||
}
|
||||
for i in 0..sp.segment_count() {
|
||||
let c = sp.segment(i);
|
||||
for k in 0..=20 {
|
||||
let q = c.eval(k as f64 / 20.0);
|
||||
let want = -size * (std::f64::consts::PI * q.x / 10.0).cos();
|
||||
assert!((q.y - want).abs() < 0.03 * size, "size {size}: {q:?} off the wave ({want})");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #1554 on curves: a circle's ridges sit at equal angles, at the radius plus and minus the size in
|
||||
/// turn all the way round.
|
||||
#[test]
|
||||
@@ -572,6 +597,18 @@ fn outline_stroke_uses_context_stroke() {
|
||||
assert!(close(out.bounds().unwrap().width(), 101.0, 0.1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_object_is_a_geometry_effect_that_keeps_the_path() {
|
||||
let info = effect_info("path.outlineObject").unwrap();
|
||||
assert_eq!((info.label, info.menu, &info.defaults, info.raster), ("Outline Object", &["Effect", "Path"][..], &json!({}), false));
|
||||
assert!(is_geometry("path.outlineObject"));
|
||||
assert_eq!(run("path.outlineObject", json!({}), &square()), square());
|
||||
// The effects after it reshape the same geometry.
|
||||
let b = square().bounds().unwrap();
|
||||
let offset = fx("path.offsetPath", json!({"offset": 5}));
|
||||
assert_eq!(apply_geometry(&[fx("path.outlineObject", json!({})), offset.clone()], &square(), b), apply_geometry(&[offset], &square(), b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_to_shape_sizes() {
|
||||
let r = run("convertToShape.rectangle", json!({"extraW": 20, "extraH": 10}), &square()).bounds().unwrap();
|
||||
|
||||
@@ -80,7 +80,7 @@ fn clean_name(name: &str) -> Option<String> {
|
||||
|
||||
/// Ids of art under `id`: the id itself when it is not a layer, else every non-layer under it
|
||||
/// (sublayers unfolded). Layers alone have nothing to collect.
|
||||
fn art_under(doc: &vectorcraft_doc::Document, id: NodeId, out: &mut Vec<NodeId>) {
|
||||
pub(crate) fn art_under(doc: &vectorcraft_doc::Document, id: NodeId, out: &mut Vec<NodeId>) {
|
||||
let Some(n) = doc.node(id) else { return };
|
||||
if n.is_layer() {
|
||||
if let Some(kids) = n.children() {
|
||||
|
||||
@@ -11,11 +11,12 @@ use std::sync::Arc;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_brush::{self as brush, Brush};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, Symbol, SymbolRegistration};
|
||||
use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, Symbol, SymbolExportType, SymbolNineSlice, SymbolRegistration};
|
||||
use vectorcraft_geom::{Affine, Point, Vec2};
|
||||
|
||||
use super::edit::{outermost, selected_roots};
|
||||
use super::pathops::{remove_each, separate};
|
||||
use super::swatch::names_param;
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
|
||||
@@ -65,8 +66,16 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
brush_new
|
||||
),
|
||||
cmd!("brush.delete", "Delete Brush", [], None, "{name} delete a brush; strokes using it become plain strokes", has_doc, brush_delete),
|
||||
cmd!("brush.duplicate", "Duplicate Brush", [], None, "{name, newName?} → {name}", has_doc, brush_duplicate),
|
||||
cmd!(
|
||||
"brush.delete",
|
||||
"Delete Brush",
|
||||
[],
|
||||
None,
|
||||
"{name} | {names: [name…]} delete brushes; strokes using them become plain strokes",
|
||||
has_doc,
|
||||
brush_delete
|
||||
),
|
||||
cmd!("brush.duplicate", "Duplicate Brush", [], None, "{name, newName?} | {names: [name…]} → {name} | {names}", has_doc, brush_duplicate),
|
||||
cmd!(
|
||||
"brush.options",
|
||||
"Brush Options…",
|
||||
@@ -95,16 +104,25 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
brush_freehand
|
||||
),
|
||||
// ----- symbols -----
|
||||
cmd!(query "symbol.list", "Symbols", [], None, "{} → {symbols: [{name, instances, size: [w,h], registration: 0..8}], current}", has_doc, symbol_list),
|
||||
cmd!(query "symbol.list", "Symbols", [], None, "{} → {symbols: [{name, instances, size: [w,h], registration: 0..8, exportType: movieClip|graphic}], current}", has_doc, symbol_list),
|
||||
cmd!(
|
||||
"symbol.new",
|
||||
"New Symbol…",
|
||||
[],
|
||||
None,
|
||||
"{name?, ids?|id?, registration?: 0..8 (9-point grid, 4 = centre; Symbol Options › Registration)} make a symbol from the selection and replace it with an instance → {name, id, registration}",
|
||||
"{name?, ids?|id?, registration?: 0..8 (9-point grid, 4 = centre; Symbol Options › Registration), exportType?: movieClip (default)|graphic, nineSlice?: bool, nineSliceGuides?: array} make a symbol from the selection and replace it with an instance; nineSlice is Symbol Options › Enable Guides for 9-Slice Scaling, nineSliceGuides [left, top, right, bottom] as fractions 0..1 of the natural size (default a third each) → {name, id, registration, exportType, nineSlice}",
|
||||
has_selection,
|
||||
symbol_new
|
||||
),
|
||||
cmd!(
|
||||
"symbol.options",
|
||||
"Symbol Options…",
|
||||
[],
|
||||
None,
|
||||
"{name?, newName?, registration?: 0..8, exportType?: movieClip|graphic, nineSlice?: bool, nineSliceGuides?: array} rename a symbol and/or set Symbol Options › Registration and Export Type and 9-slice guides (nineSliceGuides [left, top, right, bottom] fractions 0..1; default name: current) → {name, registration, exportType, nineSlice}",
|
||||
has_doc,
|
||||
symbol_options
|
||||
),
|
||||
cmd!(
|
||||
"symbol.place",
|
||||
"Place Symbol Instance",
|
||||
@@ -330,10 +348,10 @@ fn merge(base: &mut Value, patch: &Value) {
|
||||
}
|
||||
}
|
||||
|
||||
fn save_library(s: &mut Session, label: &str, lib: Vec<Brush>, rename: Option<(String, Option<String>)>) -> Result<()> {
|
||||
fn save_library(s: &mut Session, label: &str, lib: Vec<Brush>, renames: &[(String, Option<String>)]) -> Result<()> {
|
||||
s.edit(label, |d, _| {
|
||||
brush::store(d, &lib);
|
||||
if let Some((from, to)) = rename {
|
||||
for (from, to) in renames {
|
||||
let ids: Vec<NodeId> = {
|
||||
let mut v = vec![];
|
||||
d.walk(|n| {
|
||||
@@ -393,7 +411,7 @@ fn brush_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
v[k] = serde_json::to_value(&art).unwrap_or_default();
|
||||
}
|
||||
lib.push(parse_brush(C, v)?);
|
||||
save_library(s, "New Brush", lib, None)?;
|
||||
save_library(s, "New Brush", lib, &[])?;
|
||||
Ok(json!({ "name": name }))
|
||||
}
|
||||
|
||||
@@ -405,26 +423,35 @@ fn parse_brush(cmd: &str, v: Value) -> Result<Brush> {
|
||||
}
|
||||
|
||||
fn brush_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = name_param(p, "brush.delete")?.to_string();
|
||||
const C: &str = "brush.delete";
|
||||
let names = names_param(p, C)?;
|
||||
let mut lib = brush::library(&s.doc()?.doc);
|
||||
let n = lib.len();
|
||||
lib.retain(|b| b.name != name);
|
||||
if lib.len() == n {
|
||||
return Err(bad("brush.delete", format!("no brush named `{name}`")));
|
||||
if let Some(name) = names.iter().find(|n| !lib.iter().any(|b| &b.name == *n)) {
|
||||
return Err(bad(C, format!("no brush named `{name}`")));
|
||||
}
|
||||
save_library(s, "Delete Brush", lib, Some((name, None)))?;
|
||||
lib.retain(|b| !names.contains(&b.name));
|
||||
let renames: Vec<(String, Option<String>)> = names.into_iter().map(|n| (n, None)).collect();
|
||||
save_library(s, "Delete Brush", lib, &renames)?;
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Adds a copy of each brush in `name` or `names` at the end of the library. `newName`, given
|
||||
/// without `names`, names the copy.
|
||||
fn brush_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = name_param(p, "brush.duplicate")?;
|
||||
let mut lib = brush::library(&s.doc()?.doc);
|
||||
let mut b = lib.iter().find(|b| b.name == name).cloned().ok_or_else(|| bad("brush.duplicate", format!("no brush named `{name}`")))?;
|
||||
b.name = brush::unique_name(&lib, str_param(p, "newName").unwrap_or(&format!("{name} copy")));
|
||||
let out = b.name.clone();
|
||||
lib.push(b);
|
||||
save_library(s, "Duplicate Brush", lib, None)?;
|
||||
Ok(json!({ "name": out }))
|
||||
const C: &str = "brush.duplicate";
|
||||
let names = names_param(p, C)?;
|
||||
let before = brush::library(&s.doc()?.doc);
|
||||
let mut lib = before.clone();
|
||||
let mut out = vec![];
|
||||
for name in &names {
|
||||
let mut b = before.iter().find(|b| &b.name == name).cloned().ok_or_else(|| bad(C, format!("no brush named `{name}`")))?;
|
||||
let new_name = str_param(p, "newName").filter(|_| p.get("names").is_none()).map_or_else(|| format!("{name} copy"), str::to_string);
|
||||
b.name = brush::unique_name(&lib, &new_name);
|
||||
out.push(b.name.clone());
|
||||
lib.push(b);
|
||||
}
|
||||
save_library(s, "Duplicate Brush", lib, &[])?;
|
||||
Ok(if p.get("names").is_some() { json!({ "names": out }) } else { json!({ "name": out.first() }) })
|
||||
}
|
||||
|
||||
fn brush_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -449,7 +476,7 @@ fn brush_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
lib[i] = parse_brush(C, v)?;
|
||||
let out = lib[i].name.clone();
|
||||
let rename = new_name.map(|nn| (name, Some(nn)));
|
||||
save_library(s, "Brush Options", lib, rename)?;
|
||||
save_library(s, "Brush Options", lib, rename.as_slice())?;
|
||||
Ok(json!({ "name": out }))
|
||||
}
|
||||
|
||||
@@ -517,10 +544,57 @@ fn place_xf(at: Point, (w, h): (f64, f64), reg: SymbolRegistration) -> Affine {
|
||||
Affine::translate(c.to_vec2()) * Affine::scale_non_uniform(w / (2.0 * HALF), h / (2.0 * HALF))
|
||||
}
|
||||
|
||||
fn registration_param(p: &Value, cmd: &str) -> Result<SymbolRegistration> {
|
||||
fn registration_param(p: &Value, cmd: &str) -> Result<Option<SymbolRegistration>> {
|
||||
match p.get("registration") {
|
||||
None => Ok(SymbolRegistration::CENTER),
|
||||
Some(v) => v.as_u64().and_then(SymbolRegistration::from_u64).ok_or_else(|| bad(cmd, "registration must be 0..8")),
|
||||
None => Ok(None),
|
||||
Some(v) => v.as_u64().and_then(SymbolRegistration::from_u64).map(Some).ok_or_else(|| bad(cmd, "registration must be 0..8")),
|
||||
}
|
||||
}
|
||||
|
||||
fn export_type_param(p: &Value, cmd: &str) -> Result<Option<SymbolExportType>> {
|
||||
match str_param(p, "exportType") {
|
||||
None => Ok(None),
|
||||
Some(s) => SymbolExportType::from_id(s).map(Some).ok_or_else(|| bad(cmd, format!("exportType must be movieClip or graphic, not `{s}`"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// `nineSlice` (bool) and `nineSliceGuides` ([left, top, right, bottom] fractions of the natural
|
||||
/// size): `None` leaves the symbol alone, `Some(None)` turns 9-slice off, `Some(Some(g))` turns it
|
||||
/// on with guides `g` (the symbol's current ones, else the centre third, when none are given).
|
||||
fn nine_slice_param(p: &Value, cmd: &str, current: Option<SymbolNineSlice>) -> Result<Option<Option<SymbolNineSlice>>> {
|
||||
let on = match p.get("nineSlice") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(v.as_bool().ok_or_else(|| bad(cmd, "nineSlice must be true or false"))?),
|
||||
};
|
||||
let guides =
|
||||
match p.get("nineSliceGuides") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(SymbolNineSlice::from_value(v).ok_or_else(|| {
|
||||
bad(cmd, "nineSliceGuides must be [left, top, right, bottom], each 0..1, with left + right < 1 and top + bottom < 1")
|
||||
})?),
|
||||
};
|
||||
Ok(match (on, guides) {
|
||||
(Some(false), _) => Some(None),
|
||||
(None, None) => None,
|
||||
(_, Some(g)) => Some(Some(g)),
|
||||
(Some(true), None) => Some(Some(current.unwrap_or_default())),
|
||||
})
|
||||
}
|
||||
|
||||
fn nine_slice_json(d: &Document, name: &str) -> Value {
|
||||
d.symbol_nine_slice(name).map_or(Value::Null, SymbolNineSlice::to_value)
|
||||
}
|
||||
|
||||
fn rename_natural_size(d: &mut Document, from: &str, to: &str) {
|
||||
if from == to {
|
||||
return;
|
||||
}
|
||||
let size = d.unknown.get(SIZES_KEY).and_then(|m| m.get(from)).cloned();
|
||||
set_natural_size(d, from, None);
|
||||
if let Some(v) = size
|
||||
&& let (Some(w), Some(h)) = (v.get(0).and_then(Value::as_f64), v.get(1).and_then(Value::as_f64))
|
||||
{
|
||||
set_natural_size(d, to, Some((w, h)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -566,6 +640,8 @@ fn symbol_list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
"instances": instances_of(d, Some(&sym.name)).len(),
|
||||
"size": [w, h],
|
||||
"registration": d.symbol_registration(&sym.name).0,
|
||||
"exportType": d.symbol_export_type(&sym.name).id(),
|
||||
"nineSlice": nine_slice_json(d, &sym.name),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -589,11 +665,14 @@ fn replace_with_instance(d: &mut Document, ids: &[NodeId], name: &str, xf: Affin
|
||||
}
|
||||
|
||||
fn symbol_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "symbol.new";
|
||||
let ids = roots(s, p)?;
|
||||
let art = selection_art(s, p)?.ok_or_else(|| EngineError::Other("nothing selected".into()))?;
|
||||
let (art, size, c) = normalize(art)?;
|
||||
let name = s.doc()?.doc.unique_symbol_name(str_param(p, "name").filter(|n| !n.trim().is_empty()).unwrap_or("New Symbol"));
|
||||
let reg = registration_param(p, "symbol.new")?;
|
||||
let reg = registration_param(p, C)?.unwrap_or(SymbolRegistration::CENTER);
|
||||
let export = export_type_param(p, C)?.unwrap_or_default();
|
||||
let nine = nine_slice_param(p, C, None)?.flatten();
|
||||
// Keep the art where it was: the geometric centre stays at `c`, whatever the registration is.
|
||||
let place = place_xf(c + reg.offset_from_center(size.0, size.1), size, reg);
|
||||
let id = s.edit("New Symbol", |d, sel| {
|
||||
@@ -601,12 +680,73 @@ fn symbol_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
d.symbols.push(Symbol { name: name.clone(), art: Arc::new(art) });
|
||||
set_natural_size(d, &name, Some(size));
|
||||
d.set_symbol_registration(&name, reg);
|
||||
d.set_symbol_export_type(&name, export);
|
||||
d.set_symbol_nine_slice(&name, nine);
|
||||
d.unknown.insert(CURRENT_SYMBOL.into(), json!(name));
|
||||
let id = replace_with_instance(d, &ids, &name, place)?;
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})?;
|
||||
Ok(json!({ "name": name, "id": id.0, "registration": reg.0 }))
|
||||
Ok(
|
||||
json!({ "name": name, "id": id.0, "registration": reg.0, "exportType": export.id(), "nineSlice": nine.map_or(Value::Null, SymbolNineSlice::to_value) }),
|
||||
)
|
||||
}
|
||||
|
||||
fn symbol_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "symbol.options";
|
||||
let name = resolve_symbol(&s.doc()?.doc, p, C)?;
|
||||
let export = export_type_param(p, C)?;
|
||||
let reg = registration_param(p, C)?;
|
||||
let nine = nine_slice_param(p, C, s.doc()?.doc.symbol_nine_slice(&name))?;
|
||||
let new_name = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
|
||||
if new_name.is_none() && export.is_none() && reg.is_none() && nine.is_none() {
|
||||
return Err(bad(C, "pass newName, registration, exportType and/or nineSlice"));
|
||||
}
|
||||
if let Some(ref n) = new_name
|
||||
&& n != &name
|
||||
&& s.doc()?.doc.symbols.iter().any(|x| x.name == *n)
|
||||
{
|
||||
return Err(bad(C, format!("a symbol named `{n}` already exists")));
|
||||
}
|
||||
let final_name = new_name.clone().unwrap_or_else(|| name.clone());
|
||||
s.edit("Symbol Options", |d, _| {
|
||||
if let Some(ref n) = new_name
|
||||
&& n != &name
|
||||
{
|
||||
if let Some(sym) = d.symbols.iter_mut().find(|x| x.name == name) {
|
||||
sym.name = n.clone();
|
||||
}
|
||||
for id in instances_of(d, Some(&name)) {
|
||||
if let Some(NodeKind::SymbolInstance { symbol, .. }) = d.node_mut(id).map(|node| &mut node.kind) {
|
||||
*symbol = n.clone();
|
||||
}
|
||||
}
|
||||
rename_natural_size(d, &name, n);
|
||||
d.rename_symbol_registration(&name, n);
|
||||
d.rename_symbol_export_type(&name, n);
|
||||
d.rename_symbol_nine_slice(&name, n);
|
||||
if d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str) == Some(name.as_str()) {
|
||||
d.unknown.insert(CURRENT_SYMBOL.into(), json!(n));
|
||||
}
|
||||
}
|
||||
if let Some(r) = reg {
|
||||
d.set_symbol_registration(&final_name, r);
|
||||
}
|
||||
if let Some(kind) = export {
|
||||
d.set_symbol_export_type(&final_name, kind);
|
||||
}
|
||||
if let Some(g) = nine {
|
||||
d.set_symbol_nine_slice(&final_name, g);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
let d = &s.doc()?.doc;
|
||||
Ok(json!({
|
||||
"name": final_name,
|
||||
"registration": d.symbol_registration(&final_name).0,
|
||||
"exportType": d.symbol_export_type(&final_name).id(),
|
||||
"nineSlice": nine_slice_json(d, &final_name),
|
||||
}))
|
||||
}
|
||||
|
||||
fn resolve_symbol(d: &Document, p: &Value, cmd: &str) -> Result<String> {
|
||||
@@ -763,6 +903,8 @@ fn symbol_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
d.symbols.retain(|x| x.name != name);
|
||||
set_natural_size(d, &name, None);
|
||||
d.set_symbol_registration(&name, SymbolRegistration::CENTER);
|
||||
d.clear_symbol_export_type(&name);
|
||||
d.set_symbol_nine_slice(&name, None);
|
||||
if d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str) == Some(name.as_str()) {
|
||||
d.unknown.remove(CURRENT_SYMBOL);
|
||||
}
|
||||
@@ -779,14 +921,18 @@ fn symbol_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let new = d.unique_symbol_name(str_param(p, "newName").unwrap_or(&format!("{name} copy")));
|
||||
let size = natural_size(d, &name);
|
||||
let reg = d.symbol_registration(&name);
|
||||
let export = d.symbol_export_type(&name);
|
||||
let nine = d.symbol_nine_slice(&name);
|
||||
s.edit("Duplicate Symbol", |d, _| {
|
||||
let art = d.reid(&sym.art);
|
||||
d.symbols.push(Symbol { name: new.clone(), art: Arc::new(art) });
|
||||
set_natural_size(d, &new, Some(size));
|
||||
d.set_symbol_registration(&new, reg);
|
||||
d.set_symbol_export_type(&new, export);
|
||||
d.set_symbol_nine_slice(&new, nine);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "name": new }))
|
||||
Ok(json!({ "name": new, "registration": reg.0, "exportType": export.id(), "nineSlice": nine.map_or(Value::Null, SymbolNineSlice::to_value) }))
|
||||
}
|
||||
|
||||
fn symbol_replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -15,7 +15,7 @@ use std::sync::Arc;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::appearance::{AppearanceItem, FillLayer, LineCap, LineJoin, StrokeLayer};
|
||||
use vectorcraft_doc::{Appearance, Document, LiveShape, Node, NodeId, NodeKind, Selection, TraceView};
|
||||
use vectorcraft_doc::{Appearance, Document, GapOptions, GapSize, LiveShape, Node, NodeId, NodeKind, Selection, TraceView};
|
||||
use vectorcraft_geom::{FillRule, PathData, Point, Rect, SubPath};
|
||||
use vectorcraft_pathops as po;
|
||||
use vectorcraft_tools::builder::{
|
||||
@@ -88,6 +88,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection_or_ids,
|
||||
lp_expand
|
||||
),
|
||||
cmd!(
|
||||
"livePaint.gapOptions",
|
||||
"Gap Options",
|
||||
[],
|
||||
None,
|
||||
"{id?, ids?: [id…] (instead of id; default: the selected Live Paint groups), detect?: bool (Gap Detection: paint stops at gaps up to size wide), size?: \"small\" (3 pt) | \"medium\" (6 pt) | \"large\" (12 pt) | pt (custom, over 0 up to 72) (Paint Stops At), color?: #rrggbb or a layer color's name such as \"Red\" (Gap Preview Color), closeGaps?: bool (add an unpainted path across each gap up to size wide to the group's paths)} set the Gap Options of Live Paint groups (what is not given stays as it was; a group starts with detect false, small and Red) and make their faces again, keeping their paint → {ids, faces, gaps: [[x0,y0,x1,y1]…] (the gaps up to size wide that no path closes, from a loose end of a path to where it closes to; detect closes them for paint), closed (paths added)}",
|
||||
has_selection_or_ids,
|
||||
lp_gap_options
|
||||
),
|
||||
cmd!(
|
||||
"livePaint.fill",
|
||||
"Live Paint Bucket",
|
||||
@@ -111,7 +120,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Live Paint Info",
|
||||
[],
|
||||
None,
|
||||
"{group, id?: object id (default: the selection)} → {faces: [{id, fill, bounds}], edges: [{id, stroke, width}], sources}",
|
||||
"{group, id?: object id (default: the selection)} → {faces: [{id, fill, bounds}], edges: [{id, stroke, width}], sources, gapOptions: {detect, size: \"small\"|\"medium\"|\"large\"|pt, points, color: #rrggbb} (as livePaint.gapOptions sets them), gaps: [[x0,y0,x1,y1]…] (as livePaint.gapOptions returns them)}",
|
||||
has_doc,
|
||||
lp_info
|
||||
),
|
||||
@@ -383,10 +392,11 @@ fn source_nodes(n: &Node) -> Vec<Arc<Node>> {
|
||||
if is_live_paint(n) { b::sources(n).and_then(|g| g.children().cloned()).unwrap_or_default() } else { vec![Arc::new(n.clone())] }
|
||||
}
|
||||
|
||||
/// Planar map children (sources group, faces, edges) for `sources`.
|
||||
fn build_children(d: &mut Document, sources: Vec<Arc<Node>>) -> Result<(Vec<Arc<Node>>, usize, usize)> {
|
||||
/// The planar map's inputs from `sources` (paths and compound paths, back → front), with the
|
||||
/// fill, stroke and stroke width of each.
|
||||
fn source_shapes(sources: &[Arc<Node>]) -> (Vec<po::Shape>, Vec<(Paint, Paint, f64)>) {
|
||||
let mut lv = vec![];
|
||||
for n in &sources {
|
||||
for n in sources {
|
||||
b::leaf_shapes(n, &mut lv);
|
||||
}
|
||||
let mut shapes = vec![];
|
||||
@@ -400,6 +410,13 @@ fn build_children(d: &mut Document, sources: Vec<Arc<Node>>) -> Result<(Vec<Arc<
|
||||
styles.push((a.fill_paint(), a.stroke_paint(), a.stroke().map(|s| s.width).unwrap_or(1.0)));
|
||||
}
|
||||
}
|
||||
(shapes, styles)
|
||||
}
|
||||
|
||||
/// Planar map children (sources group, faces, edges) for `sources`; with `max_gap` (Gap
|
||||
/// Detection), paint stops at the gaps up to that wide.
|
||||
fn build_children(d: &mut Document, sources: Vec<Arc<Node>>, max_gap: Option<f64>) -> Result<(Vec<Arc<Node>>, usize, usize)> {
|
||||
let (shapes, styles) = source_shapes(&sources);
|
||||
if shapes.is_empty() {
|
||||
return Err(EngineError::Other("Live Paint: select paths or compound paths".into()));
|
||||
}
|
||||
@@ -410,7 +427,13 @@ fn build_children(d: &mut Document, sources: Vec<Arc<Node>>) -> Result<(Vec<Arc<
|
||||
src_group.visible = false;
|
||||
children.push(Arc::new(src_group));
|
||||
// Every path is an edge, open ones included; faces are the areas they enclose.
|
||||
let (regs, edges) = po::live_paint(&shapes);
|
||||
let (regs, edges) = match max_gap {
|
||||
Some(gap) => {
|
||||
let (regs, edges, _) = po::live_paint_with_gaps(&shapes, gap);
|
||||
(regs, edges)
|
||||
}
|
||||
None => po::live_paint(&shapes),
|
||||
};
|
||||
if regs.is_empty() && edges.is_empty() {
|
||||
return Err(EngineError::Other("Live Paint: these paths are too degenerate to paint".into()));
|
||||
}
|
||||
@@ -433,6 +456,51 @@ fn build_children(d: &mut Document, sources: Vec<Arc<Node>>) -> Result<(Vec<Arc<
|
||||
Ok((children, nfaces, nedges))
|
||||
}
|
||||
|
||||
/// Give the faces and edges of `g`, a Live Paint group made again from the sources of `old`, the
|
||||
/// look of `old`'s face or edge where they lie: each face takes everything but the outline (its
|
||||
/// appearance, none included, its transparency and the rest) of the old face its inside point is
|
||||
/// in, and each edge that of the old edge its middle lies on. Faces and edges where `old` had none
|
||||
/// keep the paint of their sources.
|
||||
fn repaint_from(g: &mut Node, old: &Node) {
|
||||
/// How far an edge's middle may be from the old edge it lies on.
|
||||
const ON_EDGE: f64 = 1e-3;
|
||||
let Some(ch) = g.children_mut() else { return };
|
||||
for c in ch.iter_mut() {
|
||||
let Some(pd) = c.path_data() else { continue };
|
||||
let was = match c.name.as_deref() {
|
||||
Some(FACE_NAME) => po::interior_point(pd).and_then(|pt| face_at(old, pt)),
|
||||
Some(EDGE_NAME) => pd
|
||||
.subpaths
|
||||
.first()
|
||||
.filter(|sp| sp.segment_count() > 0)
|
||||
.and_then(|sp| edge_near(old, kurbo::ParamCurve::eval(&sp.segment(0), 0.5), ON_EDGE)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(was) = was {
|
||||
let mut n = (**was).clone();
|
||||
// Gradients fitted to the old outline keep that fit on the new one.
|
||||
n.pin_gradients();
|
||||
n.id = c.id;
|
||||
n.kind = c.kind.clone();
|
||||
n.bbox_angle = c.bbox_angle;
|
||||
*c = Arc::new(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `options` as stored on a Live Paint group: none for the defaults.
|
||||
fn stored_gap_options(options: GapOptions) -> Option<Box<GapOptions>> {
|
||||
(options != GapOptions::default()).then(|| Box::new(options))
|
||||
}
|
||||
|
||||
/// The Gap Options of Live Paint groups merged into one, given bottom-most first: the bottom-most
|
||||
/// group's when its Gap Detection is on; else Gap Detection on with Small Gaps when another
|
||||
/// group's is on; else the bottom-most group's.
|
||||
fn merged_gap_options(groups: &[GapOptions]) -> GapOptions {
|
||||
let Some(&bottom) = groups.first() else { return GapOptions::default() };
|
||||
if !bottom.detect && groups.iter().any(|g| g.detect) { GapOptions { detect: true, size: GapSize::Small, ..bottom } } else { bottom }
|
||||
}
|
||||
|
||||
/// Make a Live Paint group from `roots`; returns (group, faces, edges).
|
||||
fn make_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result<(NodeId, usize, usize)> {
|
||||
let mut sources = vec![];
|
||||
@@ -440,7 +508,7 @@ fn make_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result<
|
||||
let n = d.node(*r).ok_or(EngineError::NoNode(*r))?;
|
||||
sources.extend(source_nodes(n));
|
||||
}
|
||||
let (children, nf, ne) = build_children(d, sources)?;
|
||||
let (children, nf, ne) = build_children(d, sources, None)?;
|
||||
let gid = d.alloc_id();
|
||||
let mut g = Node::group(gid, children);
|
||||
g.name = Some(LIVE_PAINT_NAME.into());
|
||||
@@ -484,11 +552,15 @@ fn lp_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
sources.extend(source_nodes(d.node(*r).ok_or(EngineError::NoNode(*r))?));
|
||||
}
|
||||
}
|
||||
let (children, nf, ne) = build_children(d, sources)?;
|
||||
// Merge applies the Gap Options' merge rules to the groups' options, bottom-most first.
|
||||
let groups: Vec<GapOptions> = roots.iter().filter_map(|r| d.node(*r)).filter(|n| is_live_paint(n)).map(Node::gap_options).collect();
|
||||
let options = merged_gap_options(&groups);
|
||||
let (children, nf, ne) = build_children(d, sources, options.max_gap())?;
|
||||
let mut g = Node::group(target, children);
|
||||
g.name = old.name.clone();
|
||||
g.opacity = old.opacity;
|
||||
g.blend = old.blend;
|
||||
g.extras_mut().live_paint_gaps = stored_gap_options(options);
|
||||
for (pt, paint) in keep_faces {
|
||||
if let Some(fid) = face_at(&g, pt).map(|f| f.id)
|
||||
&& let Some(ch) = g.children_mut()
|
||||
@@ -553,7 +625,11 @@ fn lp_expand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for g in &targets {
|
||||
let n = d.node_mut(*g).ok_or(EngineError::NoNode(*g))?;
|
||||
n.name = None;
|
||||
n.extras_mut().trace = None;
|
||||
{
|
||||
let mut e = n.extras_mut();
|
||||
e.trace = None;
|
||||
e.live_paint_gaps = None;
|
||||
}
|
||||
if let Some(ch) = n.children_mut() {
|
||||
ch.retain(|c| match c.name.as_deref() {
|
||||
Some(SOURCES_NAME) => false,
|
||||
@@ -569,6 +645,81 @@ fn lp_expand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "ids": ids_json(&targets) }))
|
||||
}
|
||||
|
||||
/// Gaps as `[x0, y0, x1, y1]` (the loose end, then where it closes to).
|
||||
fn gaps_json(gaps: &[[Point; 2]]) -> Value {
|
||||
json!(gaps.iter().map(|[a, b]| [a.x, a.y, b.x, b.y]).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
/// A group's Gap Options as `livePaint.info` gives them.
|
||||
fn gap_options_json(o: GapOptions) -> Value {
|
||||
let [r, g, b] = o.color;
|
||||
let size = o.size.id().map_or_else(|| json!(o.size.points()), |id| json!(id));
|
||||
json!({ "detect": o.detect, "size": size, "points": o.size.points(), "color": format!("#{r:02x}{g:02x}{b:02x}") })
|
||||
}
|
||||
|
||||
/// The gaps up to `options`' size wide among a Live Paint group's sources.
|
||||
fn open_gaps(group: &Node, options: GapOptions) -> Vec<[Point; 2]> {
|
||||
po::find_gaps(&source_shapes(&source_nodes(group)).0, options.size.points())
|
||||
}
|
||||
|
||||
fn lp_gap_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "livePaint.gapOptions";
|
||||
let detect = match p.get("detect").filter(|v| !v.is_null()) {
|
||||
Some(v) => Some(v.as_bool().ok_or_else(|| bad(C, "detect must be true or false"))?),
|
||||
None => None,
|
||||
};
|
||||
let size =
|
||||
match p.get("size").filter(|v| !v.is_null()) {
|
||||
Some(v) => Some(GapSize::parse(v).ok_or_else(|| {
|
||||
bad(C, format!("size must be \"small\", \"medium\", \"large\" or a number of points over 0 up to {}", GapSize::MAX))
|
||||
})?),
|
||||
None => None,
|
||||
};
|
||||
let color = match p.get("color").filter(|v| !v.is_null()) {
|
||||
Some(v) => Some(super::layer::layer_color_param(v).map_err(|e| bad(C, e))?.rgb()),
|
||||
None => None,
|
||||
};
|
||||
let close = bool_or(p, "closeGaps", false);
|
||||
let targets = live_targets(s, p)?;
|
||||
let (faces, gaps, closed) = s.edit("Gap Options", |d, sel| {
|
||||
let (mut faces, mut gaps, mut closed) = (0, vec![], 0);
|
||||
let mut changed = Beside::replacing(d, &targets);
|
||||
for g in &targets {
|
||||
let old = d.node(*g).cloned().ok_or(EngineError::NoNode(*g))?;
|
||||
let mut options = old.gap_options();
|
||||
options.detect = detect.unwrap_or(options.detect);
|
||||
options.size = size.unwrap_or(options.size);
|
||||
options.color = color.unwrap_or(options.color);
|
||||
let mut sources = source_nodes(&old);
|
||||
if close {
|
||||
// Close Gaps With Paths adds an unpainted line across each gap to the group's paths.
|
||||
for gap in po::find_gaps(&source_shapes(&sources).0, options.size.points()) {
|
||||
let line = PathData::single(SubPath::polyline(&gap, false));
|
||||
sources.push(Arc::new(Node::path(d.alloc_id(), line, Appearance::default())));
|
||||
closed += 1;
|
||||
}
|
||||
}
|
||||
let mut n = old.clone();
|
||||
// The faces change only with what paint stops at, or with new paths.
|
||||
if close || options.max_gap() != old.gap_options().max_gap() {
|
||||
let (children, _, _) = build_children(d, sources, options.max_gap())?;
|
||||
if let Some(ch) = n.children_mut() {
|
||||
*ch = children;
|
||||
}
|
||||
repaint_from(&mut n, &old);
|
||||
}
|
||||
n.extras_mut().live_paint_gaps = stored_gap_options(options);
|
||||
faces += b::faces(&n).len();
|
||||
gaps.extend(open_gaps(&n, options));
|
||||
changed.put(d, *g, vec![n])?;
|
||||
}
|
||||
changed.finish(d)?;
|
||||
sel.set(targets.iter().copied());
|
||||
Ok((faces, gaps, closed))
|
||||
})?;
|
||||
Ok(json!({ "ids": ids_json(&targets), "faces": faces, "gaps": gaps_json(&gaps), "closed": closed }))
|
||||
}
|
||||
|
||||
fn lp_fill(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "livePaint.fill";
|
||||
let pt = point_param(p, "point").filter(|q| finite(*q)).ok_or_else(|| bad(C, "missing point [x,y]"))?;
|
||||
@@ -637,7 +788,14 @@ fn lp_info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.map(|e| json!({ "id": e.id.0, "stroke": e.appearance.stroke_paint().label(), "width": e.appearance.stroke_width() }))
|
||||
.collect();
|
||||
let sources = b::sources(n).and_then(|g| g.children()).map(|c| c.len()).unwrap_or(0);
|
||||
Ok(json!({ "faces": faces, "edges": edges, "sources": sources }))
|
||||
let options = n.gap_options();
|
||||
Ok(json!({
|
||||
"faces": faces,
|
||||
"edges": edges,
|
||||
"sources": sources,
|
||||
"gapOptions": gap_options_json(options),
|
||||
"gaps": gaps_json(&open_gaps(n, options)),
|
||||
}))
|
||||
}
|
||||
|
||||
// ---------- Image Trace ----------
|
||||
|
||||
@@ -80,22 +80,22 @@ pub struct Clipboard {
|
||||
pub para_styles: Vec<TextStyleDef>,
|
||||
/// Brushes the objects' strokes use.
|
||||
pub brushes: Vec<Brush>,
|
||||
/// The artboard the objects were copied from (none when they lie on no artboard): Paste on All
|
||||
/// Artboards keeps their offset to it.
|
||||
/// The artboard the objects were copied from (none when they lie on no artboard; the first of
|
||||
/// [`Self::artboards`] when there are any): Paste on All Artboards keeps their offset to it.
|
||||
pub source_artboard: Option<Rect>,
|
||||
/// Per object: the name of the layer it was copied from (Paste Remembers Layers).
|
||||
pub source_layers: Vec<Option<String>>,
|
||||
/// The open document the objects came from ([`DocState::uid`]): pasting back into it uses its
|
||||
/// own resources as they are now.
|
||||
pub source_doc: Option<u64>,
|
||||
/// An artboard copied with the Artboard tool (`artboard.copy`); `nodes` are then its art. Pasting
|
||||
/// adds a copy of it with the art.
|
||||
pub artboard: Option<vectorcraft_doc::Artboard>,
|
||||
/// The artboards copied with the Artboard tool (`artboard.copy`); `nodes` are then their art.
|
||||
/// Pasting adds a copy of each with the art.
|
||||
pub artboards: Vec<vectorcraft_doc::Artboard>,
|
||||
}
|
||||
|
||||
impl Clipboard {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.nodes.is_empty() && self.artboard.is_none()
|
||||
self.nodes.is_empty() && self.artboards.is_empty()
|
||||
}
|
||||
|
||||
/// Objects `roots` of `st`'s document with their resources, source artboard and layers.
|
||||
@@ -109,7 +109,9 @@ impl Clipboard {
|
||||
// The nearest enclosing layer (or sublayer).
|
||||
source_layers.push(anc.iter().rev().skip(1).find_map(|a| d.node(*a).filter(|l| l.is_layer())).and_then(|l| l.name.clone()));
|
||||
}
|
||||
let bounds = nodes.iter().filter_map(Node::visual_bounds).reduce(|a, b| a.union(b));
|
||||
// Symbol instances are measured by their art.
|
||||
let symbols = d.symbol_boxes();
|
||||
let bounds = nodes.iter().filter_map(|n| n.visual_bounds_with(&symbols)).reduce(|a, b| a.union(b));
|
||||
// Art on no artboard has no source artboard: in-place pastes put it back where it was (#1924).
|
||||
let board = bounds.and_then(|b| d.artboard_at(b.center()));
|
||||
Self {
|
||||
@@ -127,9 +129,13 @@ impl Clipboard {
|
||||
Self::gather(src, nodes)
|
||||
}
|
||||
|
||||
/// Union of the objects' visual bounds.
|
||||
/// Union of the objects' visual bounds, with symbol instances measured by the art of the
|
||||
/// symbols the clipboard holds.
|
||||
pub fn bounds(&self) -> Option<Rect> {
|
||||
self.nodes.iter().filter_map(Node::visual_bounds).reduce(|a, b| a.union(b))
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
d.symbols = self.symbols.clone();
|
||||
let symbols = d.symbol_boxes();
|
||||
self.nodes.iter().filter_map(|n| n.visual_bounds_with(&symbols)).reduce(|a, b| a.union(b))
|
||||
}
|
||||
|
||||
/// The swatch name conflicts pasting into `st` raises, as (pasted, document) swatches: none
|
||||
|
||||
@@ -81,6 +81,7 @@ pub fn file_flavour(name: &str, head: &[u8], mimes: &[&str]) -> Option<&'static
|
||||
// reads SVGZ data.
|
||||
"svgz" if unpacks_to_svg(head) => (SVG, SVG),
|
||||
"pdf" => (PDF, PDF),
|
||||
"ps" => (EPS, EPS),
|
||||
"eps" => (EPS, EPS),
|
||||
"ai" | "ait" if head.starts_with(b"%PDF") => (PDF, PDF),
|
||||
"ai" | "ait" if vectorcraft_pdf::is_postscript(head) => (EPS, EPS),
|
||||
@@ -418,7 +419,8 @@ fn import_emf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn import_eps(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "clipboard.importEps";
|
||||
let bytes = data_param(p, C)?;
|
||||
if !vectorcraft_pdf::is_postscript(&bytes) {
|
||||
// Programs whose header is only `%!` are read too, as they are when a `.ps` or `.eps` file opens.
|
||||
if !bytes.starts_with(b"%!") && !vectorcraft_pdf::is_postscript(&bytes) {
|
||||
return Err(bad(C, "the data isn't an EPS or PostScript picture"));
|
||||
}
|
||||
import_picture(s, p, C, "Clipboard.eps", &bytes)
|
||||
|
||||
@@ -39,10 +39,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Load Color Profile…",
|
||||
[],
|
||||
None,
|
||||
"{path: .icc/.icm file} register an ICC profile until the app quits or the `run` ends (native only; it isn't remembered, so load it again in each run that names it) → {name, kind}",
|
||||
"{path: .icc/.icm file of at most 32 MB} register an ICC profile (native only). The desktop app and vectorcraft-cli keep a copy of the file in the `Color Profiles` folder next to the preferences and register it again at every start (not with VECTORCRAFT_NO_PREFS, or in a confined `mcp --automation-*-root` server: there it lasts until the process ends) → {name, kind, remembered: whether a copy was kept}",
|
||||
always,
|
||||
load_profile
|
||||
),
|
||||
cmd!(
|
||||
"color.removeProfile",
|
||||
"Remove Color Profile…",
|
||||
[],
|
||||
None,
|
||||
"{name} forget a profile color.loadProfile loaded, and the copy kept of it (built-in profiles and the working spaces stay) → {removed: name}",
|
||||
always,
|
||||
remove_profile
|
||||
),
|
||||
cmd!(
|
||||
"edit.assignProfile",
|
||||
"Assign Profile…",
|
||||
@@ -178,12 +187,35 @@ fn color_settings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn load_profile(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn load_profile(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "color.loadProfile";
|
||||
let path = str_param(p, "path").ok_or_else(|| bad(C, "missing `path`"))?;
|
||||
crate::file_access::check_read(path).map_err(EngineError::Other)?;
|
||||
let info = cms::load_icc_file(std::path::Path::new(path)).map_err(|e| bad(C, e.to_string()))?;
|
||||
Ok(json!({"name": info.name, "kind": info.kind}))
|
||||
let bytes = crate::store::read_capped(std::path::Path::new(path), crate::store::MAX_PROFILE_FILE).map_err(|e| bad(C, format!("{path}: {e}")))?;
|
||||
let info = cms::register_icc(&bytes, None).map_err(|e| bad(C, e.to_string()))?;
|
||||
// The bytes are kept, not the path: the file may move or go away.
|
||||
let remembered = s.store_profile(&info.name, &bytes);
|
||||
Ok(json!({"name": info.name, "kind": info.kind, "remembered": remembered}))
|
||||
}
|
||||
|
||||
/// Forget a profile `color.loadProfile` loaded, and the copy that is kept of it.
|
||||
fn remove_profile(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "color.removeProfile";
|
||||
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
|
||||
let info = cms::profiles().into_iter().find(|i| i.name == name).ok_or_else(|| bad(C, format!("no profile named `{name}`")))?;
|
||||
if info.builtin {
|
||||
return Err(bad(C, format!("`{name}` is a built-in profile and stays")));
|
||||
}
|
||||
let working = cms::active_settings();
|
||||
if working.rgb == name || working.cmyk == name {
|
||||
return Err(bad(C, format!("`{name}` is a working space: choose another with edit.colorSettings first")));
|
||||
}
|
||||
if !cms::unregister_icc(&info.name) {
|
||||
return Err(bad(C, format!("no profile named `{name}`")));
|
||||
}
|
||||
s.unstore_profile(&info.name);
|
||||
touch_all(s);
|
||||
Ok(json!({"removed": info.name}))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -74,7 +74,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Create Text",
|
||||
[],
|
||||
None,
|
||||
"{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on",
|
||||
"{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on",
|
||||
has_doc,
|
||||
text_create
|
||||
),
|
||||
@@ -512,6 +512,7 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let made = add_node(s, "Type", NodeKind::Text(Box::new(t)), Appearance::default(), None)?;
|
||||
let id = made.get("id").and_then(Value::as_u64).map(NodeId);
|
||||
let made = super::typecmd::with_bounds(s, id, made);
|
||||
Ok(super::typecmd::with_overflow(s, id.as_slice(), made))
|
||||
}
|
||||
|
||||
|
||||
@@ -746,13 +746,9 @@ fn puppet_warp(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// Forget the Puppet Warp pins: not an undo step, and a saved document stays saved (pins are
|
||||
/// never saved).
|
||||
/// never saved, [`crate::DocState::change_unsaved`]).
|
||||
pub(crate) fn drop_puppet_pins(st: &mut crate::DocState) {
|
||||
let clean = !st.is_dirty();
|
||||
Arc::make_mut(&mut st.doc).puppet = None;
|
||||
if clean {
|
||||
st.mark_saved();
|
||||
}
|
||||
st.change_unsaved(|d| Arc::make_mut(d).puppet = None);
|
||||
}
|
||||
|
||||
fn puppet_pins(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1335,7 +1331,8 @@ fn edit_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// Edit Text ends (isolation mode on `id` exits): the type is projected again, in the document and
|
||||
/// in every undo state (the flat view is never an undo step of its own).
|
||||
/// in every undo state (the flat view is never an undo step of its own, and never saved: a saved
|
||||
/// state stays saved, [`crate::DocState::change_unsaved`]).
|
||||
pub(crate) fn finish_edit_text(st: &mut crate::DocState, id: NodeId) {
|
||||
let clear = |doc: &mut Arc<Document>| {
|
||||
// Undo steps keep no lookup cache: look without building one, and drop the one an edit
|
||||
@@ -1346,14 +1343,14 @@ pub(crate) fn finish_edit_text(st: &mut crate::DocState, id: NodeId) {
|
||||
p.editing = false;
|
||||
}
|
||||
};
|
||||
let saved = Arc::ptr_eq(&st.doc, &st.saved_doc);
|
||||
clear(&mut st.doc);
|
||||
if saved {
|
||||
st.saved_doc = st.doc.clone();
|
||||
}
|
||||
st.change_unsaved(clear);
|
||||
for e in st.history.undo.iter_mut().chain(st.history.redo.iter_mut()) {
|
||||
let saved = Arc::ptr_eq(&e.doc, &st.saved_doc);
|
||||
clear(&mut e.doc);
|
||||
e.doc.drop_lookup_cache();
|
||||
if saved {
|
||||
st.saved_doc = e.doc.clone();
|
||||
}
|
||||
}
|
||||
if let Some(it) = &mut st.interaction {
|
||||
clear(&mut it.doc);
|
||||
|
||||
@@ -100,6 +100,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection,
|
||||
save_selection
|
||||
),
|
||||
cmd!(
|
||||
"select.update",
|
||||
"Update Selection",
|
||||
["Select"],
|
||||
None,
|
||||
"{} make the saved selection last recalled (`select.recall`) hold the selected objects instead of its own, in one undo step → {name}",
|
||||
has_recalled_selection,
|
||||
update_selection
|
||||
),
|
||||
cmd!(
|
||||
"select.recall",
|
||||
"Recall Selection",
|
||||
@@ -135,7 +144,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Release Guides",
|
||||
["View", "Guides"],
|
||||
Some("Cmd+Alt+5"),
|
||||
"{} turn the selected guides (or, with none selected, all guide paths) back into paths → {count}",
|
||||
"{index?: a ruler guide's index, ids?: [guide path id…]} turn guides into paths: index releases that ruler guide and ids release those guide paths (and the guide paths inside them); otherwise the selected guide paths and ruler guides are released, and with no guide selected every guide path is released; a ruler guide becomes a straight path with no fill or stroke, across its artboard or, for a canvas guide, across the bounds of all the artboards; index and ids need guides unlocked; one undo step, the released paths selected → {count}",
|
||||
has_doc,
|
||||
release_guides
|
||||
),
|
||||
@@ -144,7 +153,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Lock Guides",
|
||||
["View", "Guides"],
|
||||
Some("Cmd+Alt+;"),
|
||||
"{locked?: bool} toggle (or set) the session's guide lock → {locked}",
|
||||
"{locked?: bool} toggle (or set) the session's guide lock; locking deselects the ruler guides and guide paths in every open document → {locked}",
|
||||
has_doc,
|
||||
lock_guides
|
||||
),
|
||||
@@ -342,6 +351,7 @@ fn save_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if existing.is_none() && saved.len() >= SavedSelection::MAX {
|
||||
return Err(bad(C, format!("a document keeps at most {} saved selections", SavedSelection::MAX)));
|
||||
}
|
||||
let mut slots = saved_ids(s)?;
|
||||
s.edit("Save Selection", |d, _| {
|
||||
let entry = SavedSelection { name: name.clone(), objects: ids };
|
||||
match existing.and_then(|i| d.saved_selections.get_mut(i)) {
|
||||
@@ -350,18 +360,26 @@ fn save_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
let st = s.doc_mut()?;
|
||||
if existing.is_none() {
|
||||
slots.push(st.recalled_selection.new_id());
|
||||
}
|
||||
st.recalled_selection.remember(&st.doc.saved_selections, slots);
|
||||
Ok(json!({ "name": name }))
|
||||
}
|
||||
|
||||
fn recall_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "select.recall";
|
||||
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing name"))?;
|
||||
let ids = s.doc()?.doc.saved_selections.iter().find(|x| x.name == name).map(|x| x.objects.clone());
|
||||
let ids = ids.ok_or_else(|| bad(C, format!("no saved selection `{name}`")))?;
|
||||
let saved = &s.doc()?.doc.saved_selections;
|
||||
let i = saved.iter().position(|x| x.name == name).ok_or_else(|| bad(C, format!("no saved selection `{name}`")))?;
|
||||
let ids = saved.get(i).map(|x| x.objects.clone()).unwrap_or_default();
|
||||
let st = s.doc()?;
|
||||
let can = selectable_ids(&st.doc, st.isolation);
|
||||
s.select(|_, sel| sel.set(ids.into_iter().filter(|id| can.contains(id))))?;
|
||||
Ok(json!({ "count": s.doc()?.selection.len() }))
|
||||
let st = s.doc_mut()?;
|
||||
st.recalled_selection.recall(&st.doc.saved_selections, i);
|
||||
Ok(json!({ "count": st.selection.len() }))
|
||||
}
|
||||
|
||||
/// Recall and Edit Selection… need a document with at least one saved selection.
|
||||
@@ -370,6 +388,42 @@ fn has_saved_selection(s: &Session) -> std::result::Result<(), String> {
|
||||
if st.doc.saved_selections.is_empty() { Err("no saved selections".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// The session ids of the active document's saved selections ([`crate::RecalledSelection`]).
|
||||
fn saved_ids(s: &mut Session) -> Result<Vec<u64>> {
|
||||
let st = s.doc_mut()?;
|
||||
Ok(st.recalled_selection.ids(&st.doc.saved_selections))
|
||||
}
|
||||
|
||||
/// Where the saved selection last recalled is, while the document has it.
|
||||
fn recalled_index(st: &crate::DocState) -> Option<usize> {
|
||||
st.recalled_selection.index_in(&st.doc.saved_selections)
|
||||
}
|
||||
|
||||
/// Update Selection needs selected objects and a recalled saved selection the document still has.
|
||||
fn has_recalled_selection(s: &Session) -> std::result::Result<(), String> {
|
||||
has_selection(s)?;
|
||||
let st = s.active().ok_or("no document open")?;
|
||||
if recalled_index(st).is_some() { Ok(()) } else { Err("no saved selection recalled".into()) }
|
||||
}
|
||||
|
||||
fn update_selection(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
const C: &str = "select.update";
|
||||
let st = s.doc()?;
|
||||
let i = recalled_index(st).ok_or_else(|| bad(C, "no saved selection recalled"))?;
|
||||
let ids = st.selection.objects.clone();
|
||||
let name = st.doc.saved_selections.get(i).map(|x| x.name.clone()).unwrap_or_default();
|
||||
let slots = saved_ids(s)?;
|
||||
s.edit("Update Selection", |d, _| {
|
||||
if let Some(x) = d.saved_selections.get_mut(i) {
|
||||
x.objects = ids;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
let st = s.doc_mut()?;
|
||||
st.recalled_selection.remember(&st.doc.saved_selections, slots);
|
||||
Ok(json!({ "name": name }))
|
||||
}
|
||||
|
||||
fn edit_saved(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "select.editSaved";
|
||||
let edits = match p.get("edits").and_then(Value::as_array) {
|
||||
@@ -396,6 +450,7 @@ fn edit_saved(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
x.name = n;
|
||||
}
|
||||
}
|
||||
let slots: Vec<u64> = saved_ids(s)?.into_iter().zip(&after).filter(|(_, x)| x.is_some()).map(|(id, _)| id).collect();
|
||||
let after: Vec<SavedSelection> = after.into_iter().flatten().collect();
|
||||
let mut names = std::collections::HashSet::with_capacity(after.len());
|
||||
for x in &after {
|
||||
@@ -408,6 +463,8 @@ fn edit_saved(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
d.saved_selections = after.into();
|
||||
Ok(())
|
||||
})?;
|
||||
let st = s.doc_mut()?;
|
||||
st.recalled_selection.remember(&st.doc.saved_selections, slots);
|
||||
}
|
||||
ok()
|
||||
}
|
||||
@@ -465,22 +522,77 @@ fn guide_paths(d: &vectorcraft_doc::Document) -> Vec<NodeId> {
|
||||
v
|
||||
}
|
||||
|
||||
fn release_guides(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
/// Turn guides into paths: ruler guide `index` and the guide paths in `ids`, else the selected
|
||||
/// guide paths and ruler guides, else every guide path, in one undo step.
|
||||
fn release_guides(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "view.guides.release";
|
||||
let (index, ids) = (p.get("index"), p.get("ids"));
|
||||
if (index.is_some() || ids.is_some()) && s.menu.guides_locked {
|
||||
return Err(EngineError::Other("guides are locked".into()));
|
||||
}
|
||||
let (rulers, paths): (Vec<usize>, Vec<NodeId>) = if index.is_some() || ids.is_some() {
|
||||
let rulers = match index {
|
||||
Some(v) => vec![editable_guide_index(s, Some(v), C)?],
|
||||
None => vec![],
|
||||
};
|
||||
let roots = if ids.is_some() { checked_ids_param(s, p, "ids", C)? } else { vec![] };
|
||||
let d = &s.doc()?.doc;
|
||||
let mut paths = vec![];
|
||||
for r in roots {
|
||||
if let Some(n) = d.node(r) {
|
||||
n.walk(&mut |c| {
|
||||
if matches!(c.kind, NodeKind::Path { guide: true, .. }) && d.is_editable(c.id) && !paths.contains(&c.id) {
|
||||
paths.push(c.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
(rulers, paths)
|
||||
} else {
|
||||
let st = s.doc()?;
|
||||
let all = guide_paths(&st.doc);
|
||||
let sel: Vec<NodeId> = all.iter().copied().filter(|id| st.selection.contains(*id)).collect();
|
||||
let rulers: Vec<usize> = if s.menu.guides_locked {
|
||||
vec![]
|
||||
} else {
|
||||
st.selection.guides.iter().copied().filter(|i| st.doc.guides.get(*i).is_some_and(|g| st.doc.guide_editable(g))).collect()
|
||||
};
|
||||
if sel.is_empty() && rulers.is_empty() { (vec![], all.into_iter().filter(|id| st.doc.is_editable(*id)).collect()) } else { (rulers, sel) }
|
||||
};
|
||||
// Find each ruler guide's line and its parent: the guide's layer, else where new art goes.
|
||||
let st = s.doc()?;
|
||||
let all = guide_paths(&st.doc);
|
||||
let sel: Vec<NodeId> = all.iter().copied().filter(|id| st.selection.contains(*id)).collect();
|
||||
let targets: Vec<NodeId> = if sel.is_empty() { all.into_iter().filter(|id| st.doc.is_editable(*id)).collect() } else { sel };
|
||||
if targets.is_empty() {
|
||||
let artboards = st.doc.artboards.iter().map(|a| a.rect).reduce(|a, b| a.union(b));
|
||||
let mut lines = Vec::with_capacity(rulers.len());
|
||||
for &i in &rulers {
|
||||
let Some(g) = st.doc.guides.get(i) else { continue };
|
||||
let span = st.doc.guide_span(g).or_else(|| artboards.map(|r| if g.vertical { (r.y0, r.y1) } else { (r.x0, r.x1) }));
|
||||
let Some((a, b)) = span else { continue };
|
||||
let (a, b) = if g.vertical { (Point::new(g.pos, a), Point::new(g.pos, b)) } else { (Point::new(a, g.pos), Point::new(b, g.pos)) };
|
||||
let parent = match g.layer.filter(|l| st.doc.node(*l).is_some_and(Node::is_layer)) {
|
||||
Some(l) => Some(l),
|
||||
None => st.target_parent()?,
|
||||
};
|
||||
lines.push((i, parent, a, b));
|
||||
}
|
||||
let n = paths.len() + lines.len();
|
||||
if n == 0 {
|
||||
return Err(EngineError::Other("Release Guides: no guides".into()));
|
||||
}
|
||||
let n = targets.len();
|
||||
s.edit("Release Guides", |d, sel| {
|
||||
for id in &targets {
|
||||
for id in &paths {
|
||||
if let Some(NodeKind::Path { guide, .. }) = d.node_mut(*id).map(|n| &mut n.kind) {
|
||||
*guide = false;
|
||||
}
|
||||
}
|
||||
sel.set(targets.iter().copied());
|
||||
let mut made = Vec::with_capacity(lines.len());
|
||||
for &(_, parent, a, b) in &lines {
|
||||
let id = d.alloc_id();
|
||||
let look = vectorcraft_doc::Appearance::basic(vectorcraft_color::Paint::None, vectorcraft_color::Paint::None, 0.0);
|
||||
d.insert(parent, usize::MAX, Node::path(id, vectorcraft_geom::shapes::line(a, b), look))?;
|
||||
made.push(id);
|
||||
}
|
||||
d.retain_guides(sel, |i, _| !lines.iter().any(|l| l.0 == i));
|
||||
sel.set(paths.iter().copied().chain(made));
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "count": n }))
|
||||
@@ -489,12 +601,20 @@ fn release_guides(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
fn lock_guides(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = p.get("locked").and_then(Value::as_bool).unwrap_or(!s.menu.guides_locked);
|
||||
s.menu.guides_locked = v;
|
||||
// Locked guides can't stay selected, ruler guides or guide paths ([`Session::select`] lets
|
||||
// those go).
|
||||
// Locked guides can't stay selected, ruler guides or guide paths, in any open document
|
||||
// ([`Session::select`] lets those go in the active one).
|
||||
let st = s.doc()?;
|
||||
if v && (!st.selection.guides.is_empty() || st.selection.objects.iter().any(|id| crate::is_guide_path(&st.doc, *id))) {
|
||||
s.select(|_, sel| sel.guides.clear())?;
|
||||
}
|
||||
if v {
|
||||
let active = s.active;
|
||||
for (i, st) in s.docs.iter_mut().enumerate() {
|
||||
if Some(i) != active && crate::deselect_guides(&st.doc, &mut st.selection) {
|
||||
st.revision += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({ "locked": v }))
|
||||
}
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ fn apply(s: &mut DocSetup, units: &mut vectorcraft_doc::Unit, k: &str, v: &Value
|
||||
"typographersQuotes" => s.typographers_quotes = flag(k, v)?,
|
||||
"superscript" => s.superscript = script_param(k, v, s.superscript)?,
|
||||
"subscript" => s.subscript = script_param(k, v, s.subscript)?,
|
||||
"smallCapsSize" => s.small_caps_size = percent(k, v, 1.0, 200.0)?,
|
||||
"smallCapsSize" => s.small_caps_size = percent(k, v, 1.0, vectorcraft_doc::setup::MAX_SMALL_CAPS)?,
|
||||
"exportText" => s.export_text = v.as_str().and_then(ExportText::parse).ok_or_else(|| bad(C, "exportText must be editable or appearance"))?,
|
||||
"backgroundContents" => s.background = background_param(v, C)?,
|
||||
// Read-only values `document.setup` reports.
|
||||
@@ -223,8 +223,8 @@ fn script_param(k: &str, v: &Value, mut m: ScriptMetrics) -> Result<ScriptMetric
|
||||
let o = v.as_object().ok_or_else(|| bad(C, format!("{k} must be {{size?, position?}}")))?;
|
||||
for (key, x) in o {
|
||||
match key.as_str() {
|
||||
"size" => m.size = percent(&format!("{k}.size"), x, 1.0, 500.0)?,
|
||||
"position" => m.position = percent(&format!("{k}.position"), x, -500.0, 500.0)?,
|
||||
"size" => m.size = percent(&format!("{k}.size"), x, 1.0, vectorcraft_doc::setup::MAX_SCRIPT)?,
|
||||
"position" => m.position = percent(&format!("{k}.position"), x, -vectorcraft_doc::setup::MAX_SCRIPT, vectorcraft_doc::setup::MAX_SCRIPT)?,
|
||||
_ => return Err(bad(C, format!("unknown {k} key `{key}` (size, position)"))),
|
||||
}
|
||||
}
|
||||
@@ -251,7 +251,7 @@ pub(crate) fn script_params(p: &Value, setup: &DocSetup, cmd: &str) -> Result<(O
|
||||
}
|
||||
|
||||
/// A superscript or subscript in `setup`'s proportions.
|
||||
fn position_in(setup: &DocSetup, p: CharPosition) -> CharPosition {
|
||||
pub(crate) fn position_in(setup: &DocSetup, p: CharPosition) -> CharPosition {
|
||||
match p {
|
||||
CharPosition::Normal => CharPosition::Normal,
|
||||
CharPosition::Superscript(_) => CharPosition::Superscript(setup.superscript),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Commands backing the drawing tools (pencil, curvature, anchor tools, scissors, knife, eraser,
|
||||
//! blob brush, smooth, path eraser, join).
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use kurbo::{CubicBez, ParamCurve, ParamCurveNearest};
|
||||
@@ -12,7 +13,8 @@ use vectorcraft_geom::{Anchor, AnchorKind, BezPath, FillRule, PathData, Point, R
|
||||
use vectorcraft_pathops as po;
|
||||
use vectorcraft_pathops::{BoolOp, Cap, Join, SimplifyOptions};
|
||||
|
||||
use super::create::{add_look, add_node, insert_look};
|
||||
use super::create::{add_look, insert_look};
|
||||
use super::newart::NewArt;
|
||||
use super::pathops::{Beside, remove_each};
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
@@ -60,7 +62,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Remove Anchor Points",
|
||||
["Object", "Path"],
|
||||
None,
|
||||
"{} remove the direct-selected anchors without opening their paths, refitting the curve round each → {removedObjects}",
|
||||
"{} remove the direct-selected anchors without opening their paths, refitting the curve round each; the anchor after each removed one is selected, so running it again goes on along the path → {removedObjects}",
|
||||
has_anchors,
|
||||
remove_anchors
|
||||
),
|
||||
@@ -78,7 +80,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reshape Segment",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath, segment, t, dx, dy} bend a segment so the point at t moves by (dx, dy)",
|
||||
"{id, subpath, segment, t, dx, dy, perpendicular?: bool} bend a segment so the point at t moves by (dx, dy); with perpendicular (Shift), both handles stand at right angles to the line between its anchors at one length, and the point at t lies as far from that line as the moved point does",
|
||||
has_doc,
|
||||
reshape_segment
|
||||
),
|
||||
@@ -141,7 +143,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Blob Brush",
|
||||
[],
|
||||
None,
|
||||
"{points: [[x,y]…], size?: pt (10), merge?: true} filled brush shape, merged with touching same-colour blobs → {id}",
|
||||
"{points: [[x,y]…], size?: pt (10), merge?: true, mergeOnlySelection?: bool (false: merge with any touching same-color blob; true: only with selected ones), keepSelected?: bool (false: the drawn or merged path is not selected, and the rest of the selection stays; true: it is selected)} filled brush shape, merged with touching same-colour blobs → {id}",
|
||||
has_doc,
|
||||
blob
|
||||
),
|
||||
@@ -772,7 +774,16 @@ fn remove_anchors(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let mut removed = 0;
|
||||
let mut emptied = Beside::replacing(d, &jobs.iter().map(|(id, _)| *id).collect::<Vec<_>>());
|
||||
for (id, anchors) in jobs {
|
||||
removed += usize::from(remove_anchors_of(d, sel, &mut emptied, id, anchors)?);
|
||||
let shape: Vec<(usize, bool)> =
|
||||
d.node(id).and_then(Node::path_data).map(|p| p.subpaths.iter().map(|sp| (sp.anchors.len(), sp.closed)).collect()).unwrap_or_default();
|
||||
let next = following_anchors(&shape, &anchors);
|
||||
if remove_anchors_of(d, sel, &mut emptied, id, anchors)? {
|
||||
removed += 1;
|
||||
} else if !next.is_empty() {
|
||||
// The next anchor along is selected, so the next Remove Anchor Points goes on from
|
||||
// there without picking it first (#2241).
|
||||
sel.anchors.insert(id, next);
|
||||
}
|
||||
}
|
||||
emptied.finish(d)?;
|
||||
Ok(removed)
|
||||
@@ -780,6 +791,30 @@ fn remove_anchors(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({ "removedObjects": removed }))
|
||||
}
|
||||
|
||||
/// Where the selection goes once Remove Anchor Points takes `removed` (subpath, anchor) out of a
|
||||
/// path whose subpaths have `shape` (anchor count, closed): in each subpath, the anchor after its
|
||||
/// last removed one, round the end of a closed subpath, or the one before it at an open subpath's
|
||||
/// end. Indexes are those after the removal, without the subpaths left with fewer than two anchors.
|
||||
fn following_anchors(shape: &[(usize, bool)], removed: &[(usize, usize)]) -> BTreeSet<(usize, usize)> {
|
||||
let mut next = BTreeSet::new();
|
||||
let mut kept = 0;
|
||||
for (si, &(n, closed)) in shape.iter().enumerate() {
|
||||
let gone: BTreeSet<usize> = removed.iter().filter(|&&(s, a)| s == si && a < n).map(|&(_, a)| a).collect();
|
||||
if n - gone.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
if let Some(&last) = gone.last() {
|
||||
let wrap = if closed { 0..last } else { 0..0 };
|
||||
let after = (last + 1..n).chain(wrap).find(|a| !gone.contains(a));
|
||||
if let Some(a) = after.or_else(|| (0..last).rev().find(|a| !gone.contains(a))) {
|
||||
next.insert((kept, a - gone.range(..a).count()));
|
||||
}
|
||||
}
|
||||
kept += 1;
|
||||
}
|
||||
next
|
||||
}
|
||||
|
||||
/// Remove `anchors` (subpath, anchor) of path `id` without opening it, refitting the curve round
|
||||
/// each one. A subpath left with one anchor is removed, and a path left with no subpath is removed
|
||||
/// through `emptied`. True when the path went.
|
||||
@@ -854,10 +889,12 @@ fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !(dv.x.is_finite() && dv.y.is_finite()) {
|
||||
return Err(bad(C, "invalid delta"));
|
||||
}
|
||||
let perpendicular = bool_or(p, "perpendicular", false);
|
||||
s.edit("Reshape", |d, _| {
|
||||
let path = path_mut(d, id)?;
|
||||
let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
|
||||
if !sp.bend_segment(seg, t, dv) {
|
||||
let bent = if perpendicular { sp.bend_segment_perpendicular(seg, t, dv) } else { sp.bend_segment(seg, t, dv) };
|
||||
if !bent {
|
||||
return Err(EngineError::Other("no such segment".into()));
|
||||
}
|
||||
Ok(())
|
||||
@@ -1312,11 +1349,14 @@ fn blob(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} else {
|
||||
Paint::solid(Color::BLACK)
|
||||
};
|
||||
let keep_selected = bool_or(p, "keepSelected", false);
|
||||
let only_selection = bool_or(p, "mergeOnlySelection", false);
|
||||
let st = s.doc()?;
|
||||
let mut merge_ids = vec![];
|
||||
if bool_or(p, "merge", true) {
|
||||
if bool_or(p, "merge", true) && !(only_selection && st.selection.is_empty()) {
|
||||
let all = Selection::default();
|
||||
for id in target_paths(&st.doc, &all, poly_bounds(&pts, size)) {
|
||||
let from = if only_selection { &st.selection } else { &all };
|
||||
for id in target_paths(&st.doc, from, poly_bounds(&pts, size)) {
|
||||
let Some(n) = st.doc.node(id) else { continue };
|
||||
if n.appearance.fill_paint() != paint || !n.appearance.stroke_paint().is_none() {
|
||||
continue;
|
||||
@@ -1327,20 +1367,26 @@ fn blob(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let ap = Appearance::basic(paint, Paint::None, 1.0);
|
||||
let ap = NewArt::plain(Appearance::basic(paint, Paint::None, 1.0));
|
||||
if merge_ids.is_empty() {
|
||||
return add_node(s, "Blob Brush", path_kind(region), ap, None);
|
||||
return insert_look(s, "Blob Brush", path_kind(region), ap, None, keep_selected);
|
||||
}
|
||||
let mut inputs: Vec<(&PathData, FillRule)> = vec![(®ion, FillRule::NonZero)];
|
||||
inputs.extend(merge_ids.iter().map(|(_, pd, r)| (pd, *r)));
|
||||
let merged = po::unite_all(&inputs);
|
||||
let Some(((keep, _, _), rest)) = merge_ids.split_last() else { return add_node(s, "Blob Brush", path_kind(region), ap, None) };
|
||||
let Some(((keep, _, _), rest)) = merge_ids.split_last() else {
|
||||
return insert_look(s, "Blob Brush", path_kind(region), ap, None, keep_selected);
|
||||
};
|
||||
let keep = *keep;
|
||||
let others: Vec<NodeId> = rest.iter().map(|m| m.0).collect();
|
||||
s.edit("Blob Brush", |d, sel| {
|
||||
*path_mut(d, keep)? = merged;
|
||||
remove_each(d, &others)?;
|
||||
sel.set([keep]);
|
||||
if keep_selected {
|
||||
sel.set([keep]);
|
||||
} else {
|
||||
sel.remove(keep);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "id": keep.0 }))
|
||||
|
||||
+161
-35
@@ -4,7 +4,7 @@ use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, Unit};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, RulerMode, Unit};
|
||||
use vectorcraft_geom::{Affine, Point, Vec2};
|
||||
|
||||
use super::clipboard::SwatchChoices;
|
||||
@@ -24,7 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
cmd!("file.close", "Close", ["File"], Some("Cmd+W"), "{index?}", has_doc, file_close),
|
||||
cmd!("document.activate", "Activate Document", [], None, "{index}", always, doc_activate),
|
||||
cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?} → layer tree (each node: id, name, kind, visible, locked, bounds, opacity when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount)", has_doc, |s, p| Ok(inspect::document_opts(s, slice_opts(p, "document.inspect")?))),
|
||||
cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?, ids?: [id…] (list just these nodes, of any level, in this order, under `nodes` in place of `layers`; an unknown id is an error; at most 10000), kind?: panel label such as Layer; Group; Path; Type or Image (exact, ignoring case; list every such node under `nodes`, each with `path`, its ancestor ids with the layer first, searching the subtrees of `ids` when given), limit?: at most this many entries of the list, offset?: entries skipped first (default 0; with `ids`, `kind`, `limit` or `offset` the reply adds `total`, the entries before paging)} → layer tree (each node: id, name, kind, visible, locked, bounds, opacity when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount); without the scope options the reply is the whole layer tree", has_doc, doc_inspect),
|
||||
cmd!(query "document.node", "Inspect Object", [], None, "{id, summary?: compact summary, depth?: child levels in the summary (default all), childLimit?: children shown per node (default all; a level that shows fewer reports childCount)} → one object", has_doc, doc_node),
|
||||
cmd!(query "document.find", "Find Objects", [], None, "{name?: substring of the Layers panel name, kind?: panel label such as Group; Path; Type or Image (exact), text?: substring of type content, limit?: max matches (default 100, 0 counts only)} → {matches: [{id, name, kind, path}], total}", has_doc, doc_find),
|
||||
cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model", has_doc, |s, _| Ok(serde_json::to_value(&*s.doc()?.doc).unwrap_or(Value::Null))),
|
||||
@@ -37,13 +37,21 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
set_units
|
||||
),
|
||||
cmd!(query "document.rulerOrigin", "Ruler Origin", [], None, "{} → {x, y} the rulers' zero point, in absolute document points", has_doc, |s, _| Ok(json!(s.doc()?.doc.ruler_origin))),
|
||||
cmd!(
|
||||
query "document.rulerOrigin",
|
||||
"Ruler Origin",
|
||||
[],
|
||||
None,
|
||||
"{artboard?: index=0 (the active artboard, which artboard rulers measure from)} → {x, y} the rulers' zero point, in absolute document points",
|
||||
has_doc,
|
||||
ruler_origin
|
||||
),
|
||||
cmd!(
|
||||
"document.setRulerOrigin",
|
||||
"Ruler Origin",
|
||||
[],
|
||||
None,
|
||||
"{x, y} set the rulers' zero point in absolute document points; art, guides and command coordinates stay in place",
|
||||
"{x, y, artboard?: index=0} set the rulers' zero point in absolute document points: the document's with global rulers, that artboard's with artboard rulers (it moves with the artboard); art, guides and command coordinates stay in place",
|
||||
has_doc,
|
||||
set_ruler_origin
|
||||
),
|
||||
@@ -52,10 +60,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reset Ruler Origin",
|
||||
[],
|
||||
None,
|
||||
"{artboard?: index=0} reset the rulers' zero point to that artboard's top-left corner",
|
||||
"{artboard?: index=0} reset the rulers' zero point to that artboard's top-left corner (with artboard rulers, that artboard's own zero point)",
|
||||
has_doc,
|
||||
reset_ruler_origin
|
||||
),
|
||||
cmd!(
|
||||
"view.globalRulers",
|
||||
"Change to Global Rulers",
|
||||
["View", "Rulers"],
|
||||
Some("Cmd+Alt+R"),
|
||||
"{global?: bool (default: toggle)} global rulers measure the whole document from one zero point; artboard rulers (a new document's) from the active artboard's top-left corner, or the zero point set for it. Saved with the document → {global}",
|
||||
has_doc,
|
||||
global_rulers
|
||||
),
|
||||
cmd!("edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, undo),
|
||||
cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, redo),
|
||||
cmd!(
|
||||
@@ -83,7 +100,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Paste",
|
||||
["Edit"],
|
||||
Some("Cmd+V"),
|
||||
"{center?: [x, y], dx?, dy?, swatchConflict?} paste centred on `center` (the app passes the view centre), else offset by dx/dy (default: the Paste Offset preference). Pasting brings the image blobs, symbols, patterns, global and spot swatches (with the tint swatches of the tints used), gradient swatches, graphic styles, character and paragraph styles and brushes the objects use; one of the same name that differs comes in renamed. swatchConflict, for a swatch whose name the document gives another colour (clipboard.conflicts): \"merge\" (default: the objects take the document's swatch) | \"add\" (the pasted swatch comes in renamed) | {name: \"merge\"|\"add\"}. With Paste Remembers Layers on (layer.pasteRemembersLayers), objects go back into the layers they came from (by name; made when missing). An artboard copied with artboard.copy comes with its art, right of the last artboard (the other paste commands but Paste on All Artboards: where it was), its art back in the layers it came from → {ids, artboard: the new artboard's index or null, added: resources added, merged: conflicts merged, renamed: [{kind, from, to}]}",
|
||||
"{center?: [x, y], dx?, dy?, swatchConflict?} paste centred on `center` (the app passes the view centre), else offset by dx/dy (default: the Paste Offset preference). Pasting brings the image blobs, symbols, patterns, global and spot swatches (with the tint swatches of the tints used), gradient swatches, graphic styles, character and paragraph styles and brushes the objects use; one of the same name that differs comes in renamed. swatchConflict, for a swatch whose name the document gives another colour (clipboard.conflicts): \"merge\" (default: the objects take the document's swatch) | \"add\" (the pasted swatch comes in renamed) | {name: \"merge\"|\"add\"}. With Paste Remembers Layers on (layer.pasteRemembersLayers), objects go back into the layers they came from (by name; made when missing). Artboards copied with artboard.copy come with their art, together right of the last artboard as they lay (the other paste commands but Paste on All Artboards: where they were), their art back in the layers it came from, and the Artboard tool, when chosen, selects them → {ids, artboard: the first new artboard's index or null, artboards: the new artboards' indices, added: resources added, merged: conflicts merged, renamed: [{kind, from, to}]}",
|
||||
has_clipboard,
|
||||
|s, p| paste(s, p, PasteMode::Offset)
|
||||
),
|
||||
@@ -172,6 +189,26 @@ fn doc_node(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if summary { Ok(inspect::node_summary_opts(n, opts, &doc.symbol_boxes())) } else { Ok(serde_json::to_value(n).unwrap_or(Value::Null)) }
|
||||
}
|
||||
|
||||
/// Most node ids `document.inspect {ids}` takes.
|
||||
const MAX_INSPECT_IDS: usize = 10_000;
|
||||
|
||||
fn doc_inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "document.inspect";
|
||||
let opts = slice_opts(p, C)?;
|
||||
let ids = match p.get("ids") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::Array(a)) if a.len() > MAX_INSPECT_IDS => return Err(bad(C, format!("`ids` takes at most {MAX_INSPECT_IDS} ids"))),
|
||||
Some(_) => Some(checked_ids_param(s, p, "ids", C)?),
|
||||
};
|
||||
let kind = match p.get("kind") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(k)) => Some(k.clone()),
|
||||
Some(_) => return Err(bad(C, "`kind` must be a string")),
|
||||
};
|
||||
let scope = inspect::Scope { ids, kind, limit: opt_u64(p, C, "limit")?, offset: opt_u64(p, C, "offset")?.unwrap_or(0) };
|
||||
Ok(inspect::document_scoped(s, opts, &scope))
|
||||
}
|
||||
|
||||
/// `depth` / `childLimit` for a summary read: absent (or null) means no limit.
|
||||
fn slice_opts(p: &Value, cmd: &str) -> Result<inspect::SummaryOpts> {
|
||||
Ok(inspect::SummaryOpts { depth: opt_u64(p, cmd, "depth")?, child_limit: opt_u64(p, cmd, "childLimit")?, compound_member: false })
|
||||
@@ -209,33 +246,91 @@ fn set_units(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
/// The `artboard` param of the ruler origin commands: an artboard index, 0 when not given.
|
||||
fn artboard_param(s: &Session, p: &Value, c: &str) -> Result<usize> {
|
||||
let index = match p.get("artboard") {
|
||||
None => 0,
|
||||
Some(v) => v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad(c, "index out of range"))?,
|
||||
};
|
||||
if index >= s.doc()?.doc.artboards.len() {
|
||||
return Err(bad(c, "index out of range"));
|
||||
}
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
fn ruler_origin(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = &s.doc()?.doc;
|
||||
// A document without artboards measures from its global zero point.
|
||||
let index = if d.artboards.is_empty() && p.get("artboard").is_none() { 0 } else { artboard_param(s, p, "document.rulerOrigin")? };
|
||||
Ok(json!(s.doc()?.doc.ruler_origin_for(index)))
|
||||
}
|
||||
|
||||
fn set_ruler_origin(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "document.setRulerOrigin";
|
||||
let origin = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?);
|
||||
change_ruler_origin(s, origin, "Ruler Origin")
|
||||
let index = if s.doc()?.doc.rulers == RulerMode::Global && p.get("artboard").is_none() { 0 } else { artboard_param(s, p, C)? };
|
||||
change_ruler_origin(s, index, Some(origin), "Ruler Origin")
|
||||
}
|
||||
|
||||
fn reset_ruler_origin(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "document.resetRulerOrigin";
|
||||
let index = match p.get("artboard") {
|
||||
None => 0,
|
||||
Some(v) => v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad(C, "index out of range"))?,
|
||||
};
|
||||
let ab = s.doc()?.doc.artboards.get(index).ok_or_else(|| bad(C, "index out of range"))?;
|
||||
change_ruler_origin(s, Point::new(ab.rect.x0, ab.rect.y0), "Reset Ruler Origin")
|
||||
let index = artboard_param(s, p, "document.resetRulerOrigin")?;
|
||||
change_ruler_origin(s, index, None, "Reset Ruler Origin")
|
||||
}
|
||||
|
||||
fn change_ruler_origin(s: &mut Session, origin: Point, label: &str) -> Result<Value> {
|
||||
if !origin.is_finite() || origin.x.abs() > crate::MAX_COORD || origin.y.abs() > crate::MAX_COORD {
|
||||
/// Move the rulers' zero point to `origin`, or back to artboard `index`'s top-left corner: the
|
||||
/// document's (global rulers) or that artboard's (artboard rulers, kept as an offset from its
|
||||
/// corner so it follows the artboard).
|
||||
fn change_ruler_origin(s: &mut Session, index: usize, origin: Option<Point>, label: &str) -> Result<Value> {
|
||||
let d = &s.doc()?.doc;
|
||||
let global = d.rulers == RulerMode::Global;
|
||||
let corner = d.artboards.get(index).map(|ab| Point::new(ab.rect.x0, ab.rect.y0));
|
||||
let target = origin.or(corner).unwrap_or(Point::ZERO);
|
||||
if !target.is_finite() || target.x.abs() > crate::MAX_COORD || target.y.abs() > crate::MAX_COORD {
|
||||
return Err(EngineError::Other("result would exceed the canvas (coordinates out of range)".into()));
|
||||
}
|
||||
if s.doc()?.doc.ruler_origin != origin {
|
||||
if global {
|
||||
if d.ruler_origin != target {
|
||||
s.edit(label, |d, _| {
|
||||
d.ruler_origin = target;
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
return Ok(json!(target));
|
||||
}
|
||||
let Some((id, corner)) = d.artboards.get(index).map(|ab| ab.id).zip(corner) else {
|
||||
return Err(bad("document.setRulerOrigin", "index out of range"));
|
||||
};
|
||||
let offset = Some(target - corner).filter(|o| *o != Vec2::ZERO);
|
||||
if d.artboard_ruler_origins.get(&id).copied() != offset {
|
||||
s.edit(label, |d, _| {
|
||||
d.ruler_origin = origin;
|
||||
// Origins of artboards that no longer exist go too.
|
||||
let ids: BTreeSet<u32> = d.artboards.iter().map(|ab| ab.id).collect();
|
||||
d.artboard_ruler_origins.retain(|id, _| ids.contains(id));
|
||||
match offset {
|
||||
Some(o) => d.artboard_ruler_origins.insert(id, o),
|
||||
None => d.artboard_ruler_origins.remove(&id),
|
||||
};
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(json!(origin))
|
||||
Ok(json!(target))
|
||||
}
|
||||
|
||||
/// View › Rulers › Change to Global / Artboard Rulers.
|
||||
fn global_rulers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let current = s.doc()?.doc.rulers == RulerMode::Global;
|
||||
let global = match p.get("global") {
|
||||
None => !current,
|
||||
Some(v) => v.as_bool().ok_or_else(|| bad("view.globalRulers", "global must be true or false"))?,
|
||||
};
|
||||
if global != current {
|
||||
let label = if global { "Change to Global Rulers" } else { "Change to Artboard Rulers" };
|
||||
s.edit(label, |d, _| {
|
||||
d.rulers = if global { RulerMode::Global } else { RulerMode::Artboard };
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(json!({ "global": global }))
|
||||
}
|
||||
|
||||
/// A typing session in progress (the Type tool previews the whole session as one interaction).
|
||||
@@ -276,6 +371,7 @@ pub(crate) fn settle_interaction(s: &mut Session) -> Result<()> {
|
||||
|
||||
fn undo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
settle_interaction(s)?;
|
||||
let guides_locked = s.menu.guides_locked;
|
||||
let st = s.doc_mut()?;
|
||||
let e = st.history.undo.pop().ok_or_else(|| EngineError::Other("nothing to undo".into()))?;
|
||||
let label = e.label.clone();
|
||||
@@ -288,12 +384,17 @@ fn undo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
st.history.trim();
|
||||
st.revision += 1;
|
||||
st.selection.prune(&st.doc);
|
||||
// Guides locked since the step was made stay deselected.
|
||||
if guides_locked {
|
||||
crate::deselect_guides(&st.doc, &mut st.selection);
|
||||
}
|
||||
super::maskedit::follow_history(st, editing);
|
||||
Ok(json!({ "undone": label }))
|
||||
}
|
||||
|
||||
fn redo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
settle_interaction(s)?;
|
||||
let guides_locked = s.menu.guides_locked;
|
||||
let st = s.doc_mut()?;
|
||||
let e = st.history.redo.pop().ok_or_else(|| EngineError::Other("nothing to redo".into()))?;
|
||||
let label = e.label.clone();
|
||||
@@ -306,6 +407,9 @@ fn redo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
st.history.trim();
|
||||
st.revision += 1;
|
||||
st.selection.prune(&st.doc);
|
||||
if guides_locked {
|
||||
crate::deselect_guides(&st.doc, &mut st.selection);
|
||||
}
|
||||
super::maskedit::follow_history(st, editing);
|
||||
Ok(json!({ "redone": label }))
|
||||
}
|
||||
@@ -324,9 +428,23 @@ pub(crate) fn selected_roots(s: &Session) -> Result<Vec<NodeId>> {
|
||||
Ok(roots_of(&st.doc, st.selection.in_paint_order(&st.doc)))
|
||||
}
|
||||
|
||||
/// [`selected_roots`] for the commands that move or reshape the art (Move, Transform, Rotate…): a
|
||||
/// path of a compound path selected on its own (Group Selection, or in the isolated compound path)
|
||||
/// is an object of its own, so the hole of a ring moves without the ring (#2441). Paint and the
|
||||
/// rest still go to the compound path, which holds the paint.
|
||||
pub(crate) fn selected_roots_to_transform(s: &Session) -> Result<Vec<NodeId>> {
|
||||
let st = s.doc()?;
|
||||
Ok(roots_within(&st.doc, st.selection.in_paint_order(&st.doc), true))
|
||||
}
|
||||
|
||||
/// The top-level objects among `ids` (in paint order): compound-path members stand for their
|
||||
/// compound, and ids whose ancestor is also listed, and layers, are dropped.
|
||||
pub(crate) fn roots_of(doc: &Document, ids: Vec<NodeId>) -> Vec<NodeId> {
|
||||
roots_within(doc, ids, false)
|
||||
}
|
||||
|
||||
/// [`roots_of`]; with `members`, a compound path's member listed stays itself.
|
||||
fn roots_within(doc: &Document, ids: Vec<NodeId>, members: bool) -> Vec<NodeId> {
|
||||
// Every id's place in the tree, found in one walk (a lookup per id is a walk per id).
|
||||
let paths = doc.index_paths(ids.iter().copied());
|
||||
// A compound-path member acts as its compound (compounds are one object).
|
||||
@@ -336,7 +454,7 @@ pub(crate) fn roots_of(doc: &Document, ids: Vec<NodeId>) -> Vec<NodeId> {
|
||||
let path = paths.get(&id);
|
||||
let parent = path.and_then(|p| p.split_last()).map(|(_, up)| up).filter(|up| !up.is_empty());
|
||||
match parent.and_then(|up| doc.node_at(up).map(|n| (up, n))) {
|
||||
Some((up, n)) if matches!(n.kind, vectorcraft_doc::NodeKind::Compound { .. }) => (n.id, Some(up.to_vec())),
|
||||
Some((up, n)) if matches!(n.kind, vectorcraft_doc::NodeKind::Compound { .. }) && !members => (n.id, Some(up.to_vec())),
|
||||
_ => (id, path.cloned()),
|
||||
}
|
||||
})
|
||||
@@ -482,16 +600,19 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
|
||||
let st = s.doc()?;
|
||||
let same_doc = clip.source_doc == Some(st.uid);
|
||||
let parent = st.target_parent()?;
|
||||
// A copied artboard comes back with its art: right of the last artboard, or where it was
|
||||
// (Paste in Place, in Front, in Back). Paste on All Artboards pastes only the art.
|
||||
let board = clip
|
||||
.artboard
|
||||
.as_ref()
|
||||
// Copied artboards come back with their art: together right of the last artboard, as they lay,
|
||||
// or where they were (Paste in Place, in Front, in Back). Paste on All Artboards pastes only the
|
||||
// art.
|
||||
let boards = clip
|
||||
.artboards
|
||||
.iter()
|
||||
.map(|a| a.rect)
|
||||
.reduce(|a, b| a.union(b))
|
||||
.filter(|_| mode != PasteMode::AllArtboards)
|
||||
.map(|a| (a, if mode == PasteMode::Offset { super::panelcmds::beside_artboards(&st.doc, a.rect) } else { Vec2::ZERO }));
|
||||
.map(|all| (&clip.artboards, if mode == PasteMode::Offset { super::panelcmds::beside_artboards(&st.doc, all) } else { Vec2::ZERO }));
|
||||
// Paste Remembers Layers (not while isolating a group: pastes stay in it); an artboard's art
|
||||
// always goes back into its layers.
|
||||
let remember = (st.doc.paste_remembers_layers || board.is_some()) && parent.is_some_and(|p| st.doc.node(p).is_some_and(Node::is_layer));
|
||||
let remember = (st.doc.paste_remembers_layers || boards.is_some()) && parent.is_some_and(|p| st.doc.node(p).is_some_and(Node::is_layer));
|
||||
// Front/back: relative to the selection (top-most / bottom-most selected object); with
|
||||
// nothing selected, the top / bottom of the current layer.
|
||||
let anchor = match mode {
|
||||
@@ -502,7 +623,7 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let placements: Vec<Affine> = match (mode, board) {
|
||||
let placements: Vec<Affine> = match (mode, boards) {
|
||||
(_, Some((_, dv))) => vec![Affine::translate(dv)],
|
||||
(PasteMode::Offset | PasteMode::Library, None) => vec![match point_param(p, "center") {
|
||||
Some(c) => clip.bounds().map_or(Affine::IDENTITY, |b| Affine::translate(c - b.center())),
|
||||
@@ -515,9 +636,13 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
|
||||
// In place, in front, in back: onto the active artboard when the UI names it.
|
||||
_ => vec![onto_artboard(&st.doc, p, clip)],
|
||||
};
|
||||
let (ids, imported, artboard) = s.edit(mode.label(), |d, sel| {
|
||||
let (ids, imported, artboards) = s.edit(mode.label(), |d, sel| {
|
||||
let imported = clip.import_into(d, choices, same_doc);
|
||||
let artboard = board.map(|(a, dv)| super::panelcmds::push_artboard_copy(d, a, a.rect + dv));
|
||||
let artboards: Vec<usize> = boards
|
||||
.into_iter()
|
||||
.flat_map(|(all, dv)| all.iter().map(move |a| (a, dv)))
|
||||
.map(|(a, dv)| super::panelcmds::push_artboard_copy(d, a, a.rect + dv))
|
||||
.collect();
|
||||
let mut layers: BTreeMap<&str, NodeId> = BTreeMap::new();
|
||||
// Objects pasted into each parent so far (keeps their order in front and back pastes).
|
||||
let mut placed: BTreeMap<Option<NodeId>, usize> = BTreeMap::new();
|
||||
@@ -552,15 +677,16 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
|
||||
// Pasted type arrives without the layout bounds cache: fill it so boxes and alignment are
|
||||
// right away (opening the file later would, but paste must not depend on a reopen).
|
||||
super::typecmd::refresh_bounds_of(d, &new_ids);
|
||||
Ok((new_ids, imported, artboard))
|
||||
Ok((new_ids, imported, artboards))
|
||||
})?;
|
||||
// The Artboard tool takes the pasted artboard.
|
||||
if let Some(i) = artboard.filter(|_| s.tool_id() == "artboard") {
|
||||
s.set_tool_option("active", &json!(i));
|
||||
// The Artboard tool selects the pasted artboards, the first one active.
|
||||
if !artboards.is_empty() && s.tool_id() == "artboard" {
|
||||
s.set_tool_option("selected", &json!(artboards));
|
||||
}
|
||||
Ok(json!({
|
||||
"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(),
|
||||
"artboard": artboard,
|
||||
"artboard": artboards.first(),
|
||||
"artboards": artboards,
|
||||
"added": imported.added,
|
||||
"merged": imported.merged,
|
||||
"renamed": imported.renamed,
|
||||
|
||||
@@ -59,7 +59,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Expand Appearance",
|
||||
["Object"],
|
||||
None,
|
||||
"{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (strokes outlined, brushed strokes their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; a group's or layer's own fills and strokes become objects among its members, which are expanded too. Hidden fills, strokes and effects are dropped. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids}",
|
||||
"{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (strokes outlined, brushed strokes their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; a group's or layer's own fills and strokes become objects among its members, which are expanded too. Hidden fills, strokes and effects are dropped. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
|
||||
can_expand,
|
||||
expand_appearance
|
||||
),
|
||||
@@ -138,6 +138,7 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !params.is_null() && !params.is_object() {
|
||||
return Err(bad(C, "`params` must be an object"));
|
||||
}
|
||||
super::params::check_effect_color(C, id, ¶ms)?;
|
||||
// Crop Marks take their style from the preferences when applied.
|
||||
if id == effects::CROP_MARKS && params.get("style").is_none() {
|
||||
params["style"] = json!(s.crop_mark_style().id());
|
||||
@@ -313,7 +314,11 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.transpose()?;
|
||||
let visible = p.get("visible").and_then(Value::as_bool);
|
||||
let item = item_target(s, p, C)?;
|
||||
if set_params_effects(s, p).iter().any(|e| e == GLASS) {
|
||||
let edited = set_params_effects(s, p);
|
||||
for effect in &edited {
|
||||
super::params::check_effect_color(C, effect, ¶ms)?;
|
||||
}
|
||||
if edited.iter().any(|e| e == GLASS) {
|
||||
load_texture(&mut params, C)?;
|
||||
}
|
||||
for id in appearance_targets(s, p)? {
|
||||
@@ -504,10 +509,11 @@ pub(crate) fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_b
|
||||
expand_pieces(d, &mut m, false, &mut stroke_art);
|
||||
m
|
||||
})
|
||||
} else if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) {
|
||||
} else if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) && !effects::has_gradient_copies(&v) {
|
||||
effects::expand_leaf(d, &v, &mut stroke_art)
|
||||
} else {
|
||||
// Type, images, symbol instances, live objects: through their outlines.
|
||||
// Type, images, symbol instances, live objects (and paths whose Transform effect copies
|
||||
// them with a gradient): through their outlines.
|
||||
let symbol = match &n.kind {
|
||||
NodeKind::SymbolInstance { symbol, .. } => d.symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()),
|
||||
_ => None,
|
||||
@@ -545,6 +551,7 @@ pub(crate) fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_b
|
||||
|
||||
fn expand_appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let roots = appearance_targets(s, p)?;
|
||||
let failed = vectorcraft_render::decode_failures();
|
||||
let ids = s.edit("Expand Appearance", |d, _| {
|
||||
let brushes = vectorcraft_brush::library(d);
|
||||
let mut out = vec![];
|
||||
@@ -556,7 +563,12 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok(out)
|
||||
})?;
|
||||
Ok(json!({ "ids": ids_json(&ids) }))
|
||||
let mut out = json!({ "ids": ids_json(&ids) });
|
||||
// Raster effects become images rendered from the art.
|
||||
if let Some(w) = rasterfx::undecoded_warning(failed) {
|
||||
out["warning"] = json!(w);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -995,4 +1007,27 @@ mod tests {
|
||||
assert_eq!(ch.len(), 1);
|
||||
assert!((ch[0].geometric_bounds().unwrap().width() - 50.0).abs() < 1e-6);
|
||||
}
|
||||
#[test]
|
||||
fn expand_appearance_gives_each_copy_of_a_transform_its_own_gradient() {
|
||||
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
|
||||
let (mut s, id) = session_with_rect();
|
||||
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
|
||||
let ramp = Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind: GradientKind::Linear, stops })));
|
||||
s.edit("gradient", |d, _| {
|
||||
let n = d.node_mut(id).unwrap();
|
||||
n.appearance.set_fill(ramp);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("effect.apply", &json!({"effect":"distort.transform","ids":[id.0],"item":null})).unwrap();
|
||||
s.execute("effect.setParams", &json!({"index":0,"params":{"moveH":150,"copies":2},"ids":[id.0],"item":null})).unwrap();
|
||||
s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap();
|
||||
let mut boxes = vec![];
|
||||
node(&s, id).walk(&mut |n| {
|
||||
if matches!(n.kind, NodeKind::Path { .. }) && matches!(n.appearance.fill_paint(), Paint::Gradient(_)) {
|
||||
boxes.push(n.geometric_bounds().unwrap().width());
|
||||
}
|
||||
});
|
||||
assert_eq!(boxes, vec![100.0; 3], "three squares, each with its own gradient");
|
||||
}
|
||||
}
|
||||
+429
-14
@@ -1,8 +1,9 @@
|
||||
//! Object → Expand: live shapes, type and effects (Object), strokes (Stroke) and gradient fills
|
||||
//! (Fill) into plain art in one undo step; blends and repeats become groups of their steps and instances. Placed documents (Object) become editable copies of the
|
||||
//! Object → Expand: live shapes, type and effects (Object), strokes (Stroke) and gradient and pattern
|
||||
//! fills (Fill) into plain art in one undo step; blends, repeats and Intertwine groups become groups of their steps, of their instances and of what they draw. Placed documents (Object) become editable copies of the
|
||||
//! art they show, their resources joining the document. A linear or radial gradient fill becomes a
|
||||
//! gradient mesh, or solid strips / concentric ellipses, inside a clip group shaped like the object; a freeform
|
||||
//! gradient becomes a mesh shaped like the object. Create Gradient Mesh shares the sampling.
|
||||
//! gradient becomes a mesh shaped like the object. A pattern fill becomes the tiles it paints inside a clip
|
||||
//! group shaped like the object. Create Gradient Mesh shares the sampling.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -12,13 +13,14 @@ use vectorcraft_color::freeform::spread_scale;
|
||||
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, Paint};
|
||||
use vectorcraft_doc::appearance::{AppearanceItem, FillLayer};
|
||||
use vectorcraft_doc::live::{GradientMesh, lerp_color};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind};
|
||||
use vectorcraft_doc::pattern::{PatternDef, place_art};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Scaling};
|
||||
use vectorcraft_geom::{Affine, PathData, Point, Rect, SubPath, Vec2, shapes};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::menucmds::{run_raw, squash};
|
||||
use super::object::as_clipping_path;
|
||||
use super::pathops::{assemble, node_path, replace_leaves, styled_copy, with_transparency};
|
||||
use super::pathops::{assemble, node_path, outline_strokes, replace_leaves, styled_copy, with_transparency};
|
||||
use super::*;
|
||||
|
||||
const C: &str = "object.expand";
|
||||
@@ -37,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Expand…",
|
||||
["Object"],
|
||||
None,
|
||||
"{object?: true (type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art), stroke?: true (strokes → filled outlines), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step → {ids}",
|
||||
"{object?: true (type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step → {ids}",
|
||||
has_selection,
|
||||
expand
|
||||
),
|
||||
@@ -46,11 +48,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Expand Options",
|
||||
[],
|
||||
None,
|
||||
"{} what each Expand option would change in the selection → {object, fill, stroke} (true: something to expand)",
|
||||
"{} what each Expand option would change in the selection → {object, fill, stroke, gradient} (true: something to expand; gradient: a gradient fill that Fill expands, which Expand Gradient To applies to)",
|
||||
has_selection,
|
||||
|s, _| {
|
||||
let e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?);
|
||||
Ok(json!({"object": e.object(), "fill": e.fill, "stroke": e.stroke}))
|
||||
Ok(json!({"object": e.object(), "fill": e.fill, "stroke": e.stroke, "gradient": e.gradient}))
|
||||
}
|
||||
),
|
||||
]
|
||||
@@ -99,6 +101,8 @@ struct Expandable {
|
||||
live: bool,
|
||||
effects: bool,
|
||||
fill: bool,
|
||||
/// Gradient fills (the ones Expand Gradient To applies to), a part of `fill`.
|
||||
gradient: bool,
|
||||
stroke: bool,
|
||||
/// An envelope (Object expands it into its distorted content).
|
||||
envelope: bool,
|
||||
@@ -108,6 +112,8 @@ struct Expandable {
|
||||
placed: bool,
|
||||
/// Repeats (Object expands them into groups of their instances).
|
||||
repeats: bool,
|
||||
/// Intertwine groups (Object expands them into groups of what they draw).
|
||||
intertwines: bool,
|
||||
}
|
||||
|
||||
impl Expandable {
|
||||
@@ -123,18 +129,26 @@ impl Expandable {
|
||||
e.blends |= matches!(c.kind, NodeKind::Blend { .. });
|
||||
e.placed |= matches!(c.kind, NodeKind::PlacedDocument(_));
|
||||
e.repeats |= matches!(c.kind, NodeKind::Repeat(_));
|
||||
e.intertwines |= matches!(c.kind, NodeKind::Intertwine { .. });
|
||||
e.stroke |= shape && !c.appearance.stroke_paint().is_none();
|
||||
e.fill |= match &c.kind {
|
||||
e.gradient |= match &c.kind {
|
||||
NodeKind::Text(t) => t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))),
|
||||
_ => shape && c.appearance.items.iter().any(|i| gradient_fill(i).is_some()),
|
||||
};
|
||||
e.fill |= e.gradient;
|
||||
e.fill |= match &c.kind {
|
||||
NodeKind::Text(t) => {
|
||||
t.runs.iter().any(|r| matches!(&r.style.fill, Paint::Pattern { pattern, .. } if d.pattern(pattern).is_some()))
|
||||
}
|
||||
_ => shape && c.appearance.items.iter().any(|i| pattern_fill(d, i).is_some()),
|
||||
};
|
||||
});
|
||||
}
|
||||
e
|
||||
}
|
||||
|
||||
fn object(&self) -> bool {
|
||||
self.text || self.live || self.effects || self.envelope || self.blends || self.placed || self.repeats
|
||||
self.text || self.live || self.effects || self.envelope || self.blends || self.placed || self.repeats || self.intertwines
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,6 +163,17 @@ fn gradient_fill(i: &AppearanceItem) -> Option<(&FillLayer, &GradientPaint)> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The definition and placement of a visible pattern fill whose pattern `d` defines.
|
||||
fn pattern_fill<'a, 'd>(d: &'d Document, i: &'a AppearanceItem) -> Option<(&'a FillLayer, &'d PatternDef, Affine)> {
|
||||
match i {
|
||||
AppearanceItem::Fill(f) if f.visible => match &f.paint {
|
||||
Paint::Pattern { pattern, xf } => Some((f, d.pattern(pattern)?, *xf)),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn expand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let object = bool_or(p, "object", true);
|
||||
let fill = bool_or(p, "fill", true);
|
||||
@@ -162,6 +187,12 @@ fn expand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let roots = selected_roots(s)?;
|
||||
s.edit("Expand", |d, _| expand_placed_under(d, &roots))?;
|
||||
}
|
||||
// Intertwine groups before what they hold: their objects, and the copies of the parts drawn
|
||||
// over the others, expand below.
|
||||
if object && e.intertwines {
|
||||
let roots = selected_roots(s)?;
|
||||
s.edit("Expand", |d, _| super::intertwine::expand_intertwines(d, &roots).map(|_| ()))?;
|
||||
}
|
||||
// Blends first: what their steps hold (type, effects, strokes, gradients) expands below.
|
||||
if object && e.blends {
|
||||
let roots = selected_roots(s)?;
|
||||
@@ -203,6 +234,7 @@ fn expand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
if fill {
|
||||
let _ = expand_gradient_fills(s, to);
|
||||
let _ = expand_pattern_fills(s);
|
||||
}
|
||||
if s.doc()?.revision == rev0 {
|
||||
return Err(EngineError::Other("Expand: nothing to expand".into()));
|
||||
@@ -241,18 +273,27 @@ fn expand_gradient_fills(s: &mut Session, to: GradientTo) -> Result<()> {
|
||||
}
|
||||
|
||||
/// Path `l` with each gradient fill turned into art, in appearance order: the other items stay
|
||||
/// on plain copies of the path. `None` when it has no gradient fill.
|
||||
/// on plain copies of the path. The path's geometry effects ([`reshaped`]) and each fill's own
|
||||
/// ([`fill_shape`]) shape the outline the art fills first, as on the canvas. `None` when no
|
||||
/// gradient fill can be expanded.
|
||||
fn expand_fills(d: &mut Document, l: &Node, to: GradientTo) -> Option<Node> {
|
||||
if !matches!(l.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) || !l.appearance.items.iter().any(|i| gradient_fill(i).is_some()) {
|
||||
return None;
|
||||
}
|
||||
let reshaped = reshaped(l);
|
||||
let l = reshaped.as_ref().unwrap_or(l);
|
||||
let (path, _) = node_path(l)?;
|
||||
let bounds = path.bounds()?;
|
||||
let mut parts = vec![];
|
||||
let mut rest = vec![];
|
||||
let mut expanded = false;
|
||||
for item in &l.appearance.items {
|
||||
// Items that paint nothing go; a gradient that can't be expanded stays.
|
||||
let Some(art) = gradient_fill(item).and_then(|(f, g)| gradient_art(d, l, &path, bounds, f, g, to)) else {
|
||||
let art = gradient_fill(item).and_then(|(f, g)| {
|
||||
let (fl, path, bounds) = fill_shape(l, f, &path, bounds)?;
|
||||
gradient_art(d, &fl, &path, bounds, f, g, to)
|
||||
});
|
||||
let Some(art) = art else {
|
||||
if item.visible() && !item.paint().is_none() {
|
||||
rest.push(item.clone());
|
||||
}
|
||||
@@ -262,6 +303,10 @@ fn expand_fills(d: &mut Document, l: &Node, to: GradientTo) -> Option<Node> {
|
||||
parts.push(styled_copy(d, l, std::mem::take(&mut rest)));
|
||||
}
|
||||
parts.push(art);
|
||||
expanded = true;
|
||||
}
|
||||
if !expanded {
|
||||
return None;
|
||||
}
|
||||
if !rest.is_empty() {
|
||||
parts.push(styled_copy(d, l, rest));
|
||||
@@ -270,7 +315,7 @@ fn expand_fills(d: &mut Document, l: &Node, to: GradientTo) -> Option<Node> {
|
||||
}
|
||||
|
||||
/// Gradient fill `f` (gradient `g`) of path `l` (outline `path`, box `bounds`) as art, with the
|
||||
/// fill's opacity, blend mode and effects.
|
||||
/// fill's opacity, blend mode and effects other than geometry effects ([`fill_effects`]).
|
||||
fn gradient_art(d: &mut Document, l: &Node, path: &PathData, bounds: Rect, f: &FillLayer, g: &GradientPaint, to: GradientTo) -> Option<Node> {
|
||||
let content = if g.gradient.kind == GradientKind::Freeform {
|
||||
let m = GradientMesh::for_path_with(path, FREEFORM_MESH, FREEFORM_MESH, &*gradient_sampler(g, bounds))?;
|
||||
@@ -299,7 +344,377 @@ fn gradient_art(d: &mut Document, l: &Node, path: &PathData, bounds: Rect, f: &F
|
||||
Node::new(d.alloc_id(), NodeKind::Group { children, clip: true })
|
||||
};
|
||||
let mut art = with_transparency(d, art, f.opacity, f.blend);
|
||||
art.appearance.effects = f.effects.clone();
|
||||
art.appearance.effects = fill_effects(f);
|
||||
Some(art)
|
||||
}
|
||||
|
||||
/// Path `l` as the canvas paints its fills and strokes: its visible geometry effects applied to its
|
||||
/// path (a plain path then), and every geometry effect of its own removed. `None` when it has no
|
||||
/// visible geometry effect.
|
||||
fn reshaped(l: &Node) -> Option<Node> {
|
||||
use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry, is_geometry};
|
||||
if !has_geometry(&l.appearance.effects) {
|
||||
return None;
|
||||
}
|
||||
let (path, _) = node_path(l)?;
|
||||
let g = apply_geometry_with(&l.appearance.effects, &path, path.bounds()?, &GeomContext::of(l));
|
||||
let mut m = with_path(l, g);
|
||||
m.appearance.effects.retain(|e| !is_geometry(&e.id));
|
||||
Some(m)
|
||||
}
|
||||
|
||||
/// Path `l` (outline `path`, box `bounds`) as fill `f` paints it on the canvas, with its outline
|
||||
/// and box: when the fill has visible geometry effects, `l` with the outline they make of `path`
|
||||
/// as a plain path, and that outline's box.
|
||||
fn fill_shape(l: &Node, f: &FillLayer, path: &PathData, bounds: Rect) -> Option<(Node, PathData, Rect)> {
|
||||
use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry};
|
||||
if !has_geometry(&f.effects) {
|
||||
return Some((l.clone(), path.clone(), bounds));
|
||||
}
|
||||
let outline = apply_geometry_with(&f.effects, path, bounds, &GeomContext::of(l));
|
||||
let b = outline.bounds()?;
|
||||
Some((with_path(l, outline.clone()), outline, b))
|
||||
}
|
||||
|
||||
/// Path or compound path `l` with `path` as its path, a plain path.
|
||||
fn with_path(l: &Node, path: PathData) -> Node {
|
||||
let mut m = l.clone();
|
||||
m.kind = match &l.kind {
|
||||
NodeKind::Path { rule, clipping, guide, .. } => NodeKind::Path { path, rule: *rule, live: None, clipping: *clipping, guide: *guide },
|
||||
NodeKind::Compound { rule, .. } => NodeKind::Path { path, rule: *rule, live: None, clipping: false, guide: false },
|
||||
k => k.clone(),
|
||||
};
|
||||
m
|
||||
}
|
||||
|
||||
/// The effects of fill `f` that stay on its expanded art: all but its geometry effects, which
|
||||
/// shape the outline it paints ([`fill_shape`]).
|
||||
fn fill_effects(f: &FillLayer) -> Vec<vectorcraft_doc::Effect> {
|
||||
f.effects.iter().filter(|e| !vectorcraft_render::effects::is_geometry(&e.id)).cloned().collect()
|
||||
}
|
||||
|
||||
/// Replace every pattern fill of the selected paths by the tiles it paints (see [`expand_patterns`]).
|
||||
fn expand_pattern_fills(s: &mut Session) -> Result<()> {
|
||||
let roots = selected_roots(s)?;
|
||||
s.edit("Expand", |d, sel| {
|
||||
let (mut ids, mut changed) = (vec![], 0);
|
||||
for r in &roots {
|
||||
let (id, n) = replace_leaves(d, *r, expand_patterns)?;
|
||||
ids.push(id);
|
||||
changed += n;
|
||||
}
|
||||
if changed == 0 {
|
||||
return Err(EngineError::Other("Expand: nothing to expand".into()));
|
||||
}
|
||||
sel.set(ids);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Path `l` with each pattern fill turned into the tiles it paints over the box of the outline it
|
||||
/// fills, in a clip group shaped like that outline with the fill's opacity, blend mode and effects
|
||||
/// other than geometry effects, in appearance order: the other items stay on plain copies of the
|
||||
/// path. The path's geometry effects ([`reshaped`]) and each fill's own ([`fill_shape`]) shape
|
||||
/// that outline first, as on the canvas. The tiles are drawn as the canvas draws them
|
||||
/// ([`pattern_tiles`]). A pattern fill that lays no tile there stays. `None` when no pattern fill
|
||||
/// lays a tile.
|
||||
fn expand_patterns(d: &mut Document, l: &Node) -> Option<Node> {
|
||||
if !matches!(l.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) || !l.appearance.items.iter().any(|i| pattern_fill(d, i).is_some()) {
|
||||
return None;
|
||||
}
|
||||
let reshaped = reshaped(l);
|
||||
let l = reshaped.as_ref().unwrap_or(l);
|
||||
let (path, _) = node_path(l)?;
|
||||
let bounds = path.bounds()?;
|
||||
let mut parts = vec![];
|
||||
let mut rest = vec![];
|
||||
let mut expanded = false;
|
||||
for item in &l.appearance.items {
|
||||
let fill = pattern_fill(d, item).map(|(f, def, xf)| (f, def.clone(), xf));
|
||||
let tiles = fill.and_then(|(f, def, xf)| {
|
||||
let (fl, _, bounds) = fill_shape(l, f, &path, bounds)?;
|
||||
let t = pattern_tiles(d, &def, xf, bounds);
|
||||
(!t.is_empty()).then_some((f, fl, t))
|
||||
});
|
||||
let Some(art) = tiles.and_then(|(f, fl, t)| tile_art(d, &fl, f, &t)) else {
|
||||
if item.visible() && !item.paint().is_none() {
|
||||
rest.push(item.clone());
|
||||
}
|
||||
continue;
|
||||
};
|
||||
if !rest.is_empty() {
|
||||
parts.push(styled_copy(d, l, std::mem::take(&mut rest)));
|
||||
}
|
||||
parts.push(art);
|
||||
expanded = true;
|
||||
}
|
||||
if !expanded {
|
||||
return None;
|
||||
}
|
||||
if !rest.is_empty() {
|
||||
parts.push(styled_copy(d, l, rest));
|
||||
}
|
||||
assemble(d, l, parts)
|
||||
}
|
||||
|
||||
/// The tiles pattern `def` placed by `xf` lays over `bounds` in document `d`, as the canvas draws
|
||||
/// them: their stroke weights and effects scale with the pattern, and when it also turns, flips,
|
||||
/// stretches or shears them, the strokes (brushed ones included) and geometry effects of their
|
||||
/// paths and opacity masks become filled art and their live shapes become plain paths first. The
|
||||
/// same happens when it scales art with geometry effects (Roughen's detail, for one, is per inch
|
||||
/// and does not scale). No tiles when it turns, flips, stretches or shears art with raster or 3D
|
||||
/// effects or with a geometry effect on a group or type, or scales art with a geometry effect on a
|
||||
/// group or type, which the canvas draws turned or scaled with the tile and live effects would
|
||||
/// not, and when it does more than move art that holds a symbol instance, whose strokes the canvas
|
||||
/// draws scaled with the tile and an instance does not scale. Random Transform effects and brushes
|
||||
/// that vary at random in the art are baked first ([`seeds_baked`], [`random_brushes_baked`]).
|
||||
fn pattern_tiles(d: &mut Document, def: &PatternDef, xf: Affine, bounds: Rect) -> Vec<Node> {
|
||||
if !moves_only(xf) && def.art.iter().any(|a| has_instances(a)) {
|
||||
return vec![];
|
||||
}
|
||||
let Some(def) = seeds_baked(def) else {
|
||||
return vec![];
|
||||
};
|
||||
let brushes = vectorcraft_brush::library(d);
|
||||
let def = random_brushes_baked(d, &brushes, def);
|
||||
let def = &*def;
|
||||
let scaling = Scaling { strokes: true, effects: Some(vectorcraft_render::effects::scale_effect), patterns: true, ..Scaling::default() };
|
||||
if scales_evenly(xf) && (moves_only(xf) || !def.art.iter().any(|a| has_geometry_effects(a))) {
|
||||
return def.instances_in_with(xf, bounds, scaling);
|
||||
}
|
||||
if def.art.iter().any(|a| has_unbakeable_effects(a)) {
|
||||
return vec![];
|
||||
}
|
||||
let placed = Scaling { patterns: true, ..Scaling::default() };
|
||||
let mut tiles = def.instances_in_with(Affine::IDENTITY, xf.inverse().transform_rect_bbox(bounds), placed);
|
||||
for t in &mut tiles {
|
||||
*t = prepared(d, &brushes, t);
|
||||
place_art(t, xf, scaling);
|
||||
}
|
||||
tiles
|
||||
}
|
||||
|
||||
/// Pattern `def` with each path in its art (opacity masks included) that has a random Transform
|
||||
/// effect baked into filled art ([`bake_appearance`](vectorcraft_render::effects::bake_appearance))
|
||||
/// while it keeps its own id: the effect takes its seed from the id, and the canvas draws the art
|
||||
/// with those ids for every tile, while the tiles get ids of their own. `None` when something
|
||||
/// other than a path has such an effect.
|
||||
fn seeds_baked(def: &PatternDef) -> Option<std::borrow::Cow<'_, PatternDef>> {
|
||||
if !def.art.iter().any(|a| has_seeded(a)) {
|
||||
return Some(std::borrow::Cow::Borrowed(def));
|
||||
}
|
||||
let mut def = def.clone();
|
||||
def.art = def.art.iter().map(|a| bake_seeded(a).map(Arc::new)).collect::<Option<_>>()?;
|
||||
Some(std::borrow::Cow::Owned(def))
|
||||
}
|
||||
|
||||
/// Pattern `def` with each path in its art (opacity masks included) whose stroke has a brush that
|
||||
/// varies at random ([`draws_random_art`]) [`prepared`] where the pattern defines it: the brush
|
||||
/// takes its seed from the path's points, and the canvas draws the art there for every tile.
|
||||
fn random_brushes_baked<'a>(
|
||||
d: &mut Document,
|
||||
brushes: &[vectorcraft_brush::Brush],
|
||||
def: std::borrow::Cow<'a, PatternDef>,
|
||||
) -> std::borrow::Cow<'a, PatternDef> {
|
||||
if !def.art.iter().any(|a| has_random_brush(brushes, a)) {
|
||||
return def;
|
||||
}
|
||||
let mut def = def.into_owned();
|
||||
def.art = def.art.iter().map(|a| Arc::new(bake_random_brushes(d, brushes, a))).collect();
|
||||
std::borrow::Cow::Owned(def)
|
||||
}
|
||||
|
||||
/// Does brush `name` of `brushes` draw different art for different points: a calligraphic or
|
||||
/// scatter brush with Random variation, or a bristle brush?
|
||||
fn draws_random_art(brushes: &[vectorcraft_brush::Brush], name: &str) -> bool {
|
||||
use vectorcraft_brush::{BrushKind, Variation};
|
||||
brushes.iter().find(|b| b.name == name).is_some_and(|b| match &b.kind {
|
||||
BrushKind::Calligraphic(c) => c.modes().contains(&Variation::Random),
|
||||
BrushKind::Scatter(s) => s.modes().contains(&Variation::Random),
|
||||
BrushKind::Bristle(_) => true,
|
||||
BrushKind::Art(_) | BrushKind::Pattern(_) => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Is `n` a path with a visible stroke whose brush varies at random ([`draws_random_art`])?
|
||||
fn random_brushed(brushes: &[vectorcraft_brush::Brush], n: &Node) -> bool {
|
||||
let random = |st: &vectorcraft_doc::appearance::StrokeLayer| st.visible && st.brush.as_deref().is_some_and(|b| draws_random_art(brushes, b));
|
||||
matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. })
|
||||
&& n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if random(st)))
|
||||
}
|
||||
|
||||
/// Does `n`, something in it or one of their opacity masks have a stroke whose brush varies at
|
||||
/// random ([`random_brushed`])?
|
||||
fn has_random_brush(brushes: &[vectorcraft_brush::Brush], n: &Node) -> bool {
|
||||
random_brushed(brushes, n)
|
||||
|| n.extras().mask.as_ref().is_some_and(|m| has_random_brush(brushes, &m.art))
|
||||
|| (!matches!(n.kind, NodeKind::Compound { .. }) && n.children().is_some_and(|c| c.iter().any(|c| has_random_brush(brushes, c))))
|
||||
}
|
||||
|
||||
/// `n` with the paths in it whose brush varies at random [`prepared`] ([`random_brushes_baked`]).
|
||||
fn bake_random_brushes(d: &mut Document, brushes: &[vectorcraft_brush::Brush], n: &Node) -> Node {
|
||||
if !has_random_brush(brushes, n) {
|
||||
return n.clone();
|
||||
}
|
||||
if random_brushed(brushes, n) {
|
||||
return prepared(d, brushes, n);
|
||||
}
|
||||
let mut n = n.clone();
|
||||
if !matches!(n.kind, NodeKind::Compound { .. }) {
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
*c = Arc::new(bake_random_brushes(d, brushes, c));
|
||||
}
|
||||
}
|
||||
if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) {
|
||||
m.art = Arc::new(bake_random_brushes(d, brushes, &m.art));
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// Does `n`, something in it or one of their opacity masks have a visible geometry effect?
|
||||
fn has_geometry_effects(n: &Node) -> bool {
|
||||
let geometry = |e: &vectorcraft_doc::Effect| e.visible && vectorcraft_render::effects::is_geometry(&e.id);
|
||||
n.appearance.effects.iter().any(geometry)
|
||||
|| n.appearance.items.iter().any(|i| i.effects().iter().any(geometry))
|
||||
|| n.extras().mask.as_ref().is_some_and(|m| has_geometry_effects(&m.art))
|
||||
|| n.children().is_some_and(|c| c.iter().any(|c| has_geometry_effects(c)))
|
||||
}
|
||||
|
||||
/// Does `n`, something in it or one of their opacity masks hold a symbol instance?
|
||||
fn has_instances(n: &Node) -> bool {
|
||||
matches!(n.kind, NodeKind::SymbolInstance { .. })
|
||||
|| n.extras().mask.as_ref().is_some_and(|m| has_instances(&m.art))
|
||||
|| n.children().is_some_and(|c| c.iter().any(|c| has_instances(c)))
|
||||
}
|
||||
|
||||
/// Does `a` only move (no scale, turn, flip, stretch or shear)?
|
||||
fn moves_only(a: Affine) -> bool {
|
||||
let [m0, m1, m2, m3, _, _] = a.as_coeffs();
|
||||
let eps = 1e-9;
|
||||
(m0 - 1.0).abs() <= eps && m1.abs() <= eps && m2.abs() <= eps && (m3 - 1.0).abs() <= eps
|
||||
}
|
||||
|
||||
/// Does `n` have a visible Transform effect with Random on, its own or a fill's or stroke's?
|
||||
fn seeded_here(n: &Node) -> bool {
|
||||
let seeded =
|
||||
|e: &vectorcraft_doc::Effect| e.visible && e.id == "distort.transform" && e.params.get("random").and_then(Value::as_bool) == Some(true);
|
||||
n.appearance.effects.iter().any(seeded) || n.appearance.items.iter().any(|i| i.effects().iter().any(seeded))
|
||||
}
|
||||
|
||||
/// Does `n`, something in it or one of their opacity masks have a random Transform effect
|
||||
/// ([`seeded_here`])?
|
||||
fn has_seeded(n: &Node) -> bool {
|
||||
seeded_here(n)
|
||||
|| n.extras().mask.as_ref().is_some_and(|m| has_seeded(&m.art))
|
||||
|| (!matches!(n.kind, NodeKind::Compound { .. }) && n.children().is_some_and(|c| c.iter().any(|c| has_seeded(c))))
|
||||
}
|
||||
|
||||
/// `n` with the paths in it that have a random Transform effect baked ([`seeds_baked`]). `None`
|
||||
/// when something else has one.
|
||||
fn bake_seeded(n: &Node) -> Option<Node> {
|
||||
if !has_seeded(n) {
|
||||
return Some(n.clone());
|
||||
}
|
||||
let mut n = if seeded_here(n) {
|
||||
if !matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) {
|
||||
return None;
|
||||
}
|
||||
vectorcraft_render::effects::bake_appearance(n)?
|
||||
} else {
|
||||
n.clone()
|
||||
};
|
||||
if !matches!(n.kind, NodeKind::Compound { .. }) {
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
*c = Arc::new(bake_seeded(c)?);
|
||||
}
|
||||
}
|
||||
if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) {
|
||||
m.art = Arc::new(bake_seeded(&m.art)?);
|
||||
}
|
||||
Some(n)
|
||||
}
|
||||
|
||||
/// Does `n`, something in it or one of their opacity masks have a visible effect that [`prepared`]
|
||||
/// cannot bake: a raster or 3D effect, or a geometry effect on something other than a path?
|
||||
fn has_unbakeable_effects(n: &Node) -> bool {
|
||||
use vectorcraft_render::effects::{is_geometry, is_raster, is_three_d};
|
||||
let path = matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. });
|
||||
let unbakeable = |e: &vectorcraft_doc::Effect| e.visible && (is_raster(&e.id) || is_three_d(&e.id) || (!path && is_geometry(&e.id)));
|
||||
n.appearance.effects.iter().any(unbakeable)
|
||||
|| n.appearance.items.iter().any(|i| i.effects().iter().any(unbakeable))
|
||||
|| n.extras().mask.as_ref().is_some_and(|m| has_unbakeable_effects(&m.art))
|
||||
|| n.children().is_some_and(|c| c.iter().any(|c| has_unbakeable_effects(c)))
|
||||
}
|
||||
|
||||
/// Tile art `n` ready for a turn, flip, stretch or shear: the strokes and geometry effects of its
|
||||
/// paths baked ([`baked`], [`outline_brushed`]) and its live shapes turned into plain paths (a
|
||||
/// stretch would rebuild their corners round), in its opacity masks too.
|
||||
fn prepared(d: &mut Document, brushes: &[vectorcraft_brush::Brush], n: &Node) -> Node {
|
||||
let mut t = baked(n);
|
||||
outline_brushed(d, brushes, &mut t);
|
||||
drop_live(&mut t);
|
||||
prepare_masks(d, brushes, &mut t);
|
||||
t
|
||||
}
|
||||
|
||||
/// The opacity masks of `n` and of everything in it [`prepared`].
|
||||
fn prepare_masks(d: &mut Document, brushes: &[vectorcraft_brush::Brush], n: &mut Node) {
|
||||
if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) {
|
||||
m.art = Arc::new(prepared(d, brushes, &m.art));
|
||||
}
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
prepare_masks(d, brushes, Arc::make_mut(c));
|
||||
}
|
||||
}
|
||||
|
||||
/// `n` with each path in it that has a brushed stroke turned into filled art and brush art
|
||||
/// ([`outline_strokes`]).
|
||||
fn outline_brushed(d: &mut Document, brushes: &[vectorcraft_brush::Brush], n: &mut Node) {
|
||||
if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) {
|
||||
let brushed = n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if st.brush.is_some()));
|
||||
if brushed && let Some(o) = outline_strokes(d, brushes, n) {
|
||||
*n = o;
|
||||
}
|
||||
return;
|
||||
}
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
outline_brushed(d, brushes, Arc::make_mut(c));
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `a` a move and a positive uniform scale (which brush nibs, strokes and effects follow by
|
||||
/// scaling alone)?
|
||||
fn scales_evenly(a: Affine) -> bool {
|
||||
let [m0, m1, m2, m3, _, _] = a.as_coeffs();
|
||||
let eps = 1e-9 * m0.abs().max(m3.abs());
|
||||
m0 > 0.0 && m1.abs() <= eps && m2.abs() <= eps && (m0 - m3).abs() <= eps
|
||||
}
|
||||
|
||||
/// `n` with the strokes and geometry effects of each path in it baked into filled art
|
||||
/// ([`bake_appearance`](vectorcraft_render::effects::bake_appearance)).
|
||||
fn baked(n: &Node) -> Node {
|
||||
if let Some(b) = vectorcraft_render::effects::bake_appearance(n) {
|
||||
return b;
|
||||
}
|
||||
let mut n = n.clone();
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
*c = Arc::new(baked(c));
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// Pattern tiles `tiles` of fill `f` of path `l` as a clip group shaped like `l` that isolates
|
||||
/// their blending (as the canvas draws a tile on its own), with the fill's opacity, blend mode and
|
||||
/// effects other than geometry effects ([`fill_effects`]).
|
||||
fn tile_art(d: &mut Document, l: &Node, f: &FillLayer, tiles: &[Node]) -> Option<Node> {
|
||||
let mut clip = styled_copy(d, l, vec![]);
|
||||
as_clipping_path(&mut clip).ok()?;
|
||||
let tiles: Vec<Node> = tiles.iter().map(|t| d.reid(t)).collect();
|
||||
let children = std::iter::once(clip).chain(tiles).map(Arc::new).collect();
|
||||
let mut art = Node::new(d.alloc_id(), NodeKind::Group { children, clip: true });
|
||||
art.isolate = true;
|
||||
let mut art = with_transparency(d, art, f.opacity, f.blend);
|
||||
art.appearance.effects = fill_effects(f);
|
||||
Some(art)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
//! Save and Close, or Folder; Override Open / Save / Export. Cited:
|
||||
//! <https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/batch-options.html>
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::Value;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use serde_json::json;
|
||||
|
||||
use super::super::*;
|
||||
|
||||
@@ -125,6 +127,9 @@ fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run:
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn batch_datasets(s: &mut Session, cmd: &str, run: &Run<'_>) -> Result<Value> {
|
||||
use super::distinct_names;
|
||||
use super::screens::file_part;
|
||||
|
||||
let st = s.doc()?;
|
||||
let names: Vec<String> = st.doc.variables.datasets.iter().map(|d| d.name.clone()).collect();
|
||||
if names.is_empty() {
|
||||
@@ -134,12 +139,16 @@ fn batch_datasets(s: &mut Session, cmd: &str, run: &Run<'_>) -> Result<Value> {
|
||||
return Err(bad(cmd, format!("more than {MAX_BATCH_FILES} data sets")));
|
||||
}
|
||||
let doc_stem = st.path.as_deref().and_then(|p| std::path::Path::new(p).file_stem().and_then(|s| s.to_str())).unwrap_or("Untitled").to_string();
|
||||
// Data set files are named File + Data Set Name (helpx, for Override Save/Export; the folder
|
||||
// destination uses the same name). `file_part` writes `/`, `\` and `:` in the data set's name as
|
||||
// `-`, and `distinct_names` adds `-2`, `-3`… to a name an earlier data set used.
|
||||
let stems = distinct_names(names.iter().map(|name| format!("{doc_stem}_{}", file_part(name))));
|
||||
let steps = filter_steps(run.steps, true, false);
|
||||
let run = Run { steps: &steps, ..*run };
|
||||
let mut processed = Vec::new();
|
||||
let mut failed = 0usize;
|
||||
for name in &names {
|
||||
match process_one_dataset(s, &run, name, &doc_stem) {
|
||||
for (name, stem) in names.iter().zip(&stems) {
|
||||
match process_one_dataset(s, &run, name, stem) {
|
||||
Ok(row) => processed.push(row),
|
||||
Err(e) => {
|
||||
failed += 1;
|
||||
@@ -182,14 +191,12 @@ fn process_one_file(s: &mut Session, run: &Run<'_>, path: &str, stem: &str, ext:
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn process_one_dataset(s: &mut Session, run: &Run<'_>, name: &str, doc_stem: &str) -> Result<Value> {
|
||||
fn process_one_dataset(s: &mut Session, run: &Run<'_>, name: &str, stem: &str) -> Result<Value> {
|
||||
s.execute("dataset.select", &json!({"name": name}))?;
|
||||
// File + Data Set Name naming when overriding Save/Export (helpx).
|
||||
let stem = format!("{doc_stem}_{name}");
|
||||
let rewritten = rewrite_paths(run.steps, run.override_save, run.override_export, run.dest_folder, &stem, "vectorcraft");
|
||||
let rewritten = rewrite_paths(run.steps, run.override_save, run.override_export, run.dest_folder, stem, "vectorcraft");
|
||||
let batch = s.execute("command.batch", &json!({"label": run.label, "commands": rewritten}))?;
|
||||
// Data sets share one open document: write each row out but leave it open for the next.
|
||||
finish_destination(s, run, &stem, "vectorcraft", "", false)?;
|
||||
finish_destination(s, run, stem, "vectorcraft", "", false)?;
|
||||
Ok(json!({"dataset": name, "result": batch}))
|
||||
}
|
||||
|
||||
@@ -432,6 +439,102 @@ mod tests {
|
||||
assert_eq!(written.len(), 2, "{written:?}");
|
||||
}
|
||||
|
||||
/// The names of the files in `dir`, sorted.
|
||||
fn file_names(dir: &std::path::Path) -> Vec<String> {
|
||||
let mut names: Vec<String> =
|
||||
std::fs::read_dir(dir).unwrap().filter_map(|e| e.ok()).map(|e| e.file_name().to_string_lossy().into_owned()).collect();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
|
||||
/// A new document with an empty data set for each of `names`.
|
||||
fn with_data_sets(names: &[&str]) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 80})).unwrap();
|
||||
for name in names {
|
||||
s.execute("dataset.new", &json!({"name": name})).unwrap();
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_keeps_data_set_files_inside_the_destination_folder() {
|
||||
let root = tmp("set-names-root");
|
||||
let dest = root.join("out");
|
||||
// The folder that `a/../../x` would pass through on its way out of `out`.
|
||||
std::fs::create_dir_all(dest.join("Untitled_a")).unwrap();
|
||||
let mut s = with_data_sets(&["a/../../x", "../y"]);
|
||||
let r = s
|
||||
.execute(
|
||||
"action.batch",
|
||||
&json!({
|
||||
"source": "datasets",
|
||||
"destination": "folder",
|
||||
"destinationFolder": dest.to_string_lossy(),
|
||||
"stopOnError": false,
|
||||
"commands": [{"command": "object.unlockAll", "params": {}}]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["failed"], 0, "{r}");
|
||||
assert_eq!(file_names(&dest), ["Untitled_..-y.vectorcraft", "Untitled_a", "Untitled_a-..-..-x.vectorcraft"], "{r}");
|
||||
assert_eq!(file_names(&root), ["out"], "nothing is written outside the destination folder");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_saves_data_sets_named_with_separators() {
|
||||
let dest = tmp("set-names-sep");
|
||||
let exports = tmp("set-names-sep-export");
|
||||
let mut s = with_data_sets(&["a/b", "c\\d", "e:f"]);
|
||||
let r = s
|
||||
.execute(
|
||||
"action.batch",
|
||||
&json!({
|
||||
"source": "datasets",
|
||||
"destination": "folder",
|
||||
"destinationFolder": dest.to_string_lossy(),
|
||||
"stopOnError": false,
|
||||
"commands": [{"command": "object.unlockAll", "params": {}}]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["failed"], 0, "{r}");
|
||||
assert_eq!(file_names(&dest), ["Untitled_a-b.vectorcraft", "Untitled_c-d.vectorcraft", "Untitled_e-f.vectorcraft"], "{r}");
|
||||
// Override Export points each data set's export at the destination folder under the same name.
|
||||
let r = s
|
||||
.execute(
|
||||
"action.batch",
|
||||
&json!({
|
||||
"source": "datasets",
|
||||
"destinationFolder": exports.to_string_lossy(),
|
||||
"stopOnError": false,
|
||||
"commands": [{"command": "document.export", "params": {"format": "svg"}}]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["failed"], 0, "{r}");
|
||||
assert_eq!(file_names(&exports), ["Untitled_a-b.svg", "Untitled_c-d.svg", "Untitled_e-f.svg"], "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_gives_each_data_set_a_file_of_its_own() {
|
||||
let dest = tmp("set-names-same");
|
||||
let mut s = with_data_sets(&["a:b", "a-b", "A-B"]);
|
||||
let r = s
|
||||
.execute(
|
||||
"action.batch",
|
||||
&json!({
|
||||
"source": "datasets",
|
||||
"destination": "folder",
|
||||
"destinationFolder": dest.to_string_lossy(),
|
||||
"commands": [{"command": "object.unlockAll", "params": {}}]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["failed"], 0, "{r}");
|
||||
assert_eq!(file_names(&dest), ["Untitled_A-B-3.vectorcraft", "Untitled_a-b-2.vectorcraft", "Untitled_a-b.vectorcraft"], "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_refuses_nesting_and_empty_commands() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -99,7 +99,7 @@ fn run_steps(s: &mut Session, label: &str, artboard: Option<u64>, cmds: &mut [Va
|
||||
/// ones close, closed or replaced ones (`stash`) come back, and each has its art, selection,
|
||||
/// history, file, format and saved state again (#1697: undoing the batch's interaction alone left
|
||||
/// a Save As step's new file and an Undo step's history in place). Files the steps wrote stay
|
||||
/// written.
|
||||
/// written. Lock Guides stays as the steps left it, and while it is on the guides are deselected.
|
||||
fn roll_back(s: &mut Session, start: Vec<DocState>, stash: &[DocState]) {
|
||||
let open = std::mem::take(&mut s.docs);
|
||||
s.docs = start;
|
||||
@@ -107,5 +107,8 @@ fn roll_back(s: &mut Session, start: Vec<DocState>, stash: &[DocState]) {
|
||||
// Past the newest revision the document reached, so the restored one redraws.
|
||||
let newest = open.iter().chain(stash).filter(|d| d.uid == st.uid).map(|d| d.revision).max().unwrap_or(st.revision);
|
||||
st.revision = newest.saturating_add(1);
|
||||
if s.menu.guides_locked {
|
||||
crate::deselect_guides(&st.doc, &mut st.selection);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -307,6 +307,32 @@ fn use_artboards(p: &Value) -> Result<Option<bool>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `rect` param of a raster export: `[x, y, width, height]` in points, y down. `None` when
|
||||
/// absent; an error when it is not four finite numbers with a positive size, when the format isn't
|
||||
/// a raster one, or when it comes with an artboard choice (`rect` is the area, so those would
|
||||
/// contradict it). The image size limits are checked with the scale, as for an artboard.
|
||||
fn page_rect(f: &Format, p: &Value) -> Result<Option<Rect>> {
|
||||
let v = match p.get("rect") {
|
||||
None | Some(Value::Null) => return Ok(None),
|
||||
Some(v) => v,
|
||||
};
|
||||
if !f.raster {
|
||||
return Err(bad(C, format!("rect applies to raster formats, not {}", f.label)));
|
||||
}
|
||||
if let Some(k) = ARTBOARD_PARAMS.iter().chain(["useArtboards"].iter()).find(|k| p.get(**k).is_some_and(|v| !v.is_null())) {
|
||||
return Err(bad(C, format!("rect and {k} contradict each other: give the area or the artboards")));
|
||||
}
|
||||
let nums: Option<Vec<f64>> = v.as_array().filter(|a| a.len() == 4).and_then(|a| a.iter().map(Value::as_f64).collect());
|
||||
let Some(&[x, y, w, h]) = nums.as_deref() else {
|
||||
return Err(bad(C, "rect is [x, y, width, height] in points"));
|
||||
};
|
||||
let (x1, y1) = (x + w, y + h);
|
||||
if !(x.is_finite() && y.is_finite() && w > 0.0 && h > 0.0 && x1.is_finite() && y1.is_finite()) {
|
||||
return Err(bad(C, "rect needs finite numbers and a positive width and height"));
|
||||
}
|
||||
Ok(Some(Rect::new(x, y, x1, y1)))
|
||||
}
|
||||
|
||||
/// A copy of `doc` with one artboard, `rect` (exports of the art's or the selection's bounds).
|
||||
pub(super) fn single_artboard(doc: &Document, rect: Rect, name: &str) -> Document {
|
||||
with_single_artboard(doc.clone(), rect, name)
|
||||
@@ -359,7 +385,7 @@ pub fn encode(doc: &Document, format: &str, p: &Value) -> Result<Vec<u8>> {
|
||||
|
||||
/// Like [`encode`], also returning the encoder's warnings (PDF and SVG: options not applied yet,
|
||||
/// features approximated or left out; every format that draws type: fonts written in the fallback
|
||||
/// font).
|
||||
/// font; PDF, EPS, SVG, EMF, WMF and the raster formats: type that doesn't fit its frame).
|
||||
pub fn encode_with_warnings(doc: &Document, format: &str, p: &Value) -> Result<(Vec<u8>, Vec<String>)> {
|
||||
let f = super::writable(C, Some(format), None)?;
|
||||
encode_all(doc, f.id, p)?.single(f)
|
||||
@@ -387,10 +413,14 @@ fn encode_all_of(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
|
||||
let f = super::writable(C, Some(format), None)?;
|
||||
let failed = vectorcraft_render::decode_failures();
|
||||
let mut enc = encode_files(doc, f, p)?;
|
||||
// Baking, flattening and drawing stop early when cancelled and leave a truncated result.
|
||||
vectorcraft_doc::cancel::check()?;
|
||||
// An image the renderer couldn't decode draws nothing: say so rather than write a picture
|
||||
// that silently lacks it.
|
||||
if f.raster && vectorcraft_render::decode_failures() > failed {
|
||||
enc.warnings.push("an image couldn't be decoded and is missing from the picture".into());
|
||||
if f.raster
|
||||
&& let Some(w) = crate::cmd::rasterfx::undecoded_warning(failed)
|
||||
{
|
||||
enc.warnings.push(w.into());
|
||||
}
|
||||
// Formats that draw type: missing fonts come out in the fallback font (PDF and SVG say so
|
||||
// themselves: SVG only when it outlines or embeds fonts).
|
||||
@@ -408,6 +438,15 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> {
|
||||
let n = doc.artboards.len();
|
||||
// SVG reads its own `useArtboards` (an SVG option).
|
||||
let use_artboards = if f.raster || matches!(f.id, "pdf" | "ai" | "dxf" | "emf" | "wmf") { use_artboards(p)? } else { None };
|
||||
// `rect`: that area of the page, as if it were the only artboard (the document is not changed).
|
||||
if let Some(rect) = page_rect(f, p)? {
|
||||
let mut q = super::export::without_artboards(p);
|
||||
if let Some(o) = q.as_object_mut() {
|
||||
o.remove("rect");
|
||||
o.remove("useArtboards");
|
||||
}
|
||||
return encode_files(&single_artboard(doc, rect, "Rect"), f, &q);
|
||||
}
|
||||
if use_artboards == Some(false) {
|
||||
let bounds = vectorcraft_render::encode::art_bounds(doc).ok_or_else(|| bad(C, "nothing to export: the document has no visible art"))?;
|
||||
let mut q = super::export::without_artboards(p);
|
||||
@@ -467,7 +506,10 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> {
|
||||
};
|
||||
let mut enc = Encoded::default();
|
||||
let mut renderer = vectorcraft_render::Renderer::new();
|
||||
for b in chosen {
|
||||
let total = chosen.len() as u64;
|
||||
for (done, b) in chosen.into_iter().enumerate() {
|
||||
vectorcraft_doc::cancel::check()?;
|
||||
vectorcraft_doc::cancel::progress(done as u64, total, &format!("Rendering artboard {} of {total}", done + 1));
|
||||
let region = doc.artboards.get(b).ok_or_else(|| bad(C, format!("no artboard {}", b + 1)))?.rect;
|
||||
check_format_size(f, region.width() * scale, region.height() * scale)?;
|
||||
vectorcraft_render::raster_size(region, scale).map_err(|e| bad(C, e))?;
|
||||
@@ -479,11 +521,16 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> {
|
||||
} else {
|
||||
bytes
|
||||
};
|
||||
// PNG: say the colours are sRGB (what the renderer draws), unless `embedIcc` is false.
|
||||
let bytes =
|
||||
if format == RasterFormat::Png && settings.jpeg.embed_icc { vectorcraft_render::encode::png::with_srgb(bytes) } else { bytes };
|
||||
enc.files.push((Some(b), bytes));
|
||||
}
|
||||
if enc.files.is_empty() {
|
||||
return Err(bad(C, "the document has no artboard"));
|
||||
}
|
||||
let regions: Vec<Rect> = enc.files.iter().filter_map(|(b, _)| b.and_then(|b| doc.artboards.get(b))).map(|a| a.rect).collect();
|
||||
enc.warnings.extend(crate::cmd::fonts::export_overflow_warning(doc, &doc.layers, Some(®ions)));
|
||||
if format == RasterFormat::WebP && o.lossless == Some(false) {
|
||||
enc.warnings.push("lossy WebP isn't available yet: the file is lossless (exact, but larger)".into());
|
||||
}
|
||||
|
||||
@@ -270,6 +270,7 @@ pub(super) fn encode(doc: &Document, p: &Value) -> Result<Encoded> {
|
||||
if pages.is_empty() {
|
||||
return Err(bad(C, "the document has no artboard"));
|
||||
}
|
||||
let regions: Vec<Rect> = pages.iter().map(|(_, region, _)| *region).collect();
|
||||
let transparent = doc.layers.iter().any(|l| l.shows_transparency());
|
||||
let flat = flatten_document(doc, &set.flatten)?;
|
||||
let drawn = flat.as_ref().unwrap_or(doc);
|
||||
@@ -302,6 +303,7 @@ pub(super) fn encode(doc: &Document, p: &Value) -> Result<Encoded> {
|
||||
}
|
||||
}
|
||||
}
|
||||
enc.warnings.extend(crate::cmd::fonts::export_overflow_warning(doc, &doc.layers, Some(®ions)));
|
||||
Ok(enc)
|
||||
}
|
||||
|
||||
|
||||
@@ -94,11 +94,12 @@ fn selected_part(n: &Node, keep: &HashSet<NodeId>) -> Option<Node> {
|
||||
}
|
||||
|
||||
/// `doc` with only the art over `rect` (an artboard): objects whose visual bounds miss it are left
|
||||
/// out, at any depth of layers, sublayers and groups that don't clip, and objects that reach it
|
||||
/// stay whole (the file's bounds clip them), as DXF's crop leaves them. Symbol instances are
|
||||
/// measured by their art, and the box of one whose art holds brushed strokes grows by as far as
|
||||
/// their brush art can paint ([`SymbolBoxes::with_brush_art`]). An object that holds a symbol
|
||||
/// instance also stays when what the canvas paints for it reaches `rect`
|
||||
/// out, at any depth of layers, sublayers and groups that don't clip, unless what the renderer
|
||||
/// draws for them, their effects included ([`vectorcraft_render::effect_bounds`]), reaches it.
|
||||
/// Objects that reach it stay whole (the file's bounds clip them), as DXF's crop leaves them.
|
||||
/// Symbol instances are measured by their art, and the box of one whose art holds brushed strokes
|
||||
/// grows by as far as their brush art can paint ([`SymbolBoxes::with_brush_art`]). An object that
|
||||
/// holds a symbol instance also stays when what the canvas paints for it reaches `rect`
|
||||
/// ([`vectorcraft_render::painted_bounds`]: the effects in the symbols' art and its own effects). A
|
||||
/// clipping layer keeps every object, its clipping path among them. An artboard's file then holds
|
||||
/// its own art, not a whole document of artboards.
|
||||
@@ -122,8 +123,9 @@ pub(super) fn art_over(doc: &Document, rect: vectorcraft_geom::Rect) -> Document
|
||||
let over = |b: vectorcraft_geom::Rect| b.x0 <= rect.x1 && rect.x0 <= b.x1 && b.y0 <= rect.y1 && rect.y0 <= b.y1;
|
||||
let mut holds_instance = false;
|
||||
n.walk(&mut |c| holds_instance |= matches!(c.kind, NodeKind::SymbolInstance { .. }));
|
||||
let drawn = || vectorcraft_render::effect_bounds(symbols, n).is_some_and(over);
|
||||
let painted = || holds_instance && vectorcraft_render::painted_bounds(doc, n).is_some_and(over);
|
||||
(n.visual_bounds_with(symbols).is_none_or(over) || painted()).then(|| n.clone())
|
||||
(n.visual_bounds_with(symbols).is_none_or(over) || drawn() || painted()).then(|| n.clone())
|
||||
}
|
||||
let symbols = doc.symbol_boxes().with_brush_art();
|
||||
let mut d = doc.clone();
|
||||
@@ -165,13 +167,19 @@ fn source<'a>(s: &'a mut Session, f: &Format, p: &Value, cmd: &str) -> Result<Co
|
||||
/// The selected objects (not on template layers) alone on one layer, with one artboard: their
|
||||
/// visual bounds.
|
||||
fn selection(s: &mut Session, cmd: &str) -> Result<(Document, vectorcraft_geom::Rect)> {
|
||||
let ids = selected_artwork(s)?;
|
||||
isolated(&s.doc()?.doc, &ids, "Selection").ok_or_else(|| bad(cmd, "select something to export"))
|
||||
}
|
||||
|
||||
/// The selected objects (their top-most selected ancestors) that are artwork: those on template
|
||||
/// layers are guides and are left out.
|
||||
pub(crate) fn selected_artwork(s: &mut Session) -> Result<Vec<NodeId>> {
|
||||
let ids = edit::selected_roots(s)?;
|
||||
let st = s.doc()?;
|
||||
let is_template = |id| st.doc.node(id).is_some_and(|l| matches!(l.kind, NodeKind::Layer { template: true, .. }));
|
||||
// On a template layer or sublayer, at any depth.
|
||||
let on_template = |id| st.doc.ancestry(id).is_some_and(|a| a.into_iter().any(is_template));
|
||||
let ids: Vec<NodeId> = ids.into_iter().filter(|id| !on_template(*id)).collect();
|
||||
isolated(&st.doc, &ids, "Selection").ok_or_else(|| bad(cmd, "select something to export"))
|
||||
Ok(ids.into_iter().filter(|id| !on_template(*id)).collect())
|
||||
}
|
||||
|
||||
/// The crop of an export of objects `ids` of `doc` alone: the bounds the renderer measures for what
|
||||
|
||||
@@ -76,9 +76,10 @@ pub fn detect(name: &str, bytes: &[u8]) -> Option<&'static Format> {
|
||||
return format("svgz");
|
||||
}
|
||||
if vectorcraft_pdf::is_postscript(bytes) {
|
||||
// PostScript (.eps, and .ai saved without PDF compatibility) by any name; .ai and .ait
|
||||
// keep their meaning (a template), read as PostScript all the same.
|
||||
return by_name.filter(|f| matches!(f.id, "ai" | "ait")).or_else(|| format("eps"));
|
||||
// PostScript (.eps, .ps, and .ai saved without PDF compatibility) by any name; .ai, .ait
|
||||
// and .ps keep their meaning (a template, a PostScript file), read as PostScript all the
|
||||
// same.
|
||||
return by_name.filter(|f| matches!(f.id, "ai" | "ait" | "ps")).or_else(|| format("eps"));
|
||||
}
|
||||
if bytes.starts_with(b"%PDF") {
|
||||
// .ai and .ait files are PDF inside; the extension keeps the template meaning.
|
||||
@@ -167,6 +168,12 @@ fn through_editing_data(bytes: &[u8], opts: &LoadOptions, doc: Document, warning
|
||||
if !read {
|
||||
return (doc, warnings);
|
||||
}
|
||||
// The layers' text objects hold type and the stroked paths around it, which can include stroked
|
||||
// glyphs the PDF part kept as outlines: without any such glyph in the document, the warnings on
|
||||
// outlines are dropped.
|
||||
if !vectorcraft_pdf::holds_outlines(&doc) {
|
||||
warnings.retain(|w| !vectorcraft_pdf::is_outlines_note(w));
|
||||
}
|
||||
// The layers hold the art that the PDF part leaves out or, in a PDF, that the import clipped
|
||||
// to the page.
|
||||
warnings.retain(|w| w != vectorcraft_pdf::OFF_ARTBOARD_NOTE && w != vectorcraft_pdf::PAST_PAGE_NOTE);
|
||||
@@ -197,8 +204,8 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
let mut converted = false;
|
||||
let mut preview_only = false;
|
||||
let (mut doc, mut warnings, restored) = match format.id {
|
||||
// EPS and PostScript .ai: the document our EPS files carry, else the PostScript read.
|
||||
_ if format.id == "eps" || vectorcraft_pdf::is_postscript(bytes) => {
|
||||
// EPS, .ps and PostScript .ai: the document our EPS files carry, else the PostScript read.
|
||||
_ if matches!(format.id, "eps" | "ps") || vectorcraft_pdf::is_postscript(bytes) => {
|
||||
let editing = vectorcraft_eps::has_native(bytes).then_some((true, || vectorcraft_eps::native(bytes)));
|
||||
restore_or_import(editing, || {
|
||||
let r = vectorcraft_eps::import_with(bytes, opts.editing_data).map_err(|e| err(format!("can't open `{}`: {e}", file_name(name))))?;
|
||||
@@ -377,6 +384,11 @@ fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option<St
|
||||
doc.template = false;
|
||||
doc.title = s.next_untitled();
|
||||
}
|
||||
// A .ai (or .ait) another app wrote carries no open Layers rows we read: all of them start
|
||||
// closed, its top-level layers as its groups (#1475). Ours carry their own.
|
||||
if matches!(format.id, "ai" | "ait") && !restored && doc.layers_open.is_none() {
|
||||
doc.layers_open = Some(vec![]);
|
||||
}
|
||||
// Save writes the file back only in a format it writes, only a whole PDF (see
|
||||
// [`LoadOptions::is_partial`]), and a .ai file only when it carried the native document (Save
|
||||
// writes .ai that way).
|
||||
@@ -436,24 +448,37 @@ pub(super) fn new_from_template(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// that default: wasm32 has 4 GB to address and aborts when an allocation fails.
|
||||
pub(crate) use vectorcraft_doc::MAX_RASTER_ALLOC;
|
||||
|
||||
/// What to say of encoded image `bytes` that need more than [`MAX_RASTER_ALLOC`] to decode (its
|
||||
/// header says so; the pixels aren't read): the renderer, which has the same allowance, draws
|
||||
/// nothing for them, so Open, Place and the Links panel say it (#1479). `None` for images that
|
||||
/// decode, and for bytes that aren't an image.
|
||||
/// What to say of encoded image `bytes` that need more than [`MAX_RASTER_ALLOC`] to decode,
|
||||
/// counted as RGBA (4 bytes a pixel) or as the decoder gives them when that is more (its header
|
||||
/// says so; the pixels aren't read): Open, Place and the Links panel say it (#1479). `None` for
|
||||
/// images within the allowance, and for bytes that aren't an image.
|
||||
pub(crate) fn too_large_note(bytes: &[u8]) -> Option<String> {
|
||||
use image::ImageDecoder as _;
|
||||
if let Some((w, h)) = vectorcraft_doc::cmyk::oversized_tiff(bytes) {
|
||||
return Some(format!("an image of {w} × {h} pixels is too large to draw"));
|
||||
}
|
||||
let d = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_decoder().ok()?;
|
||||
let (w, h) = d.dimensions();
|
||||
(d.total_bytes() > MAX_RASTER_ALLOC).then(|| format!("an image of {w} × {h} pixels is too large to draw"))
|
||||
// The allowance also counts the pixels as RGBA: an image of fewer than 4 bytes a pixel (gray,
|
||||
// gray with alpha, RGB) takes more than the decoder counts (#1717).
|
||||
let rgba_over = u64::from(w) * u64::from(h) > MAX_RASTER_ALLOC / 4;
|
||||
(rgba_over || d.total_bytes() > MAX_RASTER_ALLOC).then(|| format!("an image of {w} × {h} pixels is too large to draw"))
|
||||
}
|
||||
|
||||
/// Decode an image's header (and, for formats stored as PNG, its pixels). CMYK TIFFs are kept as
|
||||
/// they are, with their ink amounts ([`ImageBlob::cmyk`]); CMYK TIFFs with an alpha channel, which
|
||||
/// the decoder can't read, become RGBA in the active colour settings' CMYK, as do CMYK Photoshop
|
||||
/// documents, whose merged image is read ([`psdread`]). Grayscale TIFFs with an alpha channel or
|
||||
/// other extra channels, which the decoder can't read either, become RGBA too.
|
||||
/// they are, with their ink amounts ([`ImageBlob::cmyk`]), except that ones storing one plane per
|
||||
/// ink are written again with their inks together ([`vectorcraft_doc::cmyk::stored_cmyk_tiff`]);
|
||||
/// CMYK TIFFs with an alpha channel, which the decoder can't read, become RGBA in the active color
|
||||
/// settings' CMYK, as do CMYK Photoshop documents, whose merged image is read ([`psdread`]).
|
||||
/// Grayscale TIFFs with an alpha channel or other extra channels, which the decoder can't read
|
||||
/// either, become RGBA too, whether they store their channels together or one plane per channel.
|
||||
/// A TIFF with more strips or tiles than the decoder can count
|
||||
/// ([`vectorcraft_doc::cmyk::oversized_tiff`]) is refused as too large to draw.
|
||||
pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
|
||||
let ppi = super::ppi::resolution(bytes);
|
||||
if let Some((w, h)) = vectorcraft_doc::cmyk::oversized_tiff(bytes) {
|
||||
return Err(err(format!("an image of {w} × {h} pixels is too large to draw")));
|
||||
}
|
||||
if psdread::is_psd(bytes) {
|
||||
let cms = vectorcraft_color::cms::active();
|
||||
return as_png(psdread::decode(bytes, MAX_RASTER_ALLOC, |c| cms.cmyk_to_srgb(c, false)).map_err(err)?, ppi);
|
||||
@@ -464,10 +489,14 @@ pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
|
||||
reader.limits(limits);
|
||||
let kind = reader.format().ok_or_else(|| err("not an image VectorCraft reads (see document.formats)"))?;
|
||||
let f = image_format(kind).ok_or_else(|| err(format!("{kind:?} images can't be opened (see document.formats)")))?;
|
||||
let cmyk = || ImageBlob::new(f.mime, bytes.to_vec()).cmyk().is_some();
|
||||
if matches!(f.id, "png" | "jpg" | "gif" | "webp") || (f.id == "tiff" && cmyk()) {
|
||||
let kept = match f.id {
|
||||
"png" | "jpg" | "gif" | "webp" => Some(bytes.to_vec()),
|
||||
"tiff" => vectorcraft_doc::cmyk::stored_cmyk_tiff(bytes, ppi),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(kept) = kept {
|
||||
let (width, height) = reader.into_dimensions().map_err(err)?;
|
||||
return stored(bytes.to_vec(), f.mime, (width, height), ppi);
|
||||
return stored(kept, f.mime, (width, height), ppi);
|
||||
}
|
||||
let extra_samples = || {
|
||||
let cms = vectorcraft_color::cms::active();
|
||||
|
||||
@@ -42,6 +42,8 @@ pub(super) fn encode(doc: &Document, p: &Value, use_artboards: Option<bool>, kin
|
||||
if enc.files.is_empty() {
|
||||
return Err(bad(C, "the document has no artboard"));
|
||||
}
|
||||
let regions: Vec<vectorcraft_geom::Rect> = enc.files.iter().filter_map(|(b, _)| b.and_then(|b| doc.artboards.get(b))).map(|a| a.rect).collect();
|
||||
enc.warnings.extend(crate::cmd::fonts::export_overflow_warning(doc, &doc.layers, Some(®ions)));
|
||||
Ok(enc)
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ mod legacy_ai;
|
||||
mod load;
|
||||
pub(crate) use load::{MAX_RASTER_ALLOC, too_large_note};
|
||||
mod metafile;
|
||||
mod naming;
|
||||
mod native;
|
||||
pub mod pdf;
|
||||
mod pdfimport;
|
||||
@@ -63,6 +64,7 @@ pub use load::{
|
||||
read_for_open, texture_image,
|
||||
};
|
||||
pub use load::{losses_summary, overwrite_losses};
|
||||
pub use naming::{NameParts as FileNameParts, parse_date as parse_file_date, render as render_file_name};
|
||||
pub(crate) use native::preview_png;
|
||||
pub use native::with_compression_pref;
|
||||
pub use pdfimport::{LoadOptions, page_document};
|
||||
@@ -71,7 +73,10 @@ pub use save::{
|
||||
SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, export_folder, save_filters, save_format, save_job, save_plan,
|
||||
save_with, stamp_save_dates, template_filters, templates_dialog_folder, templates_folder,
|
||||
};
|
||||
pub use screens::{PRESETS as SCREEN_PRESETS, ScreenSize, preset_rows as screen_preset_rows};
|
||||
pub use screens::{
|
||||
PRESET_KEYS as SCREEN_EXPORT_PRESET_KEYS, PRESETS as SCREEN_PRESETS, ScreenExportPreset, ScreenSize,
|
||||
builtin_presets as screen_export_builtin_presets, preset_rows as screen_preset_rows,
|
||||
};
|
||||
pub(crate) use screens::{
|
||||
SHARED_KEYS as SCREEN_SETTINGS_KEYS, check_settings as check_screen_settings, export as export_screens, store_settings as store_screen_settings,
|
||||
};
|
||||
@@ -118,7 +123,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export Document",
|
||||
[],
|
||||
None,
|
||||
"{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: 72 (raster formats: pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
|
||||
"{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: 72 (raster formats: pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
|
||||
has_doc,
|
||||
export::export
|
||||
),
|
||||
@@ -136,10 +141,37 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export for Screens",
|
||||
["File", "Export"],
|
||||
None,
|
||||
"{folder?, zip?: false (one store-only .zip of every file: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/[email protected]). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step)",
|
||||
"{folder?, zip?: false (one store-only .zip of every file: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with <filename> for the document, <name> for the artboard, asset, layer or object, <index> from 1, #### for the counter zero-padded to the run of #, <format>, <scale>, <suffix> (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, <date>, <time>, <YYYY>, <MM>, <DD>, <hh>, <mm> and <ss>; a trailing .ext is dropped, as in `COMPANY-Logo-<filename>-####-YYYYMMDD`; different files it names alike get -2, -3…), counterStart?: 1, counterPer?: item (every file of an item has its number)|file, date?: YYYY-MM-DD (the date the pattern uses; default today, UTC), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/[email protected]). eps, ai and vectorcraft rows write a document of the item alone (its one artboard). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step)",
|
||||
has_doc,
|
||||
screens::export_for_screens
|
||||
),
|
||||
cmd!(
|
||||
query "document.exportPresets.list",
|
||||
"Export Presets",
|
||||
[],
|
||||
None,
|
||||
"{} → {presets: [{name, builtIn, settings}]} the built-in batch export presets (Logo Package, Print-Ready PDF), then the saved ones; any name works as document.exportForScreens' exportPreset",
|
||||
always,
|
||||
screens::presets_list
|
||||
),
|
||||
cmd!(
|
||||
query "document.exportPresets.save",
|
||||
"Save Export Preset",
|
||||
[],
|
||||
None,
|
||||
"{name, settings: {formats, settings, preset, name, prefix, counterStart, counterPer, folders, subfolders, includeBleed, antiAlias, folder, fullDocument} (as document.exportForScreens; other keys are left out)} save (or replace) a batch export preset, kept with the preferences; built-in names are refused → {name, created, settings}",
|
||||
always,
|
||||
screens::presets_save
|
||||
),
|
||||
cmd!(
|
||||
query "document.exportPresets.delete",
|
||||
"Delete Export Preset",
|
||||
[],
|
||||
None,
|
||||
"{name} delete a saved batch export preset (built-in ones stay) → {deleted}",
|
||||
always,
|
||||
screens::presets_delete
|
||||
),
|
||||
cmd!(
|
||||
query "command.batch",
|
||||
"Batch",
|
||||
@@ -154,7 +186,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Batch…",
|
||||
[],
|
||||
None,
|
||||
"{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportForScreens / document.exportSelection the same way), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs), or on every data set of the open document (source: datasets; File + Data Set Name for Override Save/Export). Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped}",
|
||||
"{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportForScreens / document.exportSelection the same way), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs), or on every data set of the open document (source: datasets; File + Data Set Name for the folder destination and Override Save/Export, with `/`, `\\` and `:` in the data set's name as `-`, and `-2`, `-3`… added to a name already used). Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped}",
|
||||
always,
|
||||
action_batch::action_batch
|
||||
),
|
||||
@@ -279,6 +311,12 @@ const USE_ARTBOARDS: FormatOption = FormatOption {
|
||||
default: "null",
|
||||
description: "true: every chosen artboard (default all), one file each named <file>-<artboard>.<ext> (PDF and AI: one page each); false: the bounds of the visible art",
|
||||
};
|
||||
const RECT: FormatOption = FormatOption {
|
||||
name: "rect",
|
||||
ty: "array",
|
||||
default: "null",
|
||||
description: "[x, y, width, height] in document points, y down: exports that area of the page (no artboard needed, the document is not changed); not with artboard, artboards, range or useArtboards",
|
||||
};
|
||||
const PPI: FormatOption = FormatOption {
|
||||
name: "ppi",
|
||||
ty: "number",
|
||||
@@ -316,7 +354,7 @@ const EMBED_ICC: FormatOption = FormatOption {
|
||||
name: "embedIcc",
|
||||
ty: "boolean",
|
||||
default: "true",
|
||||
description: "embed the colour profile: sRGB (RGB), the working CMYK space (CMYK) or gray with sRGB's tone curve",
|
||||
description: "embed the colour profile: sRGB (RGB), the working CMYK space (CMYK) or gray with sRGB's tone curve; PNG: mark the file as sRGB (an sRGB chunk)",
|
||||
};
|
||||
const IMAGE_MAP: FormatOption = FormatOption {
|
||||
name: "imageMap",
|
||||
@@ -366,6 +404,7 @@ const PALETTE_OPTIONS: &[FormatOption] = &[
|
||||
ARTBOARDS,
|
||||
RANGE,
|
||||
USE_ARTBOARDS,
|
||||
RECT,
|
||||
PPI,
|
||||
SCALE,
|
||||
BACKGROUND,
|
||||
@@ -387,7 +426,7 @@ const BYTE_ORDER: FormatOption = FormatOption {
|
||||
description: "the order of the bytes in the file's numbers: little (II, as on PCs) or big (MM)",
|
||||
};
|
||||
const TIFF_OPTIONS: &[FormatOption] =
|
||||
&[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, COLOR_MODEL, LZW, BYTE_ORDER, EMBED_ICC];
|
||||
&[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, BACKGROUND, ANTI_ALIAS, COLOR_MODEL, LZW, BYTE_ORDER, EMBED_ICC];
|
||||
const BMP_COLOR_MODEL: FormatOption =
|
||||
FormatOption { name: "colorModel", ty: "string", default: "\"rgb\"", description: "rgb or gray (greys at every depth)" };
|
||||
const BMP_DEPTH: FormatOption = FormatOption {
|
||||
@@ -415,6 +454,7 @@ const BMP_OPTIONS: &[FormatOption] = &[
|
||||
ARTBOARDS,
|
||||
RANGE,
|
||||
USE_ARTBOARDS,
|
||||
RECT,
|
||||
PPI,
|
||||
SCALE,
|
||||
BACKGROUND,
|
||||
@@ -434,7 +474,7 @@ const TGA_DEPTH: FormatOption = FormatOption {
|
||||
default: "24",
|
||||
description: "bits per pixel: 16 (one-bit alpha), 24 (flattened on white) or 32 (keeps transparency)",
|
||||
};
|
||||
const TGA_OPTIONS: &[FormatOption] = &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, TGA_DEPTH];
|
||||
const TGA_OPTIONS: &[FormatOption] = &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, BACKGROUND, ANTI_ALIAS, TGA_DEPTH];
|
||||
const PSD_LAYERS: FormatOption = FormatOption {
|
||||
name: "layers",
|
||||
ty: "boolean",
|
||||
@@ -458,6 +498,7 @@ const PSD_OPTIONS: &[FormatOption] = &[
|
||||
ARTBOARDS,
|
||||
RANGE,
|
||||
USE_ARTBOARDS,
|
||||
RECT,
|
||||
PPI,
|
||||
SCALE,
|
||||
BACKGROUND,
|
||||
@@ -528,7 +569,7 @@ pub const FORMATS: &[Format] = &[
|
||||
read: true,
|
||||
write: true,
|
||||
raster: true,
|
||||
options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, INTERLACED],
|
||||
options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, BACKGROUND, ANTI_ALIAS, INTERLACED, EMBED_ICC],
|
||||
},
|
||||
Format {
|
||||
id: "jpg",
|
||||
@@ -543,6 +584,7 @@ pub const FORMATS: &[Format] = &[
|
||||
ARTBOARDS,
|
||||
RANGE,
|
||||
USE_ARTBOARDS,
|
||||
RECT,
|
||||
PPI,
|
||||
SCALE,
|
||||
BACKGROUND,
|
||||
@@ -564,7 +606,7 @@ pub const FORMATS: &[Format] = &[
|
||||
read: true,
|
||||
write: true,
|
||||
raster: true,
|
||||
options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, LOSSLESS, WEBP_QUALITY],
|
||||
options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, BACKGROUND, ANTI_ALIAS, LOSSLESS, WEBP_QUALITY],
|
||||
},
|
||||
Format {
|
||||
id: "tiff",
|
||||
@@ -593,7 +635,7 @@ pub const FORMATS: &[Format] = &[
|
||||
Format {
|
||||
id: "eps",
|
||||
label: "EPS",
|
||||
extensions: &["eps"],
|
||||
extensions: &["eps", "epsf"],
|
||||
mime: "application/postscript",
|
||||
read: true,
|
||||
write: true,
|
||||
@@ -609,6 +651,8 @@ pub const FORMATS: &[Format] = &[
|
||||
reader("affinity", "Affinity", &["af", "afdesign", "afpub"], "application/vnd.affinity", false),
|
||||
// Photoshop's large document format: read like a PSD (its merged image), never written.
|
||||
reader("psb", "PSB", &["psb"], "image/x-psb", true),
|
||||
// PostScript programs, read as EPS files are; Print to PostScript writes them, exports don't.
|
||||
reader("ps", "PostScript", &["ps"], "application/postscript", false),
|
||||
];
|
||||
|
||||
/// Every extension `document.open` reads (the "All readable files" filter of open dialogs).
|
||||
@@ -635,6 +679,8 @@ pub const OPEN_EXTS: &[&str] = &[
|
||||
"emf",
|
||||
"wmf",
|
||||
"eps",
|
||||
"epsf",
|
||||
"ps",
|
||||
"af",
|
||||
"afdesign",
|
||||
"afpub",
|
||||
@@ -642,8 +688,9 @@ pub const OPEN_EXTS: &[&str] = &[
|
||||
|
||||
/// Extensions in [`OPEN_EXTS`] the desktop packaging doesn't associate with VectorCraft: Photoshop
|
||||
/// documents belong to raster editors, so Finder and file managers don't offer a vector app for
|
||||
/// them (File › Open and Place still read them).
|
||||
pub const UNASSOCIATED_EXTS: &[&str] = &["psd", "psb"];
|
||||
/// them, and the packaging declares no `.epsf` or `.ps` type (File › Open and Place still read
|
||||
/// them all).
|
||||
pub const UNASSOCIATED_EXTS: &[&str] = &["psd", "psb", "epsf", "ps"];
|
||||
|
||||
/// The extension that picks each writable format when exporting (the format's first; PNG-8 shares
|
||||
/// `.png` with PNG, so `.png` comes once), in [`FORMATS`] order.
|
||||
@@ -660,7 +707,10 @@ pub fn export_extensions() -> Vec<&'static str> {
|
||||
/// Text files: File → Place sets them as area type (Text Import Options).
|
||||
pub const TEXT_EXTS: &[&str] = &["txt"];
|
||||
|
||||
/// Every extension File → Place reads: [`OPEN_EXTS`] and [`TEXT_EXTS`].
|
||||
/// RTF files: File → Place sets them as area type with their formatting.
|
||||
pub const RTF_EXTS: &[&str] = &["rtf"];
|
||||
|
||||
/// Every extension File → Place reads: [`OPEN_EXTS`], [`TEXT_EXTS`] and [`RTF_EXTS`].
|
||||
pub const PLACE_EXTS: &[&str] = &[
|
||||
"vectorcraft",
|
||||
"drawcraft",
|
||||
@@ -684,10 +734,13 @@ pub const PLACE_EXTS: &[&str] = &[
|
||||
"emf",
|
||||
"wmf",
|
||||
"eps",
|
||||
"epsf",
|
||||
"ps",
|
||||
"af",
|
||||
"afdesign",
|
||||
"afpub",
|
||||
"txt",
|
||||
"rtf",
|
||||
];
|
||||
|
||||
/// One dialog filter per readable format.
|
||||
@@ -711,11 +764,13 @@ pub fn open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static
|
||||
.chain(std::iter::once(("Plug-ins", super::plugin::EXTS)))
|
||||
}
|
||||
|
||||
/// File → Place dialog filters: "All placeable files", then one per placeable format, then text.
|
||||
/// File → Place dialog filters: "All placeable files", then one per placeable format, then text
|
||||
/// and RTF.
|
||||
pub fn place_filters() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
|
||||
std::iter::once(("All placeable files", PLACE_EXTS))
|
||||
.chain(format_filters().filter(|(_, exts)| exts.iter().all(|ext| PLACE_EXTS.contains(ext))))
|
||||
.chain(std::iter::once(("Text", TEXT_EXTS)))
|
||||
.chain(std::iter::once(("RTF", RTF_EXTS)))
|
||||
}
|
||||
|
||||
/// A format by id or extension (any case, leading dot allowed; `jpeg` finds `jpg`).
|
||||
@@ -819,6 +874,8 @@ pub(crate) fn read_file_head(path: &str, max: usize) -> Result<Vec<u8>> {
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
// Work that stops early for a cancel can leave a truncated result: none of it is written.
|
||||
vectorcraft_doc::cancel::check()?;
|
||||
may_write(path)?;
|
||||
// Never a half-written file: see [`write_atomic`].
|
||||
write_atomic(std::path::Path::new(path), bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
@@ -827,6 +884,7 @@ pub(crate) fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
/// [`write_file`] with what `fill` writes into the file (a save streamed into it).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn write_file_with(path: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>) -> Result<()> {
|
||||
vectorcraft_doc::cancel::check()?;
|
||||
may_write(path)?;
|
||||
vectorcraft_format::write_atomic_with(std::path::Path::new(path), |f| fill(f)).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
}
|
||||
@@ -1031,14 +1089,18 @@ pub fn artboard_file_names(doc: &vectorcraft_doc::Document, boards: &[usize]) ->
|
||||
/// characters as `-`, the fallback when it is empty, and `-2`, `-3`… after a name already used
|
||||
/// (compared without case).
|
||||
pub(crate) fn unique_file_names<'a>(names: impl IntoIterator<Item = (&'a str, String)>) -> Vec<String> {
|
||||
distinct_names(names.into_iter().map(|(name, fallback)| {
|
||||
let base: String = name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '-' }).collect();
|
||||
if base.is_empty() { fallback } else { base }
|
||||
}))
|
||||
}
|
||||
|
||||
/// `names` with `-2`, `-3`… after a name already used (compared without case).
|
||||
fn distinct_names(names: impl IntoIterator<Item = String>) -> Vec<String> {
|
||||
let mut taken = std::collections::HashSet::new();
|
||||
names
|
||||
.into_iter()
|
||||
.map(|(name, fallback)| {
|
||||
let mut base: String = name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '-' }).collect();
|
||||
if base.is_empty() {
|
||||
base = fallback;
|
||||
}
|
||||
.map(|base| {
|
||||
let mut name = base.clone();
|
||||
for i in 2.. {
|
||||
if taken.insert(name.to_lowercase()) {
|
||||
@@ -1143,6 +1205,8 @@ mod tests_jpeg;
|
||||
#[cfg(test)]
|
||||
mod tests_palette;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_batch;
|
||||
#[cfg(test)]
|
||||
mod tests_screens;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
//! File-name templates for batch exports (`document.exportForScreens {name}`): the same pattern
|
||||
//! gives every exported file a consistent name, such as `COMPANY-Logo-<filename>-####-YYYYMMDD`.
|
||||
//!
|
||||
//! Tokens:
|
||||
//! - `<filename>` (or `<document>`): the document's name without its extension;
|
||||
//! - `<name>` (or `<artboard>`, `<item>`): the artboard's, asset's, layer's or selection's name;
|
||||
//! - `<index>`: the item's place in the export, from 1;
|
||||
//! - a run of `#`: the counter, zero-padded to the run's length (`####` → `0001`);
|
||||
//! - `<format>` (or `<ext>`): the file's extension; `<scale>`: the row's size (`2x`, `100w`);
|
||||
//! `<suffix>`: the row's suffix (otherwise added at the end);
|
||||
//! - the date and time: `YYYYMMDD`, `YYYY-MM-DD` and `YYYY` as written, and `<date>`
|
||||
//! (YYYYMMDD), `<time>` (hhmmss), `<YYYY>`, `<MM>`, `<DD>`, `<hh>`, `<mm>`, `<ss>`.
|
||||
//!
|
||||
//! A trailing `.ext` (or `.<ext>`, `.<format>`) is dropped: the extension comes from the format.
|
||||
//! Anything that could start a folder (`/`, `\`, `:`) becomes `-`.
|
||||
|
||||
/// What a template's tokens stand for, for one file.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct NameParts<'a> {
|
||||
pub filename: &'a str,
|
||||
pub name: &'a str,
|
||||
pub index: usize,
|
||||
pub counter: u64,
|
||||
pub format: &'a str,
|
||||
pub scale: &'a str,
|
||||
pub suffix: &'a str,
|
||||
/// `[year, month, day, hour, minute, second]`.
|
||||
pub when: [i64; 6],
|
||||
}
|
||||
|
||||
/// The bracketed tokens and what each stands for.
|
||||
fn token(name: &str, p: &NameParts<'_>) -> Option<String> {
|
||||
let [y, mo, d, h, mi, s] = p.when;
|
||||
Some(match name {
|
||||
"filename" | "document" => p.filename.to_string(),
|
||||
"name" | "artboard" | "item" => p.name.to_string(),
|
||||
"index" => p.index.to_string(),
|
||||
"format" | "ext" => p.format.to_string(),
|
||||
"scale" => p.scale.to_string(),
|
||||
"suffix" => p.suffix.to_string(),
|
||||
"date" => format!("{y:04}{mo:02}{d:02}"),
|
||||
"time" => format!("{h:02}{mi:02}{s:02}"),
|
||||
"YYYY" => format!("{y:04}"),
|
||||
"MM" => format!("{mo:02}"),
|
||||
"DD" => format!("{d:02}"),
|
||||
"hh" => format!("{h:02}"),
|
||||
"mm" => format!("{mi:02}"),
|
||||
"ss" => format!("{s:02}"),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Text with `/`, `\` and `:` turned into `-`, so it can't start a folder.
|
||||
fn clean(s: &str) -> String {
|
||||
s.chars().map(|c| if matches!(c, '/' | '\\' | ':') { '-' } else { c }).collect()
|
||||
}
|
||||
|
||||
/// Does `template` place the row's suffix itself (so it isn't added at the end)?
|
||||
pub fn has_suffix_token(template: &str) -> bool {
|
||||
template.contains("<suffix>")
|
||||
}
|
||||
|
||||
/// The file name (without extension) `template` gives for `p`.
|
||||
pub fn render(template: &str, p: &NameParts<'_>) -> String {
|
||||
let mut t = template.trim();
|
||||
for ext in [".ext", ".<ext>", ".<format>"] {
|
||||
if let Some(stripped) = t.strip_suffix(ext) {
|
||||
t = stripped;
|
||||
}
|
||||
}
|
||||
let [y, mo, d, ..] = p.when;
|
||||
let mut out = String::with_capacity(t.len() + 16);
|
||||
let mut rest = t;
|
||||
while let Some(c) = rest.chars().next() {
|
||||
// Bracketed tokens; an unknown one stays as written.
|
||||
if c == '<'
|
||||
&& let Some(end) = rest.find('>')
|
||||
&& let Some(value) = rest.get(1..end).and_then(|name| token(name, p))
|
||||
{
|
||||
out.push_str(&clean(&value));
|
||||
rest = rest.get(end + 1..).unwrap_or("");
|
||||
continue;
|
||||
}
|
||||
if c == '#' {
|
||||
let run = rest.chars().take_while(|c| *c == '#').count();
|
||||
out.push_str(&format!("{:0run$}", p.counter));
|
||||
rest = rest.get(run..).unwrap_or("");
|
||||
continue;
|
||||
}
|
||||
// The date spelled out, longest first.
|
||||
let date = [("YYYYMMDD", format!("{y:04}{mo:02}{d:02}")), ("YYYY-MM-DD", format!("{y:04}-{mo:02}-{d:02}")), ("YYYY", format!("{y:04}"))];
|
||||
if let Some((pat, value)) = date.iter().find(|(pat, _)| rest.starts_with(pat)) {
|
||||
out.push_str(value);
|
||||
rest = rest.get(pat.len()..).unwrap_or("");
|
||||
continue;
|
||||
}
|
||||
out.push(if matches!(c, '/' | '\\' | ':') { '-' } else { c });
|
||||
rest = rest.get(c.len_utf8()..).unwrap_or("");
|
||||
}
|
||||
out.trim().to_string()
|
||||
}
|
||||
|
||||
/// `YYYY-MM-DD` (or `YYYYMMDD`) as `[year, month, day, 0, 0, 0]`; `None` when it isn't a date.
|
||||
pub fn parse_date(s: &str) -> Option<[i64; 6]> {
|
||||
let digits: String = s.trim().chars().filter(|c| *c != '-').collect();
|
||||
if digits.len() != 8 || !digits.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
let num = |a: usize, b: usize| digits.get(a..b).and_then(|t| t.parse::<i64>().ok());
|
||||
let (y, m, d) = (num(0, 4)?, num(4, 6)?, num(6, 8)?);
|
||||
((1..=12).contains(&m) && (1..=31).contains(&d)).then_some([y, m, d, 0, 0, 0])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parts() -> NameParts<'static> {
|
||||
NameParts { filename: "Brand", name: "Dark", index: 3, counter: 7, format: "png", scale: "2x", suffix: "@2x", when: [2026, 10, 16, 9, 5, 0] }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_example_from_the_request() {
|
||||
assert_eq!(render("COMPANY-Logo-<filename>-####-YYYYMMDD.ext", &parts()), "COMPANY-Logo-Brand-0007-20261016");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_token() {
|
||||
let p = parts();
|
||||
assert_eq!(render("<document>_<name>_<artboard>_<item>", &p), "Brand_Dark_Dark_Dark");
|
||||
assert_eq!(render("<index>-#-##-<format>-<ext>-<scale><suffix>", &p), "3-7-07-png-png-2x@2x");
|
||||
assert_eq!(render("<date>T<time> <YYYY>/<MM>/<DD> <hh>:<mm>:<ss>", &p), "20261016T090500 2026-10-16 09-05-00");
|
||||
assert_eq!(render("YYYY-MM-DD YYYY", &p), "2026-10-16 2026");
|
||||
// Words are left alone: only the date patterns and bracketed tokens are replaced.
|
||||
assert_eq!(render("SUMMER-DD-MM", &p), "SUMMER-DD-MM");
|
||||
assert_eq!(render("<unknown>-x", &p), "<unknown>-x");
|
||||
assert_eq!(render("logo.<format>", &p), "logo", "the extension comes from the format");
|
||||
assert!(has_suffix_token("a<suffix>") && !has_suffix_token("a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_name_climbs_out_of_its_folder() {
|
||||
let p = NameParts { name: "../../etc", ..parts() };
|
||||
let n = render("a/b\\c:<name>", &p);
|
||||
assert!(!n.contains('/') && !n.contains('\\') && !n.contains(':'), "{n}");
|
||||
// A long counter is padded, a short run is not cut.
|
||||
assert_eq!(render("########", &NameParts { counter: 123, ..parts() }), "00000123");
|
||||
assert_eq!(render("#", &NameParts { counter: 12345, ..parts() }), "12345");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dates_parse() {
|
||||
assert_eq!(parse_date("2026-10-16"), Some([2026, 10, 16, 0, 0, 0]));
|
||||
assert_eq!(parse_date("20261016"), Some([2026, 10, 16, 0, 0, 0]));
|
||||
for bad in ["", "2026-13-01", "2026-1-1", "today", "2026-10-1x"] {
|
||||
assert_eq!(parse_date(bad), None, "{bad}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -169,6 +169,7 @@ pub(crate) fn merge(base: &mut Value, over: &Value) {
|
||||
pub(crate) fn pdf_error(cmd: &str, e: PdfError) -> EngineError {
|
||||
match e {
|
||||
PdfError::BadSetting(_) | PdfError::Unsupported(_) => bad(cmd, e.to_string()),
|
||||
PdfError::Cancelled => EngineError::Cancelled,
|
||||
e => EngineError::Other(e.to_string()),
|
||||
}
|
||||
}
|
||||
@@ -253,9 +254,21 @@ pub fn encode(cmd: &str, doc: &Document, p: &Value) -> Result<(Vec<u8>, Vec<Stri
|
||||
let (full, mut warnings) = crate::cmd::place::document::full_documents(doc);
|
||||
let (bytes, drawn_warnings) = encode_carrying(cmd, &full, p, || Ok(vectorcraft_format::save(doc, false)))?;
|
||||
warnings.extend(drawn_warnings);
|
||||
warnings.extend(overflow_warning(cmd, &full, p)?);
|
||||
Ok((bytes, warnings))
|
||||
}
|
||||
|
||||
/// The warning about the type that the pages of a PDF of `doc` with the options in `p` draw and
|
||||
/// whose text doesn't fit (see [`crate::cmd::fonts::export_overflow_warning`]): on the pages
|
||||
/// chosen, leaving out the layers that don't print unless the PDF writes them.
|
||||
fn overflow_warning(cmd: &str, doc: &Document, p: &Value) -> Result<Option<String>> {
|
||||
let opts = options(cmd, doc, p)?;
|
||||
let areas = vectorcraft_pdf::page_areas(doc, &opts).map_err(|e| pdf_error(cmd, e))?;
|
||||
let set = &opts.settings;
|
||||
let printed = if set.include_non_printing || set.create_layers { None } else { without_non_printing(&doc.layers) };
|
||||
Ok(crate::cmd::fonts::export_overflow_warning(doc, printed.as_deref().unwrap_or(&doc.layers), Some(&areas)))
|
||||
}
|
||||
|
||||
/// [`encode`] the pages of `doc`, carrying `native()` as the editing data (a `.ai` file: the
|
||||
/// native document with its save options, whose pages may be left blank).
|
||||
pub(super) fn encode_carrying(cmd: &str, doc: &Document, p: &Value, native: impl FnOnce() -> Result<Vec<u8>>) -> Result<(Vec<u8>, Vec<String>)> {
|
||||
@@ -273,7 +286,11 @@ pub(super) fn encode_carrying(cmd: &str, doc: &Document, p: &Value, native: impl
|
||||
}
|
||||
}
|
||||
let set = &opts.settings;
|
||||
if set.pdf13() {
|
||||
vectorcraft_doc::cancel::progress(0, 0, "Flattening transparency");
|
||||
}
|
||||
let flat = flatten_for(cmd, doc, set, p)?;
|
||||
vectorcraft_doc::cancel::check()?;
|
||||
if flat.is_some() {
|
||||
let kind = if set.standard.flattens() { set.standard.label() } else { set.compatibility.label() };
|
||||
warnings.push(format!("transparency is flattened into opaque art and images ({kind} files have none): see the flattener preset"));
|
||||
|
||||
@@ -185,6 +185,9 @@ fn parent_folder(path: &str) -> Option<String> {
|
||||
fn save_format_of(f: &str) -> std::result::Result<&'static Format, String> {
|
||||
match format(f) {
|
||||
Some(f) if SAVE_FORMATS.contains(&f.id) => Ok(f),
|
||||
Some(f) if TEMPLATE_FORMATS.contains(&f.id) => {
|
||||
Err(format!("Save writes {}, not {}: use file.saveAsTemplate to write it", SAVE_FORMATS.join(", "), f.label))
|
||||
}
|
||||
Some(f) => Err(format!("Save writes {}, not {}: use document.export for other formats", SAVE_FORMATS.join(", "), f.label)),
|
||||
None => Err(format!("unknown format `{f}` (Save writes {})", SAVE_FORMATS.join(", "))),
|
||||
}
|
||||
@@ -196,6 +199,18 @@ pub fn save_format(format: Option<&str>, path: Option<&str>) -> std::result::Res
|
||||
save_format_of(format.or_else(|| path.and_then(format_for_name).map(|f| f.id)).unwrap_or("vectorcraft"))
|
||||
}
|
||||
|
||||
/// `path` for a template in format `f`: kept when its extension names `f`, with the extension
|
||||
/// replaced when it names another format (`card.png` → `card.vctemplate`), else with `f`'s
|
||||
/// extension added (`card`, `card v1.2`).
|
||||
fn template_path(path: &str, f: &Format) -> String {
|
||||
let ext = f.extensions[0];
|
||||
match format_for_name(path) {
|
||||
Some(g) if g.id == f.id => path.to_string(),
|
||||
Some(_) => std::path::Path::new(path).with_extension(ext).to_string_lossy().into_owned(),
|
||||
None => format!("{path}.{ext}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`save_format_of`] for command `cmd`.
|
||||
fn checked(cmd: &str, f: &str) -> Result<&'static Format> {
|
||||
save_format_of(f).map_err(|e| bad(cmd, e))
|
||||
@@ -217,6 +232,7 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result<SavePlan> {
|
||||
(_, None, Some(f)) => checked(cmd, f.id)?,
|
||||
(_, None, None) => checked(cmd, st.format)?,
|
||||
};
|
||||
let path = if mode == SaveMode::Template { path.map(|p| template_path(&p, format)) } else { path };
|
||||
let mut given: Map<String, Value> = match p.get("options") {
|
||||
Some(Value::Object(o)) => o.iter().filter(|(k, _)| reads_option(format, k)).map(|(k, v)| (k.clone(), v.clone())).collect(),
|
||||
None | Some(Value::Null) => Map::new(),
|
||||
@@ -280,17 +296,18 @@ fn is_svg(f: &Format) -> bool {
|
||||
}
|
||||
|
||||
/// The document as written: native files carry the view to reopen at and the Layers panel's open
|
||||
/// rows.
|
||||
/// rows and transparency grid visibility.
|
||||
fn doc_to_save(st: &DocState, f: &Format) -> Arc<Document> {
|
||||
if !is_native(f) {
|
||||
return st.doc.clone();
|
||||
}
|
||||
let open = st.layers_open.saved(&st.doc);
|
||||
if st.doc.last_view == st.view && st.doc.layers_open == open {
|
||||
if st.doc.last_view == st.view && st.doc.layers_open == open && st.doc.transparency_grid == st.transparency_grid {
|
||||
return st.doc.clone();
|
||||
}
|
||||
let mut d = (*st.doc).clone();
|
||||
d.last_view = st.view.clone();
|
||||
d.transparency_grid = st.transparency_grid;
|
||||
d.layers_open = open;
|
||||
Arc::new(d)
|
||||
}
|
||||
@@ -631,7 +648,8 @@ impl SaveJob {
|
||||
|
||||
/// Once written: Save and Save As (except to a template) make the file `st`'s own: path,
|
||||
/// title, format, options, and the snapshot as the saved state (edits made since the job was
|
||||
/// made keep the document modified).
|
||||
/// made keep the document modified; changes that are not edits to save don't,
|
||||
/// [`DocState::same_content`]).
|
||||
pub fn finish(self, st: &mut DocState) {
|
||||
let retargets = self.plan.retargets();
|
||||
if let (true, Some(path)) = (retargets, self.plan.path) {
|
||||
@@ -643,6 +661,12 @@ impl SaveJob {
|
||||
if let Some(at) = self.stamped {
|
||||
if Arc::ptr_eq(&st.doc, &self.source) {
|
||||
st.doc = self.snapshot.clone();
|
||||
} else if st.same_content(&self.source) {
|
||||
// Changed while the save ran only by changes that are not edits to save
|
||||
// (Puppet Warp pins dropped, Edit Text ended, type laid out again after a font
|
||||
// scan): with the dates, the document counts as saved.
|
||||
stamp_save_dates(st, at);
|
||||
return st.mark_saved();
|
||||
} else {
|
||||
// Edited while the save ran: the dates still go on, the edits stay unsaved. The
|
||||
// undo history holds the document as it was before the dates, so that is the
|
||||
@@ -755,7 +779,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
|
||||
"Save As…",
|
||||
["File"],
|
||||
Some("Cmd+Shift+S"),
|
||||
"{…as document.save} {path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own), options?, svg?, modified?} the document takes on the new path, name and format (except a template, which is always a copy) → {path, format, bytes, warnings}; no path → {dataBase64, format, name, folder?, warnings}",
|
||||
"{…as document.save} {path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own; ait, a .ait template, is written by file.saveAsTemplate), options?, svg?, modified?} the document takes on the new path, name and format (except a template, which is always a copy) → {path, format, bytes, warnings}; no path → {dataBase64, format, name, folder?, warnings}",
|
||||
has_doc,
|
||||
|s, p| save(s, SaveMode::SaveAs, p)
|
||||
),
|
||||
@@ -773,7 +797,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
|
||||
"Save as Template…",
|
||||
["File"],
|
||||
None,
|
||||
"{…as document.save} {path?, format?: template (default) | ait (also when format is not given and the path ends in .ait), compress?, version?, preview?} a template that opens as a new untitled document: a native template (.vctemplate), or a PDF-compatible .ait written as a .ai file is (the .ai options and the PDF options, as document.save takes them for .ai); the document is unchanged → {path, format, bytes, warnings}; no path → {dataBase64, format, name: \"<name> template.<ext>\", folder: the Templates folder (preference templatesFolder), warnings}",
|
||||
"{…as document.save} {path? (saved with the format's extension: one naming another format is replaced, and one is added to a name without it), format?: template (default) | ait (also when format is not given and the path ends in .ait), compress?, version?, preview?} a template that opens as a new untitled document: a native template (.vctemplate), or a PDF-compatible .ait written as a .ai file is (the .ai options and the PDF options, as document.save takes them for .ai); the document is unchanged → {path, format, bytes, warnings}; no path → {dataBase64, format, name: \"<name> template.<ext>\", folder: the Templates folder (preference templatesFolder), warnings}",
|
||||
has_doc,
|
||||
|s, p| save(s, SaveMode::Template, p)
|
||||
),
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
//! File → Export → Export for Screens (`document.exportForScreens`): the chosen artboards (or the
|
||||
//! whole document, or the chosen assets of the Asset Export panel) in every format row at each
|
||||
//! row's size, into a folder and its sub-folders, or returned for download (each file, or one ZIP);
|
||||
//! the presets the dialog offers; and the settings the document remembers
|
||||
//! whole document, the chosen assets of the Asset Export panel, layers, or the selection) in every
|
||||
//! format row at each row's size, into a folder and its sub-folders, or returned for download (each
|
||||
//! file, or one ZIP); the presets the dialog offers; and the settings the document remembers
|
||||
//! (`document.exportSettings`), which the Asset Export panel shares ([`SHARED_KEYS`]).
|
||||
//!
|
||||
//! A batch export names every file from one pattern (`name`, see [`super::naming`]: the document's
|
||||
//! and the item's names, a counter, the date), and sorts the files into folders by row, by format,
|
||||
//! by item, or by item and format (`folders`).
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_doc::Document;
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
use super::super::*;
|
||||
use super::encode::single_artboard;
|
||||
use super::export::{isolated, without_artboards};
|
||||
use super::export::{isolated, selected_artwork, without_artboards};
|
||||
use super::naming::{self, NameParts};
|
||||
use super::{
|
||||
ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names, create_dir, encode, unique_file_names, with_black_output, writable, write_file,
|
||||
};
|
||||
@@ -143,9 +148,9 @@ fn safe_name(s: &str) -> Option<String> {
|
||||
(!n.trim_matches('.').is_empty()).then_some(n)
|
||||
}
|
||||
|
||||
/// Text that goes into a file name (prefix, suffix): path separators and `:` become `-`, so it can't
|
||||
/// start a folder or climb out of the export folder.
|
||||
fn file_part(s: &str) -> String {
|
||||
/// Text that goes into a file name (prefix, suffix, a data set's name in Batch): path separators and
|
||||
/// `:` become `-`, so the name cannot make a subfolder or point outside the folder the file is written to.
|
||||
pub(super) fn file_part(s: &str) -> String {
|
||||
s.chars().map(|c| if matches!(c, '/' | '\\' | ':') { '-' } else { c }).collect()
|
||||
}
|
||||
|
||||
@@ -161,6 +166,8 @@ struct Row {
|
||||
folder: String,
|
||||
/// The encoder options: the format's settings, then the row's own.
|
||||
options: Value,
|
||||
/// What makes two rows the same file: format, size, suffix and options.
|
||||
key: String,
|
||||
}
|
||||
|
||||
impl Row {
|
||||
@@ -169,8 +176,8 @@ impl Row {
|
||||
return Err(bad(C, "each format is an object {format, scale?, suffix?}"));
|
||||
}
|
||||
let format = writable(C, Some(str_param(row, "format").unwrap_or("png")), None)?;
|
||||
if matches!(format.id, "vectorcraft" | "template" | "ai" | "txt" | "dxf" | "eps" | "emf" | "wmf") {
|
||||
return Err(bad(C, "Export for Screens writes png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz or pdf"));
|
||||
if matches!(format.id, "template" | "txt" | "dxf" | "emf" | "wmf") {
|
||||
return Err(bad(C, "Export for Screens writes png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz, pdf, eps, ai or vectorcraft"));
|
||||
}
|
||||
// Vector formats have no pixel size: it doesn't apply and adds no @2x suffix (not even one
|
||||
// left over from a raster row switched to SVG or PDF).
|
||||
@@ -203,13 +210,21 @@ impl Row {
|
||||
let options = Value::Object(options);
|
||||
// A PDF preset (built-in or saved) becomes its settings.
|
||||
let options = if format.id == "pdf" { super::pdf::expand_preset(s, C, &options)?.into_owned() } else { options };
|
||||
Ok(Self { format, size, suffix, folder, options })
|
||||
// Two rows that would write the same bytes under the same name are one file.
|
||||
let key = format!("{}|{}|{suffix}|{options}", format.id, size.label());
|
||||
Ok(Self { format, size, suffix, folder, options, key })
|
||||
}
|
||||
|
||||
/// Formats that hold a whole document to edit (native, Illustrator, EPS): written from a
|
||||
/// document of the item alone, one artboard, rather than with an `artboard` option.
|
||||
fn whole_document(&self) -> bool {
|
||||
matches!(self.format.id, "vectorcraft" | "ai" | "eps")
|
||||
}
|
||||
|
||||
/// The encoder options for `region`, `artboard` of the document written.
|
||||
fn options_for(&self, region: Rect, artboard: Option<usize>) -> Result<Value> {
|
||||
let mut o = self.options.clone();
|
||||
if let Some(b) = artboard {
|
||||
if let Some(b) = artboard.filter(|_| !self.whole_document()) {
|
||||
o["artboard"] = json!(b);
|
||||
}
|
||||
if self.format.raster {
|
||||
@@ -247,6 +262,60 @@ struct ScreenParams {
|
||||
prefix: Option<String>,
|
||||
/// Asset ids (Asset Export) to write instead of artboards.
|
||||
assets: Option<Vec<u64>>,
|
||||
/// Layer ids to write instead of artboards: each layer's art alone.
|
||||
layers: Option<Vec<u64>>,
|
||||
/// The selection instead of artboards: `one` file of it all, or one for `each` object.
|
||||
selection: Option<String>,
|
||||
/// The file name pattern ([`naming`]); without it, `prefix` + name + suffix.
|
||||
name: Option<String>,
|
||||
/// The counter's first value (`####` in `name`).
|
||||
counter_start: Option<u64>,
|
||||
/// The counter counts `item`s (every file of an artboard has its number) or `file`s.
|
||||
counter_per: Option<String>,
|
||||
/// The date the pattern's date tokens use (`YYYY-MM-DD`); default today (UTC).
|
||||
date: Option<String>,
|
||||
/// Where files go: `none` (all in the folder), `rows` (the rows' sub-folders, as
|
||||
/// `subfolders`), `format` (a folder per extension), `item`, or `itemFormat`.
|
||||
folders: Option<String>,
|
||||
}
|
||||
|
||||
/// How exported files are sorted into folders.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Folders {
|
||||
None,
|
||||
Rows,
|
||||
Format,
|
||||
Item,
|
||||
ItemFormat,
|
||||
}
|
||||
|
||||
impl Folders {
|
||||
fn of(o: &ScreenParams) -> Result<Self> {
|
||||
Ok(match o.folders.as_deref() {
|
||||
None if o.subfolders || o.preset.is_some() => Self::Rows,
|
||||
None | Some("none" | "") => Self::None,
|
||||
Some("rows") => Self::Rows,
|
||||
Some("format") => Self::Format,
|
||||
Some("item") => Self::Item,
|
||||
Some("itemFormat") => Self::ItemFormat,
|
||||
Some(f) => return Err(bad(C, format!("folders `{f}`: none, rows, format, item or itemFormat"))),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// One thing a batch export writes a file of per row: an artboard, an asset, a layer, the
|
||||
/// selection or one selected object, or the whole document.
|
||||
struct Item {
|
||||
/// Its name as the user gave it (`<name>`).
|
||||
name: String,
|
||||
/// Its name made safe and unique among the items (the default file name, the item folder).
|
||||
stem: String,
|
||||
/// The document written: the whole document with `artboard`, or the item alone.
|
||||
doc: Arc<Document>,
|
||||
artboard: Option<usize>,
|
||||
region: Rect,
|
||||
/// Full Document: what a PDF row writes (every artboard of the document).
|
||||
every_artboard: Option<Arc<Document>>,
|
||||
}
|
||||
|
||||
impl ScreenParams {
|
||||
@@ -289,8 +358,9 @@ pub(crate) fn check_settings(s: &Session, p: &Value) -> Result<()> {
|
||||
/// The file names (and bytes) an export wrote, or the files it returns.
|
||||
#[derive(Default)]
|
||||
struct Output {
|
||||
/// Lower-case relative names already used (two identical rows write one file).
|
||||
taken: HashSet<String>,
|
||||
/// Lower-case relative names already used, with what wrote each (two identical rows write
|
||||
/// one file; different files that would share a name get `-2`, `-3`…).
|
||||
taken: std::collections::HashMap<String, String>,
|
||||
/// Sub-folders already made.
|
||||
made: HashSet<String>,
|
||||
/// `(relative name, bytes)` kept to return or zip (empty when written to a folder).
|
||||
@@ -300,9 +370,30 @@ struct Output {
|
||||
}
|
||||
|
||||
impl Output {
|
||||
/// Is `name` new (not written by an identical row already)?
|
||||
fn claim(&mut self, name: &str) -> bool {
|
||||
self.taken.insert(name.to_lowercase())
|
||||
/// The name the file `key` writes as `name` takes: `name`, else (with `number`) `name-2`,
|
||||
/// `name-3`… when another file has it; `None` when the same file (`key`) was written as `name`
|
||||
/// already, or, without `number`, when any file was (the first row wins, as Export for Screens
|
||||
/// always did when no name pattern is given).
|
||||
fn claim(&mut self, name: &str, key: &str, number: bool) -> Option<String> {
|
||||
let (base, ext) = name.rsplit_once('.').map_or((name, ""), |(b, e)| (b, e));
|
||||
for n in 1.. {
|
||||
let candidate = if n == 1 {
|
||||
name.to_string()
|
||||
} else if ext.is_empty() {
|
||||
format!("{base}-{n}")
|
||||
} else {
|
||||
format!("{base}-{n}.{ext}")
|
||||
};
|
||||
match self.taken.get(&candidate.to_lowercase()) {
|
||||
Some(k) if k == key || !number => return None,
|
||||
Some(_) => continue,
|
||||
None => {
|
||||
self.taken.insert(candidate.to_lowercase(), key.to_string());
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Write `bytes` as `name` (relative) under `folder`, or keep them.
|
||||
@@ -324,32 +415,166 @@ impl Output {
|
||||
}
|
||||
}
|
||||
|
||||
/// File → Export for Screens: [`export`], with the params remembered in the document
|
||||
/// (`document.exportSettings`).
|
||||
/// File → Export for Screens: [`export`] (over the settings of `exportPreset`, when given), with
|
||||
/// the params remembered in the document (`document.exportSettings`).
|
||||
pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let r = export(s, p)?;
|
||||
remember(s, p);
|
||||
let p = with_preset(s, p)?;
|
||||
let r = export(s, &p)?;
|
||||
remember(s, &p);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Every chosen artboard in every format row, one file each (a PDF holds its artboard alone), or
|
||||
/// with `fullDocument` one file per row (a PDF of every artboard, else the bounds of all art), or
|
||||
/// with `assets` each asset's art alone, cropped to it. Artboards (assets) that share a name get
|
||||
/// `-2`, `-3`… instead of overwriting.
|
||||
// ---------- export presets ----------
|
||||
|
||||
/// A saved batch export: what to write and how to name and sort it, to use again
|
||||
/// (`document.exportPresets.*`, `exportPreset`). Kept with the preferences.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ScreenExportPreset {
|
||||
pub name: String,
|
||||
/// The [`PRESET_KEYS`] of `document.exportForScreens` params.
|
||||
pub settings: Map<String, Value>,
|
||||
}
|
||||
|
||||
/// What a preset keeps: the formats, their settings, the naming and the folders; not which art
|
||||
/// (it changes with the document) nor where to (unless given).
|
||||
pub const PRESET_KEYS: [&str; 13] = [
|
||||
"formats",
|
||||
"settings",
|
||||
"preset",
|
||||
"name",
|
||||
"prefix",
|
||||
"counterStart",
|
||||
"counterPer",
|
||||
"folders",
|
||||
"subfolders",
|
||||
"includeBleed",
|
||||
"antiAlias",
|
||||
"folder",
|
||||
"fullDocument",
|
||||
];
|
||||
|
||||
/// The built-in export presets: (name, settings).
|
||||
pub fn builtin_presets() -> Vec<ScreenExportPreset> {
|
||||
let marks = json!({"trim": true, "registration": true, "colorBars": true, "pageInfo": true});
|
||||
let preset = |name: &str, settings: Value| ScreenExportPreset { name: name.into(), settings: settings.as_object().cloned().unwrap_or_default() };
|
||||
vec![
|
||||
preset(
|
||||
"Logo Package",
|
||||
json!({
|
||||
"name": "<filename>-<name>",
|
||||
"folders": "format",
|
||||
"formats": [
|
||||
{"format": "svg"},
|
||||
{"format": "png", "scale": "1x", "suffix": ""},
|
||||
{"format": "png", "scale": "2x", "suffix": "@2x"},
|
||||
{"format": "pdf"},
|
||||
{"format": "pdf", "suffix": "-print", "marks": marks},
|
||||
],
|
||||
}),
|
||||
),
|
||||
preset("Print-Ready PDF", json!({"formats": [{"format": "pdf", "suffix": "-print", "marks": marks, "bleed": {"useDocument": true}}]})),
|
||||
]
|
||||
}
|
||||
|
||||
/// The preset called `name`: a saved one, else a built-in one.
|
||||
fn find_preset(s: &Session, name: &str) -> Option<ScreenExportPreset> {
|
||||
s.prefs.screen_export_presets.iter().find(|p| p.name == name).cloned().or_else(|| builtin_presets().into_iter().find(|p| p.name == name))
|
||||
}
|
||||
|
||||
/// `p` over the settings of its `exportPreset`, when it names one (its own keys win).
|
||||
fn with_preset(s: &Session, p: &Value) -> Result<Value> {
|
||||
let Some(name) = str_param(p, "exportPreset") else { return Ok(p.clone()) };
|
||||
let preset = find_preset(s, name).ok_or_else(|| bad(C, format!("no export preset `{name}` (see document.exportPresets.list)")))?;
|
||||
let mut merged = preset.settings;
|
||||
if let Some(o) = p.as_object() {
|
||||
merged.extend(o.iter().filter(|(k, _)| k.as_str() != "exportPreset").map(|(k, v)| (k.clone(), v.clone())));
|
||||
}
|
||||
Ok(Value::Object(merged))
|
||||
}
|
||||
|
||||
pub(super) fn presets_list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let row = |p: &ScreenExportPreset, built_in: bool| json!({"name": p.name, "builtIn": built_in, "settings": p.settings});
|
||||
let rows: Vec<Value> =
|
||||
builtin_presets().iter().map(|p| row(p, true)).chain(s.prefs.screen_export_presets.iter().map(|p| row(p, false))).collect();
|
||||
Ok(json!({ "presets": rows }))
|
||||
}
|
||||
|
||||
pub(super) fn presets_save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const SAVE: &str = "document.exportPresets.save";
|
||||
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(SAVE, "give the preset a name"))?.to_string();
|
||||
if builtin_presets().iter().any(|b| b.name == name) {
|
||||
return Err(bad(SAVE, format!("`{name}` is a built-in preset: save under another name")));
|
||||
}
|
||||
let given = p.get("settings").and_then(Value::as_object).ok_or_else(|| bad(SAVE, "settings is an object of document.exportForScreens params"))?;
|
||||
let settings: Map<String, Value> = given.iter().filter(|(k, _)| PRESET_KEYS.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone())).collect();
|
||||
// Refuse what an export would refuse, now rather than when the preset is used.
|
||||
check_settings(s, &Value::Object(settings.clone())).map_err(|e| bad(SAVE, e.to_string()))?;
|
||||
Folders::of(&ScreenParams::of(&Value::Object(settings.clone()))?).map_err(|e| bad(SAVE, e.to_string()))?;
|
||||
let preset = ScreenExportPreset { name: name.clone(), settings };
|
||||
let created = match s.prefs.screen_export_presets.iter_mut().find(|q| q.name == name) {
|
||||
Some(slot) => {
|
||||
*slot = preset.clone();
|
||||
false
|
||||
}
|
||||
None => {
|
||||
s.prefs.screen_export_presets.push(preset.clone());
|
||||
true
|
||||
}
|
||||
};
|
||||
Ok(json!({ "name": name, "created": created, "settings": preset.settings }))
|
||||
}
|
||||
|
||||
pub(super) fn presets_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const DELETE: &str = "document.exportPresets.delete";
|
||||
let name = str_param(p, "name").ok_or_else(|| bad(DELETE, "missing `name`"))?;
|
||||
if builtin_presets().iter().any(|b| b.name == name) {
|
||||
return Err(bad(DELETE, format!("`{name}` is built in and stays")));
|
||||
}
|
||||
let before = s.prefs.screen_export_presets.len();
|
||||
s.prefs.screen_export_presets.retain(|q| q.name != name);
|
||||
if s.prefs.screen_export_presets.len() == before {
|
||||
return Err(bad(DELETE, format!("no saved export preset `{name}`")));
|
||||
}
|
||||
Ok(json!({ "deleted": name }))
|
||||
}
|
||||
|
||||
/// Every chosen artboard (asset, layer, selected object) in every format row, one file each (a PDF
|
||||
/// holds its artboard alone), or with `fullDocument` one file per row (a PDF of every artboard,
|
||||
/// else the bounds of all art). Items that share a name get `-2`, `-3`… instead of overwriting, and
|
||||
/// so do different files the `name` pattern gives one name.
|
||||
pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let o = ScreenParams::of(p)?;
|
||||
let rows = rows_of(s, p, o.preset.as_deref())?;
|
||||
if o.full_document && o.assets.is_some() {
|
||||
return Err(bad(C, "give assets or fullDocument, not both"));
|
||||
let sources = [o.full_document, o.assets.is_some(), o.layers.is_some(), o.selection.is_some()].iter().filter(|b| **b).count();
|
||||
if sources > 1 {
|
||||
return Err(bad(C, "give one of assets, layers, selection or fullDocument"));
|
||||
}
|
||||
let subfolders = o.subfolders || o.preset.is_some();
|
||||
let folders = Folders::of(&o)?;
|
||||
// A prefix names files, not folders.
|
||||
let prefix = file_part(o.prefix.as_deref().unwrap_or(""));
|
||||
let folder = o.folder.as_deref().filter(|f| !f.is_empty());
|
||||
let when = match o.date.as_deref() {
|
||||
Some(d) => naming::parse_date(d).ok_or_else(|| bad(C, format!("date `{d}`: YYYY-MM-DD")))?,
|
||||
None => vectorcraft_doc::metadata::now_unix().map_or([1970, 1, 1, 0, 0, 0], vectorcraft_doc::metadata::civil),
|
||||
};
|
||||
let per_file = match o.counter_per.as_deref() {
|
||||
None | Some("item") => false,
|
||||
Some("file") => true,
|
||||
Some(c) => return Err(bad(C, format!("counterPer `{c}`: item or file"))),
|
||||
};
|
||||
let counter_start = o.counter_start.unwrap_or(1);
|
||||
let selection = match o.selection.as_deref() {
|
||||
None => None,
|
||||
Some("one") => Some(false),
|
||||
Some("each") => Some(true),
|
||||
Some(m) => return Err(bad(C, format!("selection `{m}`: one or each"))),
|
||||
};
|
||||
let selected = if selection.is_some() { selected_artwork(s)? } else { vec![] };
|
||||
if let Some(dir) = folder {
|
||||
create_dir(dir)?;
|
||||
}
|
||||
let doc = s.doc()?.doc.clone();
|
||||
let filename = safe_name(&super::file_stem(&doc.title)).unwrap_or_else(|| "Untitled".into());
|
||||
// Include Bleed: each artboard grown by the document's bleed.
|
||||
let doc = if o.include_bleed && doc.setup.has_bleed() {
|
||||
let mut d = (*doc).clone();
|
||||
@@ -360,79 +585,66 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} else {
|
||||
doc
|
||||
};
|
||||
let name_of = |r: &Row, stem: &str| {
|
||||
let file = format!("{prefix}{stem}{}.{}", r.suffix, r.format.extensions[0]);
|
||||
if subfolders { format!("{}/{file}", r.folder) } else { file }
|
||||
};
|
||||
let crop = rows.iter().any(|r| r.format.id != "pdf");
|
||||
let items = items(&doc, &o, selection, &selected, &filename, crop)?;
|
||||
let mut out = Output::default();
|
||||
// Appearance of Black › Printing / Exporting for RGB raster rows.
|
||||
let write = |doc: &Document, format: &str, options: &Value| encode(doc, format, &with_black_output(s, options));
|
||||
if o.full_document {
|
||||
let stem = safe_name(&super::file_stem(&doc.title)).unwrap_or_else(|| "Untitled".into());
|
||||
// The bounds of all visible art, as the one artboard of the files that aren't PDFs.
|
||||
let mut whole: Option<Document> = None;
|
||||
// A whole-document copy of an artboard, for the formats that write documents to edit.
|
||||
let mut board_docs: std::collections::HashMap<usize, Arc<Document>> = std::collections::HashMap::new();
|
||||
let mut files = 0u64;
|
||||
for (index, item) in items.iter().enumerate() {
|
||||
for r in &rows {
|
||||
let name = name_of(r, &stem);
|
||||
if !out.claim(&name) {
|
||||
continue;
|
||||
}
|
||||
let bytes = if r.format.id == "pdf" {
|
||||
let counter = counter_start.saturating_add(if per_file { files } else { index as u64 });
|
||||
let ext = r.format.extensions.first().copied().unwrap_or(r.format.id);
|
||||
let stem = match o.name.as_deref().filter(|n| !n.trim().is_empty()) {
|
||||
Some(pattern) => {
|
||||
let parts = NameParts {
|
||||
filename: &filename,
|
||||
name: &item.name,
|
||||
index: index + 1,
|
||||
counter,
|
||||
format: ext,
|
||||
scale: &if r.format.raster { r.size.label() } else { String::new() },
|
||||
suffix: &r.suffix,
|
||||
when,
|
||||
};
|
||||
let named = naming::render(pattern, &parts);
|
||||
let named = if named.is_empty() { item.stem.clone() } else { named };
|
||||
if naming::has_suffix_token(pattern) { format!("{prefix}{named}") } else { format!("{prefix}{named}{}", r.suffix) }
|
||||
}
|
||||
None => format!("{prefix}{}{}", item.stem, r.suffix),
|
||||
};
|
||||
let file = format!("{stem}.{ext}");
|
||||
let sub = match folders {
|
||||
Folders::None => None,
|
||||
Folders::Rows => Some(r.folder.clone()),
|
||||
Folders::Format => Some(ext.to_string()),
|
||||
Folders::Item => Some(item.stem.clone()),
|
||||
Folders::ItemFormat => Some(format!("{}/{ext}", item.stem)),
|
||||
};
|
||||
let name = sub.map_or_else(|| file.clone(), |d| format!("{d}/{file}"));
|
||||
let Some(name) = out.claim(&name, &format!("{}|{}", item.stem, r.key), o.name.is_some()) else { continue };
|
||||
let bytes = if let (Some(all), "pdf") = (&item.every_artboard, r.format.id) {
|
||||
// One page per artboard.
|
||||
let mut options = r.options.clone();
|
||||
options["range"] = json!("all");
|
||||
write(&doc, "pdf", &options)?
|
||||
write(all, "pdf", &options)?
|
||||
} else if r.whole_document() && item.artboard.is_some() {
|
||||
let b = item.artboard.unwrap_or(0);
|
||||
let d = board_docs.entry(b).or_insert_with(|| Arc::new(single_artboard(&item.doc, item.region, &item.name))).clone();
|
||||
write(&d, r.format.id, &r.options_for(item.region, None)?)?
|
||||
} else {
|
||||
if whole.is_none() {
|
||||
let bounds =
|
||||
vectorcraft_render::encode::art_bounds(&doc).ok_or_else(|| bad(C, "nothing to export: the document has no visible art"))?;
|
||||
whole = Some(single_artboard(&doc, bounds, "Document"));
|
||||
}
|
||||
let Some(d) = whole.as_ref() else { continue };
|
||||
let region = d.artboards.first().map_or(Rect::ZERO, |a| a.rect);
|
||||
write(d, r.format.id, &r.options_for(region, Some(0))?)?
|
||||
write(&item.doc, r.format.id, &r.options_for(item.region, item.artboard)?)?
|
||||
};
|
||||
out.put(folder, o.zip, name, bytes)?;
|
||||
}
|
||||
} else if let Some(ids) = &o.assets {
|
||||
let assets =
|
||||
ids.iter().map(|id| doc.asset(*id).ok_or_else(|| bad(C, format!("no asset {id} (see assets.list)")))).collect::<Result<Vec<_>>>()?;
|
||||
if assets.is_empty() {
|
||||
return Err(bad(C, "no assets to export"));
|
||||
}
|
||||
for (a, stem) in assets.iter().zip(unique_file_names(assets.iter().map(|a| (a.name.as_str(), "Asset".to_string())))) {
|
||||
let (d, bounds) = isolated(&doc, &doc.paint_order(a.nodes.iter().copied()), &a.name)
|
||||
.ok_or_else(|| bad(C, format!("asset `{}` has no art to export", a.name)))?;
|
||||
for r in &rows {
|
||||
let name = name_of(r, &stem);
|
||||
if !out.claim(&name) {
|
||||
continue;
|
||||
}
|
||||
let bytes = write(&d, r.format.id, &r.options_for(bounds, Some(0))?)?;
|
||||
out.put(folder, o.zip, name, bytes)?;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let n = doc.artboards.len();
|
||||
let boards = o.boards.resolve(n).map_err(|e| bad(C, e))?.unwrap_or_else(|| (0..n).collect());
|
||||
if boards.is_empty() {
|
||||
return Err(bad(C, "the document has no artboard"));
|
||||
}
|
||||
for (b, stem) in boards.iter().copied().zip(artboard_file_names(&doc, &boards)) {
|
||||
let region = doc.artboards.get(b).map_or(Rect::ZERO, |a| a.rect);
|
||||
for r in &rows {
|
||||
let name = name_of(r, &stem);
|
||||
if !out.claim(&name) {
|
||||
continue; // the same file from two identical rows
|
||||
}
|
||||
let bytes = write(&doc, r.format.id, &r.options_for(region, Some(b))?)?;
|
||||
out.put(folder, o.zip, name, bytes)?;
|
||||
}
|
||||
files += 1;
|
||||
}
|
||||
}
|
||||
let names: Vec<&str> = out.kept.iter().map(|(n, _)| n.as_str()).collect();
|
||||
if o.zip {
|
||||
let zip = super::zip::store(&out.kept).map_err(|e| bad(C, e))?;
|
||||
let zip_name = format!("{prefix}{}.zip", safe_name(&super::file_stem(&doc.title)).unwrap_or_else(|| "Untitled".into()));
|
||||
let zip_name = format!("{prefix}{filename}.zip");
|
||||
return match folder {
|
||||
Some(dir) => {
|
||||
let path = format!("{dir}/{zip_name}");
|
||||
@@ -450,6 +662,98 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// What `o` exports a file of per row ([`Item`]): the whole document, the assets, the layers, the
|
||||
/// selection (one item, or one per object), else the chosen artboards.
|
||||
/// `crop`: some row needs the Full Document's art bounds (a format other than PDF).
|
||||
fn items(doc: &Arc<Document>, o: &ScreenParams, selection: Option<bool>, selected: &[NodeId], filename: &str, crop: bool) -> Result<Vec<Item>> {
|
||||
// The art of `ids` alone, as an item named `name`.
|
||||
let alone = |ids: &[NodeId], name: &str| -> Option<(Arc<Document>, Rect)> {
|
||||
isolated(doc, &doc.paint_order(ids.iter().copied()), name).map(|(d, r)| (Arc::new(d), r))
|
||||
};
|
||||
let named = |pieces: Vec<(String, Arc<Document>, Option<usize>, Rect)>, fallback: &str| -> Vec<Item> {
|
||||
let stems = unique_file_names(pieces.iter().map(|(n, ..)| (n.as_str(), fallback.to_string())));
|
||||
pieces
|
||||
.into_iter()
|
||||
.zip(stems)
|
||||
.map(|((name, doc, artboard, region), stem)| Item { name, stem, doc, artboard, region, every_artboard: None })
|
||||
.collect()
|
||||
};
|
||||
if o.full_document {
|
||||
// The bounds of all visible art, as the one artboard of the files that aren't PDFs (a PDF
|
||||
// row writes every artboard of the document itself).
|
||||
let (whole, region) = if crop {
|
||||
let bounds = vectorcraft_render::encode::art_bounds(doc).ok_or_else(|| bad(C, "nothing to export: the document has no visible art"))?;
|
||||
(Arc::new(single_artboard(doc, bounds, "Document")), bounds)
|
||||
} else {
|
||||
(doc.clone(), Rect::ZERO)
|
||||
};
|
||||
let name = filename.to_string();
|
||||
return Ok(vec![Item { name: name.clone(), stem: name, doc: whole, artboard: Some(0), region, every_artboard: Some(doc.clone()) }]);
|
||||
}
|
||||
if let Some(ids) = &o.assets {
|
||||
let mut pieces = vec![];
|
||||
for id in ids {
|
||||
let a = doc.asset(*id).ok_or_else(|| bad(C, format!("no asset {id} (see assets.list)")))?;
|
||||
let (d, r) = alone(&a.nodes, &a.name).ok_or_else(|| bad(C, format!("asset `{}` has no art to export", a.name)))?;
|
||||
pieces.push((a.name.clone(), d, Some(0), r));
|
||||
}
|
||||
if pieces.is_empty() {
|
||||
return Err(bad(C, "no assets to export"));
|
||||
}
|
||||
return Ok(named(pieces, "Asset"));
|
||||
}
|
||||
if let Some(ids) = &o.layers {
|
||||
let mut pieces = vec![];
|
||||
for id in ids {
|
||||
let layer = doc.node(NodeId(*id)).filter(|n| n.is_layer()).ok_or_else(|| bad(C, format!("no layer {id} (see layer.list)")))?;
|
||||
let mut art = vec![];
|
||||
crate::cmd::assets::art_under(doc, layer.id, &mut art);
|
||||
let name = layer.display_name();
|
||||
let (d, r) = alone(&art, &name).ok_or_else(|| bad(C, format!("layer `{name}` has no art to export")))?;
|
||||
pieces.push((name, d, Some(0), r));
|
||||
}
|
||||
if pieces.is_empty() {
|
||||
return Err(bad(C, "no layers to export"));
|
||||
}
|
||||
return Ok(named(pieces, "Layer"));
|
||||
}
|
||||
if let Some(each) = selection {
|
||||
if selected.is_empty() {
|
||||
return Err(bad(C, "select something to export"));
|
||||
}
|
||||
let mut pieces = vec![];
|
||||
if each {
|
||||
for id in selected {
|
||||
let name = doc.node(*id).map(|n| n.display_name()).filter(|n| !n.is_empty()).unwrap_or_else(|| "Selection".into());
|
||||
if let Some((d, r)) = alone(&[*id], &name) {
|
||||
pieces.push((name, d, Some(0), r));
|
||||
}
|
||||
}
|
||||
} else if let Some((d, r)) = alone(selected, "Selection") {
|
||||
pieces.push(("Selection".to_string(), d, Some(0), r));
|
||||
}
|
||||
if pieces.is_empty() {
|
||||
return Err(bad(C, "the selection has no art to export"));
|
||||
}
|
||||
return Ok(named(pieces, "Selection"));
|
||||
}
|
||||
let n = doc.artboards.len();
|
||||
let boards = o.boards.resolve(n).map_err(|e| bad(C, e))?.unwrap_or_else(|| (0..n).collect());
|
||||
if boards.is_empty() {
|
||||
return Err(bad(C, "the document has no artboard"));
|
||||
}
|
||||
let stems = artboard_file_names(doc, &boards);
|
||||
Ok(boards
|
||||
.into_iter()
|
||||
.zip(stems)
|
||||
.map(|(b, stem)| {
|
||||
let a = doc.artboards.get(b);
|
||||
let name = a.map(|a| a.name.clone()).filter(|n| !n.is_empty()).unwrap_or_else(|| stem.clone());
|
||||
Item { name, stem, doc: doc.clone(), artboard: Some(b), region: a.map_or(Rect::ZERO, |a| a.rect), every_artboard: None }
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Keep the params of a successful export in the document (without `zip`, a delivery detail, and
|
||||
/// `assets`, a choice of art), so the dialog reopens on them.
|
||||
fn remember(s: &mut Session, p: &Value) {
|
||||
|
||||
@@ -157,6 +157,10 @@ pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result<Enco
|
||||
// that preparation out of the native attachment so missing links remain relinkable.
|
||||
let (full, warnings) = crate::cmd::place::document::full_documents(doc);
|
||||
let doc = &*full;
|
||||
// A chosen artboard's file holds the art over it; otherwise the file holds all of it.
|
||||
let regions: Option<Vec<vectorcraft_geom::Rect>> =
|
||||
chosen.then(|| boards.iter().flatten().filter_map(|b| doc.artboards.get(*b)).map(|a| a.rect).collect());
|
||||
let overflow = crate::cmd::fonts::export_overflow_warning(doc, &doc.layers, regions.as_deref());
|
||||
// SVG has no filters for the Photoshop-style effects: their objects go in as images.
|
||||
let flat = crate::cmd::rasterfx::flatten_pixel_effects(doc);
|
||||
let doc = flat.as_ref().unwrap_or(doc);
|
||||
@@ -184,5 +188,6 @@ pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result<Enco
|
||||
{
|
||||
enc.warnings.push(w);
|
||||
}
|
||||
enc.warnings.extend(overflow);
|
||||
Ok(enc)
|
||||
}
|
||||
@@ -114,6 +114,100 @@ fn cmyk_tiffs_keep_their_inks_and_other_tiffs_become_png() {
|
||||
assert_eq!(rgb.blob.mime, "image/png");
|
||||
}
|
||||
|
||||
/// A 3 × 2 TIFF at 150 ppi of `channels` (`extra`: their ExtraSamples values), 8 or 16 `bits` a
|
||||
/// sample, chunky or `planar`.
|
||||
fn hand_tiff(photometric: u16, bits: u16, extra: &[u16], planar: bool, channels: &[Vec<u16>]) -> Vec<u8> {
|
||||
let t = vectorcraft_testkit::tiff::Tiff { width: 3, height: 2, photometric, bits, extra, planar, ppi: Some(150), channels };
|
||||
vectorcraft_testkit::tiff::tiff(&t)
|
||||
}
|
||||
|
||||
/// What Place keeps of TIFF `bytes`: the ink amounts, when it keeps them, and the pixels any image
|
||||
/// reader decodes from the stored image, as RGBA.
|
||||
fn kept(what: &str, bytes: &[u8]) -> (Option<Vec<u8>>, Vec<u8>) {
|
||||
let r = raster_image(bytes).unwrap_or_else(|e| panic!("{what}: {e}"));
|
||||
assert_eq!((r.width, r.height, r.ppi), (3, 2, Some((150.0, 150.0))), "{what}");
|
||||
assert_eq!(super::ppi::resolution(&r.blob.bytes), Some((150.0, 150.0)), "{what}: the stored image keeps the resolution");
|
||||
let rgba = image::load_from_memory(&r.blob.bytes).unwrap_or_else(|e| panic!("{what}: {e}")).to_rgba8().into_raw();
|
||||
(r.blob.cmyk().map(|i| i.data), rgba)
|
||||
}
|
||||
|
||||
/// TIFFs that store one plane per channel (PlanarConfiguration 2) read as their chunky versions do:
|
||||
/// grayscale and RGB with extra channels, and CMYK with and without them (#1466).
|
||||
#[test]
|
||||
fn planar_tiffs_read_as_their_chunky_versions() {
|
||||
let ch = |v: [u16; 6]| v.to_vec();
|
||||
let (gray, alpha, spot) = (ch([0, 51, 102, 153, 204, 255]), ch([255, 128, 0, 255, 64, 255]), ch([9, 8, 7, 6, 5, 4]));
|
||||
let (r, g, b) = (ch([255, 0, 0, 10, 20, 30]), ch([0, 255, 0, 40, 50, 60]), ch([0, 0, 255, 70, 80, 90]));
|
||||
let (c, m, y, k) = (ch([255, 0, 0, 0, 12, 200]), ch([0, 255, 0, 0, 34, 100]), ch([0, 0, 255, 0, 56, 50]), ch([0, 0, 0, 255, 78, 0]));
|
||||
// Pixel by pixel, the samples of `px`.
|
||||
let interleave = |px: &[&Vec<u16>]| -> Vec<u8> { (0..6).flat_map(|i| px.iter().map(move |v| v[i] as u8)).collect() };
|
||||
let opaque = ch([255; 6]);
|
||||
let inks = interleave(&[&c, &m, &y, &k]);
|
||||
// (photometric, ExtraSamples, channels, the RGBA pixels when the color is gray or RGB, the inks when it is CMYK)
|
||||
type Case = (u16, Vec<u16>, Vec<Vec<u16>>, Option<Vec<u8>>, Option<Vec<u8>>);
|
||||
let cases: Vec<Case> = vec![
|
||||
(1, vec![2], vec![gray.clone(), alpha.clone()], Some(interleave(&[&gray, &gray, &gray, &alpha])), None),
|
||||
(1, vec![2, 0], vec![gray.clone(), alpha.clone(), spot.clone()], Some(interleave(&[&gray, &gray, &gray, &alpha])), None),
|
||||
(2, vec![0], vec![r.clone(), g.clone(), b.clone(), spot.clone()], Some(interleave(&[&r, &g, &b, &opaque])), None),
|
||||
(2, vec![2, 0], vec![r.clone(), g.clone(), b.clone(), alpha.clone(), spot.clone()], Some(interleave(&[&r, &g, &b, &alpha])), None),
|
||||
(5, vec![], vec![c.clone(), m.clone(), y.clone(), k.clone()], None, Some(inks.clone())),
|
||||
(5, vec![0], vec![c.clone(), m.clone(), y.clone(), k.clone(), spot.clone()], None, Some(inks.clone())),
|
||||
(5, vec![2], vec![c.clone(), m.clone(), y.clone(), k.clone(), alpha.clone()], None, None),
|
||||
];
|
||||
for (photometric, extra, channels, want_rgba, want_inks) in cases {
|
||||
for bits in [8, 16] {
|
||||
let wide: Vec<Vec<u16>> = channels.iter().map(|ch| ch.iter().map(|v| if bits == 16 { v * 257 } else { *v }).collect()).collect();
|
||||
let what = format!("photometric {photometric}, extra samples {extra:?}, {bits} bits");
|
||||
let chunky = kept(&format!("{what}, chunky"), &hand_tiff(photometric, bits, &extra, false, &wide));
|
||||
let planar = kept(&format!("{what}, planar"), &hand_tiff(photometric, bits, &extra, true, &wide));
|
||||
assert_eq!(planar.0, chunky.0, "{what}: planar keeps the inks chunky keeps");
|
||||
assert_eq!(planar.0, want_inks, "{what}: the inks");
|
||||
// A 16-bit chunky CMYK file is stored as it is, and a planar one is written again with
|
||||
// its 8-bit inks: their RGB pixels may be a level apart.
|
||||
let close = planar.1.len() == chunky.1.len() && planar.1.iter().zip(&chunky.1).all(|(a, b)| a.abs_diff(*b) <= 1);
|
||||
assert!(close, "{what}: planar reads as chunky: {:?} against {:?}", planar.1, chunky.1);
|
||||
if let Some(want) = &want_rgba {
|
||||
assert_eq!(&planar.1, want, "{what}: the pixels");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A planar TIFF whose header claims more strips than the decoder can count (here 2^31 + 1 rows
|
||||
/// a strip each, times 5 planes) is refused as too large to draw, without a panic.
|
||||
#[test]
|
||||
fn planar_tiffs_with_more_strips_than_can_be_counted_are_too_large() {
|
||||
let plane = |v: u16| vec![v; 6];
|
||||
let mut file = hand_tiff(5, 8, &[0], true, &[plane(200), plane(40), plane(10), plane(0), plane(90)]);
|
||||
// ImageLength (the directory's second entry, its value at offset 30) and RowsPerStrip (the
|
||||
// eighth, at offset 102).
|
||||
file[30..34].copy_from_slice(&(1u32 << 31 | 1).to_le_bytes());
|
||||
file[102..106].copy_from_slice(&1u32.to_le_bytes());
|
||||
let e = raster_image(&file).err().expect("refused");
|
||||
assert!(e.to_string().contains("an image of 3 × 2147483649 pixels is too large to draw"), "{e}");
|
||||
assert!(too_large_note(&file).is_some());
|
||||
assert_eq!(vectorcraft_doc::cmyk::oversized_tiff(&file), Some((3, 1 << 31 | 1)));
|
||||
}
|
||||
|
||||
/// A planar CMYK TIFF opens and places with its ink amounts, stored as a TIFF the image decoder
|
||||
/// reads (#1466).
|
||||
#[test]
|
||||
fn planar_cmyk_tiffs_open_and_place_with_their_inks() {
|
||||
let inks = [vec![255u16, 0, 12, 0, 40, 9], vec![0, 255, 34, 0, 50, 8], vec![0, 0, 56, 0, 60, 7], vec![0, 0, 78, 255, 70, 6]];
|
||||
let file = hand_tiff(5, 8, &[], true, &inks);
|
||||
let want: Vec<u8> = (0..6).flat_map(|i| inks.iter().map(move |v| v[i] as u8)).collect();
|
||||
assert!(image::load_from_memory(&file).is_err(), "the image decoder reads planar CMYK now");
|
||||
let mut s = Session::new();
|
||||
open(&mut s, "planar.tif", &file);
|
||||
let blob = &s.doc().unwrap().doc.images[&image_of(&s).key];
|
||||
assert_eq!(blob.cmyk().map(|i| i.data), Some(want.clone()), "Open keeps the inks");
|
||||
let mut s = session(100.0, 100.0, 1);
|
||||
s.execute("file.place", &json!({"name": "planar.tif", "dataBase64": vectorcraft_format::base64_encode(&file)})).unwrap();
|
||||
let blob = &s.doc().unwrap().doc.images[&image_of(&s).key];
|
||||
assert_eq!(blob.cmyk().map(|i| i.data), Some(want), "Place keeps the inks");
|
||||
assert!(image::load_from_memory(&blob.bytes).is_ok(), "the image decoder reads the stored image");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_beats_a_wrong_extension() {
|
||||
let png = image_bytes(2, 2, image::ImageFormat::Png);
|
||||
@@ -129,7 +223,8 @@ fn templates_open_untitled() {
|
||||
let mut s = session(100.0, 100.0, 1);
|
||||
let dir = tmp_dir("tpl");
|
||||
let path = dir.join("t.vectorcraft").to_string_lossy().to_string();
|
||||
s.execute("file.saveAsTemplate", &json!({"path": path})).unwrap();
|
||||
// A template under a native file name still opens untitled.
|
||||
std::fs::write(&path, b64(&s.execute("file.saveAsTemplate", &json!({})).unwrap())).unwrap();
|
||||
let r = s.execute("document.open", &json!({"path": path})).unwrap();
|
||||
assert!(r["title"].as_str().unwrap().starts_with("Untitled-"), "{r}");
|
||||
assert_eq!(s.doc().unwrap().path, None);
|
||||
@@ -378,7 +473,9 @@ fn export_for_screens_one_page_per_pdf_unique_names() {
|
||||
let r = s.execute("document.exportForScreens", &json!({"range": "3", "formats": [{"format": "jpg", "scale": 0.5}]})).unwrap();
|
||||
assert_eq!(r["files"][0]["name"], "[email protected]");
|
||||
assert_eq!(image::load_from_memory(&b64(&r["files"][0])).unwrap().width(), 50);
|
||||
assert!(s.execute("document.exportForScreens", &json!({"formats": [{"format": "vectorcraft"}]})).is_err());
|
||||
// A native row: each artboard as a document of its own to edit.
|
||||
let r = s.execute("document.exportForScreens", &json!({"range": "1", "formats": [{"format": "vectorcraft"}]})).unwrap();
|
||||
assert!(r["files"][0]["name"].as_str().unwrap().ends_with(".vectorcraft"), "{r}");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
|
||||
@@ -303,6 +303,144 @@ fn type_asked_for_as_outlines_opens_a_file_whose_type_shows_from_its_pdf_part()
|
||||
assert_ne!(names(&s.doc().unwrap().doc.layers), ["Art"], "{r}");
|
||||
}
|
||||
|
||||
/// `data` as ASCII85, up to the `~>`.
|
||||
fn ascii85(data: &[u8]) -> String {
|
||||
let mut out = String::new();
|
||||
for chunk in data.chunks(4) {
|
||||
let mut x = chunk.iter().enumerate().fold(0u32, |v, (i, b)| v | u32::from(*b) << (24 - 8 * i));
|
||||
let mut d = [0u8; 5];
|
||||
for c in d.iter_mut().rev() {
|
||||
*c = (x % 85) as u8 + b'!';
|
||||
x /= 85;
|
||||
}
|
||||
out.extend(d.iter().take(chunk.len() + 1).map(|c| char::from(*c)));
|
||||
}
|
||||
out.push_str("~>");
|
||||
out
|
||||
}
|
||||
|
||||
/// Page content drawing one glyph and then `rest` in 10 pt on the baseline at `y`, from x = 14, in
|
||||
/// rendering mode `mode` (0 fills the type, 1 strokes it). The glyph is code 0x41 of the font `F2`,
|
||||
/// standing for the "fi" of the story, and `F2` isn't installed and its `ToUnicode` map gives that
|
||||
/// glyph U+FFFD, so that the PDF part keeps it as outlines; `rest` is in Helvetica (`F1`).
|
||||
fn half_outlined_line(y: f64, rest: &str, mode: u8) -> String {
|
||||
format!("{mode} Tr BT /F2 10 Tf 14 {y} Td (A) Tj ET BT /F1 10 Tf 19 {y} Td ({rest}) Tj ET ")
|
||||
}
|
||||
|
||||
/// A `.ai` whose page draws a gray square and `type_content` (see [`half_outlined_line`]), and whose
|
||||
/// layer `Art` has the square and text objects: the first of story 0, which `story` gives in the
|
||||
/// text document (a line starting at (14, 65)), then `more` of a story the text document doesn't
|
||||
/// have. Without `story` the file has no text document.
|
||||
fn half_outlined_ai(story: Option<&str>, more: usize, type_content: &str) -> Vec<u8> {
|
||||
let text = |story: u32| format!("/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n{story} /StoryIndex ,\n;\n");
|
||||
let texts: String = std::iter::once(text(0)).chain((0..more).map(|_| text(99))).collect();
|
||||
let mut body =
|
||||
format!("%AI3_TemplateBox: 100 50 100 50\n{}", ai::layer("Art", &format!("0.5 g\n{}f\n{texts}", ai::rect(120.0, 20.0, 160.0, 60.0))));
|
||||
if let Some(story) = story {
|
||||
// The story's one line starts at the canvas center (8191.5, the template box's center) moved
|
||||
// to (14, 65): its y runs down the page.
|
||||
let n = story.chars().count();
|
||||
let document = format!(
|
||||
"/0 << /1 << /0 [ << /0 << /0 << /0 (Helvetica) >> >> >> ] >> /8 << /0 [ << /0 << /0 [ 0 0 ] /2 << /2 [ 1 0 0 1 0 0 ] >> >> >> ] >> >>\n\
|
||||
/1 << /1 [ << /0 << /0 ({story}\r) /5 << /0 [ << /0 << /0 << /0 () /5 << /0 0 >> /6 0 >> >> /1 {} >> ] >> \
|
||||
/6 << /0 [ << /0 << /0 << /0 () /5 0 /6 << /0 0 /1 10.0 /53 << /99 /CAITextPaint /0 << /0 2 /1 [ 1.0 0.0 0.0 0.0 1.0 ] >> >> >> >> >> /1 {} >> ] >> >> \
|
||||
/1 << /0 [ << /0 0 >> ] /2 [ << /99 /PC /6 [ << /99 /F /0 << /0 [ 8105.5 8176.5 ] >> /6 [ << /99 /R /6 [ << /99 /R /6 [ << /99 /L /6 [ \
|
||||
<< /99 /S /0 << /0 [ 0.0 0.0 ] >> /15 << /0 {} >> >> ] >> ] >> ] >> ] >> ] >> ] >> >> ] /2 << /1 12.0 >> >>\n",
|
||||
n + 1,
|
||||
n + 1,
|
||||
n + 1
|
||||
);
|
||||
let lines: Vec<String> = ascii85(document.as_bytes()).as_bytes().chunks(60).map(|c| format!("%{}", String::from_utf8_lossy(c))).collect();
|
||||
body.push_str(&format!("%AI11_BeginTextDocument\n/AI11TextDocument : /ASCII85Decode ,\n{}\n%AI11_EndTextDocument\n", lines.join("\n")));
|
||||
}
|
||||
let data = ai::editing_data(200.0, 100.0, &body);
|
||||
let content = format!("0.5 g 120 20 40 40 re f 0.2 g 0.2 G {type_content}");
|
||||
let page = PdfPage {
|
||||
resources: format!(
|
||||
"/Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> \
|
||||
/F2 << /Type /Font /Subtype /Type1 /BaseFont /VectorCraftTestUnnamed /ToUnicode {} 0 R >> >>",
|
||||
first_extra(1) + 1
|
||||
),
|
||||
entries: format!("/PieceInfo << /Illustrator << /Private << /AIPrivateData1 {} 0 R /NumBlock 1 >> >> >>", first_extra(1)),
|
||||
..PdfPage::new(200.0, 100.0, &content)
|
||||
};
|
||||
let cmap = "begincmap\n1 begincodespacerange\n<00> <FF>\nendcodespacerange\n1 beginbfchar\n<41> <FFFD>\nendbfchar\nendcmap\n";
|
||||
pdf_with(
|
||||
&[page],
|
||||
&[&format!("<< /Length {} >>\nstream\n{data}\nendstream", data.len()), &format!("<< /Length {} >>\nstream\n{cmap}endstream", cmap.len())],
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn outlines_notes(r: &Value) -> Vec<String> {
|
||||
let notes = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str);
|
||||
notes.filter(|w| vectorcraft_pdf::is_outlines_note(w)).map(String::from).collect()
|
||||
}
|
||||
|
||||
/// The notes the PDF part of [`half_outlined_ai`] reads with: its glyph of U+FFFD, kept as outlines.
|
||||
fn pdf_part_outlines_notes() -> [String; 2] {
|
||||
[format!("text in Vector Craft Test Unnamed {}", vectorcraft_pdf::UNNAMED_CHARACTERS_NOTE), vectorcraft_pdf::OUTLINED_TEXT_NOTE.into()]
|
||||
}
|
||||
|
||||
/// The note on text objects that hold the page's type without some of its characters, for `n` of
|
||||
/// them.
|
||||
fn without_outlines_note(n: usize) -> Value {
|
||||
json!(format!("{n} text objects hold their type as the page draws it, without the characters the page has only as outlines"))
|
||||
}
|
||||
|
||||
/// The texts in the first layer.
|
||||
fn texts(s: &Session) -> Vec<String> {
|
||||
let art = s.doc().unwrap().doc.layers[0].children().unwrap();
|
||||
art.iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.plain_text()) } else { None }).collect()
|
||||
}
|
||||
|
||||
/// The note that glyphs were kept as outlines is dropped when the type comes from the editing data's
|
||||
/// text document, and stays with the PDF part alone.
|
||||
#[test]
|
||||
fn type_from_the_text_document_opens_without_the_pdf_parts_note_on_outlines() {
|
||||
let file = half_outlined_ai(Some("first draft"), 0, &half_outlined_line(65.0, "rst draft", 0));
|
||||
let mut s = Session::new();
|
||||
let r = open(&mut s, "draft.ai", &file, json!({})).unwrap();
|
||||
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "{r}");
|
||||
assert_eq!(texts(&s), ["first draft"], "{r}");
|
||||
assert_eq!(outlines_notes(&r), Vec::<String>::new(), "{r}");
|
||||
assert!(!r["warnings"].as_array().unwrap().contains(&without_outlines_note(1)), "{r}");
|
||||
let r = open(&mut s, "draft.ai", &file, json!({"editingData": false})).unwrap();
|
||||
assert_eq!(outlines_notes(&r), pdf_part_outlines_notes(), "{r}");
|
||||
// A second text object that takes the page's type, away from the glyph: no note on it.
|
||||
let page = half_outlined_line(65.0, "rst draft", 0) + "BT /F1 10 Tf 14 30 Td (notes) Tj ET";
|
||||
let r = open(&mut s, "notes.ai", &half_outlined_ai(Some("first draft"), 1, &page), json!({})).unwrap();
|
||||
assert_eq!(texts(&s), ["first draft", "notes"], "{r}");
|
||||
assert_eq!(outlines_notes(&r), Vec::<String>::new(), "{r}");
|
||||
assert!(!r["warnings"].as_array().unwrap().contains(&without_outlines_note(1)), "{r}");
|
||||
}
|
||||
|
||||
/// A text object that takes the page's type lacks the glyphs next to it that the page kept as
|
||||
/// outlines: a note counts such objects, in place of the PDF part's notes. Stroked glyphs that
|
||||
/// come in with the stroked type keep the PDF part's notes.
|
||||
#[test]
|
||||
fn type_from_the_page_opens_with_a_note_on_the_characters_it_has_only_as_outlines() {
|
||||
let mut s = Session::new();
|
||||
let r = open(&mut s, "draft.ai", &half_outlined_ai(None, 0, &half_outlined_line(65.0, "rst draft", 0)), json!({})).unwrap();
|
||||
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "{r}");
|
||||
assert_eq!(texts(&s), ["rst draft"], "{r}");
|
||||
assert!(!vectorcraft_pdf::holds_outlines(&s.doc().unwrap().doc), "{r}");
|
||||
assert_eq!(outlines_notes(&r), Vec::<String>::new(), "{r}");
|
||||
assert!(r["warnings"].as_array().unwrap().contains(&without_outlines_note(1)), "{r}");
|
||||
// Stroked: the glyph comes in with the type.
|
||||
let r = open(&mut s, "draft.ai", &half_outlined_ai(None, 0, &half_outlined_line(65.0, "rst draft", 1)), json!({})).unwrap();
|
||||
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "{r}");
|
||||
assert!(vectorcraft_pdf::holds_outlines(&s.doc().unwrap().doc), "{r}");
|
||||
assert_eq!(outlines_notes(&r), pdf_part_outlines_notes(), "{r}");
|
||||
assert!(!r["warnings"].to_string().contains("without the characters"), "{r}");
|
||||
// A stroked line whose glyph comes in, and a filled one whose glyph doesn't.
|
||||
let page = half_outlined_line(65.0, "rst draft", 1) + &half_outlined_line(30.0, "nd more", 0);
|
||||
let r = open(&mut s, "both.ai", &half_outlined_ai(None, 1, &page), json!({})).unwrap();
|
||||
assert_eq!(texts(&s), ["rst draft", "nd more"], "{r}");
|
||||
assert_eq!(outlines_notes(&r), pdf_part_outlines_notes(), "{r}");
|
||||
assert!(r["warnings"].as_array().unwrap().contains(&without_outlines_note(1)), "{r}");
|
||||
}
|
||||
|
||||
/// A `.ai` saved in units of 10 points: its PDF part's page is 200 × 100 with `/UserUnit 10`, and
|
||||
/// its editing data has the artboard and a red rectangle in the same units. It opens at 2000 × 1000
|
||||
/// pt, from its editing data and from its PDF part alone, with the rectangle ten times as large,
|
||||
@@ -374,3 +512,43 @@ fn bled_file_opens_on_its_artboard(name: &str, key: &str) {
|
||||
open(&mut s, name, &file, json!({})).unwrap();
|
||||
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "{name}: the editing data's layers are read");
|
||||
}
|
||||
|
||||
/// The rows that open in the Layers panel (layers, sublayers and groups), and which of them are
|
||||
/// open.
|
||||
fn open_rows(s: &Session) -> (Vec<String>, Vec<String>) {
|
||||
let st = s.doc().unwrap();
|
||||
let (mut rows, mut open) = (vec![], vec![]);
|
||||
st.doc.walk(|n| {
|
||||
if n.children().is_some() {
|
||||
rows.push(n.name.clone().unwrap_or_default());
|
||||
if st.layers_open.contains(n.id) {
|
||||
open.push(n.name.clone().unwrap_or_default());
|
||||
}
|
||||
}
|
||||
});
|
||||
(rows, open)
|
||||
}
|
||||
|
||||
/// #1475: a `.ai` another app wrote keeps no open rows VectorCraft reads, so it opens with every
|
||||
/// row closed, its top-level layers as its groups; a `.ai` VectorCraft wrote, and other formats,
|
||||
/// open as before (with their top-level layers open unless the file says otherwise).
|
||||
#[test]
|
||||
fn an_illustrator_ai_file_opens_with_its_layers_closed() {
|
||||
let mut s = Session::new();
|
||||
for file in [ai::ai(&compressed(&ai::sample_data()), ai::page_pdf()), ai::eps(&ai::sample_data(), ai::page_ps())] {
|
||||
let r = open(&mut s, "art.ai", &file, json!({})).unwrap();
|
||||
assert_eq!((&r["format"], &r["restored"]), (&json!("ai"), &json!(false)), "{r}");
|
||||
let (rows, open) = open_rows(&s);
|
||||
assert!(rows.contains(&"Art".into()) && rows.contains(&"Pair".into()) && rows.contains(&"Sub".into()), "{rows:?}");
|
||||
assert_eq!(open, Vec::<String>::new(), "{r}");
|
||||
}
|
||||
// The same art as an EPS: its top-level layers open.
|
||||
open(&mut s, "art.eps", &ai::eps(&ai::sample_data(), ai::page_ps()), json!({})).unwrap();
|
||||
assert_eq!(open_rows(&s).1, ["Art", "Hidden"]);
|
||||
// Saved as .ai by VectorCraft, it opens with the rows it was saved with.
|
||||
let saved = s.execute("document.save", &json!({"format": "ai"})).unwrap();
|
||||
let bytes = vectorcraft_format::base64_decode(saved["dataBase64"].as_str().unwrap()).unwrap();
|
||||
let r = open(&mut s, "again.ai", &bytes, json!({})).unwrap();
|
||||
assert_eq!(r["restored"], json!(true), "{r}");
|
||||
assert_eq!(open_rows(&s).1, ["Art", "Hidden"]);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
//! Batch exports through Export for Screens (#1935): a file name pattern (the document's and the
|
||||
//! item's names, a counter, the date), folders by format, by item or both, layers and the selection
|
||||
//! as what is exported, and document formats (VectorCraft, Illustrator, EPS) as rows.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn b64(v: &Value) -> Vec<u8> {
|
||||
vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap()
|
||||
}
|
||||
|
||||
/// A document of `artboards` 40×30 pt artboards side by side with a 20 pt square on the first.
|
||||
fn session(artboards: usize) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 40, "height": 30, "artboards": artboards})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 5, "y": 5, "width": 20, "height": 20})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn export(s: &mut Session, p: Value) -> Value {
|
||||
s.execute("document.exportForScreens", &p).unwrap()
|
||||
}
|
||||
|
||||
fn files(r: &Value) -> Vec<(String, Vec<u8>)> {
|
||||
r["files"].as_array().unwrap().iter().map(|f| (f["name"].as_str().unwrap().to_string(), b64(f))).collect()
|
||||
}
|
||||
|
||||
fn names(r: &Value) -> Vec<String> {
|
||||
files(r).into_iter().map(|(n, _)| n).collect()
|
||||
}
|
||||
|
||||
/// The request in #1935: SVG, PNG, PDF and PDF with printer's marks for artboards 1–3, named
|
||||
/// `COMPANY-Logo-<filename>-####-YYYYMMDD`, sorted into a folder per extension.
|
||||
#[test]
|
||||
fn the_logo_package_of_the_request() {
|
||||
let mut s = session(4);
|
||||
let r = export(
|
||||
&mut s,
|
||||
json!({
|
||||
"range": "1-3",
|
||||
"name": "COMPANY-Logo-<filename>-####-YYYYMMDD.ext",
|
||||
"date": "2026-10-16",
|
||||
"folders": "format",
|
||||
"formats": [
|
||||
{"format": "svg"},
|
||||
{"format": "png", "scale": "2x", "suffix": ""},
|
||||
{"format": "pdf"},
|
||||
{"format": "pdf", "suffix": "-print", "marks": {"trim": true, "registration": true, "colorBars": true, "pageInfo": true}}
|
||||
]
|
||||
}),
|
||||
);
|
||||
let n = names(&r);
|
||||
assert_eq!(n.len(), 12, "{n:?}");
|
||||
for (i, number) in ["0001", "0002", "0003"].iter().enumerate() {
|
||||
let stem = format!("COMPANY-Logo-Untitled-1-{number}-20261016");
|
||||
for want in [format!("svg/{stem}.svg"), format!("png/{stem}.png"), format!("pdf/{stem}.pdf"), format!("pdf/{stem}-print.pdf")] {
|
||||
assert!(n.contains(&want), "artboard {}: {want} in {n:?}", i + 1);
|
||||
}
|
||||
}
|
||||
// The printed PDF has room for its marks: a bigger page than the plain one.
|
||||
let f = files(&r);
|
||||
let page = |name: &str| {
|
||||
let bytes = &f.iter().find(|(n, _)| n == name).unwrap().1;
|
||||
let d = vectorcraft_pdf::import(bytes).unwrap();
|
||||
let a = d.artboards.first().unwrap().rect;
|
||||
(a.width(), a.height())
|
||||
};
|
||||
let plain = page("pdf/COMPANY-Logo-Untitled-1-0001-20261016.pdf");
|
||||
let print = page("pdf/COMPANY-Logo-Untitled-1-0001-20261016-print.pdf");
|
||||
assert!(print.0 > plain.0 && print.1 > plain.1, "{plain:?} vs {print:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folders_by_item_and_by_item_and_format() {
|
||||
let mut s = session(2);
|
||||
let rows = json!([{"format": "png"}, {"format": "svg"}]);
|
||||
let by_item = names(&export(&mut s, json!({"folders": "item", "formats": rows})));
|
||||
assert_eq!(by_item, ["Artboard-1/Artboard-1.png", "Artboard-1/Artboard-1.svg", "Artboard-2/Artboard-2.png", "Artboard-2/Artboard-2.svg"]);
|
||||
let both = names(&export(&mut s, json!({"folders": "itemFormat", "formats": rows})));
|
||||
assert_eq!(both[0], "Artboard-1/png/Artboard-1.png");
|
||||
let flat = names(&export(&mut s, json!({"folders": "none", "subfolders": true, "formats": rows})));
|
||||
assert_eq!(flat[0], "Artboard-1.png", "folders wins over subfolders");
|
||||
// Without folders, subfolders keeps its meaning (a folder per row).
|
||||
let rows_mode = names(&export(&mut s, json!({"subfolders": true, "formats": rows})));
|
||||
assert_eq!(rows_mode[0], "1x/Artboard-1.png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_counter_counts_items_or_files_from_any_start() {
|
||||
let mut s = session(2);
|
||||
let rows = json!([{"format": "png"}, {"format": "svg"}]);
|
||||
let items = names(&export(&mut s, json!({"name": "logo-###", "counterStart": 9, "formats": rows})));
|
||||
assert_eq!(items, ["logo-009.png", "logo-009.svg", "logo-010.png", "logo-010.svg"]);
|
||||
let per_file = names(&export(&mut s, json!({"name": "logo-#", "counterPer": "file", "formats": rows})));
|
||||
assert_eq!(per_file, ["logo-1.png", "logo-2.svg", "logo-3.png", "logo-4.svg"]);
|
||||
// A pattern that names different files alike numbers them instead of dropping one.
|
||||
let same = names(&export(&mut s, json!({"name": "logo", "formats": [{"format": "png"}]})));
|
||||
assert_eq!(same, ["logo.png", "logo-2.png"]);
|
||||
// The item's name, its place and the row's size and suffix.
|
||||
let parts = names(&export(
|
||||
&mut s,
|
||||
json!({"name": "<index>_<name>_<scale><suffix>_<format>", "range": "2", "formats": [{"format": "png", "scale": "2x"}]}),
|
||||
));
|
||||
assert_eq!(parts, ["1_Artboard 2_2x@2x_png.png"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_and_the_selection_are_exported_by_name() {
|
||||
let mut s = session(1);
|
||||
let first_layer = s.doc().unwrap().doc.layers[0].id.0;
|
||||
let top = s.execute("layer.new", &json!({"name": "Dark Mode"})).unwrap()["id"].as_u64().unwrap();
|
||||
let circle = s.execute("shape.ellipse", &json!({"x": 2, "y": 2, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
|
||||
let r = export(&mut s, json!({"layers": [first_layer, top], "formats": [{"format": "svg"}]}));
|
||||
assert_eq!(names(&r), ["Layer-1.svg", "Dark-Mode.svg"]);
|
||||
// The selection: one file of it all, or a file per object.
|
||||
let square = s.doc().unwrap().doc.layers[0].children().unwrap()[0].id.0;
|
||||
s.execute("select.set", &json!({"ids": [square, circle]})).unwrap();
|
||||
assert_eq!(names(&export(&mut s, json!({"selection": "one", "formats": [{"format": "png"}]}))), ["Selection.png"]);
|
||||
let each = names(&export(&mut s, json!({"selection": "each", "formats": [{"format": "png"}]})));
|
||||
assert_eq!(each.len(), 2, "{each:?}");
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
assert!(s.execute("document.exportForScreens", &json!({"selection": "one"})).is_err(), "nothing selected");
|
||||
}
|
||||
|
||||
/// Document formats write each artboard as a document of its own: one artboard, its art.
|
||||
#[test]
|
||||
fn document_formats_write_one_artboard_each() {
|
||||
let mut s = session(2);
|
||||
let r = export(&mut s, json!({"formats": [{"format": "vectorcraft"}, {"format": "ai"}, {"format": "eps"}]}));
|
||||
let n = names(&r);
|
||||
assert_eq!(n, ["Artboard-1.vectorcraft", "Artboard-1.ai", "Artboard-1.eps", "Artboard-2.vectorcraft", "Artboard-2.ai", "Artboard-2.eps"]);
|
||||
let native = &files(&r)[0].1;
|
||||
let mut t = Session::new();
|
||||
t.execute("document.open", &json!({"name": "a.vectorcraft", "dataBase64": vectorcraft_format::base64_encode(native)})).unwrap();
|
||||
assert_eq!(t.doc().unwrap().doc.artboards.len(), 1, "that artboard alone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_batch_params_are_refused() {
|
||||
let mut s = session(1);
|
||||
for bad in [
|
||||
json!({"folders": "byColour"}),
|
||||
json!({"counterPer": "page"}),
|
||||
json!({"date": "16/10/2026"}),
|
||||
json!({"selection": "some"}),
|
||||
json!({"selection": "one", "fullDocument": true}),
|
||||
json!({"layers": [99999]}),
|
||||
json!({"layers": [1], "assets": [1]}),
|
||||
] {
|
||||
assert!(s.execute("document.exportForScreens", &bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Export presets: the built-in ones, saving (only what a preset keeps), using one under other
|
||||
/// params, replacing, deleting, and keeping them with the preferences.
|
||||
#[test]
|
||||
fn export_presets_save_use_and_delete() {
|
||||
let mut s = session(2);
|
||||
let list = s.execute("document.exportPresets.list", &json!({})).unwrap();
|
||||
let built_in: Vec<&str> =
|
||||
list["presets"].as_array().unwrap().iter().filter(|p| p["builtIn"] == true).map(|p| p["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(built_in, ["Logo Package", "Print-Ready PDF"]);
|
||||
// The Logo Package: SVG, PNG 1x and 2x, PDF and PDF with marks, a folder per format.
|
||||
let r = export(&mut s, json!({"exportPreset": "Logo Package", "range": "1"}));
|
||||
assert_eq!(
|
||||
names(&r),
|
||||
[
|
||||
"svg/Untitled-1-Artboard 1.svg",
|
||||
"png/Untitled-1-Artboard 1.png",
|
||||
"png/Untitled-1-Artboard [email protected]",
|
||||
"pdf/Untitled-1-Artboard 1.pdf",
|
||||
"pdf/Untitled-1-Artboard 1-print.pdf"
|
||||
]
|
||||
);
|
||||
|
||||
let settings = json!({"name": "ACME-<name>-##", "folders": "item", "formats": [{"format": "jpg", "quality": 80}], "range": "2", "assets": [1]});
|
||||
let saved = s.execute("document.exportPresets.save", &json!({"name": "Web JPG", "settings": settings})).unwrap();
|
||||
assert_eq!(saved["created"], true);
|
||||
assert!(saved["settings"].get("range").is_none() && saved["settings"].get("assets").is_none(), "which art is not kept: {saved}");
|
||||
let r = export(&mut s, json!({"exportPreset": "Web JPG", "range": "2"}));
|
||||
assert_eq!(names(&r), ["Artboard-2/ACME-Artboard 2-01.jpg"]);
|
||||
// The call's own params win over the preset's.
|
||||
let r = export(&mut s, json!({"exportPreset": "Web JPG", "range": "2", "folders": "none"}));
|
||||
assert_eq!(names(&r), ["ACME-Artboard 2-01.jpg"]);
|
||||
// Saving again replaces it; the preferences keep it.
|
||||
let again = s.execute("document.exportPresets.save", &json!({"name": "Web JPG", "settings": {"formats": [{"format": "png"}]}})).unwrap();
|
||||
assert_eq!(again["created"], false);
|
||||
let prefs = serde_json::to_value(&s.prefs).unwrap();
|
||||
assert_eq!(prefs["screenExportPresets"][0]["name"], "Web JPG");
|
||||
s.execute("document.exportPresets.delete", &json!({"name": "Web JPG"})).unwrap();
|
||||
assert!(s.execute("document.exportForScreens", &json!({"exportPreset": "Web JPG"})).is_err());
|
||||
// Built-in names stay; bad settings are refused when saved.
|
||||
for bad in [
|
||||
("document.exportPresets.save", json!({"name": "Logo Package", "settings": {}})),
|
||||
("document.exportPresets.save", json!({"name": "", "settings": {}})),
|
||||
("document.exportPresets.save", json!({"name": "X", "settings": {"formats": [{"format": "txt"}]}})),
|
||||
("document.exportPresets.save", json!({"name": "X", "settings": {"folders": "sideways"}})),
|
||||
("document.exportPresets.save", json!({"name": "X"})),
|
||||
("document.exportPresets.delete", json!({"name": "Logo Package"})),
|
||||
("document.exportPresets.delete", json!({"name": "Never saved"})),
|
||||
] {
|
||||
assert!(s.execute(bad.0, &bad.1).is_err(), "{bad:?}");
|
||||
}
|
||||
}
|
||||
@@ -171,7 +171,9 @@ fn bad_eps_options_are_refused_and_formats_list_eps() {
|
||||
let e = s.execute("document.exportEps", &p).unwrap_err().to_string();
|
||||
assert!(e.contains("EPS"), "{p}: {e}");
|
||||
}
|
||||
assert!(s.execute("document.exportForScreens", &json!({"formats": [{"format": "eps"}]})).is_err());
|
||||
// Export for Screens writes an EPS per artboard (a document of that artboard alone).
|
||||
let r = s.execute("document.exportForScreens", &json!({"formats": [{"format": "eps"}]})).unwrap();
|
||||
assert!(r["files"][0]["name"].as_str().unwrap().ends_with(".eps"), "{r}");
|
||||
let r = s.execute("document.formats", &json!({})).unwrap();
|
||||
let eps = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "eps").unwrap().clone();
|
||||
// Readable too since EPS import (tests_epsimport.rs).
|
||||
|
||||
@@ -172,3 +172,44 @@ fn eps_files_place_as_one_group_clipped_to_their_bounding_box() {
|
||||
assert!(format("eps").is_some_and(|f| f.read && f.write));
|
||||
assert!(OPEN_EXTS.contains(&"eps") && PLACE_EXTS.contains(&"eps"));
|
||||
}
|
||||
|
||||
/// PostScript `.ps` files open and place as EPS files do, under a PostScript format that exports
|
||||
/// don't write, and `.epsf` files are EPS files; the open and place dialogs list both.
|
||||
#[test]
|
||||
fn ps_and_epsf_files_open_and_place() {
|
||||
use crate::cmd::clipboard::{EPS, PASTE_ORDER, file_flavour};
|
||||
// Pages without a bounding box (each artboard is a letter page), one of them a program whose
|
||||
// header is only `%!`.
|
||||
let page = b"%!PS-Adobe-3.0\n%%Pages: 1\n%%EndComments\n%%Page: 1 1\n1 0 0 setrgbcolor\n\
|
||||
newpath 72 72 moveto 144 0 rlineto 0 72 rlineto -144 0 rlineto closepath fill\nshowpage\n%%EOF\n";
|
||||
let bare = b"%!\n0 0 1 setrgbcolor\n100 100 50 50 rectfill\nshowpage\n";
|
||||
let mut s = Session::new();
|
||||
for (name, bytes, id) in [("page.ps", &page[..], "ps"), ("PAGE.PS", page, "ps"), ("bare.ps", bare, "ps"), ("bare.epsf", bare, "eps")] {
|
||||
let r = open(&mut s, name, bytes).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert_eq!((&r["format"], &r["warnings"]), (&json!(id), &json!([])), "{name}: {r}");
|
||||
assert_eq!(objects(&s), 1, "{name}");
|
||||
assert_eq!(s.doc().unwrap().doc.artboards[0].rect.size(), vectorcraft_geom::Size::new(612.0, 792.0), "{name}");
|
||||
}
|
||||
s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap();
|
||||
for (name, bytes, id) in [("page.ps", &page[..], "ps"), ("bare.ps", bare, "ps"), ("bare.epsf", bare, "eps")] {
|
||||
let r = s.execute("file.place", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)})).unwrap();
|
||||
assert_eq!((&r["format"], r["ids"].as_array().map(Vec::len)), (&json!(id), Some(1)), "{name}: {r}");
|
||||
}
|
||||
// Copied in a file manager, they paste as EPS.
|
||||
for (name, bytes) in [("page.ps", &page[..]), ("bare.ps", bare), ("bare.epsf", bare), ("bare.eps", bare)] {
|
||||
assert_eq!(file_flavour(name, bytes, &PASTE_ORDER), Some(EPS), "{name}");
|
||||
let r = s
|
||||
.execute("clipboard.importEps", &json!({"dataBase64": vectorcraft_format::base64_encode(bytes)}))
|
||||
.unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert_eq!(r["count"], json!(1), "{name}: {r}");
|
||||
}
|
||||
// The dialogs list them, and the desktop packaging doesn't associate them with the app.
|
||||
assert!(format("ps").is_some_and(|f| f.read && !f.write) && format("epsf").is_some_and(|f| f.id == "eps" && f.write));
|
||||
for e in ["ps", "epsf"] {
|
||||
assert!(OPEN_EXTS.contains(&e) && PLACE_EXTS.contains(&e) && UNASSOCIATED_EXTS.contains(&e), "{e}");
|
||||
}
|
||||
assert!(open_filters().any(|f| f == ("PostScript", &["ps"][..])) && place_filters().any(|f| f == ("PostScript", &["ps"][..])));
|
||||
// Exports write no .ps: Print to PostScript does.
|
||||
let e = writable_format(None, Some("out.ps")).unwrap_err();
|
||||
assert_eq!(e, "PostScript files can be opened but not written (see document.formats)");
|
||||
}
|
||||
@@ -139,6 +139,27 @@ fn exports_write_ai_as_save_as_does() {
|
||||
assert!(b64(&r).starts_with(b"%PDF"));
|
||||
}
|
||||
|
||||
/// A `.ai` with an artboard larger than 14,400 pt has its PDF page written with a `UserUnit`,
|
||||
/// and reopens at its size from its editing data and from its PDF part.
|
||||
#[test]
|
||||
fn an_ai_with_an_artboard_past_14400_pt_reopens_at_its_size() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 20000, "height": 10000})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 1000, "y": 1000, "width": 5000, "height": 3000})).unwrap();
|
||||
let ai = b64(&s.execute("document.export", &json!({"format": "ai", "compress": false})).unwrap());
|
||||
assert!(String::from_utf8_lossy(&ai).contains("/UserUnit 1.389"), "20,000 / 14,400 rounded up to the next thousandth");
|
||||
let size = |s: &Session| {
|
||||
let r = s.doc().unwrap().doc.artboards[0].rect;
|
||||
(r.width().round(), r.height().round())
|
||||
};
|
||||
assert_eq!(open(&mut s, "big.ai", &ai)["restored"], true);
|
||||
assert_eq!(size(&s), (20_000.0, 10_000.0));
|
||||
// Picking its page opens the PDF part.
|
||||
let r = s.execute("document.open", &json!({"name": "big.ai", "dataBase64": vectorcraft_format::base64_encode(&ai), "pages": "1"})).unwrap();
|
||||
assert_eq!(r["restored"], false, "{r}");
|
||||
assert_eq!(size(&s), (20_000.0, 10_000.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ait_templates_open_their_document_untitled() {
|
||||
let mut s = rich();
|
||||
@@ -180,6 +201,37 @@ fn save_as_template_writes_an_ait_that_new_from_template_opens_untitled() {
|
||||
assert!(vectorcraft_pdf::editing(&b64(&r)).is_some_and(|e| e.intact));
|
||||
}
|
||||
|
||||
/// Save as Template writes a file named with its template format's extension: another format's
|
||||
/// extension is replaced and a name without one gets it; Save and Save As name the command that
|
||||
/// writes a `.ait`.
|
||||
#[test]
|
||||
fn save_as_template_names_its_file_after_the_template_format() {
|
||||
let mut s = rich();
|
||||
for (name, format, saved) in [
|
||||
("card.png", None, "card.vctemplate"),
|
||||
("card.vectorcraft", None, "card.vctemplate"),
|
||||
("card.ait", Some("template"), "card.vctemplate"),
|
||||
("card.pdf", Some("ait"), "card.ait"),
|
||||
("card v1.2", None, "card v1.2.vctemplate"),
|
||||
] {
|
||||
let (typed, want) = (tmp(&format!("named/{name}")), tmp(&format!("named/{saved}")));
|
||||
let _ = std::fs::create_dir_all(std::path::Path::new(&typed).parent().unwrap());
|
||||
let _ = std::fs::remove_file(&typed);
|
||||
let _ = std::fs::remove_file(&want);
|
||||
let r = s.execute("file.saveAsTemplate", &json!({"path": typed, "format": format})).unwrap();
|
||||
assert_eq!(r["path"], want.as_str(), "{name}: {r}");
|
||||
let bytes = std::fs::read(&want).unwrap_or_else(|e| panic!("{name}: {saved} not written: {e}"));
|
||||
let ait = saved.ends_with(".ait");
|
||||
assert_eq!(bytes.starts_with(b"%PDF"), ait, "{name}");
|
||||
assert!(ait || vectorcraft_format::load(&bytes).is_ok_and(|d| d.template), "{name}: a native template");
|
||||
assert!(!std::path::Path::new(&typed).exists(), "{name}: nothing written under the typed name");
|
||||
}
|
||||
for (cmd, p) in [("file.saveAs", json!({"format": "ait"})), ("document.save", json!({"path": tmp("card.ait")}))] {
|
||||
let e = s.execute(cmd, &p).unwrap_err().to_string();
|
||||
assert!(e.contains("file.saveAsTemplate") && !e.contains("document.export"), "{cmd}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standards_and_screens_leave_the_editing_data_out() {
|
||||
let mut s = rich();
|
||||
|
||||
@@ -159,6 +159,21 @@ fn black_output_rich_covers_ink_plane_documents_and_leaves_cmyk_exports_alone()
|
||||
assert!(inks == [cmyk(&mut s, "tiff"), cmyk(&mut s, "jpg")], "CMYK exports keep the document's inks");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn png_says_it_is_srgb_unless_embed_icc_is_false() {
|
||||
let mut s = session(40.0, 30.0, 1);
|
||||
s.execute("shape.rectangle", &json!({"x": 5, "y": 5, "width": 20, "height": 10})).unwrap();
|
||||
let png = |s: &mut Session, p: Value| b64(&s.execute("document.export", &p).unwrap());
|
||||
let on = png(&mut s, json!({"format": "png"}));
|
||||
assert_eq!(png_chunk(&on, b"sRGB"), Some(&[0u8][..]), "an sRGB chunk, perceptual intent");
|
||||
let off = png(&mut s, json!({"format": "png", "embedIcc": false}));
|
||||
assert!(png_chunk(&off, b"sRGB").is_none());
|
||||
assert_eq!(rgba(&on), rgba(&off), "same pixels either way");
|
||||
let r = s.execute("document.formats", &json!({})).unwrap();
|
||||
let f = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "png").unwrap();
|
||||
assert!(f["options"].get("embedIcc").is_some(), "png lists embedIcc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_list_the_raster_options() {
|
||||
let mut s = Session::new();
|
||||
@@ -217,6 +232,53 @@ fn an_image_opens_at_the_size_its_resolution_declares() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_raster_export_says_nothing_of_images_another_thread_could_not_decode() {
|
||||
// While another thread keeps exporting a document whose image can't be decoded (two exports at
|
||||
// once, or the canvas drawing such a document), PNG exports of art with no image report
|
||||
// nothing, and every export on the other thread still reports its own image.
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use vectorcraft_doc::{ImageBlob, ImageObject, Node, NodeKind};
|
||||
let mut broken = vectorcraft_doc::Document::new(40.0, 20.0);
|
||||
broken.images.insert("broken".into(), ImageBlob::new("image/png", b"\x89PNG\r\n\x1a\n".to_vec()));
|
||||
let im = ImageObject {
|
||||
key: "broken".into(),
|
||||
width: 2,
|
||||
height: 1,
|
||||
xf: vectorcraft_geom::Affine::scale(20.0),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
};
|
||||
let (id, layer) = (broken.alloc_id(), broken.layers[0].id);
|
||||
broken.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap();
|
||||
let mut s = session(400.0, 400.0, 1);
|
||||
s.execute("shape.ellipse", &json!({"x": 20, "y": 20, "width": 360, "height": 360})).unwrap();
|
||||
let says_so = |w: &[Value]| w.iter().any(|w| w.as_str().is_some_and(|w| w.contains("couldn't be decoded")));
|
||||
let stop = AtomicBool::new(false);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
// Nothing in the scope panics, so the other thread is always told to stop.
|
||||
let (theirs, ours) = std::thread::scope(|scope| {
|
||||
let other = scope.spawn(|| {
|
||||
let mut warned = vec![];
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
let w = encode_with_warnings(&broken, "png", &json!({})).map(|(_, w)| w.into_iter().map(Value::from).collect::<Vec<_>>());
|
||||
warned.push(w.is_ok_and(|w| says_so(&w)));
|
||||
let _ = tx.send(());
|
||||
}
|
||||
warned
|
||||
});
|
||||
let started = rx.recv_timeout(std::time::Duration::from_secs(60)).is_ok();
|
||||
let ours: Vec<Option<bool>> = (0..5)
|
||||
.map(|_| s.execute("document.export", &json!({"format": "png"})).ok().map(|r| says_so(r["warnings"].as_array().map_or(&[], |w| w))))
|
||||
.collect();
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
(other.join().ok(), started.then_some(ours))
|
||||
});
|
||||
let theirs = theirs.expect("the other thread's exports");
|
||||
assert!(!theirs.is_empty() && theirs.iter().all(|w| *w), "the other thread's exports: {theirs:?}");
|
||||
assert_eq!(ours.expect("the other thread started"), vec![Some(false); 5], "exports with no image");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_raster_export_says_when_an_image_could_not_be_decoded() {
|
||||
// A PNG whose header reads (so it places at its size) but whose pixel data is cut short:
|
||||
@@ -233,3 +295,41 @@ fn a_raster_export_says_when_an_image_could_not_be_decoded() {
|
||||
let warnings = r["warnings"].as_array().cloned().unwrap_or_default();
|
||||
assert!(warnings.iter().any(|w| w.as_str().is_some_and(|w| w.contains("couldn't be decoded"))), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_rect_writes_that_page_area_and_leaves_the_document_alone() {
|
||||
let mut s = session(200.0, 400.0, 1);
|
||||
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 20, "y": 40, "width": 40, "height": 300})).unwrap();
|
||||
let boards = s.doc().unwrap().doc.artboards.len();
|
||||
let r = s.execute("document.export", &json!({"format": "png", "rect": [20, 40, 40, 300], "ppi": 72})).unwrap();
|
||||
let img = rgba(&b64(&r));
|
||||
assert_eq!((img.width(), img.height()), (40, 300));
|
||||
assert_eq!(img.get_pixel(20, 150).0[3], 255, "the strip is all art");
|
||||
assert_eq!(s.doc().unwrap().doc.artboards.len(), boards, "no helper artboard is left");
|
||||
// Twice the resolution, and a window that holds both art and empty page.
|
||||
let img = rgba(&b64(&s.execute("document.export", &json!({"format": "png", "rect": [0.0, 40.0, 40.0, 10.0], "scale": 2})).unwrap()));
|
||||
assert_eq!((img.width(), img.height()), (80, 20));
|
||||
assert_eq!((img.get_pixel(10, 10).0[3], img.get_pixel(60, 10).0[3]), (0, 255));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_rect_refuses_what_cannot_be_exported() {
|
||||
let mut s = session(200.0, 400.0, 1);
|
||||
s.execute("shape.rectangle", &json!({"x": 20, "y": 40, "width": 40, "height": 300})).unwrap();
|
||||
let bad = [
|
||||
json!({"format": "png", "rect": [0, 0, 0, 10]}),
|
||||
json!({"format": "png", "rect": [0, 0, -5, 10]}),
|
||||
json!({"format": "png", "rect": [0, 0, 10]}),
|
||||
json!({"format": "png", "rect": [0, 0, "a", 10]}),
|
||||
json!({"format": "png", "rect": "20,40,40,300"}),
|
||||
json!({"format": "png", "rect": [1e308, 0, 1e308, 10]}),
|
||||
json!({"format": "png", "rect": [0, 0, 100000, 100000]}),
|
||||
json!({"format": "png", "rect": [0, 0, 10, 10], "artboard": 0}),
|
||||
json!({"format": "png", "rect": [0, 0, 10, 10], "useArtboards": true}),
|
||||
json!({"format": "svg", "rect": [0, 0, 10, 10]}),
|
||||
];
|
||||
for p in bad {
|
||||
assert!(s.execute("document.export", &p).is_err(), "{p}");
|
||||
}
|
||||
}
|
||||
@@ -91,3 +91,20 @@ fn a_chosen_artboard_s_svg_leaves_out_its_neighbours_art_in_a_shared_group() {
|
||||
assert_eq!(third_only.matches("<path").count() + third_only.matches("<rect").count(), 1, "{third_only}");
|
||||
assert!(third_only.contains(" C") || third_only.contains("<ellipse"), "{third_only}");
|
||||
}
|
||||
|
||||
/// An object off a chosen artboard whose drop shadow reaches onto it is in that artboard's SVG.
|
||||
#[test]
|
||||
fn a_chosen_artboard_s_svg_keeps_an_object_whose_shadow_reaches_it() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
let x0 = s.doc().unwrap().doc.artboards[0].rect.x0;
|
||||
let id = s.execute("shape.rectangle", &json!({"x": x0 - 30.0, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
|
||||
let paths = |s: &mut Session| {
|
||||
let svg = s.execute("document.serialize", &json!({"format": "svg", "artboard": 0})).unwrap();
|
||||
let text = svg["text"].as_str().unwrap().to_string();
|
||||
text.matches("<path").count() + text.matches("<rect").count()
|
||||
};
|
||||
assert_eq!(paths(&mut s), 0, "the plain rectangle is off the artboard");
|
||||
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [id], "params": {"x": 20, "y": 0, "blur": 0}})).unwrap();
|
||||
assert_eq!(paths(&mut s), 1, "its shadow reaches 10 pt onto the artboard");
|
||||
}
|
||||
@@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Flatten Transparency…",
|
||||
["Object"],
|
||||
None,
|
||||
"{preset?: \"high\"|\"medium\"|\"low\" or a saved preset's name (see flattener.presets.list; default medium), balance?: 0..100 (raster/vector balance; 0 rasterizes everything), lineArtPpi?: 1..2400, gradientPpi?: 1..2400 (areas only gradients and meshes reach), textToOutlines?, strokesToOutlines?, clipComplexRegions? (clip images to the region outlines, else rectangles), antiAlias?, preserveAlpha? (composite over nothing instead of white), preserveOverprints? (areas showing one paint keep its colour and overprint; false clears overprints), options?: {the same keys}, ids?|id?} overlapping transparent objects become one group of flat-colour regions (CMYK colours composited ink by ink in CMYK documents), plus an image where gradients, patterns, images, masks or raster effects reach; objects without transparency stay → {ids, rasterized: images made, vector: regions made, options}",
|
||||
"{preset?: \"high\"|\"medium\"|\"low\" or a saved preset's name (see flattener.presets.list; default medium), balance?: 0..100 (raster/vector balance; 0 rasterizes everything), lineArtPpi?: 1..2400, gradientPpi?: 1..2400 (areas only gradients and meshes reach), textToOutlines?, strokesToOutlines?, clipComplexRegions? (clip images to the region outlines, else rectangles), antiAlias?, preserveAlpha? (composite over nothing instead of white), preserveOverprints? (areas showing one paint keep its colour and overprint; false clears overprints), options?: {the same keys}, ids?|id?} overlapping transparent objects become one group of flat-colour regions (CMYK colours composited ink by ink in CMYK documents), plus an image where gradients, patterns, images, masks or raster effects reach; objects without transparency stay → {ids, rasterized: images made, vector: regions made, options, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
|
||||
has_doc,
|
||||
flatten
|
||||
),
|
||||
@@ -259,13 +259,19 @@ fn flatten(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(EngineError::Other("Flatten Transparency: select objects to flatten".into()));
|
||||
}
|
||||
let src = s.doc()?.doc.clone();
|
||||
let failed = vectorcraft_render::decode_failures();
|
||||
let plan = plan(&src, &roots, &o, true)?;
|
||||
let warning = super::rasterfx::undecoded_warning(failed);
|
||||
let options = serde_json::to_value(&o).unwrap_or_default();
|
||||
if plan.flat.is_empty() && !plan.kept.iter().any(|id| src.node(*id).is_some_and(|n| kept_work(n, &o))) {
|
||||
return Ok(json!({ "ids": roots.iter().map(|i| i.0).collect::<Vec<_>>(), "rasterized": 0, "vector": 0, "options": options }));
|
||||
}
|
||||
let (ids, vector, rasterized) = s.edit("Flatten Transparency", |d, sel| apply(d, sel, plan, &o))?;
|
||||
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "rasterized": rasterized, "vector": vector, "options": options }))
|
||||
let mut out = json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "rasterized": rasterized, "vector": vector, "options": options });
|
||||
if let Some(w) = warning {
|
||||
out["warning"] = json!(w);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ---------- presets ----------
|
||||
@@ -818,7 +824,10 @@ fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions, render: bool) -> R
|
||||
let see_through: Vec<bool> = plain.iter().map(|n| n.shows_transparency() || o.draws_see_through_image(n)).collect();
|
||||
let cmyk = src.color_mode == ColorMode::Cmyk;
|
||||
let mut out = Plan { flat: vec![], kept: vec![], transparent: roots.iter().zip(&see_through).filter(|(_, t)| **t).map(|(id, _)| *id).collect() };
|
||||
// Baking above stopped early when cancelled; each group is a place to stop (a plan changes nothing).
|
||||
vectorcraft_doc::cancel::check()?;
|
||||
for g in overlapping(&reaches) {
|
||||
vectorcraft_doc::cancel::check()?;
|
||||
let ids: Vec<NodeId> = g.iter().map(|&i| roots[i]).collect();
|
||||
let group = g.iter().any(|&i| see_through[i]).then(|| {
|
||||
let plain: Vec<&Node> = g.iter().map(|&i| &plain[i]).collect();
|
||||
@@ -950,6 +959,8 @@ fn render_raster(doc: &Document, art: &[Node], rect: Rect, ppi: f64, clip: Optio
|
||||
);
|
||||
let mut r = vectorcraft_render::Renderer::new();
|
||||
let mut img = r.render_region(&isolated_doc(doc, art.to_vec()), region, scale, !o.preserve_alpha);
|
||||
// The drawing stops early when cancelled: this image is not whole.
|
||||
vectorcraft_doc::cancel::check()?;
|
||||
if o.no_transparency.is_some() && clip.is_some() && !o.preserve_alpha {
|
||||
// Without transparency the clip alone shapes the image: it is opaque, over white (its
|
||||
// edge pixels too).
|
||||
|
||||
@@ -114,9 +114,12 @@ pub(super) fn used_fonts(d: &Document) -> BTreeSet<UsedFont> {
|
||||
fonts
|
||||
}
|
||||
|
||||
/// The fonts (family, style, version) of the type of `d` that prints on pages showing `areas`
|
||||
/// (document space): visible type on `layers` (the layers that print) but not on a template layer
|
||||
/// (of area type and type on a path that don't fit, the runs that their layout draws),
|
||||
/// The type of `d` that prints on pages showing `areas` (document space): the fonts (family, style,
|
||||
/// version) it is set in, and the ids of the area type and type on a path among the objects of the
|
||||
/// document whose text doesn't fit (not those in the art of symbols, opacity masks, patterns,
|
||||
/// brushes or placed documents).
|
||||
/// The type that prints is visible type on `layers` (the layers that print) but not on a template
|
||||
/// layer (of area type and type on a path that don't fit, the runs that their layout draws),
|
||||
/// where what it paints ([`vectorcraft_render::painted_bounds`], its effects applied) meets an area
|
||||
/// inside the clipping paths around it, the type in the opacity masks and patterns of the objects
|
||||
/// that print and in the pieces their brushes draw, and the type of the symbols that their
|
||||
@@ -125,27 +128,45 @@ pub(super) fn used_fonts(d: &Document) -> BTreeSet<UsedFont> {
|
||||
/// fills or strokes of its own, are taken with it when what it paints meets an area, and so is the
|
||||
/// art of a placed document (`d` as [`Document::with_placed_art`] gives it, for that art's
|
||||
/// resources).
|
||||
pub(super) fn printed_fonts(d: &Document, layers: &[Arc<Node>], areas: &[Rect]) -> BTreeSet<UsedFont> {
|
||||
let mut walk = Printed { d, fonts: BTreeSet::new(), symbols: BTreeSet::new(), patterns: BTreeSet::new() };
|
||||
pub(super) fn printed_type(d: &Document, layers: &[Arc<Node>], areas: &[Rect]) -> (BTreeSet<UsedFont>, BTreeSet<NodeId>) {
|
||||
let mut walk = Printed { d, own: true, fonts: BTreeSet::new(), overflow: BTreeSet::new(), symbols: BTreeSet::new(), patterns: BTreeSet::new() };
|
||||
for l in layers {
|
||||
walk.node(l, Some(areas));
|
||||
}
|
||||
walk.fonts
|
||||
(walk.fonts, walk.overflow)
|
||||
}
|
||||
|
||||
/// The walk of [`printed_fonts`].
|
||||
/// The warning of an export that draws `layers` of `d` over `areas` (document space; `None`: the
|
||||
/// whole of them) about the area type and type on a path that it draws and whose text doesn't fit,
|
||||
/// found as [`printed_type`] finds them for Print: visible objects of the document that aren't on
|
||||
/// a template layer.
|
||||
pub(super) fn export_overflow_warning(d: &Document, layers: &[Arc<Node>], areas: Option<&[Rect]>) -> Option<String> {
|
||||
let mut walk = Printed { d, own: true, fonts: BTreeSet::new(), overflow: BTreeSet::new(), symbols: BTreeSet::new(), patterns: BTreeSet::new() };
|
||||
for l in layers {
|
||||
walk.node(l, areas);
|
||||
}
|
||||
let overflow: Vec<NodeId> = walk.overflow.into_iter().collect();
|
||||
super::typecmd::export_overflow_warning(&overflow)
|
||||
}
|
||||
|
||||
/// The walk of [`printed_type`].
|
||||
struct Printed<'a> {
|
||||
d: &'a Document,
|
||||
/// Whether the walk is among the objects of the document, not in art drawn for one.
|
||||
own: bool,
|
||||
fonts: BTreeSet<UsedFont>,
|
||||
/// The objects of the document whose text doesn't fit.
|
||||
overflow: BTreeSet<NodeId>,
|
||||
/// The symbols and patterns whose art was walked.
|
||||
symbols: BTreeSet<String>,
|
||||
patterns: BTreeSet<String>,
|
||||
}
|
||||
|
||||
impl Printed<'_> {
|
||||
/// Adds the fonts of `n` and its descendants that print: those whose bounds meet one of `areas`,
|
||||
/// or with `None` (in the art of a symbol, opacity mask, pattern or placed document, in brush
|
||||
/// pieces, or in a live object) all of them but those outside the clipping path of a clip group.
|
||||
/// Adds the fonts of `n` and its descendants that print, and the ids of those that don't fit
|
||||
/// while [`Printed::own`] is set: those whose bounds meet one of `areas`, or with `None` (in the
|
||||
/// art of a symbol, opacity mask, pattern or placed document, in brush pieces, or in a live
|
||||
/// object) all of them but those outside the clipping path of a clip group.
|
||||
fn node(&mut self, n: &Node, areas: Option<&[Rect]>) {
|
||||
if !n.visible || matches!(n.kind, NodeKind::Layer { template: true, .. }) {
|
||||
return;
|
||||
@@ -162,9 +183,9 @@ impl Printed<'_> {
|
||||
}
|
||||
// The art that its opacity mask, pattern paints and brushed strokes draw.
|
||||
let mask = n.extras().mask.as_deref().filter(|m| !m.disabled);
|
||||
let runs = match &n.kind {
|
||||
let (runs, overflows) = match &n.kind {
|
||||
NodeKind::Text(t) => drawn_runs(t),
|
||||
_ => vec![],
|
||||
_ => (vec![], false),
|
||||
};
|
||||
let items: Vec<&AppearanceItem> =
|
||||
n.appearance.items.iter().filter(|i| i.visible() && !matches!(i, AppearanceItem::Stroke(s) if s.width <= 0.0)).collect();
|
||||
@@ -180,7 +201,7 @@ impl Printed<'_> {
|
||||
let brushed = vectorcraft_brush::has_brush(n);
|
||||
if (mask.is_some() || !patterns.is_empty() || brushed) && areas.is_none_or(|a| !open || self.meets(n, a)) {
|
||||
if let Some(m) = mask {
|
||||
self.node(&m.art, None);
|
||||
self.art(&m.art);
|
||||
}
|
||||
let d = self.d;
|
||||
for name in patterns {
|
||||
@@ -188,19 +209,22 @@ impl Printed<'_> {
|
||||
&& let Some(p) = d.pattern(name)
|
||||
{
|
||||
for art in &p.art {
|
||||
self.node(art, None);
|
||||
self.art(art);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The pieces its brushes draw along the path (art and pattern brushes leave type out).
|
||||
if brushed {
|
||||
for piece in vectorcraft_brush::node_pieces(d, n).unwrap_or_default() {
|
||||
self.node(&piece, None);
|
||||
self.art(&piece);
|
||||
}
|
||||
}
|
||||
}
|
||||
match &n.kind {
|
||||
NodeKind::Text(_) => {
|
||||
if overflows && self.own {
|
||||
self.overflow.insert(n.id);
|
||||
}
|
||||
for r in runs {
|
||||
self.fonts.insert((r.style.font_family.clone(), r.style.font_style.clone(), r.style.font_version.clone()));
|
||||
// Inline graphics draw their symbols' art.
|
||||
@@ -212,7 +236,7 @@ impl Printed<'_> {
|
||||
NodeKind::SymbolInstance { symbol, .. } => self.symbol(symbol),
|
||||
NodeKind::PlacedDocument(p) => {
|
||||
for art in p.art(n) {
|
||||
self.node(&art, None);
|
||||
self.art(&art);
|
||||
}
|
||||
}
|
||||
NodeKind::Layer { children, clip, .. } | NodeKind::Group { children, clip } if !moved => {
|
||||
@@ -240,10 +264,19 @@ impl Printed<'_> {
|
||||
if self.symbols.insert(name.to_string())
|
||||
&& let Some(s) = d.symbols.iter().find(|s| s.name == name)
|
||||
{
|
||||
self.node(&s.art, None);
|
||||
self.art(&s.art);
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds the fonts of `art`, drawn for an object of the document but not one of its objects (the
|
||||
/// art of a symbol, opacity mask, pattern or placed document, or a brush piece), with
|
||||
/// [`Printed::node`]'s `None`.
|
||||
fn art(&mut self, art: &Node) {
|
||||
let own = std::mem::replace(&mut self.own, false);
|
||||
self.node(art, None);
|
||||
self.own = own;
|
||||
}
|
||||
|
||||
/// Whether what `n` paints, its effects applied, meets one of `areas`.
|
||||
fn meets(&self, n: &Node, areas: &[Rect]) -> bool {
|
||||
vectorcraft_render::painted_bounds(self.d, n).is_some_and(|b| areas.iter().any(|r| b.intersect(*r).area() > 0.0 || r.contains_rect(b)))
|
||||
@@ -256,14 +289,15 @@ impl Printed<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The runs of `t` that print: all of them, or of area type and type on a path that don't fit, the
|
||||
/// runs that their layout draws glyphs or inline graphics of.
|
||||
fn drawn_runs(t: &vectorcraft_doc::TextObject) -> Vec<&TextRun> {
|
||||
/// The runs of `t` that print, and whether its text doesn't fit: all of them, or of area type and
|
||||
/// type on a path that don't fit, the runs that their layout draws glyphs or inline graphics of.
|
||||
fn drawn_runs(t: &vectorcraft_doc::TextObject) -> (Vec<&TextRun>, bool) {
|
||||
let drawn: Option<BTreeSet<usize>> = (!matches!(t.kind, TextKind::Point))
|
||||
.then(|| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t))
|
||||
.filter(|lay| lay.overflow)
|
||||
.map(|lay| lay.glyphs.iter().map(|g| g.run).chain(lay.inlines.iter().map(|i| i.run)).collect());
|
||||
t.runs.iter().enumerate().filter(|(i, _)| drawn.as_ref().is_none_or(|d| d.contains(i))).map(|(_, r)| r).collect()
|
||||
let runs = t.runs.iter().enumerate().filter(|(i, _)| drawn.as_ref().is_none_or(|d| d.contains(i))).map(|(_, r)| r).collect();
|
||||
(runs, drawn.is_some())
|
||||
}
|
||||
|
||||
/// What an export that draws type (as outlines, embedded fonts or pixels) says when `d` uses font
|
||||
@@ -441,16 +475,14 @@ pub(crate) fn rescan(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
});
|
||||
if !texts.is_empty() {
|
||||
let saved = !d.is_dirty();
|
||||
let doc = std::sync::Arc::make_mut(&mut d.doc);
|
||||
for id in texts {
|
||||
if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) {
|
||||
refresh_bounds(t);
|
||||
d.change_unsaved(|doc| {
|
||||
let doc = std::sync::Arc::make_mut(doc);
|
||||
for id in texts {
|
||||
if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) {
|
||||
refresh_bounds(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
if saved {
|
||||
d.mark_saved();
|
||||
}
|
||||
});
|
||||
}
|
||||
d.revision += 1;
|
||||
}
|
||||
|
||||
+782
-38
@@ -11,9 +11,10 @@ use std::sync::Arc;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{
|
||||
Appearance, CharStyle, Document, GraphDesign, GraphKind, GraphSpec, Justify, Node, NodeId, NodeKind, TextObject, TickLength, ValueAxisSide,
|
||||
Appearance, CharStyle, ColumnDesign, ColumnType, Document, Fractions, GraphDesign, GraphKind, GraphSpec, Justify, Node, NodeId, NodeKind,
|
||||
TextObject, TickLength, ValueAxisSide,
|
||||
};
|
||||
use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect, shapes};
|
||||
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, shapes};
|
||||
|
||||
use super::typecmd::refresh_bounds;
|
||||
use super::*;
|
||||
@@ -56,6 +57,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
design
|
||||
),
|
||||
cmd!(
|
||||
"graph.column",
|
||||
"Column…",
|
||||
["Object", "Graph"],
|
||||
None,
|
||||
"{id?, seriesIndexes?: [index], design?: name|null, columnType?: vertical|uniform|repeating|sliding, each?: value (more than 0), fractions?: chop|scale} draw the columns or bars and the legend swatch of the series selected with Group Selection (or listed, or every series) with a graph design, its backmost object as its frame and its guides left out: vertical stretches it along the value axis to each column's length at the column's width, uniform scales it both ways to that length, centered on the column, repeating stacks copies out from the zero line that each stand for `each` and cuts off the last one where the value ends (chop) or scales it down to fit (scale) (vertical when one copy would be longer than the canvas), and sliding scales the part above its horizontal guide line (in bar graphs, right of a vertical one) in proportion to the column's width and stretches the part below to fill the rest (vertical without a guide, or in a column too short for the part above); the design keeps its own orientation for negative values, and columns drawn with a design cast no drop shadow; null = plain columns; options left out keep the series' shared ones, else vertical, 10 and chop. Column, stacked column, bar and stacked bar series. No design → {designs: [names], design: the selected series' design or null, columnType, each, fractions}",
|
||||
has_selection,
|
||||
set_column
|
||||
),
|
||||
cmd!(
|
||||
"graph.marker",
|
||||
"Marker…",
|
||||
@@ -82,20 +92,42 @@ impl Cell {
|
||||
}
|
||||
}
|
||||
|
||||
/// Split `s` at each `sep` outside straight quotes (a `""` inside quotes toggles twice, so stays inside).
|
||||
fn split_unquoted(s: &str, sep: char) -> Vec<&str> {
|
||||
let mut parts = vec![];
|
||||
let mut quoted = false;
|
||||
let mut start = 0;
|
||||
for (i, ch) in s.char_indices() {
|
||||
if ch == '"' {
|
||||
quoted = !quoted;
|
||||
} else if ch == sep && !quoted {
|
||||
parts.push(s.get(start..i).unwrap_or_default());
|
||||
start = i + ch.len_utf8();
|
||||
}
|
||||
}
|
||||
parts.push(s.get(start..).unwrap_or_default());
|
||||
parts
|
||||
}
|
||||
|
||||
/// One CSV cell: a cell wholly in quotes keeps its inner text (`""` = `"`), others are trimmed.
|
||||
fn cell(c: &str) -> Cell {
|
||||
let c = c.trim();
|
||||
match c.strip_prefix('"').and_then(|c| c.strip_suffix('"')) {
|
||||
Some(inner) => Cell { text: inner.replace("\"\"", "\""), quoted: true },
|
||||
None => Cell { text: c.trim_matches('"').to_string(), quoted: false },
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_csv(csv: &str) -> (Vec<String>, Vec<String>, Vec<Vec<Option<f64>>>) {
|
||||
let rows: Vec<Vec<Cell>> = csv
|
||||
.lines()
|
||||
// Records end at line breaks outside quotes; a record is tab-separated if it has a tab outside quotes.
|
||||
let rows: Vec<Vec<Cell>> = split_unquoted(csv, '\n')
|
||||
.into_iter()
|
||||
.map(|l| l.trim_end_matches('\r'))
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.map(|l| {
|
||||
let sep = if l.contains('\t') { '\t' } else { ',' };
|
||||
l.split(sep)
|
||||
.map(|c| {
|
||||
let c = c.trim();
|
||||
let quoted = c.len() >= 2 && c.starts_with('"') && c.ends_with('"');
|
||||
Cell { text: c.trim_matches('"').to_string(), quoted }
|
||||
})
|
||||
.collect()
|
||||
let tabs = split_unquoted(l, '\t');
|
||||
let parts = if tabs.len() > 1 { tabs } else { split_unquoted(l, ',') };
|
||||
parts.into_iter().map(cell).collect()
|
||||
})
|
||||
.collect();
|
||||
let Some(first) = rows.first() else { return (vec![], vec![], vec![]) };
|
||||
@@ -192,7 +224,9 @@ fn drop_empty_rows(g: &mut GraphSpec) {
|
||||
|
||||
fn fmt_value(v: f64) -> String {
|
||||
if (v - v.round()).abs() < 1e-9 {
|
||||
format!("{}", v.round() as i64)
|
||||
// Beyond the i64 range the cast would saturate; every f64 that large is a whole number, so
|
||||
// `{:.0}` prints its exact digits.
|
||||
if v.abs() < 9e18 { format!("{}", v.round() as i64) } else { format!("{v:.0}") }
|
||||
} else {
|
||||
format!("{:.2}", v).trim_end_matches('0').trim_end_matches('.').to_string()
|
||||
}
|
||||
@@ -287,6 +321,8 @@ struct Gen<'a> {
|
||||
out: Vec<Arc<Node>>,
|
||||
/// The labels' kept character styles.
|
||||
styles: &'a vectorcraft_doc::LabelStyles,
|
||||
/// What the column designs may still add.
|
||||
columns: Allowance,
|
||||
}
|
||||
|
||||
/// What a graph label shows, for its kept character style.
|
||||
@@ -373,10 +409,26 @@ impl Gen<'_> {
|
||||
/// A data point's marker filling `cell`: the series' marker design, else the default square in the series' paint.
|
||||
fn marker(&mut self, design: Option<&Arc<Node>>, cell: Rect, (fill, stroke, w): (Paint, Paint, f64)) -> Arc<Node> {
|
||||
match design.and_then(|a| fit_design(self.d, a, cell)) {
|
||||
Some(n) => n,
|
||||
Some(n) => self.design_copy(n),
|
||||
None => self.path(shapes::rectangle(cell), fill, stroke, w),
|
||||
}
|
||||
}
|
||||
/// A legend swatch filling `cell`: the series' column design, else its [`Self::marker`].
|
||||
fn swatch(&mut self, column: Option<&ColumnArt>, marker: Option<&Arc<Node>>, cell: Rect, paint: (Paint, Paint, f64)) -> Arc<Node> {
|
||||
let fitted = match column {
|
||||
Some(c) if self.columns.take(c.art.count()) => fit_design(self.d, &c.art, cell),
|
||||
_ => None,
|
||||
};
|
||||
match fitted {
|
||||
Some(n) => self.design_copy(n),
|
||||
None => self.marker(marker, cell, paint),
|
||||
}
|
||||
}
|
||||
/// A copy of design art drawn in a series: a single path gets a group of its own, as painting a series paints the
|
||||
/// paths directly inside its group and the design keeps its own paint.
|
||||
fn design_copy(&mut self, n: Arc<Node>) -> Arc<Node> {
|
||||
if matches!(n.kind, NodeKind::Path { .. }) { Arc::new(Node::group(self.d.alloc_id(), vec![n])) } else { n }
|
||||
}
|
||||
/// A series group. Empty series are omitted by the caller; the index is stored on the group
|
||||
/// so later paint edits find the series without counting siblings.
|
||||
fn series(&mut self, name: &str, index: usize, children: Vec<Arc<Node>>) -> Arc<Node> {
|
||||
@@ -471,24 +523,260 @@ fn marker_cost(g: &GraphSpec, designs: &[Option<Arc<Node>>]) -> usize {
|
||||
designs.iter().flatten().map(|a| a.count().saturating_mul(points)).fold(0, usize::saturating_add)
|
||||
}
|
||||
|
||||
/// The frame graph design `art` is drawn around: its backmost object's bounds (the whole art's when the design is a
|
||||
/// single object). `None` for art with no area.
|
||||
fn design_frame(art: &Node) -> Option<Rect> {
|
||||
let usable = |b: &Rect| b.width() > 1e-9 && b.height() > 1e-9 && b.width().is_finite() && b.height().is_finite();
|
||||
let group = matches!(art.kind, NodeKind::Group { .. });
|
||||
let backmost = art.children().filter(|_| group).and_then(|c| c.first()).and_then(|c| c.geometric_bounds()).filter(usable);
|
||||
backmost.or_else(|| art.geometric_bounds().filter(usable))
|
||||
}
|
||||
|
||||
/// Graph design `art` scaled into `cell` (Object › Graph › Marker): its backmost object, the rectangle a design is
|
||||
/// drawn around, fills the cell (the whole art does when the design is a single object), strokes scaling with it.
|
||||
/// Fresh ids. `None` for art with no area.
|
||||
fn fit_design(d: &mut Document, art: &Node, cell: Rect) -> Option<Arc<Node>> {
|
||||
let usable = |b: &Rect| b.width() > 1e-9 && b.height() > 1e-9 && b.width().is_finite() && b.height().is_finite();
|
||||
let group = matches!(art.kind, NodeKind::Group { .. });
|
||||
let backmost = art.children().filter(|_| group).and_then(|c| c.first()).and_then(|c| c.geometric_bounds()).filter(usable);
|
||||
let frame = backmost.or_else(|| art.geometric_bounds().filter(usable))?;
|
||||
let a = Affine::translate(cell.origin().to_vec2())
|
||||
* Affine::scale_non_uniform(cell.width() / frame.width(), cell.height() / frame.height())
|
||||
* Affine::translate(-frame.origin().to_vec2());
|
||||
let a = fit_affine(design_frame(art)?, cell);
|
||||
// Strokes, effects and pattern tiles scale with the art.
|
||||
let art = super::place::transformed(art.clone(), a);
|
||||
Some(Arc::new(d.reid(&art)))
|
||||
}
|
||||
|
||||
/// Build the graph's children.
|
||||
/// The map that puts a design's frame `frame` on `cell` ([`fit_design`]).
|
||||
fn fit_affine(frame: Rect, cell: Rect) -> Affine {
|
||||
Affine::translate(cell.origin().to_vec2())
|
||||
* Affine::scale_non_uniform(cell.width() / frame.width(), cell.height() / frame.height())
|
||||
* Affine::translate(-frame.origin().to_vec2())
|
||||
}
|
||||
|
||||
/// Most objects a graph's column designs may add (each column, repeated copy and legend swatch draws a copy).
|
||||
const COLUMN_BUDGET: usize = 200_000;
|
||||
|
||||
/// A series' column design (Object › Graph › Column): its options, its art without guide lines, and the bounds of
|
||||
/// those guides.
|
||||
struct ColumnArt {
|
||||
options: ColumnDesign,
|
||||
art: Node,
|
||||
guides: Vec<Rect>,
|
||||
}
|
||||
|
||||
/// `art` without the paths made into guides, at any depth.
|
||||
fn without_guides(art: &Node) -> Node {
|
||||
let mut n = art.clone();
|
||||
if let Some(ch) = n.children_mut() {
|
||||
let old = std::mem::take(ch);
|
||||
*ch = old.iter().filter(|c| !matches!(c.kind, NodeKind::Path { guide: true, .. })).map(|c| Arc::new(without_guides(c))).collect();
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// Each series' column design, when the document has the design.
|
||||
fn column_designs(d: &Document, g: &GraphSpec) -> Vec<Option<ColumnArt>> {
|
||||
let nser = g.rows.iter().map(Vec::len).max().unwrap_or(0).clamp(1, vectorcraft_doc::MAX_GRAPH_SERIES);
|
||||
(0..nser)
|
||||
.map(|s| {
|
||||
let options = g.series_columns.get(s)?.as_ref()?;
|
||||
let art = &d.graph_designs.iter().find(|x| x.name == options.design)?.art;
|
||||
let mut guides = vec![];
|
||||
art.walk(&mut |n| {
|
||||
if matches!(n.kind, NodeKind::Path { guide: true, .. })
|
||||
&& let Some(b) = n.geometric_bounds()
|
||||
{
|
||||
guides.push(b);
|
||||
}
|
||||
});
|
||||
Some(ColumnArt { options: options.clone(), art: without_guides(art), guides })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The value one copy of a repeating design stands for, when the design repeats and the value is usable.
|
||||
fn repeat_each(c: &ColumnDesign) -> Option<f64> {
|
||||
(c.column_type == ColumnType::Repeating && c.each.is_finite() && c.each > 0.0).then_some(c.each)
|
||||
}
|
||||
|
||||
/// The objects a graph's column designs may still add (out of [`COLUMN_BUDGET`]); `short` once a copy needed more than
|
||||
/// was left, and the graph is then drawn with plain columns.
|
||||
struct Allowance {
|
||||
left: usize,
|
||||
short: bool,
|
||||
}
|
||||
|
||||
impl Allowance {
|
||||
fn new() -> Self {
|
||||
Self { left: COLUMN_BUDGET, short: false }
|
||||
}
|
||||
/// Spend `n` objects, when that many are left.
|
||||
fn take(&mut self, n: usize) -> bool {
|
||||
match self.left.checked_sub(n) {
|
||||
Some(rest) => {
|
||||
self.left = rest;
|
||||
true
|
||||
}
|
||||
None => {
|
||||
self.short = true;
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Column or bar `rect` drawn with column design `c` (Object › Graph › Column), as a group of fresh art, its copies
|
||||
/// spent from `left`. `far_low`: the value end of the column is at the rect's lower coordinate (the top of a column above
|
||||
/// zero, the left end of a bar below zero), so repeating copies stack from the other end. `per_unit`: plot points per
|
||||
/// unit of value. `None` for art with no area, for a column of no length and when `left` runs out, which are drawn plain.
|
||||
fn column_art(
|
||||
d: &mut Document,
|
||||
c: &ColumnArt,
|
||||
rect: Rect,
|
||||
(horizontal, far_low): (bool, bool),
|
||||
per_unit: f64,
|
||||
left: &mut Allowance,
|
||||
) -> Option<Arc<Node>> {
|
||||
let frame = design_frame(&c.art)?;
|
||||
let length = |r: Rect| if horizontal { r.width() } else { r.height() };
|
||||
let thickness = |r: Rect| if horizontal { r.height() } else { r.width() };
|
||||
if length(rect) <= 1e-9 || !length(rect).is_finite() {
|
||||
return None;
|
||||
}
|
||||
let cost = c.art.count();
|
||||
// A copy of the design fitted to `cell`; and a copy moved by `a` and clipped to what it paints, cut at `at` (an x when
|
||||
// `along_x`, else a y) with the side below `at` kept when `keep_low` (two objects more).
|
||||
let fit = |d: &mut Document, cell: Rect, left: &mut Allowance| if left.take(cost) { fit_design(d, &c.art, cell) } else { None };
|
||||
let clipped = |d: &mut Document, a: Affine, (along_x, keep_low, at): (bool, bool, f64), left: &mut Allowance| {
|
||||
let art = left.take(cost + 2).then(|| d.reid(&super::place::transformed(c.art.clone(), a)))?;
|
||||
// The box of the copy and of each object in it that isn't a group or layer, as the brush art past a brushed
|
||||
// stroke counts only in its own object's box.
|
||||
let mut b = vectorcraft_render::painted_bounds(d, &art);
|
||||
art.walk(&mut |n| {
|
||||
if !matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) {
|
||||
b = vectorcraft_geom::union_opt(b, vectorcraft_render::painted_bounds(d, n));
|
||||
}
|
||||
});
|
||||
let b = b.or_else(|| art.geometric_bounds())?;
|
||||
let keep = match (along_x, keep_low) {
|
||||
(true, true) => Rect::new(b.x0, b.y0, at.max(b.x0), b.y1),
|
||||
(true, false) => Rect::new(at.min(b.x1), b.y0, b.x1, b.y1),
|
||||
(false, true) => Rect::new(b.x0, b.y0, b.x1, at.max(b.y0)),
|
||||
(false, false) => Rect::new(b.x0, at.min(b.y1), b.x1, b.y1),
|
||||
};
|
||||
Some(Arc::new(super::rasterfx::clip_group(d, shapes::rectangle(keep), FillRule::NonZero, art)))
|
||||
};
|
||||
// The part of the column from `a` to `b` points out from its zero end.
|
||||
let piece = |a: f64, b: f64| match (horizontal, far_low) {
|
||||
(true, true) => Rect::new(rect.x1 - b, rect.y0, rect.x1 - a, rect.y1),
|
||||
(true, false) => Rect::new(rect.x0 + a, rect.y0, rect.x0 + b, rect.y1),
|
||||
(false, true) => Rect::new(rect.x0, rect.y1 - b, rect.x1, rect.y1 - a),
|
||||
(false, false) => Rect::new(rect.x0, rect.y0 + a, rect.x1, rect.y0 + b),
|
||||
};
|
||||
let mut parts = vec![];
|
||||
match c.options.column_type {
|
||||
ColumnType::Uniform => {
|
||||
// Scaled both ways to the column's length, centered across it.
|
||||
let t = thickness(frame) * length(rect) / length(frame) / 2.0;
|
||||
let cell = if horizontal {
|
||||
Rect::new(rect.x0, rect.center().y - t, rect.x1, rect.center().y + t)
|
||||
} else {
|
||||
Rect::new(rect.center().x - t, rect.y0, rect.center().x + t, rect.y1)
|
||||
};
|
||||
parts.push(fit(d, cell, left)?);
|
||||
}
|
||||
ColumnType::Repeating => {
|
||||
// When the value per copy is unusable, too small to draw or makes one copy longer than the canvas, the design
|
||||
// is drawn vertically scaled.
|
||||
let Some(unit) = repeat_each(&c.options).map(|e| e * per_unit).filter(|u| u.is_finite() && *u > 1e-9 && *u <= crate::MAX_COORD) else {
|
||||
parts.push(fit(d, rect, left)?);
|
||||
return Some(Arc::new(Node::group(d.alloc_id(), parts)));
|
||||
};
|
||||
// The whole copies, and the points of the column left for the last one (none under a millionth of a point).
|
||||
let whole = ((length(rect) + 1e-6) / unit).floor();
|
||||
let rest = length(rect) - whole * unit;
|
||||
// Each copy is spent from `left`, which stops the loop.
|
||||
for i in 0..whole as usize {
|
||||
parts.push(fit(d, piece(i as f64 * unit, (i + 1) as f64 * unit), left)?);
|
||||
}
|
||||
if rest > 1e-6 {
|
||||
let start = whole * unit;
|
||||
match c.options.fractions {
|
||||
Fractions::Scale => parts.push(fit(d, piece(start, start + rest), left)?),
|
||||
Fractions::Chop => {
|
||||
// The last copy cut off where the column ends, and nowhere else.
|
||||
let cut = match (horizontal, far_low) {
|
||||
(true, true) => (true, false, rect.x0),
|
||||
(true, false) => (true, true, rect.x1),
|
||||
(false, true) => (false, false, rect.y0),
|
||||
(false, false) => (false, true, rect.y1),
|
||||
};
|
||||
parts.push(clipped(d, fit_affine(frame, piece(start, start + unit)), cut, left)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ColumnType::Sliding => match slide(frame, &c.guides, rect, horizontal) {
|
||||
Some((kept, stretched, at)) => {
|
||||
// Each copy cut at the guide, on its own side.
|
||||
parts.push(clipped(d, kept, (horizontal, !horizontal, at), left)?);
|
||||
parts.push(clipped(d, stretched, (horizontal, horizontal, at), left)?);
|
||||
}
|
||||
None => parts.push(fit(d, rect, left)?),
|
||||
},
|
||||
ColumnType::Vertical => parts.push(fit(d, rect, left)?),
|
||||
}
|
||||
Some(Arc::new(Node::group(d.alloc_id(), parts)))
|
||||
}
|
||||
|
||||
/// Sliding design with frame `frame` in column `rect`, at the column's width: the map of the part above the first
|
||||
/// horizontal guide in `guides` inside the frame (in a bar, right of the first vertical one), scaled in proportion and
|
||||
/// put at the rect's top (in a bar, its right end), the value end for values above zero; the map of the part below (left
|
||||
/// of) it, stretched or compressed to fill the rest; and where the guide lands (y, or x in a bar). Both maps put the guide there. `None` without such a guide, or when the column is too
|
||||
/// short for the part kept in proportion.
|
||||
fn slide(frame: Rect, guides: &[Rect], rect: Rect, horizontal: bool) -> Option<(Affine, Affine, f64)> {
|
||||
// A horizontal line (in a bar, a vertical one): longer than it is across, and at most a hundredth of the frame across.
|
||||
let inside = |g: &&Rect| {
|
||||
if horizontal {
|
||||
g.width() < g.height() && g.width() <= frame.width() / 100.0 && g.center().x > frame.x0 && g.center().x < frame.x1
|
||||
} else {
|
||||
g.height() < g.width() && g.height() <= frame.height() / 100.0 && g.center().y > frame.y0 && g.center().y < frame.y1
|
||||
}
|
||||
};
|
||||
let g = guides.iter().find(inside)?.center();
|
||||
if horizontal {
|
||||
let s = rect.height() / frame.height();
|
||||
let (kept, stretched) = (frame.x1 - g.x, g.x - frame.x0);
|
||||
let rest = rect.width() - kept * s;
|
||||
if !(rest.is_finite() && rest > 1e-9) {
|
||||
return None;
|
||||
}
|
||||
let k = rest / stretched;
|
||||
let kept_map = Affine::new([s, 0.0, 0.0, s, rect.x1 - frame.x1 * s, rect.y0 - frame.y0 * s]);
|
||||
let stretched_map = Affine::new([k, 0.0, 0.0, s, rect.x0 - frame.x0 * k, rect.y0 - frame.y0 * s]);
|
||||
Some((kept_map, stretched_map, rect.x1 - kept * s))
|
||||
} else {
|
||||
let s = rect.width() / frame.width();
|
||||
let (kept, stretched) = (g.y - frame.y0, frame.y1 - g.y);
|
||||
let rest = rect.height() - kept * s;
|
||||
if !(rest.is_finite() && rest > 1e-9) {
|
||||
return None;
|
||||
}
|
||||
let k = rest / stretched;
|
||||
let kept_map = Affine::new([s, 0.0, 0.0, s, rect.x0 - frame.x0 * s, rect.y0 - frame.y0 * s]);
|
||||
let stretched_map = Affine::new([s, 0.0, 0.0, k, rect.x0 - frame.x0 * s, rect.y1 - frame.y1 * k]);
|
||||
Some((kept_map, stretched_map, rect.y0 + kept * s))
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the graph's children: with each series' column design (Object › Graph › Column) when the document has it,
|
||||
/// and with plain columns for all when the designs would draw more than COLUMN_BUDGET objects.
|
||||
fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||||
let column_looks = column_designs(d, g);
|
||||
match generate_with(d, g, &column_looks) {
|
||||
Some(out) => out,
|
||||
None => generate_with(d, g, &[]).unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`generate`] with the column designs `column_looks`; `None` when they would draw more than COLUMN_BUDGET objects.
|
||||
fn generate_with(d: &mut Document, g: &GraphSpec, column_looks: &[Option<ColumnArt>]) -> Option<Vec<Arc<Node>>> {
|
||||
let mark = |i: usize, series_fill: bool, stroke: Paint, width: f64| series_marks(g, i, series_fill, stroke, width);
|
||||
// Each series' marker design (Object › Graph › Marker), when the document has it; default squares for all
|
||||
// when the designs would draw more than MARKER_BUDGET objects.
|
||||
@@ -497,7 +785,8 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||||
designs.clear();
|
||||
}
|
||||
let design = |s: usize| designs.get(s).and_then(Option::as_ref);
|
||||
let mut b = Gen { d, out: vec![], styles: &g.label_styles };
|
||||
let column_look = |s: usize| column_looks.get(s).and_then(Option::as_ref);
|
||||
let mut b = Gen { d, out: vec![], styles: &g.label_styles, columns: Allowance::new() };
|
||||
// The labels' sizes (9 pt unless formatted), which their spacing follows.
|
||||
let (vs, cs, ls) =
|
||||
(label_size(&g.label_styles, Role::Value), label_size(&g.label_styles, Role::Category), label_size(&g.label_styles, Role::Legend));
|
||||
@@ -514,6 +803,8 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||||
// Each series' own type (Combine different graph types); one type for the whole graph unless a series has its own.
|
||||
let kinds: Vec<GraphKind> = (0..nser).map(|s| g.series_kind(s)).collect();
|
||||
let kind = |s: usize| kinds.get(s).copied().unwrap_or(g.kind);
|
||||
// Series drawn as columns or bars, which column designs apply to.
|
||||
let columnar = |s: usize| matches!(kind(s), GraphKind::Column | GraphKind::StackedColumn | GraphKind::Bar | GraphKind::StackedBar);
|
||||
let horizontal = matches!(g.kind, GraphKind::Bar | GraphKind::StackedBar);
|
||||
// Where the art right of the plot ends (the right value axis' labels), so the legend starts after it.
|
||||
let mut right_edge = r.x1;
|
||||
@@ -735,6 +1026,11 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||||
let (lo, hi, _) = scale(a);
|
||||
vpos(a, 0.0f64.clamp(lo, hi))
|
||||
};
|
||||
// Plot points per unit of value along value axis `a`.
|
||||
let per_unit = |a: usize| {
|
||||
let (lo, hi, _) = scale(a);
|
||||
(if horizontal { r.width() } else { r.height() }) / (hi - lo)
|
||||
};
|
||||
let mut axes = vec![];
|
||||
// Value axes with ticks and labels: along the bottom for bar graphs, else on the chosen sides (the right
|
||||
// one shows the right scale).
|
||||
@@ -940,10 +1236,24 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||||
let Some(v) = cell(c, s) else { continue };
|
||||
let a = c0 + slot * i as f64 + (slot - bar) / 2.0;
|
||||
let (fill, stroke, w) = mark(s, true, Paint::None, 0.0);
|
||||
let rect = bar_rect(a, bar, zero(axis(s)), vpos(axis(s), v));
|
||||
let (v0, v1) = (zero(axis(s)), vpos(axis(s), v));
|
||||
let rect = bar_rect(a, bar, v0, v1);
|
||||
bars_reach = Some(bars_reach.map_or(rect, |r| r.union(rect)));
|
||||
let n = b.path(shapes::rectangle(rect), fill, stroke, w);
|
||||
shadowed.push((s, n.clone()));
|
||||
// A column drawn with a design casts no shadow.
|
||||
let n = match column_look(s).and_then(|c| column_art(b.d, c, rect, (horizontal, v1 < v0), per_unit(axis(s)), &mut b.columns)) {
|
||||
Some(n) => {
|
||||
// A design can reach past its column (Uniformly Scaled), so the legend keeps clear of it too.
|
||||
if let Some(art) = n.geometric_bounds() {
|
||||
bars_reach = Some(bars_reach.map_or(art, |r| r.union(art)));
|
||||
}
|
||||
n
|
||||
}
|
||||
None => {
|
||||
let n = b.path(shapes::rectangle(rect), fill, stroke, w);
|
||||
shadowed.push((s, n.clone()));
|
||||
n
|
||||
}
|
||||
};
|
||||
if let Some(items) = series.get_mut(s) {
|
||||
items.push(n);
|
||||
}
|
||||
@@ -971,11 +1281,22 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||||
let (fill, stroke, sw) = mark(s, true, Paint::solid(Color::WHITE), 0.25);
|
||||
let rect = bar_rect(a, w, v0, v1);
|
||||
bars_reach = Some(bars_reach.map_or(rect, |r| r.union(rect)));
|
||||
let n = b.path(shapes::rectangle(rect), fill, stroke, sw);
|
||||
// A segment of 0 has no area to shade.
|
||||
if v != 0.0 {
|
||||
shadowed.push((s, n.clone()));
|
||||
}
|
||||
let n = match column_look(s).and_then(|c| column_art(b.d, c, rect, (horizontal, v1 < v0), per_unit(ax), &mut b.columns)) {
|
||||
Some(n) => {
|
||||
if let Some(art) = n.geometric_bounds() {
|
||||
bars_reach = Some(bars_reach.map_or(art, |r| r.union(art)));
|
||||
}
|
||||
n
|
||||
}
|
||||
None => {
|
||||
let n = b.path(shapes::rectangle(rect), fill, stroke, sw);
|
||||
// A segment of 0 has no area to shade.
|
||||
if v != 0.0 {
|
||||
shadowed.push((s, n.clone()));
|
||||
}
|
||||
n
|
||||
}
|
||||
};
|
||||
if let Some(items) = series.get_mut(s) {
|
||||
items.push(n);
|
||||
}
|
||||
@@ -1057,7 +1378,8 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||||
for (s, label, w) in row {
|
||||
let marked = matches!(g.kind, GraphKind::Scatter | GraphKind::Radar) || kind(*s) == GraphKind::Line;
|
||||
let paint = mark(*s, true, Paint::None, 0.0);
|
||||
let swatch = b.marker(design(*s).filter(|_| marked), Rect::new(x, y, x + 8.0, y + 8.0), paint);
|
||||
let swatch =
|
||||
b.swatch(column_look(*s).filter(|_| columnar(*s)), design(*s).filter(|_| marked), Rect::new(x, y, x + 8.0, y + 8.0), paint);
|
||||
if let Some(items) = series.get_mut(*s) {
|
||||
items.push(swatch);
|
||||
}
|
||||
@@ -1079,10 +1401,11 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||||
}
|
||||
y += ls * 1.8 * label.split('|').count() as f64;
|
||||
let y = row_y;
|
||||
// Series drawn with markers show their marker design in the legend.
|
||||
// Series drawn with markers show their marker design in the legend, and series drawn with a column design
|
||||
// that design.
|
||||
let marked = matches!(g.kind, GraphKind::Scatter | GraphKind::Radar) || kind(s) == GraphKind::Line;
|
||||
let paint = mark(s, true, Paint::None, 0.0);
|
||||
items.push(b.marker(design(s).filter(|_| marked), Rect::new(x, y, x + 8.0, y + 8.0), paint));
|
||||
items.push(b.swatch(column_look(s).filter(|_| columnar(s)), design(s).filter(|_| marked), Rect::new(x, y, x + 8.0, y + 8.0), paint));
|
||||
labels.push(b.label(Role::Legend, Point::new(x + 12.0, y + 7.5 + (ls - LABEL_SIZE) * 0.35), &label, Justify::Left, false));
|
||||
}
|
||||
}
|
||||
@@ -1129,7 +1452,7 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||||
let grp = b.group("Legend", labels);
|
||||
b.out.push(grp);
|
||||
}
|
||||
b.out
|
||||
(!b.columns.short).then_some(b.out)
|
||||
}
|
||||
|
||||
fn children_bounds(children: &[Arc<Node>]) -> Option<Rect> {
|
||||
@@ -1475,11 +1798,16 @@ fn design_names(d: &Document) -> Vec<String> {
|
||||
d.graph_designs.iter().map(|x| x.name.clone()).collect()
|
||||
}
|
||||
|
||||
/// Whether graph `g` draws a series with design `name`, as its marker or its columns.
|
||||
fn draws_with(g: &GraphSpec, name: &str) -> bool {
|
||||
g.series_markers.iter().flatten().any(|m| m == name) || g.series_columns.iter().flatten().any(|c| c.design == name)
|
||||
}
|
||||
|
||||
/// Regenerate the graphs whose series use design `name` (it was added or removed).
|
||||
fn refresh_graphs_using(d: &mut Document, name: &str) -> Result<()> {
|
||||
let mut ids = vec![];
|
||||
d.walk(|n| {
|
||||
if n.extras().graph.as_deref().is_some_and(|g| g.series_markers.iter().flatten().any(|m| m == name)) {
|
||||
if n.extras().graph.as_deref().is_some_and(|g| draws_with(g, name)) {
|
||||
ids.push(n.id);
|
||||
}
|
||||
});
|
||||
@@ -1497,6 +1825,7 @@ fn unused_designs(d: &Document) -> Vec<String> {
|
||||
d.walk(|n| {
|
||||
if let Some(g) = n.extras().graph.as_deref() {
|
||||
used.extend(g.series_markers.iter().flatten().cloned());
|
||||
used.extend(g.series_columns.iter().flatten().map(|c| c.design.clone()));
|
||||
}
|
||||
});
|
||||
d.graph_designs.iter().map(|x| x.name.clone()).filter(|n| !used.contains(n)).collect()
|
||||
@@ -1570,7 +1899,7 @@ fn design(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let mut graphs = vec![];
|
||||
d.walk(|n| {
|
||||
if n.extras().graph.as_deref().is_some_and(|g| g.series_markers.iter().flatten().any(|m| *m == old)) {
|
||||
if n.extras().graph.as_deref().is_some_and(|g| draws_with(g, &old)) {
|
||||
graphs.push(n.id);
|
||||
}
|
||||
});
|
||||
@@ -1579,6 +1908,9 @@ fn design(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for m in g.series_markers.iter_mut().flatten().filter(|m| **m == old) {
|
||||
*m = new.clone();
|
||||
}
|
||||
for c in g.series_columns.iter_mut().flatten().filter(|c| c.design == old) {
|
||||
c.design = new.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -1669,6 +2001,74 @@ fn set_marker(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "id": id.0 }))
|
||||
}
|
||||
|
||||
fn set_column(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "graph.column";
|
||||
let id = target(s, p, C)?;
|
||||
let mut spec = spec_of(s, id)?;
|
||||
let count = spec.rows.iter().map(Vec::len).max().unwrap_or(0).min(vectorcraft_doc::MAX_GRAPH_SERIES);
|
||||
let picked = picked_series(s, p, id, count, C)?;
|
||||
let names = design_names(&s.doc()?.doc);
|
||||
let all: Vec<usize> = if picked.is_empty() { (0..count).collect() } else { picked.clone() };
|
||||
// The column design the picked series (all of them with none picked) share; a design deleted since doesn't count,
|
||||
// as those series draw plain columns.
|
||||
let used: Vec<Option<&ColumnDesign>> =
|
||||
all.iter().map(|i| spec.series_columns.get(*i).and_then(Option::as_ref).filter(|c| names.contains(&c.design))).collect();
|
||||
let shared = used.first().copied().flatten().filter(|c| used.iter().all(|x| *x == Some(*c))).cloned();
|
||||
let Some(v) = p.get("design") else {
|
||||
let options = shared.clone().unwrap_or_default();
|
||||
return Ok(json!({
|
||||
"designs": names,
|
||||
"design": shared.map(|c| c.design),
|
||||
"columnType": options.column_type.id(),
|
||||
"each": options.each,
|
||||
"fractions": options.fractions.id(),
|
||||
}));
|
||||
};
|
||||
let design = match v {
|
||||
Value::Null => None,
|
||||
Value::String(n) if names.contains(n) => Some(n.clone()),
|
||||
Value::String(n) => return Err(bad(C, format!("no design named `{n}`"))),
|
||||
_ => return Err(bad(C, "`design` is a design name, or null for plain columns")),
|
||||
};
|
||||
let mut options = shared.unwrap_or_default();
|
||||
if let Some(t) = p.get("columnType") {
|
||||
options.column_type =
|
||||
t.as_str().and_then(ColumnType::parse).ok_or_else(|| bad(C, "`columnType` is vertical, uniform, repeating or sliding"))?;
|
||||
}
|
||||
if let Some(e) = p.get("each") {
|
||||
options.each =
|
||||
e.as_f64().filter(|e| e.is_finite() && *e > 0.0).ok_or_else(|| bad(C, "`each` is the value one design stands for, more than 0"))?;
|
||||
}
|
||||
if let Some(f) = p.get("fractions") {
|
||||
options.fractions = f.as_str().and_then(Fractions::parse).ok_or_else(|| bad(C, "`fractions` is chop or scale"))?;
|
||||
}
|
||||
let reselect: Vec<u32> = if p.get("seriesIndexes").is_none() { picked.iter().filter_map(|i| u32::try_from(*i).ok()).collect() } else { vec![] };
|
||||
spec.series_columns.truncate(count);
|
||||
spec.series_columns.resize(count, None);
|
||||
for i in all {
|
||||
if let Some(c) = spec.series_columns.get_mut(i) {
|
||||
*c = design.clone().map(|design| ColumnDesign { design, ..options.clone() });
|
||||
}
|
||||
}
|
||||
while spec.series_columns.last().is_some_and(Option::is_none) {
|
||||
spec.series_columns.pop();
|
||||
}
|
||||
// Drawn once on a copy of the document, to refuse designs past the budget before the edit.
|
||||
let mut probe = (*s.doc()?.doc).clone();
|
||||
let looks = column_designs(&probe, &spec);
|
||||
if generate_with(&mut probe, &spec, &looks).is_none() {
|
||||
return Err(bad(C, "the design is too big to draw in every column of this graph"));
|
||||
}
|
||||
s.edit("Graph Column", |d, sel| {
|
||||
regenerate(d, id, spec)?;
|
||||
if !reselect.is_empty() {
|
||||
reselect_series(d, sel, id, &reselect);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "id": id.0 }))
|
||||
}
|
||||
|
||||
fn set_type(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "graph.setType";
|
||||
let id = target(s, p, C)?;
|
||||
@@ -1896,10 +2296,20 @@ fn set_type(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{CharStyle, GraphKind, GraphSpec, NodeKind};
|
||||
use vectorcraft_doc::{CharStyle, ColumnDesign, ColumnType, Fractions, GraphKind, GraphSpec, NodeKind};
|
||||
|
||||
use crate::{NodeId, Session};
|
||||
|
||||
#[test]
|
||||
fn fmt_value_does_not_saturate_beyond_i64() {
|
||||
assert_eq!(super::fmt_value(1e19), "10000000000000000000");
|
||||
assert_eq!(super::fmt_value(2e19), "20000000000000000000");
|
||||
assert_eq!(super::fmt_value(-2e19), "-20000000000000000000");
|
||||
assert_eq!(super::fmt_value(42.0), "42");
|
||||
assert_eq!(super::fmt_value(-0.0), "0");
|
||||
assert_eq!(super::fmt_value(2.5), "2.5");
|
||||
}
|
||||
|
||||
fn graph(s: &mut Session, ty: &str) -> NodeId {
|
||||
let csv = ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6";
|
||||
NodeId(
|
||||
@@ -2358,6 +2768,30 @@ mod tests {
|
||||
assert_eq!(cells, g.cells(), "{csv}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn labels_with_commas_quotes_and_newlines_round_trip_through_csv() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let series = json!(["Revenue, USD", "Say \"hi\"", "a\tb"]);
|
||||
let categories = json!(["Doe, Jane", "2024", " padded ", "two\nlines"]);
|
||||
let rows = json!([[1, 2, 3], [4, null, 6], [7, 8, 9], [10, 11, 12]]);
|
||||
let r = s
|
||||
.execute(
|
||||
"graph.create",
|
||||
&json!({"type": "column", "x": 20, "y": 20, "width": 300, "height": 200, "series": series, "categories": categories, "rows": rows}),
|
||||
)
|
||||
.unwrap();
|
||||
let id = r["id"].as_u64().unwrap();
|
||||
let before = s.execute("graph.setData", &json!({"id": id})).unwrap();
|
||||
assert_eq!(before["series"], series);
|
||||
assert_eq!(before["categories"], categories);
|
||||
s.execute("graph.setData", &json!({"id": id, "csv": before["csv"]})).unwrap();
|
||||
let after = s.execute("graph.setData", &json!({"id": id})).unwrap();
|
||||
assert_eq!(after["series"], before["series"], "{}", before["csv"]);
|
||||
assert_eq!(after["categories"], before["categories"], "{}", before["csv"]);
|
||||
assert_eq!(after["rows"], before["rows"], "{}", before["csv"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_cells_are_blank_and_quoted_numbers_are_labels() {
|
||||
// Quoted years label the rows; empty and non-numeric cells are blank values, not zeros.
|
||||
@@ -3307,6 +3741,16 @@ mod tests {
|
||||
assert!(!painted.contains(&Some("#ff0000".into())), "{painted:?}");
|
||||
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap();
|
||||
assert_eq!(design_fill(&s), painted);
|
||||
// A design of one path too.
|
||||
let r = s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("paint.setFill", &json!({"ids": [r], "color": "#00f"})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [r]})).unwrap();
|
||||
s.execute("graph.design", &json!({"new": "Square"})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("graph.marker", &json!({"design": "Square"})).unwrap();
|
||||
let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 0).id;
|
||||
s.execute("paint.setFill", &json!({"ids": [grp.0], "color": "#f00"})).unwrap();
|
||||
assert_eq!(design_fill(&s), [Some("#0000ff".to_string())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3356,6 +3800,306 @@ mod tests {
|
||||
assert!(s.execute("graph.design", &json!({"delete": ["Nope"]})).is_err());
|
||||
}
|
||||
|
||||
/// A column design: a `w` × `h` rectangle at the back, a `w`-wide ellipse over its top `top` points and a rectangle
|
||||
/// over the rest, with a horizontal guide line between them when `guide`, saved as `name`.
|
||||
fn save_column_design(s: &mut Session, name: &str, (w, h, top): (f64, f64, f64), guide: bool) {
|
||||
let shape = |s: &mut Session, kind: &str, x: f64, y: f64, w: f64, h: f64| {
|
||||
s.execute(kind, &json!({"x": 600.0 + x, "y": 600.0 + y, "width": w, "height": h})).unwrap()["id"].as_u64().unwrap()
|
||||
};
|
||||
let mut ids = vec![shape(s, "shape.rectangle", 0.0, 0.0, w, h), shape(s, "shape.ellipse", 0.0, 0.0, w, top)];
|
||||
ids.push(shape(s, "shape.rectangle", w / 4.0, top, w / 2.0, h - top));
|
||||
if guide {
|
||||
let line =
|
||||
s.execute("shape.line", &json!({"x1": 600, "y1": 600.0 + top, "x2": 600.0 + w, "y2": 600.0 + top})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [line]})).unwrap();
|
||||
s.execute("view.guides.make", &json!({})).unwrap();
|
||||
ids.push(line);
|
||||
}
|
||||
s.execute("select.set", &json!({"ids": ids})).unwrap();
|
||||
s.execute("graph.design", &json!({"new": name})).unwrap();
|
||||
}
|
||||
|
||||
/// A column graph of one series with `values`, its value axis 0 to 50 over 200 points (4 points a unit), the zero
|
||||
/// line at y = 300.
|
||||
fn one_series(s: &mut Session, ty: &str, values: &[f64]) -> NodeId {
|
||||
let rows: Vec<Vec<f64>> = values.iter().map(|v| vec![*v]).collect();
|
||||
let p = json!({"type": ty, "x": 100, "y": 100, "width": 300, "height": 200, "series": ["A"], "rows": rows});
|
||||
let id = NodeId(s.execute("graph.create", &p).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("graph.setType", &json!({"id": id.0, "axisMin": 0, "axisMax": 50})).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
/// The bounds of the backmost object (the frame) of each copy of a design inside `n`, a column drawn with it.
|
||||
fn frames(n: &vectorcraft_doc::Node) -> Vec<vectorcraft_geom::Rect> {
|
||||
n.children().unwrap().iter().filter_map(|c| c.children()?.first()?.geometric_bounds()).collect()
|
||||
}
|
||||
|
||||
fn near(a: vectorcraft_geom::Rect, b: [f64; 4]) -> bool {
|
||||
[a.x0 - b[0], a.y0 - b[1], a.x1 - b[2], a.y1 - b[3]].iter().all(|d| d.abs() < 1e-6)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_repeating_column_design_stacks_a_copy_per_ten_and_chops_or_scales_the_last() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
save_column_design(&mut s, "Coin", (10.0, 10.0, 4.0), false);
|
||||
let id = one_series(&mut s, "column", &[35.0]);
|
||||
let plain = series_parts(&s, id, 0)[0].geometric_bounds().unwrap();
|
||||
assert!(near(plain, [plain.x0, 160.0, plain.x1, 300.0]), "{plain:?}");
|
||||
s.execute("graph.column", &json!({"id": id.0, "design": "Coin", "columnType": "repeating", "each": 10})).unwrap();
|
||||
// 35 at 10 a copy: three copies of 40 points up from the zero line, and a fourth cut off at the value.
|
||||
let column = series_parts(&s, id, 0)[0].clone();
|
||||
let parts = column.children().unwrap();
|
||||
assert_eq!(parts.len(), 4, "3.5 designs");
|
||||
let at = |y0: f64, y1: f64| [plain.x0, y0, plain.x1, y1];
|
||||
let copies = frames(&column);
|
||||
assert!(near(copies[0], at(260.0, 300.0)) && near(copies[1], at(220.0, 260.0)) && near(copies[2], at(180.0, 220.0)), "{copies:?}");
|
||||
let NodeKind::Group { clip: true, children } = &parts[3].kind else { panic!("the last copy is clipped: {:?}", parts[3].kind) };
|
||||
assert!(near(frames(&parts[3])[0], at(140.0, 180.0)), "a whole copy");
|
||||
let (clip, copy) =
|
||||
(children[0].geometric_bounds().unwrap(), vectorcraft_render::painted_bounds(&s.doc().unwrap().doc, &children[1]).unwrap());
|
||||
assert_eq!(clip.y0, 160.0, "cut off at the value");
|
||||
// Its sides and its foot, strokes included, are not cut.
|
||||
assert!(clip.x0 <= copy.x0 && clip.x1 >= copy.x1 && clip.y1 >= copy.y1 && copy.x0 < plain.x0, "{clip:?} {copy:?}");
|
||||
// Nor is the brush art of a brushed path inside a group of the design.
|
||||
let mut d = vectorcraft_doc::Document::new(800.0, 800.0);
|
||||
let square = |x0: f64, x1: f64| vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(x0, 0.0, x1, 10.0));
|
||||
let frame = vectorcraft_doc::Node::path(d.alloc_id(), square(0.0, 10.0), vectorcraft_doc::Appearance::basic(Paint::None, Paint::None, 0.0));
|
||||
let mut look = vectorcraft_doc::Appearance::basic(Paint::None, Paint::solid(vectorcraft_color::Color::BLACK), 1.0);
|
||||
for item in &mut look.items {
|
||||
if let vectorcraft_doc::AppearanceItem::Stroke(st) = item {
|
||||
st.brush = Some("Wide".into());
|
||||
}
|
||||
}
|
||||
let brushed = vectorcraft_doc::Node::path(d.alloc_id(), square(4.0, 6.0), look);
|
||||
let inner = vectorcraft_doc::Node::group(d.alloc_id(), vec![std::sync::Arc::new(brushed)]);
|
||||
let art = vectorcraft_doc::Node::group(d.alloc_id(), vec![std::sync::Arc::new(frame), std::sync::Arc::new(inner)]);
|
||||
d.graph_designs.push(vectorcraft_doc::GraphDesign { name: "Brushed".into(), art: std::sync::Arc::new(art) });
|
||||
let options = ColumnDesign { design: "Brushed".into(), column_type: ColumnType::Repeating, ..ColumnDesign::default() };
|
||||
let g = GraphSpec {
|
||||
rows: vec![vec![35.0]],
|
||||
axis_min: Some(0.0),
|
||||
axis_max: Some(50.0),
|
||||
series_columns: vec![Some(options)],
|
||||
..GraphSpec::default()
|
||||
};
|
||||
let out = super::generate(&mut d, &g);
|
||||
let column = out.iter().find(|n| n.extras().series_index == Some(0)).unwrap().children().unwrap()[0].clone();
|
||||
let last = column.children().unwrap().last().unwrap().clone();
|
||||
let NodeKind::Group { clip: true, children } = &last.kind else { panic!("the last copy is clipped: {:?}", last.kind) };
|
||||
let clip = children[0].geometric_bounds().unwrap();
|
||||
let mut reach = None;
|
||||
children[1].walk(&mut |n| {
|
||||
if vectorcraft_brush::has_brush(n) {
|
||||
reach = vectorcraft_render::painted_bounds(&d, n);
|
||||
}
|
||||
});
|
||||
let reach = reach.unwrap();
|
||||
assert!(clip.x0 <= reach.x0 && clip.x1 >= reach.x1 && clip.y1 >= reach.y1, "{clip:?} {reach:?}");
|
||||
// Scale: the last copy half as tall instead.
|
||||
s.execute("graph.column", &json!({"id": id.0, "design": "Coin", "fractions": "scale"})).unwrap();
|
||||
let column = series_parts(&s, id, 0)[0].clone();
|
||||
let copies = frames(&column);
|
||||
assert_eq!(copies.len(), 4);
|
||||
assert!(near(copies[3], at(160.0, 180.0)), "{copies:?}");
|
||||
let c = &spec(&s, id).series_columns[0];
|
||||
assert_eq!((c.as_ref().map(|c| (c.column_type, c.each, c.fractions))), Some((ColumnType::Repeating, 10.0, Fractions::Scale)));
|
||||
// A bar graph stacks them along its bars, and a negative value stacks them the other way from the zero line.
|
||||
let bars = one_series(&mut s, "bar", &[-15.0]);
|
||||
s.execute("graph.setType", &json!({"id": bars.0, "axisMin": -50, "axisMax": 50})).unwrap();
|
||||
s.execute("graph.column", &json!({"id": bars.0, "design": "Coin", "columnType": "repeating", "each": 10, "fractions": "scale"})).unwrap();
|
||||
let bar = series_parts(&s, bars, 0)[0].clone();
|
||||
let copies = frames(&bar);
|
||||
// The value axis runs from -50 to 50 over 300 points: 3 points a unit, the zero line at x = 250.
|
||||
let (zero, unit) = (250.0, 3.0);
|
||||
assert!((copies[0].x1 - zero).abs() < 1e-6 && (copies[0].width() - 10.0 * unit).abs() < 1e-6, "{copies:?}");
|
||||
assert!((copies[1].x1 - copies[0].x0).abs() < 1e-6 && (copies[1].width() - 5.0 * unit).abs() < 1e-6, "{copies:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sliding_column_design_keeps_the_part_above_its_guide_and_stretches_the_rest() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
// 10 wide and 20 tall, the guide 5 points from the top.
|
||||
save_column_design(&mut s, "Pencil", (10.0, 20.0, 5.0), true);
|
||||
let id = one_series(&mut s, "column", &[35.0, 20.0]);
|
||||
s.execute("graph.setType", &json!({"id": id.0, "columnWidth": 20})).unwrap();
|
||||
s.execute("graph.column", &json!({"id": id.0, "design": "Pencil", "columnType": "sliding"})).unwrap();
|
||||
let parts = series_parts(&s, id, 0);
|
||||
for (column, value_end) in parts.iter().zip([160.0, 220.0]) {
|
||||
// The columns are 24 points wide (2.4 times the design): two copies, each clipped at the guide, which lands
|
||||
// 12 points (2.4 times 5) below the value end.
|
||||
let halves = column.children().unwrap();
|
||||
assert_eq!(halves.len(), 2, "{halves:?}");
|
||||
let half = |i: usize| {
|
||||
let NodeKind::Group { clip: true, children } = &halves[i].kind else { panic!("a clip group: {:?}", halves[i].kind) };
|
||||
(children[0].geometric_bounds().unwrap(), children[1].children().unwrap().to_vec())
|
||||
};
|
||||
let ((above, kept), (below, stretched)) = (half(0), half(1));
|
||||
let at = value_end + 12.0;
|
||||
assert!((above.y1 - at).abs() < 1e-6 && (below.y0 - at).abs() < 1e-6, "{above:?} {below:?}");
|
||||
assert_eq!(kept.len(), 3, "the guide is not drawn");
|
||||
// Above the guide the design keeps its proportions: the 10 × 5 ellipse is 24 × 12 at the value end.
|
||||
let top = kept[1].geometric_bounds().unwrap();
|
||||
assert!((top.y0 - value_end).abs() < 1e-6 && (top.width() - 24.0).abs() < 1e-6 && (top.height() - 12.0).abs() < 1e-6, "{top:?}");
|
||||
// Below it the design is stretched down to the zero line.
|
||||
let (frame, body) = (stretched[0].geometric_bounds().unwrap(), stretched[2].geometric_bounds().unwrap());
|
||||
assert!((frame.width() - 24.0).abs() < 1e-6 && (frame.y1 - 300.0).abs() < 1e-6, "{frame:?}");
|
||||
assert!((body.y0 - at).abs() < 1e-6 && (body.y1 - 300.0).abs() < 1e-6, "{body:?}");
|
||||
}
|
||||
// Vertically Scaled stretches the whole design, the top part included.
|
||||
s.execute("graph.column", &json!({"id": id.0, "design": "Pencil", "columnType": "vertical"})).unwrap();
|
||||
let art = series_parts(&s, id, 0)[0].children().unwrap()[0].children().unwrap().to_vec();
|
||||
assert!((art[1].geometric_bounds().unwrap().height() - 140.0 / 4.0).abs() < 1e-6);
|
||||
// Without a guide a sliding design is scaled vertically too.
|
||||
save_column_design(&mut s, "Plain", (10.0, 20.0, 5.0), false);
|
||||
s.execute("graph.column", &json!({"id": id.0, "design": "Plain", "columnType": "sliding"})).unwrap();
|
||||
let art = series_parts(&s, id, 0)[0].children().unwrap()[0].children().unwrap().to_vec();
|
||||
assert!((art[1].geometric_bounds().unwrap().height() - 140.0 / 4.0).abs() < 1e-6);
|
||||
// Guides that are not a horizontal line, a 2 × 2 square and a vertical line 0.1 points long, made guides before
|
||||
// the line, are passed over.
|
||||
let id_of = |v: serde_json::Value| v["id"].as_u64().unwrap();
|
||||
let mut ids = vec![
|
||||
id_of(s.execute("shape.rectangle", &json!({"x": 700, "y": 600, "width": 10, "height": 20})).unwrap()),
|
||||
id_of(s.execute("shape.ellipse", &json!({"x": 700, "y": 600, "width": 10, "height": 5})).unwrap()),
|
||||
];
|
||||
let square = id_of(s.execute("shape.rectangle", &json!({"x": 704, "y": 611, "width": 2, "height": 2})).unwrap());
|
||||
let tick = id_of(s.execute("shape.line", &json!({"x1": 702, "y1": 615, "x2": 702, "y2": 615.1})).unwrap());
|
||||
let line = id_of(s.execute("shape.line", &json!({"x1": 700, "y1": 605, "x2": 710, "y2": 605})).unwrap());
|
||||
s.execute("select.set", &json!({"ids": [square, tick, line]})).unwrap();
|
||||
s.execute("view.guides.make", &json!({})).unwrap();
|
||||
ids.extend([square, tick, line]);
|
||||
s.execute("select.set", &json!({"ids": ids})).unwrap();
|
||||
s.execute("graph.design", &json!({"new": "Boxed"})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("graph.column", &json!({"id": id.0, "design": "Boxed", "columnType": "sliding"})).unwrap();
|
||||
let halves = series_parts(&s, id, 0)[0].children().unwrap().to_vec();
|
||||
let above = halves[0].children().unwrap()[0].geometric_bounds().unwrap();
|
||||
assert!((above.y1 - (160.0 + 12.0)).abs() < 1e-6, "cut at the line, not at the square or the short vertical line: {above:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uniformly_scaled_column_designs_keep_their_proportions_and_cast_no_shadow() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
save_column_design(&mut s, "Coin", (10.0, 10.0, 4.0), false);
|
||||
let id = one_series(&mut s, "column", &[35.0, 20.0]);
|
||||
s.execute("graph.setType", &json!({"id": id.0, "dropShadow": true})).unwrap();
|
||||
let plain = series_parts(&s, id, 0)[0].geometric_bounds().unwrap();
|
||||
s.execute("graph.column", &json!({"id": id.0, "design": "Coin", "columnType": "uniform"})).unwrap();
|
||||
for (column, length) in series_parts(&s, id, 0).iter().zip([140.0, 80.0]) {
|
||||
let frame = frames(column)[0];
|
||||
assert!((frame.width() - length).abs() < 1e-6 && (frame.height() - length).abs() < 1e-6, "{frame:?}");
|
||||
assert!((frame.center().x - column.geometric_bounds().unwrap().center().x).abs() < 1e-6);
|
||||
}
|
||||
assert!((frames(&series_parts(&s, id, 0)[0])[0].center().x - plain.center().x).abs() < 1e-6, "centered on the column");
|
||||
// A design twice as wide as it is tall reaches past the plot, and the legend starts right of it.
|
||||
save_column_design(&mut s, "Strip", (20.0, 10.0, 4.0), false);
|
||||
let wide = one_series(&mut s, "column", &[20.0, 35.0]);
|
||||
s.execute("graph.column", &json!({"id": wide.0, "design": "Strip", "columnType": "uniform"})).unwrap();
|
||||
let parts = series_parts(&s, wide, 0);
|
||||
let (reach, legend) = (frames(&parts[1])[0], parts.last().unwrap().geometric_bounds().unwrap());
|
||||
assert!(reach.x1 > 400.0 && legend.x0 > reach.x1, "{reach:?} {legend:?}");
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
assert!(n.children().unwrap().iter().all(|c| c.name.as_deref() != Some("Drop Shadow")), "no shadow for designed columns");
|
||||
// The legend swatch shows the design, its frame filling the 8-point square.
|
||||
let swatch = series_parts(&s, id, 0).last().unwrap().clone();
|
||||
let frame = swatch.children().unwrap()[0].geometric_bounds().unwrap();
|
||||
assert!((frame.width() - 8.0).abs() < 1e-6 && (frame.height() - 8.0).abs() < 1e-6, "{frame:?}");
|
||||
// Painting the series leaves the design's paint alone.
|
||||
let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 0).id;
|
||||
s.execute("paint.setFill", &json!({"ids": [grp.0], "color": "#f00"})).unwrap();
|
||||
let mut red = false;
|
||||
s.doc().unwrap().doc.node(id).unwrap().walk(&mut |n| red |= n.appearance.fill_paint().color().is_some_and(|c| c.to_hex() == "#ff0000"));
|
||||
assert!(!red);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_designs_survive_save_and_open_and_follow_their_design() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
save_column_design(&mut s, "Coin", (10.0, 10.0, 4.0), false);
|
||||
let id = graph(&mut s, "stackedColumn");
|
||||
s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "line"})).unwrap();
|
||||
// Every series takes the design; only the stacked column series draws it.
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
let p = json!({"id": id.0, "design": "Coin", "columnType": "repeating", "each": 2.5, "fractions": "scale"});
|
||||
s.execute("graph.column", &p).unwrap();
|
||||
assert!(series_parts(&s, id, 0).iter().all(|n| matches!(n.kind, NodeKind::Group { .. })), "columns and swatch");
|
||||
assert!(series_parts(&s, id, 1).iter().all(|n| matches!(n.kind, NodeKind::Path { .. })), "the line series");
|
||||
let v = s.execute("graph.column", &json!({"id": id.0, "seriesIndexes": [0]})).unwrap();
|
||||
assert_eq!(v, json!({"designs": ["Coin"], "design": "Coin", "columnType": "repeating", "each": 2.5, "fractions": "scale"}));
|
||||
let path = vectorcraft_testkit::temp_dir("graph-column-designs").join("columns.vectorcraft");
|
||||
s.execute("document.save", &json!({"path": path})).unwrap();
|
||||
s.execute("document.open", &json!({"path": path})).unwrap();
|
||||
let options = ColumnDesign { design: "Coin".into(), column_type: ColumnType::Repeating, each: 2.5, fractions: Fractions::Scale };
|
||||
assert_eq!(spec(&s, id).series_columns, [Some(options.clone()), Some(options)]);
|
||||
assert!(serde_json::to_value(GraphSpec::default()).unwrap().get("seriesColumns").is_none());
|
||||
// Renamed, the graph draws it still; in use, it isn't unused; deleted, the columns are plain again.
|
||||
s.execute("graph.design", &json!({"rename": "Coin", "to": "Token"})).unwrap();
|
||||
assert!(spec(&s, id).series_columns.iter().flatten().all(|c| c.design == "Token"));
|
||||
assert_eq!(s.execute("graph.design", &json!({"selectUnused": true})).unwrap()["unused"], json!([]));
|
||||
s.execute("graph.design", &json!({"delete": "Token"})).unwrap();
|
||||
assert!(series_parts(&s, id, 0).iter().all(|n| matches!(n.kind, NodeKind::Path { .. })));
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
assert!(s.execute("graph.column", &json!({"id": id.0})).unwrap()["design"].is_null());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(series_parts(&s, id, 0).iter().all(|n| matches!(n.kind, NodeKind::Group { .. })));
|
||||
// Null goes back to plain columns.
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("graph.column", &json!({"id": id.0, "design": null})).unwrap();
|
||||
assert!(spec(&s, id).series_columns.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_column_params_are_checked_and_designs_too_big_are_drawn_plain() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
save_column_design(&mut s, "Coin", (10.0, 10.0, 4.0), false);
|
||||
let id = one_series(&mut s, "column", &[35.0]);
|
||||
for p in [
|
||||
json!({"design": "Nope"}),
|
||||
json!({"design": 3}),
|
||||
json!({"design": "Coin", "columnType": "diagonal"}),
|
||||
json!({"design": "Coin", "each": 0}),
|
||||
json!({"design": "Coin", "each": -1}),
|
||||
json!({"design": "Coin", "each": "10"}),
|
||||
json!({"design": "Coin", "fractions": "round"}),
|
||||
] {
|
||||
let mut p = p;
|
||||
p["id"] = json!(id.0);
|
||||
assert!(s.execute("graph.column", &p).is_err(), "{p}");
|
||||
}
|
||||
assert!(spec(&s, id).series_columns.is_empty());
|
||||
// 350,000 copies of a three-object design are past the budget: refused, and a file asking for it draws plain columns.
|
||||
let p = json!({"id": id.0, "design": "Coin", "columnType": "repeating", "each": 0.0001});
|
||||
assert!(s.execute("graph.column", &p).is_err());
|
||||
let mut g = spec(&s, id);
|
||||
let options = ColumnDesign { design: "Coin".into(), column_type: ColumnType::Repeating, each: 0.0001, fractions: Fractions::Chop };
|
||||
g.series_columns = vec![Some(options.clone())];
|
||||
let mut d = (*s.doc().unwrap().doc).clone();
|
||||
let art = super::generate(&mut d, &g);
|
||||
let s0 = art.iter().find(|n| n.extras().series_index == Some(0)).unwrap();
|
||||
assert!(s0.children().unwrap().iter().all(|n| matches!(n.kind, NodeKind::Path { .. })));
|
||||
// Columns of 0 on a value axis from 10,000 to 10,001 run 2,000,000 points from the clamped zero line: a copy each
|
||||
// 0.1 is past the budget too.
|
||||
let far = one_series(&mut s, "column", &[0.0, 0.0]);
|
||||
s.execute("graph.setType", &json!({"id": far.0, "axisMin": 10_000, "axisMax": 10_001})).unwrap();
|
||||
assert!(s.execute("graph.column", &json!({"id": far.0, "design": "Coin", "columnType": "repeating", "each": 0.1})).is_err());
|
||||
assert!(spec(&s, far).series_columns.is_empty());
|
||||
// A file's unusable value per copy draws the design vertically scaled, and so does a copy longer than the canvas:
|
||||
// 35 at 1,000,000,000 a copy, 4,000,000,000 points a copy.
|
||||
for (each, fractions) in [(-1.0, Fractions::Chop), (1e9, Fractions::Scale), (1e9, Fractions::Chop)] {
|
||||
g.series_columns = vec![Some(ColumnDesign { each, fractions, ..options.clone() })];
|
||||
let art = super::generate(&mut d, &g);
|
||||
let s0 = art.iter().find(|n| n.extras().series_index == Some(0)).unwrap();
|
||||
let column = s0.children().unwrap()[0].clone();
|
||||
assert_eq!(frames(&column).len(), 1, "{each} {fractions:?}");
|
||||
assert!((frames(&column)[0].height() - 140.0).abs() < 1e-6, "{each} {fractions:?}");
|
||||
assert!(!matches!(column.children().unwrap()[0].kind, NodeKind::Group { clip: true, .. }), "{each} {fractions:?}: not clipped");
|
||||
}
|
||||
}
|
||||
|
||||
fn make(s: &mut Session, ty: &str, csv: &str) -> NodeId {
|
||||
NodeId(
|
||||
s.execute("graph.create", &json!({"type": ty, "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"]
|
||||
|
||||
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Vector Halftone…",
|
||||
["Object"],
|
||||
None,
|
||||
"{shape?: \"circle\"|\"ellipse\"|\"square\"|\"diamond\"|\"line\" (circle), frequency?: lines per inch 1–300 (20), angle?: deg (45; CMYK: the black screen's, cyan −30°, magenta +30°, yellow −45° from it), mode?: \"mono\"|\"cmyk\" (mono), color?: mono dot colour (\"#000000\"), invert?: bool (dots for light tones), clip?: true (clip the dots to the art's outline), keepOriginal?: false} the selection as vector halftone dots sized by its tone, a group of one path per screen (CMYK screens multiply), as one undo step → {id, dots}",
|
||||
"{shape?: \"circle\"|\"ellipse\"|\"square\"|\"diamond\"|\"line\" (circle), frequency?: lines per inch 1–300 (20), angle?: deg (45; CMYK: the black screen's, cyan −30°, magenta +30°, yellow −45° from it), mode?: \"mono\"|\"cmyk\" (mono), color?: mono dot colour (\"#000000\"), invert?: bool (dots for light tones), clip?: true (clip the dots to the art's outline), keepOriginal?: false} the selection as vector halftone dots sized by its tone, a group of one path per screen (CMYK screens multiply), as one undo step → {id, dots, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
|
||||
has_selection,
|
||||
halftone
|
||||
)]
|
||||
@@ -185,11 +185,13 @@ fn halftone(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut r = vectorcraft_render::Renderer::new();
|
||||
let (w, h) = vectorcraft_render::region_pixels(region, scale);
|
||||
let (w, h) = (w as usize, h as usize);
|
||||
let failed = vectorcraft_render::decode_failures();
|
||||
let px = if mono {
|
||||
r.render_region(&tmp, region, scale, true).pixels
|
||||
} else {
|
||||
r.render_region_inks(&tmp, region, scale, &vectorcraft_render::RenderOptions { skip_templates: true, ..Default::default() })
|
||||
};
|
||||
let warning = super::rasterfx::undecoded_warning(failed);
|
||||
if px.len() < w * h * 4 {
|
||||
return Err(EngineError::Other("the art could not be sampled".into()));
|
||||
}
|
||||
@@ -241,7 +243,11 @@ fn halftone(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
sel.set([gid]);
|
||||
Ok(gid)
|
||||
})?;
|
||||
Ok(json!({ "id": id.0, "dots": dots }))
|
||||
let mut out = json!({ "id": id.0, "dots": dots });
|
||||
if let Some(w) = warning {
|
||||
out["warning"] = json!(w);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The dots of one screen over `bounds` as one path, with their count.
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
//! Window → SVG Interactivity: the JavaScript an object runs on each event in web output
|
||||
//! (`svgInteractivity.setEvent`, `removeEvent`, `clearEvents`) and the JavaScript files the
|
||||
//! document links (`svgInteractivity.addFile`, `removeFile`). SVG export writes the events as
|
||||
//! event attributes (`onclick="…"`) and the files as `<script xlink:href>` elements; SVG import
|
||||
//! reads both back as text. The panel reads everything with `svgInteractivity.info`.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, MAX_SVG_SCRIPTS, NodeId, SVG_EVENTS};
|
||||
|
||||
use super::overprint::edit_counted;
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
query "svgInteractivity.info",
|
||||
"SVG Interactivity",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?} → {ids, events: [{event, script}…] in the panel's order | null (the objects' events differ), files: [the linked JavaScript files…], eventNames: [the events the panel lists…]} the SVG Interactivity panel's values for ids or the selection",
|
||||
has_doc,
|
||||
info
|
||||
),
|
||||
cmd!(
|
||||
"svgInteractivity.setEvent",
|
||||
"Add Event",
|
||||
[],
|
||||
None,
|
||||
"{event: \"onclick\"|\"onmouseover\"|… (svgInteractivity.info's eventNames), script: string (the JavaScript; \"\" removes the event), ids?|id?} set the JavaScript an event runs on ids or the selection, replacing the event's script where it has one, as one undo step; SVG export writes it as the event attribute → {changed: objects}",
|
||||
has_doc,
|
||||
set_event
|
||||
),
|
||||
cmd!(
|
||||
"svgInteractivity.removeEvent",
|
||||
"Remove Selected Entry",
|
||||
[],
|
||||
None,
|
||||
"{event, ids?|id?} remove an event and its JavaScript from ids or the selection, as one undo step → {changed: objects}",
|
||||
has_doc,
|
||||
remove_event
|
||||
),
|
||||
cmd!(
|
||||
"svgInteractivity.clearEvents",
|
||||
"Clear Events",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?} remove every event from ids or the selection, as one undo step → {changed: objects}",
|
||||
has_doc,
|
||||
clear_events
|
||||
),
|
||||
cmd!(
|
||||
"svgInteractivity.addFile",
|
||||
"Add JavaScript File",
|
||||
[],
|
||||
None,
|
||||
"{file: path or URL (written as given, without surrounding spaces)} link a JavaScript file to the document, after the others: SVG export writes a <script xlink:href> for each linked file. A file already linked stays where it is; a document links at most 64 files → {files}",
|
||||
has_doc,
|
||||
add_file
|
||||
),
|
||||
cmd!(
|
||||
"svgInteractivity.removeFile",
|
||||
"Remove JavaScript File",
|
||||
[],
|
||||
None,
|
||||
"{file} unlink a JavaScript file from the document → {files}",
|
||||
has_doc,
|
||||
remove_file
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// `p`'s `event`, one of [`SVG_EVENTS`] (any case).
|
||||
fn event_param(p: &Value, cmd: &str) -> Result<&'static str> {
|
||||
let e = str_param(p, "event").ok_or_else(|| bad(cmd, "give `event`, such as onclick"))?;
|
||||
SVG_EVENTS
|
||||
.iter()
|
||||
.find(|n| n.eq_ignore_ascii_case(e.trim()))
|
||||
.copied()
|
||||
.ok_or_else(|| bad(cmd, format!("`event` must be one of {}", SVG_EVENTS.join(", "))))
|
||||
}
|
||||
|
||||
/// The events of object `id` in `d` (none for a missing object).
|
||||
fn events_of(d: &Document, id: NodeId) -> Vec<(String, String)> {
|
||||
d.node(id).and_then(|n| n.extras().attrs.as_deref()).map(|a| a.events.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Change the events of `ids` (or the selection) with `f`, as one undo step named `label`; an
|
||||
/// object whose events `f` leaves as they were isn't changed → `{changed: objects}`.
|
||||
fn edit_events(s: &mut Session, p: &Value, cmd: &str, label: &str, f: impl Fn(&mut Vec<(String, String)>)) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
if ids.is_empty() {
|
||||
return Err(bad(cmd, "select the objects (or give their ids)"));
|
||||
}
|
||||
let changed = edit_counted(s, label, |d| {
|
||||
let mut changed = 0;
|
||||
for id in &ids {
|
||||
let mut events = events_of(d, *id);
|
||||
let before = events.clone();
|
||||
f(&mut events);
|
||||
if events != before
|
||||
&& let Some(n) = d.node_mut(*id)
|
||||
{
|
||||
n.edit_attrs(|a| a.events = events);
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
Ok(changed)
|
||||
})?;
|
||||
Ok(json!({ "changed": changed }))
|
||||
}
|
||||
|
||||
fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let lists: Vec<Vec<(String, String)>> = ids.iter().map(|id| events_of(d, *id)).collect();
|
||||
let events = lists
|
||||
.first()
|
||||
.filter(|first| lists.iter().all(|l| l == *first))
|
||||
.map(|l| l.iter().map(|(event, script)| json!({ "event": event, "script": script })).collect::<Vec<_>>());
|
||||
Ok(json!({
|
||||
"ids": ids.iter().map(|id| id.0).collect::<Vec<_>>(),
|
||||
"events": events,
|
||||
"files": d.svg_scripts,
|
||||
"eventNames": SVG_EVENTS,
|
||||
}))
|
||||
}
|
||||
|
||||
fn set_event(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "svgInteractivity.setEvent";
|
||||
let event = event_param(p, C)?;
|
||||
let script = p.get("script").map(|v| v.as_str().ok_or_else(|| bad(C, "`script` must be a string"))).transpose()?;
|
||||
let script = script.ok_or_else(|| bad(C, "give `script`, the JavaScript the event runs (\"\" removes the event)"))?.trim().to_string();
|
||||
edit_events(s, p, C, "Add Event", |events| {
|
||||
if script.is_empty() {
|
||||
events.retain(|(e, _)| e != event);
|
||||
} else if let Some((_, js)) = events.iter_mut().find(|(e, _)| e == event) {
|
||||
js.clone_from(&script);
|
||||
} else {
|
||||
events.push((event.to_string(), script.clone()));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn remove_event(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "svgInteractivity.removeEvent";
|
||||
let event = event_param(p, C)?;
|
||||
edit_events(s, p, C, "Remove Selected Entry", |events| events.retain(|(e, _)| e != event))
|
||||
}
|
||||
|
||||
fn clear_events(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
edit_events(s, p, "svgInteractivity.clearEvents", "Clear Events", Vec::clear)
|
||||
}
|
||||
|
||||
/// `p`'s `file`, trimmed and not empty.
|
||||
fn file_param(p: &Value, cmd: &str) -> Result<String> {
|
||||
let f = str_param(p, "file").map(str::trim).filter(|f| !f.is_empty());
|
||||
f.map(str::to_string).ok_or_else(|| bad(cmd, "give `file`, the JavaScript file's path or URL"))
|
||||
}
|
||||
|
||||
fn files(s: &Session) -> Result<Value> {
|
||||
Ok(json!({ "files": s.doc()?.doc.svg_scripts }))
|
||||
}
|
||||
|
||||
fn add_file(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "svgInteractivity.addFile";
|
||||
let file = file_param(p, C)?;
|
||||
let linked = &s.doc()?.doc.svg_scripts;
|
||||
if !linked.contains(&file) {
|
||||
if linked.len() >= MAX_SVG_SCRIPTS {
|
||||
return Err(bad(C, format!("a document links at most {MAX_SVG_SCRIPTS} JavaScript files")));
|
||||
}
|
||||
s.edit("Add JavaScript File", |d, _| {
|
||||
d.svg_scripts.push(file);
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
files(s)
|
||||
}
|
||||
|
||||
fn remove_file(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "svgInteractivity.removeFile";
|
||||
let file = file_param(p, C)?;
|
||||
if !s.doc()?.doc.svg_scripts.contains(&file) {
|
||||
return Err(bad(C, format!("{file:?} isn't linked")));
|
||||
}
|
||||
s.edit("Remove JavaScript File", |d, _| {
|
||||
d.svg_scripts.retain(|f| *f != file);
|
||||
Ok(())
|
||||
})?;
|
||||
files(s)
|
||||
}
|
||||
@@ -5,7 +5,8 @@
|
||||
//! `object.intertwine.arrange` picks overlap areas, by a click (`at`) or by an enclosed path drawn
|
||||
//! around them (`area`), and changes which member is drawn on top there: each member keeps the
|
||||
//! area where it is drawn over the others (`NodeExtras::intertwine`, see
|
||||
//! `vectorcraft_doc::intertwine`).
|
||||
//! `vectorcraft_doc::intertwine`). Object › Expand turns the group into a plain group of what it
|
||||
//! draws ([`expand_intertwines`]), which Ungroup takes apart.
|
||||
//!
|
||||
//! An overlap area is a face of the planar arrangement of what the members paint (their fills and
|
||||
//! strokes, type outlined; other objects by their bounds): the part where two or more of them
|
||||
@@ -186,6 +187,41 @@ fn release(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
// ---------- Expand ----------
|
||||
|
||||
/// Replace Intertwine group `id` by a plain group of the art it draws
|
||||
/// ([`vectorcraft_doc::intertwine::expand`]: its objects, then each part drawn over the others as a
|
||||
/// clip group), keeping everything about the group itself; the generated art gets ids of its own.
|
||||
fn expand_group(d: &mut Document, id: NodeId) -> Result<()> {
|
||||
let Some(mut g) = d.node(id).cloned() else { return Ok(()) };
|
||||
let NodeKind::Intertwine { children } = &g.kind else { return Ok(()) };
|
||||
g.kind = NodeKind::Group { children: vectorcraft_doc::intertwine::expand(children).into_iter().map(Arc::new).collect(), clip: false };
|
||||
super::live::fix_ids(d, &mut g);
|
||||
*d.node_mut(id).ok_or(EngineError::NoNode(id))? = g;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Object › Expand: every Intertwine group in the subtrees of `roots` becomes a group of the art
|
||||
/// it draws ([`expand_group`]), inner ones first so an outer one copies plain art. Returns how
|
||||
/// many. A live Intertwine is expanded before it can be ungrouped:
|
||||
/// <https://community.adobe.com/questions-652/expand-ungroup-800281>.
|
||||
pub(crate) fn expand_intertwines(d: &mut Document, roots: &[NodeId]) -> Result<usize> {
|
||||
let mut groups = vec![];
|
||||
for r in roots {
|
||||
if let Some(n) = d.node(*r) {
|
||||
n.walk(&mut |c| {
|
||||
if is_intertwine(c) {
|
||||
groups.push(c.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
for g in groups.iter().rev() {
|
||||
expand_group(d, *g)?;
|
||||
}
|
||||
Ok(groups.len())
|
||||
}
|
||||
|
||||
// ---------- Arrange ----------
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
|
||||
@@ -152,7 +152,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Fit to Artwork Bounds",
|
||||
["Object", "Artboards"],
|
||||
None,
|
||||
"{index?: n (0-based; the app passes the active artboard; default: the first)} the artboard fits the art that shows (with its strokes and effects; hidden objects and layers, guides and template layers don't count)",
|
||||
"{index?: n | indices?: [n…] (0-based; the app passes the selected artboards: the Artboard tool's, else the active one; default: the first)} each artboard fits the art on it that shows (with its strokes and effects; hidden objects and layers, guides and template layers don't count), in one undo step: an object counts for every artboard its bounds overlap, and a group or clipping layer counts as one object; an artboard with no art stays as it is, and the command fails when no artboard it names has art",
|
||||
has_doc,
|
||||
artboard_fit_art
|
||||
),
|
||||
@@ -161,7 +161,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Fit to Selected Art",
|
||||
["Object", "Artboards"],
|
||||
None,
|
||||
"{index?: n (0-based; the app passes the active artboard; default: the first)}",
|
||||
"{index?: n (0-based; the app passes the active artboard; default: the first)} the artboard fits the selected art (with its strokes)",
|
||||
has_selection,
|
||||
artboard_fit_sel
|
||||
),
|
||||
@@ -286,7 +286,7 @@ pub(crate) fn make_layer(d: &mut Document, name: Option<&str>) -> Node {
|
||||
}
|
||||
|
||||
/// A layer colour: a preset index (0–26), `#rrggbb` or a preset's name (any case).
|
||||
fn layer_color_param(v: &Value) -> std::result::Result<LayerColor, String> {
|
||||
pub(super) fn layer_color_param(v: &Value) -> std::result::Result<LayerColor, String> {
|
||||
match v {
|
||||
Value::Number(n) => match n.as_u64() {
|
||||
Some(i) if (i as usize) < LAYER_COLORS.len() => Ok(LayerColor::Preset(i as u8)),
|
||||
@@ -412,6 +412,9 @@ fn target(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !st.doc.is_editable(id) {
|
||||
return Err(bad(C, "the object is hidden or locked"));
|
||||
}
|
||||
if !in_isolation(&st.doc, st.isolation, id) {
|
||||
return Err(bad(C, "the object is outside the isolated group"));
|
||||
}
|
||||
let layer = n.is_layer();
|
||||
let rows = st.layer_rows.clone();
|
||||
if p.get("add").and_then(Value::as_bool).unwrap_or(false) {
|
||||
@@ -645,6 +648,11 @@ fn collect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "id": lid.0 }))
|
||||
}
|
||||
|
||||
/// Whether `id` lies inside the isolated group or layer (always, outside isolation mode).
|
||||
fn in_isolation(doc: &Document, isolation: Option<NodeId>, id: NodeId) -> bool {
|
||||
isolation.is_none_or(|iso| iso != id && doc.ancestry(id).is_some_and(|a| a.contains(&iso)))
|
||||
}
|
||||
|
||||
/// The selection column: select a row's art.
|
||||
fn select_art(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "layer.selectAll";
|
||||
@@ -652,7 +660,7 @@ fn select_art(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let add = bool_or(p, "add", false);
|
||||
let st = s.doc()?;
|
||||
let n = st.doc.node(id).ok_or(EngineError::NoNode(id))?;
|
||||
let ids: Vec<NodeId> = if !st.doc.is_editable(id) {
|
||||
let ids: Vec<NodeId> = if !st.doc.is_editable(id) || !in_isolation(&st.doc, st.isolation, id) {
|
||||
vec![]
|
||||
} else if n.is_layer() {
|
||||
n.layer_art(true)
|
||||
@@ -826,7 +834,7 @@ fn artboard_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let i = super::panelcmds::artboard_index(&s.doc()?.doc, p, "artboard.setProps")?;
|
||||
let was = s.doc()?.doc.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
let now = rect_from(p, was);
|
||||
let resized = (now.width() - was.width()).abs() > 1e-6 || (now.height() - was.height()).abs() > 1e-6;
|
||||
@@ -890,17 +898,43 @@ fn rect_map(from: Rect, to: Rect) -> Option<Affine> {
|
||||
xf.is_finite().then_some(xf)
|
||||
}
|
||||
|
||||
/// The artboard fits the art that exports draw: hidden objects and layers, guides and template
|
||||
/// layers don't count.
|
||||
/// Each artboard of `index` or `indices` (default: the first) fits the art on it that exports
|
||||
/// draw ([`vectorcraft_render::encode::art_bounds_on`]): hidden objects and layers, guides and
|
||||
/// template layers don't count. An artboard with no art on it stays as it is, and the command
|
||||
/// fails when none has art. The fits are one undo step named Artboard Options.
|
||||
fn artboard_fit_art(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let b = vectorcraft_render::encode::art_bounds(&s.doc()?.doc).ok_or_else(|| EngineError::Other("no artwork".into()))?;
|
||||
s.execute("artboard.setProps", &json!({"index": i, "x": b.x0, "y": b.y0, "width": b.width(), "height": b.height()}))
|
||||
let d = &s.doc()?.doc;
|
||||
let indices =
|
||||
if p.get("index").is_none() && p.get("indices").is_none() { vec![0] } else { super::panelcmds::artboard_indices(d, p, "artboard.fitToArt")? };
|
||||
let mut fits = vec![];
|
||||
for i in indices {
|
||||
let was = d.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
if let Some(b) = vectorcraft_render::encode::art_bounds_on(d, was) {
|
||||
// At least 1 pt a side, as `rect_from` makes Artboard Options' sizes.
|
||||
fits.push((i, was, Rect::new(b.x0, b.y0, b.x0 + b.width().max(1.0), b.y0 + b.height().max(1.0))));
|
||||
}
|
||||
}
|
||||
if fits.is_empty() {
|
||||
return Err(EngineError::Other("no artwork".into()));
|
||||
}
|
||||
s.edit("Artboard Options", |d, _| {
|
||||
for &(i, was, now) in &fits {
|
||||
let a = d.artboards.get_mut(i).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
a.rect = now;
|
||||
let id = a.id;
|
||||
// Moved and not resized, it takes its guides along, as in Artboard Options.
|
||||
if (now.width() - was.width()).abs() <= 1e-6 && (now.height() - was.height()).abs() <= 1e-6 {
|
||||
d.move_artboard_guides(id, now.origin() - was.origin());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
fn artboard_fit_sel(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let st = s.doc()?;
|
||||
let i = if p.get("index").is_none() { 0 } else { super::panelcmds::artboard_index(&st.doc, p, "artboard.fitToSelection")? };
|
||||
let b = st.doc.bounds_of(&st.selection.objects, true).ok_or_else(|| EngineError::Other("no selection bounds".into()))?;
|
||||
s.execute("artboard.setProps", &json!({"index": i, "x": b.x0, "y": b.y0, "width": b.width(), "height": b.height()}))
|
||||
}
|
||||
@@ -271,8 +271,19 @@ fn merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if ids.len() < 2 {
|
||||
return Err(bad(C, "highlight two or more rows to merge"));
|
||||
}
|
||||
let into =
|
||||
ids.iter().rev().copied().find(|id| d.node(*id).is_some_and(Node::is_layer)).ok_or_else(|| bad(C, "highlight a layer to merge into"))?;
|
||||
// The last highlighted layer that is not inside another highlighted row: with a layer and its
|
||||
// sublayer both highlighted (Select All in the panel), the sublayer travels with its layer
|
||||
// instead of becoming the target (#2383).
|
||||
let roots: HashSet<NodeId> = row_roots(d, &ids).into_iter().collect();
|
||||
let is_layer = |id: &NodeId| d.node(*id).is_some_and(Node::is_layer);
|
||||
let into = ids
|
||||
.iter()
|
||||
.rev()
|
||||
.copied()
|
||||
.filter(is_layer)
|
||||
.find(|id| roots.contains(id))
|
||||
.or_else(|| ids.iter().rev().copied().find(is_layer))
|
||||
.ok_or_else(|| bad(C, "highlight a layer to merge into"))?;
|
||||
let around = d.ancestry(into).unwrap_or_default();
|
||||
// (Rows inside the target layer merge too: only rows inside other listed rows go with those.)
|
||||
let rest: Vec<NodeId> = ids.iter().copied().filter(|id| *id != into).collect();
|
||||
|
||||
@@ -101,7 +101,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reshape Spine Segment",
|
||||
[],
|
||||
None,
|
||||
"{id? (default: the selected blend), segment: index, t: 0..1, dx, dy} bend a segment of the blend's spine so its point at t moves by (dx, dy), as path.reshapeSegment bends a path's: a straight spine turns into a curve (#1111)",
|
||||
"{id? (default: the selected blend), segment: index, t: 0..1, dx, dy, perpendicular?: bool} bend a segment of the blend's spine so its point at t moves by (dx, dy), as path.reshapeSegment bends a path's (perpendicular too): a straight spine turns into a curve (#1111)",
|
||||
has_doc,
|
||||
spine_reshape_segment
|
||||
),
|
||||
@@ -238,7 +238,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Create Gradient Mesh…",
|
||||
["Object"],
|
||||
None,
|
||||
"{ids?, rows?: n (4), cols?: n (4), appearance?: flat|center|edge, highlight?: % (100), at?: [x,y], columns?: as cols} convert filled paths into gradient meshes in their fill colour (a gradient fill: the colour it paints at each point), lightened per `appearance` (with `at`: 1×1 mesh plus lines through that point) → {ids, index?}",
|
||||
"{ids?, rows?: n (4), cols?: n (4), appearance?: flat|center|edge, highlight?: % (100), at?: [x,y], columns?: as cols} convert filled paths into gradient meshes in their fill colour (a gradient fill: the colour it paints at each point), lightened per `appearance` (with `at`: 1×1 mesh plus lines through that point); an embedded image becomes a mesh over its rectangle (its transform kept) whose points take the average colour and opacity of the picture around them (a linked image is refused: embed it first) → {ids, index?}",
|
||||
has_selection,
|
||||
mesh_create
|
||||
),
|
||||
@@ -383,7 +383,7 @@ fn replace_with(d: &mut Document, id: NodeId, nodes: Vec<Node>) -> Result<Vec<No
|
||||
}
|
||||
|
||||
/// Fresh ids for any id-0 (generated) node in `n`'s subtree; keeps real ids.
|
||||
fn fix_ids(d: &mut Document, n: &mut Node) {
|
||||
pub(super) fn fix_ids(d: &mut Document, n: &mut Node) {
|
||||
if n.id == NodeId(0) {
|
||||
n.id = d.alloc_id();
|
||||
}
|
||||
@@ -402,16 +402,20 @@ const DEFAULT_DISTANCE: f64 = 10.0;
|
||||
|
||||
fn spacing_param(p: &Value, cmd: &str, current: BlendSpacing) -> Result<BlendSpacing> {
|
||||
let num = |k: &str| p.get(k).and_then(Value::as_f64);
|
||||
let steps = |k: &str, n: f64| whole(n, k, cmd).map(|n| BlendSpacing::Steps(n as u32));
|
||||
let mode = str_param(p, "spacing");
|
||||
let value = num("value");
|
||||
let sp = match mode {
|
||||
Some("smooth") | Some("smoothColor") => BlendSpacing::SmoothColor,
|
||||
Some("steps") => BlendSpacing::Steps(value.or(num("steps")).unwrap_or(DEFAULT_STEPS as f64) as u32),
|
||||
Some("steps") => match value {
|
||||
Some(v) => steps("value", v)?,
|
||||
None => steps("steps", num("steps").unwrap_or(DEFAULT_STEPS as f64))?,
|
||||
},
|
||||
Some("distance") => BlendSpacing::Distance(value.or(num("distance")).unwrap_or(DEFAULT_DISTANCE)),
|
||||
Some(o) => return Err(bad(cmd, format!("unknown spacing `{o}` (smooth|steps|distance)"))),
|
||||
None => {
|
||||
if let Some(n) = num("steps") {
|
||||
BlendSpacing::Steps(n as u32)
|
||||
steps("steps", n)?
|
||||
} else if let Some(d) = num("distance") {
|
||||
BlendSpacing::Distance(d)
|
||||
} else if bool_or(p, "smooth", false) {
|
||||
@@ -657,7 +661,11 @@ fn spine_reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !(t.is_finite() && dv.x.is_finite() && dv.y.is_finite() && dv.hypot() < 1e9) {
|
||||
return Err(bad(C, "t, dx and dy must be finite"));
|
||||
}
|
||||
edit_spine(s, p, C, "Reshape Spine", |sp, _, _| if sp.bend_segment(seg, t, dv) { Ok(()) } else { Err(bad(C, "no such spine segment")) })?;
|
||||
let perpendicular = bool_or(p, "perpendicular", false);
|
||||
edit_spine(s, p, C, "Reshape Spine", |sp, _, _| {
|
||||
let bent = if perpendicular { sp.bend_segment_perpendicular(seg, t, dv) } else { sp.bend_segment(seg, t, dv) };
|
||||
if bent { Ok(()) } else { Err(bad(C, "no such spine segment")) }
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
@@ -955,14 +963,15 @@ fn envelope(id: NodeId, content: Vec<Arc<Node>>, kind: EnvelopeKind, (options, f
|
||||
Node::new(id, NodeKind::Envelope { content, kind: Box::new(kind), fidelity, editing: false, options, frame: Box::new(Affine::IDENTITY) })
|
||||
}
|
||||
|
||||
/// `rows` and `cols` (or `columns`), 1..50 each, defaulting to `default`.
|
||||
/// `rows` and `cols` (or `columns`), whole numbers 1..50 each, defaulting to `default`.
|
||||
fn rows_cols(p: &Value, cmd: &str, default: (u32, u32)) -> Result<(u32, u32)> {
|
||||
let rows = f64_or(p, "rows", default.0 as f64);
|
||||
let cols = f64_or(p, "cols", f64_or(p, "columns", default.1 as f64));
|
||||
let cols_key = if p.get("cols").and_then(Value::as_f64).is_some() { "cols" } else { "columns" };
|
||||
let cols = f64_or(p, cols_key, default.1 as f64);
|
||||
if !(1.0..=50.0).contains(&rows) || !(1.0..=50.0).contains(&cols) {
|
||||
return Err(bad(cmd, "rows and cols must be 1..50"));
|
||||
}
|
||||
Ok((rows as u32, cols as u32))
|
||||
Ok((whole(rows, "rows", cmd)? as u32, whole(cols, cols_key, cmd)? as u32))
|
||||
}
|
||||
|
||||
fn env_make_warp(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1299,7 +1308,7 @@ fn env_edit_contents(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn env_set_mesh_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.envelope.setMeshPoint";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
|
||||
let index = f64_req(p, "index", C)? as usize;
|
||||
let index = index_param(p, "index", C)?;
|
||||
let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?);
|
||||
let handle = handle_param(p, C)?;
|
||||
s.edit("Move Envelope Point", |d, _| {
|
||||
@@ -1344,18 +1353,33 @@ fn mesh_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (ids, index) = s.edit("Create Gradient Mesh", |d, sel| {
|
||||
let mut out = vec![];
|
||||
let mut index = None;
|
||||
let mut refused = None;
|
||||
for id in &roots {
|
||||
let Some(n) = d.node(*id).cloned() else { continue };
|
||||
let Some(path) = outline_of(&n) else { continue };
|
||||
let m = match n.appearance.fill_paint() {
|
||||
// The points take the colours the gradient paints there.
|
||||
Paint::Gradient(g) => {
|
||||
path.bounds().and_then(|b| GradientMesh::for_path_with(&path, rows, cols, &*gradient_sampler(&g, b))).map(|mut m| {
|
||||
let m = if let NodeKind::Image(im) = &n.kind {
|
||||
// An embedded image: the points take the picture's colours there.
|
||||
match image_mesh(d, im, rows, cols) {
|
||||
Ok(mut m) => {
|
||||
m.highlight(app, highlight);
|
||||
m
|
||||
})
|
||||
Some(m)
|
||||
}
|
||||
Err(why) => {
|
||||
refused.get_or_insert(why);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let Some(path) = outline_of(&n) else { continue };
|
||||
match n.appearance.fill_paint() {
|
||||
// The points take the colours the gradient paints there.
|
||||
Paint::Gradient(g) => {
|
||||
path.bounds().and_then(|b| GradientMesh::for_path_with(&path, rows, cols, &*gradient_sampler(&g, b))).map(|mut m| {
|
||||
m.highlight(app, highlight);
|
||||
m
|
||||
})
|
||||
}
|
||||
p => GradientMesh::for_path(&path, rows, cols, p.color().unwrap_or(Color::WHITE), app, highlight),
|
||||
}
|
||||
p => GradientMesh::for_path(&path, rows, cols, p.color().unwrap_or(Color::WHITE), app, highlight),
|
||||
};
|
||||
let Some(mut m) = m else { continue };
|
||||
if let Some(q) = at {
|
||||
@@ -1371,7 +1395,7 @@ fn mesh_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
out.push(*id);
|
||||
}
|
||||
if out.is_empty() {
|
||||
return Err(bad(C, "select a path or compound path"));
|
||||
return Err(bad(C, refused.unwrap_or("select a path, a compound path or an embedded image")));
|
||||
}
|
||||
sel.set(out.iter().copied());
|
||||
Ok((out, index))
|
||||
@@ -1383,6 +1407,31 @@ fn mesh_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Create Gradient Mesh on an embedded image: a `rows` × `cols` mesh over the picture's rectangle
|
||||
/// (placed with the image's transform), each point taking the average colour and opacity of the
|
||||
/// area around it. Linked images are refused: the documentation allows embedded ones only.
|
||||
fn image_mesh(d: &Document, im: &vectorcraft_doc::ImageObject, rows: u32, cols: u32) -> std::result::Result<GradientMesh, &'static str> {
|
||||
if im.link.is_some() {
|
||||
return Err("a linked image can't become a gradient mesh: embed it first");
|
||||
}
|
||||
let xf = im.xf.as_coeffs();
|
||||
if im.width == 0 || im.height == 0 || !xf.iter().all(|k| k.is_finite()) {
|
||||
return Err("the image has no usable size or position");
|
||||
}
|
||||
let samples = d.images.get(&im.key).and_then(|b| b.grid_samples(rows, cols)).ok_or("the image's pixels can't be read")?;
|
||||
let (w, h) = (im.width as f64, im.height as f64);
|
||||
let at = |u: f64, v: f64| im.xf * Point::new(u * w, v * h);
|
||||
if ![at(0.0, 0.0), at(1.0, 0.0), at(0.0, 1.0), at(1.0, 1.0)].iter().all(|p| p.x.abs() < 1e15 && p.y.abs() < 1e15) {
|
||||
return Err("the image has no usable size or position");
|
||||
}
|
||||
let cols_n = cols as usize + 1;
|
||||
let steps = |n: u32| (0..=n).map(|i| i as f64 / n as f64).collect::<Vec<_>>();
|
||||
Ok(GradientMesh::from_grid(&steps(cols), &steps(rows), &at, &|r, c, _| {
|
||||
let [pr, pg, pb, pa] = samples.get(r * cols_n + c).copied().unwrap_or([0; 4]);
|
||||
(Color::rgb8(pr, pg, pb), pa as f32 / 255.0)
|
||||
}))
|
||||
}
|
||||
|
||||
/// Run `f` on the gradient mesh `p.id` (one undo step `label`); with `envelopes`, a mesh
|
||||
/// envelope's grid too (its points and handles are written back: editing fills in the handles).
|
||||
fn with_mesh<T>(s: &mut Session, p: &Value, cmd: &str, label: &str, envelopes: bool, f: impl FnOnce(&mut GradientMesh) -> Result<T>) -> Result<T> {
|
||||
@@ -1422,7 +1471,12 @@ fn index_param(p: &Value, key: &str, cmd: &str) -> Result<usize> {
|
||||
if i < 0.0 {
|
||||
return Err(bad(cmd, "index must be ≥ 0"));
|
||||
}
|
||||
Ok(i as usize)
|
||||
Ok(whole(i, key, cmd)? as usize)
|
||||
}
|
||||
|
||||
/// `x`, the value of `key`, when it is a whole number, and an error otherwise.
|
||||
fn whole(x: f64, key: &str, cmd: &str) -> Result<f64> {
|
||||
if x.fract() == 0.0 { Ok(x) } else { Err(bad(cmd, format!("`{key}` must be a whole number"))) }
|
||||
}
|
||||
|
||||
fn mesh_set_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -94,7 +94,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Rasterize…",
|
||||
["Object"],
|
||||
None,
|
||||
"{ppi?, background?: transparent|white|black|\"#rrggbb\", antiAlias?: none|art|type (text snapped to pixels)|bool, padding?|addAround?: pt (0–1000), colorModel?: \"rgb\"|\"cmyk\"|\"grayscale\"|\"bitmap\", clippingMask?: bool (a clip group with the art's outline)} replace the selection with an embedded PNG image; each defaults to document.rasterEffectsSettings → {id (the image, or its clip group), width, height}",
|
||||
"{ppi?, background?: transparent|white|black|\"#rrggbb\", antiAlias?: none|art|type (text snapped to pixels)|bool, padding?|addAround?: pt (0–1000), colorModel?: \"rgb\"|\"cmyk\"|\"grayscale\"|\"bitmap\", clippingMask?: bool (a clip group with the art's outline)} replace the selection with an embedded PNG image; each defaults to document.rasterEffectsSettings → {id (the image, or its clip group), width, height, warning?: \"an image couldn't be decoded and is missing from the picture\" (as raster exports say)}",
|
||||
has_selection,
|
||||
rasterize
|
||||
),
|
||||
@@ -103,7 +103,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Create Object Mosaic…",
|
||||
["Object"],
|
||||
None,
|
||||
"{columns=10, rows=10, width?, height? (new size in pt; default the image's), spacingX=0, spacingY=0 (pt between tiles), gray?: bool, deleteRaster?: bool} a group of rectangles coloured by the selected image's average tile colours → {id, tiles}",
|
||||
"{columns=10, rows=10 (whole numbers, kept within 1..1000), width?, height? (new size in pt; default the image's), spacingX=0, spacingY=0 (pt between tiles), gray?: bool, deleteRaster?: bool} a group of rectangles coloured by the selected image's average tile colours → {id, tiles}",
|
||||
has_image,
|
||||
object_mosaic
|
||||
),
|
||||
@@ -112,7 +112,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Crop Image",
|
||||
["Object"],
|
||||
None,
|
||||
"{rect?: [x, y, width, height] | trim?: true (to the pixels that aren't fully transparent, pixel for pixel; an image with none to cut is left as it is, trimmed: false)} crop the selected image (default: to the artboard it sits on) → {id, width, height, trimmed? (with trim)}",
|
||||
"{rect?: [x, y, width, height] | trim?: true (to the pixels that aren't fully transparent, pixel for pixel; an image with none to cut is left as it is, trimmed: false)} crop the selected image (default: to the artboard it sits on) → {id, width, height, trimmed? (with trim), warning? (without trim: the image couldn't be decoded, as object.rasterize says)}",
|
||||
has_image,
|
||||
crop_image
|
||||
),
|
||||
@@ -168,6 +168,20 @@ fn ids_json(ids: &[NodeId]) -> Value {
|
||||
json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() })
|
||||
}
|
||||
|
||||
/// The whole number `p` gives for `key`, written `4` or `4.0`, or `default` when `p` gives none or null. A value that
|
||||
/// is not a number gives the [`check_numbers`] error, and a fraction or a negative number gives
|
||||
/// "`{key}` must be a whole number".
|
||||
fn whole_or(p: &Value, key: &str, default: u64, cmd: &str) -> Result<u64> {
|
||||
check_numbers(p, &[key], cmd)?;
|
||||
let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(default) };
|
||||
match (v.as_u64(), v.as_f64()) {
|
||||
(Some(n), _) => Ok(n),
|
||||
// A whole number past `u64::MAX` saturates to it.
|
||||
(None, Some(x)) if x >= 0.0 && x.fract() == 0.0 => Ok(x as u64),
|
||||
_ => Err(bad(cmd, format!("`{key}` must be a whole number"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a command's implementation without the enablement check or journaling.
|
||||
pub(crate) fn run_raw(s: &mut Session, id: &str, p: &Value) -> Result<Value> {
|
||||
let c = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?;
|
||||
@@ -210,8 +224,9 @@ fn reset_bounding_box(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
fn above(s: &mut Session, lock: bool) -> Result<Value> {
|
||||
let roots = selected_roots(s)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let symbols = d.symbol_boxes();
|
||||
// `selected` holds each selected object's visual bounds, by layer, with the index there of its
|
||||
// ancestor directly inside the layer.
|
||||
// ancestor directly inside the layer. Symbol instances are measured by their art.
|
||||
let mut selected: BTreeMap<NodeId, Vec<(usize, Rect)>> = BTreeMap::new();
|
||||
for id in &roots {
|
||||
let Some(layer) = d.layer_of(*id) else { continue };
|
||||
@@ -219,7 +234,7 @@ fn above(s: &mut Session, lock: bool) -> Result<Value> {
|
||||
let anc = d.ancestry(*id).unwrap_or_default();
|
||||
let Some(pos) = anc.iter().position(|a| *a == layer) else { continue };
|
||||
let top = anc.get(pos + 1).copied().unwrap_or(*id);
|
||||
let Some(b) = d.node(*id).and_then(|n| n.visual_bounds()) else { continue };
|
||||
let Some(b) = d.node(*id).and_then(|n| n.visual_bounds_with(&symbols)) else { continue };
|
||||
let Some((_, idx, _)) = d.position(top) else { continue };
|
||||
selected.entry(layer).or_default().push((idx, b));
|
||||
}
|
||||
@@ -239,7 +254,7 @@ fn above(s: &mut Session, lock: bool) -> Result<Value> {
|
||||
if under.is_empty() || roots.contains(&n.id) {
|
||||
continue;
|
||||
}
|
||||
if n.visual_bounds().is_some_and(|nb| under.iter().any(|b| nb.intersect(*b).area() > 0.0 || nb.contains(b.center()))) {
|
||||
if n.visual_bounds_with(&symbols).is_some_and(|nb| under.iter().any(|b| nb.intersect(*b).area() > 0.0 || nb.contains(b.center()))) {
|
||||
hits.insert(n.id);
|
||||
}
|
||||
}
|
||||
@@ -335,13 +350,13 @@ fn transform_each_as(s: &mut Session, p: &Value, label: &str) -> Result<Value> {
|
||||
let rx = bool_or(p, "reflectX", false);
|
||||
let ry = bool_or(p, "reflectY", false);
|
||||
let random = bool_or(p, "random", false);
|
||||
let refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4).min(8) as usize;
|
||||
let refi = whole_or(p, "reference", 4, "object.transformEach")?.min(8) as usize;
|
||||
let copy = bool_or(p, "copy", false);
|
||||
let objects = super::object::transforms_objects(p);
|
||||
if sh == 0.0 || sv == 0.0 {
|
||||
return Err(bad("object.transformEach", "scale must be non-zero"));
|
||||
}
|
||||
let mut rng = Rng(p.get("seed").and_then(Value::as_u64).unwrap_or(0x9E37_79B9_7F4A_7C15).max(1));
|
||||
let mut rng = Rng(whole_or(p, "seed", 0x9E37_79B9_7F4A_7C15, "object.transformEach")?.max(1));
|
||||
let mut sc = if sh != 100.0 || sv != 100.0 { super::object::scaling(s, p) } else { Default::default() };
|
||||
sc.patterns = super::object::transform_patterns(s, p, &roots)?;
|
||||
// With no object to transform, or without a copy when both scales are 100% and there is no
|
||||
@@ -352,8 +367,11 @@ fn transform_each_as(s: &mut Session, p: &Value, label: &str) -> Result<Value> {
|
||||
}
|
||||
let ids = s.edit(label, |d, sel| {
|
||||
let targets = if copy { duplicate_in(d, sel, &roots, Affine::IDENTITY)? } else { roots.clone() };
|
||||
for id in &targets {
|
||||
let Some(b) = d.node(*id).and_then(|n| n.geometric_bounds()) else { continue };
|
||||
// Measure each object's geometric bounds first, with symbol instances measured by their art.
|
||||
let symbols = d.symbol_boxes();
|
||||
let boxes: Vec<Option<Rect>> = targets.iter().map(|id| d.node(*id).and_then(|n| n.geometric_bounds_with(&symbols))).collect();
|
||||
for (id, b) in targets.iter().zip(boxes) {
|
||||
let Some(b) = b else { continue };
|
||||
let (mut k_sh, mut k_sv, mut k_mh, mut k_mv, mut k_rot) = (sh, sv, mh, mv, rot);
|
||||
if random {
|
||||
k_sh = 100.0 + (sh - 100.0) * rng.next();
|
||||
@@ -408,21 +426,24 @@ pub(crate) fn unique_key(d: &vectorcraft_doc::Document, stem: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// `doc` rendered over `region` with `opts` as a PNG in colour model `color`.
|
||||
/// `doc` rendered over `region` with `opts` as a PNG in colour model `color`, with the note that an
|
||||
/// image in it couldn't be decoded ([`super::rasterfx::undecoded_warning`]).
|
||||
fn render_png(
|
||||
doc: &vectorcraft_doc::Document,
|
||||
region: Rect,
|
||||
scale: f64,
|
||||
opts: &vectorcraft_render::RenderOptions,
|
||||
color: RasterColorModel,
|
||||
) -> Result<(Vec<u8>, u32, u32)> {
|
||||
) -> Result<(Vec<u8>, u32, u32, Option<&'static str>)> {
|
||||
if region.width() * region.height() * scale * scale > MAX_PIXELS || region.width() * scale > 65535.0 || region.height() * scale > 65535.0 {
|
||||
return Err(EngineError::Other("image would be too large; lower the resolution".into()));
|
||||
}
|
||||
let mut r = vectorcraft_render::Renderer::new();
|
||||
let failed = vectorcraft_render::decode_failures();
|
||||
let mut img = r.render_region_with(doc, region, scale, opts);
|
||||
let warning = super::rasterfx::undecoded_warning(failed);
|
||||
RasterEffectsSettings { color_model: color, ..Default::default() }.finish_pixels(&mut img.pixels);
|
||||
Ok((img.to_png().map_err(EngineError::Other)?, img.width, img.height))
|
||||
Ok((img.to_png().map_err(EngineError::Other)?, img.width, img.height, warning))
|
||||
}
|
||||
|
||||
fn rasterize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -479,7 +500,7 @@ fn rasterize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.flatten()
|
||||
.map(|(bp, rule)| (PathData::from_bezpath(&bp), rule));
|
||||
let tmp = isolated_doc(&st.doc, nodes);
|
||||
let (png, w, h) = render_png(&tmp, region, scale, &raster.render_options(RasterFormat::Png), look.color_model)?;
|
||||
let (png, w, h, warning) = render_png(&tmp, region, scale, &raster.render_options(RasterFormat::Png), look.color_model)?;
|
||||
if roots.is_empty() {
|
||||
return Err(bad(C, "nothing selected"));
|
||||
}
|
||||
@@ -497,7 +518,11 @@ fn rasterize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})?;
|
||||
Ok(json!({ "id": id.0, "width": w, "height": h }))
|
||||
let mut out = json!({ "id": id.0, "width": w, "height": h });
|
||||
if let Some(w) = warning {
|
||||
out["warning"] = json!(w);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn has_image(s: &Session) -> std::result::Result<(), String> {
|
||||
@@ -524,8 +549,8 @@ fn object_mosaic(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let NodeKind::Image(im) = &node.kind else { return Err(bad(C, "select an image")) };
|
||||
let blob = st.doc.images.get(&im.key).filter(|b| !b.bytes.is_empty()).ok_or_else(|| bad(C, "the image has no embedded pixels"))?;
|
||||
let raster = vectorcraft_trace::Raster::decode(&blob.bytes).map_err(|e| bad(C, e.to_string()))?;
|
||||
let cols = p.get("columns").and_then(Value::as_u64).unwrap_or(10).clamp(1, 1000) as u32;
|
||||
let rows = p.get("rows").and_then(Value::as_u64).unwrap_or(10).clamp(1, 1000) as u32;
|
||||
let cols = whole_or(p, "columns", 10, C)?.clamp(1, 1000) as u32;
|
||||
let rows = whole_or(p, "rows", 10, C)?.clamp(1, 1000) as u32;
|
||||
let gray = bool_or(p, "gray", false);
|
||||
let colors = vectorcraft_trace::mosaic(&raster, cols, rows);
|
||||
// Tiles in image space, placed by the image transform (scaled to a new size if asked).
|
||||
@@ -634,7 +659,8 @@ fn crop_image(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
bare.visible = true;
|
||||
let tmp = isolated_doc(&st.doc, vec![bare]);
|
||||
let region = Rect::new(r.x0, r.y0, r.x0 + (r.width() * scale).round().max(1.0) / scale, r.y0 + (r.height() * scale).round().max(1.0) / scale);
|
||||
let (png, w, h) = render_png(&tmp, region, scale, &RasterExportOptions::default().render_options(RasterFormat::Png), RasterColorModel::Document)?;
|
||||
let (png, w, h, warning) =
|
||||
render_png(&tmp, region, scale, &RasterExportOptions::default().render_options(RasterFormat::Png), RasterColorModel::Document)?;
|
||||
s.edit("Crop Image", |d, _| {
|
||||
let key = unique_key(d, "crop");
|
||||
d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", png));
|
||||
@@ -650,7 +676,11 @@ fn crop_image(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "id": id.0, "width": w, "height": h }))
|
||||
let mut out = json!({ "id": id.0, "width": w, "height": h });
|
||||
if let Some(w) = warning {
|
||||
out["warning"] = json!(w);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Crop Image's trim: the image cut to the box of its pixels that aren't fully transparent, the
|
||||
@@ -664,9 +694,18 @@ fn trim_image(s: &mut Session, id: NodeId, im: ImageObject) -> Result<Value> {
|
||||
return Ok(json!({ "id": id.0, "width": im.width, "height": im.height, "trimmed": false }));
|
||||
}
|
||||
// Print rasters run to gigabytes decoded: the same allowance opening them has.
|
||||
let mut reader = image::ImageReader::new(std::io::Cursor::new(blob.bytes.as_slice()))
|
||||
.with_guessed_format()
|
||||
.map_err(|_| bad(C, "the image's pixels can't be read"))?;
|
||||
let reader = || {
|
||||
image::ImageReader::new(std::io::Cursor::new(blob.bytes.as_slice()))
|
||||
.with_guessed_format()
|
||||
.map_err(|_| bad(C, "the image's pixels can't be read"))
|
||||
};
|
||||
// The image's size as RGBA, read from its header, is held to that allowance before anything
|
||||
// is decoded (#1717).
|
||||
let (w, h) = reader()?.into_dimensions().map_err(|e| bad(C, format!("the image's pixels can't be read: {e}")))?;
|
||||
if u64::from(w) * u64::from(h) > crate::cmd::fileio::MAX_RASTER_ALLOC / 4 {
|
||||
return Err(bad(C, format!("an image of {w} × {h} pixels is too large to draw")));
|
||||
}
|
||||
let mut reader = reader()?;
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_alloc = Some(crate::cmd::fileio::MAX_RASTER_ALLOC);
|
||||
reader.limits(limits);
|
||||
@@ -863,9 +902,11 @@ fn convert_to_shape(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
fn convert_to_artboards(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let roots = selected_roots(s)?;
|
||||
let n = s.edit("Convert to Artboards", |d, sel| {
|
||||
// Measure each object's geometric bounds first, with symbol instances measured by their art.
|
||||
let symbols = d.symbol_boxes();
|
||||
let boxes: Vec<(NodeId, Rect)> = roots.iter().filter_map(|id| Some((*id, d.node(*id)?.geometric_bounds_with(&symbols)?))).collect();
|
||||
let mut converted = vec![];
|
||||
for id in &roots {
|
||||
let Some(b) = d.node(*id).and_then(|n| n.geometric_bounds()) else { continue };
|
||||
for (id, b) in boxes {
|
||||
if b.width() <= 0.0 || b.height() <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
@@ -877,7 +918,7 @@ fn convert_to_artboards(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
show_center_mark: false,
|
||||
show_cross_hairs: false,
|
||||
});
|
||||
converted.push(*id);
|
||||
converted.push(id);
|
||||
}
|
||||
if converted.is_empty() {
|
||||
return Err(EngineError::Other("Convert to Artboards: selection has no area".into()));
|
||||
@@ -907,8 +948,8 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let count = s.doc()?.doc.artboards.len();
|
||||
let n_ab = count.max(1);
|
||||
// Columns of a grid by row, rows of a grid by column (NaN casts to 0).
|
||||
let lines = (f64_or(p, "columns", 2.0).clamp(1.0, n_ab as f64) as usize).max(1);
|
||||
// Columns of a grid by row, rows of a grid by column.
|
||||
let lines = whole_or(p, "columns", 2, CMD)?.clamp(1, n_ab as u64) as usize;
|
||||
let (rows, cols) = match layout {
|
||||
L::GridByRow => (n_ab.div_ceil(lines), lines),
|
||||
L::GridByColumn => (lines, n_ab.div_ceil(lines)),
|
||||
@@ -929,8 +970,10 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if deltas.iter().all(|dl| dl.x.abs() <= 1e-9 && dl.y.abs() <= 1e-9) {
|
||||
return Ok(json!({ "artboards": count, "rows": rows, "columns": cols }));
|
||||
}
|
||||
// With moveArtwork, each top-level object moves with the artboard its center is on; locked and
|
||||
// With moveArtwork, each top-level object moves with the artboard its center is on, with symbol
|
||||
// instances measured by their art as `Document::art_on_artboard` measures them; locked and
|
||||
// hidden art moves only when prefs moveLockedWithArtboard is on.
|
||||
let symbols = d.symbol_boxes();
|
||||
let art: Vec<(NodeId, Vec2)> = if move_art {
|
||||
d.layers
|
||||
.iter()
|
||||
@@ -938,8 +981,8 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.flat_map(|l| l.children().into_iter().flatten())
|
||||
.filter(|n| n.rides_with_artboard(locked_and_hidden))
|
||||
.filter_map(|n| {
|
||||
let c = n.geometric_bounds()?.center();
|
||||
let dl = *rects.iter().position(|r| r.contains(c)).and_then(|i| deltas.get(i))?;
|
||||
let c = n.geometric_bounds_with(&symbols)?.center();
|
||||
let dl = *d.artboard_holding(c, false).and_then(|i| deltas.get(i))?;
|
||||
(dl != Vec2::ZERO).then_some((n.id, dl))
|
||||
})
|
||||
.collect()
|
||||
|
||||
@@ -34,6 +34,7 @@ mod halftone;
|
||||
pub mod help;
|
||||
pub mod importmatches;
|
||||
pub(crate) mod inline;
|
||||
mod interactivity;
|
||||
mod intertwine;
|
||||
mod layer;
|
||||
mod layerpanel;
|
||||
@@ -263,6 +264,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(library::specs());
|
||||
v.extend(expand::specs());
|
||||
v.extend(attributes::specs());
|
||||
v.extend(interactivity::specs());
|
||||
v.extend(newart::specs());
|
||||
v.extend(colortheme::specs());
|
||||
v.extend(fileio::pdf::specs());
|
||||
|
||||
Loaded 100 of 477 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user