mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-20 14:14:17 -04:00
Compare commits
490
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5ec99d515 | ||
|
|
92c1657334 | ||
|
|
8f481aa2f4 | ||
|
|
7616bb9221 | ||
|
|
1f579352ed | ||
|
|
851eb7f076 | ||
|
|
a2c764d312 | ||
|
|
917aa29dd5 | ||
|
|
11adb72d44 | ||
|
|
52380d683d | ||
|
|
1f2007eb46 | ||
|
|
19e3144d27 | ||
|
|
cfea906681 | ||
|
|
fbc654734f | ||
|
|
ac83e91c5f | ||
|
|
10e59b0d13 | ||
|
|
dc79640956 | ||
|
|
e2b2c410d9 | ||
|
|
30646c83bd | ||
|
|
361ebd29bf | ||
|
|
39daf465f5 | ||
|
|
d8b0dcfc04 | ||
|
|
d5cd54c9b8 | ||
|
|
443ffd6c6f | ||
|
|
73a3347bbb | ||
|
|
5c32d527f1 | ||
|
|
1abe1961ca | ||
|
|
f922a635cb | ||
|
|
91fde1d7fa | ||
|
|
6a8d52804b | ||
|
|
8521e0a505 | ||
|
|
e96771d5ea | ||
|
|
223bfd29c2 | ||
|
|
12f7d41c38 | ||
|
|
85163a7c82 | ||
|
|
1c1fd57b33 | ||
|
|
88a444ef7b | ||
|
|
0c62d9c02c | ||
|
|
cb229ac08c | ||
|
|
b8f9c403a7 | ||
|
|
fd53416de3 | ||
|
|
d507a73d09 | ||
|
|
9435c5079d | ||
|
|
99aa4e6a7c | ||
|
|
0ee6b1ac68 | ||
|
|
b21b3edb6c | ||
|
|
88bd9bcd9e | ||
|
|
6ef9f2dd93 | ||
|
|
6d713de980 | ||
|
|
1eac4f5b79 | ||
|
|
a2aac6bead | ||
|
|
222ec72e28 | ||
|
|
8f7486b90f | ||
|
|
5c5684f4c5 | ||
|
|
19adc36964 | ||
|
|
f412d30adf | ||
|
|
7bf326eaf1 | ||
|
|
bf021ae08b | ||
|
|
301727c7d5 | ||
|
|
a52198cefa | ||
|
|
926ca62952 | ||
|
|
d3ee9646de | ||
|
|
a446d76637 | ||
|
|
81cf399bfa | ||
|
|
d8c20c8eb4 | ||
|
|
f106f32af4 | ||
|
|
acd8ca097d | ||
|
|
f9e559156f | ||
|
|
c2a605ce8e | ||
|
|
ec4975f237 | ||
|
|
cb631ae635 | ||
|
|
5087186eac | ||
|
|
84ab208c43 | ||
|
|
ae617db6d3 | ||
|
|
408081000d | ||
|
|
34a9a48340 | ||
|
|
6de7c0a441 | ||
|
|
b48290a4f9 | ||
|
|
7833756f6e | ||
|
|
ce998b89bc | ||
|
|
db92c91678 | ||
|
|
ddc5fda173 | ||
|
|
bb32072be9 | ||
|
|
d41af02637 | ||
|
|
62910d0327 | ||
|
|
db9fa8e0a9 | ||
|
|
6d7a062227 | ||
|
|
4b8e42e517 | ||
|
|
5bf159d5d1 | ||
|
|
a48e65ba68 | ||
|
|
f557a1b6f3 | ||
|
|
5af05ab572 | ||
|
|
197cf0ba67 | ||
|
|
0734836616 | ||
|
|
de8492b90c | ||
|
|
e975942be0 | ||
|
|
2f93923e60 | ||
|
|
68a99c359b | ||
|
|
1a1b0dfd59 | ||
|
|
472a6d5359 | ||
|
|
819da290f1 | ||
|
|
303c5f8bca | ||
|
|
de985a46e6 | ||
|
|
035f1cb2f6 | ||
|
|
e52b07105d | ||
|
|
00f1231334 | ||
|
|
c58c642f07 | ||
|
|
39b59b5730 | ||
|
|
2c6a2ad082 | ||
|
|
b09e23f4a1 | ||
|
|
1e914c8d82 | ||
|
|
45d9a409d4 | ||
|
|
9d4675e7d4 | ||
|
|
d5dfbf4bc3 | ||
|
|
af48dda322 | ||
|
|
b87cb157ed | ||
|
|
d2b927b57b | ||
|
|
04750ece1b | ||
|
|
c08955320c | ||
|
|
742a5c1af2 | ||
|
|
1f80c28662 | ||
|
|
6fed871f88 | ||
|
|
3033fb9985 | ||
|
|
7e1095f9b1 | ||
|
|
f378c5c836 | ||
|
|
55acd88d42 | ||
|
|
319221c087 | ||
|
|
9d43326ad7 | ||
|
|
8baf5b1343 | ||
|
|
a60f8d7b9f | ||
|
|
2c52bed0b5 | ||
|
|
de3dd27fda | ||
|
|
575bd0f924 | ||
|
|
1ef66c48c1 | ||
|
|
35b5895183 | ||
|
|
0bd2a40128 | ||
|
|
605597b09c | ||
|
|
646563b033 | ||
|
|
2861c113d6 | ||
|
|
48a0206adb | ||
|
|
1fc3c23096 | ||
|
|
7dc0c034d8 | ||
|
|
be7fd07562 | ||
|
|
007f536c79 | ||
|
|
515569470c | ||
|
|
c07945b934 | ||
|
|
de4c6b1a6d | ||
|
|
ecaae7ee46 | ||
|
|
7bc7025a62 | ||
|
|
8d123b19ca | ||
|
|
4aa4b0e70b | ||
|
|
4614c08476 | ||
|
|
2ecc488111 | ||
|
|
6ed171c1b4 | ||
|
|
3eed3be8e6 | ||
|
|
85af04a83a | ||
|
|
bdd5470dcf | ||
|
|
157679c1a8 | ||
|
|
320bffb8ad | ||
|
|
3d23fc5175 | ||
|
|
8c6085beca | ||
|
|
6cb075b926 | ||
|
|
0f27503f5b | ||
|
|
92ca32e55d | ||
|
|
37ee8bee3c | ||
|
|
219253f163 | ||
|
|
e6c8ce1759 | ||
|
|
e1761eb489 | ||
|
|
7f01abd687 | ||
|
|
729582dc78 | ||
|
|
7c64ecd998 | ||
|
|
55f98095f3 | ||
|
|
ac1b380b40 | ||
|
|
c3a6aa0a29 | ||
|
|
1d214956f9 | ||
|
|
a466625c89 | ||
|
|
73f921fa0a | ||
|
|
fb18533e12 | ||
|
|
56be31fe9b | ||
|
|
a23d7a827f | ||
|
|
1c80b23745 | ||
|
|
c9a2491ef7 | ||
|
|
2ae992870b | ||
|
|
cec31c5f4a | ||
|
|
3b527ca339 | ||
|
|
4f18f1c860 | ||
|
|
b529f356cc | ||
|
|
048875cc4e | ||
|
|
57ddb71d97 | ||
|
|
49f7f5d7d8 | ||
|
|
aa9c70a7b1 | ||
|
|
278fba490a | ||
|
|
d807236bf2 | ||
|
|
9c1d1d56ed | ||
|
|
53bd154059 | ||
|
|
1f2e90a7b3 | ||
|
|
c7f748c75c | ||
|
|
4de769175e | ||
|
|
9a8e2ac6a0 | ||
|
|
419ed87cff | ||
|
|
eeb9513bbb | ||
|
|
c092ca1a48 | ||
|
|
ba2a11a807 | ||
|
|
f1757c7bda | ||
|
|
e616f1fe5c | ||
|
|
647d51eaf6 | ||
|
|
0d34a58b7b | ||
|
|
3a685eae61 | ||
|
|
e4d94109a2 | ||
|
|
4a90d13fc0 | ||
|
|
8a2d0db225 | ||
|
|
e358bbfdfb | ||
|
|
7d7853d7fd | ||
|
|
0a14001126 | ||
|
|
910e33b796 | ||
|
|
9b64fce9ec | ||
|
|
cd35c27a53 | ||
|
|
ab01a1a927 | ||
|
|
d054b5b4c7 | ||
|
|
ba32ef5b09 | ||
|
|
f0c2d8c890 | ||
|
|
e5c9054324 | ||
|
|
9fe0f03de2 | ||
|
|
429e147164 | ||
|
|
c0ec14b86d | ||
|
|
89a73abc38 | ||
|
|
cc8944ad2a | ||
|
|
05264b2801 | ||
|
|
372887fc6c | ||
|
|
841fc0cd51 | ||
|
|
d7e7066251 | ||
|
|
dc7216f0e4 | ||
|
|
36aecff81d | ||
|
|
9dbab52168 | ||
|
|
09a6fbe470 | ||
|
|
b30d80e056 | ||
|
|
1b8e3d9ae1 | ||
|
|
12d107cc56 | ||
|
|
eba9abc21e | ||
|
|
88494319da | ||
|
|
d25289a3be | ||
|
|
2025c357d9 | ||
|
|
16dcaa425b | ||
|
|
bf19ada5e1 | ||
|
|
c5798f446e | ||
|
|
4675a1633e | ||
|
|
d99b04e594 | ||
|
|
0f6154e7b9 | ||
|
|
fc594b51ee | ||
|
|
cdba0aac03 | ||
|
|
f44c3e9593 | ||
|
|
c965f49fda | ||
|
|
a10604bbc6 | ||
|
|
4f526b4e3f | ||
|
|
f2601137ab | ||
|
|
9938a31c9b | ||
|
|
5f37fccdce | ||
|
|
e8e88c14f2 | ||
|
|
2cdb0d2d53 | ||
|
|
fe16f852db | ||
|
|
43bb588e5e | ||
|
|
a57eaa0782 | ||
|
|
007df2ee24 | ||
|
|
0955e6845c | ||
|
|
4bafe834a6 | ||
|
|
87603e4c35 | ||
|
|
e60882a3a1 | ||
|
|
25a987a94f | ||
|
|
bb14031aa3 | ||
|
|
eec304dde1 | ||
|
|
b934451b6d | ||
|
|
5906f53ca5 | ||
|
|
411f5f7680 | ||
|
|
63cebd392a | ||
|
|
3041b6a672 | ||
|
|
090659b78b | ||
|
|
640c87b537 | ||
|
|
bf2379f401 | ||
|
|
561db27fef | ||
|
|
cc1a496947 | ||
|
|
48ef70e873 | ||
|
|
b58100c2f2 | ||
|
|
37b90eb050 | ||
|
|
7cf8dd4764 | ||
|
|
4547439176 | ||
|
|
797e899c56 | ||
|
|
6516e8611c | ||
|
|
e342c2843a | ||
|
|
52b6543c2e | ||
|
|
1e3ab306d4 | ||
|
|
1b4c421bc4 | ||
|
|
73f40e3fd3 | ||
|
|
049739b85e | ||
|
|
dcad0878c5 | ||
|
|
08c21446b4 | ||
|
|
d4a68dd73f | ||
|
|
45ca05e7b2 | ||
|
|
14d1667a7e | ||
|
|
5a2510576f | ||
|
|
47b5e886f8 | ||
|
|
650e938b68 | ||
|
|
36d751b72f | ||
|
|
c40718fafa | ||
|
|
d9d1a1aba3 | ||
|
|
2c4f059444 | ||
|
|
dba668240c | ||
|
|
a0657c9a22 | ||
|
|
5f20ef194b | ||
|
|
eb8eaa7aa0 | ||
|
|
1d6daaefcc | ||
|
|
78b7ffe1fb | ||
|
|
31df7aee9a | ||
|
|
2909972140 | ||
|
|
cfe77f1706 | ||
|
|
69dad83148 | ||
|
|
5646fa7253 | ||
|
|
545e935e0c | ||
|
|
46a1d9f175 | ||
|
|
f794069fbd | ||
|
|
c2bd5b4a52 | ||
|
|
2623e95a13 | ||
|
|
ced933af47 | ||
|
|
c29701f65b | ||
|
|
44e7be8b04 | ||
|
|
2c93a290e8 | ||
|
|
646a5cc9a3 | ||
|
|
20c8296b5a | ||
|
|
eb5bf7b828 | ||
|
|
36b85472d2 | ||
|
|
e69a08c1a6 | ||
|
|
602b3b34bc | ||
|
|
9361a3b3f1 | ||
|
|
dfb1a86a23 | ||
|
|
3f673c35eb | ||
|
|
2d2698e5fb | ||
|
|
0842941f8d | ||
|
|
df1bf3fe32 | ||
|
|
dbb6b470b3 | ||
|
|
9735518c84 | ||
|
|
cf5caa43d5 | ||
|
|
ed69938e0e | ||
|
|
ec410552cf | ||
|
|
a275496ef4 | ||
|
|
5851d8b1ad | ||
|
|
d8b0ce1013 | ||
|
|
282ed8955b | ||
|
|
034ef916ad | ||
|
|
138e9ca466 | ||
|
|
1caa957531 | ||
|
|
4803573940 | ||
|
|
3c0c112fbd | ||
|
|
aa9cf83fd2 | ||
|
|
24d4777d1a | ||
|
|
739b0b7335 | ||
|
|
468d7b21e9 | ||
|
|
db3092ae2e | ||
|
|
0cf12a1611 | ||
|
|
d23030ce2b | ||
|
|
2643f57b1f | ||
|
|
ef9e554dd2 | ||
|
|
438d27ffbe | ||
|
|
7d56260b47 | ||
|
|
22ce516e2d | ||
|
|
30e54dedcf | ||
|
|
e461b8b818 | ||
|
|
496288a023 | ||
|
|
bb7d6b487d | ||
|
|
dd5d421a9c | ||
|
|
5df664d1a7 | ||
|
|
b27b5b80ea | ||
|
|
2fc47948d5 | ||
|
|
df7daff3e4 | ||
|
|
030729d6f3 | ||
|
|
c0e6d8eb02 | ||
|
|
d7e79448d7 | ||
|
|
d66077a15f | ||
|
|
4eef9b7e8c | ||
|
|
aafe102649 | ||
|
|
ffc12babf2 | ||
|
|
830cefcaf6 | ||
|
|
bdf6059155 | ||
|
|
c73945d244 | ||
|
|
904f0fe522 | ||
|
|
5e6810655f | ||
|
|
299a4b36ad | ||
|
|
6a83f6fea6 | ||
|
|
e6aec0664c | ||
|
|
87a5b4cc83 | ||
|
|
d7a61f1c50 | ||
|
|
95e98d18de | ||
|
|
f4668bfb2b | ||
|
|
590f91574d | ||
|
|
facab88bcc | ||
|
|
4ffaeb1895 | ||
|
|
99b3917aaa | ||
|
|
2ef544cab7 | ||
|
|
67673faea7 | ||
|
|
ec3a1d2a82 | ||
|
|
a96a6800c9 | ||
|
|
2ca923b43a | ||
|
|
ef666b4bfd | ||
|
|
e26393c7d8 | ||
|
|
ba69d744ae | ||
|
|
879fb63248 | ||
|
|
5b7411e9d1 | ||
|
|
b5715f7509 | ||
|
|
66cfc1dca6 | ||
|
|
74cf04fa3b | ||
|
|
fda352cf7b | ||
|
|
98fc8146dc | ||
|
|
3988aafc1a | ||
|
|
2433c44915 | ||
|
|
dfde775213 | ||
|
|
2fa0e979f3 | ||
|
|
eba745e354 | ||
|
|
64fda576b2 | ||
|
|
07bf1ddf8c | ||
|
|
cc5fba7577 | ||
|
|
95427b71bf | ||
|
|
c9bebb92d6 | ||
|
|
eaa21d42d5 | ||
|
|
d5cfb27989 | ||
|
|
a7fded4584 | ||
|
|
3e09a469de | ||
|
|
079cd4983e | ||
|
|
1651e7bea7 | ||
|
|
e01fe134e9 | ||
|
|
f5d61f6f27 | ||
|
|
7da58e35cc | ||
|
|
a03583fc73 | ||
|
|
598f1de3cc | ||
|
|
3fe8aa7db9 | ||
|
|
015bab68a1 | ||
|
|
23dd9a1f2a | ||
|
|
20590e778c | ||
|
|
04e784a04f | ||
|
|
1de20a2972 | ||
|
|
35d70f5531 | ||
|
|
2ccb147403 | ||
|
|
a12f5b0f20 | ||
|
|
efb49169a3 | ||
|
|
6b13dd776b | ||
|
|
26af00d108 | ||
|
|
6e29afe648 | ||
|
|
919970a467 | ||
|
|
7f858a7e59 | ||
|
|
dc48ce6c74 | ||
|
|
ceddd8185f | ||
|
|
aeb9314e2c | ||
|
|
85fce6852e | ||
|
|
6a2bb03ebf | ||
|
|
a209996da2 | ||
|
|
1c4ead3703 | ||
|
|
dd466d79e4 | ||
|
|
8fbd515d6c | ||
|
|
c44fb242aa | ||
|
|
e5945c6795 | ||
|
|
81574b1648 | ||
|
|
d44dc983e6 | ||
|
|
f7382b2844 | ||
|
|
039a467dcb | ||
|
|
1f304f6a23 | ||
|
|
45c2b0b363 | ||
|
|
85afe7caaf | ||
|
|
92e33b1579 | ||
|
|
fa9a52bd71 | ||
|
|
5f70f96a66 | ||
|
|
10ae4cc5a8 | ||
|
|
eeeb9645c1 | ||
|
|
e1a6e373d5 | ||
|
|
286a1fb8b3 | ||
|
|
949473f1e3 | ||
|
|
f999688b41 | ||
|
|
b5164825d6 | ||
|
|
56df1a1663 | ||
|
|
6a42b3bf10 | ||
|
|
5b85ca6358 | ||
|
|
e2b4cef111 | ||
|
|
f92c622e97 | ||
|
|
43356a1cfb | ||
|
|
b55d277b49 | ||
|
|
2a5dfa6a94 | ||
|
|
6190525482 | ||
|
|
39f9204d57 | ||
|
|
f7de3d6a01 | ||
|
|
82e363c73b | ||
|
|
b05043c759 | ||
|
|
ef5084f48b | ||
|
|
1ddfb56f66 | ||
|
|
b46ffd0059 |
No files matched your search
@@ -0,0 +1,33 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Maintain Rust / Cargo dependencies
|
||||
- package-ecosystem: "cargo"
|
||||
directories:
|
||||
- "/"
|
||||
- "/crates/affinity/fuzz"
|
||||
- "/crates/plugins/example"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
# Consolidate updates into a single PR rather than one PR per crate
|
||||
groups:
|
||||
cargo-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
|
||||
# Maintain GitHub Actions workflows
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "ci(deps)"
|
||||
@@ -35,5 +35,5 @@ jobs:
|
||||
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
|
||||
with:
|
||||
require-craft-fonts: true
|
||||
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
|
||||
run-package-check: true
|
||||
@@ -26,7 +26,8 @@ jobs:
|
||||
with:
|
||||
app-name: vectorcraft
|
||||
app-title: VectorCraft
|
||||
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
|
||||
web-craft-fonts: true
|
||||
version: ${{ inputs.version }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
|
||||
@@ -167,6 +167,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
||||
| `assets/icons/dc-new-fill.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-new-item.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-new-stroke.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-objects-on-path.svg` | VectorCraft contributors | Original work, drawn for VectorCraft: three small squares along a curve | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-options.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-para-center.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-para-justify-all.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
|
||||
Generated
+41
-28
@@ -806,6 +806,11 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
|
||||
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
@@ -4507,7 +4512,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"block2 0.6.2",
|
||||
@@ -4531,6 +4536,7 @@ dependencies = [
|
||||
"vectorcraft-engine",
|
||||
"vectorcraft-text",
|
||||
"vectorcraft-ui-egui",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"webbrowser",
|
||||
@@ -4538,11 +4544,12 @@ dependencies = [
|
||||
"winit",
|
||||
"winresource",
|
||||
"winsafe",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-affinity"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
@@ -4553,7 +4560,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-brush"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde",
|
||||
@@ -4565,7 +4572,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cad"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4581,7 +4588,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cli"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"objc2-core-foundation",
|
||||
@@ -4603,7 +4610,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-color"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"moxcms",
|
||||
@@ -4615,8 +4622,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-doc"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"hayro-ccitt",
|
||||
"image",
|
||||
"jpeg-encoder",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4632,7 +4640,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-effects"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4648,14 +4656,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-engine"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
"log",
|
||||
"proptest",
|
||||
"roxmltree 0.21.1",
|
||||
"ruzstd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4684,7 +4694,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-eps"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"fax",
|
||||
"flate2",
|
||||
@@ -4707,7 +4717,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-format"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"proptest",
|
||||
@@ -4722,7 +4732,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-geom"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"proptest",
|
||||
@@ -4734,7 +4744,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-mcp"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4753,7 +4763,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-metafile"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4767,7 +4777,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pathops"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"linesweeper",
|
||||
@@ -4779,7 +4789,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pdf"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"craft-pdf",
|
||||
@@ -4809,7 +4819,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-plugins"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4823,7 +4833,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-render"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"gif",
|
||||
@@ -4850,15 +4860,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-scriptcheck"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-svg"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"flate2",
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4881,8 +4892,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-testkit"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"fax",
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
"proptest",
|
||||
@@ -4898,7 +4910,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-text"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"harfrust 0.13.3",
|
||||
"icu_segmenter",
|
||||
@@ -4918,7 +4930,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-three-d"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
@@ -4926,7 +4938,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-tools"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4942,7 +4954,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-trace"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4954,7 +4966,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-ui-egui"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"egui",
|
||||
@@ -4988,13 +5000,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-web"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
"js-sys",
|
||||
"log",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"vectorcraft-engine",
|
||||
"vectorcraft-format",
|
||||
"vectorcraft-text",
|
||||
@@ -6098,7 +6111,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+3
-2
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -13,6 +13,7 @@ repository = "https://github.com/storytold/vectorcraft"
|
||||
[workspace.dependencies]
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d", default-features = false, features = ["interpret", "write"] }
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
# internal
|
||||
vectorcraft-geom = { path = "crates/geom" }
|
||||
@@ -64,7 +65,7 @@ wat = "1.228"
|
||||
# Without `text` (glyph runs, through glifo and a second skrifa): the renderer draws the outlines
|
||||
# vectorcraft-text gives it. The other default features stay.
|
||||
vello_cpu = { version = "0.2", default-features = false, features = ["std", "png", "u8_pipeline", "f32_pipeline"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp", "tga"] }
|
||||
# Without `text` and the system fonts (fontdb, harfrust and skrifa, at other versions than ours):
|
||||
# SVG import reads `<text>` from the XML itself and never gives usvg a font. A test of the SVG
|
||||
# crate turns `text` on.
|
||||
|
||||
@@ -11,11 +11,11 @@
|
||||
<h1 align="center">VectorCraft</h1>
|
||||
|
||||
<p align="center">
|
||||
<b>Vector illustration; an open-source, clean-room reimplementation of Adobe Illustrator, rebuilt in pure Rust.</b>
|
||||
<b>Vector illustration; an open-source, modern vector art application, built in pure Rust.</b>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
A fast, open-source, clean-room take on the Adobe Illustrator workflow. It runs natively on
|
||||
A fast, open-source vector illustration workflow. It runs natively on
|
||||
macOS, Windows, Linux and FreeBSD, and in the browser via WebAssembly. Built by the ArtCraft team.
|
||||
</p>
|
||||
|
||||
@@ -147,7 +147,7 @@ exposes to agents, and exported by VectorCraft's own renderer. The source files
|
||||
|
||||
## Why VectorCraft
|
||||
|
||||
- **Familiar.** Illustrator's layout, tools, menus, panels and shortcuts: the Pen, Direct Selection,
|
||||
- **Familiar.** A familiar layout, tools, menus, panels and shortcuts: the Pen, Direct Selection,
|
||||
Pathfinder, Smart Guides, Appearance, Swatches, Layers and more. You already know how to use it.
|
||||
- **Fast.** Multithreaded SIMD rendering off the UI thread. 20,000 shapes render in about 27 ms at
|
||||
full retina resolution while the interface stays at 120 fps.
|
||||
@@ -194,8 +194,10 @@ drag-and-drop, set **Preferences › Performance › Linux Display Server** to *
|
||||
the app with `WAYLAND_DISPLAY= vectorcraft`: the window then opens through XWayland, where drops work
|
||||
(see [`docs/development.md`](docs/development.md#linux-wayland-and-x11)).
|
||||
|
||||
On Linux under KDE Plasma 6.3 or later with Wayland, a drawing tablet's pen moves the cursor but VectorCraft doesn't
|
||||
respond to it yet (#491). Open the window through XWayland instead, with the same preference or `WAYLAND_DISPLAY= vectorcraft` (see
|
||||
On Linux with Wayland, under KDE Plasma 6.3 or later and under GNOME, VectorCraft doesn't respond to a drawing tablet's
|
||||
pen yet (#491, #2028). Open the window through XWayland instead, where the pen works as a mouse: set the same
|
||||
preference to **X11 (XWayland)**, or to **X11 (XWayland) When a Pen Is Attached** to use XWayland only when a pen is
|
||||
attached as VectorCraft starts, and restart; or start the app with `WAYLAND_DISPLAY= vectorcraft` (see
|
||||
[`docs/development.md`](docs/development.md#linux-wayland-and-x11)).
|
||||
|
||||
### Use it from Claude Code and other agents
|
||||
@@ -222,9 +224,8 @@ VectorCraft is under active development. [**ROADMAP.md**](ROADMAP.md) has the st
|
||||
and what's next; [`docs/target-app-parity.md`](docs/target-app-parity.md) the full parity assessment and
|
||||
[`docs/gaps.md`](docs/gaps.md) the ranked list of what's missing.
|
||||
|
||||
**Where we are (2026-10-10): alpha.** About 88% of Illustrator 2026's menu items, tools, effects and formats exist
|
||||
(92.5% of menu items do something), feature depth is ~75%, and we estimate ~65% "ready for real work" for a mainstream illustrator (~62% against
|
||||
all of Illustrator): everyday
|
||||
**Where we are (2026-10-10): alpha.** About 88% of core menu items, tools, effects and formats exist
|
||||
(92.5% of menu items do something), feature depth is ~75%, and we estimate ~65% ready for real work for a mainstream illustrator (~62% across all tracked features): everyday
|
||||
vector illustration is close to usable: drawing and path tools,
|
||||
Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles, threading and Hebrew/Arabic bidirectional layout, and
|
||||
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, legacy version 7 `.ai` keeping layers, groups and type, EPS, DXF, EMF/WMF, raster formats, PSD out and PSD/PSB in as their merged image, Print, Package).
|
||||
@@ -236,22 +237,22 @@ doesn't write Affinity files. Current `.af` validation includes 33 pinned files,
|
||||
and every-board SVG/PDF/PSD export, with fixes for Affinity 3 artboards, source-backed JPEGs and text runs.
|
||||
[Scope and limits](crates/affinity/README.md); [source audit and remaining gaps](docs/affinity-validation.md).
|
||||
The interface
|
||||
speaks English, German, Japanese, Korean, Vietnamese, Traditional and Simplified Chinese, Spanish, French, Hungarian, Italian, Polish, Brazilian Portuguese, Russian, Swedish, Ukrainian, Finnish, Bulgarian, Greek and Czech;
|
||||
speaks English, German, Japanese, Korean, Vietnamese, Traditional and Simplified Chinese, Spanish, French, Hungarian, Italian, Polish, Brazilian Portuguese, Russian, Swedish, Ukrainian, Finnish, Bulgarian, Greek, Czech and Georgian;
|
||||
[localization-parity.md](docs/localization-parity.md) has the details.
|
||||
The scores are self-assessed from our code against Illustrator's public documentation;
|
||||
The scores are self-assessed from our code against public documentation;
|
||||
[target-app-parity.md](docs/target-app-parity.md#how-this-was-measured-2026-10-10) explains how far to trust them.
|
||||
|
||||
**What's missing:**
|
||||
- reliable exchange of Illustrator's own `.ai` files (layer structure in real files; Illustrator's editing data isn't written);
|
||||
- reliable exchange of .ai files (layer structure in real files; proprietary 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 (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;
|
||||
- about half of the 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.
|
||||
|
||||
**Where we're going:** next is the interaction-fidelity pass, `.ai` files from real users and start-up stability,
|
||||
alongside the raster-effects package; then type, the Illustrator 2026 additions and 3D. The ranked list is in
|
||||
alongside the raster-effects package; then type, recent additions and 3D. The ranked list is in
|
||||
[`docs/gaps.md`](docs/gaps.md).
|
||||
|
||||
**Workspace:** `crates/{geom, color, doc, pathops, text, effects, three-d, trace, brush, render, svg, pdf, eps, cad, metafile, format, tools, engine, ui-egui, mcp, testkit}`
|
||||
@@ -370,8 +371,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif
|
||||
part of this repository and VectorCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
|
||||
Forks and modified versions must remove them.
|
||||
|
||||
<sub>Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. VectorCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
|
||||
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
|
||||
|
||||
+13
-3
@@ -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-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)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (about 90 community PRs of 2026-10-19: Objects on Path, the Simplify slider, Pencil Edit Selected Paths in the middle of a path, Georgian, and more; Background Export progress dialog with Cancel, #2564; about 85 community PRs of 2026-10-18: Justification…, Word .docx placed as area type, CCITT Group 3 TIFF, Roman Hanging Punctuation, blend spines edited with the Anchor Point tool, shadows and glows in stack order, coloured 3D lights, a Dutch catalog completed; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; 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
|
||||
@@ -112,8 +112,8 @@ Weights, evidence and the missing items per area: [target-app-parity.md](docs/ta
|
||||
| Korean | `ko` | full | 100% |
|
||||
| Vietnamese | `vi` | full | 100% |
|
||||
|
||||
Also shipped: Traditional Chinese (partial, 80%), Bulgarian, Finnish, Greek, Hungarian, Italian, Polish, Russian, Swedish and Ukrainian (full), Czech (catalog complete; render-site gaps reduced; native review pending).
|
||||
No catalog has had a native speaker's review. Details: [localization-parity.md](docs/localization-parity.md).
|
||||
Also shipped: Traditional Chinese (partial, 80%), Bulgarian, Finnish, Greek, Hungarian, Italian, Polish, Russian, Swedish, Ukrainian and Georgian (full), Czech (catalog complete; render-site gaps reduced; native review pending).
|
||||
The Georgian catalog has had a native speaker's review; no other catalog has. Details: [localization-parity.md](docs/localization-parity.md).
|
||||
|
||||
## Upcoming
|
||||
|
||||
@@ -134,6 +134,11 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-18 | Inch rulers divide the inch in half, not in ten. Zooming in stepped an inch ruler through 0.2, 0.1 and 0.05 of an inch, marks an inch ruler does not carry; below an inch the steps are binary fractions now, 1/2 down to 1/16, with the unlabelled ticks between them binary too (down to 1/128, so the 32nds, 64ths and 128ths appear as the report expects), while the metric units, points, pixels and picas keep their tenths. A label carries the decimals its step needs, so a sixteenth reads 0.0625 rather than the rounded 0.06, with trailing zeros dropped (#2827). Headline numbers unchanged. |
|
||||
| 2026-10-18 | Exported and saved files can repeat their bytes: PNG, PDF, EPS, legacy `.ai`, packaged zips and `.vectorcraft` saves recorded the wall-clock time they were made in, so the same commands run twice gave different bytes though the pixels matched. `SOURCE_DATE_EPOCH` now stands in for the clock in everything a file records, as the reproducible-builds specification defines it; a date a command is given still wins, and a malformed value is ignored rather than refusing the export. The recovery heartbeat and the seed PDF encryption draws on keep the real clock, so a password-protected PDF still differs between runs (#2880). Headline numbers unchanged. |
|
||||
| 2026-10-18 | About 85 community PRs landed in two batches through the full gate, with our fixes on top. New: Word `.docx` files place as area type with their formatting (#2766), CCITT Group 3 TIFF files open (#2770), Paragraph panel menu › Roman Hanging Punctuation (#2775), the Anchor Point tool edits blend spine points and handles (#2643, fixes #2544), shadows and glows each draw from the art with the effects before them, without the old exponential cost (#2395), one to ten coloured lights in 3D effects (#2706), Artboard Options X and Y with a reference point (#2739), more Contextual Task Bar actions (#2471), Optical Margin Alignment for a whole thread (#2773), History panel Step Backward/Forward and Set History Limit (#2764), Edit › Undo and Redo name the step as the History panel does (#2774), bounding-box handles snap to the grid, pixels and guides (#2715), Ctrl/Cmd+A in the Artboards panel (#2761), Tabs panel menu items (#2742), Import Data Sets defines variables from header cells (#2736), a Linux option to use X11 when a pen is attached (#2717). Fidelity and robustness: variable fonts named by their default weight and fallback glyphs keep bold and italic (#2733, #2732), legacy `.ai` layer names in Windows Latin-1 (#2722), WMF window mapping (#2721), Magic Wand inside isolation (#2720), type values out of range in files are clamped (#2728, #2757), raster effects stay responsive in drags (#2745), exports and Save for Web report undecodable images (#2762, #2737, #2684), Windows-safe exported file names (#2738), a marquee skips a Shaper Group's originals (#2772). Second batch: an export progress dialog with Cancel on the shared cancel token (#2564), CMYK gradient meshes drawn several times faster within a 16 MB cap (#2802), Paragraph panel menu › Justification… with file values clamped (#2771), Edit › Clear History (#2843), SVG image links keep their spelling (#2837, fixes main's Windows test failures), the Linux save dialog through the XDG portal (#2783), RGB input stored as CMYK in CMYK documents (#2812), legacy `.ai` export of images and radial highlights (#2801, #2807), `.ai` import notes for damaged editing data, page sizes and variable libraries (#2799, #2800, #2842), PDF and `.ai` keep their creation date (#2838), undecodable images left out of PDF exports with a note (#2790), MCP input waits for queue room (#2776), slice, snapping, selection, type and accessibility fixes (#2794–#2797, #2805, #2806, #2809, #2815, #2844–#2846), dropdowns close after a keyboard pick (#2824), plug-in dialogs scroll (#2840). Languages: the Dutch catalog completed (#2730). Headline numbers unchanged (estimated). |
|
||||
| 2026-10-19 | About 90 community PRs landed in batches through the full gate, with our fixes on top (formatting, catalog rows, small correctness fixes). New: Objects on Path (#3066), Object › Path › Simplify's slider with Auto-Simplify and More Options (#2555), Pencil Edit Selected Paths redraws the middle of a selected path (#2905), Pencil Tool Options' Alt key toggles to Smooth Tool (#2685), 3D solids built from each stroke's outline (#3049), Live Paint Selection Tool Options (#3052), Locate Selected Objects in the Layers panel (#3033), Snap to Unit for tab stops (#3039), DXF circles and arcs as native entities (#2962), Targa files (#2765), a Georgian interface (#2045), Traditional Chinese status and error messages (#2784), the web page's `window.vectorcraft` control channel (#2943), `select.color` (#2939), reproducible export bytes with `SOURCE_DATE_EPOCH` (#2947), Spacing Guides on resizes (#2966) and measurement labels on more tools. Fixed: number fields step to the next whole unit (#2916), a click away from edited type makes no new text (#2922), inch rulers in halves (#2951), Recent cards in New Document (#2904), Japanese W-weights in SVG (#2896), the point-type baseline marker (#2897) and the Reshape tool's falloff (#2900). Headline numbers unchanged. |
|
||||
| 2026-10-17 | Background Export shows a progress dialog: the file being exported, the artboard count and a bar, Cancel (Esc) and Continue in Background. It runs on the shared cancel token of #2546, so a cancelled export writes no file and keeps no half-drawn pattern tile or effect raster; translated in every language with complete menus (#2564). Headline numbers unchanged. |
|
||||
| 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). Later: Effect › Rasterize (#2409), variable-font axis sliders in the Character panel (#2710), Image Trace Create Strokes on an exact distance transform with Guo–Hall thinning and a skeleton graph (#2703–#2705), Auto Color keeps rare colours (#2702), large images trace with a warning (#2700), a 1e9 pt input bound on Reshape Segment, Convert Anchor Point and the Pencil (#2709), task-bar Connect, Corner and Smooth for selected anchors (#2697), PDF 0 w strokes import as hairlines (#2696), threading text from theLine truncated
|
||||
| 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. |
|
||||
@@ -162,6 +167,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
| 2026-10-12 | A `.ai` file with placed (linked) pictures opens from its editing data, with its guides, sublayers and hidden objects: each picture is a linked image in its place, showing the low-resolution preview the file keeps until its file is found or relinked (#1264). Scores unchanged. |
|
||||
| 2026-10-12 | Desktop printing adopts the shared `craft-print` spooler with exact physical sheet sizes. Windows 7 retains its WMI/viewer transport and excludes the shared backend; compatibility CI checks the dependency tree and PE imports. Scores unchanged. |
|
||||
| 2026-10-12 | Graphs: Object › Graph › Design… and Marker… (designs with a preview, New Design, Rename, Delete Design, Paste Design, Select Unused, and markers drawn with a design or None) (#1092); Graph Data opens as a worksheet with Import Data…, column width and decimals (#1138), and takes Transpose, Switch x/y, `|` line breaks in labels and the pie Legend, Position and Sort options (#1115); Graph Type adds Add Drop Shadow and Add Legend Across Top (#1119), widths past 100%, First Row and First Column in Front, and Draw Filled Lines (#1136); graphs keep their label formatting when drawn again (#1137). Align: a Horizontal & Vertical Align Center button (#1073); Align to Key Object makes the topmost selected object the key (#1221), and the palette's key entry reads Cancel Key Object (#1224). Pen: the nearest end wins over snapping, Alt on the last anchor edits it, and Enter, Escape or a tool switch finish a handle preview as one undo step (#821). Direct Selection: a click on an anchor of an already selected path selects that anchor alone (#1237). Make Compound Path keeps the backmost object's paint, and a selected member reads and changes the compound's fill rule (#1225). Shape Builder and Live Paint no longer turn a line's loose end past a crossing into a piece that runs out and back (#1230). Art moved or duplicated with an artboard follows Transform Pattern Tiles (#1219). A click with the Rectangular Grid tool opens its options dialog (#1169). Commands that change nothing (Lock, Hide, Transform Each, Rearrange Artboards and others) add no undo step (#1233). Saving with missing placed files keeps their relink data, and .ai export honours the chosen artboards (#718). SVG export keeps Unicode data-attribute names, and SVG Options refuses an invalid artboard range (#989). CMYK colours given in percent with a component under 1 keep their scale (#1249). Colour Image Trace keeps close dark tones apart (#1247). macOS: the Save sheet has a Format menu (#1235). The Hand and Zoom tools show drawn cursors on every platform (#1238). Czech: the remaining strings are translated, and Pathfinder's Add reads "Sjednotit" (#1226). MCP: a lost reply reports that the edit may have happened instead of replaying it (#1246); `select.key` checks its id and agents can read the key object (#1222). Tests: the command sweeps and import fuzz tests fail on a panic caught by the engine's guard (#1218), and the envelope export test ignores PDF dates and file IDs (#1220). Effect › Artistic › Film Grain (Grain, Highlight Area, Intensity; Photoshop-style filters 29/57) (#1282), and Craquelure, Mosaic Tiles and Stained Glass redraw 25–30% faster with identical pixels (#1265). Direct Selection: clicking a segment selects only that segment, and Delete removes just it (#1300); Shift-click deselects one anchor of a path selected whole (#1263). Pen + Alt converts anchors of paths inside a selected group or compound path (#1306). The Curvature tool shows circular point markers without direction handles (#1284). Snap to Point: pressing near an anchor drags by that anchor, with Smart Guides on or off (#1313). Transform Again repeats the options of the transform it repeats (#1303). Aligning anchors that are already aligned adds no undo step (#1276), and type commands that change nothing record none either (#1316). Simplify keeps the path selected with its new anchors, and its dialog previews and shows the anchor count (#1305). Adding a stroke colour to an unpainted zero-width stroke gives it a visible 1 pt width (#1256). The Eyedropper samples locked objects, template layers and art outside isolation mode (#1260), and shows a pipette cursor (#1288). Fonts named by their full name resolve to the right face (#1267), and Proportional Metrics brings the matching kerning (`apkn`, `vkrn`, `vapk`) (#1291). Artboards: Escape leaves Artboard editing (#1296), Document Setup's Edit Artboards switches to the Artboard tool (#1294), and the Artboard tool starts on the active artboard (#1309). New and imported documents use the Gridline every and Subdivisions preferences (#1314). Send to Current Layer refuses a hidden or locked current layer (#1301), and layer row commands check explicit ids (#1255). Links: Find in Folder… finds missing linked files in a folder and its subfolders and can copy them into the document's folder (#1287). Image Trace traces images over 64 megapixels scaled down instead of refusing them (#1315). DXF export marks closed curves as closed SPLINE entities (#1320). EPS import: closepath respects the path size limit (#1311). The interface is available in Hungarian (#1302), and more Czech labels are translated (#1259). Linux RPMs declare shared-library capabilities (#1290). CI pins every third-party action to a verified commit (#1321). Select menu commands skip hidden and locked art and stay inside the isolated group in isolation mode (#1325). Fill, stroke, gradient and Appearance panel commands that change nothing add no undLine truncated
|
||||
| 2026-10-12 | Georgian interface (`ka`, ქართული): the whole catalog, plurals and messages, tested complete; Georgian letters come from an installed font (SF Georgian, Sylfaen, Noto Sans Georgian, DejaVu Sans) from the first frame; Type › Change Case › Title Case and Sentence case leave Georgian lower case instead of turning it into Mtavruli. |
|
||||
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). Build scripts leave a generated source they wouldn't change untouched, so the next build doesn't compile most of the workspace again where file times lose their sub-second part (#1051). A fill colour recolours the gradient mesh point last clicked with the Mesh tool or Direct Selection, and a Mesh tool click on a mesh's point selects that mesh (#1052). The All Tools drawer stays two tools wide instead of filling a wide window (#1054). Text Wrap's Make, Release and Options refuse a list of object ids with a malformed or unknown id instead of skipping it (#1055). A freeform gradient line with a malformed point index is refused instead of being shortened to the indices that are valid (#1056). The Corners dialog (`ui.corners`) refuses a malformed object id or a listed anchor that isn't a corner instead of editing the selection or the other corners (#1057). New View refuses a centre that isn't exactly [x, y] and a rotation that isn't a number, instead of trimming the one and saving the other as 0° (#1058). The command sweeps try junk scale, spacing and artboard values too, which they had skipped since before those bugs were fixed (#1059). EPS and PostScript `arc` and `arcn` with huge angles no longer hang the import, and an arc whose angles are whole turns apart is empty, as the PostScript reference defines it (#1062). Align and DisLine truncated
|
||||
| 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) |
|
||||
| 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day |
|
||||
@@ -179,6 +185,10 @@ The full pre-standard record of what landed is kept in [docs/roadmap.md](docs/ro
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-17 | minor | Colored Inflate: shared membrane normals and smooth RGB vector ramps replace flat polygon bands; channel groups are batched per solid-fill surface. White-light Inflate retains its original polygons, paint composition and seam joins; the 0.05 seam minimum is restored. Scores and hour estimates unchanged. |
|
||||
| 2026-10-16 | minor | 3D lighting: one to ten directional lights, a selectable light list, Add Light and Delete Light, independent settings and artboard gestures; ambient applied once and smooth RGB vector export; scores and hour estimates unchanged |
|
||||
| 2026-10-16 | minor | 3D lighting: opaque RGB Color for the shared directional light, with neutral ambient, preview/reset controls and smooth vector Revolve shading; scores and hour estimates unchanged |
|
||||
| 2026-10-17 | minor | Progress log row for the Background Export progress dialog (#2564); headline numbers unchanged. |
|
||||
| 2026-10-14 | minor | G11: open-path anchor Delete removes adjoining segments without bridging the gap; explicit Remove Anchors keeps its reconnect/refit behavior. Scores and hours unchanged. |
|
||||
| 2026-10-14 | minor | Document fonts register every explicitly supplied craft-fonts family after the existing Japanese, Arabic and Thai prefix; optional empty input remains supported. Headline scores and estimates are unchanged. |
|
||||
| 2026-10-14 | minor | Progress log rows for the later merges of 2026-10-14 (#1630–#1875 as listed there); headline numbers unchanged. |
|
||||
|
||||
@@ -78,16 +78,20 @@ USAGE:
|
||||
|
||||
vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON]]... [--export FILE]... [--scale N]
|
||||
Headless batch: open FILE (any readable format) or start a new document, run commands in
|
||||
order, export each FILE in the format its extension picks (see Writable formats). Prints one
|
||||
JSON result per step.
|
||||
order, export each FILE in the format its extension picks (see Writable formats; a .ait FILE
|
||||
is a template, written as file.saveAsTemplate writes it). --scale sizes raster exports
|
||||
(pixels per point, default 1); other formats leave it out. Prints one JSON result per step.
|
||||
|
||||
vectorcraft-cli commands
|
||||
Print the command catalogue as JSON.
|
||||
|
||||
vectorcraft-cli convert IN OUT [--scale N] [--artboard I | --range R] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit]
|
||||
Open IN (any readable format) and export OUT in the format its extension picks (see Writable
|
||||
formats). --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard
|
||||
formats). --scale sizes a raster OUT (pixels per point, default 1); other formats leave it
|
||||
out. --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard
|
||||
unless one of them is given, EPS the bounds of the art, the other formats the first artboard.
|
||||
A .ait OUT is a template of every artboard, written as file.saveAsTemplate writes it (not with
|
||||
--artboard or --range).
|
||||
Live effects are kept, and hidden layers and objects (written hidden in SVG and PSD), with
|
||||
SVG's data-* attributes; --outline-text writes SVG text as paths, --decimals sets SVG's coordinate
|
||||
precision (1–7, default 3). OUT may not be IN when reading
|
||||
@@ -259,6 +263,10 @@ fn convert(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
let [input, output] = <[String; 2]>::try_from(files).map_err(|_| "convert needs IN and OUT")?;
|
||||
let template = is_template(&output);
|
||||
if template && (artboard.is_some() || range.is_some()) {
|
||||
return Err("a .ait template holds every artboard: --artboard and --range don't apply".into());
|
||||
}
|
||||
let mut h = Headless::new();
|
||||
let opened = if editing_data {
|
||||
let params = json!({"path": input, "editingData": "always"});
|
||||
@@ -266,16 +274,23 @@ fn convert(args: &[String]) -> Result<(), String> {
|
||||
} else {
|
||||
h.call("app.open", json!({"path": input})).map_err(|e| format!("open {input}: {e}"))?
|
||||
};
|
||||
// A conversion keeps hidden layers and objects, written hidden, where the format can (SVG,
|
||||
// PSD).
|
||||
let mut params = json!({"path": output, "scale": scale, "artboard": artboard, "range": range, "outlineText": outline_text, "hiddenLayers": true, "acknowledgeLoss": replace_lossy});
|
||||
if let Some(decimals) = decimals {
|
||||
params["decimals"] = json!(decimals);
|
||||
}
|
||||
let (command, mut params) = if template {
|
||||
// Save as Template writes every artboard, with the .ai options.
|
||||
("file.saveAsTemplate", json!({"path": output, "format": "ait", "outlineText": outline_text, "acknowledgeLoss": replace_lossy}))
|
||||
} else {
|
||||
// A conversion keeps hidden layers and objects, written hidden, where the format can (SVG,
|
||||
// PSD).
|
||||
let params = json!({"path": output, "artboard": artboard, "range": range, "outlineText": outline_text, "hiddenLayers": true, "acknowledgeLoss": replace_lossy});
|
||||
let mut params = with_pixel_scale(params, &output, scale);
|
||||
if let Some(decimals) = decimals {
|
||||
params["decimals"] = json!(decimals);
|
||||
}
|
||||
("document.export", params)
|
||||
};
|
||||
for (key, value) in legacy {
|
||||
params[key] = json!(value);
|
||||
}
|
||||
let mut r = h.call("engine.execute", json!({"command": "document.export", "params": params})).map_err(|e| format!("export {output}: {e}"))?;
|
||||
let mut r = h.call("engine.execute", json!({"command": command, "params": params})).map_err(|e| format!("export {output}: {e}"))?;
|
||||
// What reading the input left out (a missing linked file, an unsupported filter…) matters as
|
||||
// much as what writing the output did.
|
||||
if let (Some(w), Some(out)) = (opened.get("warnings").filter(|w| w.as_array().is_some_and(|a| !a.is_empty())), r.as_object_mut()) {
|
||||
@@ -285,6 +300,21 @@ fn convert(args: &[String]) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Is `path` a `.ait` template? `file.saveAsTemplate` writes it; exports don't.
|
||||
fn is_template(path: &str) -> bool {
|
||||
vectorcraft_engine::cmd::fileio::format_for_name(path).is_some_and(|f| f.id == "ait")
|
||||
}
|
||||
|
||||
/// The export `params` for `path`, with `--scale` (pixels per point) when its format is raster.
|
||||
/// DXF has a `scale` of its own (drawing units per unit), which would resize the drawing and leave
|
||||
/// its units unset.
|
||||
fn with_pixel_scale(mut params: Value, path: &str, scale: f64) -> Value {
|
||||
if vectorcraft_engine::cmd::fileio::writable_format(None, Some(path)).is_ok_and(|f| f.raster) {
|
||||
params["scale"] = json!(scale);
|
||||
}
|
||||
params
|
||||
}
|
||||
|
||||
fn info(args: &[String]) -> Result<(), String> {
|
||||
let file = args.first().ok_or("info needs a FILE")?;
|
||||
let mut h = Headless::new();
|
||||
@@ -371,7 +401,12 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
r
|
||||
}
|
||||
Step::Export(path) => {
|
||||
let r = h.call("app.export", json!({"path": path, "scale": scale})).map_err(|e| format!("export {path}: {e}"))?;
|
||||
let r = if is_template(&path) {
|
||||
h.call("engine.execute", json!({"command": "file.saveAsTemplate", "params": {"path": path, "format": "ait"}}))
|
||||
} else {
|
||||
h.call("app.export", with_pixel_scale(json!({"path": path}), &path, scale))
|
||||
};
|
||||
let r = r.map_err(|e| format!("export {path}: {e}"))?;
|
||||
emit(json!({"step": "export", "result": r}))?;
|
||||
r
|
||||
}
|
||||
|
||||
@@ -58,6 +58,28 @@ fn run_batch_exports() {
|
||||
assert_eq!(last["result"]["artboards"][0]["width"], 200.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_sizes_raster_exports_only() {
|
||||
// --scale is pixels per point. DXF has a `scale` of its own (drawing units per mm) that would
|
||||
// resize the drawing and leave its units unset.
|
||||
let svg = tmp("scale.svg");
|
||||
std::fs::write(&svg, r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="50"><rect x="10" y="10" width="30" height="20"/></svg>"#)
|
||||
.unwrap();
|
||||
let svg = svg.to_str().unwrap();
|
||||
let (plain, scaled, png) = (tmp("plain.dxf"), tmp("scaled.dxf"), tmp("scaled.png"));
|
||||
let ok = |args: &[&str]| {
|
||||
let out = Command::new(BIN).args(args).output().unwrap();
|
||||
assert!(out.status.success(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
};
|
||||
ok(&["convert", svg, plain.to_str().unwrap()]);
|
||||
ok(&["convert", svg, scaled.to_str().unwrap(), "--scale", "2"]);
|
||||
assert!(std::fs::read(&scaled).unwrap() == std::fs::read(&plain).unwrap(), "convert --scale 2 changed the DXF");
|
||||
ok(&["run", "--in", svg, "--export", scaled.to_str().unwrap(), "--export", png.to_str().unwrap(), "--scale", "2"]);
|
||||
assert!(std::fs::read(&scaled).unwrap() == std::fs::read(&plain).unwrap(), "run --scale 2 changed the DXF");
|
||||
// 100 pt at scale 2 → 200 px (IHDR width).
|
||||
assert_eq!(std::fs::read(&png).unwrap()[16..20], 200u32.to_be_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_reports_errors() {
|
||||
let out = Command::new(BIN).args(["run", "--cmd", "nope.nothing"]).output().unwrap();
|
||||
|
||||
@@ -179,3 +179,28 @@ fn convert_writes_legacy_ai_with_its_options() {
|
||||
assert!(!bad.status.success());
|
||||
assert!(String::from_utf8_lossy(&bad.stderr).contains("hiddenObjects"), "{}", String::from_utf8_lossy(&bad.stderr));
|
||||
}
|
||||
|
||||
/// convert and `run --export` write a `.ait` as Save as Template does: a PDF-compatible template of
|
||||
/// every artboard that opens as a new untitled document.
|
||||
#[test]
|
||||
fn convert_and_run_write_ait_templates() {
|
||||
let pdf = tmp("template-in.pdf");
|
||||
make(&pdf, 120, 80);
|
||||
let (converted, exported) = (tmp("converted.ait"), tmp("exported.ait"));
|
||||
let r: Value = serde_json::from_str(&ok(&["convert", &pdf, &converted])).unwrap();
|
||||
assert_eq!((&r["format"], &r["path"]), (&Value::from("ait"), &Value::from(converted.as_str())), "{r}");
|
||||
let r: Value = serde_json::from_str(ok(&["run", "--in", &pdf, "--export", &exported]).lines().nth(1).unwrap()).unwrap();
|
||||
assert_eq!(r["result"]["format"], "ait", "{r}");
|
||||
for ait in [&converted, &exported] {
|
||||
assert!(std::fs::read(ait).unwrap().starts_with(b"%PDF"), "{ait}");
|
||||
let opened: Value = serde_json::from_str(ok(&["run", "--in", ait]).lines().next().unwrap()).unwrap();
|
||||
let opened = &opened["result"];
|
||||
assert_eq!((&opened["format"], &opened["restored"]), (&Value::from("ait"), &Value::from(true)), "{opened}");
|
||||
assert!(opened["title"].as_str().is_some_and(|t| t.starts_with("Untitled-")), "{opened}");
|
||||
assert_eq!(info(ait)["artboards"].as_array().unwrap().len(), 2, "{ait}");
|
||||
}
|
||||
// A template holds every artboard: one can't be picked.
|
||||
let one = Command::new(BIN).args(["convert", &pdf, &tmp("one.ait"), "--artboard", "0"]).output().unwrap();
|
||||
assert!(!one.status.success());
|
||||
assert!(String::from_utf8_lossy(&one.stderr).contains("every artboard"), "{}", String::from_utf8_lossy(&one.stderr));
|
||||
}
|
||||
@@ -26,6 +26,7 @@ log = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
|
||||
rfd = { workspace = true }
|
||||
wasm-bindgen = "0.2"
|
||||
serde_json = { workspace = true }
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlIFrameElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "PointerEvent", "TouchEvent", "AddEventListenerOptions", "DomRect", "DragEvent", "Storage", "Navigator", "RequestInit", "Response", "AbortController", "AbortSignal"] }
|
||||
@@ -0,0 +1,184 @@
|
||||
//! `window.vectorcraft`: the control channel for the page, as promises.
|
||||
//!
|
||||
//! The same JSON methods as the desktop control server (`docs/control-protocol.md`), so an agent
|
||||
//! driving VectorCraft in a browser tab (or an `<iframe>` in another app) uses the same commands as
|
||||
//! the CLI and the MCP server:
|
||||
//!
|
||||
//! ```js
|
||||
//! await vectorcraft.execute("object.rectangle", {x: 0, y: 0, width: 100, height: 50}) // engine command → its result
|
||||
//! await vectorcraft.request("ui.inspect") // control method → its result
|
||||
//! await vectorcraft.commands() // command list
|
||||
//! const {base64} = await vectorcraft.screenshot() // the canvas as PNG (base64)
|
||||
//! await vectorcraft.openFile("logo.svg", bytes) // opened as a dropped file is
|
||||
//! vectorcraft.captureDownloads(true) // saves stay in the page…
|
||||
//! vectorcraft.files(); await vectorcraft.readFile("logo.png") // …to be read back
|
||||
//! ```
|
||||
//!
|
||||
//! Results resolve with the method's `result`; errors reject with an `Error`.
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::sync::mpsc::{Receiver, Sender, TryRecvError};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_ui_egui::ControlRequest;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::saved_files::SavedFiles;
|
||||
|
||||
use crate::web::Inbox;
|
||||
|
||||
type Waiting = (Receiver<Value>, js_sys::Function, js_sys::Function);
|
||||
|
||||
thread_local! {
|
||||
static TX: RefCell<Option<Sender<ControlRequest>>> = const { RefCell::new(None) };
|
||||
static WAITING: RefCell<Vec<Waiting>> = const { RefCell::new(Vec::new()) };
|
||||
static CTX: RefCell<Option<egui::Context>> = const { RefCell::new(None) };
|
||||
static INBOX: RefCell<Option<Inbox>> = const { RefCell::new(None) };
|
||||
static CAPTURE: Cell<bool> = const { Cell::new(false) };
|
||||
static SAVED: RefCell<SavedFiles> = RefCell::new(SavedFiles::default());
|
||||
}
|
||||
|
||||
/// Wire the page's requests to the app: control requests go to `tx`, opened files to `inbox`.
|
||||
/// After a graphics restart the app keeps its channel: `tx` is `None` and only the context changes.
|
||||
pub fn connect(tx: Option<Sender<ControlRequest>>, inbox: Inbox, ctx: egui::Context) {
|
||||
if let Some(tx) = tx {
|
||||
TX.with(|t| *t.borrow_mut() = Some(tx));
|
||||
}
|
||||
INBOX.with(|i| *i.borrow_mut() = Some(inbox));
|
||||
CTX.with(|c| *c.borrow_mut() = Some(ctx));
|
||||
}
|
||||
|
||||
fn repaint() {
|
||||
CTX.with(|c| {
|
||||
if let Some(ctx) = c.borrow().as_ref() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Keep a save instead of downloading it, when the page asked for that. True when kept.
|
||||
pub fn capture(path: &str, bytes: &[u8]) -> bool {
|
||||
if !CAPTURE.with(Cell::get) {
|
||||
return false;
|
||||
}
|
||||
SAVED.with(|s| s.borrow_mut().keep(path, bytes));
|
||||
true
|
||||
}
|
||||
|
||||
fn to_js(v: &Value) -> JsValue {
|
||||
js_sys::JSON::parse(&v.to_string()).unwrap_or(JsValue::NULL)
|
||||
}
|
||||
|
||||
fn from_js(v: &JsValue) -> Value {
|
||||
if v.is_undefined() || v.is_null() {
|
||||
return json!({});
|
||||
}
|
||||
js_sys::JSON::stringify(v).ok().and_then(|s| s.as_string()).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({}))
|
||||
}
|
||||
|
||||
/// Settle the promises of answered requests (called every frame).
|
||||
pub fn poll_replies() {
|
||||
WAITING.with(|w| {
|
||||
w.borrow_mut().retain(|(rx, resolve, reject)| match rx.try_recv() {
|
||||
Ok(v) => {
|
||||
if v.get("ok").and_then(Value::as_bool) == Some(true) {
|
||||
let _ = resolve.call1(&JsValue::NULL, &to_js(v.get("result").unwrap_or(&Value::Null)));
|
||||
} else {
|
||||
let msg = v.get("error").and_then(Value::as_str).unwrap_or("error").to_string();
|
||||
let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new(&msg));
|
||||
}
|
||||
false
|
||||
}
|
||||
Err(TryRecvError::Empty) => true,
|
||||
Err(TryRecvError::Disconnected) => {
|
||||
let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new("request dropped"));
|
||||
false
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn request(method: &str, params: Value) -> js_sys::Promise {
|
||||
let method = method.to_string();
|
||||
let p = js_sys::Promise::new(&mut |resolve, reject| {
|
||||
let (req, rx) = ControlRequest::new(method.clone(), params.clone());
|
||||
let sent = TX.with(|t| t.borrow().as_ref().is_some_and(|tx| tx.send(req).is_ok()));
|
||||
if sent {
|
||||
WAITING.with(|w| w.borrow_mut().push((rx, resolve, reject)));
|
||||
} else {
|
||||
let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new("VectorCraft is not running yet"));
|
||||
}
|
||||
});
|
||||
repaint();
|
||||
p
|
||||
}
|
||||
|
||||
fn set_fn(obj: &js_sys::Object, name: &str, f: JsValue) -> Result<(), JsValue> {
|
||||
js_sys::Reflect::set(obj, &name.into(), &f).map(|_| ())
|
||||
}
|
||||
|
||||
/// Install `window.vectorcraft`.
|
||||
pub fn install() -> Result<(), JsValue> {
|
||||
let obj = js_sys::Object::new();
|
||||
set_fn(
|
||||
&obj,
|
||||
"request",
|
||||
Closure::<dyn Fn(String, JsValue) -> js_sys::Promise>::new(|method: String, params: JsValue| request(&method, from_js(¶ms)))
|
||||
.into_js_value(),
|
||||
)?;
|
||||
set_fn(
|
||||
&obj,
|
||||
"execute",
|
||||
Closure::<dyn Fn(String, JsValue) -> js_sys::Promise>::new(|command: String, params: JsValue| {
|
||||
request("engine.execute", json!({"command": command, "params": from_js(¶ms)}))
|
||||
})
|
||||
.into_js_value(),
|
||||
)?;
|
||||
set_fn(&obj, "commands", Closure::<dyn Fn() -> js_sys::Promise>::new(|| request("engine.commands", json!({}))).into_js_value())?;
|
||||
set_fn(&obj, "inspect", Closure::<dyn Fn() -> js_sys::Promise>::new(|| request("ui.inspect", json!({}))).into_js_value())?;
|
||||
set_fn(
|
||||
&obj,
|
||||
"screenshot",
|
||||
Closure::<dyn Fn(JsValue) -> js_sys::Promise>::new(|params: JsValue| request("ui.screenshot", from_js(¶ms))).into_js_value(),
|
||||
)?;
|
||||
set_fn(
|
||||
&obj,
|
||||
"openFile",
|
||||
Closure::<dyn Fn(String, js_sys::Uint8Array) -> JsValue>::new(|name: String, bytes: js_sys::Uint8Array| {
|
||||
let queued = INBOX.with(|i| {
|
||||
i.borrow().as_ref().is_some_and(|inbox| {
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes.to_vec()));
|
||||
true
|
||||
})
|
||||
});
|
||||
repaint();
|
||||
JsValue::from_bool(queued)
|
||||
})
|
||||
.into_js_value(),
|
||||
)?;
|
||||
set_fn(&obj, "captureDownloads", Closure::<dyn Fn(bool)>::new(|on: bool| CAPTURE.with(|c| c.set(on))).into_js_value())?;
|
||||
set_fn(
|
||||
&obj,
|
||||
"files",
|
||||
Closure::<dyn Fn() -> JsValue>::new(|| {
|
||||
SAVED.with(|s| to_js(&Value::Array(s.borrow().list().map(|(n, size)| json!({"path": n, "size": size})).collect())))
|
||||
})
|
||||
.into_js_value(),
|
||||
)?;
|
||||
set_fn(
|
||||
&obj,
|
||||
"readFile",
|
||||
Closure::<dyn Fn(String) -> Result<JsValue, JsValue>>::new(|name: String| {
|
||||
SAVED.with(|s| {
|
||||
s.borrow()
|
||||
.get(&name)
|
||||
.map(|b| js_sys::Uint8Array::from(b).into())
|
||||
.ok_or_else(|| js_sys::Error::new(&format!("{name}: not found")).into())
|
||||
})
|
||||
})
|
||||
.into_js_value(),
|
||||
)?;
|
||||
let w = web_sys::window().ok_or("no window")?;
|
||||
js_sys::Reflect::set(&w, &"vectorcraft".into(), &obj)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -16,12 +16,16 @@
|
||||
//! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?lang=es` (any
|
||||
//! language code) stands in for the browser's languages when the interface language is Automatic.
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod api;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod fonts;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod locks;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod pencil;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod saved_files;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
//! What the page keeps of the app's saves (`api.rs`): `readFile` reads them back.
|
||||
|
||||
/// Saved files kept (the oldest go first).
|
||||
pub const MAX_SAVED: usize = 16;
|
||||
|
||||
/// The last component of a `/` or `\` path.
|
||||
fn file_name(path: &str) -> &str {
|
||||
path.rsplit(['/', '\\']).next().filter(|n| !n.is_empty()).unwrap_or(path)
|
||||
}
|
||||
|
||||
/// Files by name, newest last: saving a name again replaces it.
|
||||
#[derive(Default)]
|
||||
pub struct SavedFiles(Vec<(String, Vec<u8>)>);
|
||||
|
||||
impl SavedFiles {
|
||||
/// Keep `bytes` under the last component of `path`.
|
||||
pub fn keep(&mut self, path: &str, bytes: &[u8]) {
|
||||
let name = file_name(path).to_string();
|
||||
self.0.retain(|(n, _)| *n != name);
|
||||
self.0.push((name, bytes.to_vec()));
|
||||
let extra = self.0.len().saturating_sub(MAX_SAVED);
|
||||
self.0.drain(..extra);
|
||||
}
|
||||
|
||||
/// A kept file by its name, or by a path ending in it.
|
||||
pub fn get(&self, name: &str) -> Option<&[u8]> {
|
||||
let wanted = file_name(name);
|
||||
self.0.iter().find(|(n, _)| n == wanted).map(|(_, b)| b.as_slice())
|
||||
}
|
||||
|
||||
/// `(name, size)` of each kept file, oldest first.
|
||||
pub fn list(&self) -> impl Iterator<Item = (&str, usize)> {
|
||||
self.0.iter().map(|(n, b)| (n.as_str(), b.len()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn keeps_by_name_replaces_and_reads_back_by_path() {
|
||||
let mut s = SavedFiles::default();
|
||||
s.keep("exports/poster.png", b"one");
|
||||
s.keep(r"C:\out\poster.png", b"two");
|
||||
s.keep("notes.txt", b"three");
|
||||
assert_eq!(s.list().collect::<Vec<_>>(), vec![("poster.png", 3), ("notes.txt", 5)]);
|
||||
assert_eq!(s.get("poster.png"), Some(&b"two"[..]));
|
||||
assert_eq!(s.get("/exports/poster.png"), Some(&b"two"[..]));
|
||||
assert_eq!(s.get("missing.png"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_the_oldest_past_the_cap() {
|
||||
let mut s = SavedFiles::default();
|
||||
for i in 0..MAX_SAVED + 3 {
|
||||
s.keep(&format!("f{i}.png"), &[0]);
|
||||
}
|
||||
assert_eq!(s.list().count(), MAX_SAVED);
|
||||
assert_eq!(s.get("f0.png"), None);
|
||||
assert!(s.get(&format!("f{}.png", MAX_SAVED + 2)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_trailing_separator_keeps_the_whole_name() {
|
||||
let mut s = SavedFiles::default();
|
||||
s.keep("odd/", b"x");
|
||||
assert_eq!(s.get("odd/"), Some(&b"x"[..]));
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ use wasm_bindgen::JsCast as _;
|
||||
use crate::locks::WebLocks;
|
||||
use crate::pencil::SharedPointers;
|
||||
|
||||
type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
pub(crate) type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
/// Where the pointer was over the canvas during the last file drag (CSS px from its top-left) and
|
||||
/// whether Shift was held: where dropped files land.
|
||||
@@ -122,9 +122,18 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option<Movi
|
||||
// The old services asked the old context for frames.
|
||||
app.services = services;
|
||||
app.status("The graphics were lost and have been restarted");
|
||||
crate::api::connect(None, host.inbox.clone(), ctx.clone());
|
||||
app
|
||||
}
|
||||
None => VectorcraftApp::new(Session::new(), services),
|
||||
None => {
|
||||
// `window.vectorcraft`: the page's control channel (crate::api).
|
||||
let (control_tx, control_rx) = std::sync::mpsc::channel();
|
||||
crate::api::connect(Some(control_tx), host.inbox.clone(), ctx.clone());
|
||||
if let Err(e) = crate::api::install() {
|
||||
log::error!("vectorcraft-web: couldn't install window.vectorcraft: {}", js_err(e));
|
||||
}
|
||||
VectorcraftApp::new(Session::new(), services).with_control(control_rx)
|
||||
}
|
||||
};
|
||||
Ok(Box::new(WebShell { app: Some(app), host, canvas: page, loss }))
|
||||
}),
|
||||
@@ -386,6 +395,7 @@ impl eframe::App for WebShell {
|
||||
}
|
||||
}
|
||||
app.logic(ctx);
|
||||
crate::api::poll_replies();
|
||||
}
|
||||
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
@@ -399,6 +409,7 @@ impl eframe::App for WebShell {
|
||||
if let Some(app) = &mut self.app {
|
||||
app.ui(ui);
|
||||
}
|
||||
crate::api::poll_replies();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -544,6 +555,9 @@ impl RecoveryStore for BrowserStore {
|
||||
|
||||
/// Trigger a browser download of `bytes` named after the last component of `path`.
|
||||
fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
if crate::api::capture(path, bytes) {
|
||||
return Ok(());
|
||||
}
|
||||
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "vectorcraft".into());
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
|
||||
@@ -75,7 +75,17 @@ pollster = "1"
|
||||
# Asks the Wayland compositor which GPU it draws with (linux-dmabuf feedback, src/compositor_gpu.rs),
|
||||
# so Automatic draws on that one. Both are already in the build through winit.
|
||||
wayland-client = "0.31"
|
||||
wayland-protocols = { version = "0.32", features = ["client"] }
|
||||
wayland-protocols = { version = "0.32", features = ["client", "unstable"] }
|
||||
# The save dialog's parent window on Wayland (src/portal_save.rs): an xdg-foreign handle (in
|
||||
# wayland-protocols' `unstable` set) made on the window's own connection. MIT, already in the build
|
||||
# through winit and rfd.
|
||||
wayland-backend = { version = "0.3", features = ["client_system"] }
|
||||
|
||||
# The save dialog's file type (src/portal_save.rs, #2679): the desktop portal over D-Bus, on the
|
||||
# systems where rfd shows the portal's dialog. MIT, already in the build through accesskit. Pinned
|
||||
# exactly: review the diff before bumping.
|
||||
[target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))'.dependencies]
|
||||
zbus = "=5.19.0"
|
||||
|
||||
[build-dependencies]
|
||||
# Icon + VERSIONINFO for vectorcraft.exe; build.rs only uses it when targeting Windows.
|
||||
@@ -84,3 +94,5 @@ winresource = "0.1"
|
||||
[dev-dependencies]
|
||||
# Bound socket buffers in real loopback transport deadline regressions.
|
||||
socket2 = { workspace = true }
|
||||
# src/portal_save.rs's tests build and read the portal's D-Bus values on every system.
|
||||
zbus = "=5.19.0"
|
||||
@@ -383,7 +383,10 @@ mod tests {
|
||||
});
|
||||
let mut reader = BufReader::new(client.try_clone().unwrap());
|
||||
for id in [1, 2] {
|
||||
writeln!(client, "{}", json!({"id": id, "method": "inspect"})).unwrap();
|
||||
// The client sends each line in one write. `writeln!` on the socket sends the JSON in many
|
||||
// small writes, and when the machine is busy, a pause of more than the 120 ms line deadline
|
||||
// between two of them closes the connection.
|
||||
client.write_all(format!("{}\n", json!({"id": id, "method": "inspect"})).as_bytes()).unwrap();
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Value>(&line).unwrap(), json!({"id": id, "ok": true}));
|
||||
|
||||
@@ -43,6 +43,8 @@ mod mac_screen_color;
|
||||
mod mac_window;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod open_documents;
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd", test))]
|
||||
mod portal_save;
|
||||
mod prefs_dir;
|
||||
mod printing;
|
||||
#[cfg(all(windows, not(target_vendor = "win7")))]
|
||||
@@ -359,10 +361,20 @@ fn make_folder(request: &PickRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Show `dialog` for `request` (on the calling thread, until it closes) → the paths picked.
|
||||
/// Show `dialog` for `request` (on the calling thread, until it closes) → the paths picked. On
|
||||
/// Linux and the BSDs a save dialog with two or more file types is the desktop portal's, over
|
||||
/// `parent`, and its answer also names the file type picked ([`portal_save`]); `dialog` shows when
|
||||
/// the portal can't.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn show_dialog(dialog: rfd::FileDialog, request: &PickRequest) -> Vec<String> {
|
||||
#[cfg_attr(windows, allow(unused_variables))]
|
||||
fn show_dialog(dialog: rfd::FileDialog, request: &PickRequest, parent: &Parent) -> Vec<String> {
|
||||
make_folder(request);
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
|
||||
if let PickRequest::Save(pick) = request
|
||||
&& let Some(answer) = portal_save::pick(pick, parent.as_deref())
|
||||
{
|
||||
return answer;
|
||||
}
|
||||
let paths = match request {
|
||||
PickRequest::Open(_) => dialog.pick_file().into_iter().collect(),
|
||||
PickRequest::OpenMany => dialog.pick_files().unwrap_or_default(),
|
||||
@@ -375,7 +387,7 @@ fn show_dialog(dialog: rfd::FileDialog, request: &PickRequest) -> Vec<String> {
|
||||
/// Show the dialog for `request` over `parent` now → the paths picked.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn pick_now(request: PickRequest, parent: &Parent) -> Vec<String> {
|
||||
show_dialog(file_dialog(&request, parent), &request)
|
||||
show_dialog(file_dialog(&request, parent), &request, parent)
|
||||
}
|
||||
|
||||
/// macOS shows no dialog in line: its modal loop would run inside winit's event handler, and an
|
||||
@@ -389,6 +401,7 @@ fn pick_now(request: PickRequest, _parent: &Parent) -> Vec<String> {
|
||||
|
||||
/// File → Show in Folder: select `path` in Finder / Explorer, or open its folder elsewhere.
|
||||
fn reveal(path: &str) -> Result<(), String> {
|
||||
explorer_safe(path)?;
|
||||
reveal_command(path).spawn().map(|_| ()).map_err(|e| format!("can't show {path}: {e}"))
|
||||
}
|
||||
|
||||
@@ -430,11 +443,12 @@ fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Opti
|
||||
let dialog = file_dialog(&request, parent);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let ui = ui.clone();
|
||||
let parent = parent.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("file-dialog".into())
|
||||
.spawn(move || {
|
||||
// The app gone meanwhile has no use for the answer.
|
||||
let _ = tx.send(show_dialog(dialog, &request));
|
||||
let _ = tx.send(show_dialog(dialog, &request, &parent));
|
||||
ui.request_repaint();
|
||||
})
|
||||
.ok()?;
|
||||
@@ -559,8 +573,15 @@ fn services(parent: Parent, ui: egui::Context) -> Services {
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows passes `path` to Explorer inside quotes it adds itself, so a path holding a quote (which
|
||||
/// no Windows file name has) is refused rather than split into other arguments.
|
||||
fn explorer_safe(path: &str) -> Result<(), String> {
|
||||
if cfg!(windows) && path.contains('"') { Err(format!("can't open {path}: the path contains a quotation mark")) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// Edit Original, Show Package: open `path` (a file or a folder) in the system's default app for it.
|
||||
fn open_file(path: &str) -> Result<(), String> {
|
||||
explorer_safe(path)?;
|
||||
#[cfg(windows)]
|
||||
let mut c = {
|
||||
use std::os::windows::process::CommandExt as _;
|
||||
@@ -788,14 +809,19 @@ fn main() -> std::process::ExitCode {
|
||||
let custom_titlebar = custom_titlebar(saved.as_ref());
|
||||
let options = native_options(custom_titlebar);
|
||||
// winit picks Wayland whenever `WAYLAND_DISPLAY` is set; Preferences › Performance › Linux
|
||||
// Display Server › X11 opens the window through XWayland instead, where file drops work (#1151).
|
||||
// Display Server › X11 opens the window through XWayland instead, where file drops work (#1151),
|
||||
// and X11 (XWayland) When a Pen Is Attached does so when a pen is attached, where the pen draws (#2028).
|
||||
#[cfg(target_os = "linux")]
|
||||
let options = {
|
||||
let mut options = options;
|
||||
let pref = saved.as_ref().and_then(|ui| ui.engine_prefs.get("linuxDisplayServer")).and_then(serde_json::Value::as_str);
|
||||
if xwayland::wanted(pref, |name| std::env::var_os(name)) {
|
||||
if xwayland::wanted(pref, |name| std::env::var_os(name), xwayland::pen_attached) {
|
||||
if xwayland::x11_libraries_found() {
|
||||
log::info!("opening the window through XWayland (Preferences › Performance › Linux Display Server)");
|
||||
if pref == Some("x11WithPen") {
|
||||
log::info!("pen found: opening the window through XWayland (Preferences › Performance › Linux Display Server)");
|
||||
} else {
|
||||
log::info!("opening the window through XWayland (Preferences › Performance › Linux Display Server)");
|
||||
}
|
||||
use winit::platform::x11::EventLoopBuilderExtX11 as _;
|
||||
options.event_loop_builder = Some(Box::new(|builder| {
|
||||
builder.with_x11();
|
||||
@@ -1064,4 +1090,116 @@ mod tests {
|
||||
assert!(types > 1 && plist.matches("<key>LSHandlerRank</key>").count() == types, "every document type has a rank");
|
||||
assert_eq!(plist.matches("<string>Owner</string>").count(), 2, "only VectorCraft documents and templates are owned");
|
||||
}
|
||||
|
||||
/// The MIME types a Linux desktop gives each extension in `OPEN_EXTS` but `UNASSOCIATED_EXTS`
|
||||
/// (one row per type): shared-mime-info's, or one that `ai.storyteller.vectorcraft.mime.xml`
|
||||
/// declares.
|
||||
const LINUX_TYPES: &[(&str, &str)] = &[
|
||||
("vectorcraft", "application/x-vectorcraft"),
|
||||
("drawcraft", "application/x-vectorcraft"),
|
||||
("svg", "image/svg+xml"),
|
||||
("svgz", "image/svg+xml-compressed"),
|
||||
("pdf", "application/pdf"),
|
||||
("ai", "application/illustrator"), // brand-ok: shared-mime-info's type
|
||||
("ait", "application/x-ai-template"),
|
||||
("png", "image/png"),
|
||||
("jpg", "image/jpeg"),
|
||||
("jpeg", "image/jpeg"),
|
||||
("gif", "image/gif"),
|
||||
("webp", "image/webp"),
|
||||
("tif", "image/tiff"),
|
||||
("tiff", "image/tiff"),
|
||||
("bmp", "image/bmp"),
|
||||
("vctemplate", "application/x-vectorcraft-template"),
|
||||
("dxf", "image/vnd.dxf"),
|
||||
("emf", "image/emf"),
|
||||
("wmf", "image/wmf"),
|
||||
("eps", "image/x-eps"),
|
||||
("af", "application/x-affinity"),
|
||||
("afdesign", "application/x-affinity"),
|
||||
("afpub", "application/x-affinity"),
|
||||
];
|
||||
|
||||
/// The types in `LINUX_TYPES` that shared-mime-info defines. The MIME package declares every
|
||||
/// other one.
|
||||
const LINUX_SHARED_TYPES: &[&str] = &[
|
||||
"image/svg+xml",
|
||||
"image/svg+xml-compressed",
|
||||
"application/pdf",
|
||||
"application/illustrator", // brand-ok: shared-mime-info's type
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/tiff",
|
||||
"image/bmp",
|
||||
"image/vnd.dxf",
|
||||
"image/emf",
|
||||
"image/wmf",
|
||||
"image/x-eps",
|
||||
];
|
||||
|
||||
/// Extensions in `LINUX_TYPES` whose type the MIME package matches by file content and not by a
|
||||
/// glob, with the package's `<magic>` rule for that type (its lines joined, indentation removed).
|
||||
const LINUX_BY_CONTENT: &[(&str, &str)] = &[(
|
||||
"af",
|
||||
// `MAGIC` at offset 0 and `DOCUMENT_KIND` at offset 8 (crates/affinity/src/container.rs).
|
||||
r#"<magic><match type="string" offset="0" value="\x00\xff\x4b\x41"><match type="string" offset="8" value="nsrP"/></match></magic>"#,
|
||||
)];
|
||||
|
||||
/// The desktop entry lists a MIME type for every extension in `OPEN_EXTS` but
|
||||
/// `UNASSOCIATED_EXTS`, and the app's MIME package declares the types shared-mime-info doesn't
|
||||
/// have.
|
||||
#[test]
|
||||
fn the_linux_desktop_entry_opens_every_readable_format() {
|
||||
let desktop = include_str!("../../../packaging/linux/ai.storyteller.vectorcraft.desktop");
|
||||
let mime = include_str!("../../../packaging/linux/ai.storyteller.vectorcraft.mime.xml");
|
||||
let listed: Vec<&str> =
|
||||
desktop.lines().find_map(|l| l.strip_prefix("MimeType=")).unwrap_or_default().split(';').filter(|t| !t.is_empty()).collect();
|
||||
// Each type the MIME package declares, with the extensions of its globs and its `<magic>`
|
||||
// rule (empty when it has none) in the form `LINUX_BY_CONTENT` gives.
|
||||
let declared: Vec<(&str, Vec<&str>, String)> = mime
|
||||
.split("<mime-type type=\"")
|
||||
.skip(1)
|
||||
.map(|block| {
|
||||
let body = block.split("</mime-type>").next().unwrap_or_default();
|
||||
let globs = body.split("<glob pattern=\"*.").skip(1).filter_map(|g| g.split('"').next()).collect();
|
||||
let magic = match (body.find("<magic"), body.find("</magic>")) {
|
||||
(Some(start), Some(end)) => body[start..end + "</magic>".len()].lines().map(str::trim).collect(),
|
||||
_ => String::new(),
|
||||
};
|
||||
(block.split('"').next().unwrap_or_default(), globs, magic)
|
||||
})
|
||||
.collect();
|
||||
let mut associated: Vec<&str> = fileio::OPEN_EXTS.iter().copied().filter(|e| !fileio::UNASSOCIATED_EXTS.contains(e)).collect();
|
||||
let mut typed: Vec<&str> = LINUX_TYPES.iter().map(|(e, _)| *e).collect();
|
||||
associated.sort_unstable();
|
||||
typed.sort_unstable();
|
||||
typed.dedup();
|
||||
assert_eq!(typed, associated, "a MIME type for each extension the packaging associates with the app");
|
||||
for (e, t) in LINUX_TYPES {
|
||||
assert!(listed.contains(t), "the desktop entry doesn't list {t} (.{e})");
|
||||
let package = declared.iter().find(|(name, ..)| name == t);
|
||||
if LINUX_SHARED_TYPES.contains(t) {
|
||||
assert!(package.is_none(), "{t} is shared-mime-info's, and the MIME package declares it again");
|
||||
continue;
|
||||
}
|
||||
let Some((_, globs, magic)) = package else { panic!("the MIME package doesn't declare {t} (.{e})") };
|
||||
if let Some((_, rule)) = LINUX_BY_CONTENT.iter().find(|(content, _)| content == e) {
|
||||
assert!(!globs.contains(e), "{t} matches .{e} files by content only");
|
||||
assert_eq!(magic, rule, "{t}'s magic rule for .{e} files");
|
||||
} else {
|
||||
assert!(globs.contains(e), "{t} has no *.{e} glob");
|
||||
}
|
||||
}
|
||||
for t in &listed {
|
||||
assert!(LINUX_TYPES.iter().any(|(_, known)| known == t), "the desktop entry lists {t}, which the app doesn't open");
|
||||
}
|
||||
for (t, globs, _) in &declared {
|
||||
assert!(listed.contains(t), "the desktop entry doesn't list the declared {t}");
|
||||
for g in globs {
|
||||
assert!(LINUX_TYPES.contains(&(*g, *t)), "{t}'s *.{g} isn't a file the app opens as {t}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
//! Linux and the BSDs: the save dialog of a save with two or more file types (Save, Save As, Save a
|
||||
//! Copy, Save as Template; #2679), asked of the XDG desktop portal here
|
||||
//! (<https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.FileChooser.html>).
|
||||
//! rfd 0.17 sends no `current_filter` with `SaveFile` and reads only `uris` from the answer
|
||||
//! (PolyMeilex/rfd#341), and the GTK file chooser behind the portal keeps the typed name when the
|
||||
//! user picks another file type, so the type picked was lost. This call sends the first file type as
|
||||
//! `current_filter`, reads the one picked back, and answers its first extension after the path
|
||||
//! ([`vectorcraft_ui_egui::picks::PickAnswer`]); the app gives the name that type's extension.
|
||||
//! rfd shows the dialog when the portal can't (no session bus, no file chooser), with zenity as its
|
||||
//! own fallback. PhotoCraft's `portal_save.rs` (storytold/photocraft#6151) makes the same call.
|
||||
//!
|
||||
//! The dialog's parent is the window: `x11:<window id>` on X11, and on Wayland (Linux) a handle
|
||||
//! from the compositor's xdg-foreign protocol, as rfd names it (its `window_identifier`).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use vectorcraft_ui_egui::FilePick;
|
||||
use zbus::zvariant::{OwnedValue, Value};
|
||||
|
||||
/// A file type in the dialog: its label and its extensions without the dot.
|
||||
type Filter = (String, Vec<String>);
|
||||
|
||||
/// A save dialog to show.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
struct SaveRequest {
|
||||
/// The portal's identifier of the parent window, or empty.
|
||||
parent: String,
|
||||
/// The suggested name; empty for none.
|
||||
name: String,
|
||||
folder: Option<String>,
|
||||
/// The file types, labeled in the interface's language; the dialog starts on the first.
|
||||
filters: Vec<Filter>,
|
||||
}
|
||||
|
||||
impl SaveRequest {
|
||||
/// The dialog for `pick` over the window `parent` names.
|
||||
fn new(pick: &FilePick, parent: String) -> Self {
|
||||
let lang = vectorcraft_ui_egui::i18n::current();
|
||||
let filters = pick
|
||||
.filters
|
||||
.iter()
|
||||
.map(|(label, exts)| {
|
||||
(vectorcraft_ui_egui::i18n::tr_ctx(lang, "file-filter", label).to_string(), exts.iter().map(|e| e.to_string()).collect())
|
||||
})
|
||||
.collect();
|
||||
Self { parent, name: pick.name.clone(), folder: pick.folder.clone(), filters }
|
||||
}
|
||||
}
|
||||
|
||||
/// What the dialog answered: the path, as typed, and the label of the file type picked.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct Saved {
|
||||
path: String,
|
||||
filter: Option<String>,
|
||||
}
|
||||
|
||||
/// `filter` as the portal takes it: its label and a `*.ext` pattern for each extension (`*` for
|
||||
/// every file), as rfd sends them.
|
||||
fn wire(filter: &Filter) -> (String, Vec<(u32, String)>) {
|
||||
let globs = filter.1.iter().map(|e| (0, if e.is_empty() || e == "*" { "*".to_string() } else { format!("*.{e}") })).collect();
|
||||
(filter.0.clone(), globs)
|
||||
}
|
||||
|
||||
/// The `SaveFile` options for `request`, answering on `token`.
|
||||
fn options<'a>(request: &'a SaveRequest, token: &'a str) -> HashMap<&'static str, Value<'a>> {
|
||||
let mut o = HashMap::from([("handle_token", Value::from(token))]);
|
||||
if !request.name.is_empty() {
|
||||
o.insert("current_name", Value::from(request.name.as_str()));
|
||||
}
|
||||
if let Some(folder) = &request.folder {
|
||||
// A byte string ending in NUL, as the portal specifies.
|
||||
let mut bytes = folder.as_bytes().to_vec();
|
||||
bytes.push(0);
|
||||
o.insert("current_folder", Value::from(bytes));
|
||||
}
|
||||
if let Some(first) = request.filters.first() {
|
||||
o.insert("filters", Value::from(request.filters.iter().map(wire).collect::<Vec<_>>()));
|
||||
o.insert("current_filter", Value::from(wire(first)));
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
/// The portal's `Response`: code 0 with the first of its `uris` and the label of its
|
||||
/// `current_filter`. Any other code ends the dialog without a file (1 is Cancel, 2 a dialog closed
|
||||
/// some other way), as rfd reads it.
|
||||
fn answer(code: u32, results: &HashMap<String, OwnedValue>) -> Result<Option<Saved>, String> {
|
||||
if code != 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let uris: Vec<String> = results
|
||||
.get("uris")
|
||||
.ok_or("no file in the portal's answer")?
|
||||
.try_clone()
|
||||
.map_err(|e| e.to_string())?
|
||||
.try_into()
|
||||
.map_err(|e: zbus::zvariant::Error| e.to_string())?;
|
||||
let uri = uris.first().ok_or("no file in the portal's answer")?;
|
||||
let path = file_uri_path(uri).ok_or_else(|| format!("not a local file: {uri}"))?;
|
||||
let filter = results.get("current_filter").and_then(|v| {
|
||||
let (label, _globs): (String, Vec<(u32, String)>) = v.try_clone().ok()?.try_into().ok()?;
|
||||
Some(label)
|
||||
});
|
||||
Ok(Some(Saved { path, filter }))
|
||||
}
|
||||
|
||||
/// `file:///home/me/A%20b.svg` → `/home/me/A b.svg`; `None` for a file elsewhere (`file://host/`)
|
||||
/// or another scheme.
|
||||
fn file_uri_path(uri: &str) -> Option<String> {
|
||||
let rest = uri.strip_prefix("file://")?;
|
||||
let rest = rest.strip_prefix("localhost").unwrap_or(rest);
|
||||
if !rest.starts_with('/') {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::with_capacity(rest.len());
|
||||
let mut rest = rest.as_bytes();
|
||||
while let Some((&b, tail)) = rest.split_first() {
|
||||
let hex = tail.get(..2).filter(|h| b == b'%' && h.iter().all(u8::is_ascii_hexdigit));
|
||||
match hex.and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok()) {
|
||||
Some(decoded) => {
|
||||
out.push(decoded);
|
||||
rest = tail.get(2..).unwrap_or_default();
|
||||
}
|
||||
None => {
|
||||
out.push(b);
|
||||
rest = tail;
|
||||
}
|
||||
}
|
||||
}
|
||||
// `show_dialog` makes rfd's path a string with `to_string_lossy` too.
|
||||
Some(String::from_utf8_lossy(&out).into_owned())
|
||||
}
|
||||
|
||||
/// `saved` as the answer of the save dialog for `request`: the path, then the first extension of
|
||||
/// the file type picked, found by its label. Empty when the dialog was canceled.
|
||||
fn pick_answer(saved: Option<Saved>, request: &SaveRequest) -> Vec<String> {
|
||||
let Some(saved) = saved else { return Vec::new() };
|
||||
let picked = saved.filter.and_then(|label| request.filters.iter().find(|(l, _)| *l == label)).and_then(|(_, exts)| exts.first().cloned());
|
||||
std::iter::once(saved.path).chain(picked).collect()
|
||||
}
|
||||
|
||||
/// The portal's identifier of an X11 window.
|
||||
fn x11_parent(window: impl std::fmt::LowerHex) -> String {
|
||||
format!("x11:{window:x}")
|
||||
}
|
||||
|
||||
/// Show the portal's save dialog for `pick` over `window` and wait for it → the answer (see the
|
||||
/// module's comment). `None` when `pick` offers fewer than two file types, or the portal couldn't
|
||||
/// show the dialog; rfd shows it then.
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
|
||||
pub fn pick(pick: &FilePick, window: Option<&winit::window::Window>) -> Option<Vec<String>> {
|
||||
if pick.filters.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
// The export stays until the dialog has answered: the handle is valid while it lives.
|
||||
let (parent, _export) = window.map(parent).unwrap_or_default();
|
||||
let request = SaveRequest::new(pick, parent);
|
||||
let saved = zbus::blocking::Connection::session().map_err(|e| e.to_string()).and_then(|conn| save_file(&conn, &request));
|
||||
match saved {
|
||||
Ok(saved) => Some(pick_answer(saved, &request)),
|
||||
Err(e) => {
|
||||
log::info!("the portal's save dialog failed ({e}): rfd shows it");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask the portal on `conn` for the save dialog of `request` and wait for its answer.
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
|
||||
fn save_file(conn: &zbus::blocking::Connection, request: &SaveRequest) -> Result<Option<Saved>, String> {
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
use zbus::blocking::Proxy;
|
||||
use zbus::zvariant::OwnedObjectPath;
|
||||
|
||||
const DESKTOP: &str = "org.freedesktop.portal.Desktop";
|
||||
const REQUEST: &str = "org.freedesktop.portal.Request";
|
||||
static NEXT: AtomicU32 = AtomicU32::new(0);
|
||||
let sender = conn.unique_name().ok_or("no name on the session bus")?.as_str().trim_start_matches(':').replace('.', "_");
|
||||
let token = format!("vectorcraft_save_{}_{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed));
|
||||
let path = format!("/org/freedesktop/portal/desktop/request/{sender}/{token}");
|
||||
// Listen before asking, so that an answer that comes at once is not missed.
|
||||
let mut replies = Proxy::new(conn, DESKTOP, path.as_str(), REQUEST).and_then(|p| p.receive_signal("Response")).map_err(|e| e.to_string())?;
|
||||
let chooser = Proxy::new(conn, DESKTOP, "/org/freedesktop/portal/desktop", "org.freedesktop.portal.FileChooser").map_err(|e| e.to_string())?;
|
||||
// The title is empty, as rfd sends it for a save.
|
||||
let handle: OwnedObjectPath = chooser.call("SaveFile", &(request.parent.as_str(), "", options(request, &token))).map_err(|e| e.to_string())?;
|
||||
// A portal older than version 0.9 ignores `handle_token` and answers on the request path it
|
||||
// returns.
|
||||
let reply = if handle.as_str() == path {
|
||||
replies.next()
|
||||
} else {
|
||||
Proxy::new(conn, DESKTOP, handle.as_str(), REQUEST).and_then(|p| p.receive_signal("Response")).map_err(|e| e.to_string())?.next()
|
||||
}
|
||||
.ok_or("the save dialog closed without an answer")?;
|
||||
let (code, results): (u32, HashMap<String, OwnedValue>) = reply.body().deserialize().map_err(|e| e.to_string())?;
|
||||
answer(code, &results)
|
||||
}
|
||||
|
||||
/// The portal's identifier of `window` when it is an X11 window.
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
|
||||
fn x11_window(window: &winit::window::Window) -> Option<String> {
|
||||
use winit::raw_window_handle::{HasWindowHandle as _, RawWindowHandle};
|
||||
match window.window_handle().ok()?.as_raw() {
|
||||
RawWindowHandle::Xlib(h) => Some(x11_parent(h.window)),
|
||||
RawWindowHandle::Xcb(h) => Some(x11_parent(h.window.get())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The portal's identifier of `window`, and on Wayland the export that keeps it valid; empty when
|
||||
/// the window has neither an X11 id nor a Wayland handle.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn parent(window: &winit::window::Window) -> (String, Option<wayland::Export>) {
|
||||
if let Some(id) = x11_window(window) {
|
||||
return (id, None);
|
||||
}
|
||||
match wayland::export(window) {
|
||||
Some(export) => (format!("wayland:{}", export.handle), Some(export)),
|
||||
None => (String::new(), None),
|
||||
}
|
||||
}
|
||||
|
||||
/// The portal's identifier of `window`: its X11 id, else empty (no Wayland export is made off
|
||||
/// Linux).
|
||||
#[cfg(any(target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
|
||||
fn parent(window: &winit::window::Window) -> (String, Option<()>) {
|
||||
(x11_window(window).unwrap_or_default(), None)
|
||||
}
|
||||
|
||||
/// The Wayland window's handle for the portal: an xdg-foreign v2 export of its surface
|
||||
/// (<https://wayland.app/protocols/xdg-foreign-unstable-v2>), made as rfd's `window_identifier`
|
||||
/// makes it.
|
||||
#[cfg(target_os = "linux")]
|
||||
mod wayland {
|
||||
use wayland_client::globals::{GlobalListContents, registry_queue_init};
|
||||
use wayland_client::protocol::wl_registry::{self, WlRegistry};
|
||||
use wayland_client::protocol::wl_surface::WlSurface;
|
||||
use wayland_client::{Connection, Dispatch, Proxy, QueueHandle};
|
||||
use wayland_protocols::xdg::foreign::zv2::client::zxdg_exported_v2::{self, ZxdgExportedV2};
|
||||
use wayland_protocols::xdg::foreign::zv2::client::zxdg_exporter_v2::{self, ZxdgExporterV2};
|
||||
|
||||
/// A window exported for the portal: `handle` names it until this is dropped.
|
||||
pub struct Export {
|
||||
pub handle: String,
|
||||
exported: ZxdgExportedV2,
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl Drop for Export {
|
||||
fn drop(&mut self) {
|
||||
self.exported.destroy();
|
||||
let _ = self.conn.flush();
|
||||
}
|
||||
}
|
||||
|
||||
/// The events the export waits for.
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
handle: Option<String>,
|
||||
}
|
||||
|
||||
impl Dispatch<WlRegistry, GlobalListContents> for State {
|
||||
fn event(_: &mut Self, _: &WlRegistry, _: wl_registry::Event, _: &GlobalListContents, _: &Connection, _: &QueueHandle<Self>) {}
|
||||
}
|
||||
|
||||
impl Dispatch<ZxdgExporterV2, ()> for State {
|
||||
fn event(_: &mut Self, _: &ZxdgExporterV2, _: zxdg_exporter_v2::Event, _: &(), _: &Connection, _: &QueueHandle<Self>) {}
|
||||
}
|
||||
|
||||
impl Dispatch<ZxdgExportedV2, ()> for State {
|
||||
fn event(state: &mut Self, _: &ZxdgExportedV2, event: zxdg_exported_v2::Event, _: &(), _: &Connection, _: &QueueHandle<Self>) {
|
||||
if let zxdg_exported_v2::Event::Handle { handle } = event {
|
||||
state.handle = Some(handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Export `window`'s surface on the window's own Wayland connection, with an event queue of its
|
||||
/// own → `None` when the compositor has no xdg-foreign v2 or answers no handle.
|
||||
pub fn export(window: &winit::window::Window) -> Option<Export> {
|
||||
let (conn, surface) = surface(window)?;
|
||||
let (globals, mut queue) = registry_queue_init::<State>(&conn).ok()?;
|
||||
let qh = queue.handle();
|
||||
let exporter: ZxdgExporterV2 = globals.bind(&qh, 1..=1, ()).ok()?;
|
||||
let exported = exporter.export_toplevel(&surface, &qh, ());
|
||||
let mut state = State::default();
|
||||
let answered = queue.roundtrip(&mut state);
|
||||
exporter.destroy();
|
||||
match (answered, state.handle) {
|
||||
(Ok(_), Some(handle)) => Some(Export { handle, exported, conn }),
|
||||
_ => {
|
||||
exported.destroy();
|
||||
let _ = conn.flush();
|
||||
log::info!("the compositor gave no xdg-foreign handle: the save dialog has no parent window");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `window`'s Wayland connection and surface, from its raw handles.
|
||||
#[allow(unsafe_code)]
|
||||
fn surface(window: &winit::window::Window) -> Option<(Connection, WlSurface)> {
|
||||
use wayland_backend::sys::client::{Backend, ObjectId};
|
||||
use winit::raw_window_handle::{HasDisplayHandle as _, HasWindowHandle as _, RawDisplayHandle, RawWindowHandle};
|
||||
let (RawWindowHandle::Wayland(w), RawDisplayHandle::Wayland(d)) =
|
||||
(window.window_handle().ok()?.as_raw(), window.display_handle().ok()?.as_raw())
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
// SAFETY: the display is winit's open `wl_display`, which outlives `window` (the caller
|
||||
// holds it until the export is dropped); a backend made from a foreign display does not
|
||||
// close it when dropped.
|
||||
let backend = unsafe { Backend::from_foreign_display(d.display.as_ptr().cast()) };
|
||||
let conn = Connection::from_backend(backend);
|
||||
// SAFETY: the pointer is the window's live `wl_surface` proxy on that display.
|
||||
let id = unsafe { ObjectId::from_ptr(WlSurface::interface(), w.surface.as_ptr().cast()) }.ok()?;
|
||||
let surface = WlSurface::from_id(&conn, id).ok()?;
|
||||
Some((conn, surface))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn request() -> SaveRequest {
|
||||
let pick = FilePick {
|
||||
name: "Untitled-1.vectorcraft".into(),
|
||||
folder: Some("/home/me/Art".into()),
|
||||
filters: vec![("VectorCraft", &["vectorcraft"]), ("SVG", &["svg"]), ("PDF-compatible .ai", &["ai"])],
|
||||
};
|
||||
SaveRequest::new(&pick, "x11:3a0000f".into())
|
||||
}
|
||||
|
||||
/// The dialog starts on the first file type (the document's format) and shows the suggested
|
||||
/// name in its folder.
|
||||
#[test]
|
||||
fn the_request_names_the_file_type_the_dialog_starts_on() {
|
||||
let request = request();
|
||||
let o = options(&request, "t1");
|
||||
assert_eq!(o.get("current_filter"), Some(&Value::from(("VectorCraft".to_string(), vec![(0u32, "*.vectorcraft".to_string())]))));
|
||||
let filters = vec![
|
||||
("VectorCraft".to_string(), vec![(0u32, "*.vectorcraft".to_string())]),
|
||||
("SVG".to_string(), vec![(0u32, "*.svg".to_string())]),
|
||||
("PDF-compatible .ai".to_string(), vec![(0u32, "*.ai".to_string())]),
|
||||
];
|
||||
assert_eq!(o.get("filters"), Some(&Value::from(filters)));
|
||||
assert_eq!(o.get("current_name"), Some(&Value::from("Untitled-1.vectorcraft")));
|
||||
assert_eq!(o.get("current_folder"), Some(&Value::from(b"/home/me/Art\0".to_vec())));
|
||||
assert_eq!(o.get("handle_token"), Some(&Value::from("t1")));
|
||||
let bare = SaveRequest { name: String::new(), folder: None, filters: Vec::new(), ..request };
|
||||
let o = options(&bare, "t2");
|
||||
assert_eq!(o.keys().copied().collect::<Vec<_>>(), ["handle_token"]);
|
||||
}
|
||||
|
||||
/// The portal's answer as a D-Bus dictionary: `uris` and `current_filter`.
|
||||
fn results(uri: &str, filter: Option<&str>) -> HashMap<String, OwnedValue> {
|
||||
let mut r = HashMap::new();
|
||||
r.insert("uris".to_string(), OwnedValue::try_from(Value::from(vec![uri.to_string()])).unwrap());
|
||||
if let Some(label) = filter {
|
||||
let picked = Value::from((label.to_string(), vec![(0u32, "*.ai".to_string())]));
|
||||
r.insert("current_filter".to_string(), OwnedValue::try_from(picked).unwrap());
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// The file type picked comes back with the path as typed, and its first extension follows
|
||||
/// the path in the answer.
|
||||
#[test]
|
||||
fn the_answer_carries_the_file_type_picked() {
|
||||
let request = request();
|
||||
let saved = answer(0, &results("file:///home/me/Art/poster", Some("PDF-compatible .ai"))).unwrap();
|
||||
assert_eq!(saved, Some(Saved { path: "/home/me/Art/poster".into(), filter: Some("PDF-compatible .ai".into()) }));
|
||||
assert_eq!(pick_answer(saved, &request), ["/home/me/Art/poster", "ai"]);
|
||||
// A portal that names no file type, or one the dialog did not offer: the path alone.
|
||||
let saved = answer(0, &results("file:///home/me/Art/poster.svg", None)).unwrap();
|
||||
assert_eq!(pick_answer(saved, &request), ["/home/me/Art/poster.svg"]);
|
||||
let saved = answer(0, &results("file:///home/me/Art/poster", Some("PNG"))).unwrap();
|
||||
assert_eq!(pick_answer(saved, &request), ["/home/me/Art/poster"]);
|
||||
}
|
||||
|
||||
/// Cancel (1) and a dialog closed another way (2) answer no file; an answer without a local file
|
||||
/// is an error, and rfd shows the dialog instead.
|
||||
#[test]
|
||||
fn a_canceled_dialog_answers_no_file() {
|
||||
let request = request();
|
||||
for code in [1, 2] {
|
||||
assert_eq!(answer(code, &HashMap::new()), Ok(None));
|
||||
assert!(pick_answer(None, &request).is_empty());
|
||||
}
|
||||
assert!(answer(0, &HashMap::new()).is_err());
|
||||
assert!(answer(0, &results("sftp://host/poster.svg", Some("SVG"))).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_uris_become_local_paths() {
|
||||
assert_eq!(file_uri_path("file:///home/me/Untitled-1.vectorcraft").as_deref(), Some("/home/me/Untitled-1.vectorcraft"));
|
||||
assert_eq!(file_uri_path("file:///home/me/A%20b%C3%A9.svg").as_deref(), Some("/home/me/A bé.svg"));
|
||||
assert_eq!(file_uri_path("file://localhost/tmp/x.pdf").as_deref(), Some("/tmp/x.pdf"));
|
||||
// A `%` without two hex digits after it stays as it is.
|
||||
assert_eq!(file_uri_path("file:///tmp/100%+1%2").as_deref(), Some("/tmp/100%+1%2"));
|
||||
assert_eq!(file_uri_path("file://host/x.svg"), None);
|
||||
assert_eq!(file_uri_path("sftp://host/x.svg"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn x11_windows_are_named_by_their_id() {
|
||||
assert_eq!(x11_parent(0x3a0000f), "x11:3a0000f");
|
||||
}
|
||||
|
||||
/// What the mock portal answers to each `SaveFile` call: the response code, and the file and
|
||||
/// the label of the file type "picked".
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
|
||||
type Script = Vec<(u32, Option<(&'static str, &'static str)>)>;
|
||||
|
||||
/// What each `SaveFile` call carried: `current_name` and the label of `current_filter`.
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
|
||||
type Seen = std::sync::Arc<std::sync::Mutex<Vec<(Option<String>, Option<String>)>>>;
|
||||
|
||||
/// A session bus of its own (`dbus-daemon`) with a mock `org.freedesktop.portal.FileChooser`
|
||||
/// that answers `SaveFile` from `script`, as PhotoCraft's portal tests run it. With `own_paths`
|
||||
/// it is a portal older than 0.9, which answers on a request path of its own. `None` when there
|
||||
/// is no `dbus-daemon` to run.
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
|
||||
fn mock_portal(script: Script, own_paths: bool) -> Option<(zbus::blocking::Connection, Seen, std::process::Child)> {
|
||||
use std::io::BufRead as _;
|
||||
|
||||
use zbus::zvariant::OwnedObjectPath;
|
||||
let mut daemon = std::process::Command::new("dbus-daemon")
|
||||
.args(["--session", "--nofork", "--print-address=1"])
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.ok()?;
|
||||
let mut address = String::new();
|
||||
std::io::BufReader::new(daemon.stdout.take()?).read_line(&mut address).ok()?;
|
||||
let address = address.trim().to_string();
|
||||
let server =
|
||||
zbus::blocking::connection::Builder::address(address.as_str()).ok()?.name("org.freedesktop.portal.Desktop").ok()?.build().ok()?;
|
||||
let seen = Seen::default();
|
||||
let log = seen.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut script = script.into_iter();
|
||||
for msg in zbus::blocking::MessageIterator::from(&server) {
|
||||
let Ok(msg) = msg else { break };
|
||||
let header = msg.header();
|
||||
if header.message_type() != zbus::message::Type::MethodCall || header.member().map(|m| m.as_str()) != Some("SaveFile") {
|
||||
continue;
|
||||
}
|
||||
let Ok((_parent, _title, options)) = msg.body().deserialize::<(String, String, HashMap<String, OwnedValue>)>() else { break };
|
||||
let text = |key: &str| options.get(key).and_then(|v| String::try_from(v.try_clone().ok()?).ok());
|
||||
let filter = options.get("current_filter").and_then(|v| {
|
||||
let (label, _): (String, Vec<(u32, String)>) = v.try_clone().ok()?.try_into().ok()?;
|
||||
Some(label)
|
||||
});
|
||||
let calls = log.lock().map(|mut l| {
|
||||
l.push((text("current_name"), filter));
|
||||
l.len()
|
||||
});
|
||||
let Some(sender) = header.sender().map(|s| s.as_str().trim_start_matches(':').replace('.', "_")) else { break };
|
||||
let token = if own_paths { format!("old{}", calls.unwrap_or_default()) } else { text("handle_token").unwrap_or_default() };
|
||||
let path = format!("/org/freedesktop/portal/desktop/request/{sender}/{token}");
|
||||
let Ok(handle) = OwnedObjectPath::try_from(path.as_str()) else { break };
|
||||
if server.reply(&header, &handle).is_err() {
|
||||
break;
|
||||
}
|
||||
if own_paths {
|
||||
// As a person answers the dialog: after the caller listened on the path returned.
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
}
|
||||
let (code, pick) = script.next().unwrap_or((2, None));
|
||||
let mut results: HashMap<&str, Value<'_>> = HashMap::new();
|
||||
if let Some((uri, label)) = pick {
|
||||
results.insert("uris", Value::from(vec![uri.to_string()]));
|
||||
results.insert("current_filter", Value::from((label.to_string(), vec![(0u32, "*.x".to_string())])));
|
||||
}
|
||||
let to = header.sender().cloned().map(zbus::names::BusName::from);
|
||||
if server.emit_signal(to, path.as_str(), "org.freedesktop.portal.Request", "Response", &(code, results)).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
let client = zbus::blocking::connection::Builder::address(address.as_str()).ok()?.build().ok()?;
|
||||
Some((client, seen, daemon))
|
||||
}
|
||||
|
||||
/// Through a real D-Bus: the call starts the dialog on the first file type with the suggested
|
||||
/// name, and the answer names the file and the type picked; Cancel answers no file.
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
|
||||
#[test]
|
||||
fn the_portal_is_asked_on_the_first_file_type_and_answers_the_one_picked() {
|
||||
let script = vec![(0, Some(("file:///home/me/Art/poster", "PDF-compatible .ai"))), (1, None)];
|
||||
let Some((conn, seen, mut daemon)) = mock_portal(script, false) else {
|
||||
eprintln!("no dbus-daemon: skipped");
|
||||
return;
|
||||
};
|
||||
let request = request();
|
||||
let saved = save_file(&conn, &request).unwrap();
|
||||
assert_eq!(pick_answer(saved, &request), ["/home/me/Art/poster", "ai"]);
|
||||
assert_eq!(save_file(&conn, &request), Ok(None));
|
||||
let seen = seen.lock().map(|s| s.clone()).unwrap_or_default();
|
||||
assert_eq!(seen.first(), Some(&(Some("Untitled-1.vectorcraft".to_string()), Some(request.filters[0].0.clone()))));
|
||||
let _ = daemon.kill();
|
||||
}
|
||||
|
||||
/// A portal older than 0.9 answers on the request path it returns, and the answer is awaited
|
||||
/// there.
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
|
||||
#[test]
|
||||
fn the_answer_is_awaited_on_the_path_the_portal_returns() {
|
||||
let Some((conn, _seen, mut daemon)) = mock_portal(vec![(0, Some(("file:///tmp/card", "SVG")))], true) else {
|
||||
eprintln!("no dbus-daemon: skipped");
|
||||
return;
|
||||
};
|
||||
let request = request();
|
||||
assert_eq!(pick_answer(save_file(&conn, &request).unwrap(), &request), ["/tmp/card", "svg"]);
|
||||
let _ = daemon.kill();
|
||||
}
|
||||
|
||||
/// The labels go to the dialog in the interface's language, which is how the answer is matched.
|
||||
#[test]
|
||||
fn the_file_types_are_labeled_as_the_other_dialogs_label_them() {
|
||||
let request = request();
|
||||
let labels: Vec<&str> = request.filters.iter().map(|(l, _)| l.as_str()).collect();
|
||||
let lang = vectorcraft_ui_egui::i18n::current();
|
||||
let expected: Vec<&str> =
|
||||
["VectorCraft", "SVG", "PDF-compatible .ai"].iter().map(|l| vectorcraft_ui_egui::i18n::tr_ctx(lang, "file-filter", l)).collect();
|
||||
assert_eq!(labels, expected);
|
||||
assert_eq!(request.parent, "x11:3a0000f");
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
//! Linux: Preferences › Performance › Linux Display Server › X11 opens the window through XWayland
|
||||
//! on a Wayland session, where dropped files arrive (winit 0.30 has no Wayland drag and drop,
|
||||
//! #1151). Only when XWayland can run the window: an X display is set and the X11 libraries winit
|
||||
//! loads at runtime are installed. Otherwise the window opens on Wayland, so a missing library
|
||||
//! never keeps the app from starting.
|
||||
//! #1151). X11 (XWayland) When a Pen Is Attached does so only when a pen is attached as the app
|
||||
//! starts: winit 0.30 doesn't bind the Wayland tablet protocol, and XWayland hands the pen to X11
|
||||
//! windows as a pointer (#2028). Only when XWayland can run the window: an X display is set and
|
||||
//! the X11 libraries winit loads at runtime are installed. Otherwise the window opens on Wayland,
|
||||
//! so a missing library never keeps the app from starting.
|
||||
//!
|
||||
//! The library check comes from PhotoCraft's `linux_libs.rs`: no `dlopen` (that needs `unsafe`),
|
||||
//! just the dynamic linker's cache (`ldconfig -p`) and the usual library directories.
|
||||
//! just the dynamic linker's cache (`ldconfig -p`) and the usual library directories. The pen
|
||||
//! check comes from PhotoCraft's `tablet.rs`: the input devices the kernel lists in
|
||||
//! `/proc/bus/input/devices`.
|
||||
#![cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
|
||||
use std::ffi::OsString;
|
||||
@@ -16,12 +20,50 @@ use std::path::{Path, PathBuf};
|
||||
const X11_LIBS: &[&str] =
|
||||
&["libX11.so.6", "libX11-xcb.so.1", "libxcb.so.1", "libXcursor.so.1", "libXi.so.6", "libxkbcommon.so.0", "libxkbcommon-x11.so.0"];
|
||||
|
||||
/// Whether the saved `linuxDisplayServer` and the environment ask for XWayland: `x11` chosen, a
|
||||
/// Wayland session (winit picks Wayland whenever `WAYLAND_DISPLAY` or `WAYLAND_SOCKET` is set)
|
||||
/// and an X display to open.
|
||||
pub fn wanted(pref: Option<&str>, var: impl Fn(&str) -> Option<OsString>) -> bool {
|
||||
/// Whether the saved `linuxDisplayServer` and the environment ask for XWayland: `x11` chosen, or
|
||||
/// `x11WithPen` chosen and `pen` finding a pen; a Wayland session (winit picks Wayland whenever
|
||||
/// `WAYLAND_DISPLAY` or `WAYLAND_SOCKET` is set); and an X display to open. `pen` runs only for
|
||||
/// `x11WithPen`, after the session checks.
|
||||
pub fn wanted(pref: Option<&str>, var: impl Fn(&str) -> Option<OsString>, pen: impl FnOnce() -> bool) -> bool {
|
||||
let set = |name: &str| var(name).is_some_and(|v| !v.is_empty());
|
||||
pref == Some("x11") && (set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET")) && set("DISPLAY")
|
||||
let session = (set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET")) && set("DISPLAY");
|
||||
match pref {
|
||||
Some("x11") => session,
|
||||
Some("x11WithPen") => session && pen(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `BTN_TOOL_PEN` (linux/input-event-codes.h): the key that a pen tablet's stylus, a pen display's
|
||||
/// pen or a pen-enabled touchscreen's pen reports.
|
||||
const BTN_TOOL_PEN: u32 = 0x140;
|
||||
/// `ABS_X` and `ABS_Y` (linux/input-event-codes.h): the absolute axes every pen reports.
|
||||
const ABS_X: u32 = 0x00;
|
||||
const ABS_Y: u32 = 0x01;
|
||||
|
||||
/// Is a pen attached? Reads `/proc/bus/input/devices`; a file that can't be read has no pen.
|
||||
pub fn pen_attached() -> bool {
|
||||
std::fs::read_to_string("/proc/bus/input/devices").is_ok_and(|devices| has_pen(&devices))
|
||||
}
|
||||
|
||||
/// Is a pen among the input devices `/proc/bus/input/devices` lists? A pen is a device whose
|
||||
/// `B: KEY=` bitmap has `BTN_TOOL_PEN` and whose `B: ABS=` bitmap has `ABS_X` and `ABS_Y`, as
|
||||
/// libinput asks of a tablet tool. A virtual keyboard made with uinput can claim every key code,
|
||||
/// `BTN_TOOL_PEN` included, but has no absolute axes.
|
||||
fn has_pen(devices: &str) -> bool {
|
||||
devices.split("\n\n").any(|device| {
|
||||
let bitmap = |name: &str| device.lines().find_map(|l| l.strip_prefix(name));
|
||||
bitmap("B: KEY=").is_some_and(|keys| bit_set(keys, BTN_TOOL_PEN))
|
||||
&& bitmap("B: ABS=").is_some_and(|axes| bit_set(axes, ABS_X) && bit_set(axes, ABS_Y))
|
||||
})
|
||||
}
|
||||
|
||||
/// Bit `bit` of a `/proc/bus/input/devices` bitmap: hex words of the kernel's `long`, most
|
||||
/// significant first. Short or malformed bitmaps have no bits set.
|
||||
fn bit_set(bitmap: &str, bit: u32) -> bool {
|
||||
let bits = usize::BITS;
|
||||
let word = usize::try_from(bit / bits).unwrap_or(usize::MAX);
|
||||
bitmap.split_whitespace().rev().nth(word).and_then(|w| u64::from_str_radix(w, 16).ok()).is_some_and(|w| (w >> (bit % bits)) & 1 == 1)
|
||||
}
|
||||
|
||||
/// Are all the libraries an X11 window needs found on this system? Libraries the check can't find
|
||||
@@ -98,18 +140,88 @@ mod tests {
|
||||
move |k| pairs.iter().find(|(name, _)| *name == k).map(|(_, v)| OsString::from(v))
|
||||
}
|
||||
|
||||
fn no_probe() -> bool {
|
||||
panic!("the pen check ran")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xwayland_only_when_chosen_on_wayland_with_an_x_display() {
|
||||
let wayland = env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0")]);
|
||||
assert!(wanted(Some("x11"), &wayland));
|
||||
assert!(wanted(Some("x11"), env(&[("WAYLAND_SOCKET", "3"), ("DISPLAY", ":0")])));
|
||||
assert!(!wanted(Some("automatic"), &wayland));
|
||||
assert!(!wanted(None, &wayland));
|
||||
assert!(!wanted(Some("wayland"), &wayland));
|
||||
assert!(wanted(Some("x11"), &wayland, no_probe));
|
||||
assert!(wanted(Some("x11"), env(&[("WAYLAND_SOCKET", "3"), ("DISPLAY", ":0")]), no_probe));
|
||||
assert!(!wanted(Some("automatic"), &wayland, no_probe));
|
||||
assert!(!wanted(None, &wayland, no_probe));
|
||||
assert!(!wanted(Some("wayland"), &wayland, no_probe));
|
||||
// An X11 session already opens on X11; without an X display there is no XWayland to use.
|
||||
assert!(!wanted(Some("x11"), env(&[("DISPLAY", ":0")])));
|
||||
assert!(!wanted(Some("x11"), env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", "")])));
|
||||
assert!(!wanted(Some("x11"), env(&[("WAYLAND_DISPLAY", "wayland-0")])));
|
||||
assert!(!wanted(Some("x11"), env(&[("DISPLAY", ":0")]), no_probe));
|
||||
assert!(!wanted(Some("x11"), env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", "")]), no_probe));
|
||||
assert!(!wanted(Some("x11"), env(&[("WAYLAND_DISPLAY", "wayland-0")]), no_probe));
|
||||
}
|
||||
|
||||
/// X11 (XWayland) When a Pen Is Attached (#2028): XWayland on a Wayland session with an X
|
||||
/// display when a pen is found, the session's own display server otherwise. The pen check
|
||||
/// runs only on such a session.
|
||||
#[test]
|
||||
fn the_pen_choice_opens_xwayland_only_with_a_pen() {
|
||||
let wayland = env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0")]);
|
||||
assert!(wanted(Some("x11WithPen"), &wayland, || true));
|
||||
assert!(wanted(Some("x11WithPen"), env(&[("WAYLAND_SOCKET", "3"), ("DISPLAY", ":0")]), || true));
|
||||
assert!(!wanted(Some("x11WithPen"), &wayland, || false));
|
||||
assert!(!wanted(Some("x11WithPen"), env(&[("DISPLAY", ":0")]), no_probe));
|
||||
assert!(!wanted(Some("x11WithPen"), env(&[("WAYLAND_DISPLAY", "wayland-0")]), no_probe));
|
||||
assert!(!wanted(Some("x11WithPen"), env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", "")]), no_probe));
|
||||
}
|
||||
|
||||
/// `/proc/bus/input/devices` of a laptop with a Wacom Intuos Pro attached (64-bit longs: the
|
||||
/// pen's `KEY=1c03 0 0 0 0 0` has bit 0x140, `BTN_TOOL_PEN`), from PhotoCraft's tests.
|
||||
const WITH_PEN: &str = "I: Bus=0011 Vendor=0001 Product=0001 Version=ab83\n\
|
||||
N: Name=\"AT Translated Set 2 keyboard\"\n\
|
||||
H: Handlers=sysrq kbd event3 leds\n\
|
||||
B: KEY=402000000 3803078f800d001 feffffdfffefffff fffffffffffffffe\n\
|
||||
\n\
|
||||
I: Bus=0018 Vendor=06cb Product=ce26 Version=0100\n\
|
||||
N: Name=\"SYNA2B33:00 06CB:CE26 Touchpad\"\n\
|
||||
H: Handlers=mouse1 event7\n\
|
||||
B: KEY=e520 10000 0 0 0 0\n\
|
||||
B: ABS=2e0800000000003\n\
|
||||
\n\
|
||||
I: Bus=0003 Vendor=056a Product=0357 Version=0110\n\
|
||||
N: Name=\"Wacom Intuos Pro M Pen\"\n\
|
||||
H: Handlers=mouse2 event10\n\
|
||||
B: KEY=1c03 0 0 0 0 0\n\
|
||||
B: ABS=1000f000003\n";
|
||||
|
||||
/// A uinput virtual keyboard that claims every key code, `BTN_TOOL_PEN` included, and has no
|
||||
/// absolute axes (`EV=20013`, no `B: ABS=` line), from PhotoCraft's tests.
|
||||
const VIRTUAL_KEYBOARD: &str = "I: Bus=0003 Vendor=1234 Product=5678 Version=0111\n\
|
||||
N: Name=\"Virtual Keyboard\"\n\
|
||||
H: Handlers=sysrq rfkill kbd event256 leds \n\
|
||||
B: EV=20013\n\
|
||||
B: KEY=ffffffffff 0 7ffffff000301ff 1000f7fffffff 7fe001fffff000f 7ffffffffffffff ffffffff0003fdff 7fff8fff00ff03ff 1ffffffffffff07 ffffffffffffffff ffffffffffefffff fffffffffffffffe\n";
|
||||
|
||||
#[test]
|
||||
fn a_pen_is_a_device_with_btn_tool_pen_and_absolute_axes() {
|
||||
assert!(has_pen(WITH_PEN));
|
||||
let without = WITH_PEN.split("\n\n").take(2).collect::<Vec<_>>().join("\n\n");
|
||||
assert!(!has_pen(&without), "a touchpad's BTN_TOOL_FINGER (0x145) is not a pen");
|
||||
// A pen display reports the pen with its own axes.
|
||||
assert!(has_pen("N: Name=\"Wacom One Pen\"\nB: KEY=1c03 0 0 0 0 0\nB: ABS=3\n"));
|
||||
// Axes without ABS_X or ABS_Y, or no axes at all, are no pen.
|
||||
assert!(!has_pen("B: KEY=1c03 0 0 0 0 0\nB: ABS=1000f000002\n"));
|
||||
assert!(!has_pen("B: KEY=1c03 0 0 0 0 0\nB: ABS=1000f000001\n"));
|
||||
let pen_without_axes = WITH_PEN.lines().filter(|l| !l.starts_with("B: ABS=1000f")).collect::<Vec<_>>().join("\n");
|
||||
assert!(!has_pen(&pen_without_axes));
|
||||
// The virtual keyboard claims the key but is no pen, and a real pen beside it is still found.
|
||||
assert!(VIRTUAL_KEYBOARD.lines().find_map(|l| l.strip_prefix("B: KEY=")).is_some_and(|k| bit_set(k, BTN_TOOL_PEN)));
|
||||
assert!(!has_pen(VIRTUAL_KEYBOARD));
|
||||
assert!(has_pen(&format!("{VIRTUAL_KEYBOARD}\n{WITH_PEN}")));
|
||||
// Short or malformed bitmaps are no pen, never a panic.
|
||||
for keys in ["", "B: KEY=", "B: KEY=1", "B: KEY=zz 0 0 0 0 0", "B: KEY=ffffffffffffffffff 0 0 0 0 0", "B: KEY=0 0 0 0 0 0 0 0"] {
|
||||
assert!(!has_pen(&format!("{keys}\nB: ABS=3")), "{keys:?}");
|
||||
}
|
||||
for abs in ["B: ABS=", "B: ABS=zz", "B: ABS=ffffffffffffffffff", "B: ABS=0 0"] {
|
||||
assert!(!has_pen(&format!("B: KEY=1c03 0 0 0 0 0\n{abs}")), "{abs:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="10" height="10" fill="currentColor" fill-opacity="0.4"/><rect x="10" y="10" width="10" height="10"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="7" r="2" fill="currentColor" fill-opacity="0.45"/><path d="M10 7h10"/><circle cx="6" cy="12" r="2" fill="currentColor" fill-opacity="0.45"/><path d="M10 12h8"/><circle cx="6" cy="17" r="2" fill="currentColor" fill-opacity="0.45"/><path d="M10 17h11"/></svg>
|
||||
|
Before Width: | Height: | Size: 315 B After Width: | Height: | Size: 460 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18C8 6 16 6 21 14"/><rect x="3.6" y="11.6" width="3.6" height="3.6"/><rect x="10.2" y="6.7" width="3.6" height="3.6"/><rect x="16.8" y="9.2" width="3.6" height="3.6"/></svg>
|
||||
|
After Width: | Height: | Size: 369 B |
@@ -126,3 +126,14 @@ sync_github_name = true
|
||||
|
||||
[people.dennismutuku2005]
|
||||
sync_github_name = true
|
||||
|
||||
[people.krinry]
|
||||
display_name = "krinry"
|
||||
sync_github_name = true
|
||||
|
||||
[people.makesomethingshit]
|
||||
sync_github_name = true
|
||||
|
||||
[people.MiQ71]
|
||||
real_name = "Mirko Kerezovic"
|
||||
sync_github_name = false
|
||||
@@ -56,6 +56,84 @@ struct Sub {
|
||||
closed: bool,
|
||||
}
|
||||
|
||||
/// A subpath close enough to a circle to preserve as a native DXF CIRCLE/ARC. VectorCraft's
|
||||
/// ellipse helper uses the standard four-cubic circle approximation, so recognition needs a small
|
||||
/// relative tolerance rather than exact control-point tests.
|
||||
struct CircularSub {
|
||||
centre: Point,
|
||||
radius: f64,
|
||||
samples: Vec<Point>,
|
||||
}
|
||||
|
||||
fn cubic_point(p0: Point, p1: Point, p2: Point, p3: Point, t: f64) -> Point {
|
||||
let u = 1.0 - t;
|
||||
let a = u * u * u;
|
||||
let b = 3.0 * u * u * t;
|
||||
let c = 3.0 * u * t * t;
|
||||
let d = t * t * t;
|
||||
Point::new(a * p0.x + b * p1.x + c * p2.x + d * p3.x, a * p0.y + b * p1.y + c * p2.y + d * p3.y)
|
||||
}
|
||||
|
||||
/// Conservatively fit a curved subpath to one circle. Straight segments are never promoted. The
|
||||
/// 5e-4 radial tolerance is just above the maximum radial error of the usual kappa cubic circle
|
||||
/// approximation (~2.7e-4 r), while rejecting visibly elliptical or arbitrary cubic curves.
|
||||
fn circular_subpath(sub: &Sub) -> Option<CircularSub> {
|
||||
if sub.segs.is_empty() || sub.segs.iter().any(|s| matches!(s, Seg::Line(_))) {
|
||||
return None;
|
||||
}
|
||||
let mut samples = vec![sub.start];
|
||||
let mut from = sub.start;
|
||||
for seg in &sub.segs {
|
||||
let Seg::Cubic(a, b, to) = *seg else { return None };
|
||||
for i in 1..=8 {
|
||||
samples.push(cubic_point(from, a, b, to, f64::from(i) / 8.0));
|
||||
}
|
||||
from = to;
|
||||
}
|
||||
if samples.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
let mut anchors = vec![sub.start];
|
||||
anchors.extend(sub.segs.iter().map(|s| s.end()));
|
||||
if sub.closed && anchors.last().is_some_and(|p| p.distance(sub.start) <= 1e-9) {
|
||||
anchors.pop();
|
||||
}
|
||||
let (p0, p1, p2) = if anchors.len() >= 3 {
|
||||
(anchors[0], anchors[anchors.len() / 3], anchors[anchors.len() * 2 / 3])
|
||||
} else {
|
||||
(samples[0], samples[samples.len() / 2], *samples.last()?)
|
||||
};
|
||||
let d = 2.0 * (p0.x * (p1.y - p2.y) + p1.x * (p2.y - p0.y) + p2.x * (p0.y - p1.y));
|
||||
let scale2 = p0.distance(p1).max(p1.distance(p2)).max(p2.distance(p0)).powi(2);
|
||||
if !d.is_finite() || d.abs() <= scale2 * 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let q0 = p0.x * p0.x + p0.y * p0.y;
|
||||
let q1 = p1.x * p1.x + p1.y * p1.y;
|
||||
let q2 = p2.x * p2.x + p2.y * p2.y;
|
||||
let centre = Point::new(
|
||||
(q0 * (p1.y - p2.y) + q1 * (p2.y - p0.y) + q2 * (p0.y - p1.y)) / d,
|
||||
(q0 * (p2.x - p1.x) + q1 * (p0.x - p2.x) + q2 * (p1.x - p0.x)) / d,
|
||||
);
|
||||
let radius = centre.distance(p0);
|
||||
if !centre.x.is_finite() || !centre.y.is_finite() || !radius.is_finite() || radius <= 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let tolerance = (radius * 5e-4).max(1e-6);
|
||||
if samples.iter().any(|p| (centre.distance(*p) - radius).abs() > tolerance) {
|
||||
return None;
|
||||
}
|
||||
Some(CircularSub { centre, radius, samples })
|
||||
}
|
||||
|
||||
fn angle_deg(p: Point, centre: Point) -> f64 {
|
||||
(p.y - centre.y).atan2(p.x - centre.x).to_degrees().rem_euclid(360.0)
|
||||
}
|
||||
|
||||
fn on_ccw_arc(start: f64, end: f64, angle: f64) -> bool {
|
||||
(angle - start).rem_euclid(360.0) <= (end - start).rem_euclid(360.0) + 1e-7
|
||||
}
|
||||
|
||||
/// The subpaths of `bp` (quadratics raised to cubics).
|
||||
fn subpaths(bp: &BezPath) -> Vec<Sub> {
|
||||
let mut out = vec![];
|
||||
@@ -576,6 +654,10 @@ impl<'a> Scene<'a> {
|
||||
pts.pop();
|
||||
}
|
||||
self.polyline(&pts, sub.closed, style, width);
|
||||
} else if (v.handles() || width.is_none()) && self.circular(&sub, style) {
|
||||
// Native circles/arcs are preferred by CAD/CAM software. In R12, however, a
|
||||
// stroked curve must stay on the flattened POLYLINE path so groups 40/41 retain
|
||||
// its width. Non-circular curves continue through the existing fallback.
|
||||
} else if v.handles() {
|
||||
self.spline(&sub, style);
|
||||
} else {
|
||||
@@ -636,6 +718,73 @@ impl<'a> Scene<'a> {
|
||||
self.e.entity("SEQEND", end, h, &layer, style, "");
|
||||
}
|
||||
|
||||
/// Write a safely recognized circular subpath as a native DXF CIRCLE or ARC. Returns false
|
||||
/// when the curve is not circular enough, leaving the caller to write it as a spline/polyline.
|
||||
fn circular(&mut self, sub: &Sub, style: &EntityStyle) -> bool {
|
||||
let Some(last) = sub.segs.last().map(|s| s.end()) else { return false };
|
||||
let geometrically_closed = last.distance(sub.start) <= 1e-9;
|
||||
// ClosePath can add an implicit straight closing edge. Do not replace that geometry with
|
||||
// a full CIRCLE; likewise, an open curve that returns to its start is not an ARC. Check
|
||||
// before fitting, updating extents or allocating an entity handle.
|
||||
if sub.closed != geometrically_closed {
|
||||
return false;
|
||||
}
|
||||
let Some(circle) = circular_subpath(sub) else { return false };
|
||||
let centre = self.to_dxf * circle.centre;
|
||||
let radius = circle.radius * self.k.abs();
|
||||
if !centre.x.is_finite() || !centre.y.is_finite() || !radius.is_finite() || radius <= 1e-12 {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Count sampled points in the drawing extents. For a full circle include all cardinal
|
||||
// points exactly; for an arc, the dense samples conservatively cover its visible span.
|
||||
if sub.closed {
|
||||
let r = circle.radius;
|
||||
for p in [
|
||||
Point::new(circle.centre.x - r, circle.centre.y),
|
||||
Point::new(circle.centre.x + r, circle.centre.y),
|
||||
Point::new(circle.centre.x, circle.centre.y - r),
|
||||
Point::new(circle.centre.x, circle.centre.y + r),
|
||||
] {
|
||||
self.pt(p);
|
||||
}
|
||||
} else {
|
||||
for p in &circle.samples {
|
||||
self.pt(*p);
|
||||
}
|
||||
}
|
||||
|
||||
let layer = self.layer.clone();
|
||||
let h = self.handle();
|
||||
if sub.closed {
|
||||
self.e.entity("CIRCLE", h, fixed::MODEL_RECORD, &layer, style, "AcDbCircle");
|
||||
self.e.xyz(10, centre.x, centre.y);
|
||||
self.e.num(40, radius);
|
||||
self.e.normal();
|
||||
return true;
|
||||
}
|
||||
|
||||
let start = self.to_dxf * sub.start;
|
||||
let end = self.to_dxf * last;
|
||||
let middle = self.to_dxf * circle.samples[circle.samples.len() / 2];
|
||||
let mut a0 = angle_deg(start, centre);
|
||||
let mut a1 = angle_deg(end, centre);
|
||||
let am = angle_deg(middle, centre);
|
||||
if !on_ccw_arc(a0, a1, am) {
|
||||
std::mem::swap(&mut a0, &mut a1);
|
||||
}
|
||||
self.e.entity("ARC", h, fixed::MODEL_RECORD, &layer, style, "AcDbCircle");
|
||||
self.e.xyz(10, centre.x, centre.y);
|
||||
self.e.num(40, radius);
|
||||
self.e.normal();
|
||||
if self.o.version.handles() {
|
||||
self.e.raw(100, "AcDbArc");
|
||||
}
|
||||
self.e.num(50, a0);
|
||||
self.e.num(51, a1);
|
||||
true
|
||||
}
|
||||
|
||||
/// A curved subpath as one cubic B-spline whose clamped knots repeat at every joint, so it
|
||||
/// passes through every anchor (straight segments become straight cubics).
|
||||
fn spline(&mut self, sub: &Sub, style: &EntityStyle) {
|
||||
|
||||
+73
-14
@@ -143,8 +143,8 @@ fn lines_are_lwpolylines_curves_splines_and_fills_hatches() {
|
||||
let out = export(&d, &opts(&d)).unwrap();
|
||||
assert_eq!(
|
||||
kinds(&out),
|
||||
["LWPOLYLINE", "HATCH", "LWPOLYLINE", "SPLINE"],
|
||||
"the fill's outline and hatch, then the stroke, then the ellipse's stroke"
|
||||
["LWPOLYLINE", "HATCH", "LWPOLYLINE", "CIRCLE"],
|
||||
"the fill's outline and hatch, then the stroke, then the circle's stroke"
|
||||
);
|
||||
let e = entities(&out);
|
||||
// The rectangle, y up from the artboard's bottom-left corner: 10..110 × 40..90.
|
||||
@@ -164,11 +164,9 @@ fn lines_are_lwpolylines_curves_splines_and_fills_hatches() {
|
||||
assert_eq!((get(&e[0], 370), get(&e[2], 370)), (None, Some("35")));
|
||||
let hatch = &e[1];
|
||||
assert_eq!((get(hatch, 2), get(hatch, 70), get(hatch, 91), get(hatch, 93)), (Some("SOLID"), Some("1"), Some("1"), Some("4")));
|
||||
// The ellipse: four cubics through clamped knots.
|
||||
let spline = &e[3];
|
||||
assert_eq!((get(spline, 71), get(spline, 73)), (Some("3"), Some("13")));
|
||||
assert_eq!(get(spline, 70), Some("9"), "closed planar spline (#1299): 1 = closed, 8 = planar");
|
||||
assert_eq!(all(spline, 40), [0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 2.0, 2.0, 2.0, 3.0, 3.0, 3.0, 4.0, 4.0, 4.0, 4.0]);
|
||||
// The circular ellipse is preserved as a native circle (#1299).
|
||||
let circle = &e[3];
|
||||
assert_eq!((all(circle, 10), all(circle, 20), all(circle, 40)), (vec![150.0], vec![50.0], vec![30.0]));
|
||||
// R14 has lightweight polylines but no lineweights: the stroke is a polyline width.
|
||||
let r14 = export(&d, &DxfOptions { version: DxfVersion::R14, ..opts(&d) }).unwrap();
|
||||
assert_eq!(get(&entities(&r14)[2], 43), Some("1"));
|
||||
@@ -177,16 +175,77 @@ fn lines_are_lwpolylines_curves_splines_and_fills_hatches() {
|
||||
// R13 has splines and hatches, and polylines of vertices.
|
||||
let r13 = export(&d, &DxfOptions { version: DxfVersion::R13, ..opts(&d) }).unwrap();
|
||||
let poly = ["POLYLINE", "VERTEX", "VERTEX", "VERTEX", "VERTEX", "SEQEND"];
|
||||
assert_eq!(kinds(&r13), [&poly[..], &["HATCH"], &poly, &["SPLINE"]].concat());
|
||||
// R12: polylines only; the fill is its outline, the ellipse flattened.
|
||||
assert_eq!(kinds(&r13), [&poly[..], &["HATCH"], &poly, &["CIRCLE"]].concat());
|
||||
// R12: visible stroke widths live on old POLYLINE entities, including curved strokes.
|
||||
let r12 = export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap();
|
||||
let k = kinds(&r12);
|
||||
assert!(k.iter().all(|k| matches!(k.as_str(), "POLYLINE" | "VERTEX" | "SEQEND")), "{k:?}");
|
||||
assert_eq!(k.iter().filter(|k| *k == "POLYLINE").count(), 3);
|
||||
assert!(k.iter().filter(|k| *k == "VERTEX").count() > 20, "the ellipse is flattened");
|
||||
assert_eq!(k.iter().filter(|k| *k == "CIRCLE").count(), 0);
|
||||
assert!(r12.warnings.iter().any(|w| w.contains("R12 has no fills")), "{:?}", r12.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circles_and_circular_arcs_are_native_dxf_entities() {
|
||||
let d = doc_with(|d| {
|
||||
let circle = shapes::ellipse(Rect::new(20.0, 20.0, 80.0, 80.0));
|
||||
let c = Point::new(120.0, 50.0);
|
||||
let r = 30.0;
|
||||
let k = r * shapes::KAPPA;
|
||||
let mut arc = vectorcraft_geom::BezPath::new();
|
||||
arc.move_to(Point::new(c.x + r, c.y));
|
||||
arc.curve_to(Point::new(c.x + r, c.y - k), Point::new(c.x + k, c.y - r), Point::new(c.x, c.y - r));
|
||||
vec![
|
||||
path(d, circle, Paint::None, Paint::solid(Color::BLACK), 1.0),
|
||||
path(d, PathData::from_bezpath(&arc), Paint::None, Paint::solid(Color::BLACK), 1.0),
|
||||
]
|
||||
});
|
||||
for version in DxfVersion::ALL.into_iter().filter(|version| *version != DxfVersion::R12) {
|
||||
let out = export(&d, &DxfOptions { version, ..opts(&d) }).unwrap();
|
||||
let e = entities(&out);
|
||||
assert_eq!(e.iter().map(|x| kind(x)).collect::<Vec<_>>(), ["CIRCLE", "ARC"], "{version:?}");
|
||||
assert_eq!((all(&e[0], 10), all(&e[0], 20), all(&e[0], 40)), (vec![50.0], vec![50.0], vec![30.0]), "{version:?}");
|
||||
assert_eq!((all(&e[1], 10), all(&e[1], 20), all(&e[1], 40)), (vec![120.0], vec![50.0], vec![30.0]), "{version:?}");
|
||||
assert_eq!((all(&e[1], 50), all(&e[1], 51)), (vec![0.0], vec![90.0]), "{version:?}");
|
||||
}
|
||||
|
||||
// An actual ellipse is not close enough to a circle and remains a spline.
|
||||
let ellipse = doc_with(|d| vec![path(d, shapes::ellipse(Rect::new(20.0, 20.0, 100.0, 60.0)), Paint::None, Paint::solid(Color::BLACK), 1.0)]);
|
||||
assert_eq!(kinds(&export(&ellipse, &opts(&ellipse)).unwrap()), ["SPLINE"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn r12_stroked_circles_keep_polyline_width() {
|
||||
let d = doc_with(|d| vec![path(d, shapes::ellipse(Rect::new(20.0, 20.0, 80.0, 80.0)), Paint::None, Paint::solid(Color::BLACK), 3.0)]);
|
||||
let out = export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap();
|
||||
let e = entities(&out);
|
||||
assert_eq!(kind(&e[0]), "POLYLINE");
|
||||
assert_eq!((get(&e[0], 40), get(&e[0], 41)), (Some("3"), Some("3")));
|
||||
assert!(!e.iter().any(|entity| kind(entity) == "CIRCLE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_circular_arc_with_a_closing_chord_is_not_a_circle() {
|
||||
let d = doc_with(|d| {
|
||||
let c = Point::new(50.0, 50.0);
|
||||
let r = 30.0;
|
||||
let k = r * shapes::KAPPA;
|
||||
let mut arc = vectorcraft_geom::BezPath::new();
|
||||
arc.move_to(Point::new(c.x + r, c.y));
|
||||
arc.curve_to(Point::new(c.x + r, c.y - k), Point::new(c.x + k, c.y - r), Point::new(c.x, c.y - r));
|
||||
arc.curve_to(Point::new(c.x - k, c.y - r), Point::new(c.x - r, c.y - k), Point::new(c.x - r, c.y));
|
||||
arc.curve_to(Point::new(c.x - r, c.y + k), Point::new(c.x - k, c.y + r), Point::new(c.x, c.y + r));
|
||||
arc.close_path();
|
||||
vec![path(d, PathData::from_bezpath(&arc), Paint::None, Paint::solid(Color::BLACK), 1.0)]
|
||||
});
|
||||
|
||||
let modern = export(&d, &opts(&d)).unwrap();
|
||||
assert_eq!(kinds(&modern), ["SPLINE"]);
|
||||
let r12 = export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap();
|
||||
assert!(kinds(&r12).iter().any(|k| k == "POLYLINE"));
|
||||
assert!(!kinds(&r12).iter().any(|k| k == "CIRCLE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spline_closed_flags_follow_path_intent_across_versions_without_changing_geometry() {
|
||||
let cases = [(true, true), (true, false), (false, false), (false, true)];
|
||||
@@ -353,7 +412,7 @@ fn alter_paths_writes_strokes_as_filled_outlines() {
|
||||
// Each fill and stroke: its outlines, then its hatch.
|
||||
let fill = ["LWPOLYLINE", "HATCH"];
|
||||
let rect_stroke = ["LWPOLYLINE", "LWPOLYLINE", "HATCH"];
|
||||
assert_eq!(kinds(&out), [&fill[..], &rect_stroke, &["SPLINE", "SPLINE", "HATCH"]].concat());
|
||||
assert_eq!(kinds(&out), [&fill[..], &rect_stroke, &["CIRCLE", "CIRCLE", "HATCH"]].concat());
|
||||
// The rectangle's stroke: an outer and an inner boundary.
|
||||
assert_eq!(get(&entities(&out)[4], 91), Some("2"));
|
||||
// Preserve Appearance outlines only the strokes a line can't draw.
|
||||
@@ -362,9 +421,9 @@ fn alter_paths_writes_strokes_as_filled_outlines() {
|
||||
if let Some(st) = d.node_mut(rect).and_then(|n| n.appearance.stroke_mut()) {
|
||||
st.align = vectorcraft_doc::StrokeAlign::Outside;
|
||||
}
|
||||
assert_eq!(kinds(&export(&d, &opts(&d)).unwrap()), [&fill[..], &rect_stroke, &["SPLINE"]].concat());
|
||||
assert_eq!(kinds(&export(&d, &opts(&d)).unwrap()), [&fill[..], &rect_stroke, &["CIRCLE"]].concat());
|
||||
let editable = export(&d, &DxfOptions { preserve: Preserve::Editability, ..opts(&d) }).unwrap();
|
||||
assert_eq!(kinds(&editable), ["LWPOLYLINE", "HATCH", "LWPOLYLINE", "SPLINE"]);
|
||||
assert_eq!(kinds(&editable), ["LWPOLYLINE", "HATCH", "LWPOLYLINE", "CIRCLE"]);
|
||||
assert!(editable.warnings.iter().any(|w| w.contains("stroke alignment")));
|
||||
}
|
||||
|
||||
@@ -451,7 +510,7 @@ fn crop_leaves_out_art_outside_the_region() {
|
||||
let d = shapes_doc();
|
||||
// Only the ellipse (120..180) reaches into 115..200.
|
||||
let o = DxfOptions { region: Rect::new(115.0, 0.0, 200.0, 100.0), crop: true, ..opts(&d) };
|
||||
assert_eq!(kinds(&export(&d, &o).unwrap()), ["SPLINE"]);
|
||||
assert_eq!(kinds(&export(&d, &o).unwrap()), ["CIRCLE"]);
|
||||
assert_eq!(kinds(&export(&d, &DxfOptions { crop: false, ..o }).unwrap()).len(), 4);
|
||||
}
|
||||
|
||||
|
||||
@@ -320,9 +320,14 @@ fn labelled(mut p: ColorProfile, name: &str) -> ColorProfile {
|
||||
/// Grid points per input channel of the CMYK → Lab table and of the Lab → CMYK table.
|
||||
const A2B_GRID: usize = 9;
|
||||
const B2A_GRID: usize = 17;
|
||||
/// Grid points per Lab channel of the gamut table.
|
||||
const GAMUT_GRID: usize = 9;
|
||||
|
||||
/// A CMYK output profile sampled from a conversion: `to_lab` (CMYK → media-relative Lab) in a
|
||||
/// 9⁴ table, `from_lab` (Lab → CMYK, gamut-mapped) in a 17³ table, Lab PCS (ICC v4 encoding).
|
||||
/// It carries every tag ICC.1 requires of an output profile: the tables for all three intents
|
||||
/// and the gamut tag (Windows rejects a CMYK profile without them, so Photos couldn't show a CMYK
|
||||
/// TIFF that embedded one, #2707).
|
||||
pub(super) fn cmyk_profile(name: &str, to_lab: impl Fn([f32; 4]) -> lab::Lab, from_lab: impl Fn(lab::Lab) -> [f32; 4]) -> ColorProfile {
|
||||
let unit = |i: usize, n: usize| i as f32 / (n - 1) as f32;
|
||||
let q = |v: f32| (v.clamp(0.0, 1.0) * 65535.0).round() as u16;
|
||||
@@ -337,6 +342,13 @@ pub(super) fn cmyk_profile(name: &str, to_lab: impl Fn([f32; 4]) -> lab::Lab, fr
|
||||
let [l, a, b] = [2, 1, 0].map(|d| unit(i / B2A_GRID.pow(d) % B2A_GRID, B2A_GRID));
|
||||
b2a.extend(from_lab(lab::Lab::new(l * 100.0, a * 255.0 - 128.0, b * 255.0 - 128.0)).map(q));
|
||||
}
|
||||
// Gamut: 0 where a colour separates and comes back within a ΔE of 3, else out of gamut.
|
||||
let mut gamut = Vec::with_capacity(GAMUT_GRID.pow(3));
|
||||
for i in 0..GAMUT_GRID.pow(3) {
|
||||
let [l, a, b] = [2, 1, 0].map(|d| unit(i / GAMUT_GRID.pow(d) % GAMUT_GRID, GAMUT_GRID));
|
||||
let want = lab::Lab::new(l * 100.0, a * 255.0 - 128.0, b * 255.0 - 128.0);
|
||||
gamut.push(if lab::delta_e2000(want, to_lab(from_lab(want))) <= 3.0 { 0 } else { u16::MAX });
|
||||
}
|
||||
let identity = |n: usize| vec![ToneReprCurve::Parametric(vec![1.0]); n];
|
||||
let table = |inputs: usize, outputs: usize, grid: usize, clut: Vec<u16>| {
|
||||
let mut grid_points = [0u8; 16];
|
||||
@@ -346,8 +358,9 @@ pub(super) fn cmyk_profile(name: &str, to_lab: impl Fn([f32; 4]) -> lab::Lab, fr
|
||||
num_output_channels: outputs as u8,
|
||||
grid_points,
|
||||
clut: Some(LutStore::Store16(clut)),
|
||||
// A curves sit on the device side, B curves on the PCS side.
|
||||
a_curves: identity(4),
|
||||
// A curves sit on the device side (the gamut tag's is its one output), B curves on the
|
||||
// PCS side.
|
||||
a_curves: identity(inputs + outputs - 3),
|
||||
b_curves: identity(3),
|
||||
m_curves: vec![],
|
||||
matrix: Matrix3d::IDENTITY,
|
||||
@@ -356,6 +369,7 @@ pub(super) fn cmyk_profile(name: &str, to_lab: impl Fn([f32; 4]) -> lab::Lab, fr
|
||||
};
|
||||
let a2b = table(4, 3, A2B_GRID, a2b);
|
||||
let b2a = table(3, 4, B2A_GRID, b2a);
|
||||
let gamut = table(3, 1, GAMUT_GRID, gamut);
|
||||
let mut p = ColorProfile::default();
|
||||
p.profile_class = ProfileClass::OutputDevice;
|
||||
p.color_space = DataColorSpace::Cmyk;
|
||||
@@ -364,9 +378,12 @@ pub(super) fn cmyk_profile(name: &str, to_lab: impl Fn([f32; 4]) -> lab::Lab, fr
|
||||
p.white_point = moxcms::WHITE_POINT_D50.to_xyzd();
|
||||
p.media_white_point = Some(p.white_point);
|
||||
p.lut_a_to_b_perceptual = Some(a2b.clone());
|
||||
p.lut_a_to_b_saturation = Some(a2b.clone());
|
||||
p.lut_a_to_b_colorimetric = Some(a2b);
|
||||
p.lut_b_to_a_perceptual = Some(b2a.clone());
|
||||
p.lut_b_to_a_saturation = Some(b2a.clone());
|
||||
p.lut_b_to_a_colorimetric = Some(b2a);
|
||||
p.gamut = Some(gamut);
|
||||
labelled(p, name)
|
||||
}
|
||||
|
||||
|
||||
@@ -135,3 +135,20 @@ fn written_profiles_align_their_tags() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #2707: the CMYK profiles written here carry every tag ICC.1 requires of an output profile (the
|
||||
/// tables of all three intents and the gamut tag): Windows rejects one without them, and Photos
|
||||
/// then can't show a CMYK TIFF that embeds it.
|
||||
#[test]
|
||||
fn cmyk_profiles_have_the_tags_of_an_output_profile() {
|
||||
for name in BUILTIN_CMYK {
|
||||
let icc = icc_bytes(name).unwrap();
|
||||
let be = |at: usize| u32::from_be_bytes([icc[at], icc[at + 1], icc[at + 2], icc[at + 3]]) as usize;
|
||||
let tags: Vec<&[u8]> = (0..be(128)).map(|i| &icc[132 + 12 * i..136 + 12 * i]).collect();
|
||||
for want in [b"desc", b"cprt", b"wtpt", b"A2B0", b"A2B1", b"A2B2", b"B2A0", b"B2A1", b"B2A2", b"gamt"] {
|
||||
assert!(tags.contains(&&want[..]), "{name}: no {}", String::from_utf8_lossy(want));
|
||||
}
|
||||
let p = parse(&icc);
|
||||
assert!(p.lut_a_to_b_saturation.is_some() && p.lut_b_to_a_saturation.is_some() && p.gamut.is_some(), "{name}");
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
use kurbo::{Affine, BezPath, CubicBez, ParamCurve, ParamCurveNearest, Point, Rect};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::cms::{self, Model};
|
||||
use crate::{Color, Gradient, GradientStop};
|
||||
|
||||
/// The spread a new point gets.
|
||||
@@ -157,11 +158,12 @@ impl Freeform {
|
||||
f
|
||||
}
|
||||
|
||||
/// Colour the points along `g`, first to last.
|
||||
/// Color the points along `g`, first to last, mixing neighboring stops in the model they share
|
||||
/// ([`Color::lerp_keep_model`]).
|
||||
pub fn recolor(&mut self, g: &Gradient) {
|
||||
let n = self.points.len();
|
||||
for (k, p) in self.points.iter_mut().enumerate() {
|
||||
(p.color, p.opacity) = g.sample(k as f32 / (n - 1).max(1) as f32);
|
||||
(p.color, p.opacity) = g.sample_with(k as f32 / (n - 1).max(1) as f32, Color::lerp_keep_model);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,10 +232,10 @@ impl Freeform {
|
||||
best
|
||||
}
|
||||
|
||||
/// The colour and opacity at `p` for a painted box whose [`spread_scale`] is `scale`.
|
||||
/// The color and opacity at `p` for a painted box whose [`spread_scale`] is `scale`, as
|
||||
/// [`Self::model_field`] mixes them.
|
||||
pub fn sample(&self, p: Point, scale: f64) -> (Color, f32) {
|
||||
let ([r, g, b], a) = self.field(scale).sample(p);
|
||||
(Color::rgb(r, g, b), a)
|
||||
self.model_field(scale).sample(p)
|
||||
}
|
||||
|
||||
/// The colour field, with colours converted and lines tessellated once (for sampling many
|
||||
@@ -265,6 +267,32 @@ impl Freeform {
|
||||
Field { points, pieces, eps: 1e-9 * (scale.abs() + 1.0) }
|
||||
}
|
||||
|
||||
/// [`Self::field`] mixing the colors in the model every point shares instead of display RGB:
|
||||
/// the C, M, Y and K inks (as a CMYK document drawn by ink planes mixes them), the Gray ink,
|
||||
/// or the RGB or Lab components. Points in different models mix in display RGB, as
|
||||
/// [`Self::field`] mixes them, and the field gives that color in the working RGB space.
|
||||
pub fn model_field(&self, scale: f64) -> ModelField {
|
||||
let Some(model) = self.shared_model() else { return ModelField { model: None, first: self.field(scale), k: None } };
|
||||
let parts = |c: &Color| match *c {
|
||||
Color::Rgb { r, g, b } => [r, g, b, 0.0],
|
||||
Color::Cmyk { c, m, y, k } => [c, m, y, k],
|
||||
Color::Gray { k } => [k, 0.0, 0.0, 0.0],
|
||||
Color::Lab { l, a, b } => [l, a, b, 0.0],
|
||||
};
|
||||
let first = self.field_with(scale, &|c| {
|
||||
let [x, y, z, _] = parts(c);
|
||||
[x, y, z]
|
||||
});
|
||||
let k = (model == Model::Cmyk).then(|| self.field_with(scale, &|c| [parts(c)[3], 0.0, 0.0]));
|
||||
ModelField { model: Some(model), first, k }
|
||||
}
|
||||
|
||||
/// The color model every point is in (`None` without points or when their models differ).
|
||||
pub fn shared_model(&self) -> Option<Model> {
|
||||
let m = self.points.first()?.color.model();
|
||||
self.points.iter().all(|p| p.color.model() == m).then_some(m)
|
||||
}
|
||||
|
||||
/// Add a point; returns its index.
|
||||
pub fn add_point(&mut self, p: FreeformPoint) -> usize {
|
||||
self.points.push(p);
|
||||
@@ -324,8 +352,9 @@ impl Freeform {
|
||||
true
|
||||
}
|
||||
|
||||
/// Insert a point on segment `segment` of line `line` at curve parameter `t`, with the colour,
|
||||
/// opacity and spread between the segment's ends. Returns the new point's index.
|
||||
/// Insert a point on segment `segment` of line `line` at curve parameter `t`, with the color
|
||||
/// (in the model the ends share, [`Color::lerp_keep_model`]), opacity and spread between the
|
||||
/// segment's ends. Returns the new point's index.
|
||||
pub fn split_line(&mut self, line: usize, segment: usize, t: f64) -> Option<usize> {
|
||||
let c = *self.segments(line).get(segment)?;
|
||||
let l = &self.lines[line];
|
||||
@@ -334,7 +363,7 @@ impl Freeform {
|
||||
let u = t as f32;
|
||||
let p = FreeformPoint {
|
||||
at: c.eval(t),
|
||||
color: a.color.lerp(&b.color, u),
|
||||
color: a.color.lerp_keep_model(&b.color, u),
|
||||
opacity: a.opacity + (b.opacity - a.opacity) * u,
|
||||
spread: a.spread + (b.spread - a.spread) * u,
|
||||
};
|
||||
@@ -440,6 +469,41 @@ impl Field {
|
||||
}
|
||||
}
|
||||
|
||||
/// A freeform gradient ready to sample in its points' color model ([`Freeform::model_field`]).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ModelField {
|
||||
/// The model the points share (`None` when their models differ).
|
||||
model: Option<Model>,
|
||||
/// The first three components (C, M and Y; R, G and B; L, a and b; display RGB when the
|
||||
/// models differ), or the Gray ink as the first of the three values.
|
||||
first: Field,
|
||||
/// The K ink of a CMYK field.
|
||||
k: Option<Field>,
|
||||
}
|
||||
|
||||
impl ModelField {
|
||||
/// The model the points share (`None` when their models differ).
|
||||
pub fn model(&self) -> Option<Model> {
|
||||
self.model
|
||||
}
|
||||
|
||||
/// The color and opacity at `p`: in the points' shared model, else in the working RGB space.
|
||||
pub fn sample(&self, p: Point) -> (Color, f32) {
|
||||
let ([x, y, z], a) = self.first.sample(p);
|
||||
let c = match self.model {
|
||||
None => {
|
||||
let [r, g, b] = cms::active().srgb_to_rgb([x, y, z]);
|
||||
Color::rgb(r, g, b)
|
||||
}
|
||||
Some(Model::Rgb) => Color::rgb(x, y, z),
|
||||
Some(Model::Cmyk) => Color::cmyk(x, y, z, self.k.as_ref().map_or(0.0, |k| k.sample(p).0[0])),
|
||||
Some(Model::Gray) => Color::gray(x),
|
||||
Some(Model::Lab) => Color::lab(x, y, z),
|
||||
};
|
||||
(c, a)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -477,6 +541,31 @@ mod tests {
|
||||
assert!(close(c.to_rgb(), [0.5, 0.0, 0.5]) && (o - 0.75).abs() < 1e-6, "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_sample_mixes_in_the_model_the_points_share() {
|
||||
let halfway = |a: Color, b: Color| {
|
||||
let mut f = two(0.0);
|
||||
(f.points[0].color, f.points[1].color) = (a, b);
|
||||
f.sample(Point::new(50.0, 0.0), 50.0).0
|
||||
};
|
||||
let near = |a: Color, b: Color| match (a, b) {
|
||||
(Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => {
|
||||
[c - c2, m - m2, y - y2, k - k2].iter().all(|d| d.abs() < 1e-5)
|
||||
}
|
||||
(Color::Gray { k }, Color::Gray { k: k2 }) => (k - k2).abs() < 1e-5,
|
||||
(Color::Lab { l, a, b }, Color::Lab { l: l2, a: a2, b: b2 }) => [l - l2, a - a2, b - b2].iter().all(|d| d.abs() < 1e-4),
|
||||
_ => false,
|
||||
};
|
||||
let c = halfway(Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::cmyk(0.0, 0.0, 0.0, 1.0));
|
||||
assert!(near(c, Color::cmyk(0.5, 0.0, 0.0, 0.5)), "{c:?}");
|
||||
let c = halfway(Color::gray(0.2), Color::gray(0.6));
|
||||
assert!(near(c, Color::gray(0.4)), "{c:?}");
|
||||
let c = halfway(Color::lab(50.0, 10.0, -20.0), Color::lab(70.0, 30.0, 0.0));
|
||||
assert!(near(c, Color::lab(60.0, 20.0, -10.0)), "{c:?}");
|
||||
// Points in different models mix in RGB.
|
||||
assert!(matches!(halfway(Color::cmyk(1.0, 0.0, 0.0, 0.0), red()), Color::Rgb { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spread_keeps_the_colour_pure_inside_and_falls_off_outside() {
|
||||
// Scale 100: a 0.2 spread is a 20 pt disc.
|
||||
|
||||
@@ -241,6 +241,17 @@ impl Color {
|
||||
let b = other.to_rgb();
|
||||
Color::rgb(a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t)
|
||||
}
|
||||
/// Linear interpolation in the model both colors share when it is CMYK, Gray or Lab, else in
|
||||
/// display RGB ([`Color::lerp`]).
|
||||
pub fn lerp_keep_model(&self, other: &Color, t: f32) -> Color {
|
||||
let l = |x: f32, y: f32| x + (y - x) * t;
|
||||
match (*self, *other) {
|
||||
(Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => Color::cmyk(l(c, c2), l(m, m2), l(y, y2), l(k, k2)),
|
||||
(Color::Gray { k }, Color::Gray { k: k2 }) => Color::gray(l(k, k2)),
|
||||
(Color::Lab { l: l1, a, b }, Color::Lab { l: l2, a: a2, b: b2 }) => Color::lab(l(l1, l2), l(a, a2), l(b, b2)),
|
||||
_ => self.lerp(other, t),
|
||||
}
|
||||
}
|
||||
/// The colour model this colour is authored in.
|
||||
pub fn model(&self) -> cms::Model {
|
||||
match self {
|
||||
|
||||
@@ -14,6 +14,8 @@ vectorcraft-color = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
image = { workspace = true }
|
||||
tiff = { workspace = true }
|
||||
# 1-bit TIFFs compressed with CCITT Group 3 (Compression 2 and 3).
|
||||
hayro-ccitt = { workspace = true }
|
||||
zune-jpeg = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -739,9 +739,10 @@ pub struct Appearance {
|
||||
pub items: Vec<AppearanceItem>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub effects: Vec<Effect>,
|
||||
/// Groups, layers and type: how many items paint below the object's contents (its members, or
|
||||
/// type's characters), the slot of the Appearance panel's Contents (Characters) row. `None`:
|
||||
/// every item paints above the contents (see [`Self::contents_at`]).
|
||||
/// Groups, layers, type and images: how many items paint below the object's contents (its
|
||||
/// members, type's characters, or an image's pixels), the slot of the Appearance panel's
|
||||
/// Contents (Characters) row. `None`: every item paints above the contents (see
|
||||
/// [`Self::contents_at`]).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub contents_index: Option<usize>,
|
||||
}
|
||||
|
||||
@@ -33,13 +33,7 @@ use crate::text::TextObject;
|
||||
|
||||
/// Interpolate two colours in their shared model (display RGB when the models differ).
|
||||
pub fn lerp_color(a: &Color, b: &Color, t: f32) -> Color {
|
||||
let l = |x: f32, y: f32| x + (y - x) * t;
|
||||
match (*a, *b) {
|
||||
(Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => Color::cmyk(l(c, c2), l(m, m2), l(y, y2), l(k, k2)),
|
||||
(Color::Gray { k }, Color::Gray { k: k2 }) => Color::gray(l(k, k2)),
|
||||
(Color::Lab { l: l1, a, b }, Color::Lab { l: l2, a: a2, b: b2 }) => Color::lab(l(l1, l2), l(a, a2), l(b, b2)),
|
||||
_ => a.lerp(b, t),
|
||||
}
|
||||
a.lerp_keep_model(b, t)
|
||||
}
|
||||
|
||||
fn lerp_f32(x: f32, y: f32, t: f32) -> f32 {
|
||||
|
||||
@@ -349,6 +349,162 @@ pub fn extra_samples_tiff_rgba(bytes: &[u8], rgb: impl Fn([f32; 4]) -> [f32; 3])
|
||||
image::RgbaImage::from_raw(w, h, out)
|
||||
}
|
||||
|
||||
/// Why a TIFF compressed with CCITT Group 3 wasn't read ([`group3_tiff_rgba`]).
|
||||
#[derive(Clone, Debug, PartialEq, thiserror::Error)]
|
||||
pub enum Group3Error {
|
||||
/// Its pixels would take more than [`crate::MAX_RASTER_ALLOC`] as RGBA.
|
||||
#[error("an image of {w} × {h} pixels is too large to draw")]
|
||||
TooLarge { w: u32, h: u32 },
|
||||
/// A strip or tile ends before its last row or can't be decoded (it holds a code the decoder
|
||||
/// doesn't read, such as T.4's uncompressed mode, or a row of the wrong width), or a tile is
|
||||
/// larger than [`crate::MAX_RASTER_ALLOC`] allows as RGBA.
|
||||
#[error("could not decode image: {0}")]
|
||||
Damaged(hayro_ccitt::DecodeError),
|
||||
}
|
||||
|
||||
/// The pixels of a 1-bit TIFF compressed with CCITT Group 3, which the image decoder doesn't read,
|
||||
/// as RGBA. Compression 2 is modified Huffman coding with each row starting at a byte and no EOL
|
||||
/// codes. Compression 3 is ITU-T T.4 coding with an EOL code before each row: one-dimensional rows,
|
||||
/// or with T4Options bit 0, rows that a tag bit marks as one-dimensional or as coded against the
|
||||
/// row above. Fill bits before an EOL are skipped. Rows in T.4's uncompressed mode (T4Options
|
||||
/// bit 1) can't be decoded. Each strip or tile is coded on its own. White runs are 0 bits and black
|
||||
/// runs are 1 bits, as libtiff writes them, and PhotometricInterpretation 0 shows 0 bits as white
|
||||
/// and 1 shows them as black. FillOrder 2 (each byte's lowest bit first) is read too. Together, the
|
||||
/// strips and tiles are read from at most as many bytes as the file has. `None` for other images;
|
||||
/// an error when the pixels would take more than [`crate::MAX_RASTER_ALLOC`] as RGBA, a tile is
|
||||
/// larger than that allowance, or a strip or tile can't be decoded or ends before its last row.
|
||||
pub fn group3_tiff_rgba(bytes: &[u8]) -> Option<Result<image::RgbaImage, Group3Error>> {
|
||||
use hayro_ccitt::{DecodeError, DecodeSettings, DecoderContext, EncodingMode};
|
||||
use tiff::tags::Tag;
|
||||
let (kind, mut d) = tiff(bytes)?;
|
||||
let compression = d.find_tag_unsigned::<u16>(Tag::Compression).ok().flatten()?;
|
||||
let (w, h) = d.dimensions().ok()?;
|
||||
if kind != tiff::ColorType::Gray(1) || !matches!(compression, 2 | 3) || w == 0 || h == 0 {
|
||||
return None;
|
||||
}
|
||||
// The level of a 0 bit (a white run): white when white is zero (0), black when black is zero (1).
|
||||
let zero = match d.find_tag_unsigned::<u16>(Tag::PhotometricInterpretation).ok().flatten() {
|
||||
Some(0) => u8::MAX,
|
||||
Some(1) => 0,
|
||||
_ => return None,
|
||||
};
|
||||
if u64::from(w) * u64::from(h) > crate::MAX_RASTER_ALLOC / 4 {
|
||||
return Some(Err(Group3Error::TooLarge { w, h }));
|
||||
}
|
||||
let t4_options = d.find_tag_unsigned::<u32>(Tag::Unknown(292)).ok().flatten().unwrap_or(0);
|
||||
let lsb_first = d.find_tag_unsigned::<u16>(Tag::FillOrder).ok().flatten() == Some(2);
|
||||
let encoding = if compression == 3 && t4_options & 1 != 0 {
|
||||
// Each row's tag bit says how it is coded.
|
||||
EncodingMode::Group3_2D { k: 2 }
|
||||
} else {
|
||||
EncodingMode::Group3_1D
|
||||
};
|
||||
// The width and height of each strip or tile, and the tags of their offsets and byte counts.
|
||||
let tiled = d.get_chunk_type() == tiff::decoder::ChunkType::Tile;
|
||||
let (cw, ch, offsets, counts) = if tiled {
|
||||
let tw = d.find_tag_unsigned::<u32>(Tag::TileWidth).ok().flatten().filter(|&v| v > 0)?;
|
||||
let th = d.find_tag_unsigned::<u32>(Tag::TileLength).ok().flatten().filter(|&v| v > 0)?;
|
||||
(tw, th, Tag::TileOffsets, Tag::TileByteCounts)
|
||||
} else {
|
||||
let rows = d.find_tag_unsigned::<u32>(Tag::RowsPerStrip).ok().flatten().filter(|&v| v > 0).map_or(h, |r| r.min(h));
|
||||
(w, rows, Tag::StripOffsets, Tag::StripByteCounts)
|
||||
};
|
||||
let damaged = |e: DecodeError| Some(Err(Group3Error::Damaged(e)));
|
||||
// A tile is held to the allowance the image is held to; a strip is no larger than the image.
|
||||
if u64::from(cw) * u64::from(ch) > crate::MAX_RASTER_ALLOC / 4 {
|
||||
return damaged(DecodeError::Overflow);
|
||||
}
|
||||
let offsets = d.find_tag_unsigned_vec::<u64>(offsets).ok().flatten().unwrap_or_default();
|
||||
let counts = d.find_tag_unsigned_vec::<u64>(counts).ok().flatten().unwrap_or_default();
|
||||
let across = w.div_ceil(cw);
|
||||
let chunks = u64::from(across) * u64::from(h.div_ceil(ch));
|
||||
if chunks > offsets.len().min(counts.len()) as u64 {
|
||||
return damaged(DecodeError::UnexpectedEof);
|
||||
}
|
||||
let mut gray = vec![zero; usize::try_from(u64::from(w) * u64::from(h)).ok()?];
|
||||
let mut budget = bytes.len();
|
||||
for (i, (&offset, &count)) in (0..chunks).zip(offsets.iter().zip(&counts)) {
|
||||
let (x0, y0) = (u32::try_from(i % u64::from(across)).ok()? * cw, u32::try_from(i / u64::from(across)).ok()? * ch);
|
||||
let Some(start) = usize::try_from(offset).ok().filter(|&s| s <= bytes.len()) else {
|
||||
return damaged(DecodeError::UnexpectedEof);
|
||||
};
|
||||
let len = usize::try_from(count).unwrap_or(usize::MAX).min(bytes.len() - start).min(budget);
|
||||
budget -= len;
|
||||
let data = bytes.get(start..start + len)?;
|
||||
let data: std::borrow::Cow<[u8]> = if lsb_first { data.iter().map(|b| b.reverse_bits()).collect() } else { data.into() };
|
||||
let settings = DecodeSettings {
|
||||
columns: cw,
|
||||
// A tile is coded whole; the last strip only has the image's remaining rows.
|
||||
rows: if tiled { ch } else { ch.min(h - y0) },
|
||||
end_of_block: compression == 3,
|
||||
end_of_line: compression == 3,
|
||||
rows_are_byte_aligned: compression == 2,
|
||||
encoding,
|
||||
invert_black: false,
|
||||
};
|
||||
let mut rows = Rows { out: &mut gray, width: w, height: h, x0, y0, x: 0, y: 0, zero };
|
||||
if let Err(e) = hayro_ccitt::decode(&data, &mut rows, &mut DecoderContext::new(settings)) {
|
||||
return damaged(e);
|
||||
}
|
||||
// The data ended, or six EOL codes came, before the strip's or tile's last row.
|
||||
if rows.y < settings.rows {
|
||||
return damaged(DecodeError::UnexpectedEof);
|
||||
}
|
||||
}
|
||||
let gray = image::GrayImage::from_raw(w, h, gray)?;
|
||||
Some(Ok(image::DynamicImage::ImageLuma8(gray).to_rgba8()))
|
||||
}
|
||||
|
||||
/// Where a strip or tile of a Group 3 TIFF ([`group3_tiff_rgba`]) puts its decoded pixels: `out`
|
||||
/// holds the image's levels row by row, `width` × `height`, and the strip or tile starts at (`x0`,
|
||||
/// `y0`). Pixels past the image's right or bottom edge (a tile's padding) are dropped.
|
||||
struct Rows<'a> {
|
||||
out: &'a mut [u8],
|
||||
width: u32,
|
||||
height: u32,
|
||||
x0: u32,
|
||||
y0: u32,
|
||||
/// The next pixel in the strip or tile.
|
||||
x: u64,
|
||||
y: u32,
|
||||
/// The level of a white run.
|
||||
zero: u8,
|
||||
}
|
||||
|
||||
impl Rows<'_> {
|
||||
/// The next `n` pixels of the row, a white run's or a black run's.
|
||||
fn run(&mut self, white: bool, n: u64) {
|
||||
let level = if white { self.zero } else { u8::MAX - self.zero };
|
||||
let row = u64::from(self.y0) + u64::from(self.y);
|
||||
let left = u64::from(self.x0) + self.x;
|
||||
let right = left.saturating_add(n).min(u64::from(self.width));
|
||||
if row < u64::from(self.height) && left < right {
|
||||
let at = row * u64::from(self.width);
|
||||
if let (Ok(a), Ok(b)) = (usize::try_from(at + left), usize::try_from(at + right))
|
||||
&& let Some(px) = self.out.get_mut(a..b)
|
||||
{
|
||||
px.fill(level);
|
||||
}
|
||||
}
|
||||
self.x = self.x.saturating_add(n);
|
||||
}
|
||||
}
|
||||
|
||||
impl hayro_ccitt::Decoder for Rows<'_> {
|
||||
fn push_pixel(&mut self, white: bool) {
|
||||
self.run(white, 1);
|
||||
}
|
||||
|
||||
fn push_pixel_chunk(&mut self, white: bool, chunk_count: u32) {
|
||||
self.run(white, u64::from(chunk_count) * 8);
|
||||
}
|
||||
|
||||
fn next_line(&mut self) {
|
||||
self.y = self.y.saturating_add(1);
|
||||
self.x = 0;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+204
-26
@@ -1,11 +1,12 @@
|
||||
//! Hit testing over the document tree.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use vectorcraft_geom::hit::{fill_contains, stroke_contains};
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
use vectorcraft_geom::{BezPath, FillRule, Point, Rect};
|
||||
|
||||
use crate::{Document, Node, NodeId, NodeKind, SymbolBoxes};
|
||||
use crate::{AppearanceItem, Document, Node, NodeId, NodeKind, SymbolBoxes};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum HitKind {
|
||||
@@ -62,14 +63,37 @@ pub struct HitOptions {
|
||||
/// (point type's baseline, area type's frame, type on a path's path), not anywhere in its
|
||||
/// bounds.
|
||||
pub type_path_only: bool,
|
||||
/// Guide objects (paths made into guides) are not hittable: View › Guides › Lock Guides is
|
||||
/// on or the guides are hidden.
|
||||
pub skip_guides: bool,
|
||||
}
|
||||
|
||||
impl Default for HitOptions {
|
||||
fn default() -> Self {
|
||||
Self { tol: 3.0, outline: false, path_only: false, type_path_only: false, scope: None }
|
||||
Self { tol: 3.0, outline: false, path_only: false, type_path_only: false, skip_guides: false, scope: None }
|
||||
}
|
||||
}
|
||||
|
||||
/// The shapes that an image's own fills and strokes paint, each with its fill rule and the fill or
|
||||
/// stroke that paints it (none for fills and strokes that paint nothing). Effects are evaluated
|
||||
/// above this crate, so the engine registers one with [`set_image_paint`].
|
||||
pub type ImagePaint = fn(&Node) -> Vec<(BezPath, FillRule, AppearanceItem)>;
|
||||
|
||||
static IMAGE_PAINT: OnceLock<ImagePaint> = OnceLock::new();
|
||||
|
||||
/// Register how the shapes of an image's own fills and strokes are found (the first registration
|
||||
/// stays).
|
||||
pub fn set_image_paint(f: ImagePaint) {
|
||||
// Already set: the same engine, registered by another session.
|
||||
let _ = IMAGE_PAINT.set(f);
|
||||
}
|
||||
|
||||
/// The shapes that the image `n`'s own fills and strokes paint ([`set_image_paint`]), none while
|
||||
/// nothing is registered.
|
||||
pub(crate) fn image_paint(n: &Node) -> Vec<(BezPath, FillRule, AppearanceItem)> {
|
||||
IMAGE_PAINT.get().map_or_else(Vec::new, |f| f(n))
|
||||
}
|
||||
|
||||
/// Topmost editable object under `p`.
|
||||
pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit> {
|
||||
hit_test_skipping(doc, p, opt, &|_| false, true)
|
||||
@@ -311,20 +335,34 @@ fn clip_regions(clip: &Node) -> Vec<(vectorcraft_geom::BezPath, vectorcraft_geom
|
||||
/// hidden, when it can't be edited (`editable_only`), when it is the part of a Shaper group that
|
||||
/// isn't shown, or when `p` is outside its reach.
|
||||
fn may_hit(parent: &Node, i: usize, c: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes, editable_only: bool) -> bool {
|
||||
let source_isolated = |n: &Node| n.children().and_then(|children| children.first()).is_some_and(|source| Some(source.id) == opt.scope);
|
||||
if parent.extras().shaper.is_some() && (i == 0) != source_isolated(parent) {
|
||||
if shaper_hides(parent, i, opt.scope) {
|
||||
return false;
|
||||
}
|
||||
// Read-only samples can read locked/template art; hidden art never participates.
|
||||
if !c.visible || editable_only && (c.locked || c.is_template()) {
|
||||
return false;
|
||||
}
|
||||
if opt.skip_guides && matches!(c.kind, NodeKind::Path { guide: true, .. }) {
|
||||
return false;
|
||||
}
|
||||
// (An envelope's content sits where it was, not where the envelope draws it.)
|
||||
edits_contents(c)
|
||||
|| c.extras().shaper.is_some() && source_isolated(c)
|
||||
|| c.extras().shaper.is_some() && source_of(c, opt.scope)
|
||||
|| c.reach_bounds_with(symbols).is_none_or(|b| b.inflate(opt.tol, opt.tol).contains(p))
|
||||
}
|
||||
|
||||
/// Is the first child of `n` the isolated container `scope`?
|
||||
fn source_of(n: &Node, scope: Option<NodeId>) -> bool {
|
||||
n.children().and_then(|children| children.first()).is_some_and(|source| Some(source.id) == scope)
|
||||
}
|
||||
|
||||
/// Do clicks and marquees skip the child `i` of `parent` with `scope` isolated? For a Shaper
|
||||
/// Group that is its original shapes (its first child), or, in Construction mode, when they are
|
||||
/// isolated, the art the group shows.
|
||||
fn shaper_hides(parent: &Node, i: usize, scope: Option<NodeId>) -> bool {
|
||||
parent.extras().shaper.is_some() && (i == 0) != source_of(parent, scope)
|
||||
}
|
||||
|
||||
fn hit_children(
|
||||
parent: &Node,
|
||||
p: Point,
|
||||
@@ -422,12 +460,23 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Optio
|
||||
n.geometric_bounds().filter(|b| b.inflate(opt.tol, opt.tol).contains(p))?;
|
||||
on_type_path(n, t, p, opt.tol).then_some(HitKind::Outline)
|
||||
}
|
||||
NodeKind::Text(_)
|
||||
| NodeKind::Image(_)
|
||||
| NodeKind::PlacedDocument(_)
|
||||
| NodeKind::SymbolInstance { .. }
|
||||
| NodeKind::Blend { .. }
|
||||
| NodeKind::Envelope { .. } => n.geometric_bounds_with(symbols).filter(|b| b.inflate(opt.tol, opt.tol).contains(p)).map(|_| HitKind::Bounds),
|
||||
// An image is hit within its frame, and where its own fills and strokes paint
|
||||
// ([`image_paint`]) except in outline mode.
|
||||
NodeKind::Image(_) => {
|
||||
if n.geometric_bounds_with(symbols).is_some_and(|b| b.inflate(opt.tol, opt.tol).contains(p)) {
|
||||
return Some(HitKind::Bounds);
|
||||
}
|
||||
if opt.outline {
|
||||
return None;
|
||||
}
|
||||
image_paint(n).iter().rev().find_map(|(bp, rule, item)| match item {
|
||||
AppearanceItem::Stroke(s) => s.hits(bp, *rule, p, opt.tol).then_some(HitKind::Stroke),
|
||||
AppearanceItem::Fill(_) => (!opt.path_only && fill_contains(bp, *rule, p)).then_some(HitKind::Fill),
|
||||
})
|
||||
}
|
||||
NodeKind::Text(_) | NodeKind::PlacedDocument(_) | NodeKind::SymbolInstance { .. } | NodeKind::Blend { .. } | NodeKind::Envelope { .. } => {
|
||||
n.geometric_bounds_with(symbols).filter(|b| b.inflate(opt.tol, opt.tol).contains(p)).map(|_| HitKind::Bounds)
|
||||
}
|
||||
NodeKind::Repeat(r) => r.expand().iter().find_map(|g| hit_any(g, p, opt, symbols)),
|
||||
NodeKind::Mesh(m) => {
|
||||
let bp = m.outline().to_bezpath();
|
||||
@@ -493,7 +542,7 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
|
||||
// `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(c), Some(r)) => c.iter().enumerate().filter(|(i, _)| !shaper_hides(s, *i, scope)).map(|(_, c)| (c, r)).collect(),
|
||||
(Some(_), None) => vec![],
|
||||
// A single object isolated: only it.
|
||||
(None, _) => doc
|
||||
@@ -529,13 +578,17 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
|
||||
}
|
||||
out
|
||||
}
|
||||
fn touches(n: &Node, s: &SymbolBoxes, r: Rect) -> bool {
|
||||
/// Does the marquee `r` touch `n`? It skips the children of a Shaper Group that clicks skip
|
||||
/// ([`shaper_hides`]). An Intertwine group is touched by its members, and a repeat is touched
|
||||
/// by the copies it draws, as clicks hit them. Objects of every other kind except paths,
|
||||
/// compound paths, layers and groups are touched by their box.
|
||||
fn touches(n: &Node, s: &SymbolBoxes, r: Rect, scope: Option<NodeId>) -> bool {
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, .. } => vectorcraft_geom::hit::intersects_rect(path, 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)),
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Intertwine { children } => marquee_in(n, r)
|
||||
.is_some_and(|r| children.iter().enumerate().any(|(i, c)| c.visible && !shaper_hides(n, i, scope) && touches(c, s, r, scope))),
|
||||
NodeKind::Compound { children, .. } => children.iter().any(|c| c.visible && touches(c, s, r, scope)),
|
||||
NodeKind::Repeat(spec) => spec.expand().iter().any(|copy| touches(copy, s, r, scope)),
|
||||
// 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| {
|
||||
@@ -544,18 +597,18 @@ 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>) {
|
||||
fn collect_leaves(n: &Node, s: &SymbolBoxes, r: Rect, scope: Option<NodeId>, out: &mut Vec<NodeId>) {
|
||||
match &n.kind {
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => {
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Intertwine { 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);
|
||||
for (i, c) in children.iter().enumerate() {
|
||||
if c.visible && !c.locked && !shaper_hides(n, i, scope) {
|
||||
collect_leaves(c, s, r, scope, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if touches(n, s, r) {
|
||||
if touches(n, s, r, scope) {
|
||||
out.push(n.id);
|
||||
}
|
||||
}
|
||||
@@ -567,8 +620,8 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
|
||||
continue;
|
||||
}
|
||||
if leaves {
|
||||
collect_leaves(t, &symbols, r, &mut out);
|
||||
} else if touches(t, &symbols, r) {
|
||||
collect_leaves(t, &symbols, r, scope, &mut out);
|
||||
} else if touches(t, &symbols, r, scope) {
|
||||
out.push(t.id);
|
||||
}
|
||||
}
|
||||
@@ -771,6 +824,131 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A marquee touches a Shaper Group only by the art it shows, as clicks do ([`may_hit`]): over
|
||||
/// the original shape of a face the group erased it selects nothing, with Group Selection's
|
||||
/// leaves too, and over a face it shows it takes the group. With the original shapes isolated
|
||||
/// (Construction mode) it takes the original shape it reaches.
|
||||
#[test]
|
||||
fn a_marquee_leaves_a_shaper_group_by_its_original_shapes() {
|
||||
let mut d = Document::new(300.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
// Two original squares, 0..100 and 50..150; the group shows only the first one's face.
|
||||
let square = |x: f64| shapes::rectangle(Rect::new(x, 0.0, x + 100.0, 100.0));
|
||||
let a = Node::path(d.alloc_id(), square(0.0), Appearance::default_art());
|
||||
let b = Node::path(d.alloc_id(), square(50.0), Appearance::default_art());
|
||||
let b_id = b.id;
|
||||
let mut sources = Node::group(d.alloc_id(), vec![Arc::new(a), Arc::new(b)]);
|
||||
sources.name = Some(crate::shaper::SOURCES.into());
|
||||
sources.opacity = 0.0;
|
||||
let sources_id = sources.id;
|
||||
let mut face = Node::path(d.alloc_id(), square(0.0), Appearance::default_art());
|
||||
face.name = Some(format!("{}0", crate::shaper::FILL_PREFIX));
|
||||
let face_id = face.id;
|
||||
let g = d.alloc_id();
|
||||
let mut group = Node::group(g, vec![Arc::new(sources), Arc::new(face)]);
|
||||
group.extras_mut().shaper = Some(Box::default());
|
||||
d.insert(Some(l), 0, group).unwrap();
|
||||
// This marquee crosses only the right edge of the second square, whose face is erased.
|
||||
let erased = Rect::new(140.0, 40.0, 160.0, 60.0);
|
||||
assert!(hit_test(&d, Point::new(150.0, 50.0), HitOptions::default()).is_none(), "a click there misses");
|
||||
assert!(marquee(&d, erased, None, false).is_empty());
|
||||
assert!(marquee(&d, erased, None, true).is_empty());
|
||||
assert!(marquee(&d, erased, Some(g), false).is_empty());
|
||||
// This marquee crosses the shown face's left edge.
|
||||
let shown = Rect::new(-10.0, 40.0, 10.0, 60.0);
|
||||
assert_eq!(marquee(&d, shown, None, false), vec![g]);
|
||||
assert_eq!(marquee(&d, shown, None, true), vec![face_id]);
|
||||
assert_eq!(marquee(&d, shown, Some(g), false), vec![face_id]);
|
||||
// Construction mode: the original shapes are the objects.
|
||||
assert_eq!(marquee(&d, erased, Some(sources_id), false), vec![b_id]);
|
||||
}
|
||||
|
||||
/// A marquee touches an Intertwine group by its members, as it touches a plain group (#1103) and
|
||||
/// as clicks do: in the gap between two members, or inside one member's fill, it selects nothing.
|
||||
/// Across a member's edge it selects the group, and with the leaves that Direct and Group
|
||||
/// Selection take, or in isolation mode, it selects the member.
|
||||
#[test]
|
||||
fn a_marquee_meets_an_intertwine_group_by_its_members() {
|
||||
let mut d = Document::new(300.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
// Two squares, 0..100 and 150..250, with a gap between them inside the group's box.
|
||||
let square = |x: f64| shapes::rectangle(Rect::new(x, 0.0, x + 100.0, 100.0));
|
||||
let mut a = Node::path(d.alloc_id(), square(0.0), Appearance::default_art());
|
||||
a.extras_mut().intertwine = Some(Box::new(shapes::rectangle(Rect::new(50.0, 0.0, 100.0, 100.0))));
|
||||
let b = Node::path(d.alloc_id(), square(150.0), Appearance::default_art());
|
||||
let (a_id, g) = (a.id, d.alloc_id());
|
||||
d.insert(Some(l), 0, Node::new(g, NodeKind::Intertwine { children: vec![Arc::new(a), Arc::new(b)] })).unwrap();
|
||||
let gap = Rect::new(110.0, 40.0, 140.0, 60.0);
|
||||
assert!(hit_test(&d, gap.center(), HitOptions::default()).is_none(), "a click in the gap misses");
|
||||
assert!(marquee(&d, gap, None, false).is_empty());
|
||||
assert!(marquee(&d, gap, None, true).is_empty());
|
||||
assert!(marquee(&d, gap, Some(g), false).is_empty());
|
||||
assert!(marquee(&d, Rect::new(20.0, 20.0, 40.0, 40.0), None, false).is_empty(), "inside a member's fill");
|
||||
// This marquee crosses the first square's left edge.
|
||||
let edge = Rect::new(-10.0, 40.0, 10.0, 60.0);
|
||||
assert_eq!(marquee(&d, edge, None, false), vec![g]);
|
||||
assert_eq!(marquee(&d, edge, None, true), vec![a_id]);
|
||||
assert_eq!(marquee(&d, edge, Some(g), false), vec![a_id]);
|
||||
}
|
||||
|
||||
/// A marquee touches a repeat by the copies it draws, as clicks do: between two copies of a
|
||||
/// grid repeat it selects nothing, and across a copy's edge it takes the repeat.
|
||||
#[test]
|
||||
fn a_marquee_meets_a_repeat_by_its_copies() {
|
||||
let mut d = Document::new(300.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
let source = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default_art());
|
||||
// Copies at 0..20, 40..60 and 80..100.
|
||||
let kind = crate::pattern::RepeatKind::Grid {
|
||||
h_spacing: 20.0,
|
||||
v_spacing: 20.0,
|
||||
rows: 1,
|
||||
cols: 3,
|
||||
grid_type: crate::pattern::TileType::Grid,
|
||||
flip_rows: false,
|
||||
flip_cols: false,
|
||||
};
|
||||
let r = d.alloc_id();
|
||||
d.insert(Some(l), 0, Node::new(r, NodeKind::Repeat(crate::pattern::RepeatSpec { kind, source: vec![Arc::new(source)] }))).unwrap();
|
||||
let gap = Rect::new(25.0, 5.0, 35.0, 15.0);
|
||||
assert!(hit_test(&d, gap.center(), HitOptions::default()).is_none(), "a click between copies misses");
|
||||
assert!(marquee(&d, gap, None, false).is_empty());
|
||||
assert!(marquee(&d, gap, None, true).is_empty());
|
||||
assert_eq!(marquee(&d, Rect::new(35.0, 5.0, 45.0, 15.0), None, false), vec![r], "across the second copy's left edge");
|
||||
assert_eq!(marquee(&d, Rect::new(35.0, 5.0, 45.0, 15.0), None, true), vec![r]);
|
||||
}
|
||||
|
||||
/// A blend and an envelope are clicked anywhere in their box, and a marquee touches them there
|
||||
/// too: inside the box, away from every key object's and the content's outline, it takes them.
|
||||
#[test]
|
||||
fn a_marquee_meets_blends_and_envelopes_by_their_box() {
|
||||
let mut d = Document::new(300.0, 300.0);
|
||||
let l = d.layers[0].id;
|
||||
let square = |id, x: f64, y: f64| Arc::new(Node::path(id, shapes::rectangle(Rect::new(x, y, x + 20.0, y + 20.0)), Appearance::default_art()));
|
||||
let blend = d.alloc_id();
|
||||
let keys = vec![square(d.alloc_id(), 0.0, 0.0), square(d.alloc_id(), 100.0, 0.0)];
|
||||
d.insert(Some(l), 0, Node::new(blend, NodeKind::Blend { children: keys, spec: Default::default() })).unwrap();
|
||||
let envelope = d.alloc_id();
|
||||
let content = vec![Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 150.0, 100.0, 250.0)), Appearance::default_art()))];
|
||||
let e = Node::new(
|
||||
envelope,
|
||||
NodeKind::Envelope {
|
||||
content,
|
||||
kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true }),
|
||||
fidelity: 100.0,
|
||||
editing: false,
|
||||
options: Default::default(),
|
||||
frame: Box::new(vectorcraft_geom::Affine::IDENTITY),
|
||||
},
|
||||
);
|
||||
d.insert(Some(l), 1, e).unwrap();
|
||||
for (r, id) in [(Rect::new(50.0, 5.0, 60.0, 15.0), blend), (Rect::new(40.0, 190.0, 60.0, 210.0), envelope)] {
|
||||
assert_eq!(hit_test(&d, r.center(), HitOptions::default()).map(|h| h.leaf), Some(id));
|
||||
assert_eq!(marquee(&d, r, None, false), vec![id]);
|
||||
assert_eq!(marquee(&d, r, None, true), vec![id]);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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]
|
||||
|
||||
+87
-7
@@ -36,6 +36,7 @@ pub mod marks;
|
||||
mod mem;
|
||||
pub mod metadata;
|
||||
pub mod node;
|
||||
pub mod objects_on_path;
|
||||
pub mod orient;
|
||||
pub mod overprint;
|
||||
pub mod pattern;
|
||||
@@ -117,9 +118,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, HyphenationOptions, 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,
|
||||
JustificationOptions, Justify, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, ScriptMetrics,
|
||||
SingleWordJustify, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, VerticalAlign, WrapShape,
|
||||
};
|
||||
pub use trace::TraceView;
|
||||
pub use variables::{DataSet, DataValue, Variable, VariableKind, Variables};
|
||||
@@ -215,13 +216,33 @@ impl Unit {
|
||||
/// The rulers' label step at `zoom` (screen pixels per point), in this unit: the first of
|
||||
/// 1, 2, 5 × 10ⁿ that puts labels at least 50 pixels apart. Ticks mark every tenth of it.
|
||||
pub fn ruler_step(self, zoom: f64) -> f64 {
|
||||
const STEPS: [f64; 19] =
|
||||
const DECIMAL: [f64; 19] =
|
||||
[0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 25.0, 50.0, 100.0, 200.0, 500.0, 1000.0, 2000.0, 5000.0, 10000.0];
|
||||
STEPS.iter().copied().find(|s| s * self.points() * zoom >= 50.0).unwrap_or(10000.0)
|
||||
// An inch is divided in half, not in ten: a tenth of an inch is not a mark an inch ruler
|
||||
// carries, so below an inch the steps are binary fractions (#2827). Labelling stops at a
|
||||
// sixteenth; zooming in past that leaves the labels where they are and only the ticks
|
||||
// between them grow denser, down to a hundred and twenty-eighth
|
||||
// ([`Self::ruler_minor`]). A label is at most six characters that way ("0.0625"), which
|
||||
// the left ruler has room for when it spells one down the ruler a character at a time.
|
||||
// From an inch up the ladder is the shared one.
|
||||
const BINARY: [f64; 15] = [1.0 / 16.0, 0.125, 0.25, 0.5, 1.0, 2.0, 5.0, 10.0, 25.0, 50.0, 100.0, 200.0, 500.0, 1000.0, 10000.0];
|
||||
let steps: &[f64] = if self.ruler_halves() { &BINARY } else { &DECIMAL };
|
||||
steps.iter().copied().find(|s| s * self.points() * zoom >= 50.0).unwrap_or(10000.0)
|
||||
}
|
||||
/// Does this unit's ruler divide a step in half rather than in ten? Inches do; the metric
|
||||
/// units, points, pixels and picas are read in tenths.
|
||||
fn ruler_halves(self) -> bool {
|
||||
matches!(self, Unit::Inches)
|
||||
}
|
||||
/// The unlabelled ticks between two labels, `step` apart ([`Self::ruler_step`]): an eighth of
|
||||
/// the step on an inch ruler, so every tick is a binary fraction of an inch and they stay about
|
||||
/// as dense as the tenths elsewhere; a tenth of it otherwise.
|
||||
pub fn ruler_minor(self, step: f64) -> f64 {
|
||||
if self.ruler_halves() { step / 8.0 } else { step / 10.0 }
|
||||
}
|
||||
/// `v` (points) on the nearest ruler tick at `zoom` ([`Self::ruler_step`]).
|
||||
pub fn snap_to_ruler_tick(self, v: f64, zoom: f64) -> f64 {
|
||||
let tick = self.ruler_step(zoom) / 10.0 * self.points();
|
||||
let tick = self.ruler_minor(self.ruler_step(zoom)) * self.points();
|
||||
(v / tick).round() * tick
|
||||
}
|
||||
pub fn from_pt(self, v: f64) -> f64 {
|
||||
@@ -274,7 +295,12 @@ impl Unit {
|
||||
pub fn named(name: &str) -> Option<Unit> {
|
||||
let letters = |s: &'static str| s.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase());
|
||||
let given = || name.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase());
|
||||
Unit::ALL.into_iter().find(|u| letters(u.label()).eq(given()) || u.suffix().chars().eq(given()))
|
||||
// Feet and Feet & Inches share the suffix `ft`: it names the plain unit, so Feet & Inches
|
||||
// is left to its label and key.
|
||||
Unit::ALL
|
||||
.into_iter()
|
||||
.find(|u| letters(u.label()).eq(given()))
|
||||
.or_else(|| Unit::ALL.into_iter().filter(|u| *u != Unit::FeetInches).find(|u| u.suffix().chars().eq(given())))
|
||||
}
|
||||
/// A point value as the canvas measurement labels show it: two decimals and the suffix
|
||||
/// (`12.50 mm`).
|
||||
@@ -801,6 +827,12 @@ type Places = BTreeMap<Vec<usize>, Vec<(usize, usize)>>;
|
||||
pub const FORMAT_VERSION: u32 = 1;
|
||||
|
||||
impl Document {
|
||||
/// Document Raster Effects Settings › Resolution in ppi, 1 to 2400 (72 when the stored value
|
||||
/// isn't a number).
|
||||
pub fn raster_ppi(&self) -> f64 {
|
||||
Some(self.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0)
|
||||
}
|
||||
|
||||
/// The ruler zero point, or zero on both axes when either stored coordinate is invalid.
|
||||
/// Rulers, coordinate fields and guide snapping use the same supported range.
|
||||
pub fn ruler_origin_or_zero(&self) -> Point {
|
||||
@@ -1549,9 +1581,17 @@ impl Document {
|
||||
{
|
||||
dimension.source = None;
|
||||
}
|
||||
let mut renamed = std::collections::HashMap::new();
|
||||
if let Some(ch) = n.children_mut() {
|
||||
let old: Vec<Arc<Node>> = std::mem::take(ch);
|
||||
*ch = old.iter().map(|c| Arc::new(self.reid(c))).collect();
|
||||
renamed.extend(old.iter().zip(ch.iter()).map(|(o, c)| (o.id, c.id)));
|
||||
}
|
||||
// An Objects on Path group names its path and its objects by id: the copy names the copies.
|
||||
if let Some(e) = n.extras_if_any()
|
||||
&& let Some(on_path) = &mut e.objects_on_path
|
||||
{
|
||||
on_path.rename(&renamed);
|
||||
}
|
||||
n
|
||||
}
|
||||
@@ -1792,6 +1832,46 @@ mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_geom::shapes;
|
||||
|
||||
/// Zooming in on an inch ruler divided the inch into tenths (#2827). Its steps below an inch
|
||||
/// are binary fractions now, and the minor ticks between them stay binary too; every other
|
||||
/// unit is still read in tenths.
|
||||
#[test]
|
||||
fn an_inch_ruler_steps_in_binary_fractions() {
|
||||
let step = |u: Unit, zoom: f64| u.ruler_step(zoom);
|
||||
// Every inch step at or below an inch is 1/2^k, never a tenth or a fifth.
|
||||
for zoom in [1.0, 2.0, 5.0, 7.5, 10.0, 20.0, 40.0, 100.0, 400.0] {
|
||||
let s = step(Unit::Inches, zoom);
|
||||
if s <= 1.0 {
|
||||
let k = s.recip();
|
||||
assert_eq!(k, k.round(), "{s} at zoom {zoom} is 1/k for a whole k");
|
||||
assert!((k.round() as u64).is_power_of_two(), "{s} at zoom {zoom} is 1/2^k");
|
||||
}
|
||||
// A label step always leaves at least 50 px, which is what the ladder is picked for.
|
||||
assert!(s * Unit::Inches.points() * zoom >= 50.0, "zoom {zoom}");
|
||||
}
|
||||
// The steps the report named: tenths became quarters, eighths and sixteenths.
|
||||
assert_eq!(step(Unit::Inches, 5.0), 0.25);
|
||||
assert_eq!(step(Unit::Inches, 10.0), 0.125);
|
||||
assert_eq!(step(Unit::Inches, 20.0), 0.0625);
|
||||
// An inch and wider is unchanged.
|
||||
assert_eq!(step(Unit::Inches, 1.0), 1.0);
|
||||
// Labelling stops at a sixteenth, as the report says Illustrator's does, so zooming in
|
||||
// further only adds ticks. A label therefore never needs more than the six characters of
|
||||
// "0.0625", which the left ruler has room for when it spells one out a character per line.
|
||||
for zoom in [20.0, 50.0, 100.0, 400.0, 1600.0] {
|
||||
assert_eq!(step(Unit::Inches, zoom), 0.0625, "zoom {zoom}");
|
||||
}
|
||||
// The unlabelled ticks go on down to a hundred and twenty-eighth: the 32nds, 64ths and
|
||||
// 128ths the report expects to appear unlabelled when zoomed in close.
|
||||
assert_eq!(Unit::Inches.ruler_minor(0.0625), 1.0 / 128.0);
|
||||
// The minor ticks: an eighth of the step on an inch ruler, a tenth elsewhere.
|
||||
assert_eq!(Unit::Inches.ruler_minor(0.25), 0.03125);
|
||||
assert_eq!(Unit::Millimeters.ruler_minor(25.0), 2.5);
|
||||
assert_eq!(Unit::Points.ruler_minor(100.0), 10.0);
|
||||
// Other units keep the decimal ladder, so a tenth and a fifth still appear.
|
||||
assert!([0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 25.0].contains(&step(Unit::Millimeters, 5.0)), "{}", step(Unit::Millimeters, 5.0));
|
||||
}
|
||||
|
||||
fn doc_with_rects() -> (Document, NodeId, NodeId) {
|
||||
let mut d = Document::new(612.0, 792.0);
|
||||
let layer = d.layers[0].id;
|
||||
|
||||
+38
-7
@@ -730,19 +730,35 @@ impl GradientMesh {
|
||||
.to_point()
|
||||
}
|
||||
|
||||
/// Bilinear colour and opacity of patch (r, c) at local (u, v).
|
||||
/// Bilinear colour and opacity of patch (r, c) at local (u, v). Corners all in CMYK (or all
|
||||
/// grey) mix in that space, as a PDF shading mixes in its own colour space: the inks come out
|
||||
/// exact, and a CMYK document separates no colour to draw them. Other corners mix in RGB.
|
||||
pub fn color_at(&self, r: usize, c: usize, u: f64, v: f64) -> (Color, f32) {
|
||||
let q =
|
||||
[&self.points[self.idx(r, c)], &self.points[self.idx(r, c + 1)], &self.points[self.idx(r + 1, c)], &self.points[self.idx(r + 1, c + 1)]];
|
||||
let w = [(1.0 - u) * (1.0 - v), u * (1.0 - v), (1.0 - u) * v, u * v];
|
||||
let op = q.iter().zip(w).map(|(p, w)| p.opacity * w as f32).sum();
|
||||
let cmyk: Option<Vec<[f32; 4]>> =
|
||||
q.iter().map(|p| if let Color::Cmyk { c, m, y, k } = p.color { Some([c, m, y, k]) } else { None }).collect();
|
||||
if let Some(inks) = cmyk {
|
||||
let mut mix = [0.0f32; 4];
|
||||
for (ink, w) in inks.iter().zip(w) {
|
||||
for (m, x) in mix.iter_mut().zip(ink) {
|
||||
*m += x * w as f32;
|
||||
}
|
||||
}
|
||||
let [c, m, y, k] = mix;
|
||||
return (Color::Cmyk { c, m, y, k }, op);
|
||||
}
|
||||
let grey: Option<Vec<f32>> = q.iter().map(|p| if let Color::Gray { k } = p.color { Some(k) } else { None }).collect();
|
||||
if let Some(ks) = grey {
|
||||
return (Color::Gray { k: ks.iter().zip(w).map(|(k, w)| k * w as f32).sum() }, op);
|
||||
}
|
||||
let mut rgb = [0.0f32; 3];
|
||||
let mut op = 0.0f32;
|
||||
for k in 0..4 {
|
||||
let c = q[k].color.to_rgb();
|
||||
for (ch, x) in rgb.iter_mut().zip(c) {
|
||||
*ch += x * w[k] as f32;
|
||||
for (p, w) in q.iter().zip(w) {
|
||||
for (ch, x) in rgb.iter_mut().zip(p.color.to_rgb()) {
|
||||
*ch += x * w as f32;
|
||||
}
|
||||
op += q[k].opacity * w[k] as f32;
|
||||
}
|
||||
(Color::rgb(rgb[0], rgb[1], rgb[2]), op)
|
||||
}
|
||||
@@ -1823,4 +1839,19 @@ mod tests {
|
||||
assert!(m.remove_point_lines(i));
|
||||
assert_eq!((m.rows, m.cols), (1, 1));
|
||||
}
|
||||
|
||||
/// Corners all in CMYK mix in CMYK (the inks halfway), not in RGB.
|
||||
#[test]
|
||||
fn a_cmyk_mesh_mixes_its_inks() {
|
||||
let cyan = Color::Cmyk { c: 1.0, m: 0.0, y: 0.0, k: 0.0 };
|
||||
let mut m = GradientMesh::for_path(&shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), 1, 1, cyan, MeshAppearance::Flat, 0.0).unwrap();
|
||||
m.points[0].color = Color::Cmyk { c: 0.0, m: 0.0, y: 0.0, k: 1.0 };
|
||||
let (c, op) = m.color_at(0, 0, 0.0, 0.0);
|
||||
assert_eq!((c, op), (Color::Cmyk { c: 0.0, m: 0.0, y: 0.0, k: 1.0 }, 1.0));
|
||||
let Color::Cmyk { c, k, .. } = m.color_at(0, 0, 0.5, 0.5).0 else { panic!("mixed out of CMYK") };
|
||||
assert!((c - 0.75).abs() < 1e-6 && (k - 0.25).abs() < 1e-6, "{c} {k}");
|
||||
// A corner in another space mixes them all in RGB.
|
||||
m.points[1].color = Color::rgb(1.0, 0.0, 0.0);
|
||||
assert!(matches!(m.color_at(0, 0, 0.5, 0.5).0, Color::Rgb { .. }));
|
||||
}
|
||||
}
|
||||
@@ -253,6 +253,9 @@ fn node_bytes(n: &Node, other: Option<&Node>) -> usize {
|
||||
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));
|
||||
b += e.objects_on_path.as_deref().map_or(0, |o| {
|
||||
ALLOC + size_of::<crate::objects_on_path::ObjectsOnPath>() + o.members.capacity() * size_of::<crate::objects_on_path::Member>()
|
||||
});
|
||||
}
|
||||
b += match &n.kind {
|
||||
NodeKind::Path { path, live, .. } => path_bytes(path) + boxed(live),
|
||||
|
||||
@@ -82,6 +82,31 @@ impl DocMetadata {
|
||||
}
|
||||
}
|
||||
|
||||
/// The time exported files record: PDF `CreationDate` and `ModDate`, the PNG `Creation Time`
|
||||
/// entry, a zip entry's date, and a saved document's `created` and `modified`. It is the clock,
|
||||
/// unless `SOURCE_DATE_EPOCH` is set, which replaces it so that the same commands produce the same
|
||||
/// bytes twice (#2880).
|
||||
///
|
||||
/// The raw clock stays in [`now_unix`], which is what a heartbeat is judged by and what seeds
|
||||
/// encryption: neither may be pinned by the environment.
|
||||
pub fn output_time() -> Option<i64> {
|
||||
pinned_or(|n| std::env::var(n).ok(), now_unix)
|
||||
}
|
||||
|
||||
/// [`output_time`] with the environment and the clock supplied, so a test can show that the
|
||||
/// variable is preferred and that the clock answers whenever it does not.
|
||||
fn pinned_or(get: impl Fn(&str) -> Option<String>, clock: impl Fn() -> Option<i64>) -> Option<i64> {
|
||||
stamp_from_env(get).or_else(clock)
|
||||
}
|
||||
|
||||
/// `SOURCE_DATE_EPOCH` as Unix seconds: the reproducible-builds variable
|
||||
/// (<https://reproducible-builds.org/specs/source-date-epoch/>), a non-negative decimal integer of
|
||||
/// seconds since the epoch. `None` when it is unset, blank, negative or not an integer, and then
|
||||
/// the clock decides — an export is never refused over the environment.
|
||||
fn stamp_from_env(get: impl Fn(&str) -> Option<String>) -> Option<i64> {
|
||||
get("SOURCE_DATE_EPOCH")?.trim().parse::<i64>().ok().filter(|t| *t >= 0)
|
||||
}
|
||||
|
||||
/// The current time as Unix seconds (UTC); `None` where the platform has no clock (the web build).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn now_unix() -> Option<i64> {
|
||||
@@ -148,4 +173,38 @@ mod tests {
|
||||
);
|
||||
assert!(DocMetadata::default().png_text("").is_empty());
|
||||
}
|
||||
|
||||
/// `SOURCE_DATE_EPOCH` replaces the clock in exported output only when it is a non-negative
|
||||
/// decimal integer; anything else leaves the clock to decide, so an export is never refused.
|
||||
#[test]
|
||||
fn source_date_epoch_is_read_only_as_a_non_negative_integer() {
|
||||
let env = |v: Option<&'static str>| move |n: &str| v.filter(|_| n == "SOURCE_DATE_EPOCH").map(str::to_owned);
|
||||
assert_eq!(stamp_from_env(env(Some("1791208980"))), Some(1_791_208_980));
|
||||
assert_eq!(stamp_from_env(env(Some("0"))), Some(0));
|
||||
assert_eq!(stamp_from_env(env(Some(" 1791208980 "))), Some(1_791_208_980));
|
||||
for bad in ["", " ", "-1", "later", "1791208980.5", "1e9", "99999999999999999999"] {
|
||||
assert_eq!(stamp_from_env(env(Some(bad))), None, "{bad:?}");
|
||||
}
|
||||
assert_eq!(stamp_from_env(env(None)), None, "unset");
|
||||
// Another variable never stands in for it.
|
||||
assert_eq!(stamp_from_env(|_| Some("1791208980".to_owned())), Some(1_791_208_980));
|
||||
assert_eq!(stamp_from_env(|n: &str| (n == "TZ").then(|| "1791208980".to_owned())), None);
|
||||
}
|
||||
|
||||
/// What a file records: the variable when it is usable, the clock whenever it is not. The
|
||||
/// environment and the clock are passed in, so no test changes this process's environment --
|
||||
/// that is `unsafe` under edition 2024 and would reach every other test running beside it.
|
||||
#[test]
|
||||
fn a_pinned_epoch_is_preferred_and_the_clock_answers_otherwise() {
|
||||
let env = |v: Option<&'static str>| move |_: &str| v.map(str::to_owned);
|
||||
let clock = || Some(1_000_000);
|
||||
assert_eq!(pinned_or(env(Some("1791208980")), clock), Some(1_791_208_980), "the variable wins over the clock");
|
||||
assert_eq!(pinned_or(env(Some("0")), clock), Some(0), "including the epoch itself");
|
||||
for ignored in [None, Some(""), Some("-1"), Some("later")] {
|
||||
assert_eq!(pinned_or(env(ignored), clock), Some(1_000_000), "{ignored:?} leaves it to the clock");
|
||||
}
|
||||
// A platform with no clock and no variable has no date to record.
|
||||
assert_eq!(pinned_or(env(None), || None), None);
|
||||
assert_eq!(pinned_or(env(Some("1791208980")), || None), Some(1_791_208_980), "the variable needs no clock");
|
||||
}
|
||||
}
|
||||
+45
-9
@@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize};
|
||||
use vectorcraft_color::{BlendMode, Color, Paint};
|
||||
use vectorcraft_geom::corners::cut_corners;
|
||||
use vectorcraft_geom::shapes::{self, CornerKind};
|
||||
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect};
|
||||
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Shape};
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::live::{BlendSpec, EnvelopeKind, GradientMesh, Outliner};
|
||||
@@ -572,6 +572,9 @@ pub struct NodeExtras {
|
||||
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>>,
|
||||
/// Object › Objects on Path: the group's path and the objects attached to it (see
|
||||
/// [`crate::objects_on_path`]).
|
||||
pub objects_on_path: Option<Box<crate::objects_on_path::ObjectsOnPath>>,
|
||||
}
|
||||
|
||||
/// No extras: what [`Node::extras`] gives for a node without any.
|
||||
@@ -590,6 +593,7 @@ static NO_EXTRAS: NodeExtras = NodeExtras {
|
||||
dimension: None,
|
||||
intertwine: None,
|
||||
live_paint_gaps: None,
|
||||
objects_on_path: None,
|
||||
};
|
||||
|
||||
/// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal
|
||||
@@ -724,6 +728,7 @@ impl Serialize for Node {
|
||||
e.dimension.is_none(),
|
||||
e.intertwine.is_none(),
|
||||
e.live_paint_gaps.is_none(),
|
||||
e.objects_on_path.is_none(),
|
||||
];
|
||||
let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?;
|
||||
let mut skip = skips.into_iter();
|
||||
@@ -762,6 +767,7 @@ impl Serialize for Node {
|
||||
field!("dimension", &e.dimension);
|
||||
field!("intertwine", &e.intertwine);
|
||||
field!("live_paint_gaps", &e.live_paint_gaps);
|
||||
field!("objects_on_path", &e.objects_on_path);
|
||||
st.end()
|
||||
}
|
||||
}
|
||||
@@ -821,6 +827,8 @@ struct NodeWire {
|
||||
intertwine: Option<Box<PathData>>,
|
||||
#[serde(default)]
|
||||
live_paint_gaps: Option<Box<crate::live_paint::GapOptions>>,
|
||||
#[serde(default)]
|
||||
objects_on_path: Option<Box<crate::objects_on_path::ObjectsOnPath>>,
|
||||
}
|
||||
|
||||
impl From<NodeWire> for Node {
|
||||
@@ -840,6 +848,7 @@ impl From<NodeWire> for Node {
|
||||
dimension: w.dimension,
|
||||
intertwine: w.intertwine,
|
||||
live_paint_gaps: w.live_paint_gaps,
|
||||
objects_on_path: w.objects_on_path,
|
||||
};
|
||||
Node {
|
||||
id: w.id,
|
||||
@@ -1025,6 +1034,7 @@ impl Node {
|
||||
match &self.kind {
|
||||
NodeKind::Layer { .. } => "Layer",
|
||||
NodeKind::Group { clip: true, .. } => "Clip Group",
|
||||
NodeKind::Group { .. } if self.extras().objects_on_path.is_some() => "Objects on Path",
|
||||
NodeKind::Group { .. } => "Group",
|
||||
NodeKind::Path { live: Some(l), .. } => l.label(),
|
||||
NodeKind::Path { guide: true, .. } => "Guide",
|
||||
@@ -1087,8 +1097,10 @@ impl Node {
|
||||
}
|
||||
/// A quick box round everything the object can paint, for culling clicks: paths and compound
|
||||
/// paths grow their geometric bounds by the farthest their strokes can reach from any path
|
||||
/// ([`Appearance::outset`]) instead of measuring along their shape. Symbol instances are
|
||||
/// measured as in [`Node::visual_bounds`].
|
||||
/// ([`Appearance::outset`]) instead of measuring along their shape. An image's box takes in
|
||||
/// the shapes its own fills and strokes paint ([`crate::hit::set_image_paint`]). Groups, layers
|
||||
/// without a clipping mask and Intertwine groups take in their members' boxes, grown by their
|
||||
/// own strokes. Symbol instances are measured as in [`Node::visual_bounds`].
|
||||
pub fn reach_bounds(&self) -> Option<Rect> {
|
||||
self.reach(None)
|
||||
}
|
||||
@@ -1106,6 +1118,22 @@ impl Node {
|
||||
let o = self.appearance.outset();
|
||||
self.geometric(symbols).map(|b| b.inflate(o, o))
|
||||
}
|
||||
// The members' reach, grown by the container's own strokes.
|
||||
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => {
|
||||
let o = self.appearance.outset();
|
||||
children
|
||||
.iter()
|
||||
.skip(usize::from(self.extras().shaper.is_some()))
|
||||
.fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.reach(symbols)))
|
||||
.map(|b| b.inflate(o, o))
|
||||
}
|
||||
NodeKind::Intertwine { children } => children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.reach(symbols))),
|
||||
// The frame and each shape the image's own fills and strokes paint, a stroke's grown by
|
||||
// the farthest it can reach from its shape.
|
||||
NodeKind::Image(_) => crate::hit::image_paint(self).iter().fold(self.geometric(symbols), |acc, (bp, _, item)| {
|
||||
let o = if let crate::AppearanceItem::Stroke(s) = item { s.reach() } else { 0.0 };
|
||||
vectorcraft_geom::union_opt(acc, (!bp.elements().is_empty()).then(|| bp.bounding_box().inflate(o, o)))
|
||||
}),
|
||||
_ => self.visual(symbols),
|
||||
}
|
||||
}
|
||||
@@ -1155,9 +1183,9 @@ impl Node {
|
||||
}
|
||||
}
|
||||
/// Visual bounds: what the object paints, its strokes included (paths and compound paths
|
||||
/// measure theirs along their own shape, see [`Appearance::stroked_bounds`]). A symbol
|
||||
/// instance is measured as in [`Node::geometric_bounds`], grown by its own strokes' reach
|
||||
/// ([`Node::visual_bounds_with`] measures its art).
|
||||
/// measure theirs along their own shape, see [`Appearance::stroked_bounds`]). An image is
|
||||
/// measured by its frame. A symbol instance is measured as in [`Node::geometric_bounds`],
|
||||
/// grown by its own strokes' reach ([`Node::visual_bounds_with`] measures its art).
|
||||
pub fn visual_bounds(&self) -> Option<Rect> {
|
||||
self.visual(None)
|
||||
}
|
||||
@@ -1232,6 +1260,8 @@ impl Node {
|
||||
let o = self.appearance.outset().max(chars);
|
||||
self.geometric(symbols).map(|b| b.inflate(o, o))
|
||||
}
|
||||
// An image is measured by its frame, without its own fills and strokes.
|
||||
NodeKind::Image(_) => self.geometric(symbols),
|
||||
_ => {
|
||||
let o = self.appearance.outset();
|
||||
self.geometric(symbols).map(|b| b.inflate(o, o))
|
||||
@@ -1251,6 +1281,12 @@ impl Node {
|
||||
{
|
||||
dimension.transform(a);
|
||||
}
|
||||
// The frames of the objects attached to a path move with them.
|
||||
if let Some(e) = self.extras_if_any()
|
||||
&& let Some(on_path) = &mut e.objects_on_path
|
||||
{
|
||||
on_path.transform(a);
|
||||
}
|
||||
if !self.is_layer() {
|
||||
self.bbox_angle = crate::orient::transformed_angle(self.bbox_angle, a);
|
||||
}
|
||||
@@ -1683,11 +1719,11 @@ impl Node {
|
||||
|
||||
impl Node {
|
||||
/// The Appearance panel's row for what this object's own fills and strokes paint around:
|
||||
/// "Contents" for a group or layer (its members), "Characters" for type; `None` for objects
|
||||
/// whose fills and strokes paint their own geometry.
|
||||
/// "Contents" for a group or layer (its members) and for an image (its pixels), "Characters"
|
||||
/// for type; `None` for objects whose fills and strokes paint their own geometry.
|
||||
pub fn contents_label(&self) -> Option<&'static str> {
|
||||
match self.kind {
|
||||
NodeKind::Group { .. } | NodeKind::Layer { .. } => Some("Contents"),
|
||||
NodeKind::Group { .. } | NodeKind::Layer { .. } | NodeKind::Image(_) => Some("Contents"),
|
||||
NodeKind::Text(_) => Some("Characters"),
|
||||
_ => None,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
//! Object › Objects on Path: objects attached to a path and spread along it.
|
||||
//!
|
||||
//! An Objects on Path group is an ordinary group ([`NodeKind::Group`]) with an [`ObjectsOnPath`]
|
||||
//! record in its extras ([`crate::node::NodeExtras::objects_on_path`]). One child is the path, and
|
||||
//! the other children are the attached objects, kept where they are drawn: the group draws, hits,
|
||||
//! saves and exports as a group. The record lists the objects in their order along the path, each
|
||||
//! with the frame it is drawn in and the angle that frame is turned by. [`place`] spreads them
|
||||
//! evenly along the path from its first anchor (the first object sits on that anchor, and the last
|
||||
//! one sits on the end of an open path), puts the attachment point of each one
|
||||
//! ([`ObjectsOnPath::pivot`]) on the path, and turns its frame to the path's direction there,
|
||||
//! tilted by [`ObjectsOnPath::rotate`].
|
||||
//!
|
||||
//! Behavior: <https://helpx.adobe.com/illustrator/using/objects-on-path.html>.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use vectorcraft_geom::{Affine, PathData, Point};
|
||||
|
||||
use crate::blend::Spine;
|
||||
use crate::symbols::SymbolBoxes;
|
||||
use crate::{Node, NodeId, NodeKind};
|
||||
|
||||
/// The center of the nine reference points.
|
||||
pub const CENTER: u8 = 4;
|
||||
|
||||
fn center() -> u8 {
|
||||
CENTER
|
||||
}
|
||||
|
||||
/// What makes a group an Objects on Path group (see the module docs).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ObjectsOnPath {
|
||||
/// The child of the group that is the path.
|
||||
pub path: NodeId,
|
||||
/// The attached objects, in their order along the path.
|
||||
#[serde(default)]
|
||||
pub members: Vec<Member>,
|
||||
/// Pivot: the reference point of each object that attaches to the path, 0 to 8, row by row
|
||||
/// from the top left of the object's bounds in its frame (4: the center).
|
||||
#[serde(default = "center")]
|
||||
pub pivot: u8,
|
||||
/// Rotate: the counter-clockwise degrees each object is turned from upright on the path. An
|
||||
/// upright object has its frame's x axis along the path's direction at its attachment point.
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub rotate: f64,
|
||||
}
|
||||
|
||||
/// An object attached to the path of an Objects on Path group.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(from = "MemberWire")]
|
||||
pub struct Member {
|
||||
pub id: NodeId,
|
||||
/// The object's frame, mapped to the document: the object's art is drawn in this frame.
|
||||
pub frame: Affine,
|
||||
/// The angle the frame is turned by (radians, the document's axes): the path's direction at
|
||||
/// the object's attachment point plus the tilt from Rotate when it was placed, or the angle of
|
||||
/// the frame's x axis before then.
|
||||
pub angle: f64,
|
||||
}
|
||||
|
||||
/// A [`Member`] as read from a file: without `angle`, the angle of the frame's x axis.
|
||||
#[derive(Deserialize)]
|
||||
struct MemberWire {
|
||||
id: NodeId,
|
||||
frame: Affine,
|
||||
#[serde(default)]
|
||||
angle: Option<f64>,
|
||||
}
|
||||
|
||||
impl From<MemberWire> for Member {
|
||||
fn from(w: MemberWire) -> Self {
|
||||
let angle = w.angle.filter(|a| a.is_finite()).unwrap_or_else(|| x_angle(w.frame));
|
||||
Self { id: w.id, frame: w.frame, angle }
|
||||
}
|
||||
}
|
||||
|
||||
/// The angle of `frame`'s x axis (radians, the document's axes).
|
||||
fn x_angle(frame: Affine) -> f64 {
|
||||
let [a0, a1, ..] = frame.as_coeffs();
|
||||
a1.atan2(a0)
|
||||
}
|
||||
|
||||
impl Member {
|
||||
/// `n` in its frame as drawn ([`upright_frame`]).
|
||||
pub fn new(n: &Node) -> Self {
|
||||
let frame = upright_frame(n);
|
||||
Self { id: n.id, frame, angle: x_angle(frame) }
|
||||
}
|
||||
}
|
||||
|
||||
impl ObjectsOnPath {
|
||||
/// A record for the objects `members` (in their order along the path) attached to `path`, each
|
||||
/// in its frame as drawn ([`upright_frame`]).
|
||||
pub fn new(path: NodeId, members: &[&Node], pivot: u8, rotate: f64) -> Self {
|
||||
let members = members.iter().map(|n| Member::new(n)).collect();
|
||||
Self { path, members, pivot: pivot.min(8), rotate: if rotate.is_finite() { rotate } else { 0.0 } }
|
||||
}
|
||||
/// The frames go with the group, and each frame's angle becomes the transformed path's
|
||||
/// direction at the object plus the tilt, so that [`place`] keeps each object's scale, shear
|
||||
/// and reflection when it spreads the objects along the transformed path again. A reflection
|
||||
/// also reverses Rotate: the objects keep the mirrored tilt to the mirrored path.
|
||||
pub fn transform(&mut self, a: Affine) {
|
||||
let before = tilt(self.rotate);
|
||||
if a.determinant() < 0.0 && self.rotate != 0.0 {
|
||||
self.rotate = -self.rotate;
|
||||
}
|
||||
let after = tilt(self.rotate);
|
||||
let [a0, a1, a2, a3, _, _] = a.as_coeffs();
|
||||
for m in &mut self.members {
|
||||
m.frame = a * m.frame;
|
||||
let (sin, cos) = (m.angle - before).sin_cos();
|
||||
let (x, y) = (a0 * cos + a2 * sin, a1 * cos + a3 * sin);
|
||||
if x.is_finite() && y.is_finite() && (x != 0.0 || y != 0.0) {
|
||||
m.angle = y.atan2(x) + after;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The children given new ids (a copy of the group): the path and the objects by their new ids.
|
||||
pub fn rename(&mut self, ids: &HashMap<NodeId, NodeId>) {
|
||||
let to = |id: NodeId| ids.get(&id).copied().unwrap_or(id);
|
||||
self.path = to(self.path);
|
||||
for m in &mut self.members {
|
||||
m.id = to(m.id);
|
||||
}
|
||||
}
|
||||
/// The group's children changed from `before` to `after`: a new child at the index of a member
|
||||
/// that is gone replaces it (Break Link to Symbol, for one), keeping its place on the path.
|
||||
pub fn follow_replacements(&mut self, before: &[Arc<Node>], after: &[Arc<Node>]) {
|
||||
let now: HashSet<NodeId> = after.iter().map(|c| c.id).collect();
|
||||
let then: HashSet<NodeId> = before.iter().map(|c| c.id).collect();
|
||||
let listed: HashSet<NodeId> = self.members.iter().map(|m| m.id).collect();
|
||||
let mut ids = HashMap::new();
|
||||
for (old, new) in before.iter().zip(after) {
|
||||
if listed.contains(&old.id) && !now.contains(&old.id) && !then.contains(&new.id) && new.id != self.path {
|
||||
ids.insert(old.id, new.id);
|
||||
}
|
||||
}
|
||||
if !ids.is_empty() {
|
||||
self.rename(&ids);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `n` an Objects on Path group?
|
||||
pub fn is_objects_on_path(n: &Node) -> bool {
|
||||
matches!(n.kind, NodeKind::Group { .. }) && n.extras().objects_on_path.is_some()
|
||||
}
|
||||
|
||||
/// The path of `n` if it is a path object an Objects on Path group can attach objects to: a path
|
||||
/// (not a guide) whose first subpath has some length.
|
||||
pub fn attachable_path(n: &Node) -> Option<&PathData> {
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, guide: false, .. } if Spine::new(path).is_some_and(|s| s.length() > 1e-9) => Some(path),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The frame `n` is drawn in when it is attached: the page's axes turned by its rotation
|
||||
/// ([`Node::bbox_angle`]) less the nearest multiple of 90°, about the center of its bounds. Placed in
|
||||
/// that frame, an object stands square to the path or across it.
|
||||
pub fn upright_frame(n: &Node) -> Affine {
|
||||
let a = n.bbox_angle;
|
||||
let off = if a.is_finite() { a - (a / 90.0).round() * 90.0 } else { 0.0 };
|
||||
match n.geometric_bounds() {
|
||||
Some(b) if off.abs() > 1e-9 => Affine::rotate_about(-off.to_radians(), b.center()),
|
||||
_ => Affine::IDENTITY,
|
||||
}
|
||||
}
|
||||
|
||||
/// Where `n` objects attach along `path` (its first subpath): the point and the direction there
|
||||
/// (radians, as `atan2` gives in the document's axes). They are spread evenly by length from the
|
||||
/// first anchor: on an open path the last one is at its end, on a closed path the gaps go all the way
|
||||
/// around. `None` for a path without length.
|
||||
pub fn stops(path: &PathData, n: usize) -> Option<Vec<(Point, f64)>> {
|
||||
let spine = Spine::new(path)?;
|
||||
let len = spine.length();
|
||||
if !len.is_finite() || len <= 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let closed = path.subpaths.first().is_some_and(|s| s.closed);
|
||||
let gaps = if closed { n } else { n.saturating_sub(1) }.max(1);
|
||||
Some((0..n).map(|i| spine.at_length(len * i as f64 / gaps as f64)).collect())
|
||||
}
|
||||
|
||||
/// The tilt Rotate gives (radians, the document's axes, where y points down): `rotate` degrees
|
||||
/// counter-clockwise on the page.
|
||||
fn tilt(rotate: f64) -> f64 {
|
||||
-rotate.to_radians()
|
||||
}
|
||||
|
||||
/// The frame that puts the attachment point `pivot` of `n` (drawn in `m`'s frame) at `at`, turned
|
||||
/// by `angle` (radians, the document's axes) instead of `m`'s angle: the frame's own scale, shear
|
||||
/// and reflection are kept. Symbol instances are measured by their art (`symbols`).
|
||||
fn placed_frame(n: &Node, symbols: &SymbolBoxes, m: &Member, pivot: u8, at: Point, angle: f64) -> Option<Affine> {
|
||||
let det = m.frame.determinant();
|
||||
if !det.is_finite() || det.abs() < 1e-12 || !at.x.is_finite() || !at.y.is_finite() || !angle.is_finite() || !m.angle.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let mut local = n.clone();
|
||||
local.transform(m.frame.inverse(), false);
|
||||
let q = vectorcraft_geom::reference_point(local.geometric_bounds_with(symbols)?, usize::from(pivot.min(8)));
|
||||
let [a0, a1, a2, a3, _, _] = m.frame.as_coeffs();
|
||||
let shape = Affine::rotate(-m.angle) * Affine::new([a0, a1, a2, a3, 0.0, 0.0]);
|
||||
Some(Affine::translate(at.to_vec2()) * Affine::rotate(angle) * shape * Affine::translate(-q.to_vec2()))
|
||||
}
|
||||
|
||||
/// Is `a` the identity, give or take rounding?
|
||||
fn negligible(a: Affine) -> bool {
|
||||
let [a0, a1, a2, a3, a4, a5] = a.as_coeffs();
|
||||
(a0 - 1.0).abs() < 1e-9 && a1.abs() < 1e-9 && a2.abs() < 1e-9 && (a3 - 1.0).abs() < 1e-9 && a4.abs() < 1e-6 && a5.abs() < 1e-6
|
||||
}
|
||||
|
||||
/// Put the objects of Objects on Path group `g` on its path (see the module docs). Before that, the
|
||||
/// record lets go of the objects that are no longer in the group, and children it does not list
|
||||
/// (other than the path) join it at the end of the path. A group whose path is gone, or is no
|
||||
/// longer a path object, becomes a plain group. Symbol instances are measured by their art
|
||||
/// (`symbols`, from the document the group is in). Returns whether `g` changed.
|
||||
pub fn place(g: &mut Node, symbols: &SymbolBoxes) -> bool {
|
||||
let Some(mut rec) = g.extras().objects_on_path.as_deref().cloned() else { return false };
|
||||
let old = rec.clone();
|
||||
let NodeKind::Group { children, .. } = &mut g.kind else { return false };
|
||||
let path_id = rec.path;
|
||||
let Some(path) = children.iter().find(|c| c.id == path_id).filter(|c| matches!(c.kind, NodeKind::Path { .. })) else {
|
||||
g.extras_mut().objects_on_path = None;
|
||||
return true;
|
||||
};
|
||||
let path = attachable_path(path).cloned();
|
||||
let at: HashMap<NodeId, usize> = children.iter().enumerate().map(|(i, c)| (c.id, i)).collect();
|
||||
rec.members.retain(|m| m.id != path_id && at.contains_key(&m.id));
|
||||
let mut listed: HashSet<NodeId> = rec.members.iter().map(|m| m.id).collect();
|
||||
for c in children.iter() {
|
||||
if c.id != path_id && listed.insert(c.id) {
|
||||
rec.members.push(Member::new(c));
|
||||
}
|
||||
}
|
||||
let mut moved = false;
|
||||
if let Some(stops) = path.and_then(|p| stops(&p, rec.members.len())) {
|
||||
let turn = tilt(rec.rotate);
|
||||
for (m, (p, dir)) in rec.members.iter_mut().zip(stops) {
|
||||
let Some(c) = at.get(&m.id).and_then(|i| children.get_mut(*i)) else { continue };
|
||||
let Some(next) = placed_frame(c, symbols, m, rec.pivot, p, dir + turn) else { continue };
|
||||
let delta = next * m.frame.inverse();
|
||||
if !negligible(delta) {
|
||||
Arc::make_mut(c).transform(delta, false);
|
||||
*m = Member { id: m.id, frame: next, angle: dir + turn };
|
||||
moved = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if rec != old {
|
||||
g.extras_mut().objects_on_path = Some(Box::new(rec));
|
||||
return true;
|
||||
}
|
||||
moved
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_geom::{Rect, shapes};
|
||||
|
||||
fn rect(id: u64, r: Rect) -> Node {
|
||||
Node::path(NodeId(id), shapes::rectangle(r), Default::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stops_spread_from_the_first_anchor() {
|
||||
let open = shapes::line(Point::new(0.0, 0.0), Point::new(300.0, 0.0));
|
||||
let s = stops(&open, 4).unwrap();
|
||||
let xs: Vec<f64> = s.iter().map(|(p, _)| p.x.round()).collect();
|
||||
assert_eq!(xs, [0.0, 100.0, 200.0, 300.0], "first at the start, last at the end of an open path");
|
||||
let closed = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0));
|
||||
let s = stops(&closed, 4).unwrap();
|
||||
let mut pts: Vec<(i64, i64)> = s.iter().map(|(p, _)| (p.x.round() as i64, p.y.round() as i64)).collect();
|
||||
pts.sort();
|
||||
assert_eq!(pts, [(0, 0), (0, 100), (100, 0), (100, 100)], "a closed path spreads them all the way round");
|
||||
assert!(stops(&shapes::line(Point::ZERO, Point::ZERO), 2).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upright_frames_round_the_rotation_to_a_right_angle() {
|
||||
let mut n = rect(1, Rect::new(0.0, 0.0, 10.0, 10.0));
|
||||
assert_eq!(upright_frame(&n), Affine::IDENTITY);
|
||||
n.bbox_angle = 90.0;
|
||||
assert_eq!(upright_frame(&n), Affine::IDENTITY);
|
||||
n.bbox_angle = 30.0;
|
||||
let f = upright_frame(&n);
|
||||
let [a0, a1, ..] = f.as_coeffs();
|
||||
assert!((a1.atan2(a0).to_degrees() + 30.0).abs() < 1e-9, "a frame turned 30° counter-clockwise");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_symbol_instance_is_placed_by_its_art() {
|
||||
// The art is a 40 × 10 box to the right of and below the origin, not the 20 × 20 square
|
||||
// around it that instances are measured by without the symbols.
|
||||
let mut d = crate::Document::new(400.0, 400.0);
|
||||
d.symbols.push(crate::Symbol { name: "Bar".into(), art: Arc::new(rect(9, Rect::new(0.0, 0.0, 40.0, 10.0))) });
|
||||
let line = Node::path(NodeId(1), shapes::line(Point::new(100.0, 300.0), Point::new(300.0, 300.0)), Default::default());
|
||||
let instance = Node::new(NodeId(2), NodeKind::SymbolInstance { symbol: "Bar".into(), xf: Affine::IDENTITY });
|
||||
let other = rect(3, Rect::new(0.0, 0.0, 10.0, 10.0));
|
||||
let members = [&instance, &other];
|
||||
let mut g = Node::new(
|
||||
NodeId(4),
|
||||
NodeKind::Group { children: vec![Arc::new(line), Arc::new(instance.clone()), Arc::new(other.clone())], clip: false },
|
||||
);
|
||||
g.extras_mut().objects_on_path = Some(Box::new(ObjectsOnPath::new(NodeId(1), &members, 7, 0.0)));
|
||||
let symbols = d.symbol_boxes();
|
||||
assert!(place(&mut g, &symbols));
|
||||
let placed = g.children().and_then(|c| c.iter().find(|c| c.id == NodeId(2)).cloned()).unwrap();
|
||||
let b = placed.geometric_bounds_with(&symbols).unwrap();
|
||||
assert!((b.y1 - 300.0).abs() < 1e-9 && (b.center().x - 100.0).abs() < 1e-9, "the art's bottom middle on the start: {b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_member_saved_without_its_angle_reads_the_angle_of_its_frame() {
|
||||
let m = Member { id: NodeId(2), frame: Affine::rotate(0.5), angle: 0.5 };
|
||||
let mut v = serde_json::to_value(m).unwrap();
|
||||
assert_eq!(serde_json::from_value::<Member>(v.clone()).unwrap(), m);
|
||||
v.as_object_mut().unwrap().remove("angle");
|
||||
let back: Member = serde_json::from_value(v).unwrap();
|
||||
assert!((back.angle - 0.5).abs() < 1e-12, "{back:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_without_its_path_is_a_plain_group() {
|
||||
let a = rect(2, Rect::new(0.0, 0.0, 10.0, 10.0));
|
||||
let mut g = Node::new(NodeId(3), NodeKind::Group { children: vec![Arc::new(a.clone())], clip: false });
|
||||
g.extras_mut().objects_on_path = Some(Box::new(ObjectsOnPath::new(NodeId(1), &[&a], CENTER, 0.0)));
|
||||
let d = crate::Document::new(100.0, 100.0);
|
||||
assert!(place(&mut g, &d.symbol_boxes()));
|
||||
assert!(!is_objects_on_path(&g));
|
||||
}
|
||||
}
|
||||
@@ -430,7 +430,7 @@ fn art_id() -> crate::NodeId {
|
||||
}
|
||||
|
||||
/// Visit `n` and every descendant (opacity-mask art included) mutably.
|
||||
fn each_mut(n: &mut Node, f: &mut impl FnMut(&mut Node)) {
|
||||
pub(crate) fn each_mut(n: &mut Node, f: &mut impl FnMut(&mut Node)) {
|
||||
f(n);
|
||||
if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) {
|
||||
each_mut(Arc::make_mut(&mut m.art), f);
|
||||
|
||||
@@ -98,6 +98,13 @@ fn units_are_named_by_label_key_or_suffix() {
|
||||
assert_eq!(Unit::named(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shared_suffix_names_the_plain_unit() {
|
||||
assert_eq!(Unit::named(Unit::Feet.suffix()), Some(Unit::Feet));
|
||||
assert_eq!(Unit::named("FT"), Some(Unit::Feet));
|
||||
assert_eq!(Unit::named(Unit::FeetInches.label()), Some(Unit::FeetInches));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readouts_show_two_decimals() {
|
||||
assert_eq!(Unit::Points.readout(15.0), "15.00 pt");
|
||||
|
||||
@@ -179,6 +179,18 @@ pub struct ScriptMetrics {
|
||||
|
||||
impl ScriptMetrics {
|
||||
pub const DEFAULT: Self = Self { size: 58.3, position: 33.3 };
|
||||
/// Returns these proportions with the size kept to 1 to 500% and the position kept to -500 to
|
||||
/// 500%, the ranges `document.setup` takes for `superscript` and `subscript`. A value outside
|
||||
/// its range becomes the nearer end.
|
||||
pub fn in_range(self) -> Self {
|
||||
Self { size: self.size.clamp(1.0, 500.0), position: self.position.clamp(-500.0, 500.0) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a small caps size kept to 1 to 200%, the range `document.setup` takes for
|
||||
/// `smallCapsSize`. A size outside it becomes the nearer end.
|
||||
fn small_caps_in_range(size: f64) -> f64 {
|
||||
size.clamp(1.0, 200.0)
|
||||
}
|
||||
|
||||
impl Default for ScriptMetrics {
|
||||
@@ -215,6 +227,14 @@ impl CharPosition {
|
||||
CharPosition::Subscript(_) => "subscript",
|
||||
}
|
||||
}
|
||||
/// Returns this position with its proportions kept by [`ScriptMetrics::in_range`].
|
||||
pub fn in_range(self) -> Self {
|
||||
match self {
|
||||
CharPosition::Normal => CharPosition::Normal,
|
||||
CharPosition::Superscript(m) => CharPosition::Superscript(m.in_range()),
|
||||
CharPosition::Subscript(m) => CharPosition::Subscript(m.in_range()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ten() -> f64 {
|
||||
@@ -273,6 +293,20 @@ impl CharStyle {
|
||||
pub fn effective_leading(&self) -> f64 {
|
||||
self.leading.unwrap_or(self.size * 1.2)
|
||||
}
|
||||
/// Keeps the size, leading, baseline shift and horizontal and vertical scales in range: a size
|
||||
/// or scale of 0 or less becomes the default new type takes (12 pt, 100%), a negative leading
|
||||
/// becomes Auto, and a value past [`crate::MAX_COORD`] (a baseline shift past it either way)
|
||||
/// becomes the nearer end. A leading of 0, which legacy `.ai` import stores, stays, and a
|
||||
/// baseline shift that is not a number becomes 0.
|
||||
pub fn keep_dimensions_in_range(&mut self) {
|
||||
let default = Self::default();
|
||||
let within = |v: f64, or: f64| if v > 0.0 { v.min(crate::MAX_COORD) } else { or };
|
||||
self.size = within(self.size, default.size);
|
||||
self.h_scale = within(self.h_scale, default.h_scale);
|
||||
self.v_scale = within(self.v_scale, default.v_scale);
|
||||
self.leading = self.leading.filter(|l| *l >= 0.0).map(|l| l.min(crate::MAX_COORD));
|
||||
self.baseline_shift = if self.baseline_shift.is_nan() { 0.0 } else { self.baseline_shift.clamp(-crate::MAX_COORD, crate::MAX_COORD) };
|
||||
}
|
||||
/// Do the characters draw a stroke (a paint and a positive weight)?
|
||||
pub fn has_stroke(&self) -> bool {
|
||||
!self.stroke.is_none() && self.stroke_width > 0.0
|
||||
@@ -333,6 +367,60 @@ impl CharStyle {
|
||||
pub fn basic_appearance(&self) -> Appearance {
|
||||
Appearance { items: vec![AppearanceItem::Fill(self.fill_layer()), AppearanceItem::Stroke(self.stroke_layer())], ..Default::default() }
|
||||
}
|
||||
/// Keeps the superscript or subscript proportions in range with [`CharPosition::in_range`] and
|
||||
/// keeps the small caps size to 1 to 200%, the range `document.setup` takes for `smallCapsSize`.
|
||||
pub fn keep_scripts_in_range(&mut self) {
|
||||
self.position = self.position.in_range();
|
||||
self.small_caps = self.small_caps.map(small_caps_in_range);
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::Document {
|
||||
/// Keeps the superscript, subscript and small caps proportions to the ranges `document.setup`
|
||||
/// takes ([`CharStyle::keep_scripts_in_range`]) in the type of the layers, symbols, graph
|
||||
/// designs, pattern swatches and the pattern definition that pattern editing's Cancel puts
|
||||
/// back (opacity-mask art included), in graphs' label styles and in the character styles. It
|
||||
/// recurses through the art: call it on a document no deeper than [`crate::depth::MAX_DEPTH`].
|
||||
pub fn keep_scripts_in_range(&mut self) {
|
||||
let mut fix = |n: &mut crate::Node| {
|
||||
if let crate::NodeKind::Text(t) = &mut n.kind {
|
||||
for r in &mut t.runs {
|
||||
r.style.keep_scripts_in_range();
|
||||
}
|
||||
}
|
||||
if let Some(g) = n.extras_if_any().and_then(|e| e.graph.as_deref_mut()) {
|
||||
let l = &mut g.label_styles;
|
||||
for st in [&mut l.values, &mut l.categories, &mut l.legend].into_iter().flatten() {
|
||||
st.keep_scripts_in_range();
|
||||
}
|
||||
}
|
||||
};
|
||||
let roots = self
|
||||
.layers
|
||||
.iter_mut()
|
||||
.chain(self.symbols.iter_mut().map(|s| &mut s.art))
|
||||
.chain(self.graph_designs.iter_mut().map(|g| &mut g.art))
|
||||
.chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut()))
|
||||
.chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut()));
|
||||
for root in roots {
|
||||
crate::placed_document::each_mut(std::sync::Arc::make_mut(root), &mut fix);
|
||||
}
|
||||
// Character styles store the attributes they set by their serialized names.
|
||||
for def in self.char_styles.iter_mut() {
|
||||
if let Some(v) = def.attrs.get_mut("position")
|
||||
&& let Ok(p) = serde_json::from_value::<CharPosition>(v.clone())
|
||||
&& p.in_range() != p
|
||||
{
|
||||
*v = serde_json::to_value(p.in_range()).unwrap_or(serde_json::Value::Null);
|
||||
}
|
||||
if let Some(v) = def.attrs.get_mut("small_caps")
|
||||
&& let Some(size) = v.as_f64()
|
||||
&& small_caps_in_range(size) != size
|
||||
{
|
||||
*v = small_caps_in_range(size).into();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tab stop alignment (Tabs panel).
|
||||
@@ -418,6 +506,98 @@ impl Default for HyphenationOptions {
|
||||
}
|
||||
}
|
||||
|
||||
/// How a justified line holding a single word is set ([`JustificationOptions::single_word`]).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum SingleWordJustify {
|
||||
/// Spread across the line, between its letters.
|
||||
#[default]
|
||||
FullJustify,
|
||||
AlignLeft,
|
||||
AlignCenter,
|
||||
AlignRight,
|
||||
}
|
||||
|
||||
/// A paragraph's justification options (Paragraph panel menu › Justification…), in percent.
|
||||
///
|
||||
/// Each spacing has a Minimum, a Desired and a Maximum value (`[min, desired, max]`). Desired
|
||||
/// applies to every line of the paragraph, justified or not: word spaces are that share of the
|
||||
/// font's word space, each letter is followed by that share of a word space, and glyphs are that
|
||||
/// wide. Justified lines may vary them between Minimum and Maximum to fill the measure. Saved only
|
||||
/// when they differ from the defaults: word spacing 80/100/133, letter spacing 0/0/0, glyph
|
||||
/// scaling 100/100/100, Auto Leading 120 and Full Justify.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct JustificationOptions {
|
||||
/// Word Spacing, percent of the font's word space (0..1000).
|
||||
pub word_spacing: [f64; 3],
|
||||
/// Letter Spacing, percent of the font's word space added after each letter (-100..500).
|
||||
pub letter_spacing: [f64; 3],
|
||||
/// Glyph Scaling, percent of the glyphs' width (50..200).
|
||||
pub glyph_scaling: [f64; 3],
|
||||
/// Auto Leading: the leading of characters whose leading is Auto, in percent of their size
|
||||
/// (0..500).
|
||||
pub auto_leading: f64,
|
||||
/// Single Word Justification: how a justified line holding one word is set.
|
||||
pub single_word: SingleWordJustify,
|
||||
}
|
||||
|
||||
impl JustificationOptions {
|
||||
/// Word Spacing's range (percent).
|
||||
pub const WORD_SPACING: (f64, f64) = (0.0, 1000.0);
|
||||
/// Letter Spacing's range (percent).
|
||||
pub const LETTER_SPACING: (f64, f64) = (-100.0, 500.0);
|
||||
/// Glyph Scaling's range (percent).
|
||||
pub const GLYPH_SCALING: (f64, f64) = (50.0, 200.0);
|
||||
/// Auto Leading's range (percent).
|
||||
pub const AUTO_LEADING: (f64, f64) = (0.0, 500.0);
|
||||
|
||||
/// Are Desired word spacing, letter spacing and glyph scaling the defaults (nothing to change
|
||||
/// in a line's natural widths)?
|
||||
pub fn desired_is_default(&self) -> bool {
|
||||
let d = Self::default();
|
||||
self.word_spacing[1] == d.word_spacing[1] && self.letter_spacing[1] == d.letter_spacing[1] && self.glyph_scaling[1] == d.glyph_scaling[1]
|
||||
}
|
||||
|
||||
/// Keeps options read from a file to what `text.setFormat` can set: a value that is not a
|
||||
/// finite number becomes its default, every value is clamped to its range, and a Minimum,
|
||||
/// Desired and Maximum out of order go back to their defaults together.
|
||||
pub fn keep_in_range(&mut self) {
|
||||
let d = Self::default();
|
||||
for (v, dv, (lo, hi)) in [
|
||||
(&mut self.word_spacing, d.word_spacing, Self::WORD_SPACING),
|
||||
(&mut self.letter_spacing, d.letter_spacing, Self::LETTER_SPACING),
|
||||
(&mut self.glyph_scaling, d.glyph_scaling, Self::GLYPH_SCALING),
|
||||
] {
|
||||
for (x, dx) in v.iter_mut().zip(dv) {
|
||||
*x = if x.is_finite() { x.clamp(lo, hi) } else { dx };
|
||||
}
|
||||
if !(v[0] <= v[1] && v[1] <= v[2]) {
|
||||
*v = dv;
|
||||
}
|
||||
}
|
||||
let (lo, hi) = Self::AUTO_LEADING;
|
||||
self.auto_leading = if self.auto_leading.is_finite() { self.auto_leading.clamp(lo, hi) } else { d.auto_leading };
|
||||
}
|
||||
|
||||
/// A paragraph's leading for characters of `size` whose leading is Auto.
|
||||
pub fn auto_leading_for(&self, size: f64) -> f64 {
|
||||
size * self.auto_leading / 100.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for JustificationOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
word_spacing: [80.0, 100.0, 133.0],
|
||||
letter_spacing: [0.0, 0.0, 0.0],
|
||||
glyph_scaling: [100.0, 100.0, 100.0],
|
||||
auto_leading: 120.0,
|
||||
single_word: SingleWordJustify::FullJustify,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Paragraph attributes (the Paragraph panel).
|
||||
///
|
||||
/// Saved through [`ParaStyleFile`], which keeps files with text openable by builds from before
|
||||
@@ -434,6 +614,9 @@ pub struct ParaStyle {
|
||||
pub hyphenate: bool,
|
||||
/// The limits automatic hyphenation keeps to (Paragraph panel menu › Hyphenation…).
|
||||
pub hyphenation: HyphenationOptions,
|
||||
/// Word and letter spacing, glyph scaling, Auto Leading and Single Word Justification
|
||||
/// (Paragraph panel menu › Justification…).
|
||||
pub justification: JustificationOptions,
|
||||
/// Line breaking: Single-line or Every-line Composer.
|
||||
pub composer: Composer,
|
||||
/// Tab stops (Tabs panel), sorted by position.
|
||||
@@ -458,6 +641,26 @@ pub struct ParaStyle {
|
||||
/// Kinsoku (Paragraph panel › Kinsoku Set): which Japanese characters may not start or end a
|
||||
/// line.
|
||||
pub kinsoku: Kinsoku,
|
||||
/// Roman Hanging Punctuation (Paragraph panel menu): quotation marks, hyphens, full stops and
|
||||
/// commas starting or ending a line hang outside the margin, and asterisks, tildes, ellipses,
|
||||
/// dashes, colons and semicolons hang by half. East Asian punctuation hangs by burasagari.
|
||||
pub roman_hanging: bool,
|
||||
}
|
||||
|
||||
impl ParaStyle {
|
||||
/// Keeps the indents and the space before and after to -[`crate::MAX_COORD`] to
|
||||
/// [`crate::MAX_COORD`], and the tab stop positions to 0 to 100,000 pt, the range
|
||||
/// `text.tabs.set` takes. A value outside its range becomes the nearer end, and one that is
|
||||
/// not a number becomes 0.
|
||||
pub fn keep_spacing_in_range(&mut self) {
|
||||
let within = |v: f64, lo: f64, hi: f64| if v.is_nan() { 0.0 } else { v.clamp(lo, hi) };
|
||||
for v in [&mut self.left_indent, &mut self.right_indent, &mut self.first_line_indent, &mut self.space_before, &mut self.space_after] {
|
||||
*v = within(*v, -crate::MAX_COORD, crate::MAX_COORD);
|
||||
}
|
||||
for t in &mut self.tabs {
|
||||
t.position = within(t.position, 0.0, 100_000.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`ParaStyle`] as saved. [`Justify::Auto`] is written as the alignment it has in the paragraph
|
||||
@@ -483,6 +686,8 @@ struct ParaStyleFile {
|
||||
hyphenate: bool,
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
hyphenation: HyphenationOptions,
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
justification: JustificationOptions,
|
||||
#[serde(default, skip_serializing_if = "Composer::is_default")]
|
||||
composer: Composer,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
@@ -499,6 +704,8 @@ struct ParaStyleFile {
|
||||
burasagari: Burasagari,
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
kinsoku: Kinsoku,
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
roman_hanging: bool,
|
||||
}
|
||||
|
||||
impl From<ParaStyle> for ParaStyleFile {
|
||||
@@ -517,6 +724,7 @@ impl From<ParaStyle> for ParaStyleFile {
|
||||
space_after,
|
||||
hyphenate,
|
||||
hyphenation,
|
||||
justification,
|
||||
composer,
|
||||
tabs,
|
||||
style_name,
|
||||
@@ -525,6 +733,7 @@ impl From<ParaStyle> for ParaStyleFile {
|
||||
leading_model,
|
||||
burasagari,
|
||||
kinsoku,
|
||||
roman_hanging,
|
||||
..
|
||||
} = p;
|
||||
Self {
|
||||
@@ -537,6 +746,7 @@ impl From<ParaStyle> for ParaStyleFile {
|
||||
space_after,
|
||||
hyphenate,
|
||||
hyphenation,
|
||||
justification,
|
||||
composer,
|
||||
tabs,
|
||||
style_name,
|
||||
@@ -545,6 +755,7 @@ impl From<ParaStyle> for ParaStyleFile {
|
||||
leading_model,
|
||||
burasagari,
|
||||
kinsoku,
|
||||
roman_hanging,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -552,6 +763,8 @@ impl From<ParaStyle> for ParaStyleFile {
|
||||
impl From<ParaStyleFile> for ParaStyle {
|
||||
fn from(f: ParaStyleFile) -> Self {
|
||||
let justify = if f.justify_auto { Justify::Auto } else { f.justify };
|
||||
let mut justification = f.justification;
|
||||
justification.keep_in_range();
|
||||
let ParaStyleFile {
|
||||
left_indent,
|
||||
right_indent,
|
||||
@@ -568,6 +781,7 @@ impl From<ParaStyleFile> for ParaStyle {
|
||||
leading_model,
|
||||
burasagari,
|
||||
kinsoku,
|
||||
roman_hanging,
|
||||
..
|
||||
} = f;
|
||||
Self {
|
||||
@@ -579,6 +793,7 @@ impl From<ParaStyleFile> for ParaStyle {
|
||||
space_after,
|
||||
hyphenate,
|
||||
hyphenation,
|
||||
justification,
|
||||
composer,
|
||||
tabs,
|
||||
style_name,
|
||||
@@ -587,6 +802,7 @@ impl From<ParaStyleFile> for ParaStyle {
|
||||
leading_model,
|
||||
burasagari,
|
||||
kinsoku,
|
||||
roman_hanging,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1361,6 +1577,87 @@ impl TextObject {
|
||||
});
|
||||
self.cached_baselines.iter().copied().chain(first)
|
||||
}
|
||||
/// Keeps every run's size, leading, baseline shift and scales in range
|
||||
/// ([`CharStyle::keep_dimensions_in_range`]) and every paragraph's indents, spacing and tab
|
||||
/// stops ([`ParaStyle::keep_spacing_in_range`]).
|
||||
pub fn keep_type_values_in_range(&mut self) {
|
||||
for r in &mut self.runs {
|
||||
r.style.keep_dimensions_in_range();
|
||||
}
|
||||
for p in self.para_styles_mut() {
|
||||
p.keep_spacing_in_range();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::Document {
|
||||
/// Keeps the character dimensions, indents, paragraph spacing and tab stops in range
|
||||
/// ([`TextObject::keep_type_values_in_range`]) in the type of the layers, symbols, graph
|
||||
/// designs, pattern swatches and the pattern definition that pattern editing's Cancel puts
|
||||
/// back (opacity-mask art included), and the same attributes of the character and paragraph
|
||||
/// styles. It recurses through the art: call it on a document no deeper than
|
||||
/// [`crate::depth::MAX_DEPTH`].
|
||||
pub fn keep_type_values_in_range(&mut self) {
|
||||
let mut fix = |n: &mut crate::Node| {
|
||||
if let crate::NodeKind::Text(t) = &mut n.kind {
|
||||
t.keep_type_values_in_range();
|
||||
}
|
||||
};
|
||||
let roots = self
|
||||
.layers
|
||||
.iter_mut()
|
||||
.chain(self.symbols.iter_mut().map(|s| &mut s.art))
|
||||
.chain(self.graph_designs.iter_mut().map(|g| &mut g.art))
|
||||
.chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut()))
|
||||
.chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut()));
|
||||
for root in roots {
|
||||
crate::placed_document::each_mut(std::sync::Arc::make_mut(root), &mut fix);
|
||||
}
|
||||
// Styles store the attributes they set by their serialized names (a number, or null for
|
||||
// Auto leading); a number is rewritten only when it changes.
|
||||
for def in self.char_styles.iter_mut() {
|
||||
let num = |k: &str| def.attrs.get(k).and_then(serde_json::Value::as_f64);
|
||||
let mut st = CharStyle { leading: num("leading"), ..CharStyle::default() };
|
||||
st.size = num("size").unwrap_or(st.size);
|
||||
st.h_scale = num("h_scale").unwrap_or(st.h_scale);
|
||||
st.v_scale = num("v_scale").unwrap_or(st.v_scale);
|
||||
st.baseline_shift = num("baseline_shift").unwrap_or(st.baseline_shift);
|
||||
st.keep_dimensions_in_range();
|
||||
let kept = [
|
||||
("size", Some(st.size)),
|
||||
("h_scale", Some(st.h_scale)),
|
||||
("v_scale", Some(st.v_scale)),
|
||||
("baseline_shift", Some(st.baseline_shift)),
|
||||
("leading", st.leading),
|
||||
];
|
||||
for (k, v) in kept {
|
||||
if let Some(old) = def.attrs.get_mut(k)
|
||||
&& old.is_number()
|
||||
&& old.as_f64() != v
|
||||
{
|
||||
*old = v.into();
|
||||
}
|
||||
}
|
||||
}
|
||||
for def in self.para_styles.iter_mut() {
|
||||
for k in ["left_indent", "right_indent", "first_line_indent", "space_before", "space_after"] {
|
||||
if let Some(v) = def.attrs.get_mut(k)
|
||||
&& let Some(x) = v.as_f64()
|
||||
&& x.abs() > crate::MAX_COORD
|
||||
{
|
||||
*v = x.clamp(-crate::MAX_COORD, crate::MAX_COORD).into();
|
||||
}
|
||||
}
|
||||
for t in def.attrs.get_mut("tabs").and_then(serde_json::Value::as_array_mut).into_iter().flatten() {
|
||||
if let Some(v) = t.get_mut("position")
|
||||
&& let Some(x) = v.as_f64()
|
||||
&& !(0.0..=100_000.0).contains(&x)
|
||||
{
|
||||
*v = x.clamp(0.0, 100_000.0).into();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1390,6 +1687,37 @@ mod tests {
|
||||
assert_eq!(serde_json::from_value::<CharStyle>(serde_json::to_value(&farsi).unwrap()).unwrap(), farsi);
|
||||
}
|
||||
|
||||
/// A file can't feed layout justification values `text.setFormat` would refuse: each is
|
||||
/// clamped to its range, one that isn't finite takes its default, and a Minimum, Desired and
|
||||
/// Maximum out of order go back to their defaults.
|
||||
#[test]
|
||||
fn justification_options_read_from_a_file_stay_in_range() {
|
||||
let d = JustificationOptions::default();
|
||||
let read: ParaStyle = serde_json::from_value(serde_json::json!({
|
||||
"justification": {
|
||||
"word_spacing": [-5.0, 1e300, 1e308], "letter_spacing": [10.0, 5.0, 20.0],
|
||||
"glyph_scaling": [1.0, 100.0, 9e9], "auto_leading": -1e12
|
||||
}
|
||||
}))
|
||||
.unwrap();
|
||||
let j = read.justification;
|
||||
assert_eq!(j.word_spacing, [0.0, 1000.0, 1000.0]);
|
||||
assert_eq!(j.letter_spacing, d.letter_spacing, "out of order");
|
||||
assert_eq!(j.glyph_scaling, [50.0, 100.0, 200.0]);
|
||||
assert_eq!(j.auto_leading, 0.0);
|
||||
let mut j = JustificationOptions {
|
||||
word_spacing: [f64::NAN, 100.0, f64::INFINITY],
|
||||
letter_spacing: [-1.0, 0.0, f64::NEG_INFINITY],
|
||||
auto_leading: f64::NAN,
|
||||
..d.clone()
|
||||
};
|
||||
j.keep_in_range();
|
||||
assert_eq!((j.word_spacing, j.letter_spacing, j.auto_leading), (d.word_spacing, [-1.0, 0.0, 0.0], d.auto_leading));
|
||||
let mut same = d.clone();
|
||||
same.keep_in_range();
|
||||
assert_eq!(same, d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_controls_reject_collapsed_and_nonfinite_transforms() {
|
||||
let mut t = TextObject::point(Point::ZERO, "Text", CharStyle::default());
|
||||
|
||||
@@ -48,15 +48,29 @@ enum Channel {
|
||||
|
||||
impl Channel {
|
||||
fn of(p: &Value) -> Self {
|
||||
match text(p, "channel", "rgb").to_ascii_lowercase().as_str() {
|
||||
"red" | "r" => Channel::One(0),
|
||||
"green" | "g" => Channel::One(1),
|
||||
"blue" | "b" => Channel::One(2),
|
||||
_ => Channel::All,
|
||||
Self::parse(text(p, "channel", "rgb")).unwrap_or(Channel::All)
|
||||
}
|
||||
|
||||
fn parse(s: &str) -> Option<Self> {
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"rgb" => Some(Channel::All),
|
||||
"red" | "r" => Some(Channel::One(0)),
|
||||
"green" | "g" => Some(Channel::One(1)),
|
||||
"blue" | "b" => Some(Channel::One(2)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The channels Curves and Levels apply to (`channel`), as their parameters list them.
|
||||
pub const CHANNELS: [&str; 4] = ["rgb", "red", "green", "blue"];
|
||||
|
||||
/// Do Curves and Levels read `s` as their `channel`: one of [`CHANNELS`], or r, g or b, in any
|
||||
/// case? They draw any other value on all three channels, as RGB.
|
||||
pub fn is_channel(s: &str) -> bool {
|
||||
Channel::parse(s).is_some()
|
||||
}
|
||||
|
||||
/// Entries of a sampled tone curve.
|
||||
const LUT: usize = 256;
|
||||
|
||||
|
||||
@@ -107,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, 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.
|
||||
// Type, images, symbol instances and live objects: reshaped through their outlines, and so are
|
||||
// type whose own fills or strokes have geometry effects and images whose own fills or strokes
|
||||
// paint a shape ([`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() || has_item_geometry(n)) {
|
||||
if let Some(m) = moved_type_group(n) {
|
||||
return Some(bake_pieces(d, m));
|
||||
|
||||
+43
-10
@@ -37,6 +37,7 @@ mod bake;
|
||||
mod clip;
|
||||
mod distort;
|
||||
mod group;
|
||||
mod lighting;
|
||||
mod live;
|
||||
mod marks;
|
||||
pub mod pixel;
|
||||
@@ -58,7 +59,10 @@ use serde_json::{Map, Value, json};
|
||||
use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_geom::{BezPath, FillRule, PathData, Rect};
|
||||
|
||||
pub use adjust::{ADJUSTMENTS, ColorMap, ImageHook, adjust, adjust_in_document, color_map, curve_at, curve_points, has_adjustment, is_adjustment};
|
||||
pub use adjust::{
|
||||
ADJUSTMENTS, CHANNELS, ColorMap, ImageHook, adjust, adjust_in_document, color_map, curve_at, curve_points, has_adjustment, is_adjustment,
|
||||
is_channel,
|
||||
};
|
||||
pub use bake::{StrokeArt, bake_appearance, bake_document, expand_art, expand_leaf, fresh_ids, needs_bake};
|
||||
pub use clip::clip_outline;
|
||||
pub use group::{
|
||||
@@ -69,7 +73,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::{RASTERIZE, RasterFx, Rasterize, outset, raster_effects};
|
||||
pub use reshape::{expand_outlined, has_gradient_copies, has_item_geometry, needs_outline, outline_art, outline_text, reshape};
|
||||
pub use reshape::{expand_outlined, has_gradient_copies, has_item_geometry, image_paint, 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 +153,7 @@ fn lengths_of(id: &str) -> Lengths {
|
||||
"texture.mosaicTiles" => always(&["tileSize"]),
|
||||
"texture.patchwork" => always(&["squareSize"]),
|
||||
"texture.stainedGlass" => always(&["cellSize"]),
|
||||
"sketch.graphicPen" => always(&["strokeLength"]),
|
||||
"sketch.halftonePattern" => always(&["size"]),
|
||||
RASTERIZE => always(&["addAround"]),
|
||||
_ => Lengths::default(),
|
||||
@@ -203,29 +208,29 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
REVOLVE,
|
||||
"Revolve…",
|
||||
&["Effect", "3D and Materials"],
|
||||
"{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), segments: integer (8..128, 64), shade: bool (true), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), cap: bool (true, close circular ends of open profiles; partial-sweep cut sides stay open), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance)}. Shaded surfaces use smooth vector gradients automatically; intersecting profiles use approximate painter visibility.",
|
||||
json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspectiveAngle":0.0,"segments":64,"shade":true,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"cap":true,"expandVisibleOnly":true}),
|
||||
"{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), segments: integer (8..128, 64), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), cap: bool (true, close circular ends of open profiles; partial-sweep cut sides stay open), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance)}. Shaded surfaces use smooth vector gradients automatically; intersecting profiles use approximate painter visibility.",
|
||||
json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspectiveAngle":0.0,"segments":64,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"cap":true,"expandVisibleOnly":true}),
|
||||
),
|
||||
g(
|
||||
EXTRUDE,
|
||||
"Extrude & Bevel…",
|
||||
&["Effect", "3D and Materials"],
|
||||
"{depth: pt (0..2000, 50), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelWidth: pt (0..1000, 4), bevelHeight: pt (0..1000, 4), bevelRepeat: integer (1..32, 1), bevelSpace: % (0..90, 0; flat between repeats), bevelInside: bool (false: added around the shape; true: carved into it), bevelBothSides: bool (false), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. The front face is the art, extruded backwards about the centre of its bounds; painted with the top fill (else the top stroke).",
|
||||
json!({"depth":50.0,"twist":0.0,"taper":100.0,"cap":true,"bevel":"none","bevelWidth":4.0,"bevelHeight":4.0,"bevelRepeat":1,"bevelSpace":0.0,"bevelInside":false,"bevelBothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}),
|
||||
"{depth: pt (0..2000, 50), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelWidth: pt (0..1000, 4), bevelHeight: pt (0..1000, 4), bevelRepeat: integer (1..32, 1), bevelSpace: % (0..90, 0; flat between repeats), bevelInside: bool (false: added around the shape; true: carved into it), bevelBothSides: bool (false), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. The front face is the art, extruded backwards about the centre of its bounds; painted with the top fill (else the top stroke).",
|
||||
json!({"depth":50.0,"twist":0.0,"taper":100.0,"cap":true,"bevel":"none","bevelWidth":4.0,"bevelHeight":4.0,"bevelRepeat":1,"bevelSpace":0.0,"bevelInside":false,"bevelBothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}),
|
||||
),
|
||||
g(
|
||||
INFLATE,
|
||||
"Inflate…",
|
||||
&["Effect", "3D and Materials"],
|
||||
"{depth: pt (0..2000, 0; flat depth between front and back), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: no back, a shell open behind), volume: % (0..100, 50; 50 rounds the edge like a half sphere), bothSides: bool (false: flat back), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. Painted with the top fill (else the top stroke).",
|
||||
json!({"depth":0.0,"twist":0.0,"taper":100.0,"cap":true,"volume":50.0,"bothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}),
|
||||
"{depth: pt (0..2000, 0; flat depth between front and back), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: no back, a shell open behind), volume: % (0..100, 50; 50 rounds the edge like a half sphere), bothSides: bool (false: flat back), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. Painted with the top fill (else the top stroke).",
|
||||
json!({"depth":0.0,"twist":0.0,"taper":100.0,"cap":true,"volume":50.0,"bothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}),
|
||||
),
|
||||
g(
|
||||
ROTATE,
|
||||
"Rotate…",
|
||||
&["Effect", "3D and Materials"],
|
||||
"{rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. Turns the flat art in 3D about the centre of its bounds; every fill and stroke keeps its paint, shaded as one plane.",
|
||||
json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}),
|
||||
"{rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. Turns the flat art in 3D about the centre of its bounds; every fill and stroke keeps its paint, shaded as one plane.",
|
||||
json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}),
|
||||
),
|
||||
g(
|
||||
"convertToShape.rectangle",
|
||||
@@ -358,6 +363,13 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"{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.plasticWrap",
|
||||
"Plastic Wrap…",
|
||||
ARTISTIC,
|
||||
"{highlightStrength: 0..20 (20; brighter specular highlights), detail: 0..15 (12; finer wrinkles in document space), smoothness: 1..15 (15; softer folds)} coats the object in shiny plastic, keeping its coverage. highlightStrength 0 leaves the art unchanged. Original visual approximation; chosen VectorCraft defaults.",
|
||||
json!({"highlightStrength": 20.0, "detail": 12.0, "smoothness": 15.0}),
|
||||
),
|
||||
r(
|
||||
"artistic.posterEdges",
|
||||
"Poster Edges…",
|
||||
@@ -463,6 +475,20 @@ 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.chalkCharcoal",
|
||||
"Chalk & Charcoal…",
|
||||
SKETCH,
|
||||
"{chalkArea: 0..20 (6; chalk in light areas), charcoalArea: 0..20 (6; charcoal in dark areas), strokePressure: 0..5 (2)} redraws the object with chalk highlights and charcoal strokes on white paper, keeping its coverage. Original visual approximation from public documentation; slider ranges and defaults are VectorCraft choices, not verified proprietary values.",
|
||||
json!({"chalkArea": 6.0, "charcoalArea": 6.0, "strokePressure": 2.0}),
|
||||
),
|
||||
r(
|
||||
"sketch.graphicPen",
|
||||
"Graphic Pen…",
|
||||
SKETCH,
|
||||
"{strokeLength: pt 1..15 (5; the longest ink stroke), lightDarkBalance: 0..100 (50; more black when higher), strokeDirection: \"rightDiagonal\"|\"horizontal\"|\"leftDiagonal\"|\"vertical\" (\"rightDiagonal\")} redraws the object in fine linear black ink on white paper, with strokes denser in darker areas. Control names and ranges follow the public documentation; the defaults are unverified.",
|
||||
json!({"strokeLength": 5.0, "lightDarkBalance": 50.0, "strokeDirection": "rightDiagonal"}),
|
||||
),
|
||||
r(
|
||||
"sketch.halftonePattern",
|
||||
"Halftone Pattern…",
|
||||
@@ -470,6 +496,13 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"{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(
|
||||
"sketch.reticulation",
|
||||
"Reticulation…",
|
||||
SKETCH,
|
||||
"{density: 0..50 (12; finer grain at higher values), foregroundLevel: 0..50 (6; pulls shadows toward black ink), backgroundLevel: 0..50 (6; pulls highlights toward white paper)} redraws the object in black ink on white paper through a high-contrast film-grain screen, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
|
||||
json!({"density": 12.0, "foregroundLevel": 6.0, "backgroundLevel": 6.0}),
|
||||
),
|
||||
r(
|
||||
"stylize.glowingEdges",
|
||||
"Glowing Edges…",
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
//! Shared conversion and vector paint shading for the 3D effects' directional lights.
|
||||
use serde_json::Value;
|
||||
use vectorcraft_doc::color::{Color, Paint};
|
||||
use vectorcraft_doc::{AppearanceItem, FillLayer};
|
||||
|
||||
fn parse(v: &Value) -> Option<Color> {
|
||||
let s = v.as_str()?;
|
||||
// Match the command's opaque RGB contract, before any parsing or allocation.
|
||||
(s.len() == 7 && s.starts_with('#') && s.as_bytes().iter().skip(1).all(u8::is_ascii_hexdigit)).then(|| Color::from_hex(s)).flatten()
|
||||
}
|
||||
|
||||
pub(crate) fn light_color(p: &Value) -> [f64; 3] {
|
||||
p.get("lightColor").and_then(parse).unwrap_or(Color::WHITE).to_rgb_uncalibrated().map(f64::from)
|
||||
}
|
||||
|
||||
pub(crate) fn validate(p: &Value) -> Result<(), String> {
|
||||
if p.get("lightColor").is_some_and(|v| parse(v).is_none()) {
|
||||
return Err("lightColor must be an opaque RGB color in #rrggbb form".into());
|
||||
}
|
||||
if let Some(list) = p.get("lights").filter(|v| !v.is_null()) {
|
||||
parse_lights(list)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn lights(p: &Value) -> Option<vectorcraft_three_d::Lights> {
|
||||
p.get("lights").filter(|v| !v.is_null()).and_then(|v| parse_lights(v).ok())
|
||||
}
|
||||
|
||||
fn parse_lights(v: &Value) -> Result<vectorcraft_three_d::Lights, String> {
|
||||
use vectorcraft_three_d::{Light, Lights, MAX_LIGHTS};
|
||||
let list = v.as_array().filter(|v| !v.is_empty() && v.len() <= MAX_LIGHTS).ok_or("lights must contain one to ten lights")?;
|
||||
let mut out = Vec::with_capacity(list.len());
|
||||
for item in list {
|
||||
let item = item.as_object().ok_or("each light must be an object")?;
|
||||
if item.keys().any(|key| !["color", "intensity", "rotation", "height", "softness", "behind"].contains(&key.as_str())) {
|
||||
return Err("unknown light setting: use color, intensity, rotation, height, softness or behind".into());
|
||||
}
|
||||
let defaults = Light::default();
|
||||
let number = |key: &str, default: f64, min: f64, max: f64| -> Result<f64, String> {
|
||||
match item.get(key) {
|
||||
None => Ok(default),
|
||||
Some(v) => v
|
||||
.as_f64()
|
||||
.filter(|n| n.is_finite() && *n >= min && *n <= max)
|
||||
.ok_or_else(|| format!("light {key} must be a number from {min} to {max}")),
|
||||
}
|
||||
};
|
||||
let color = match item.get("color") {
|
||||
None => [1.0; 3],
|
||||
Some(v) => parse(v).ok_or("light color must use opaque #rrggbb")?.to_rgb_uncalibrated().map(f64::from),
|
||||
};
|
||||
let behind = item.get("behind").map_or(Ok(false), |v| v.as_bool().ok_or("light behind must be true or false"))?;
|
||||
out.push(Light {
|
||||
color,
|
||||
behind,
|
||||
intensity: number("intensity", defaults.intensity * 100.0, 0.0, 100.0)? / 100.0,
|
||||
rotation: number("rotation", defaults.rotation, -180.0, 180.0)?,
|
||||
height: number("height", defaults.height, 0.0, 90.0)?,
|
||||
softness: number("softness", defaults.softness * 100.0, 0.0, 100.0)? / 100.0,
|
||||
});
|
||||
}
|
||||
Lights::new(&out)
|
||||
}
|
||||
|
||||
pub(crate) fn shade(c: Color, rgb: [f64; 3]) -> Color {
|
||||
let [r, g, b] = c.to_rgb();
|
||||
Color::rgb(r * rgb[0] as f32, g * rgb[1] as f32, b * rgb[2] as f32)
|
||||
}
|
||||
|
||||
pub(crate) fn smooth_paint(g: vectorcraft_three_d::LightGradient, color: Color) -> Paint {
|
||||
use vectorcraft_doc::color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||
let stops = g.stops().into_iter().map(|(at, rgb)| GradientStop::new(at, shade(color, rgb))).collect();
|
||||
let mut p = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops });
|
||||
p.geom = Some(GradientGeom { start: g.diffuse.start, end: g.diffuse.end, aspect: 1.0, focal: None });
|
||||
Paint::Gradient(Box::new(p))
|
||||
}
|
||||
|
||||
/// Multiply two independently placed vector gradients before applying the face's coverage
|
||||
/// once. Separately antialiasing the source and light along each triangle edge leaves seams.
|
||||
pub(crate) fn gradient_art(
|
||||
id: vectorcraft_doc::NodeId,
|
||||
path: &vectorcraft_geom::PathData,
|
||||
paint: &Paint,
|
||||
light: vectorcraft_three_d::LightGradient,
|
||||
) -> vectorcraft_doc::Node {
|
||||
composed_art(id, path, paint, Ramp::Single(light), 0.75)
|
||||
}
|
||||
|
||||
pub(crate) fn rgb_gradient_art(
|
||||
id: vectorcraft_doc::NodeId,
|
||||
path: &vectorcraft_geom::PathData,
|
||||
paint: &Paint,
|
||||
light: vectorcraft_three_d::RgbGradient,
|
||||
) -> vectorcraft_doc::Node {
|
||||
composed_art(id, path, paint, Ramp::Rgb(light), 0.75)
|
||||
}
|
||||
|
||||
/// Smooth Inflate triangles keep the seam within their narrow projected footprint.
|
||||
pub(crate) fn facet_art(
|
||||
id: vectorcraft_doc::NodeId,
|
||||
path: &vectorcraft_geom::PathData,
|
||||
paint: &Paint,
|
||||
light: vectorcraft_three_d::RgbGradient,
|
||||
seam: f64,
|
||||
) -> vectorcraft_doc::Node {
|
||||
let [r, g, b] = light.channels;
|
||||
if r == g && g == b {
|
||||
let light = vectorcraft_three_d::LightGradient { diffuse: r, ambient: 0.0, color: [1.0; 3] };
|
||||
if let Some(color) = paint.color() {
|
||||
let paint = smooth_paint(light, color);
|
||||
let mut edge = vectorcraft_doc::StrokeLayer::new(paint.clone(), seam);
|
||||
edge.join = vectorcraft_doc::LineJoin::Round;
|
||||
return vectorcraft_doc::Node::path(
|
||||
id,
|
||||
path.clone(),
|
||||
vectorcraft_doc::Appearance {
|
||||
items: vec![AppearanceItem::Fill(FillLayer::new(paint)), AppearanceItem::Stroke(edge)],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
return composed_art(id, path, paint, Ramp::Single(light), seam);
|
||||
}
|
||||
composed_art(id, path, paint, Ramp::Rgb(light), seam)
|
||||
}
|
||||
|
||||
/// Composite smooth solid-fill facets with one clip and three channel groups per surface.
|
||||
/// Black channel backdrops keep shared edges opaque before the final coverage is applied.
|
||||
pub(crate) fn solid_surface(id: vectorcraft_doc::NodeId, facets: &[vectorcraft_three_d::Facet], paint: &Paint) -> vectorcraft_doc::Node {
|
||||
use kurbo::Shape;
|
||||
use std::sync::Arc;
|
||||
use vectorcraft_doc::color::BlendMode;
|
||||
use vectorcraft_doc::{Appearance, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_geom::{BezPath, PathData, Rect, shapes};
|
||||
|
||||
let mut boundary = BezPath::new();
|
||||
let mut bounds = None;
|
||||
for face in facets {
|
||||
let path = face.path();
|
||||
if let Some(b) = path.bounds() {
|
||||
bounds = Some(bounds.map_or(b, |r: Rect| r.union(b)));
|
||||
}
|
||||
let mut contour = path.to_bezpath();
|
||||
if contour.area() < 0.0 {
|
||||
contour = contour.reverse_subpaths();
|
||||
}
|
||||
boundary.extend(contour.iter());
|
||||
}
|
||||
let clip = Node::path(id, PathData::from_bezpath(&boundary), Appearance::default());
|
||||
let bounds = bounds.unwrap_or(Rect::ZERO).inflate(1.0, 1.0);
|
||||
let cover = |paint| Node::path(id, shapes::rectangle(bounds), Appearance::basic(paint, Paint::None, 0.0));
|
||||
let channels = (0..3)
|
||||
.map(|channel| {
|
||||
let mut color = [0.0; 3];
|
||||
if let Some(value) = color.get_mut(channel) {
|
||||
*value = 1.0;
|
||||
}
|
||||
let mut children = vec![Arc::new(cover(Paint::solid(Color::BLACK)))];
|
||||
children.extend(facets.iter().map(|face| {
|
||||
let paint = if let Some(ramp) = face.rgb_gradient {
|
||||
smooth_paint(vectorcraft_three_d::LightGradient { diffuse: ramp.channels[channel], ambient: 0.0, color }, Color::WHITE)
|
||||
} else {
|
||||
Paint::solid(shade(Color::WHITE, color.map(|c| c * face.illumination[channel])))
|
||||
};
|
||||
let mut edge = StrokeLayer::new(paint.clone(), (face.thickness() / 2.0).clamp(0.05, 0.75));
|
||||
edge.join = vectorcraft_doc::LineJoin::Round;
|
||||
Arc::new(Node::path(
|
||||
id,
|
||||
face.path(),
|
||||
Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(paint)), AppearanceItem::Stroke(edge)], ..Default::default() },
|
||||
))
|
||||
}));
|
||||
let mut group = Node::group(id, children);
|
||||
group.isolate = true;
|
||||
group.blend = BlendMode::Screen;
|
||||
Arc::new(group)
|
||||
})
|
||||
.collect();
|
||||
let mut light = Node::group(id, channels);
|
||||
light.isolate = true;
|
||||
light.blend = BlendMode::Multiply;
|
||||
let mut colors = Node::group(id, vec![Arc::new(cover(paint.clone())), Arc::new(light)]);
|
||||
colors.isolate = true;
|
||||
Node::new(id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(colors)], clip: true })
|
||||
}
|
||||
|
||||
enum Ramp {
|
||||
Single(vectorcraft_three_d::LightGradient),
|
||||
Rgb(vectorcraft_three_d::RgbGradient),
|
||||
}
|
||||
|
||||
/// Separate a gradient's color and coverage so a tessellated surface can apply its opacity
|
||||
/// once, instead of building opacity at the overlapping seam strokes of adjacent faces.
|
||||
pub(crate) fn opaque_paint(paint: &Paint) -> (Paint, Option<Paint>) {
|
||||
let mut base = paint.clone();
|
||||
let mut mask = paint.clone();
|
||||
let mut transparent = false;
|
||||
if let (Paint::Gradient(b), Paint::Gradient(m)) = (&mut base, &mut mask) {
|
||||
b.swatch = None;
|
||||
m.swatch = None;
|
||||
for (b, m) in b.gradient.stops.iter_mut().zip(&mut m.gradient.stops) {
|
||||
transparent |= b.opacity != 1.0;
|
||||
b.opacity = 1.0;
|
||||
m.color = Color::WHITE;
|
||||
m.swatch = None;
|
||||
m.tint = 1.0;
|
||||
}
|
||||
if let (Some(b), Some(m)) = (&mut b.freeform, &mut m.freeform) {
|
||||
for (b, m) in b.points.iter_mut().zip(&mut m.points) {
|
||||
transparent |= b.opacity != 1.0;
|
||||
b.opacity = 1.0;
|
||||
m.color = Color::WHITE;
|
||||
}
|
||||
}
|
||||
}
|
||||
(base, transparent.then_some(mask))
|
||||
}
|
||||
|
||||
fn composed_art(id: vectorcraft_doc::NodeId, path: &vectorcraft_geom::PathData, paint: &Paint, light: Ramp, seam: f64) -> vectorcraft_doc::Node {
|
||||
use kurbo::Shape;
|
||||
use std::sync::Arc;
|
||||
use vectorcraft_doc::color::BlendMode;
|
||||
use vectorcraft_doc::{Appearance, Node, NodeKind, OpacityMask};
|
||||
use vectorcraft_geom::{Rect, shapes};
|
||||
let (base, mask) = opaque_paint(paint);
|
||||
// Clip once to the union of this single-contour face and its round seam stroke.
|
||||
// Align their winding so the nonzero fill adds coverage instead of cancelling it.
|
||||
let mut boundary = path.to_bezpath();
|
||||
let mut footprint = kurbo::stroke(boundary.iter(), &kurbo::Stroke::new(seam).with_join(kurbo::Join::Round), &kurbo::StrokeOpts::default(), 0.05);
|
||||
if boundary.area() * footprint.area() < 0.0 {
|
||||
boundary = boundary.reverse_subpaths();
|
||||
}
|
||||
footprint.extend(boundary.iter());
|
||||
let clip = Node::path(id, vectorcraft_geom::PathData::from_bezpath(&footprint), Appearance::default());
|
||||
// Opaque rectangles cover the complete seam stroke, without separately antialiasing
|
||||
// either ramp. Opaque sources need only vector clipping, not full-frame opacity masks.
|
||||
let bounds = path.bounds().unwrap_or(Rect::ZERO).inflate(1.0, 1.0);
|
||||
let cover = |paint| Node::path(id, shapes::rectangle(bounds), Appearance::basic(paint, Paint::None, 0.0));
|
||||
let mut illumination = match light {
|
||||
Ramp::Single(light) => cover(smooth_paint(light, Color::WHITE)),
|
||||
Ramp::Rgb(light) => {
|
||||
let channels = light
|
||||
.channels
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(channel, gradient)| {
|
||||
let mut color = [0.0; 3];
|
||||
if let Some(value) = color.get_mut(channel) {
|
||||
*value = 1.0;
|
||||
}
|
||||
let mut layer = cover(smooth_paint(vectorcraft_three_d::LightGradient { diffuse: *gradient, ambient: 0.0, color }, Color::WHITE));
|
||||
// Screen adds disjoint R, G and B channels exactly; no light is clamped
|
||||
// separately. Each ramp already holds the sum of all directional lights.
|
||||
layer.blend = BlendMode::Screen;
|
||||
Arc::new(layer)
|
||||
})
|
||||
.collect();
|
||||
let mut group = Node::group(id, channels);
|
||||
group.isolate = true;
|
||||
group
|
||||
}
|
||||
};
|
||||
illumination.blend = BlendMode::Multiply;
|
||||
let mut colors = Node::group(id, vec![Arc::new(cover(base)), Arc::new(illumination)]);
|
||||
colors.isolate = true;
|
||||
// Keep isolation inside the clip: the clip then applies to the finished colors,
|
||||
// rather than separately antialiasing each of the two gradient drawings.
|
||||
let mut face = Node::new(id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(colors)], clip: true });
|
||||
if let Some(mask) = mask {
|
||||
face.extras_mut().mask = Some(Box::new(OpacityMask::new(cover(mask), true)));
|
||||
}
|
||||
face
|
||||
}
|
||||
|
||||
pub(crate) fn fills(paint: &Paint, rgb: [f64; 3], brightness: f64, colored: bool) -> Vec<AppearanceItem> {
|
||||
let shade = |c: Color| shade(c, rgb);
|
||||
if let Some(c) = paint.color() {
|
||||
return vec![AppearanceItem::Fill(FillLayer::new(Paint::solid(shade(c))))];
|
||||
}
|
||||
// Keep the white-light gradient treatment identical to existing documents. Colored light
|
||||
// shades the ramp itself, keeping placement, stops, midpoint and opacity as vector data.
|
||||
if matches!(paint, Paint::Gradient(_)) && colored {
|
||||
let mut shaded = paint.clone();
|
||||
vectorcraft_doc::recolor::map_paint(&mut shaded, &shade);
|
||||
if let Paint::Gradient(g) = &mut shaded {
|
||||
g.swatch = None;
|
||||
}
|
||||
return vec![AppearanceItem::Fill(FillLayer::new(shaded))];
|
||||
}
|
||||
// Pattern tiles remain referenced artwork. Retain their existing neutral shadow
|
||||
// approximation; tinting arbitrary pattern contents needs document-level evaluation.
|
||||
let mut shadow = FillLayer::new(Paint::solid(Color::BLACK));
|
||||
shadow.opacity = (1.0 - brightness) as f32;
|
||||
vec![AppearanceItem::Fill(FillLayer::new(paint.clone())), AppearanceItem::Fill(shadow)]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
#[test]
|
||||
fn command_contract_is_strict_and_loaded_invalid_colors_fall_back_to_white() {
|
||||
assert_eq!(light_color(&json!({})), [1.0; 3]);
|
||||
assert_eq!(light_color(&json!({"lightColor":"#FF0000"})), [1.0, 0.0, 0.0]);
|
||||
for v in [json!("#ff0"), json!("#ffffff80"), json!("blue"), json!("#gg0000"), json!(null), json!([1, 0, 0])] {
|
||||
let p = json!({"lightColor":v});
|
||||
assert!(validate(&p).is_err(), "{p}");
|
||||
assert_eq!(light_color(&p), [1.0; 3]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_loaded_lists_keep_the_legacy_light_and_valid_lists_override_it() {
|
||||
for value in [json!([]), json!(vec![json!({}); 11]), json!([false]), json!([{"height": 91}]), json!([{"color":"#ff0"}])] {
|
||||
let p = json!({"lights": value, "lightColor":"#ff0000"});
|
||||
assert!(lights(&p).is_none());
|
||||
assert_eq!(light_color(&p), [1.0, 0.0, 0.0]);
|
||||
assert!(validate(&p).is_err());
|
||||
}
|
||||
let p = json!({"lights":[{}, {"color":"#0000ff","intensity":40}], "lightColor":"#ff0000"});
|
||||
let list = lights(&p).unwrap();
|
||||
assert_eq!(list.iter().count(), 2);
|
||||
assert_eq!(*list.iter().next().unwrap(), vectorcraft_three_d::Light::default());
|
||||
assert_eq!(list.iter().nth(1).unwrap().color, [0.0, 0.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colored_gradient_keeps_placement_alpha_and_midpoints() {
|
||||
let mut stop = GradientStop::new(0.0, Color::WHITE);
|
||||
stop.opacity = 0.4;
|
||||
stop.midpoint = 0.3;
|
||||
let g = GradientPaint {
|
||||
gradient: Gradient { kind: GradientKind::Linear, stops: vec![stop, GradientStop::new(1.0, Color::BLACK)] },
|
||||
geom: Some(GradientGeom { start: Point::new(20.0, 30.0), end: Point::new(100.0, 30.0), aspect: 1.0, focal: None }),
|
||||
angle: 0.0,
|
||||
swatch: None,
|
||||
freeform: None,
|
||||
};
|
||||
let paint = Paint::Gradient(Box::new(g.clone()));
|
||||
let items = fills(&paint, [1.0, 0.25, 0.25], 1.0, true);
|
||||
let Paint::Gradient(shaded) = items[0].paint() else { panic!("vector gradient") };
|
||||
assert_eq!(shaded.geom, g.geom);
|
||||
assert_eq!(shaded.gradient.stops[0].color.to_rgb(), [1.0, 0.25, 0.25]);
|
||||
assert_eq!(shaded.gradient.stops[0].opacity, 0.4);
|
||||
assert_eq!(shaded.gradient.stops[0].midpoint, 0.3);
|
||||
assert_eq!(shaded.gradient.stops[1].color, Color::BLACK);
|
||||
assert_eq!(paint, Paint::Gradient(Box::new(g)));
|
||||
let white = fills(&paint, [0.4; 3], 0.4, false);
|
||||
assert_eq!(white[0].paint(), &paint, "white retains the previous gradient treatment");
|
||||
assert_eq!(white.len(), 2);
|
||||
let unlit = fills(&paint, [0.25; 3], 0.25, true);
|
||||
assert_eq!(unlit.len(), 1, "neutral ambient under colored light still tints once");
|
||||
let mut freeform = if let Paint::Gradient(g) = paint { g } else { panic!("gradient") };
|
||||
freeform.gradient.kind = GradientKind::Freeform;
|
||||
freeform.gradient.stops = vec![GradientStop::new(0.0, Color::BLACK)];
|
||||
freeform.swatch = Some("Original ramp".into());
|
||||
freeform.freeform = Some(vectorcraft_doc::color::Freeform {
|
||||
points: vec![vectorcraft_doc::color::FreeformPoint::new(Point::new(12.0, 18.0), Color::WHITE)],
|
||||
..Default::default()
|
||||
});
|
||||
let original = Paint::Gradient(freeform);
|
||||
let items = fills(&original, [1.0, 0.25, 0.25], 1.0, true);
|
||||
let Paint::Gradient(shaded) = items[0].paint() else { panic!("freeform gradient") };
|
||||
let point = shaded.freeform.as_ref().unwrap().points[0];
|
||||
assert_eq!(point.at, Point::new(12.0, 18.0));
|
||||
assert_eq!(point.color.to_rgb(), [1.0, 0.25, 0.25]);
|
||||
assert_eq!(shaded.swatch, None, "a changed freeform point detaches the gradient swatch");
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Effect › Artistic (Cutout, Film Grain, Neon Glow, Poster Edges). Original filters built from
|
||||
//! Effect › Artistic (Cutout, Film Grain, Neon Glow, Plastic Wrap, 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>.
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
use super::edges::sobel;
|
||||
use super::pixelate::{Place, cell, rand};
|
||||
|
||||
const SALT_PLASTIC: u64 = 512;
|
||||
use super::texture::{luma, premultiply, straight};
|
||||
use super::{PixelSpace, blur_plane};
|
||||
use vectorcraft_geom::Point;
|
||||
@@ -165,7 +167,7 @@ fn planes(px: &[[u8; 4]]) -> ([Vec<f32>; 3], Vec<f32>) {
|
||||
/// 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) {
|
||||
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;
|
||||
@@ -212,7 +214,7 @@ pub(super) fn cutout(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace,
|
||||
/// 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) {
|
||||
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;
|
||||
@@ -239,6 +241,79 @@ pub(super) fn poster_edges(px: &mut [[u8; 4]], w: usize, h: usize, space: &Pixel
|
||||
}
|
||||
}
|
||||
|
||||
/// How far (document units) Plastic Wrap's softened relief is sampled at `smoothness` (1..15).
|
||||
pub(super) fn plastic_reach(smoothness: f64) -> f64 {
|
||||
3.0 * (0.15 + 0.35 * smoothness.clamp(1.0, 15.0))
|
||||
}
|
||||
|
||||
/// Plastic Wrap: the object under shiny plastic. Its tones form a relief; `detail` (0..15) adds
|
||||
/// finer wrinkles in document space; `smoothness` (1..15) softens the relief; specular highlights
|
||||
/// as strong as `highlight` (0..20) are screened over it. Coverage is kept.
|
||||
pub(super) fn plastic_wrap(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, highlight: f64, detail: f64, smoothness: f64) {
|
||||
if ![highlight, detail, smoothness].iter().all(|v| v.is_finite()) || !space.px.is_finite() || space.px <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let strength = (highlight / 20.0).clamp(0.0, 1.0) as f32;
|
||||
if strength == 0.0 {
|
||||
return;
|
||||
}
|
||||
let detail_amt = (detail / 15.0).clamp(0.0, 1.0) as f32;
|
||||
let sigma = (0.15 + 0.35 * smoothness.clamp(1.0, 15.0)) / space.px;
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let len = px.len();
|
||||
let mut height = vec![0.0f32; len];
|
||||
let alpha: Vec<f32> = px.iter().map(|p| f32::from(p[3]) / 255.0).collect();
|
||||
let cell_scale = 8.0 + 24.0 * (1.0 - detail_amt as f64);
|
||||
let mut noise = CellNoise::default();
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let i = y * w + x;
|
||||
let l = luma(straight(*p));
|
||||
let q = place.at(x, y);
|
||||
let nq = Point::new(q.x / cell_scale, q.y / cell_scale);
|
||||
height[i] = l + detail_amt * 0.35 * noise.at(SALT_PLASTIC, nq) as f32;
|
||||
});
|
||||
for (h, a) in height.iter_mut().zip(&alpha) {
|
||||
*h *= *a;
|
||||
}
|
||||
blur_plane(&mut height, w, h, sigma);
|
||||
let mut cov = alpha.clone();
|
||||
blur_plane(&mut cov, w, h, sigma);
|
||||
for (h, c) in height.iter_mut().zip(&cov) {
|
||||
*h = if *c > 1e-4 { *h / *c } else { 0.0 };
|
||||
}
|
||||
const LIGHT: [f32; 3] = [0.35, -0.25, 0.9];
|
||||
let inv = (LIGHT[0] * LIGHT[0] + LIGHT[1] * LIGHT[1] + LIGHT[2] * LIGHT[2]).sqrt().max(1e-6);
|
||||
let light = LIGHT.map(|v| v / inv);
|
||||
let scale = (8.0 * space.px).max(1e-6) as f32;
|
||||
let sample = |x: isize, y: isize| -> f32 {
|
||||
if x < 0 || y < 0 || x >= w as isize || y >= h as isize {
|
||||
return 0.0;
|
||||
}
|
||||
height[y as usize * w + x as usize]
|
||||
};
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let gx = sample(x as isize + 1, y as isize) - sample(x as isize - 1, y as isize);
|
||||
let gy = sample(x as isize, y as isize + 1) - sample(x as isize, y as isize - 1);
|
||||
let nx = -gx / scale;
|
||||
let ny = -gy / scale;
|
||||
let nz = 1.0;
|
||||
let nlen = (nx * nx + ny * ny + nz * nz).sqrt().max(1e-6);
|
||||
let ndotl = (nx / nlen * light[0] + ny / nlen * light[1] + nz / nlen * light[2]).max(0.0);
|
||||
let spec = ndotl.powi(12) * strength;
|
||||
if spec <= 1e-5 {
|
||||
return;
|
||||
}
|
||||
let c = straight(*p).map(|v| 1.0 - (1.0 - v) * (1.0 - spec));
|
||||
*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)
|
||||
@@ -248,7 +323,7 @@ pub(super) fn glow_spread(size: f64) -> f64 {
|
||||
/// 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) {
|
||||
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;
|
||||
|
||||
@@ -516,3 +516,40 @@ pub(super) fn sumi_e(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace,
|
||||
*p = premultiply(out, f32::from(p[3]) / 255.0);
|
||||
});
|
||||
}
|
||||
|
||||
const SALT_GRAPHIC: u64 = 200;
|
||||
|
||||
/// Graphic Pen: the object redrawn in fine linear ink strokes along `dir`, each `len` (1..15)
|
||||
/// points long on white paper; `balance` (0..100) sets the tone threshold (higher draws more
|
||||
/// black ink).
|
||||
pub(super) fn graphic_pen(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, len: f64, balance: f64, dir: StrokeDirection) {
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let len = if len.is_finite() { len.clamp(1.0, 15.0) } else { 1.0 };
|
||||
let smear = Smear::new(px, w, h, &place, len);
|
||||
let tones = Tones::new(px, w, h, TONE_SOFTNESS / place.px);
|
||||
let threshold = 1.0 - balance.clamp(0.0, 100.0) / 100.0;
|
||||
let row = (0.2 * len).max(0.75);
|
||||
let along = dir.along();
|
||||
place.each(px, |x, y, p| {
|
||||
let a = f32::from(p[3]) / 255.0;
|
||||
if a <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let q = place.at(x, y);
|
||||
let (c, stroke_a) = unpremultiply(smear.stroke(SALT_GRAPHIC, q, along, row));
|
||||
let dark = (1.0 - tones.get(x, y)).max(f64::from(1.0 - luma(c)) * f64::from(stroke_a));
|
||||
if dark <= threshold {
|
||||
*p = premultiply([1.0, 1.0, 1.0], a);
|
||||
return;
|
||||
}
|
||||
let weight = ramp((dark - threshold) / (1.0 - threshold).max(1e-3));
|
||||
let u = frame(q, along).0;
|
||||
let on_stroke = {
|
||||
let t = (u / len).rem_euclid(1.0);
|
||||
if t < 0.65 { 1.0 } else { 0.0 }
|
||||
};
|
||||
let grain = 0.08 * streak(SALT_GRAPHIC + 1, q, along, 0.35 * len, 0.5);
|
||||
let ink = (weight * on_stroke + grain).clamp(0.0, 1.0);
|
||||
*p = premultiply([(1.0 - ink) as f32; 3], a);
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
//! 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, Sketch › Halftone Pattern, Stylize ›
|
||||
//! Photoshop-style raster effects (Effect › Artistic › Cutout, Film Grain, Neon Glow, Plastic Wrap 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, Sketch › Chalk & Charcoal, Graphic Pen, Halftone Pattern and
|
||||
//! Reticulation, 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.
|
||||
@@ -39,10 +41,11 @@ 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; 32] = [
|
||||
pub const PIXEL_EFFECTS: [&str; 36] = [
|
||||
"artistic.cutout",
|
||||
"artistic.filmGrain",
|
||||
"artistic.neonGlow",
|
||||
"artistic.plasticWrap",
|
||||
"artistic.posterEdges",
|
||||
"blur.radial",
|
||||
"blur.smart",
|
||||
@@ -62,7 +65,10 @@ pub const PIXEL_EFFECTS: [&str; 32] = [
|
||||
"pixelate.mezzotint",
|
||||
"pixelate.pointillize",
|
||||
"sharpen.unsharpMask",
|
||||
"sketch.chalkCharcoal",
|
||||
"sketch.graphicPen",
|
||||
"sketch.halftonePattern",
|
||||
"sketch.reticulation",
|
||||
"stylize.glowingEdges",
|
||||
"texture.craquelure",
|
||||
"texture.grain",
|
||||
@@ -88,6 +94,9 @@ pub enum PixelFx {
|
||||
/// 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 › Plastic Wrap: specular highlights as strong as `highlight` (0..20) on a relief
|
||||
/// wrinkled by `detail` (0..15) and softened by `smoothness` (1..15).
|
||||
PlasticWrap { highlight: f64, detail: f64, smoothness: f64 },
|
||||
/// 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 },
|
||||
@@ -139,9 +148,18 @@ 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 › Chalk & Charcoal: chalk highlights in light areas and charcoal in dark ones;
|
||||
/// `chalk` and `charcoal` (0..20) set how much, `pressure` (0..5) the stroke weight.
|
||||
ChalkCharcoal { chalk: f64, charcoal: f64, pressure: f64 },
|
||||
/// Sketch › Graphic Pen: fine linear ink strokes along `dir`, each `length` (1..15) points long
|
||||
/// on white paper; `balance` (0..100) sets how much of the tone range becomes black ink.
|
||||
GraphicPen { length: f64, balance: f64, direction: StrokeDirection },
|
||||
/// 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 },
|
||||
/// Sketch › Reticulation: black ink on white paper through a high-contrast film-grain screen;
|
||||
/// `density` (0..50), `foreground` and `background` (0..50) pull tones before the grain.
|
||||
Reticulation { density: f64, foreground: f64, background: f64 },
|
||||
/// 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
|
||||
@@ -242,6 +260,11 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
|
||||
highlight: num(p, "highlightArea", 0.0).clamp(0.0, 20.0),
|
||||
intensity: num(p, "intensity", 10.0).clamp(0.0, 10.0),
|
||||
},
|
||||
"artistic.plasticWrap" => PixelFx::PlasticWrap {
|
||||
highlight: num(p, "highlightStrength", 20.0).clamp(0.0, 20.0),
|
||||
detail: num(p, "detail", 12.0).clamp(0.0, 15.0),
|
||||
smoothness: num(p, "smoothness", 15.0).clamp(1.0, 15.0),
|
||||
},
|
||||
"blur.radial" => PixelFx::RadialBlur {
|
||||
amount: num(p, "amount", 10.0).clamp(1.0, 100.0),
|
||||
zoom: text(p, "method", "spin").eq_ignore_ascii_case("zoom"),
|
||||
@@ -311,11 +334,26 @@ 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.chalkCharcoal" => PixelFx::ChalkCharcoal {
|
||||
chalk: num(p, "chalkArea", 6.0).clamp(0.0, 20.0),
|
||||
charcoal: num(p, "charcoalArea", 6.0).clamp(0.0, 20.0),
|
||||
pressure: num(p, "strokePressure", 2.0).clamp(0.0, 5.0),
|
||||
},
|
||||
"sketch.graphicPen" => PixelFx::GraphicPen {
|
||||
length: num(p, "strokeLength", 5.0).clamp(1.0, 15.0),
|
||||
balance: num(p, "lightDarkBalance", 50.0).clamp(0.0, 100.0),
|
||||
direction: StrokeDirection::parse(text(p, "strokeDirection", "rightDiagonal")),
|
||||
},
|
||||
"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")),
|
||||
},
|
||||
"sketch.reticulation" => PixelFx::Reticulation {
|
||||
density: num(p, "density", 12.0).clamp(0.0, 50.0),
|
||||
foreground: num(p, "foregroundLevel", 6.0).clamp(0.0, 50.0),
|
||||
background: num(p, "backgroundLevel", 6.0).clamp(0.0, 50.0),
|
||||
},
|
||||
"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),
|
||||
@@ -381,7 +419,9 @@ impl PixelFx {
|
||||
PixelFx::SmartBlur { radius, .. } => radius,
|
||||
PixelFx::AccentedEdges { .. } => 0.0,
|
||||
// Strokes reach out of the object as far as they reach into it.
|
||||
PixelFx::AngledStrokes { length, .. } | PixelFx::InkOutlines { length, .. } => brushstrokes::stroke_reach(length),
|
||||
PixelFx::AngledStrokes { length, .. } | PixelFx::GraphicPen { length, .. } | PixelFx::InkOutlines { length, .. } => {
|
||||
brushstrokes::stroke_reach(length)
|
||||
}
|
||||
PixelFx::Crosshatch { length, .. } => brushstrokes::stroke_reach(length) + brushstrokes::rough(length),
|
||||
PixelFx::DarkStrokes { .. } => brushstrokes::stroke_reach(brushstrokes::LIGHT_LENGTH),
|
||||
PixelFx::Spatter { radius, .. } => brushstrokes::spatter_shift(radius),
|
||||
@@ -392,11 +432,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 { .. } | PixelFx::HalftonePattern { .. } => 0.0,
|
||||
PixelFx::UnsharpMask { .. } | PixelFx::ChalkCharcoal { .. } | PixelFx::HalftonePattern { .. } | PixelFx::Reticulation { .. } => 0.0,
|
||||
PixelFx::GlowingEdges { width, smoothness, .. } => width.max(smoothness * 3.0),
|
||||
PixelFx::FilmGrain { .. }
|
||||
| PixelFx::Cutout { .. }
|
||||
| PixelFx::NeonGlow { .. }
|
||||
| PixelFx::PlasticWrap { .. }
|
||||
| PixelFx::PosterEdges { .. }
|
||||
| PixelFx::ColorHalftone { .. }
|
||||
| PixelFx::Mezzotint { .. }
|
||||
@@ -422,7 +463,9 @@ impl PixelFx {
|
||||
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)),
|
||||
PixelFx::AngledStrokes { length, .. } | PixelFx::GraphicPen { length, .. } => {
|
||||
Some(brushstrokes::stroke_reach(length).max(brushstrokes::TONE_REACH))
|
||||
}
|
||||
PixelFx::Crosshatch { length, .. } => {
|
||||
Some((brushstrokes::stroke_reach(length) + brushstrokes::rough(length)).max(brushstrokes::TONE_REACH))
|
||||
}
|
||||
@@ -439,11 +482,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 { .. } | PixelFx::HalftonePattern { .. } => Some(0.0),
|
||||
PixelFx::ChalkCharcoal { .. } => Some(sketch::CHALK_CHARCOAL_REACH),
|
||||
PixelFx::FilmGrain { .. } | PixelFx::Mezzotint { .. } | PixelFx::HalftonePattern { .. } | PixelFx::Reticulation { .. } => 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)),
|
||||
PixelFx::PlasticWrap { smoothness, .. } => Some(artistic::plastic_reach(smoothness)),
|
||||
// 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.
|
||||
@@ -468,6 +513,7 @@ impl PixelFx {
|
||||
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::PlasticWrap { highlight, detail, smoothness } => artistic::plastic_wrap(px, w, h, space, highlight, detail, smoothness),
|
||||
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)
|
||||
@@ -493,7 +539,10 @@ 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::ChalkCharcoal { chalk, charcoal, pressure } => sketch::chalk_charcoal(px, w, h, space, chalk, charcoal, pressure),
|
||||
PixelFx::GraphicPen { length, balance, direction } => brushstrokes::graphic_pen(px, w, h, space, length, balance, direction),
|
||||
PixelFx::HalftonePattern { size, contrast, kind } => sketch::halftone_pattern(px, w, h, space, size, contrast, kind),
|
||||
PixelFx::Reticulation { density, foreground, background } => sketch::reticulation(px, w, h, space, density, foreground, background),
|
||||
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),
|
||||
@@ -594,6 +643,45 @@ pub fn blur_plane(a: &mut Vec<f32>, w: usize, h: usize, sigma: f64) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Grow a `w` × `h` plane by `radius` pixels each way, the box of SVG's feMorphology: each value
|
||||
/// becomes the largest within the radius. A fractional radius blends the two whole radii around
|
||||
/// it, so a silhouette grows smoothly as the raster scales. Does nothing for a radius of zero or
|
||||
/// less, or when the sizes don't match.
|
||||
pub fn dilate_plane(a: &mut Vec<f32>, w: usize, h: usize, radius: f64) {
|
||||
if radius.is_nan() || radius <= 0.0 || w == 0 || h == 0 || w.checked_mul(h) != Some(a.len()) {
|
||||
return;
|
||||
}
|
||||
let radius = radius.min(MAX_BOX as f64);
|
||||
let whole = radius.floor() as usize;
|
||||
let frac = (radius - whole as f64) as f32;
|
||||
let mut tmp = vec![0.0; a.len()];
|
||||
for (len, other) in [(w, h), (h, w)] {
|
||||
max_plane(a, &mut tmp, len, whole, frac);
|
||||
std::mem::swap(a, &mut tmp);
|
||||
transpose(a, &mut tmp, len, other);
|
||||
std::mem::swap(a, &mut tmp);
|
||||
}
|
||||
}
|
||||
|
||||
/// One horizontal pass of [`dilate_plane`] over the rows of `w` values of a plane: the largest
|
||||
/// value within `whole`, blended by `frac` towards the largest within `whole + 1`.
|
||||
fn max_plane(src: &[f32], dst: &mut [f32], w: usize, whole: usize, frac: f32) {
|
||||
for (row, out) in src.chunks_exact(w).zip(dst.chunks_exact_mut(w)) {
|
||||
for (x, o) in out.iter_mut().enumerate() {
|
||||
let lo = x.saturating_sub(whole + 1);
|
||||
let hi = (x + whole + 1).min(w - 1);
|
||||
let (mut inner, mut ring) = (row[x], row[x]);
|
||||
for (j, v) in row[lo..=hi].iter().enumerate() {
|
||||
if (lo + j).abs_diff(x) <= whole {
|
||||
inner = inner.max(*v);
|
||||
}
|
||||
ring = ring.max(*v);
|
||||
}
|
||||
*o = inner + (ring - inner) * frac;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Premultiplied RGBA at 16 bits (×257): blurs and averages that keep their tones.
|
||||
pub(super) type Px16 = [u16; 4];
|
||||
|
||||
|
||||
@@ -1,12 +1,37 @@
|
||||
//! Effect › Sketch › Halftone Pattern. Original filter built from public behaviour documentation:
|
||||
//! Effect › Sketch (Chalk & Charcoal, Halftone Pattern and Reticulation; Graphic Pen lives in
|
||||
//! [`super::brushstrokes`]). Original filters 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.
|
||||
//! black and white) and the public Photoshop Elements Artistic filters guide. Visual
|
||||
//! approximations, not proprietary reproductions.
|
||||
|
||||
use super::pixelate::Place;
|
||||
use super::texture::{luma, premultiply, straight};
|
||||
use super::{PixelSpace, pick};
|
||||
use std::f64::consts::TAU;
|
||||
|
||||
use super::edges::sobel;
|
||||
use super::pixelate::{Place, cell, rand, value_noise};
|
||||
use super::texture::{Tones, luma, premultiply, straight};
|
||||
use super::{PixelSpace, blur_plane, pick};
|
||||
|
||||
const SALT_CHALK: u64 = 216;
|
||||
const EDGE_SOFT: f64 = 0.8;
|
||||
const TONE_SOFT: f64 = 1.0;
|
||||
|
||||
/// How far (document units) Chalk & Charcoal samples softened tones and edges.
|
||||
pub(super) const CHALK_CHARCOAL_REACH: f64 = 3.0 * TONE_SOFT + 1.0 + 3.0 * EDGE_SOFT;
|
||||
|
||||
fn ramp(t: f64) -> f64 {
|
||||
t.clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
fn tone_edges(px: &[[u8; 4]], w: usize, h: usize, place: &Place, sigma: f64) -> Vec<f32> {
|
||||
let mut tone: Vec<f32> = px.iter().map(|p| luma(straight(*p)) * f32::from(p[3]) / 255.0).collect();
|
||||
let mut cover: Vec<f32> = px.iter().map(|p| f32::from(p[3]) / 255.0).collect();
|
||||
let s = sigma / place.px;
|
||||
blur_plane(&mut tone, w, h, s);
|
||||
blur_plane(&mut cover, w, h, s);
|
||||
let norm = (s.max(0.8) * TAU.sqrt() / 8.0) as f32;
|
||||
let norm = if norm.is_finite() { norm } else { 0.0 };
|
||||
sobel(&tone, w, h).iter().zip(sobel(&cover, w, h)).map(|(t, c)| t.max(c) * norm).collect()
|
||||
}
|
||||
|
||||
/// 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")];
|
||||
@@ -69,3 +94,79 @@ pub(super) fn halftone_pattern(px: &mut [[u8; 4]], w: usize, h: usize, space: &P
|
||||
*p = premultiply([(1.0 - ink) as f32; 3], f32::from(p[3]) / 255.0);
|
||||
});
|
||||
}
|
||||
|
||||
/// Chalk & Charcoal: light areas get chalky highlights and dark areas charcoal strokes and grain;
|
||||
/// `chalk` and `charcoal` (0..20) set how strongly, `pressure` (0..5) the stroke weight. Black
|
||||
/// chalk and charcoal on white paper, coverage kept.
|
||||
pub(super) fn chalk_charcoal(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, chalk: f64, charcoal: f64, pressure: f64) {
|
||||
if !(chalk.is_finite() && charcoal.is_finite() && pressure.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let scale = (pressure.clamp(0.0, 5.0) / 5.0).clamp(0.0, 1.0);
|
||||
let chalk_amt = (0.05 * chalk.clamp(0.0, 20.0) * scale).clamp(0.0, 1.0);
|
||||
let charcoal_amt = (0.05 * charcoal.clamp(0.0, 20.0) * scale).clamp(0.0, 1.0);
|
||||
if chalk_amt <= 0.0 && charcoal_amt <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let tones = Tones::new(px, w, h, TONE_SOFT / place.px);
|
||||
let edges = tone_edges(px, w, h, &place, EDGE_SOFT);
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let q = place.at(x, y);
|
||||
let tone = tones.get(x, y);
|
||||
let dark = (1.0 - tone).clamp(0.0, 1.0);
|
||||
let light = tone.clamp(0.0, 1.0);
|
||||
let edge = f64::from(edges.get(y * w + x).copied().unwrap_or(0.0));
|
||||
let charcoal_stroke = value_noise(SALT_CHALK + 1, q.x * 1.1, q.y * 0.12);
|
||||
let charcoal_grain = value_noise(SALT_CHALK, q.x * 0.35, q.y * 0.35);
|
||||
let charcoal_mask = ramp((dark - 0.3) / 0.22)
|
||||
* charcoal_amt
|
||||
* (0.35 + 0.65 * edge)
|
||||
* (0.45 + 0.55 * ramp(charcoal_stroke * 2.2 - 0.55))
|
||||
* (0.6 + 0.4 * charcoal_grain);
|
||||
let chalk_grain = value_noise(SALT_CHALK + 2, q.x * 0.45 + 7.0, q.y * 0.45);
|
||||
let chalk_mask = ramp((light - 0.5) / 0.22) * chalk_amt * (0.55 + 0.45 * chalk_grain);
|
||||
let down = (0.9 * charcoal_mask).clamp(0.0, 1.0) as f32;
|
||||
let up = (0.8 * chalk_mask).clamp(0.0, 1.0) as f32;
|
||||
let mut grey = luma(straight(*p));
|
||||
grey *= 1.0 - down;
|
||||
grey += (1.0 - grey) * up;
|
||||
*p = premultiply([grey, grey, grey], f32::from(p[3]) / 255.0);
|
||||
});
|
||||
}
|
||||
|
||||
/// Reticulation: the object redrawn in black ink on white paper through a high-contrast film-grain
|
||||
/// screen. `density` (0..50) sets how fine the grain is; `foreground` and `background` (0..50 each)
|
||||
/// pull shadows toward black and highlights toward white before the grain decides ink. Coverage is
|
||||
/// kept.
|
||||
pub(super) fn reticulation(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, density: f64, foreground: f64, background: f64) {
|
||||
if !(density.is_finite() && foreground.is_finite() && background.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
// Finer grain at higher density: cells from about 11 pt down to 1 pt.
|
||||
let cell_size = 1.0 + (50.0 - density.clamp(0.0, 50.0)) * 0.2;
|
||||
let fg = foreground.clamp(0.0, 50.0) / 50.0;
|
||||
let bg = background.clamp(0.0, 50.0) / 50.0;
|
||||
let spread = 0.12 + 0.38 * (density.clamp(0.0, 50.0) / 50.0);
|
||||
const SALT: u64 = 0x5245_7469;
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let q = place.at(x, y);
|
||||
let dark = f64::from(1.0 - luma(straight(*p))).clamp(0.0, 1.0);
|
||||
let tone = (dark + fg * (1.0 - dark) * 0.65 - bg * dark * 0.65).clamp(0.0, 1.0);
|
||||
let coarse = value_noise(SALT, q.x / cell_size, q.y / cell_size);
|
||||
let fine = match (cell(q.x), cell(q.y)) {
|
||||
(Some(i), Some(j)) => rand(SALT + 1, i, j, 2),
|
||||
_ => 0.5,
|
||||
};
|
||||
let grain = 0.55 * coarse + 0.45 * fine;
|
||||
let ink = if tone + (grain - 0.5) * spread > 0.5 { 1.0 } else { 0.0 };
|
||||
*p = premultiply([(1.0 - ink) as f32; 3], f32::from(p[3]) / 255.0);
|
||||
});
|
||||
}
|
||||
@@ -41,6 +41,26 @@ fn fx(id: &str, p: serde_json::Value) -> PixelFx {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dilate_plane_grows_by_its_radius_and_blends_a_fraction() {
|
||||
// GUARDS: an Outer Glow grows its silhouette by a quarter raster pixel before the blur; a step in
|
||||
// that growth as the raster scales would show as the glow's width jumping with the zoom.
|
||||
let mut dot = vec![0.0f32; 7 * 7];
|
||||
dot[3 * 7 + 3] = 255.0;
|
||||
let mut grown = dot.clone();
|
||||
dilate_plane(&mut grown, 7, 7, 1.0);
|
||||
assert_eq!(grown[2 * 7 + 2], 255.0);
|
||||
assert_eq!(grown[7 + 3], 0.0);
|
||||
let mut half = dot.clone();
|
||||
dilate_plane(&mut half, 7, 7, 1.5);
|
||||
assert_eq!(half[3 * 7 + 4], 255.0);
|
||||
assert_eq!(half[3 * 7 + 5], 127.5);
|
||||
assert_eq!(half[3 * 7 + 6], 0.0);
|
||||
let mut same = dot.clone();
|
||||
dilate_plane(&mut same, 7, 7, 0.0);
|
||||
assert_eq!(same, dot);
|
||||
}
|
||||
|
||||
fn grey(v: u8) -> [u8; 4] {
|
||||
[v, v, v, 255]
|
||||
}
|
||||
@@ -1978,6 +1998,47 @@ fn brush_strokes_follow_the_object_at_any_resolution() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Chalk & Charcoal of `p` over `w` × `h` pixels of `src`, laid out by `s`.
|
||||
fn chalk_charcoal(p: serde_json::Value, src: &[u8], w: usize, h: usize, s: &PixelSpace) -> Vec<u8> {
|
||||
let mut out = src.to_vec();
|
||||
fx("sketch.chalkCharcoal", p).apply(&mut out, w, h, s);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chalk_charcoal_params_take_defaults_and_stay_in_range() {
|
||||
assert_eq!(fx("sketch.chalkCharcoal", json!({})), PixelFx::ChalkCharcoal { chalk: 6.0, charcoal: 6.0, pressure: 2.0 });
|
||||
assert_eq!(
|
||||
fx("sketch.chalkCharcoal", json!({"chalkArea": 1e9, "charcoalArea": -2, "strokePressure": 99})),
|
||||
PixelFx::ChalkCharcoal { chalk: 20.0, charcoal: 0.0, pressure: 5.0 }
|
||||
);
|
||||
let doc = crate::effect_info("sketch.chalkCharcoal").unwrap().params;
|
||||
assert!(doc.contains("chalkArea") && doc.contains("charcoalArea") && doc.contains("strokePressure"), "{doc}");
|
||||
let f = fx("sketch.chalkCharcoal", json!({"strokePressure": 5}));
|
||||
assert_eq!(f.outset(Rect::new(0.0, 0.0, 100.0, 50.0)), 0.0);
|
||||
assert!(f.reach().unwrap() > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chalk_charcoal_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()
|
||||
});
|
||||
let out = chalk_charcoal(json!({"chalkArea": 12, "charcoalArea": 12, "strokePressure": 4}), &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], "coverage kept");
|
||||
if p[3] == 0 {
|
||||
continue;
|
||||
}
|
||||
assert_eq!(p[0], p[1]);
|
||||
assert_eq!(p[1], p[2]);
|
||||
}
|
||||
assert_ne!(out, src);
|
||||
}
|
||||
|
||||
/// 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();
|
||||
@@ -1990,6 +2051,38 @@ 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 graphic_pen_params_take_defaults_and_stay_in_range() {
|
||||
use super::STROKE_DIRECTIONS;
|
||||
assert_eq!(fx("sketch.graphicPen", json!({})), PixelFx::GraphicPen { length: 5.0, balance: 50.0, direction: StrokeDirection::RightDiagonal });
|
||||
assert_eq!(
|
||||
fx("sketch.graphicPen", json!({"strokeLength": 0, "lightDarkBalance": 200, "strokeDirection": "VERTICAL"})),
|
||||
PixelFx::GraphicPen { length: 1.0, balance: 100.0, direction: StrokeDirection::Vertical }
|
||||
);
|
||||
let doc = crate::effect_info("sketch.graphicPen").unwrap().params;
|
||||
assert!(STROKE_DIRECTIONS.iter().all(|(_, v)| doc.contains(&format!("\"{v}\""))), "{doc}");
|
||||
let f = fx("sketch.graphicPen", json!({"strokeLength": 15}));
|
||||
assert_eq!(f.outset(Rect::new(0.0, 0.0, 100.0, 50.0)), brushstrokes::stroke_reach(15.0));
|
||||
assert_eq!(f.reach(), Some(brushstrokes::stroke_reach(15.0).max(brushstrokes::TONE_REACH)));
|
||||
assert_eq!(
|
||||
fx("sketch.graphicPen", json!({"strokeLength": 99})),
|
||||
PixelFx::GraphicPen { length: 15.0, balance: 50.0, direction: StrokeDirection::RightDiagonal }
|
||||
);
|
||||
}
|
||||
|
||||
/// Graphic Pen on a mid-grey field draws both black ink and white paper.
|
||||
#[test]
|
||||
fn graphic_pen_draws_ink_and_paper_on_grey() {
|
||||
let (w, h) = (48, 48);
|
||||
let src = image(w, h, |_, _| grey(32));
|
||||
let mut out = src.clone();
|
||||
fx("sketch.graphicPen", json!({"strokeLength": 4, "lightDarkBalance": 50})).apply(&mut out, w, h, &space(w, h));
|
||||
let lumas: Vec<u8> = out.as_chunks::<4>().0.iter().map(|p| p[0]).collect();
|
||||
let (min, max) = lumas.iter().fold((255u8, 0u8), |(lo, hi), l| (lo.min(*l), hi.max(*l)));
|
||||
assert!(min < 80 && max > 200, "min {min} max {max}");
|
||||
assert_ne!(out, src, "the filter should change the raster");
|
||||
}
|
||||
|
||||
#[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 });
|
||||
@@ -2094,6 +2187,50 @@ fn halftone_draws_in_black_and_white_only() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reticulation of `p` over `w` × `h` pixels of `src`, laid out by `s`.
|
||||
fn reticulation(p: serde_json::Value, src: &[u8], w: usize, h: usize, s: &PixelSpace) -> Vec<u8> {
|
||||
let mut out = src.to_vec();
|
||||
fx("sketch.reticulation", p).apply(&mut out, w, h, s);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reticulation_params_take_defaults_and_stay_in_range() {
|
||||
assert_eq!(fx("sketch.reticulation", json!({})), PixelFx::Reticulation { density: 12.0, foreground: 6.0, background: 6.0 });
|
||||
assert_eq!(
|
||||
fx("sketch.reticulation", json!({"density": -5, "foregroundLevel": 99, "backgroundLevel": -1})),
|
||||
PixelFx::Reticulation { density: 0.0, foreground: 50.0, background: 0.0 }
|
||||
);
|
||||
let f = fx("sketch.reticulation", json!({}));
|
||||
assert_eq!(f.outset(Rect::new(0.0, 0.0, 100.0, 50.0)), 0.0);
|
||||
assert_eq!(f.reach(), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reticulation_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()
|
||||
});
|
||||
let out = reticulation(json!({}), &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], "coverage kept");
|
||||
assert!(p[0] == p[1] && p[1] == p[2] && (p[0] == 0 || p[0] == p[3]), "{p:?} from {q:?}");
|
||||
}
|
||||
assert!(share(&out, |p| p[0] == 0) > 0.05 && share(&out, |p| p[0] == p[3]) > 0.05, "ink and paper");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reticulation_density_changes_the_grain() {
|
||||
let (w, h) = (64, 64);
|
||||
let src = image(w, h, |_, _| grey(128));
|
||||
let low = reticulation(json!({"density": 5}), &src, w, h, &space(w, h));
|
||||
let high = reticulation(json!({"density": 45}), &src, w, h, &space(w, h));
|
||||
assert_ne!(low, high);
|
||||
}
|
||||
|
||||
/// 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()
|
||||
@@ -2128,11 +2265,80 @@ fn artistic_params_take_defaults_and_stay_in_range() {
|
||||
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("artistic.plasticWrap", json!({})), PixelFx::PlasticWrap { highlight: 20.0, detail: 12.0, smoothness: 15.0 });
|
||||
assert_eq!(
|
||||
fx("artistic.plasticWrap", json!({"highlightStrength": -1, "detail": 99, "smoothness": 0})),
|
||||
PixelFx::PlasticWrap { highlight: 0.0, detail: 15.0, smoothness: 1.0 }
|
||||
);
|
||||
for id in ["artistic.cutout", "artistic.neonGlow", "artistic.plasticWrap", "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 artistic_filters_leave_invalid_coordinate_spaces_alone() {
|
||||
let (w, h) = (16, 12);
|
||||
let src = ramp(w, h);
|
||||
let run = |f: &PixelFx, s: &PixelSpace| {
|
||||
let mut out = src.clone();
|
||||
f.apply(&mut out, w, h, s);
|
||||
out
|
||||
};
|
||||
let colour = [0.3, 0.65, 1.0];
|
||||
let filters = [
|
||||
PixelFx::Cutout { levels: 4.0, simplicity: 4.0, fidelity: 2.0 },
|
||||
PixelFx::NeonGlow { size: 5.0, brightness: 15.0, colour },
|
||||
PixelFx::PlasticWrap { highlight: 20.0, detail: 12.0, smoothness: 15.0 },
|
||||
PixelFx::PosterEdges { thickness: 2.0, intensity: 1.0, posterization: 2.0 },
|
||||
];
|
||||
for f in &filters {
|
||||
assert_ne!(run(f, &space(w, h)), src, "{f:?} changes the art in a valid space");
|
||||
}
|
||||
let mut changed = Vec::new();
|
||||
for f in &filters {
|
||||
for px in [0.0, -1.0, f64::NAN, f64::INFINITY] {
|
||||
if run(f, &PixelSpace { px, ..space(w, h) }) != src {
|
||||
changed.push(format!("{f:?} at px {px}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
for f in [
|
||||
PixelFx::Cutout { levels: f64::NAN, simplicity: 4.0, fidelity: 2.0 },
|
||||
PixelFx::Cutout { levels: 4.0, simplicity: f64::INFINITY, fidelity: 2.0 },
|
||||
PixelFx::Cutout { levels: 4.0, simplicity: 4.0, fidelity: f64::NEG_INFINITY },
|
||||
PixelFx::NeonGlow { size: f64::NAN, brightness: 15.0, colour },
|
||||
PixelFx::NeonGlow { size: 5.0, brightness: f64::INFINITY, colour },
|
||||
PixelFx::PosterEdges { thickness: f64::NAN, intensity: 1.0, posterization: 2.0 },
|
||||
PixelFx::PosterEdges { thickness: 2.0, intensity: f64::INFINITY, posterization: 2.0 },
|
||||
PixelFx::PosterEdges { thickness: 2.0, intensity: 1.0, posterization: f64::NEG_INFINITY },
|
||||
] {
|
||||
if run(&f, &space(w, h)) != src {
|
||||
changed.push(format!("{f:?}"));
|
||||
}
|
||||
}
|
||||
assert!(changed.is_empty(), "directly constructed non-finite parameters and invalid spaces are harmless: {changed:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plastic_wrap_adds_specular_highlights_and_keeps_coverage() {
|
||||
let (w, h) = (48, 32);
|
||||
let src = image(w, h, |x, y| grey((((x + y) * 5) % 200 + 30) as u8));
|
||||
let mut none = src.clone();
|
||||
fx("artistic.plasticWrap", json!({"highlightStrength": 0})).apply(&mut none, w, h, &space(w, h));
|
||||
assert_eq!(none, src);
|
||||
let mut out = src.clone();
|
||||
fx("artistic.plasticWrap", json!({})).apply(&mut out, w, h, &space(w, h));
|
||||
assert!(out != src, "defaults brighten some pixels");
|
||||
assert!(out.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0).all(|(o, s)| o[3] == s[3]));
|
||||
let mut weak = src.clone();
|
||||
let mut strong = src.clone();
|
||||
fx("artistic.plasticWrap", json!({"highlightStrength": 4})).apply(&mut weak, w, h, &space(w, h));
|
||||
fx("artistic.plasticWrap", json!({"highlightStrength": 20})).apply(&mut strong, w, h, &space(w, h));
|
||||
let lift =
|
||||
|a: &[u8], b: &[u8]| a.as_chunks::<4>().0.iter().zip(b.as_chunks::<4>().0).map(|(o, s)| i32::from(o[0]) - i32::from(s[0])).sum::<i32>();
|
||||
assert!(lift(&strong, &src) > lift(&weak, &src), "stronger highlights lift more");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cutout_paints_a_few_flat_colours_and_keeps_coverage() {
|
||||
let (w, h) = (64, 24);
|
||||
|
||||
@@ -29,6 +29,8 @@ pub enum RasterFx {
|
||||
/// instead of `color`; `None` when it is `color`.
|
||||
darkness: Option<f32>,
|
||||
},
|
||||
/// Blurs the art's solid silhouette grown by a quarter of a pixel of the document's raster grid,
|
||||
/// as the glows AI files export measure.
|
||||
OuterGlow {
|
||||
mode: BlendMode,
|
||||
opacity: f32,
|
||||
@@ -93,6 +95,10 @@ impl RasterFx {
|
||||
pub fn spread(&self, bounds: Rect, line: f64) -> Rect {
|
||||
match self {
|
||||
RasterFx::Rasterize(r) => r.image(bounds, line),
|
||||
RasterFx::OuterGlow { .. } => {
|
||||
let o = self.outset(bounds) + 0.25 * line;
|
||||
bounds.inflate(o, o)
|
||||
}
|
||||
fx => {
|
||||
let o = fx.outset(bounds);
|
||||
bounds.inflate(o, o)
|
||||
@@ -197,7 +203,7 @@ impl Rasterize {
|
||||
let (px, rest) = data.as_chunks_mut::<4>();
|
||||
let (src, src_rest) = image.as_chunks::<4>();
|
||||
let fits = |n: usize, w: usize, h: usize, rest: usize| w > 0 && h > 0 && rest == 0 && w.checked_mul(h) == Some(n);
|
||||
if !fits(px.len(), w, h, rest.len()) || !fits(src.len(), cols, rows, src_rest.len()) || !(rect.width() > 0.0 && rect.height() > 0.0) {
|
||||
if !(fits(px.len(), w, h, rest.len()) && fits(src.len(), cols, rows, src_rest.len()) && rect.width() > 0.0 && rect.height() > 0.0) {
|
||||
return;
|
||||
}
|
||||
let (sx, sy) = (cols as f64 / rect.width(), rows as f64 / rect.height());
|
||||
|
||||
+107
-19
@@ -7,12 +7,14 @@
|
||||
//! 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. 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.
|
||||
//! effects reshapes the whole outline that its fill or stroke paints. An image's own fills and
|
||||
//! strokes paint only the shape that a Convert to Shape or Outline Object effect on them gives
|
||||
//! them ([`image_rows`]).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, Node, NodeKind};
|
||||
use vectorcraft_geom::{Affine, FillRule, PathData, Rect, shapes};
|
||||
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Rect, Shape, shapes};
|
||||
|
||||
use crate::{GeomContext, apply_one, is_geometry, merged_params};
|
||||
|
||||
@@ -44,11 +46,75 @@ pub fn has_gradient_copies(n: &Node) -> bool {
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// visible geometry effect, or an image with a fill or stroke of its own that paints a shape
|
||||
/// ([`image_rows`])? On type, 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()))
|
||||
match &n.kind {
|
||||
NodeKind::Text(t) => {
|
||||
t.runs.iter().all(|r| r.inline.is_none()) && n.appearance.items.iter().any(|i| crate::paints(i) && crate::has_geometry(i.effects()))
|
||||
}
|
||||
NodeKind::Image(_) => n.appearance.items.iter().any(shapes_image),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Does effect `e` give an image's fill or stroke a shape to paint (a visible Convert to Shape or
|
||||
/// Outline Object)?
|
||||
fn gives_shape(e: &Effect) -> bool {
|
||||
e.visible
|
||||
&& matches!(e.id.as_str(), "convertToShape.rectangle" | "convertToShape.roundedRectangle" | "convertToShape.ellipse" | "path.outlineObject")
|
||||
}
|
||||
|
||||
/// Does `item`, a fill or stroke of an image, paint something: does it paint and carry an effect
|
||||
/// that gives it a shape ([`gives_shape`])?
|
||||
fn shapes_image(item: &AppearanceItem) -> bool {
|
||||
crate::paints(item) && item.effects().iter().any(gives_shape)
|
||||
}
|
||||
|
||||
/// The fills and strokes of `items` (an image's own) that paint something ([`shapes_image`]), each
|
||||
/// without the effects before its first Convert to Shape or Outline Object. Convert to Shape sizes
|
||||
/// its shape from the image's box, and Outline Object gives the image's frame. The others paint
|
||||
/// nothing.
|
||||
fn image_rows(items: &[AppearanceItem]) -> Vec<AppearanceItem> {
|
||||
items
|
||||
.iter()
|
||||
.filter(|i| shapes_image(i))
|
||||
.map(|i| {
|
||||
let mut i = i.clone();
|
||||
let fx = i.effects_mut();
|
||||
let first = fx.iter().position(gives_shape).unwrap_or(0);
|
||||
fx.drain(..first);
|
||||
i
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The shapes that the image `n`'s own fills and strokes paint ([`image_rows`]), each with its fill
|
||||
/// rule and the fill or stroke that paints it: the paths (or compound paths) along its frame that
|
||||
/// [`reshape`] makes, with each fill's or stroke's geometry effects applied as the renderer applies
|
||||
/// them. Empty for anything but an image with such fills or strokes.
|
||||
pub fn image_paint(n: &Node) -> Vec<(BezPath, FillRule, AppearanceItem)> {
|
||||
if !matches!(n.kind, NodeKind::Image(_)) || !has_item_geometry(n) {
|
||||
return vec![];
|
||||
}
|
||||
let Some(art) = reshape(n, None) else { return vec![] };
|
||||
let mut out = vec![];
|
||||
art.walk(&mut |c| {
|
||||
let (NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. }) = &c.kind else { return };
|
||||
let Some(g) = c.stroke_path() else { return };
|
||||
let ctx = GeomContext::of(c);
|
||||
for item in c.appearance.items.iter().filter(|i| crate::paints(i)) {
|
||||
let fx = item.effects();
|
||||
let shape = if crate::has_geometry(fx) && !g.elements().is_empty() {
|
||||
crate::apply_geometry_bez(fx, &g, g.bounding_box(), &ctx.item(item))
|
||||
} else {
|
||||
g.clone()
|
||||
};
|
||||
out.push((shape, *rule, item.clone()));
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// The glyph outlines of type `t` in text space with its underline and strikethrough bars: one
|
||||
@@ -186,11 +252,13 @@ 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. 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`).
|
||||
/// frame. Type's own fills and strokes keep their effects on its whole outline. An image's own
|
||||
/// fills and strokes that paint a shape ([`image_rows`]) become paths along its frame below and
|
||||
/// above it, reshaped with it, and keep their effects from their first Convert to Shape or Outline
|
||||
/// Object on. The result is a group with `n`'s id, name, visibility, transparency, opacity mask
|
||||
/// and remaining (raster) effects; `None` when `n` has no visible geometry effect, no perspective
|
||||
/// and is not type or an image with a fill or stroke of its own that [`has_item_geometry`] counts,
|
||||
/// or has no art (a symbol instance without `symbol_art`).
|
||||
pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
if (geometry_effects(n).is_empty() && n.projection().is_none() && !has_item_geometry(n)) || !(needs_outline(n) || has_gradient_copies(n)) {
|
||||
return None;
|
||||
@@ -200,14 +268,17 @@ pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
|
||||
/// `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.
|
||||
/// geometry effects, else type with fills or strokes of its own outlined, or an image with fills
|
||||
/// or strokes of its own that paint a shape, in the same form. `None` when it has none of these.
|
||||
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();
|
||||
let own_paint = match n.kind {
|
||||
NodeKind::Text(_) => !n.appearance.items.is_empty(),
|
||||
_ => has_item_geometry(n),
|
||||
};
|
||||
if !needs_outline(n) || (geometry_effects(n).is_empty() && !own_paint) {
|
||||
return None;
|
||||
}
|
||||
@@ -220,12 +291,28 @@ pub(crate) fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
let mut art = match &n.kind {
|
||||
NodeKind::Image(im) => {
|
||||
let frame = shapes::rectangle(Rect::new(0.0, 0.0, im.width as f64, im.height as f64)).transformed(im.xf);
|
||||
let mut clip = Node::path(n.id, frame, Appearance::default());
|
||||
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
|
||||
*clipping = true;
|
||||
}
|
||||
let image = Node::new(n.id, n.kind.clone());
|
||||
Node::new(n.id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image)], clip: true })
|
||||
let clipped = || {
|
||||
let mut clip = Node::path(n.id, frame.clone(), Appearance::default());
|
||||
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
|
||||
*clipping = true;
|
||||
}
|
||||
Node::new(n.id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image.clone())], clip: true })
|
||||
};
|
||||
// The fills and strokes that paint a shape, as paths along the frame below and above
|
||||
// the image.
|
||||
let (below, above) = n.appearance.split_contents();
|
||||
let (below, above) = (image_rows(below), image_rows(above));
|
||||
if below.is_empty() && above.is_empty() {
|
||||
clipped()
|
||||
} else {
|
||||
let rows = |items: Vec<AppearanceItem>| {
|
||||
(!items.is_empty()).then(|| Arc::new(Node::path(n.id, frame.clone(), Appearance { items, ..Default::default() })))
|
||||
};
|
||||
// Without geometry effects, the image is not clipped.
|
||||
let image = if fx.is_empty() { image.clone() } else { clipped() };
|
||||
Node::group(n.id, rows(below).into_iter().chain([Arc::new(image)]).chain(rows(above)).collect())
|
||||
}
|
||||
}
|
||||
_ => outline_art(n, symbol_art)?,
|
||||
};
|
||||
@@ -234,7 +321,8 @@ pub(crate) fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
if let Some(h) = n.projection() {
|
||||
project_leaves(&mut art, &h);
|
||||
}
|
||||
// The art is a group with `n`'s id (a clip group for an image).
|
||||
// The art is a group with `n`'s id (for an image without fills or strokes that paint, a clip
|
||||
// group).
|
||||
let mut out = art;
|
||||
out.name = n.name.clone();
|
||||
out.visible = n.visible;
|
||||
|
||||
@@ -34,6 +34,8 @@ pub fn revolve_options(p: &Value) -> Revolve {
|
||||
light_rotation: num(p, "lightRotation", vectorcraft_three_d::DEFAULT_LIGHT.0),
|
||||
light_height: num(p, "lightHeight", vectorcraft_three_d::DEFAULT_LIGHT.1),
|
||||
light_behind: flag(p, "lightBehind", false),
|
||||
light_color: crate::lighting::light_color(p),
|
||||
lights: crate::lighting::lights(p),
|
||||
light_intensity: num(p, "lightIntensity", 80.0) / 100.0,
|
||||
light_softness: num(p, "lightSoftness", 0.0) / 100.0,
|
||||
ambient: num(p, "ambient", 25.0) / 100.0,
|
||||
@@ -46,6 +48,7 @@ pub fn revolve_options(p: &Value) -> Revolve {
|
||||
/// Validate before a command mutates the document. Loading external files is still bounded
|
||||
/// by the evaluator; unsupported source kinds remain unchanged when rendered.
|
||||
pub fn validate_revolve(n: &Node, p: &Value) -> Result<(), String> {
|
||||
crate::lighting::validate(p)?;
|
||||
let path = profile(n).ok_or("Revolve currently requires a path or compound path. Select a profile drawn with the Pen tool.")?;
|
||||
let b = path.bounds().ok_or("Revolve requires a profile with at least two anchors")?;
|
||||
if !path.subpaths.iter().any(|sp| sp.anchors.len() >= 2) {
|
||||
@@ -87,7 +90,9 @@ fn surface(
|
||||
let b = path.bounds()?;
|
||||
// A loaded file or a profile edited past the limits keeps drawing its source path instead
|
||||
// of crashing the frame. Commands validate the same evaluator and report its errors.
|
||||
let faces = vectorcraft_three_d::revolve(path, b, revolve_options(&merged_params(REVOLVE, &fx.params))).ok()?;
|
||||
let options = revolve_options(&merged_params(REVOLVE, &fx.params));
|
||||
let colored = options.light_color != [1.0; 3] || options.lights.is_some();
|
||||
let faces = vectorcraft_three_d::revolve(path, b, options).ok()?;
|
||||
// Visibility is an expansion option, never a change to live geometry or export baking.
|
||||
// Keep all faces for paints whose coverage we cannot prove opaque. A bounded visibility
|
||||
// failure also retains the complete surface rather than losing any of the user's art.
|
||||
@@ -102,19 +107,42 @@ fn surface(
|
||||
let faces = visible.unwrap_or_else(|| {
|
||||
faces
|
||||
.into_iter()
|
||||
.map(|f| vectorcraft_three_d::VisibleFace { path: f.path(), brightness: f.brightness, depth: f.depth, gradient: f.gradient })
|
||||
.map(|f| vectorcraft_three_d::VisibleFace {
|
||||
path: f.path(),
|
||||
brightness: f.brightness,
|
||||
depth: f.depth,
|
||||
gradient: f.gradient,
|
||||
illumination: f.illumination,
|
||||
light_gradient: f.light_gradient,
|
||||
rgb_gradient: f.rgb_gradient,
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
let children = faces
|
||||
.into_iter()
|
||||
.map(|face| {
|
||||
if let Some(g) = face.rgb_gradient
|
||||
&& (paint.color().is_some() || matches!(paint, Paint::Gradient(_)))
|
||||
{
|
||||
return Arc::new(crate::lighting::rgb_gradient_art(n.id, &face.path, &paint, g));
|
||||
}
|
||||
if let (true, Paint::Gradient(_), Some(g)) = (colored, &paint, face.light_gradient) {
|
||||
return Arc::new(crate::lighting::gradient_art(n.id, &face.path, &paint, g));
|
||||
}
|
||||
let mut fill = FillLayer::new(paint.clone());
|
||||
let mut items = vec![];
|
||||
if let Some(c) = paint.color() {
|
||||
let [r, g, b] = c.to_rgb();
|
||||
let color = |s: f64| Color::rgb(r * s as f32, g * s as f32, b * s as f32);
|
||||
fill.paint = face.gradient.map_or_else(|| Paint::solid(color(face.brightness)), |g| shading_paint(g, color, |_| 1.0));
|
||||
fill.paint = if colored {
|
||||
face.light_gradient
|
||||
.map_or_else(|| Paint::solid(crate::lighting::shade(c, face.illumination)), |g| crate::lighting::smooth_paint(g, c))
|
||||
} else {
|
||||
face.gradient.map_or_else(|| Paint::solid(color(face.brightness)), |g| shading_paint(g, color, |_| 1.0))
|
||||
};
|
||||
items.push(AppearanceItem::Fill(fill));
|
||||
} else if colored && matches!(paint, Paint::Gradient(_)) {
|
||||
items = crate::lighting::fills(&paint, face.illumination, face.brightness, true);
|
||||
} else {
|
||||
items.push(AppearanceItem::Fill(fill));
|
||||
let mut shadow = FillLayer::new(Paint::solid(Color::BLACK));
|
||||
|
||||
+106
-24
@@ -3,11 +3,14 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_doc::color::{BlendMode, Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_doc::color::{BlendMode, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_geom::{FillRule, PathData, Rect};
|
||||
use vectorcraft_three_d::{Bevel, BevelShape, Extrude, Facet, Inflate, View};
|
||||
|
||||
#[cfg(test)]
|
||||
use vectorcraft_doc::color::Color;
|
||||
|
||||
use crate::util::{flag, num, text};
|
||||
use crate::{GeomContext, apply_geometry_with, is_geometry};
|
||||
|
||||
@@ -65,6 +68,8 @@ pub fn view_options(p: &Value) -> View {
|
||||
light_rotation: num(p, "lightRotation", vectorcraft_three_d::DEFAULT_LIGHT.0),
|
||||
light_height: num(p, "lightHeight", vectorcraft_three_d::DEFAULT_LIGHT.1),
|
||||
light_behind: flag(p, "lightBehind", false),
|
||||
light_color: crate::lighting::light_color(p),
|
||||
lights: crate::lighting::lights(p),
|
||||
light_intensity: num(p, "lightIntensity", 80.0) / 100.0,
|
||||
light_softness: num(p, "lightSoftness", 0.0) / 100.0,
|
||||
ambient: num(p, "ambient", 25.0) / 100.0,
|
||||
@@ -124,6 +129,7 @@ fn facets(id: &str, path: &PathData, rule: FillRule, bounds: Rect, p: &Value) ->
|
||||
|
||||
/// Validate before a command changes the document; the evaluator bounds loaded files too.
|
||||
pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> {
|
||||
crate::lighting::validate(p)?;
|
||||
let label = crate::effect_info(id).map_or("This effect", |e| e.label.trim_end_matches('…'));
|
||||
let (path, rule) =
|
||||
profile(n).ok_or_else(|| format!("{label} currently requires a path or compound path. Convert type, images and groups to paths first."))?;
|
||||
@@ -139,32 +145,38 @@ pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> {
|
||||
|
||||
/// One surface: each facet a path shaded from `paint`, as a group with the paint's opacity and
|
||||
/// blend mode (so overlapping facets don't add up their transparency).
|
||||
fn surface(n: &Node, facets: Vec<Facet>, paint: &Paint, opacity: f32, blend: BlendMode) -> Node {
|
||||
fn surface(n: &Node, facets: Vec<Facet>, paint: &Paint, opacity: f32, blend: BlendMode, colored: bool) -> Node {
|
||||
if colored && paint.color().is_some() && facets.iter().any(|face| face.rgb_gradient.is_some()) {
|
||||
let mut group = crate::lighting::solid_surface(n.id, &facets, paint);
|
||||
group.opacity = opacity;
|
||||
group.blend = blend;
|
||||
return group;
|
||||
}
|
||||
// Preserve existing white-light paints, alpha composition and seam joins exactly.
|
||||
let (opaque, mask) = if colored { crate::lighting::opaque_paint(paint) } else { (paint.clone(), None) };
|
||||
let paint = &opaque;
|
||||
let children = facets
|
||||
.into_iter()
|
||||
.map(|face| {
|
||||
let mut items = vec![];
|
||||
if let Some(c) = paint.color() {
|
||||
let [r, g, b] = c.to_rgb();
|
||||
let s = face.brightness as f32;
|
||||
items.push(AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::rgb(r * s, g * s, b * s)))));
|
||||
} else {
|
||||
// Gradients and patterns keep their paint, darkened by a black layer.
|
||||
items.push(AppearanceItem::Fill(FillLayer::new(paint.clone())));
|
||||
let mut shadow = FillLayer::new(Paint::solid(Color::BLACK));
|
||||
shadow.opacity = (1.0 - face.brightness) as f32;
|
||||
items.push(AppearanceItem::Fill(shadow));
|
||||
}
|
||||
// Adjacent facets, antialiased on their own, would leave hairline seams: a narrow
|
||||
// stroke of the same paint closes them. No wider than the facet is thick, though: seen
|
||||
// at a grazing angle a facet can be thinner than the stroke, and its stroke would then
|
||||
// paint over its neighbours.
|
||||
let seam = (face.thickness() / 2.0).clamp(0.05, 0.75);
|
||||
if let Some(g) = face.rgb_gradient
|
||||
&& (paint.color().is_some() || matches!(paint, Paint::Gradient(_)))
|
||||
{
|
||||
return Arc::new(crate::lighting::facet_art(n.id, &face.path(), paint, g, seam));
|
||||
}
|
||||
let items = crate::lighting::fills(paint, face.illumination, face.brightness, colored);
|
||||
let items = items
|
||||
.into_iter()
|
||||
.flat_map(|item| {
|
||||
let AppearanceItem::Fill(fill) = &item else { return vec![item] };
|
||||
let mut edge = StrokeLayer::new(fill.paint.clone(), seam);
|
||||
if colored {
|
||||
edge.join = vectorcraft_doc::LineJoin::Round;
|
||||
}
|
||||
edge.opacity = fill.opacity;
|
||||
vec![item, AppearanceItem::Stroke(edge)]
|
||||
})
|
||||
@@ -175,6 +187,11 @@ fn surface(n: &Node, facets: Vec<Facet>, paint: &Paint, opacity: f32, blend: Ble
|
||||
let mut group = Node::group(n.id, children);
|
||||
group.opacity = opacity;
|
||||
group.blend = blend;
|
||||
if let Some(mask) = mask {
|
||||
let bounds = group.visual_bounds().unwrap_or(Rect::ZERO).inflate(1.0, 1.0);
|
||||
let coverage = Node::path(n.id, vectorcraft_geom::shapes::rectangle(bounds), Appearance::basic(mask, Paint::None, 0.0));
|
||||
group.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(coverage, true)));
|
||||
}
|
||||
group
|
||||
}
|
||||
|
||||
@@ -195,11 +212,16 @@ fn region(path: &PathData, rule: FillRule, item: &AppearanceItem) -> (PathData,
|
||||
}
|
||||
|
||||
/// The 3D art of one effect `fx` on `path` (already through the effects before it).
|
||||
/// Extrude & Bevel and Inflate build a solid from the outline, painted with the top visible fill
|
||||
/// (or, without one, the top stroke); Rotate turns every fill and stroke, each in its own paint.
|
||||
/// Rotate turns every fill and stroke, each in its own paint. Extrude & Bevel and Inflate build a
|
||||
/// solid from the path for the top visible fill and one from the outline of each visible stroke,
|
||||
/// each in its own paint. When every one is opaque with normal blending and no colored light makes
|
||||
/// a surface one composite or masks it, their faces are drawn in one back-to-front order (where
|
||||
/// depths tie, as for the caps turned toward the viewer, in the order of the appearance);
|
||||
/// otherwise each solid is a surface of its own, in that order.
|
||||
fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&AppearanceItem]) -> Option<Vec<Arc<Node>>> {
|
||||
let b = path.bounds()?;
|
||||
let p = crate::merged_params(&fx.id, &fx.params);
|
||||
let colored = view_options(&p).has_colored_lights();
|
||||
if fx.id == ROTATE {
|
||||
// Every part turns about the centre of the object's own bounds, so they stay together.
|
||||
let mut out = vec![];
|
||||
@@ -207,16 +229,76 @@ fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&App
|
||||
let (paint, opacity, blend) = paint_of(item);
|
||||
let (area, rule) = region(path, rule, item);
|
||||
let f = facets(ROTATE, &area, rule, b, &p).ok()?;
|
||||
out.push(Arc::new(surface(n, f, &paint, opacity, blend)));
|
||||
out.push(Arc::new(surface(n, f, &paint, opacity, blend, colored)));
|
||||
}
|
||||
return Some(out);
|
||||
}
|
||||
let item = items.iter().rev().find(|i| matches!(i, AppearanceItem::Fill(_))).or_else(|| items.last())?;
|
||||
let (paint, opacity, blend) = paint_of(item);
|
||||
// A loaded file or a shape edited past the limits keeps drawing its source path instead of
|
||||
// failing the frame; commands validate the same evaluator and report its errors.
|
||||
let f = facets(&fx.id, path, rule, b, &p).ok()?;
|
||||
Some(vec![Arc::new(surface(n, f, &paint, opacity, blend))])
|
||||
let top_fill = items.iter().rposition(|i| matches!(i, AppearanceItem::Fill(_)));
|
||||
let parts: Vec<(&AppearanceItem, PathData, FillRule)> = items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(k, i)| Some(*k) == top_fill || matches!(i, AppearanceItem::Stroke(_)))
|
||||
.map(|(_, i)| {
|
||||
let (area, rule) = region(path, rule, i);
|
||||
(*i, area, rule)
|
||||
})
|
||||
.collect();
|
||||
// The solids share the bounds of all their shapes: one center to turn, twist and taper about,
|
||||
// and Inflate's grid reaches over the strokes' outlines.
|
||||
let bounds = parts.iter().filter_map(|(_, area, _)| area.bounds()).reduce(|a, c| a.union(c)).unwrap_or(b);
|
||||
let built = if fx.id == INFLATE {
|
||||
// One eye for every puff, as far away as the highest needs.
|
||||
let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, area, rule)| (area, *rule)).collect();
|
||||
vectorcraft_three_d::inflate_together(&shapes, bounds, inflate_options(&p), view_options(&p))
|
||||
} else {
|
||||
parts.iter().map(|(_, area, rule)| facets(&fx.id, area, *rule, bounds, &p)).collect()
|
||||
};
|
||||
let mut solids = vec![];
|
||||
for ((item, _, _), f) in parts.iter().zip(built) {
|
||||
let item: &AppearanceItem = item;
|
||||
match f {
|
||||
Ok(f) => solids.push((item, f)),
|
||||
// A stroke's outline past the limits is left out; the fill's solid still draws.
|
||||
Err(_) if matches!(item, AppearanceItem::Stroke(_)) => {}
|
||||
// A loaded file or a shape edited past the limits keeps drawing its source path
|
||||
// instead of failing the frame; commands validate the same evaluator and report its
|
||||
// errors.
|
||||
Err(_) => return None,
|
||||
}
|
||||
}
|
||||
if solids.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let opaque = solids.iter().all(|(item, _)| {
|
||||
let (_, opacity, blend) = paint_of(item);
|
||||
opacity >= 1.0 && blend == BlendMode::Normal
|
||||
});
|
||||
let surfaces: Vec<(Vec<f64>, Node)> = solids
|
||||
.into_iter()
|
||||
.map(|(item, f)| {
|
||||
let depths: Vec<f64> = f.iter().map(|f| f.depth).collect();
|
||||
let (paint, opacity, blend) = paint_of(item);
|
||||
(depths, surface(n, f, &paint, opacity, blend, colored))
|
||||
})
|
||||
.collect();
|
||||
// One order takes a plain group of faces from each solid, one face for each facet. With
|
||||
// colored lights a surface can instead be one clipped composite of all its facets, or carry an
|
||||
// opacity mask, and then it stays whole.
|
||||
let plain = surfaces.iter().all(|(depths, s)| {
|
||||
s.extras().mask.is_none() && matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len())
|
||||
});
|
||||
if !opaque || !plain {
|
||||
return Some(surfaces.into_iter().map(|(_, s)| Arc::new(s)).collect());
|
||||
}
|
||||
let mut faces = vec![];
|
||||
for (depths, s) in surfaces {
|
||||
if let NodeKind::Group { children, .. } = s.kind {
|
||||
faces.extend(depths.into_iter().zip(children));
|
||||
}
|
||||
}
|
||||
// A stable sort: faces at the same depth keep the order of the appearance.
|
||||
faces.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
Some(vec![Arc::new(Node::group(n.id, faces.into_iter().map(|(_, face)| face).collect()))])
|
||||
}
|
||||
|
||||
fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData {
|
||||
|
||||
@@ -834,6 +834,50 @@ fn drop_shadow_darkness_replaces_its_color_when_chosen() {
|
||||
assert_eq!(darkness(json!({})), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rasterize_compose_leaves_the_raster_alone_when_the_sizes_do_not_fit() {
|
||||
let r = match raster_effects(&[fx(RASTERIZE, json!({}))]).as_slice() {
|
||||
[RasterFx::Rasterize(r)] => *r,
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
let space = PixelSpace {
|
||||
to_doc: Affine::IDENTITY,
|
||||
px: 1.0,
|
||||
center: Point::new(1.0, 1.0),
|
||||
channels: pixel::Channels::Rgb,
|
||||
line: 1.0,
|
||||
extent: std::f64::consts::SQRT_2,
|
||||
};
|
||||
let compose = |len: usize, w: usize, h: usize, image: &[u8], cols: usize, rows: usize, rect: Rect| {
|
||||
let mut data = vec![7; len];
|
||||
r.compose(&mut data, w, h, &space, image, cols, rows, rect);
|
||||
data
|
||||
};
|
||||
// A 2 × 2 red image over a 2 × 2 raster of one-point pixels: each pixel takes the image's color.
|
||||
let red = [255, 0, 0, 255].repeat(4);
|
||||
let mut long = red.clone();
|
||||
long.extend([255, 0]);
|
||||
let (img, long, area) = (red.as_slice(), long.as_slice(), Rect::new(0.0, 0.0, 2.0, 2.0));
|
||||
assert_eq!(compose(16, 2, 2, img, 2, 2, area), red);
|
||||
let cases = [
|
||||
("w × h is not the raster's pixel count", 16, 1, 2, img, 2, 2, area),
|
||||
("the raster's length is not a multiple of 4", 18, 2, 2, img, 2, 2, area),
|
||||
("cols × rows is not the image's pixel count", 16, 2, 2, img, 1, 2, area),
|
||||
("the image's length is not a multiple of 4", 16, 2, 2, long, 2, 2, area),
|
||||
("the image has no width", 16, 2, 2, img, 2, 2, Rect::new(0.0, 0.0, 0.0, 2.0)),
|
||||
("the image has no height", 16, 2, 2, img, 2, 2, Rect::new(0.0, 0.0, 2.0, 0.0)),
|
||||
("the image has a negative width", 16, 2, 2, img, 2, 2, Rect::new(2.0, 0.0, 0.0, 2.0)),
|
||||
("the image has a NaN width", 16, 2, 2, img, 2, 2, Rect::new(0.0, 0.0, f64::NAN, 2.0)),
|
||||
("the image has a NaN height", 16, 2, 2, img, 2, 2, Rect::new(0.0, 0.0, 2.0, f64::NAN)),
|
||||
];
|
||||
let failures: Vec<_> = cases
|
||||
.iter()
|
||||
.filter(|&&(_, len, w, h, image, cols, rows, rect)| compose(len, w, h, image, cols, rows, rect) != vec![7; len])
|
||||
.map(|c| c.0)
|
||||
.collect();
|
||||
assert!(failures.is_empty(), "{failures:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_mode_names() {
|
||||
use vectorcraft_doc::color::BlendMode;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::{Document, NodeId};
|
||||
use vectorcraft_geom::{Affine, BezPath};
|
||||
use vectorcraft_geom::{Affine, BezPath, Point, Shape};
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -27,7 +27,10 @@ fn faces(art: &Node) -> Vec<&Node> {
|
||||
|
||||
fn fill_rgb(n: &Node) -> [f32; 3] {
|
||||
match &n.appearance.items[0] {
|
||||
AppearanceItem::Fill(f) => f.paint.color().unwrap().to_rgb(),
|
||||
AppearanceItem::Fill(f) => match &f.paint {
|
||||
Paint::Gradient(g) => g.gradient.sample(0.5).0.to_rgb(),
|
||||
p => p.color().unwrap().to_rgb(),
|
||||
},
|
||||
_ => panic!("a fill first"),
|
||||
}
|
||||
}
|
||||
@@ -41,7 +44,8 @@ fn every_solid_draws_shaded_faces_and_leaves_the_source_editable() {
|
||||
let art = solid_art(&n).unwrap_or_else(|| panic!("{id}"));
|
||||
assert_eq!(n.path_data(), Some(&source), "{id}");
|
||||
assert!(!faces(&art).is_empty(), "{id}");
|
||||
// The faces are the fill's red, shaded: never brighter, never another hue.
|
||||
// The faces are the fill's red or the stroke's black, shaded: never brighter, never another
|
||||
// hue.
|
||||
for f in faces(&art) {
|
||||
let [r, g, b] = fill_rgb(f);
|
||||
assert!(r <= 0.8 + 1e-6 && (g * 4.0 - r).abs() < 1e-4 && (b * 8.0 - r).abs() < 1e-4 || id == ROTATE, "{id}: {r} {g} {b}");
|
||||
@@ -73,12 +77,229 @@ fn rotate_turns_every_fill_and_stroke_and_the_solids_use_the_top_fill() {
|
||||
n.appearance.effects[0].params = json!({"rotationX": 0, "rotationY": 60, "rotationZ": 0});
|
||||
let b = solid_art(&n).unwrap().children().unwrap()[1].visual_bounds().unwrap();
|
||||
assert!((b.width() - 104.0 * 0.5 - 0.75).abs() < 1e-6, "{b:?}");
|
||||
// Extrude & Bevel paints one surface with the top fill.
|
||||
// Extrude & Bevel draws the fill's solid and the stroke's, both opaque, as one surface.
|
||||
let mut n = square_node();
|
||||
n.appearance.effects.push(fx(EXTRUDE, json!({})));
|
||||
assert_eq!(solid_art(&n).unwrap().children().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
const BLUE: Color = Color::Rgb { r: 0.1, g: 0.3, b: 0.9 };
|
||||
|
||||
/// `b` with a red fill and a 6 pt blue stroke above it, on the path.
|
||||
fn stroked(b: &BezPath) -> Node {
|
||||
Node::path(NodeId(1), PathData::from_bezpath(b), Appearance::basic(Paint::solid(RED), Paint::solid(BLUE), 6.0))
|
||||
}
|
||||
|
||||
/// A `width` × 100 pt rectangle at (100, 100), stroked.
|
||||
fn stroked_rect(width: f64) -> Node {
|
||||
let mut b = BezPath::new();
|
||||
b.move_to((100.0, 100.0));
|
||||
b.line_to((100.0 + width, 100.0));
|
||||
b.line_to((100.0 + width, 200.0));
|
||||
b.line_to((100.0, 200.0));
|
||||
b.close_path();
|
||||
stroked(&b)
|
||||
}
|
||||
|
||||
/// Is `face` in the stroke's blue (rather than the fill's red)?
|
||||
fn blue(face: &Node) -> bool {
|
||||
let [r, _, b] = fill_rgb(face);
|
||||
b > r
|
||||
}
|
||||
|
||||
/// The bounds of the faces' own paths (without their seam strokes).
|
||||
fn extent(faces: &[&Node]) -> Rect {
|
||||
faces.iter().filter_map(|f| f.path_data()?.bounds()).reduce(|a, b| a.union(b)).unwrap()
|
||||
}
|
||||
|
||||
fn assert_rect(r: Rect, x0: f64, y0: f64, x1: f64, y1: f64) {
|
||||
assert!([r.x0 - x0, r.y0 - y0, r.x1 - x1, r.y1 - y1].iter().all(|d| d.abs() < 1e-6), "{r:?}");
|
||||
}
|
||||
|
||||
/// Extrude & Bevel and Inflate build a solid from each stroke's outline as well as from the path
|
||||
/// for the fill: seen straight on, the stroke's faces reach 3 pt (half its width) outside the
|
||||
/// fill's, and they are drawn in the stroke's paint.
|
||||
#[test]
|
||||
fn a_stroke_builds_a_solid_from_its_outline_beside_the_fill() {
|
||||
for id in [EXTRUDE, INFLATE] {
|
||||
let mut n = stroked_rect(100.0);
|
||||
n.appearance.effects.push(fx(id, json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0})));
|
||||
let art = solid_art(&n).unwrap();
|
||||
let (strokes, fills): (Vec<&Node>, Vec<&Node>) = faces(&art).into_iter().partition(|f| blue(f));
|
||||
assert!(!strokes.is_empty(), "{id}: the stroke draws");
|
||||
assert_rect(extent(&fills), 100.0, 100.0, 200.0, 200.0);
|
||||
assert_rect(extent(&strokes), 97.0, 97.0, 203.0, 203.0);
|
||||
// Its own blue, shaded: never brighter, never another hue.
|
||||
for f in &strokes {
|
||||
let [r, g, b] = fill_rgb(f);
|
||||
assert!(b <= 0.9 + 1e-6 && (r * 9.0 - b).abs() < 1e-4 && (g * 3.0 - b).abs() < 1e-4, "{id}: {r} {g} {b}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Turned, the extrusion's walls show in both paints, and the front caps, which nothing of either
|
||||
/// solid can hide, are drawn last: the fill's, then the stroke's above it, equally lit. Without
|
||||
/// depth the two flat shapes are drawn in the same order. A stroke below the fill has its cap
|
||||
/// drawn below the fill's.
|
||||
#[test]
|
||||
fn the_front_caps_are_drawn_last_in_the_order_of_the_appearance() {
|
||||
// A triangle wider than deep, so the near ends of the walls lie nearer than the middle of the
|
||||
// caps; the mean of its outline's points is not the mean of the stroke's.
|
||||
let mut b = BezPath::new();
|
||||
b.move_to((100.0, 100.0));
|
||||
b.line_to((400.0, 100.0));
|
||||
b.line_to((100.0, 200.0));
|
||||
b.close_path();
|
||||
for params in [json!({}), json!({"depth": 120, "bevel": "rounded"}), json!({"depth": 0})] {
|
||||
let mut n = stroked(&b);
|
||||
n.appearance.effects.push(fx(EXTRUDE, params.clone()));
|
||||
n.appearance.items.reverse();
|
||||
let art = solid_art(&n).unwrap();
|
||||
let [.., stroke_cap, fill_cap] = faces(&art)[..] else { panic!("{params}: two caps") };
|
||||
assert!(blue(stroke_cap) && !blue(fill_cap), "{params}: the stroke below");
|
||||
n.appearance.items.reverse();
|
||||
let art = solid_art(&n).unwrap();
|
||||
let faces = faces(&art);
|
||||
let flat = params["depth"] == json!(0);
|
||||
assert!(if flat { faces.len() == 2 } else { faces.len() > 4 }, "{params}: {} faces", faces.len());
|
||||
let [.., fill_cap, stroke_cap] = faces.as_slice() else { panic!("{params}: two caps") };
|
||||
assert!(!blue(fill_cap) && blue(stroke_cap), "{params}");
|
||||
// The stroke's cap is a ring: its outline and the hole the fill shows through.
|
||||
assert_eq!(stroke_cap.path_data().unwrap().subpaths.len(), 2, "{params}");
|
||||
assert_eq!(fill_cap.path_data().unwrap().subpaths.len(), 1, "{params}");
|
||||
let shade = |f: &Node, i: usize, of: f32| fill_rgb(f)[i] / of;
|
||||
assert!((shade(fill_cap, 0, 0.8) - shade(stroke_cap, 2, 0.9)).abs() < 1e-5, "{params}: one plane, one light");
|
||||
if !flat {
|
||||
assert!(faces.iter().filter(|f| blue(f)).count() > 2 && faces.iter().filter(|f| !blue(f)).count() > 1, "{params}: walls of both");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A stroked open path builds a ribbon, the stroke's outline, not the area the path would enclose
|
||||
/// if it were closed: for an L of two 100 pt legs, a 6 pt band with butt ends and a mitered corner.
|
||||
#[test]
|
||||
fn a_stroked_open_path_builds_a_ribbon() {
|
||||
let mut b = BezPath::new();
|
||||
b.move_to((100.0, 100.0));
|
||||
b.line_to((200.0, 100.0));
|
||||
b.line_to((200.0, 200.0));
|
||||
for id in [EXTRUDE, INFLATE] {
|
||||
let mut n = Node::path(NodeId(1), PathData::from_bezpath(&b), Appearance::basic(Paint::None, Paint::solid(BLUE), 6.0));
|
||||
n.appearance.effects.push(fx(id, json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0, "volume": 0})));
|
||||
let art = solid_art(&n).unwrap();
|
||||
let faces = faces(&art);
|
||||
assert!(faces.iter().all(|f| blue(f)), "{id}");
|
||||
assert_rect(extent(&faces), 100.0, 97.0, 203.0, 200.0);
|
||||
let area: f64 = faces.iter().map(|f| f.path_data().unwrap().to_bezpath().area().abs()).sum();
|
||||
// 6 × 200 less the 3 × 3 the legs share, plus the 3 × 3 miter.
|
||||
assert!((area - 1200.0).abs() < 1e-6, "{id}: {area}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A stroke whose outline takes more points than a solid takes is left out, and the fill's solid
|
||||
/// still draws.
|
||||
#[test]
|
||||
fn a_stroke_past_the_limits_is_left_out() {
|
||||
// 1,500 corners: within the 2,048 points of the fill's solid, but not the stroke's outline.
|
||||
let points: Vec<Point> = (0..1500)
|
||||
.map(|k| {
|
||||
let (s, c) = (k as f64 * std::f64::consts::TAU / 1500.0).sin_cos();
|
||||
let r = if k % 2 == 0 { 100.0 } else { 96.0 };
|
||||
Point::new(200.0 + r * c, 200.0 + r * s)
|
||||
})
|
||||
.collect();
|
||||
let path = PathData::single(vectorcraft_geom::SubPath::polyline(&points, true));
|
||||
for id in [EXTRUDE, INFLATE] {
|
||||
let mut n = Node::path(NodeId(1), path.clone(), Appearance::basic(Paint::solid(RED), Paint::solid(BLUE), 2.0));
|
||||
n.appearance.effects.push(fx(id, json!({})));
|
||||
let art = solid_art(&n).unwrap_or_else(|| panic!("{id}: the fill's solid draws"));
|
||||
assert!(!faces(&art).is_empty() && faces(&art).iter().all(|f| !blue(f)), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// At no volume Inflate's front is flat: turned, the fill's cells are all drawn before the stroke's,
|
||||
/// whatever their depths, as the extrusion's caps are. When the fill puffs, a stroke too narrow to
|
||||
/// puff on the grid stays flat and keeps its depths: the fill's puff is drawn over its far side.
|
||||
#[test]
|
||||
fn a_flat_inflate_draws_the_strokes_front_above_the_fills() {
|
||||
// On the 100 pt square's grid (cells 101 / 48 pt apart from 99.5) no grid point falls inside a
|
||||
// 1 pt stroke, so the stroke stays flat.
|
||||
for (volume, width, length) in [(0.0, 6.0, 300.0), (50.0, 1.0, 100.0)] {
|
||||
let mut n = stroked_rect(length);
|
||||
if let Some(AppearanceItem::Stroke(s)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) {
|
||||
s.width = width;
|
||||
}
|
||||
n.appearance.effects.push(fx(INFLATE, json!({ "volume": volume })));
|
||||
let art = solid_art(&n).unwrap();
|
||||
let faces = faces(&art);
|
||||
let first_blue = faces.iter().position(|f| blue(f)).unwrap();
|
||||
let last_red = faces.iter().rposition(|f| !blue(f)).unwrap();
|
||||
assert_eq!(last_red < first_blue, volume == 0.0, "{volume}: red at {last_red}, blue from {first_blue} of {}", faces.len());
|
||||
}
|
||||
}
|
||||
|
||||
/// With Perspective one eye sees the fill's puff and the stroke's, however much higher one rises:
|
||||
/// seen from behind, the back of a stroke aligned outside meets the fill's back exactly.
|
||||
#[test]
|
||||
fn inflate_sees_the_fill_and_the_stroke_from_one_eye() {
|
||||
let mut n = stroked_rect(100.0);
|
||||
if let Some(AppearanceItem::Stroke(s)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) {
|
||||
s.align = vectorcraft_doc::StrokeAlign::Outside;
|
||||
}
|
||||
let params = json!({"volume": 100, "depth": 100, "perspectiveAngle": 120, "rotationX": 15, "rotationY": 160, "rotationZ": 0});
|
||||
n.appearance.effects.push(fx(INFLATE, params));
|
||||
let art = solid_art(&n).unwrap();
|
||||
let [.., fill_back, stroke_back] = faces(&art)[..] else { panic!("two backs") };
|
||||
assert!(!blue(fill_back) && blue(stroke_back));
|
||||
let fill = fill_back.path_data().unwrap().bounds().unwrap();
|
||||
// The ring's inner contour: the smaller of its two.
|
||||
let rims = stroke_back.path_data().unwrap().subpaths.iter().filter_map(|s| PathData::new(vec![s.clone()]).bounds());
|
||||
let hole = rims.reduce(|a, b| a.intersect(b)).unwrap();
|
||||
assert_rect(hole, fill.x0, fill.y0, fill.x1, fill.y1);
|
||||
}
|
||||
|
||||
/// A stroke with its own transparency or blending is a surface of its own above the fill's, so its
|
||||
/// opacity applies to the stroke as a whole.
|
||||
#[test]
|
||||
fn a_translucent_stroke_is_a_surface_of_its_own() {
|
||||
for (opacity, blend) in [(0.5, BlendMode::Normal), (1.0, BlendMode::Multiply)] {
|
||||
let mut n = stroked_rect(100.0);
|
||||
if let Some(AppearanceItem::Stroke(s)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) {
|
||||
s.opacity = opacity;
|
||||
s.blend = blend;
|
||||
}
|
||||
n.appearance.effects.push(fx(EXTRUDE, json!({})));
|
||||
let art = solid_art(&n).unwrap();
|
||||
let surfaces = art.children().unwrap();
|
||||
assert_eq!(surfaces.len(), 2);
|
||||
assert_eq!((surfaces[0].opacity, surfaces[0].blend), (1.0, BlendMode::Normal));
|
||||
assert_eq!((surfaces[1].opacity, surfaces[1].blend), (opacity, blend));
|
||||
assert!(surfaces[0].children().unwrap().iter().all(|f| !blue(f)) && surfaces[1].children().unwrap().iter().all(|f| blue(f)));
|
||||
}
|
||||
}
|
||||
|
||||
/// With a light that is not white, a fill in a gradient with transparent stops is drawn with an
|
||||
/// opacity mask over its whole surface: the fill's solid and the stroke's stay surfaces of their
|
||||
/// own, and the mask stays on the fill's. Under a white light the same object is one surface.
|
||||
#[test]
|
||||
fn a_masked_surface_under_a_colored_light_stays_whole() {
|
||||
use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop};
|
||||
let mut half = GradientStop::new(1.0, RED);
|
||||
half.opacity = 0.5;
|
||||
let gradient = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops: vec![GradientStop::new(0.0, RED), half] });
|
||||
for (lights, surfaces) in [(json!({"lights": [{"color": "#ff8080"}]}), 2), (json!({}), 1)] {
|
||||
let mut n = stroked_rect(100.0);
|
||||
if let Some(AppearanceItem::Fill(f)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Fill(_))) {
|
||||
f.paint = Paint::Gradient(Box::new(gradient.clone()));
|
||||
}
|
||||
n.appearance.effects.push(fx(EXTRUDE, lights.clone()));
|
||||
let art = solid_art(&n).unwrap();
|
||||
let drawn = art.children().unwrap();
|
||||
assert_eq!(drawn.len(), surfaces, "{lights}");
|
||||
assert_eq!(drawn[0].extras().mask.is_some(), surfaces == 2, "{lights}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparency_stays_on_the_whole_surface_and_items_take_their_own_effect() {
|
||||
let mut n = square_node();
|
||||
@@ -183,10 +404,12 @@ fn an_earlier_perspective_strength_draws_as_its_angle() {
|
||||
#[test]
|
||||
fn an_earlier_light_direction_shades_as_its_rotation_and_height() {
|
||||
fn colours(n: &Node, out: &mut Vec<[f32; 3]>) {
|
||||
if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first()
|
||||
&& let Some(c) = f.paint.color()
|
||||
{
|
||||
out.push(c.to_rgb());
|
||||
if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first() {
|
||||
if let Paint::Gradient(g) = &f.paint {
|
||||
out.extend(g.gradient.stops.iter().map(|s| s.color.to_rgb()));
|
||||
} else if let Some(c) = f.paint.color() {
|
||||
out.push(c.to_rgb());
|
||||
}
|
||||
}
|
||||
for c in n.children().into_iter().flatten() {
|
||||
colours(c, out);
|
||||
@@ -225,9 +448,9 @@ fn effects_before_it_shape_the_solid_and_effects_after_it_bend_the_result() {
|
||||
let mut n = square_node();
|
||||
n.appearance.effects.push(fx("path.offsetPath", json!({"offset": 20})));
|
||||
n.appearance.effects.push(fx(EXTRUDE, json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0, "perspectiveAngle": 0})));
|
||||
// Straight on, the extrusion shows its front: the offset square.
|
||||
// Straight on, the extrusion shows its front: the offset square and its 4 pt stroke.
|
||||
let b = solid_art(&n).unwrap().visual_bounds().unwrap();
|
||||
assert!((b.width() - 140.0 - 0.75).abs() < 1e-6, "{b:?}");
|
||||
assert!((b.width() - 144.0 - 0.75).abs() < 1e-6, "{b:?}");
|
||||
n.appearance.effects.push(fx("distort.twist", json!({"angle": 30})));
|
||||
let art = solid_art(&n).unwrap();
|
||||
assert!(art.appearance.effects.iter().any(|e| e.id == "distort.twist"), "applied to the result as a whole");
|
||||
@@ -265,3 +488,55 @@ fn inflate_reads_twist_taper_and_cap() {
|
||||
assert_eq!((d.twist, d.taper, d.cap), (0.0, 1.0, true));
|
||||
assert_eq!(crate::default_params(INFLATE).unwrap()["cap"], json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_white_inflate_keeps_polygon_faces_and_its_seam_range() {
|
||||
let mut n = square_node();
|
||||
n.appearance.effects.push(fx(INFLATE, json!({})));
|
||||
let art = solid_art(&n).unwrap();
|
||||
let original = faces(&art);
|
||||
assert!(original.len() > 1000);
|
||||
assert!(original.iter().any(|f| f.path_data().unwrap().subpaths.iter().any(|p| p.anchors.len() > 3)), "legacy cells remain polygons");
|
||||
for face in original {
|
||||
assert!(matches!(face.kind, NodeKind::Path { .. }));
|
||||
let AppearanceItem::Stroke(stroke) = &face.appearance.items[1] else { panic!("seam") };
|
||||
assert!((0.05..=0.75).contains(&stroke.width));
|
||||
assert_eq!(stroke.join, vectorcraft_doc::LineJoin::Miter);
|
||||
}
|
||||
// A tiny shape exercises the lower bound, which otherwise disappears at grazing angles.
|
||||
n.transform(Affine::scale(0.001), false);
|
||||
let art = solid_art(&n).unwrap();
|
||||
assert!(faces(&art).iter().any(|face| matches!(&face.appearance.items[1], AppearanceItem::Stroke(s) if s.width == 0.05)));
|
||||
// An explicit white light overrides a stale legacy color without switching shading paths.
|
||||
n.appearance.effects[0].params = json!({"lightColor":"#ff0000","lights":[{}]});
|
||||
let art = solid_art(&n).unwrap();
|
||||
assert!(faces(&art).iter().all(|face| matches!(face.kind, NodeKind::Path { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colored_solid_inflate_batches_channels_and_clips_once_per_surface() {
|
||||
let mut n = square_node();
|
||||
n.appearance.effects.push(fx(INFLATE, json!({"lights":[{"color":"#ff0000"},{"color":"#0000ff"}]})));
|
||||
let art = solid_art(&n).unwrap();
|
||||
fn count(n: &Node) -> (usize, usize, usize) {
|
||||
let (mut groups, mut clips, mut paths) = match &n.kind {
|
||||
NodeKind::Group { clip, .. } => (1, usize::from(*clip), 0),
|
||||
NodeKind::Path { .. } => (0, 0, 1),
|
||||
_ => (0, 0, 0),
|
||||
};
|
||||
for child in n.children().into_iter().flatten() {
|
||||
let (g, c, p) = count(child);
|
||||
groups += g;
|
||||
clips += c;
|
||||
paths += p;
|
||||
}
|
||||
(groups, clips, paths)
|
||||
}
|
||||
let (groups, clips, paths) = count(&art);
|
||||
assert!(paths > 1000, "exercise a tessellated surface");
|
||||
// The fill's solid and the stroke's, each one composite with its own clip.
|
||||
let surfaces = art.children().unwrap().len();
|
||||
assert_eq!(surfaces, 2);
|
||||
assert_eq!(clips, surfaces);
|
||||
assert_eq!(groups, 1 + 6 * surfaces, "group count must not grow with triangle count");
|
||||
}
|
||||
@@ -15,6 +15,8 @@ default = ["bundled-fonts"]
|
||||
bundled-fonts = ["vectorcraft-text/bundled-fonts"]
|
||||
|
||||
[dependencies]
|
||||
# Which paths stored in documents may be opened.
|
||||
craft-docpath = { workspace = true }
|
||||
vectorcraft-geom = { workspace = true }
|
||||
vectorcraft-color = { workspace = true }
|
||||
vectorcraft-doc = { workspace = true }
|
||||
@@ -35,6 +37,8 @@ vectorcraft-affinity = { workspace = true }
|
||||
vectorcraft-scriptcheck = { workspace = true }
|
||||
image = { workspace = true }
|
||||
flate2 = { workspace = true }
|
||||
# The XML parts of Word documents placed as type (place/docx.rs).
|
||||
roxmltree = "0.21"
|
||||
kurbo = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
//! layer's own fills and strokes paint its members' geometry and its effects apply to them as one
|
||||
//! piece; the Contents row (type: Characters) is the slot that says which of its fills and strokes
|
||||
//! paint below the members and which above ([`Appearance::contents_at`], moved by
|
||||
//! `appearance.moveItem {from: "contents"}`).
|
||||
//! `appearance.moveItem {from: "contents"}`). An image's own fills and strokes paint only the
|
||||
//! shape that a Convert to Shape or Outline Object effect on them gives them, below or above the
|
||||
//! image as its Contents row says; new ones go right below that row.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{BlendMode, Paint};
|
||||
@@ -36,7 +38,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add New Fill",
|
||||
["Window", "Appearance"],
|
||||
None,
|
||||
"{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack (a copy of its top fill; type without one: its characters' fill; else the default fill)",
|
||||
"{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row (a copy of its top fill; type without one: its characters' fill; else the default fill)",
|
||||
has_doc,
|
||||
|s, p| add_item(s, p, true)
|
||||
),
|
||||
@@ -45,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add New Stroke",
|
||||
["Window", "Appearance"],
|
||||
None,
|
||||
"{ids?|id?, target?} add a stroke on top of each selected object's own stack (a copy of its top stroke, else the default stroke)",
|
||||
"{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row (a copy of its top stroke, else the default stroke)",
|
||||
has_doc,
|
||||
|s, p| add_item(s, p, false)
|
||||
),
|
||||
@@ -408,6 +410,9 @@ fn set_active_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
|
||||
let ids = require_targets(s, p, if fill { "appearance.addFill" } else { "appearance.addStroke" })?;
|
||||
let defaults = s.paint.clone();
|
||||
// Where the new item went in on the active item's object, when it is an image.
|
||||
let active = s.active_appearance_item.as_ref().and_then(|a| a.objects.first().copied());
|
||||
let mut inserted = None;
|
||||
s.edit(if fill { "Add New Fill" } else { "Add New Stroke" }, |d, _| {
|
||||
for id in &ids {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
@@ -429,11 +434,23 @@ fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
|
||||
} else {
|
||||
AppearanceItem::Stroke(StrokeLayer::new(paint, n.appearance.stroke_width().max(1.0)))
|
||||
};
|
||||
n.appearance.items.push(item);
|
||||
if matches!(n.kind, NodeKind::Image(_)) {
|
||||
// On an image it goes right below the Contents row (the image).
|
||||
let k = n.appearance.contents_at();
|
||||
n.appearance.items.insert(k, item);
|
||||
n.appearance.set_contents_at(k + 1);
|
||||
if active == Some(*id) {
|
||||
inserted = Some(k);
|
||||
}
|
||||
} else {
|
||||
n.appearance.items.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
// The rows above the new item move up by one.
|
||||
s.remap_appearance_item(&ids, |a| Some(a + usize::from(inserted.is_some_and(|k| k <= a))));
|
||||
ok()
|
||||
}
|
||||
|
||||
@@ -478,7 +495,7 @@ fn transfer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let (Some(f), Some(t)) = (src.geometric_bounds(), to.geometric_bounds()) {
|
||||
ap.rebase_gradients(f, t);
|
||||
}
|
||||
// Only groups, layers and type have a Contents (Characters) row to keep a slot for.
|
||||
// Only groups, layers, type and images have a Contents (Characters) row to keep a slot for.
|
||||
if to.contents_label().is_none() {
|
||||
ap.contents_index = None;
|
||||
}
|
||||
@@ -717,7 +734,7 @@ fn move_contents(s: &mut Session, ids: &[NodeId], to: usize) -> Result<Value> {
|
||||
n.appearance.set_contents_at(to);
|
||||
slot.get_or_insert(n.appearance.contents_at());
|
||||
}
|
||||
slot.ok_or_else(|| bad("appearance.moveItem", "only groups, layers and type have a Contents (Characters) row"))?;
|
||||
slot.ok_or_else(|| bad("appearance.moveItem", "only groups, layers, type and images have a Contents (Characters) row"))?;
|
||||
Ok(count_changed(d, &before, ids))
|
||||
})?;
|
||||
Ok(json!({ "contents": slot }))
|
||||
|
||||
@@ -62,7 +62,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"New Brush…",
|
||||
[],
|
||||
None,
|
||||
"{type: calligraphic|scatter|art|pattern|bristle, name?, params?: {…definition fields}, ids?|id?} new brush; art/scatter/pattern brushes take the selected art (pattern: side tile) unless params give it → {name}",
|
||||
"{type: calligraphic|scatter|art|pattern|bristle, name? (trimmed; blank: New <Type> Brush), params?: {…definition fields}, ids?|id?} new brush; art/scatter/pattern brushes take the selected art (pattern: side tile) unless params give it → {name}",
|
||||
has_doc,
|
||||
brush_new
|
||||
),
|
||||
@@ -75,13 +75,21 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
brush_delete
|
||||
),
|
||||
cmd!("brush.duplicate", "Duplicate Brush", [], None, "{name, newName?} | {names: [name…]} → {name} | {names}", has_doc, brush_duplicate),
|
||||
cmd!(
|
||||
"brush.duplicate",
|
||||
"Duplicate Brush",
|
||||
[],
|
||||
None,
|
||||
"{name, newName? (trimmed; blank: <name> copy)} | {names: [name…]} → {name} | {names}",
|
||||
has_doc,
|
||||
brush_duplicate
|
||||
),
|
||||
cmd!(
|
||||
"brush.options",
|
||||
"Brush Options…",
|
||||
[],
|
||||
None,
|
||||
"{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180°, modes: [fixed|random|pressure for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest), rotation_relative_to_path. art: width 1..1000%, scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour)}}, newName?} edit a brush definition (strokes using it update) → {name}",
|
||||
"{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180°, modes: [fixed|random|pressure for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest), rotation_relative_to_path. art: width 1..1000%, scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour)}}, newName? (trimmed; blank: the name is kept)} edit a brush definition (strokes using it update) → {name}",
|
||||
has_doc,
|
||||
brush_options
|
||||
),
|
||||
@@ -202,7 +210,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Symbolism Tool",
|
||||
[],
|
||||
None,
|
||||
"{tool: shift|scrunch|size|spin|stain|screen|style, points: [[x,y]…], radius?: 40, intensity?: 1..10 (5), alt?: bool, color?, style?: name} adjust instances near the points (selection, else all) → {count}",
|
||||
"{tool: shift|scrunch|size|spin|stain|screen|style, points: [[x,y]…], radius?: 40, intensity?: 1..10 (5), alt?: bool, color?, keepModel?: bool (as paint.setFill), style?: name} adjust instances near the points (selection, else all) → {count}",
|
||||
has_doc,
|
||||
symbol_adjust
|
||||
),
|
||||
@@ -393,7 +401,8 @@ fn brush_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
o => return Err(bad(C, format!("unknown brush type `{o}`"))),
|
||||
};
|
||||
let mut lib = brush::library(&s.doc()?.doc);
|
||||
let base_name = str_param(p, "name").map(str::to_string).unwrap_or_else(|| format!("New {} Brush", ty[..1].to_uppercase() + &ty[1..]));
|
||||
let given = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty());
|
||||
let base_name = given.map_or_else(|| format!("New {} Brush", ty[..1].to_uppercase() + &ty[1..]), str::to_string);
|
||||
let name = brush::unique_name(&lib, &base_name);
|
||||
let mut v = json!({"name": name, "type": ty});
|
||||
if let Some(params) = p.get("params") {
|
||||
@@ -445,7 +454,8 @@ fn brush_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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);
|
||||
let given = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty() && p.get("names").is_none());
|
||||
let new_name = given.map_or_else(|| format!("{} copy", name.trim()), str::to_string);
|
||||
b.name = brush::unique_name(&lib, &new_name);
|
||||
out.push(b.name.clone());
|
||||
lib.push(b);
|
||||
@@ -463,7 +473,7 @@ fn brush_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(params) = p.get("params") {
|
||||
merge(&mut v, params);
|
||||
}
|
||||
let new_name = str_param(p, "newName").filter(|n| !n.trim().is_empty() && *n != name).map(str::to_string);
|
||||
let new_name = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty() && *n != name).map(str::to_string);
|
||||
if let Some(nn) = &new_name {
|
||||
if lib.iter().any(|b| &b.name == nn) {
|
||||
return Err(bad(C, format!("a brush named `{nn}` exists")));
|
||||
@@ -669,7 +679,7 @@ fn symbol_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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 name = s.doc()?.doc.unique_symbol_name(str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).unwrap_or("New Symbol"));
|
||||
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();
|
||||
@@ -918,7 +928,8 @@ fn symbol_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = name_param(p, C)?.to_string();
|
||||
let d = &s.doc()?.doc;
|
||||
let sym = d.symbols.iter().find(|x| x.name == name).cloned().ok_or_else(|| bad(C, format!("no symbol named `{name}`")))?;
|
||||
let new = d.unique_symbol_name(str_param(p, "newName").unwrap_or(&format!("{name} copy")));
|
||||
let given = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty());
|
||||
let new = d.unique_symbol_name(&given.map_or_else(|| format!("{} copy", name.trim()), str::to_string));
|
||||
let size = natural_size(d, &name);
|
||||
let reg = d.symbol_registration(&name);
|
||||
let export = d.symbol_export_type(&name);
|
||||
@@ -1099,7 +1110,7 @@ fn symbol_adjust(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let alt = bool_or(p, "alt", false);
|
||||
let st = s.doc()?;
|
||||
let color = match p.get("color").and_then(color_value) {
|
||||
Some(c) => c,
|
||||
Some(c) => super::paint::new_color(s, p, c),
|
||||
None => s.paint.fill.color().unwrap_or(Color::BLACK),
|
||||
};
|
||||
let style_paint = match str_param(p, "style") {
|
||||
|
||||
@@ -232,7 +232,7 @@ fn fill_param(s: &Session, p: &Value, cmd: &str) -> Result<Option<Paint>> {
|
||||
Some(v @ Value::Object(o)) if o.contains_key("color") || o.contains_key("swatch") || o.contains_key("gradient") || o.contains_key("none") => {
|
||||
paint_from(s, v)
|
||||
}
|
||||
Some(v) => color_value(v).map(|c| Some(Paint::solid(c))).ok_or_else(|| bad(cmd, format!("bad fill {v}"))),
|
||||
Some(v) => color_value(v).map(|c| Some(Paint::solid(super::paint::new_color(s, p, c)))).ok_or_else(|| bad(cmd, format!("bad fill {v}"))),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Clipboard as PNG",
|
||||
[],
|
||||
None,
|
||||
"{scale?: 1 (pixels per point, up to 64)} → {dataBase64, width, height (px)} the copied objects cropped to their visual bounds, transparent around them (dataBase64 null when the clipboard is empty)",
|
||||
"{scale?: 1 (pixels per point, up to 64)} → {dataBase64, width, height (px), warnings} the copied objects cropped to their visual bounds, transparent around them (dataBase64 null when the clipboard is empty); warnings as for document.export (an image that couldn't be decoded, type in the fallback font or that doesn't fit its frame)",
|
||||
always,
|
||||
export_png
|
||||
),
|
||||
@@ -139,7 +139,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Clipboard as PDF",
|
||||
[],
|
||||
None,
|
||||
"{} → {dataBase64} a one-page PDF of the copied objects, the page their visual bounds (null when the clipboard is empty)",
|
||||
"{} → {dataBase64, warnings} a one-page PDF of the copied objects, the page their visual bounds (dataBase64 null when the clipboard is empty); warnings as for document.export",
|
||||
always,
|
||||
export_pdf
|
||||
),
|
||||
@@ -218,6 +218,9 @@ fn type_only(n: &Node) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// A PNG of the copied objects: (bytes, width, height in pixels, the encoder's warnings).
|
||||
pub type Png = (Vec<u8>, u32, u32, Vec<String>);
|
||||
|
||||
impl Clipboard {
|
||||
/// The copied type objects' text, one object per line (`None` unless it holds type only).
|
||||
pub fn text(&self) -> Option<String> {
|
||||
@@ -243,21 +246,21 @@ impl Clipboard {
|
||||
Some((fileio::with_single_artboard(self.to_document(), b, "Clipboard"), b))
|
||||
}
|
||||
|
||||
/// A PNG of the objects at `scale` pixels per point → (bytes, width, height); `None` when
|
||||
/// there are none.
|
||||
pub fn png(&self, scale: f64) -> Result<Option<(Vec<u8>, u32, u32)>> {
|
||||
/// A PNG of the objects at `scale` pixels per point → (bytes, width, height, the warnings
|
||||
/// [`fileio::encode_with_warnings`] gives); `None` when there are none.
|
||||
pub fn png(&self, scale: f64) -> Result<Option<Png>> {
|
||||
const C: &str = "clipboard.exportPng";
|
||||
let Some((doc, b)) = self.cropped() else { return Ok(None) };
|
||||
let (w, h) = vectorcraft_render::raster_size(b, scale).map_err(|e| bad(C, e))?;
|
||||
let png = fileio::encode(&doc, "png", &json!({ "scale": scale, "background": "transparent" }))?;
|
||||
Ok(Some((png, w, h)))
|
||||
let (png, warnings) = fileio::encode_with_warnings(&doc, "png", &json!({ "scale": scale, "background": "transparent" }))?;
|
||||
Ok(Some((png, w, h, warnings)))
|
||||
}
|
||||
|
||||
/// A one-page PDF of the objects (without the native document inside); `None` when there are
|
||||
/// none.
|
||||
pub fn pdf(&self) -> Result<Option<Vec<u8>>> {
|
||||
/// A one-page PDF of the objects (without the native document inside) → (bytes, the encoder's
|
||||
/// warnings); `None` when there are none.
|
||||
pub fn pdf(&self) -> Result<Option<(Vec<u8>, Vec<String>)>> {
|
||||
let Some((doc, _)) = self.cropped() else { return Ok(None) };
|
||||
fileio::encode(&doc, "pdf", &json!({ "preserveEditing": false })).map(Some)
|
||||
fileio::encode_with_warnings(&doc, "pdf", &json!({ "preserveEditing": false })).map(Some)
|
||||
}
|
||||
|
||||
/// The MIME types of [`Self::flavours`], without making them.
|
||||
@@ -275,23 +278,38 @@ impl Clipboard {
|
||||
/// (`pdf`) as given (see `clipboard.flavours`). A [`Flavour`] that can't be made (art too large
|
||||
/// to render) is left out; the PNG is scaled down to at most [`COPY_PNG_SIDE`] pixels a side.
|
||||
pub fn flavours(&self, svg: bool, pdf: bool) -> Vec<Flavour> {
|
||||
self.flavours_with_warnings(svg, pdf).0
|
||||
}
|
||||
|
||||
/// [`Self::flavours`], with the warnings of the PDF and PNG encoders, each once (an image that
|
||||
/// couldn't be decoded, type in the fallback font).
|
||||
pub fn flavours_with_warnings(&self, svg: bool, pdf: bool) -> (Vec<Flavour>, Vec<String>) {
|
||||
let types = self.flavour_types(svg, pdf);
|
||||
let svg = types.contains(&SVG).then(|| self.svg()).flatten();
|
||||
let mut out = vec![];
|
||||
let mut warnings: Vec<String> = vec![];
|
||||
let mut keep = |(bytes, notes): (Vec<u8>, Vec<String>)| {
|
||||
for w in notes {
|
||||
if !warnings.contains(&w) {
|
||||
warnings.push(w);
|
||||
}
|
||||
}
|
||||
bytes
|
||||
};
|
||||
for mime in types {
|
||||
let data = match mime {
|
||||
TEXT => self.text().or_else(|| svg.clone()).map(String::into_bytes),
|
||||
SVG => svg.clone().map(String::into_bytes),
|
||||
PDF => self.pdf().inspect_err(|e| log::warn!("clipboard PDF: {e}")).ok().flatten(),
|
||||
PDF => self.pdf().inspect_err(|e| log::warn!("clipboard PDF: {e}")).ok().flatten().map(&mut keep),
|
||||
_ => {
|
||||
let side = self.bounds().map_or(1.0, |b| b.width().max(b.height()));
|
||||
let scale = (COPY_PNG_SIDE / side).clamp(0.01, 1.0);
|
||||
self.png(scale).inspect_err(|e| log::warn!("clipboard PNG: {e}")).ok().flatten().map(|(png, ..)| png)
|
||||
self.png(scale).inspect_err(|e| log::warn!("clipboard PNG: {e}")).ok().flatten().map(|(png, _, _, notes)| keep((png, notes)))
|
||||
}
|
||||
};
|
||||
out.extend(data.map(|data| Flavour { mime, data }));
|
||||
}
|
||||
out
|
||||
(out, warnings)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,15 +320,15 @@ impl Session {
|
||||
self.clipboard.text()
|
||||
}
|
||||
|
||||
/// A PNG of the copied objects at `scale` pixels per point → (bytes, width, height); `None`
|
||||
/// when the clipboard is empty.
|
||||
pub fn clipboard_png(&self, scale: f64) -> Result<Option<(Vec<u8>, u32, u32)>> {
|
||||
/// A PNG of the copied objects at `scale` pixels per point → (bytes, width, height, the
|
||||
/// encoder's warnings); `None` when the clipboard is empty.
|
||||
pub fn clipboard_png(&self, scale: f64) -> Result<Option<Png>> {
|
||||
self.clipboard.png(scale)
|
||||
}
|
||||
|
||||
/// A one-page PDF of the copied objects (without the native document inside); `None` when the
|
||||
/// clipboard is empty.
|
||||
pub fn clipboard_pdf(&self) -> Result<Option<Vec<u8>>> {
|
||||
/// A one-page PDF of the copied objects (without the native document inside) → (bytes, the
|
||||
/// encoder's warnings); `None` when the clipboard is empty.
|
||||
pub fn clipboard_pdf(&self) -> Result<Option<(Vec<u8>, Vec<String>)>> {
|
||||
self.clipboard.pdf()
|
||||
}
|
||||
|
||||
@@ -356,13 +374,18 @@ fn export_png(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad("clipboard.exportPng", "scale must be a number above 0, at most 64"));
|
||||
}
|
||||
Ok(match s.clipboard_png(scale)? {
|
||||
Some((png, w, h)) => json!({ "dataBase64": vectorcraft_format::base64_encode(&png), "width": w, "height": h }),
|
||||
Some((png, w, h, warnings)) => {
|
||||
json!({ "dataBase64": vectorcraft_format::base64_encode(&png), "width": w, "height": h, "warnings": warnings })
|
||||
}
|
||||
None => json!({ "dataBase64": null }),
|
||||
})
|
||||
}
|
||||
|
||||
fn export_pdf(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({ "dataBase64": s.clipboard_pdf()?.map(|b| vectorcraft_format::base64_encode(&b)) }))
|
||||
Ok(match s.clipboard_pdf()? {
|
||||
Some((pdf, warnings)) => json!({ "dataBase64": vectorcraft_format::base64_encode(&pdf), "warnings": warnings }),
|
||||
None => json!({ "dataBase64": null }),
|
||||
})
|
||||
}
|
||||
|
||||
fn export_text(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
@@ -142,7 +142,8 @@ fn add_to_swatches(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "colorTheme.addToSwatches";
|
||||
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
|
||||
let t = &s.prefs.color_themes[find(s, name, C)?];
|
||||
let params = json!({ "name": t.name, "colors": t.colors.iter().map(color_json).collect::<Vec<_>>() });
|
||||
// The theme's colors keep their model, in a CMYK document too.
|
||||
let params = json!({ "name": t.name, "colors": t.colors.iter().map(color_json).collect::<Vec<_>>(), "keepModel": true });
|
||||
let r = s.execute("swatch.newGroup", ¶ms)?;
|
||||
Ok(json!({ "name": r["name"], "swatches": r["swatches"] }))
|
||||
}
|
||||
@@ -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, 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",
|
||||
"{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, keepModel?: bool (as paint.setFill), 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
|
||||
),
|
||||
@@ -573,8 +573,9 @@ pub(crate) fn new_area_fit(s: &Session, p: &Value, c: &str) -> Result<vectorcraf
|
||||
Ok(super::typecmd::fit_param(p, default, c)?.unwrap_or(default))
|
||||
}
|
||||
|
||||
/// The character style new type gets: `size`, `font`, `style` and `color` from `p`, else the
|
||||
/// defaults, filled with the current fill unless that is None or white (black then).
|
||||
/// The character style new type gets: `size`, `font`, `style` and `color` from `p` (an RGB
|
||||
/// `color` in a CMYK document as [`super::paint::new_color`] stores it), else the defaults,
|
||||
/// filled with the current fill unless that is None or white (black then).
|
||||
pub(crate) fn new_type_style(s: &Session, p: &Value) -> CharStyle {
|
||||
let mut style = CharStyle::default();
|
||||
if let Some(sz) = p.get("size").and_then(Value::as_f64) {
|
||||
@@ -587,7 +588,7 @@ pub(crate) fn new_type_style(s: &Session, p: &Value) -> CharStyle {
|
||||
style.font_style = f.to_string();
|
||||
}
|
||||
style.fill = match p.get("color").and_then(color_value) {
|
||||
Some(c) => vectorcraft_color::Paint::solid(c),
|
||||
Some(c) => vectorcraft_color::Paint::solid(super::paint::new_color(s, p, c)),
|
||||
None if !s.paint.fill.is_none() && s.paint.fill != vectorcraft_color::Paint::solid(vectorcraft_color::Color::WHITE) => s.paint.fill.clone(),
|
||||
None => vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK),
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ use serde_json::{Value, json};
|
||||
use vectorcraft_doc::NodeId;
|
||||
use vectorcraft_svg::{CssOptions, CssSheet, css_rules};
|
||||
|
||||
use super::fileio::{default_name, encode, isolated, write_file};
|
||||
use super::fileio::{default_name, encode_with_warnings, isolated, write_file};
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export CSS",
|
||||
[],
|
||||
None,
|
||||
"{path?, scope?: selection|all (default selection; ids? picks the objects), …options as css.selection, id?: object id (default: the selection)} write the CSS (css.selection, or css.generate for all) to path and, with rasterize, each picture its rules refer to next to it (PNG of the object's visual bounds, 1 px per point) → {path, bytes, rules: n, images: [path…], warnings}; no path → {data: the CSS, name: <document>.css, rules, images: [{name, dataBase64}], warnings}",
|
||||
"{path?, scope?: selection|all (default selection; ids? picks the objects), …options as css.selection, id?: object id (default: the selection)} write the CSS (css.selection, or css.generate for all) to path and, with rasterize, each picture its rules refer to next to it (PNG of the object's visual bounds, 1 px per point) → {path, bytes, rules: n, images: [path…], warnings}; no path → {data: the CSS, name: <document>.css, rules, images: [{name, dataBase64}], warnings}; warnings are css.selection's and the pictures' as document.export gives them (an image that couldn't be decoded, type in the fallback font or that doesn't fit its frame), each once",
|
||||
has_doc,
|
||||
export
|
||||
),
|
||||
@@ -100,12 +100,19 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let css = format!("{}\n", sheet.text());
|
||||
let doc = &s.doc()?.doc;
|
||||
// The pictures of rasterized art.
|
||||
// Encode the pictures of rasterized art, and add each of their warnings once.
|
||||
let mut images = Vec::new();
|
||||
let mut warnings = sheet.warnings.clone();
|
||||
for r in &sheet.rules {
|
||||
let Some(name) = &r.image else { continue };
|
||||
let (d, _) = isolated(doc, &[r.id], name).ok_or_else(|| bad(C, format!("{} paints nothing to rasterize", r.selector)))?;
|
||||
images.push((name, encode(&d, "png", &json!({}))?));
|
||||
let (png, notes) = encode_with_warnings(&d, "png", &json!({}))?;
|
||||
images.push((name, png));
|
||||
for w in notes {
|
||||
if !warnings.contains(&w) {
|
||||
warnings.push(w);
|
||||
}
|
||||
}
|
||||
}
|
||||
let n = sheet.rules.len();
|
||||
match str_param(p, "path") {
|
||||
@@ -118,12 +125,12 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
write_file(&at, bytes)?;
|
||||
written.push(at);
|
||||
}
|
||||
Ok(json!({ "path": path, "bytes": css.len(), "rules": n, "images": written, "warnings": sheet.warnings }))
|
||||
Ok(json!({ "path": path, "bytes": css.len(), "rules": n, "images": written, "warnings": warnings }))
|
||||
}
|
||||
None => {
|
||||
let images: Vec<Value> =
|
||||
images.iter().map(|(name, bytes)| json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })).collect();
|
||||
Ok(json!({ "data": css, "name": default_name(doc, "css"), "rules": n, "images": images, "warnings": sheet.warnings }))
|
||||
Ok(json!({ "data": css, "name": default_name(doc, "css"), "rules": n, "images": images, "warnings": warnings }))
|
||||
}
|
||||
}
|
||||
}
|
||||
+124
-12
@@ -26,7 +26,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Pencil",
|
||||
[],
|
||||
None,
|
||||
"{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic brushes with Pressure variation; a point without one presses fully), fidelity?: pt (1.5), closed?, style?: \"pencil\"|\"brush\", fill?: bool, keepSelected?: bool (true: the drawn or continued path is selected; false: it is not, and the rest of the selection stays), extend?: {id, end: \"start\"|\"end\"}} fit a freehand stroke → {id}",
|
||||
"{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic brushes with Pressure variation; a point without one presses fully), fidelity?: pt (1.5), closed?, style?: \"pencil\"|\"brush\", fill?: bool, keepSelected?: bool (true: the drawn or continued path is selected; false: it is not, and the rest of the selection stays), extend?: {id, end: \"start\"|\"end\"}, redraw?: {id, within?: pt (12)}} fit a freehand stroke → {id}. extend continues the path from that end; redraw (Edit Selected Paths) replaces the section of the path between where the stroke starts and ends, each within `within` of one subpath (the shorter way round a closed one), and draws a new path when the stroke doesn't end on it",
|
||||
has_doc,
|
||||
freehand
|
||||
),
|
||||
@@ -53,7 +53,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Delete Anchor Point",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath, anchor} remove one anchor, re-fitting the curve",
|
||||
"{id, subpath?, anchor} remove one anchor, re-fitting the curve",
|
||||
has_doc,
|
||||
remove_anchor
|
||||
),
|
||||
@@ -71,7 +71,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Convert Anchor Point",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath, anchor, to: \"corner\"|\"smooth\", x?, y?} (smooth with x/y: out handle there, in handle mirrored; without: as the Curvature tool curves a point, along the circle through it and its neighbours, or at an open end one handle into the curve)",
|
||||
"{id, subpath?, anchor, to: \"corner\"|\"smooth\", x?, y?} (smooth with x/y: out handle there, in handle mirrored; without: as the Curvature tool curves a point, along the circle through it and its neighbours, or at an open end one handle into the curve)",
|
||||
has_doc,
|
||||
convert_anchor
|
||||
),
|
||||
@@ -80,7 +80,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reshape Segment",
|
||||
[],
|
||||
None,
|
||||
"{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",
|
||||
"{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
|
||||
),
|
||||
@@ -89,7 +89,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Split Path",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath, segment, t} or {id, subpath, anchor}: closed paths open there, open paths become two → {ids}",
|
||||
"{id, subpath?, segment, t} or {id, subpath?, anchor}: closed paths open there, open paths become two → {ids}",
|
||||
has_doc,
|
||||
split
|
||||
),
|
||||
@@ -215,6 +215,15 @@ fn usize_req(p: &Value, key: &str, cmd: &str) -> Result<usize> {
|
||||
p.get(key).and_then(Value::as_u64).map(|v| v as usize).ok_or_else(|| bad(cmd, format!("missing integer `{key}`")))
|
||||
}
|
||||
|
||||
/// The whole number of 0 or more that `key` holds, or `None` when `key` is absent or null. Any other
|
||||
/// value is [`usize_req`]'s error.
|
||||
pub(super) fn usize_opt(p: &Value, key: &str, cmd: &str) -> Result<Option<usize>> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(_) => usize_req(p, key, cmd).map(Some),
|
||||
}
|
||||
}
|
||||
|
||||
fn path_mut(d: &mut Document, id: NodeId) -> Result<&mut PathData> {
|
||||
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
|
||||
match &mut n.kind {
|
||||
@@ -548,6 +557,87 @@ fn fit_freehand(pts: &[Point], tol: f64, closed: bool) -> SubPath {
|
||||
fitted.subpaths.into_iter().next().filter(|s| s.anchors.len() >= 2).unwrap_or(sp)
|
||||
}
|
||||
|
||||
/// Pencil's Edit Selected Paths in the middle of a path: a stroke from one point of a subpath of
|
||||
/// `pd` to another, each within `within` of it, replaces the section between them (the shorter
|
||||
/// way round a closed subpath) with the stroke fitted at `tol`. The subpath's index and its new
|
||||
/// shape; None when the stroke doesn't start and end on the same subpath, or meets it once.
|
||||
fn redraw_section(pd: &PathData, pts: &[Point], tol: f64, within: f64) -> Option<(usize, SubPath)> {
|
||||
use kurbo::ParamCurveArclen;
|
||||
let (first, last) = (*pts.first()?, *pts.last()?);
|
||||
let (si, _, _, _, d_a) = pd.nearest(first)?;
|
||||
let (sj, _, _, _, d_b) = pd.nearest(last)?;
|
||||
if si != sj || d_a > within || d_b > within || pts.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let mut sp = pd.subpaths.get(si)?.clone();
|
||||
if sp.anchors.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
// The anchor nearest to `q`: the one there, else a new one, and whether it was added. Each
|
||||
// point is looked up on the subpath as it is by then: splitting a straight segment makes two
|
||||
// new ones, which a `t` along the old one doesn't measure.
|
||||
let place = |sp: &mut SubPath, q: Point| {
|
||||
let (_, seg, t, _, _) = PathData::single(sp.clone()).nearest(q)?;
|
||||
let n = sp.anchors.len();
|
||||
Some(if t <= 1e-6 {
|
||||
(seg % n, false)
|
||||
} else if t >= 1.0 - 1e-6 {
|
||||
((seg + 1) % n, false)
|
||||
} else {
|
||||
(sp.insert_anchor(seg, t), true)
|
||||
})
|
||||
};
|
||||
let (mut from, _) = place(&mut sp, first)?;
|
||||
let (to, added) = place(&mut sp, last)?;
|
||||
if added && to <= from {
|
||||
from += 1;
|
||||
}
|
||||
if from == to {
|
||||
return None;
|
||||
}
|
||||
let n = sp.anchors.len();
|
||||
// The length of the run of segments from anchor i forward to anchor j.
|
||||
let run = |i: usize, j: usize| (0..(j + n - i) % n).map(|k| sp.segment((i + k) % n).arclen(0.01)).sum::<f64>();
|
||||
// The stroke goes from `start` to `end` and replaces the run between them; `kept` runs from
|
||||
// `end` round to `start`, then the stroke's inner anchors follow.
|
||||
let (start, end, reversed) = if sp.closed {
|
||||
if run(from, to) <= run(to, from) { (from, to, false) } else { (to, from, true) }
|
||||
} else if from < to {
|
||||
(from, to, false)
|
||||
} else {
|
||||
(to, from, true)
|
||||
};
|
||||
let mut stroke = pts.to_vec();
|
||||
if reversed {
|
||||
stroke.reverse();
|
||||
}
|
||||
let (Some(s0), Some(s1)) = (stroke.first_mut(), sp.anchors.get(start)) else { return None };
|
||||
*s0 = s1.p;
|
||||
let (Some(e0), Some(e1)) = (stroke.last_mut(), sp.anchors.get(end)) else { return None };
|
||||
*e0 = e1.p;
|
||||
let fit = fit_freehand(&stroke, tol, false).anchors;
|
||||
let (Some(f0), Some(f1)) = (fit.first().copied(), fit.last().copied()) else { return None };
|
||||
let inner = fit.get(1..fit.len().saturating_sub(1)).unwrap_or_default();
|
||||
let (before, after): (Vec<Anchor>, Vec<Anchor>) = if sp.closed {
|
||||
((0..=(start + n - end) % n).filter_map(|k| sp.anchors.get((end + k) % n).copied()).collect(), vec![])
|
||||
} else {
|
||||
(sp.anchors.get(..=start)?.to_vec(), sp.anchors.get(end..)?.to_vec())
|
||||
};
|
||||
let mut anchors = before;
|
||||
if let Some(a) = anchors.last_mut() {
|
||||
*a = Anchor::with_handles(a.p, a.h_in, f0.h_out);
|
||||
}
|
||||
anchors.extend_from_slice(inner);
|
||||
let at_end = anchors.len();
|
||||
anchors.extend(after);
|
||||
// The anchor the stroke ends on: the first after it, or round to the first of a closed one.
|
||||
let end_at = if sp.closed { 0 } else { at_end };
|
||||
if let Some(a) = anchors.get_mut(end_at) {
|
||||
*a = Anchor::with_handles(a.p, f1.h_in, a.h_out);
|
||||
}
|
||||
Some((si, SubPath::new(anchors, sp.closed)))
|
||||
}
|
||||
|
||||
fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.freehand";
|
||||
let samples = samples_param(p, "points", C)?;
|
||||
@@ -630,6 +720,28 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})?;
|
||||
return Ok(json!({ "id": id.0 }));
|
||||
}
|
||||
if let Some(rd) = p.get("redraw").filter(|v| !v.is_null()) {
|
||||
let id = id_param(rd, "id").ok_or_else(|| bad(C, "redraw needs id"))?;
|
||||
let within = f64_or(rd, "within", 12.0);
|
||||
if !within.is_finite() || within < 0.0 {
|
||||
return Err(bad(C, "redraw within must be a finite distance of 0 or more"));
|
||||
}
|
||||
let (pd, _) = path_of(&s.doc()?.doc, id)?;
|
||||
// shortcut: a stroke pressure profile is left as it was, measured along the old path; fit
|
||||
// it to the new length if Calligraphic pressure strokes get redrawn.
|
||||
if let Some((si, sp)) = redraw_section(&pd, &pts, tol, within) {
|
||||
s.edit(if brush { "Paintbrush" } else { "Pencil" }, |d, sel| {
|
||||
*path_mut(d, id)?.subpaths.get_mut(si).ok_or_else(|| bad(C, "no such subpath"))? = sp;
|
||||
if keep_selected {
|
||||
sel.set([id]);
|
||||
} else {
|
||||
sel.remove(id);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
return Ok(json!({ "id": id.0 }));
|
||||
}
|
||||
}
|
||||
if pts.len() < 2 {
|
||||
return Err(bad(C, "need at least two points"));
|
||||
}
|
||||
@@ -692,7 +804,7 @@ fn curvature_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use vectorcraft_tools::draw2::{EndCurve, curvature_close, curvature_extend, curvature_insert, curvature_move};
|
||||
const C: &str = "path.curvatureEdit";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
|
||||
let at = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) {
|
||||
(Some(x), Some(y)) if x.is_finite() && y.is_finite() => Some(Point::new(x, y)),
|
||||
(None, None) => None,
|
||||
@@ -756,7 +868,7 @@ fn curvature_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn remove_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.removeAnchor";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
|
||||
let ai = usize_req(p, "anchor", C)?;
|
||||
let removed = s.edit("Delete Anchor Point", |d, sel| {
|
||||
let sp = path_mut(d, id)?.subpaths.get(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
|
||||
@@ -863,7 +975,7 @@ fn remove_anchors_of(
|
||||
fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.convertAnchor";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
|
||||
let ai = usize_req(p, "anchor", C)?;
|
||||
let smooth = str_param(p, "to") == Some("smooth");
|
||||
let target = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) {
|
||||
@@ -898,7 +1010,7 @@ fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.reshapeSegment";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
|
||||
let seg = usize_req(p, "segment", C)?;
|
||||
let t = f64_or(p, "t", 0.5).clamp(0.05, 0.95);
|
||||
let dv = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
|
||||
@@ -923,9 +1035,9 @@ fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn split(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.split";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let anchor = p.get("anchor").and_then(Value::as_u64).map(|v| v as usize);
|
||||
let seg = p.get("segment").and_then(Value::as_u64).map(|v| v as usize);
|
||||
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
|
||||
let anchor = usize_opt(p, "anchor", C)?;
|
||||
let seg = usize_opt(p, "segment", C)?;
|
||||
if anchor.is_none() && seg.is_none() {
|
||||
return Err(bad(C, "need `anchor` or `segment` + `t`"));
|
||||
}
|
||||
|
||||
@@ -75,6 +75,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
cmd!("edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, undo),
|
||||
cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, redo),
|
||||
cmd!(
|
||||
"edit.clearHistory",
|
||||
"Clear History",
|
||||
[],
|
||||
None,
|
||||
"{} remove every undo and redo step of the active document (the History panel menu's Clear History): the document stays as it is, and clearing is not an undo step → {cleared: the number of steps removed}",
|
||||
can_clear_history,
|
||||
clear_history
|
||||
),
|
||||
cmd!(
|
||||
"edit.undoGroup.begin",
|
||||
"Begin Undo Group",
|
||||
@@ -380,7 +389,9 @@ fn undo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let doc = std::mem::replace(&mut st.doc, e.doc);
|
||||
let selection = std::mem::replace(&mut st.selection, Arc::unwrap_or_clone(e.selection));
|
||||
let selection = shared_selection(selection, st.history.redo.last());
|
||||
st.history.redo.push(crate::HistoryEntry::turned(e.label, doc, selection, &st.doc, costs));
|
||||
let mut turned = crate::HistoryEntry::turned(e.label, doc, selection, &st.doc, costs);
|
||||
turned.named = e.named;
|
||||
st.history.redo.push(turned);
|
||||
st.history.trim();
|
||||
st.revision += 1;
|
||||
st.selection.prune(&st.doc);
|
||||
@@ -403,7 +414,9 @@ fn redo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let doc = std::mem::replace(&mut st.doc, e.doc);
|
||||
let selection = std::mem::replace(&mut st.selection, Arc::unwrap_or_clone(e.selection));
|
||||
let selection = shared_selection(selection, st.history.undo.last());
|
||||
st.history.undo.push(crate::HistoryEntry::turned(e.label, doc, selection, &st.doc, costs));
|
||||
let mut turned = crate::HistoryEntry::turned(e.label, doc, selection, &st.doc, costs);
|
||||
turned.named = e.named;
|
||||
st.history.undo.push(turned);
|
||||
st.history.trim();
|
||||
st.revision += 1;
|
||||
st.selection.prune(&st.doc);
|
||||
@@ -414,6 +427,19 @@ fn redo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({ "redone": label }))
|
||||
}
|
||||
|
||||
/// History panel › Clear History: removes the steps after the current state and the steps before
|
||||
/// it, and leaves the document as it is. Typing or a drag still in progress is first kept as a
|
||||
/// step, as Undo keeps it, so it is removed with the others and its change stays in the document.
|
||||
fn clear_history(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
settle_interaction(s)?;
|
||||
let st = s.doc_mut()?;
|
||||
let cleared = st.history.undo.len() + st.history.redo.len();
|
||||
st.history.undo.clear();
|
||||
st.history.redo.clear();
|
||||
st.revision += 1;
|
||||
Ok(json!({ "cleared": cleared }))
|
||||
}
|
||||
|
||||
/// `selection` for an undo (redo) step, sharing that of `like`, the step beside it, when the same.
|
||||
fn shared_selection(selection: vectorcraft_doc::Selection, like: Option<&crate::HistoryEntry>) -> Arc<vectorcraft_doc::Selection> {
|
||||
match like {
|
||||
|
||||
@@ -139,6 +139,7 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, "`params` must be an object"));
|
||||
}
|
||||
super::params::check_effect_color(C, id, ¶ms)?;
|
||||
super::params::check_effect_channel(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());
|
||||
@@ -317,6 +318,7 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let edited = set_params_effects(s, p);
|
||||
for effect in &edited {
|
||||
super::params::check_effect_color(C, effect, ¶ms)?;
|
||||
super::params::check_effect_channel(C, effect, ¶ms)?;
|
||||
}
|
||||
if edited.iter().any(|e| e == GLASS) {
|
||||
load_texture(&mut params, C)?;
|
||||
@@ -406,6 +408,7 @@ fn expandable(n: &Node) -> bool {
|
||||
!n.appearance.items.is_empty() || !n.appearance.effects.is_empty() || children.iter().any(|c| expandable(c))
|
||||
}
|
||||
NodeKind::Text(_) => !n.appearance.items.is_empty() || !n.appearance.effects.is_empty(),
|
||||
NodeKind::Image(_) => effects::has_item_geometry(n) || !n.appearance.effects.is_empty(),
|
||||
_ => !n.appearance.effects.is_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,7 +318,7 @@ fn expand_fills(d: &mut Document, l: &Node, to: GradientTo) -> Option<Node> {
|
||||
/// 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))?;
|
||||
let m = GradientMesh::for_path_with(path, FREEFORM_MESH, FREEFORM_MESH, &*gradient_sampler(g, bounds, d.color_mode.model()))?;
|
||||
vec![Node::new(d.alloc_id(), NodeKind::Mesh(m))]
|
||||
} else {
|
||||
let frame = Frame::new(g, bounds);
|
||||
@@ -720,29 +720,24 @@ fn tile_art(d: &mut Document, l: &Node, f: &FillLayer, tiles: &[Node]) -> Option
|
||||
|
||||
/// The colour and opacity gradient fill `g` paints at a document point of an object whose box is
|
||||
/// `bounds`, as the canvas draws it (the end colours continue past the ends). Colours stay in the
|
||||
/// colour model their stops (or freeform points) share; display RGB where models differ.
|
||||
pub(crate) fn gradient_sampler(g: &GradientPaint, bounds: Rect) -> Box<dyn Fn(Point) -> (Color, f32) + '_> {
|
||||
/// color model their stops share; display RGB where models differ. Freeform points mix in the
|
||||
/// model they share ([`vectorcraft_color::Freeform::model_field`]); where their models differ, the
|
||||
/// RGB color they mix to is stored in `doc_model` (the document's model) the way `paint.setFill`
|
||||
/// stores an RGB color ([`super::paint::stored_color`]).
|
||||
pub(crate) fn gradient_sampler(g: &GradientPaint, bounds: Rect, doc_model: Model) -> Box<dyn Fn(Point) -> (Color, f32) + '_> {
|
||||
if g.gradient.kind == GradientKind::Freeform {
|
||||
let b = bounds.abs();
|
||||
let f = g.freeform_on(b);
|
||||
let model = shared_model(f.points.iter().map(|p| p.color));
|
||||
let field = f.field(spread_scale(b));
|
||||
let field = g.freeform_on(b).model_field(spread_scale(b));
|
||||
let store_in = (field.model().is_none() && doc_model != Model::Rgb).then_some(doc_model);
|
||||
return Box::new(move |p| {
|
||||
let ([r, g, b], a) = field.sample(p);
|
||||
let c = Color::rgb(r, g, b);
|
||||
(model.map_or(c, |m| c.in_model(m)), a)
|
||||
let (c, a) = field.sample(p);
|
||||
(super::paint::stored_color(store_in, c), a)
|
||||
});
|
||||
}
|
||||
let geom = g.resolve(bounds);
|
||||
Box::new(move |p| g.gradient.sample_with(geom.param_at(g.gradient.kind, p) as f32, lerp_color))
|
||||
}
|
||||
|
||||
/// The colour model all of `colors` share.
|
||||
fn shared_model(mut colors: impl Iterator<Item = Color>) -> Option<Model> {
|
||||
let m = colors.next()?.model();
|
||||
colors.all(|c| c.model() == m).then_some(m)
|
||||
}
|
||||
|
||||
/// Where the colour changes along a gradient between parameters `t0` and `t1`: both ends, every
|
||||
/// stop between them (in its own colour, so coincident stops keep a sharp edge) and every midpoint
|
||||
/// off the centre, as (parameter, colour, opacity) in order, with more between them where they
|
||||
|
||||
@@ -140,8 +140,9 @@ fn batch_datasets(s: &mut Session, cmd: &str, run: &Run<'_>) -> Result<Value> {
|
||||
}
|
||||
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.
|
||||
// destination uses the same name). `file_part` writes the characters Windows does not allow in a
|
||||
// file name (`/`, `\`, `:`, `<`, `>`, `"`, `|`, `?`, `*` and control characters) 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 };
|
||||
@@ -246,7 +247,8 @@ fn write_result(s: &mut Session, path: &str, ext: &str, as_copy: bool) -> Result
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Point Save / Export steps at `dest_folder/{stem}.{ext}` when overrides are on.
|
||||
/// Point Save / Export steps at `dest_folder/{stem}.{ext}` when overrides are on: `ext` is the
|
||||
/// extension of the step's `format` ([`format_extension`]), else `default_ext`.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn rewrite_paths(
|
||||
steps: &[Value],
|
||||
@@ -271,8 +273,10 @@ fn rewrite_paths(
|
||||
}
|
||||
let params = c.get("params").cloned().unwrap_or(json!({}));
|
||||
let mut params = params.as_object().cloned().unwrap_or_default();
|
||||
let ext = params.get("format").and_then(Value::as_str).unwrap_or(default_ext);
|
||||
let ext = if ext.is_empty() { default_ext } else { ext };
|
||||
let ext = match params.get("format").and_then(Value::as_str).filter(|f| !f.is_empty()) {
|
||||
Some(f) => format_extension(f),
|
||||
None => default_ext.to_string(),
|
||||
};
|
||||
let file = std::path::Path::new(folder).join(format!("{stem}.{ext}"));
|
||||
params.insert("path".into(), json!(file.to_string_lossy()));
|
||||
if let Some(o) = c.as_object_mut() {
|
||||
@@ -283,6 +287,21 @@ fn rewrite_paths(
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The extension of the file a step writes in `format` (an id or extension, as [`super::format`]
|
||||
/// reads it: any case, a leading dot allowed): the extension as written, in lower case and without
|
||||
/// the dot, when it is one of the format's (`SVG` → `svg`, `.JPEG` → `jpeg`), else the format's
|
||||
/// first (`png8` → `png`, `template` → `vctemplate`). An unknown format (`xyz`) keeps its spelling,
|
||||
/// and the step fails with its command's error.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn format_extension(format: &str) -> String {
|
||||
let written = format.trim_start_matches('.').to_ascii_lowercase();
|
||||
match super::format(format) {
|
||||
Some(f) if f.extensions.contains(&written.as_str()) => written,
|
||||
Some(f) => f.extensions.first().map_or(written, |e| (*e).to_string()),
|
||||
None => format.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Readable files under `folder` (and optionally its subfolders), capped.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn collect_files(folder: &str, include_subdirs: bool, depth: usize) -> Result<Vec<String>> {
|
||||
@@ -410,6 +429,70 @@ mod tests {
|
||||
assert!(dest.join("only.svg").is_file());
|
||||
}
|
||||
|
||||
/// Override Export names each file with the extension of the format the step names, however
|
||||
/// the step spells it; a format given by one of its extensions keeps that extension.
|
||||
#[test]
|
||||
fn batch_override_export_names_files_with_the_format_s_extension() {
|
||||
let src = tmp("ext-src");
|
||||
write_svg(&src, "a.svg", "#f00");
|
||||
let cases = [(".svg", "a.svg"), ("SVG", "a.svg"), ("svg", "a.svg"), ("png8", "a.png"), ("template", "a.vctemplate"), (".JPEG", "a.jpeg")];
|
||||
for (i, (format, want)) in cases.into_iter().enumerate() {
|
||||
let dest = tmp(&format!("ext-out-{i}"));
|
||||
let mut s = Session::new();
|
||||
let r = s
|
||||
.execute(
|
||||
"action.batch",
|
||||
&json!({
|
||||
"folder": src.to_string_lossy(),
|
||||
"destinationFolder": dest.to_string_lossy(),
|
||||
"commands": [{"command": "document.export", "params": {"format": format}}]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["failed"], 0, "{format}: {r}");
|
||||
assert_eq!(file_names(&dest), [want], "{format}: {r}");
|
||||
}
|
||||
// An unknown format keeps its spelling, and the step fails with the export's error.
|
||||
let dest = tmp("ext-out-unknown");
|
||||
let mut s = Session::new();
|
||||
let r = s
|
||||
.execute(
|
||||
"action.batch",
|
||||
&json!({
|
||||
"folder": src.to_string_lossy(),
|
||||
"destinationFolder": dest.to_string_lossy(),
|
||||
"commands": [{"command": "document.export", "params": {"format": "xyz"}}]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["failed"], 1, "{r}");
|
||||
assert!(r["processed"][0]["error"].as_str().is_some_and(|e| e.contains("unknown format `xyz`")), "{r}");
|
||||
assert!(file_names(&dest).is_empty(), "{r}");
|
||||
}
|
||||
|
||||
/// Override Save names each file with the extension of the format the step names, also over
|
||||
/// data sets.
|
||||
#[test]
|
||||
fn batch_override_save_names_files_with_the_format_s_extension() {
|
||||
for (i, format) in [".svg", "SVG", ".PDF"].into_iter().enumerate() {
|
||||
let dest = tmp(&format!("ext-save-{i}"));
|
||||
let mut s = with_data_sets(&["a"]);
|
||||
let r = s
|
||||
.execute(
|
||||
"action.batch",
|
||||
&json!({
|
||||
"source": "datasets",
|
||||
"destinationFolder": dest.to_string_lossy(),
|
||||
"commands": [{"command": "file.saveAs", "params": {"format": format}}]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["failed"], 0, "{format}: {r}");
|
||||
let want = format!("Untitled_a.{}", format.trim_start_matches('.').to_ascii_lowercase());
|
||||
assert_eq!(file_names(&dest), [want], "{format}: {r}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_datasets_applies_each_row() {
|
||||
let dest = tmp("sets");
|
||||
@@ -516,6 +599,32 @@ mod tests {
|
||||
assert_eq!(file_names(&exports), ["Untitled_a-b.svg", "Untitled_c-d.svg", "Untitled_e-f.svg"], "{r}");
|
||||
}
|
||||
|
||||
/// Windows does not allow `<`, `>`, `"`, `|`, `?`, `*` or control characters in a file name:
|
||||
/// Batch writes them as `-` in a data set's name, on every platform.
|
||||
#[test]
|
||||
fn batch_saves_data_sets_named_with_characters_windows_does_not_allow() {
|
||||
let dest = tmp("set-names-win");
|
||||
let mut s = with_data_sets(&["a?b", "c*d<e>", "f\"g|h", "i\u{7}j"]);
|
||||
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-e-.vectorcraft", "Untitled_f-g-h.vectorcraft", "Untitled_i-j.vectorcraft"],
|
||||
"{r}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_gives_each_data_set_a_file_of_its_own() {
|
||||
let dest = tmp("set-names-same");
|
||||
|
||||
@@ -278,7 +278,7 @@ pub(super) fn encode(doc: &Document, p: &Value) -> Result<Encoded> {
|
||||
if transparent && flat.is_some() {
|
||||
enc.warnings.push("transparency is flattened into opaque art and images (EPS has none): see the flattener preset".into());
|
||||
}
|
||||
let created = vectorcraft_doc::metadata::now_unix();
|
||||
let created = vectorcraft_doc::metadata::output_time();
|
||||
let mut renderer = vectorcraft_render::Renderer::new();
|
||||
for (board, region, origin) in pages {
|
||||
let rect = vectorcraft_eps::preview_rect(region, origin);
|
||||
|
||||
@@ -12,8 +12,7 @@ use crate::DocState;
|
||||
use super::super::*;
|
||||
use super::encode::single_artboard;
|
||||
use super::{
|
||||
ARTBOARD_PARAMS, Format, default_name, encode, encode_all, encode_with_warnings, merge, with_black_output, writable, write_encoded,
|
||||
write_or_return,
|
||||
ARTBOARD_PARAMS, Format, default_name, encode_all, encode_with_warnings, merge, with_black_output, writable, write_encoded, write_or_return,
|
||||
};
|
||||
|
||||
pub(super) fn serialize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -173,7 +172,7 @@ fn selection(s: &mut Session, cmd: &str) -> Result<(Document, vectorcraft_geom::
|
||||
|
||||
/// 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>> {
|
||||
pub(crate) fn selected_artwork(s: &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, .. }));
|
||||
@@ -224,7 +223,7 @@ pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// File → Save for Office Documents: one artboard as a PNG at `ppi`, on white unless
|
||||
/// `transparent`.
|
||||
/// `transparent`, with the encoder's warnings.
|
||||
pub(super) fn export_for_office(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "document.exportForOffice";
|
||||
if let Some(path) = str_param(p, "path") {
|
||||
@@ -242,8 +241,8 @@ pub(super) fn export_for_office(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let background = if bool_or(p, "transparent", false) { "transparent" } else { "white" };
|
||||
let opts = with_black_output(s, &json!({ "ppi": ppi, "background": background, "artboard": artboard }));
|
||||
let bytes = encode(doc, "png", &opts)?;
|
||||
let (bytes, warnings) = encode_with_warnings(doc, "png", &opts)?;
|
||||
// The pixel size, from the PNG header.
|
||||
let side = |at: usize| bytes.get(at..at + 4).and_then(|b| b.try_into().ok()).map_or(0, u32::from_be_bytes);
|
||||
write_or_return(path, &bytes, json!({ "width": side(16), "height": side(20) }))
|
||||
write_or_return(path, &bytes, json!({ "width": side(16), "height": side(20), "warnings": warnings }))
|
||||
}
|
||||
@@ -15,7 +15,9 @@
|
||||
//! blending mode written opaque is a box hiding the art under it (version 7 images have no alpha
|
||||
//! channel, §8.1). With `flatten` (the default) each becomes an opaque image of itself and the art
|
||||
//! under it, rendered from the document as it is, clipped to the area where it changes what shows;
|
||||
//! the art under it stays as it was. With `omit` they are left out.
|
||||
//! the art under it stays as it was. With `omit` they are left out. An image that can't be decoded
|
||||
//! is missing from the images that flattening and expanding raster effects draw, and the warnings
|
||||
//! then carry the raster exports' note once.
|
||||
//!
|
||||
//! The format can't hide objects, only layers (`hiddenObjects`): with `layer` (the default) each
|
||||
//! layer's hidden objects go to a hidden layer of their own just above it, with `omit` they are
|
||||
@@ -33,15 +35,20 @@ use vectorcraft_geom::{Anchor, FillRule, PathData, Point, Rect, SubPath};
|
||||
use super::super::*;
|
||||
use super::{ArtboardPick, Encoded, Format};
|
||||
|
||||
/// Does `p` ask for a legacy `.ai` file of version 7 (`aiVersion: "7"`)? An unknown version is an error.
|
||||
pub(super) fn wanted(cmd: &str, p: &Value) -> Result<bool> {
|
||||
match p.get("aiVersion") {
|
||||
None | Some(Value::Null) => Ok(false),
|
||||
Some(Value::String(v)) if v.trim().is_empty() || v.eq_ignore_ascii_case("pdf") => Ok(false),
|
||||
Some(Value::String(v)) if v.trim() == "7" => Ok(true),
|
||||
Some(Value::Number(n)) if n.as_f64() == Some(7.0) => Ok(true),
|
||||
Some(v) => Err(bad(cmd, format!("aiVersion {v}: \"pdf\" (PDF-compatible, the default) or \"7\" (legacy .ai, version 7)"))),
|
||||
/// Does `p` ask a save in `f` for a legacy `.ai` file of version 7 (`aiVersion: "7"`)? An unknown
|
||||
/// version is an error, and so is version 7 for a `.ait` template, which is PDF-compatible only.
|
||||
pub(super) fn wanted(cmd: &str, f: &Format, p: &Value) -> Result<bool> {
|
||||
let legacy = match p.get("aiVersion") {
|
||||
None | Some(Value::Null) => false,
|
||||
Some(Value::String(v)) if v.trim().is_empty() || v.eq_ignore_ascii_case("pdf") => false,
|
||||
Some(Value::String(v)) if v.trim() == "7" => true,
|
||||
Some(Value::Number(n)) if n.as_f64() == Some(7.0) => true,
|
||||
Some(v) => return Err(bad(cmd, format!("aiVersion {v}: \"pdf\" (PDF-compatible, the default) or \"7\" (legacy .ai, version 7)"))),
|
||||
};
|
||||
if legacy && f.id == "ait" {
|
||||
return Err(bad(cmd, "aiVersion 7 writes legacy .ai files; a .ait template is always PDF-compatible"));
|
||||
}
|
||||
Ok(legacy)
|
||||
}
|
||||
|
||||
/// What a legacy `.ai` does with the images it can't hold (`transparentImages`).
|
||||
@@ -103,7 +110,8 @@ pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result
|
||||
if boards.len() > 1 {
|
||||
enc.warnings.push(SEPARATE.to_string());
|
||||
}
|
||||
let created = vectorcraft_doc::metadata::now_unix();
|
||||
let created = vectorcraft_doc::metadata::output_time();
|
||||
let failed = vectorcraft_render::decode_failures();
|
||||
for b in boards {
|
||||
let one = super::save::artboard_doc(doc, b);
|
||||
let (full, placed) = crate::cmd::place::document::full_documents(&one);
|
||||
@@ -127,6 +135,11 @@ pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result
|
||||
}
|
||||
enc.files.push((Some(b), out.bytes));
|
||||
}
|
||||
// Expanding raster effects and flattening images draw the art, and an image the renderer
|
||||
// couldn't decode drew nothing there.
|
||||
if let Some(w) = super::super::rasterfx::undecoded_warning(failed) {
|
||||
note(&mut enc.warnings, w);
|
||||
}
|
||||
Ok(enc)
|
||||
}
|
||||
|
||||
|
||||
@@ -194,6 +194,12 @@ fn from_editing_data_alone(bytes: &[u8], opts: &LoadOptions) -> Option<Result<(D
|
||||
)
|
||||
}
|
||||
|
||||
/// Do `opts` read every page of the PDF `bytes`: no `pages`, or a range that names each one?
|
||||
fn every_page(bytes: &[u8], opts: &LoadOptions) -> bool {
|
||||
let Some(range) = &opts.pages else { return true };
|
||||
vectorcraft_pdf::info(bytes, opts.password.as_deref()).is_ok_and(|i| parse_range(range, i.pages.len()).is_ok_and(|p| p.len() == i.pages.len()))
|
||||
}
|
||||
|
||||
/// [`load`] with `document.open` options (the PDF pages, box and password; the DXF options).
|
||||
pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded> {
|
||||
let format = detect(name, bytes).ok_or_else(|| match super::unsupported(&super::extension(name)) {
|
||||
@@ -239,9 +245,11 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
let alone = r.only_text.then(|| from_editing_data_alone(bytes, opts)).flatten().and_then(|read| read.ok());
|
||||
Ok(alone.unwrap_or_else(|| through_editing_data(bytes, opts, r.document, r.warnings, r.user_unit)))
|
||||
}
|
||||
// Saved without its PDF part: the editing copy is all the file has.
|
||||
// Saved without its PDF part: the editing copy is all the file has (`pages` that
|
||||
// name every page read it as the whole file).
|
||||
Err(e) if e.to_string() == vectorcraft_pdf::PdfError::PlaceholderOnly.to_string() => {
|
||||
from_editing_data_alone(bytes, opts).unwrap_or(Err(e))
|
||||
let whole = LoadOptions { pages: None, ..opts.clone() };
|
||||
from_editing_data_alone(bytes, if every_page(bytes, opts) { &whole } else { opts }).unwrap_or(Err(e))
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
})?
|
||||
@@ -270,9 +278,13 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
{
|
||||
super::super::fileinfo::read_pdf(&mut doc.metadata, info);
|
||||
}
|
||||
// Threaded type read from a file flows through its frames.
|
||||
// Threaded type read from a file flows through its frames (the threaded type of a restored
|
||||
// document keeps its text as saved), and every frame of a thread takes its first frame's
|
||||
// Optical Margin Alignment setting.
|
||||
if !doc.text_threads.is_empty() && !restored {
|
||||
crate::cmd::threads::reflow(&Document::new(1.0, 1.0), &mut doc);
|
||||
} else if !doc.text_threads.is_empty() {
|
||||
crate::cmd::threads::share_optical_margin(&mut doc);
|
||||
}
|
||||
if let Some(mode) = opts.color_mode.filter(|m| *m != doc.color_mode) {
|
||||
super::super::colormgmt::set_color_mode(&mut doc, mode, true, None, opts.grays);
|
||||
@@ -293,8 +305,11 @@ pub(crate) fn import_svg(text: &str, folder: Option<&str>) -> Result<(Document,
|
||||
// The importer reads on a thread of its own (for its stack): what may be read is what may be
|
||||
// read here.
|
||||
let scope = crate::file_access::ScopeHandle::capture();
|
||||
// Paths are checked as stored in a document inside `folder` (only the folder is used).
|
||||
let document = folder.map(|f| std::path::Path::new(f).join("-").to_string_lossy().into_owned());
|
||||
let read = |path: &str| {
|
||||
scope.clone().run(|| {
|
||||
let path = &super::document_path(path, document.as_deref()).ok()?;
|
||||
// Only files (not folders or devices) are read.
|
||||
file_stamp(path)?;
|
||||
let bytes = read_file(path).ok()?;
|
||||
@@ -470,8 +485,14 @@ pub(crate) fn too_large_note(bytes: &[u8]) -> Option<String> {
|
||||
/// 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`]).
|
||||
/// 1-bit TIFFs compressed with CCITT Group 3, which the decoder can't read either, become RGBA
|
||||
/// too ([`vectorcraft_doc::cmyk::group3_tiff_rgba`]).
|
||||
/// 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 Targa file, which starts with no signature, is recognized by its header
|
||||
/// ([`super::tgaread::is_targa`]) and becomes RGBA with the transparency it stores
|
||||
/// ([`super::tgaread::fix_alpha`]). An image stored as PNG whose decoded samples, or whose pixels
|
||||
/// as RGBA, need more than [`MAX_RASTER_ALLOC`] is refused as too large to draw.
|
||||
/// 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> {
|
||||
@@ -484,6 +505,9 @@ pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
|
||||
return as_png(psdread::decode(bytes, MAX_RASTER_ALLOC, |c| cms.cmyk_to_srgb(c, false)).map_err(err)?, ppi);
|
||||
}
|
||||
let mut reader = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().map_err(err)?;
|
||||
if reader.format().is_none() && super::tgaread::is_targa(bytes) {
|
||||
reader.set_format(image::ImageFormat::Tga);
|
||||
}
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_alloc = Some(MAX_RASTER_ALLOC);
|
||||
reader.limits(limits);
|
||||
@@ -502,10 +526,25 @@ pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
|
||||
let cms = vectorcraft_color::cms::active();
|
||||
vectorcraft_doc::cmyk::extra_samples_tiff_rgba(bytes, |c| cms.cmyk_to_srgb(c, false))
|
||||
};
|
||||
let img = match (f.id == "tiff").then(extra_samples).flatten() {
|
||||
let mut img = match (f.id == "tiff").then(extra_samples).flatten() {
|
||||
Some(img) => img,
|
||||
None => reader.decode().map_err(err)?.to_rgba8(),
|
||||
None => match (f.id == "tiff").then(|| vectorcraft_doc::cmyk::group3_tiff_rgba(bytes)).flatten() {
|
||||
Some(read) => read.map_err(err)?,
|
||||
None => {
|
||||
use image::ImageDecoder as _;
|
||||
let decoder = reader.into_decoder().map_err(err)?;
|
||||
// The allowance counts the decoded samples and the RGBA pixels they become (#1717).
|
||||
let (w, h) = decoder.dimensions();
|
||||
if decoder.total_bytes() > MAX_RASTER_ALLOC || u64::from(w) * u64::from(h) * 4 > MAX_RASTER_ALLOC {
|
||||
return Err(err(format!("an image of {w} × {h} pixels is too large to draw")));
|
||||
}
|
||||
image::DynamicImage::from_decoder(decoder).map_err(err)?.to_rgba8()
|
||||
}
|
||||
},
|
||||
};
|
||||
if f.id == "tga" {
|
||||
super::tgaread::fix_alpha(bytes, &mut img);
|
||||
}
|
||||
as_png(img, ppi)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//! - `svg`: the SVG Options (styling, fonts, images, object ids, artboards…).
|
||||
//! - `export`: `document.export` / `serialize` / `exportSelection` / `exportForOffice`.
|
||||
//! - `screens`: Export for Screens (`document.exportForScreens`, `document.exportSettings`); `zip`
|
||||
//! packs its files as one download.
|
||||
//! packs its files as one download, and reads the files of Word documents placed as type.
|
||||
//! - `save`: `document.save`, Save As / a Copy / as Template, New from Template, Revert and the
|
||||
//! per-format options (`file.formatOptions`); [`save_with`] is the one save path of every frontend.
|
||||
//! - `pdf`: PDF settings and presets for every PDF export, `document.exportPdf`.
|
||||
@@ -40,12 +40,14 @@ pub mod pdf;
|
||||
mod pdfimport;
|
||||
pub mod pngtext;
|
||||
pub mod ppi;
|
||||
pub mod progress;
|
||||
pub mod psdread;
|
||||
mod save;
|
||||
mod screens;
|
||||
mod svg;
|
||||
mod text;
|
||||
mod zip;
|
||||
mod tgaread;
|
||||
pub mod zip;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -75,10 +77,11 @@ pub use save::{
|
||||
};
|
||||
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,
|
||||
builtin_presets as screen_export_builtin_presets, first_file as screen_first_file, 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,
|
||||
SHARED_KEYS as SCREEN_SETTINGS_KEYS, check_settings as check_screen_settings, export as export_screens, safe_file_name,
|
||||
store_settings as store_screen_settings,
|
||||
};
|
||||
pub use svg::options_map as svg_options;
|
||||
/// Atomic file writes (a temporary file renamed over the target: a failed write never damages the
|
||||
@@ -114,7 +117,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Serialize Document",
|
||||
[],
|
||||
None,
|
||||
"{format?: vectorcraft (default)|template|svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd, …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in their layers)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]",
|
||||
"{format?: vectorcraft (default)|template|svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd (ait, a .ait template, is written by file.saveAsTemplate), …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in their layers)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]",
|
||||
has_doc,
|
||||
export::serialize
|
||||
),
|
||||
@@ -123,7 +126,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…], 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",
|
||||
"{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; ait, a .ait template, is written by file.saveAsTemplate), 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
|
||||
),
|
||||
@@ -141,7 +144,7 @@ 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), 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)",
|
||||
"{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]); every reply has warnings: [the encoders' warnings for the files, each once: an image that couldn't be decoded, type written in the fallback font…]. 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
|
||||
),
|
||||
@@ -186,7 +189,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 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}",
|
||||
"{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 control characters 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
|
||||
),
|
||||
@@ -204,7 +207,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"PDF Info",
|
||||
[],
|
||||
None,
|
||||
"{path | name? | dataBase64 (a file's path, or its bytes), password?, thumbnail?: page (1-based), thumbnailSize?: 160 (px, longest side), cropTo?: crop (the thumbnail's box)} → {pages, needsPassword, wrongPassword?, pageInfo: [{width, height (pt, as shown), rotation, boxes: {media, crop, bleed, trim, art: [x0, y0, x1, y1] (PDF space, pt)}}], thumbnail?: PNG dataBase64}; a page whose UserUnit (PDF 1.6) is above 1 gives its size and boxes in pt, its numbers times the UserUnit (one above 75,000 counts as 75,000); an encrypted PDF without its password → {pages: 0, needsPassword: true}",
|
||||
"{path | name? | dataBase64 (a file's path, or its bytes), password?, thumbnail?: page (1-based), thumbnailSize?: 160 (px, longest side), cropTo?: crop (the thumbnail's box)} → {pages, needsPassword, wrongPassword?, pageInfo: [{width, height (pt, as shown), rotation, boxes: {media, crop, bleed, trim, art: [x0, y0, x1, y1] (PDF space, pt)}}], thumbnail?: PNG dataBase64}; a page whose UserUnit (PDF 1.6) is above 1 gives its size and boxes in pt, its numbers times the UserUnit (one above 75,000 counts as 75,000); an encrypted PDF without its password → {pages: 0, needsPassword: true}; a PostScript file (an EPS, a .ps file, a .ai in PostScript form) → {pages: 1, needsPassword: false, pageInfo: [{width, height (its bounding box, a US Letter page without one), rotation: 0, boxes: {}}]} (password and cropTo don't apply; thumbnail: 1 shows what the page prints)",
|
||||
always,
|
||||
pdfimport::pdf_info
|
||||
),
|
||||
@@ -213,7 +216,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Save for Office Documents…",
|
||||
["File"],
|
||||
None,
|
||||
"{path?, ppi?: 150 (72, 150, 300… pixels per inch), transparent?: false (else on white), artboard?: 0, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out)} the artboard as a PNG for office documents and slides → {path, bytes, width, height} (pixels); no path → {dataBase64, bytes, width, height}",
|
||||
"{path?, ppi?: 150 (72, 150, 300… pixels per inch), transparent?: false (else on white), artboard?: 0, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out)} the artboard as a PNG for office documents and slides → {path, bytes, width, height, warnings} (pixels); no path → {dataBase64, bytes, width, height, warnings}; warnings as for document.export (an image that couldn't be decoded, type in the fallback font or that doesn't fit its frame)",
|
||||
has_doc,
|
||||
export::export_for_office
|
||||
),
|
||||
@@ -644,11 +647,20 @@ pub const FORMATS: &[Format] = &[
|
||||
},
|
||||
Format { id: "emf", label: "EMF", extensions: &["emf"], mime: "image/emf", read: true, write: true, raster: false, options: metafile::OPTIONS },
|
||||
Format { id: "wmf", label: "WMF", extensions: &["wmf"], mime: "image/wmf", read: true, write: true, raster: false, options: metafile::OPTIONS },
|
||||
Format { id: "tga", label: "Targa", extensions: &["tga"], mime: "image/x-tga", read: false, write: true, raster: true, options: TGA_OPTIONS },
|
||||
Format {
|
||||
id: "tga",
|
||||
label: "Targa",
|
||||
extensions: &["tga", "vda", "icb", "vst"],
|
||||
mime: "image/x-tga",
|
||||
read: true,
|
||||
write: true,
|
||||
raster: true,
|
||||
options: TGA_OPTIONS,
|
||||
},
|
||||
Format { id: "psd", label: "PSD", extensions: &["psd"], mime: "image/x-psd", read: true, write: true, raster: true, options: PSD_OPTIONS },
|
||||
// Affinity Photo's `.afphoto` opens by its content but isn't listed: Finder shouldn't offer a
|
||||
// vector app for a raster editor's documents.
|
||||
reader("affinity", "Affinity", &["af", "afdesign", "afpub"], "application/vnd.affinity", false),
|
||||
reader("affinity", "Affinity", &["af", "afdesign", "afpub"], "application/x-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.
|
||||
@@ -672,6 +684,10 @@ pub const OPEN_EXTS: &[&str] = &[
|
||||
"tif",
|
||||
"tiff",
|
||||
"bmp",
|
||||
"tga",
|
||||
"vda",
|
||||
"icb",
|
||||
"vst",
|
||||
"psd",
|
||||
"psb",
|
||||
"vctemplate",
|
||||
@@ -686,11 +702,12 @@ pub const OPEN_EXTS: &[&str] = &[
|
||||
"afpub",
|
||||
];
|
||||
|
||||
/// 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, 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"];
|
||||
/// Extensions in [`OPEN_EXTS`] the packaging declares no type for: Photoshop documents belong to
|
||||
/// raster editors, so Finder and file managers don't offer a vector app for them, and there is no
|
||||
/// `.epsf`, `.ps` or Targa type (File › Open and Place still read them all). On Linux the desktop
|
||||
/// entry's EPS type (shared-mime-info's) also covers `.epsf`, so file managers offer VectorCraft
|
||||
/// for it.
|
||||
pub const UNASSOCIATED_EXTS: &[&str] = &["psd", "psb", "epsf", "ps", "tga", "vda", "icb", "vst"];
|
||||
|
||||
/// 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.
|
||||
@@ -710,7 +727,11 @@ pub const TEXT_EXTS: &[&str] = &["txt"];
|
||||
/// 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`].
|
||||
/// Word documents: File → Place sets them as area type with their formatting.
|
||||
pub const DOCX_EXTS: &[&str] = &["docx"];
|
||||
|
||||
/// Every extension File → Place reads: [`OPEN_EXTS`], [`TEXT_EXTS`], [`RTF_EXTS`] and
|
||||
/// [`DOCX_EXTS`].
|
||||
pub const PLACE_EXTS: &[&str] = &[
|
||||
"vectorcraft",
|
||||
"drawcraft",
|
||||
@@ -727,6 +748,10 @@ pub const PLACE_EXTS: &[&str] = &[
|
||||
"tif",
|
||||
"tiff",
|
||||
"bmp",
|
||||
"tga",
|
||||
"vda",
|
||||
"icb",
|
||||
"vst",
|
||||
"psd",
|
||||
"psb",
|
||||
"vctemplate",
|
||||
@@ -741,6 +766,7 @@ pub const PLACE_EXTS: &[&str] = &[
|
||||
"afpub",
|
||||
"txt",
|
||||
"rtf",
|
||||
"docx",
|
||||
];
|
||||
|
||||
/// One dialog filter per readable format.
|
||||
@@ -764,13 +790,14 @@ 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
|
||||
/// and RTF.
|
||||
/// File → Place dialog filters: "All placeable files", then one per placeable format, then text,
|
||||
/// RTF and Word.
|
||||
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)))
|
||||
.chain(std::iter::once(("Word", DOCX_EXTS)))
|
||||
}
|
||||
|
||||
/// A format by id or extension (any case, leading dot allowed; `jpeg` finds `jpg`).
|
||||
@@ -806,7 +833,8 @@ pub fn format_for_name(name: &str) -> Option<&'static Format> {
|
||||
Some(extension(name)).filter(|e| !e.is_empty()).and_then(|e| format(&e))
|
||||
}
|
||||
|
||||
/// The format to write: `format` (an id or extension), else the path's extension, else PNG.
|
||||
/// The format to write: `format` (an id or extension), else the path's extension, else PNG. For a
|
||||
/// `.ait` template it returns an error that names `file.saveAsTemplate`, which writes it.
|
||||
pub fn writable_format(format_param: Option<&str>, path: Option<&str>) -> std::result::Result<&'static Format, String> {
|
||||
// Formats that can't be written say what to use instead.
|
||||
let unknown = |what: String, key: &str| unsupported(key).map_or(what, |u| u.hint.to_string());
|
||||
@@ -815,7 +843,13 @@ pub fn writable_format(format_param: Option<&str>, path: Option<&str>) -> std::r
|
||||
(None, Some(e)) => format(&e).ok_or_else(|| unknown(format!("unknown extension `.{e}`: pass `format` (see document.formats)"), &e))?,
|
||||
(None, None) => format("png").ok_or("no PNG encoder")?,
|
||||
};
|
||||
if f.write { Ok(f) } else { Err(format!("{} files can be opened but not written (see document.formats)", f.label)) }
|
||||
if f.write {
|
||||
Ok(f)
|
||||
} else if TEMPLATE_FORMATS.contains(&f.id) {
|
||||
Err(format!("{} files are written by file.saveAsTemplate", f.label))
|
||||
} else {
|
||||
Err(format!("{} files can be opened but not written (see document.formats)", f.label))
|
||||
}
|
||||
}
|
||||
|
||||
/// [`writable_format`] for command `cmd`.
|
||||
@@ -843,9 +877,36 @@ fn formats(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
/// `path` checked for reading against the automation roots in force.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn may_read(path: &str) -> Result<()> {
|
||||
#[cfg(test)]
|
||||
touch(path);
|
||||
crate::file_access::check_read(path).map_err(EngineError::Other)
|
||||
}
|
||||
|
||||
/// Paths read while [`touched`] runs, on any thread (the SVG importer reads on one of its own).
|
||||
#[cfg(test)]
|
||||
static TOUCHED: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
|
||||
#[cfg(test)]
|
||||
static RECORDING: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
|
||||
#[cfg(test)]
|
||||
fn touch(path: &str) {
|
||||
if RECORDING.load(std::sync::atomic::Ordering::SeqCst) > 0 {
|
||||
TOUCHED.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(path.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f` and list the paths read through this module meanwhile (by any test running then).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn touched<T>(f: impl FnOnce() -> T) -> (T, Vec<String>) {
|
||||
use std::sync::atomic::Ordering::SeqCst;
|
||||
let start = TOUCHED.lock().unwrap_or_else(std::sync::PoisonError::into_inner).len();
|
||||
RECORDING.fetch_add(1, SeqCst);
|
||||
let out = f();
|
||||
RECORDING.fetch_sub(1, SeqCst);
|
||||
let seen = TOUCHED.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(start..).unwrap_or_default().to_vec();
|
||||
(out, seen)
|
||||
}
|
||||
|
||||
/// `path` checked for writing against the automation roots in force.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn may_write(path: &str) -> Result<()> {
|
||||
@@ -1003,8 +1064,7 @@ fn list_dir(_: &str, _: usize) -> Result<(Vec<Value>, bool)> {
|
||||
/// keeps one); `None` when there is no file at `path` (or automation may not read it).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn file_stamp(path: &str) -> Option<(u64, Option<u64>)> {
|
||||
may_read(path).ok()?;
|
||||
let m = std::fs::metadata(path).ok().filter(std::fs::Metadata::is_file)?;
|
||||
let m = file_metadata(path).filter(std::fs::Metadata::is_file)?;
|
||||
let modified = m.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_millis() as u64);
|
||||
Some((m.len(), modified))
|
||||
}
|
||||
@@ -1013,11 +1073,17 @@ pub(crate) fn file_stamp(path: &str) -> Option<(u64, Option<u64>)> {
|
||||
/// (and automation may read it).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn file_created(path: &str) -> Option<u64> {
|
||||
may_read(path).ok()?;
|
||||
let t = std::fs::metadata(path).ok()?.created().ok()?;
|
||||
let t = file_metadata(path)?.created().ok()?;
|
||||
t.duration_since(std::time::UNIX_EPOCH).ok().map(|d| d.as_millis() as u64)
|
||||
}
|
||||
|
||||
/// The file system's metadata of `path`, following links (when automation may read it).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn file_metadata(path: &str) -> Option<std::fs::Metadata> {
|
||||
may_read(path).ok()?;
|
||||
std::fs::metadata(path).ok()
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) fn file_created(_: &str) -> Option<u64> {
|
||||
None
|
||||
@@ -1033,6 +1099,81 @@ pub(crate) fn absolute_path(path: &str) -> String {
|
||||
std::path::absolute(path).map_or_else(|_| path.to_string(), |p| p.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
/// The file to open for `stored`, a path or `file:` URL stored in a document (a linked file, an
|
||||
/// SVG image's href), or why it is not followed. `document` is the path of the document it is
|
||||
/// stored in, when it has one: a relative path is taken from its folder. Only files on this
|
||||
/// computer, or on the network share the document is on, are followed (see `craft_docpath`); a
|
||||
/// path that is still relative (no document) is refused, as the working directory isn't the
|
||||
/// document's choice. On Windows, a document on a mapped drive is on the share behind the drive
|
||||
/// ([`share_path`]): a path refused as on another computer is checked again against the
|
||||
/// document's path on that share. Every stored path reaches the file system through this.
|
||||
pub(crate) fn document_path(stored: &str, document: Option<&str>) -> std::result::Result<String, craft_docpath::Refused> {
|
||||
document_path_with(stored, document, share_path)
|
||||
}
|
||||
|
||||
/// [`document_path`], with `share` giving a document's path on the network share behind its drive.
|
||||
pub(crate) fn document_path_with(
|
||||
stored: &str,
|
||||
document: Option<&str>,
|
||||
share: impl FnOnce(&str) -> Option<String>,
|
||||
) -> std::result::Result<String, craft_docpath::Refused> {
|
||||
use craft_docpath::Refused;
|
||||
let document = document.map(absolute_path);
|
||||
let resolve = |document: Option<&str>| craft_docpath::resolve(stored, document.map(std::path::Path::new));
|
||||
let path = match resolve(document.as_deref()) {
|
||||
Err(Refused::Network) => {
|
||||
let share = document.as_deref().and_then(share).ok_or(Refused::Network)?;
|
||||
resolve(Some(share.as_str()))?
|
||||
}
|
||||
path => path?,
|
||||
};
|
||||
if !path.is_absolute() {
|
||||
return Err(Refused::Invalid);
|
||||
}
|
||||
path.into_os_string().into_string().map_err(|_| Refused::Invalid)
|
||||
}
|
||||
|
||||
/// The path of `document` (on a drive, `Z:\jobs\doc.vectorcraft`) on the network share the drive is
|
||||
/// mapped to (`\\server\share\jobs\doc.vectorcraft`), when its folder is on one and automation may
|
||||
/// read the folder. The folder's final path ([`std::fs::canonicalize`]) names the share as
|
||||
/// `\\?\UNC\server\share\…`.
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn share_path(document: &str) -> Option<String> {
|
||||
share_of(document, |folder| {
|
||||
may_read(folder).ok()?;
|
||||
std::fs::canonicalize(folder).ok()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub(crate) fn share_path(_: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The path of `document` on the network share its drive is mapped to, given `final_path`, which
|
||||
/// returns the final path of a folder: `None` when `document` is not on a drive (`C:\` or `C:/`)
|
||||
/// or `final_path` gives no share (`\\?\UNC\server\share\…`), such as a folder on a local volume.
|
||||
#[cfg(any(windows, test))]
|
||||
pub(crate) fn share_of(document: &str, final_path: impl FnOnce(&str) -> Option<std::path::PathBuf>) -> Option<String> {
|
||||
let mut chars = document.chars();
|
||||
if !matches!((chars.next(), chars.next(), chars.next()), (Some(l), Some(':'), Some('\\' | '/')) if l.is_ascii_alphabetic()) {
|
||||
return None;
|
||||
}
|
||||
// The drive's root folder keeps its separator (`Z:\`).
|
||||
let split = document.rfind(['\\', '/'])?;
|
||||
let (folder, name) = (document.get(..split.max(3))?, document.get(split + 1..)?);
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let target = final_path(folder)?;
|
||||
let target = target.to_str()?;
|
||||
let unc = target.get(..8).filter(|p| p.eq_ignore_ascii_case(r"\\?\UNC\")).and(target.get(8..))?.trim_end_matches('\\');
|
||||
if unc.split('\\').filter(|c| !c.is_empty()).count() < 2 {
|
||||
return None;
|
||||
}
|
||||
Some(format!(r"\\{unc}\{name}"))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) fn file_stamp(_: &str) -> Option<(u64, Option<u64>)> {
|
||||
None
|
||||
|
||||
@@ -12,7 +12,10 @@
|
||||
//! (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 `-`.
|
||||
//! The characters Windows does not allow in a file name (`/`, `\`, `:`, `<`, `>`, `"`, `|`, `?`,
|
||||
//! `*` and control characters) become `-`, in the template's text and in what the tokens put in.
|
||||
|
||||
use super::screens::file_part;
|
||||
|
||||
/// What a template's tokens stand for, for one file.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
@@ -50,11 +53,6 @@ fn token(name: &str, p: &NameParts<'_>) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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>")
|
||||
@@ -72,12 +70,12 @@ pub fn render(template: &str, p: &NameParts<'_>) -> String {
|
||||
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.
|
||||
// Bracketed tokens; an unknown one stays as written, with `-` for its brackets.
|
||||
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));
|
||||
out.push_str(&value);
|
||||
rest = rest.get(end + 1..).unwrap_or("");
|
||||
continue;
|
||||
}
|
||||
@@ -94,10 +92,10 @@ pub fn render(template: &str, p: &NameParts<'_>) -> String {
|
||||
rest = rest.get(pat.len()..).unwrap_or("");
|
||||
continue;
|
||||
}
|
||||
out.push(if matches!(c, '/' | '\\' | ':') { '-' } else { c });
|
||||
out.push(c);
|
||||
rest = rest.get(c.len_utf8()..).unwrap_or("");
|
||||
}
|
||||
out.trim().to_string()
|
||||
file_part(out.trim())
|
||||
}
|
||||
|
||||
/// `YYYY-MM-DD` (or `YYYYMMDD`) as `[year, month, day, 0, 0, 0]`; `None` when it isn't a date.
|
||||
@@ -133,7 +131,7 @@ mod tests {
|
||||
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("<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"));
|
||||
}
|
||||
@@ -143,6 +141,9 @@ mod tests {
|
||||
let p = NameParts { name: "../../etc", ..parts() };
|
||||
let n = render("a/b\\c:<name>", &p);
|
||||
assert!(!n.contains('/') && !n.contains('\\') && !n.contains(':'), "{n}");
|
||||
// Nor any other character Windows does not allow in a file name, typed or put in.
|
||||
let p = NameParts { name: "<a>\"b|c?d*\u{0}\u{1f}", ..parts() };
|
||||
assert_eq!(render("<name>|?*\u{1}", &p), "-a--b-c-d-------");
|
||||
// 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");
|
||||
|
||||
@@ -103,7 +103,7 @@ pub const AI_OPTIONS: &[FormatOption] = &[
|
||||
name: "aiVersion",
|
||||
ty: "string",
|
||||
default: "\"pdf\"",
|
||||
description: "pdf (a PDF-compatible .ai carrying the VectorCraft document) or 7 (the legacy, PostScript-form .ai of version 7, which keeps layers, groups, compound paths and clipping groups in apps that read it; one file per artboard, transparent objects opaque; the other .ai options don't apply)",
|
||||
description: "pdf (a PDF-compatible .ai carrying the VectorCraft document) or 7 (the legacy, PostScript-form .ai of version 7, which keeps layers, groups, compound paths and clipping groups in apps that read it; one file per artboard, transparent objects opaque; the other .ai options don't apply; an error for a .ait template)",
|
||||
},
|
||||
FormatOption {
|
||||
name: "transparentImages",
|
||||
|
||||
@@ -235,11 +235,15 @@ pub fn expand_preset<'a>(s: &Session, cmd: &str, p: &'a Value) -> Result<Cow<'a,
|
||||
super::eps::with_flattener(s, cmd, expanded)
|
||||
}
|
||||
|
||||
/// The full export options for `doc`: settings plus the artboards `p` picks (default all).
|
||||
/// The full export options for `doc`: settings plus the artboards `p` picks (default all), created
|
||||
/// at `doc`'s File Info creation date (the time of writing when it has none) and modified at the
|
||||
/// time of writing. On the web, which has no clock, the creation date is the modification date too,
|
||||
/// and a document without one gets neither.
|
||||
pub fn options(cmd: &str, doc: &Document, p: &Value) -> Result<PdfOptions> {
|
||||
let pick = if p.is_object() { ArtboardPick::deserialize(p).map_err(|e| bad(cmd, format!("PDF options: {e}")))? } else { ArtboardPick::default() };
|
||||
let artboards = pick.resolve(doc.artboards.len()).map_err(|e| bad(cmd, e))?;
|
||||
Ok(PdfOptions { settings: settings(cmd, p)?, artboards, ..Default::default() })
|
||||
let now = vectorcraft_doc::metadata::output_time();
|
||||
Ok(PdfOptions { settings: settings(cmd, p)?, artboards, created: doc.metadata.created.or(now), modified: now, ..Default::default() })
|
||||
}
|
||||
|
||||
/// Why a PDF of some artboards carries no editing data.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Opening and placing PDF (and PDF-compatible `.ai`/`.ait`) files: the options `document.open`
|
||||
//! and `file.place` read ([`LoadOptions`]), the pages they import and `document.pdfInfo` (pages,
|
||||
//! boxes, a thumbnail) for the Import PDF dialog.
|
||||
//! boxes, a thumbnail; one page for a PostScript file) for the Import PDF dialog.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{ColorMode, Document};
|
||||
@@ -143,13 +143,13 @@ pub(super) fn import(bytes: &[u8], o: &LoadOptions, editor_document: bool) -> Re
|
||||
}
|
||||
|
||||
/// Page `page` (0-based) of a PDF as a document of one artboard (its `o.crop` box) and one
|
||||
/// layer of what it shows (text as outlines: it looks as printed), and the import notes.
|
||||
/// layer of what it shows, with the same text choice as Open, and the import notes.
|
||||
pub fn page_document(bytes: &[u8], page: usize, o: &LoadOptions) -> Result<(Document, Vec<String>)> {
|
||||
let opts = ImportOptions {
|
||||
pages: Some(vec![page]),
|
||||
crop: o.crop,
|
||||
password: o.password.clone(),
|
||||
text_as: TextAs::Outlines,
|
||||
text_as: o.text_as,
|
||||
layers: false,
|
||||
..Default::default()
|
||||
};
|
||||
@@ -162,6 +162,10 @@ const INFO: &str = "document.pdfInfo";
|
||||
pub(super) fn pdf_info(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
let bytes = source(p, INFO)?.bytes;
|
||||
let o = LoadOptions::from_params(INFO, p)?;
|
||||
// Programs whose header is only `%!` are PostScript too, as `clipboard.importEps` reads them.
|
||||
if bytes.starts_with(b"%!") || vectorcraft_pdf::is_postscript(&bytes) {
|
||||
return postscript_info(&bytes, p);
|
||||
}
|
||||
let info = match vectorcraft_pdf::info(&bytes, o.password.as_deref()) {
|
||||
Ok(i) => i,
|
||||
Err(e @ (PdfError::NeedsPassword | PdfError::WrongPassword)) => {
|
||||
@@ -179,14 +183,42 @@ pub(super) fn pdf_info(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
.collect();
|
||||
let mut out = json!({"pages": pages.len(), "needsPassword": false, "pageInfo": pages});
|
||||
if let Some(n) = p.get("thumbnail").and_then(Value::as_u64) {
|
||||
let page = usize::try_from(n).ok().and_then(|n| n.checked_sub(1)).filter(|i| *i < info.pages.len());
|
||||
let page = page.ok_or_else(|| bad(INFO, format!("thumbnail: no page {n} (the PDF has {})", info.pages.len())))?;
|
||||
if let Some(page) = thumbnail_page(p, info.pages.len(), "PDF")? {
|
||||
let (doc, _) = page_document(&bytes, page, &o)?;
|
||||
let size = f64_or(p, "thumbnailSize", 160.0).clamp(16.0, 1024.0);
|
||||
let r = doc.artboards.first().map(|a| a.rect).ok_or_else(|| err("the page has no area"))?;
|
||||
let png = super::encode(&doc, "png", &json!({"scale": size / r.width().max(r.height()).max(1.0)}))?;
|
||||
out["thumbnail"] = json!(vectorcraft_format::base64_encode(&png));
|
||||
out["thumbnail"] = json!(thumbnail(&doc, p)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The page (0-based) `document.pdfInfo`'s `thumbnail` names, from 1 to `count`; `None` when it is
|
||||
/// absent or null. Any other value (0, a negative or fractional number, a string) is an error that
|
||||
/// names the pages the `what` has.
|
||||
fn thumbnail_page(p: &Value, count: usize, what: &str) -> Result<Option<usize>> {
|
||||
let Some(v) = p.get("thumbnail").filter(|v| !v.is_null()) else { return Ok(None) };
|
||||
let page = v.as_u64().and_then(|n| usize::try_from(n).ok()).and_then(|n| n.checked_sub(1)).filter(|i| *i < count);
|
||||
page.map(Some).ok_or_else(|| bad(INFO, format!("thumbnail: no page {v} (the {what} has {count})")))
|
||||
}
|
||||
|
||||
/// `document.pdfInfo` of a PostScript file (an EPS, a `.ps` file, a `.ai` in PostScript form): one
|
||||
/// page, the page [`vectorcraft_eps::page`] reads, with no PDF boxes. Its thumbnail is the file as
|
||||
/// the EPS reader reads it without any editing data it carries: what the page prints, or the
|
||||
/// file's preview when its PostScript can't be read.
|
||||
fn postscript_info(bytes: &[u8], p: &Value) -> Result<Value> {
|
||||
let page = vectorcraft_eps::page(bytes).map_err(err)?;
|
||||
let info = json!({"width": page.width(), "height": page.height(), "rotation": 0, "boxes": {}});
|
||||
let mut out = json!({"pages": 1, "needsPassword": false, "pageInfo": [info]});
|
||||
if thumbnail_page(p, 1, "file")?.is_some() {
|
||||
let doc = vectorcraft_eps::import_with(bytes, false).map_err(err)?.document;
|
||||
out["thumbnail"] = json!(thumbnail(&doc, p)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A PNG of `doc`'s first artboard, `thumbnailSize` px (default 160, from 16 to 1024) on its longer
|
||||
/// side, base64.
|
||||
fn thumbnail(doc: &Document, p: &Value) -> Result<String> {
|
||||
let size = f64_or(p, "thumbnailSize", 160.0).clamp(16.0, 1024.0);
|
||||
let r = doc.artboards.first().map(|a| a.rect).ok_or_else(|| err("the page has no area"))?;
|
||||
let png = super::encode(doc, "png", &json!({"scale": size / r.width().max(r.height()).max(1.0)}))?;
|
||||
Ok(vectorcraft_format::base64_encode(&png))
|
||||
}
|
||||
@@ -220,6 +220,21 @@ mod tests {
|
||||
assert!(close(resolution(&jpg).unwrap(), (254.0, 240.0 * 2.54)));
|
||||
}
|
||||
|
||||
/// A CMYK JPEG carries no JFIF (it describes one- and three-component images), so its
|
||||
/// resolution is written as EXIF instead and this reader still finds it (#3084).
|
||||
#[test]
|
||||
fn a_cmyk_jpeg_keeps_its_resolution_without_jfif() {
|
||||
use vectorcraft_render::encode::jpeg::{self, ColorModel, JpegOptions};
|
||||
let o = JpegOptions { color_model: ColorModel::Cmyk, ..Default::default() };
|
||||
let jpg = jpeg::encode(&[0; 4 * 4], 2, 2, 90, Some(240.0), &o).unwrap();
|
||||
assert!(!jpg.windows(5).any(|w| w == b"JFIF\0"), "no JFIF on a CMYK file");
|
||||
assert_eq!(resolution(&jpg), Some((240.0, 240.0)));
|
||||
// An RGB file still says it in JFIF, as it always did.
|
||||
let rgb = jpeg::encode(&[0; 3 * 4], 2, 2, 90, Some(240.0), &JpegOptions::default()).unwrap();
|
||||
assert!(rgb.windows(5).any(|w| w == b"JFIF\0"));
|
||||
assert_eq!(resolution(&rgb), Some((240.0, 240.0)));
|
||||
}
|
||||
|
||||
/// A little-endian TIFF/EXIF block declaring `x`/`y` resolution in `unit`.
|
||||
fn exif_block(x: u32, y: u32, unit: u16) -> Vec<u8> {
|
||||
let mut b = b"II*\0".to_vec();
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Progress and Cancel of an export running off the UI thread (Background Export), on the shared
|
||||
//! [`vectorcraft_doc::cancel`] mechanism.
|
||||
//!
|
||||
//! The frontend makes a [`Progress`], runs the export inside [`scope`] on its worker and reads the
|
||||
//! counts from the UI thread to draw them; its Cancel button calls [`Progress::cancel`]. Inside the
|
||||
//! scope the encoders that work in steps (raster formats: one artboard at a time) report each step
|
||||
//! through [`vectorcraft_doc::cancel::progress`] and stop at the next check once cancelled; the
|
||||
//! renderer stops drawing and keeps no half-drawn pattern tile or effect raster. Encoders that
|
||||
//! make one file in one go report nothing: the frontend shows them as busy and checks
|
||||
//! [`Progress::cancelled`] itself before writing, so a cancelled export never writes a file.
|
||||
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use vectorcraft_doc::cancel::{self, CancelToken, ProgressSink};
|
||||
|
||||
/// How far an export has got, shared between the worker and the UI thread.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Progress {
|
||||
token: CancelToken,
|
||||
done: AtomicU64,
|
||||
total: AtomicU64,
|
||||
}
|
||||
|
||||
impl Progress {
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self::default())
|
||||
}
|
||||
|
||||
/// `(steps done, steps in all)`; `total` is 0 while the export can't say (one file made in one go).
|
||||
pub fn counts(&self) -> (u64, u64) {
|
||||
let total = self.total.load(Ordering::Relaxed);
|
||||
(self.done.load(Ordering::Relaxed).min(total), total)
|
||||
}
|
||||
|
||||
/// The share of the export done, 0 to 1; `None` while the export can't say.
|
||||
pub fn fraction(&self) -> Option<f64> {
|
||||
let (done, total) = self.counts();
|
||||
(total > 0).then(|| done as f64 / total as f64)
|
||||
}
|
||||
|
||||
/// Ask the export to stop: it writes nothing more, and a render under way stops drawing.
|
||||
pub fn cancel(&self) {
|
||||
self.token.cancel();
|
||||
}
|
||||
|
||||
pub fn cancelled(&self) -> bool {
|
||||
self.token.is_cancelled()
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f` with `progress`'s token in effect on this thread and the steps reported to it.
|
||||
pub fn scope<R>(progress: &Arc<Progress>, f: impl FnOnce() -> R) -> R {
|
||||
let to = Arc::clone(progress);
|
||||
let sink: ProgressSink = Rc::new(move |done, total, _| {
|
||||
to.total.store(total, Ordering::Relaxed);
|
||||
to.done.store(done, Ordering::Relaxed);
|
||||
});
|
||||
cancel::scope(&progress.token, Some(sink), f)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_raster_export_counts_its_artboards_and_stops_once_cancelled() {
|
||||
let mut doc = vectorcraft_doc::Document::new(100.0, 100.0);
|
||||
let second = doc.artboards[0].clone();
|
||||
doc.artboards.push(second);
|
||||
let p = Progress::new();
|
||||
assert_eq!(p.fraction(), None, "nothing reported yet");
|
||||
let png = scope(&p, || super::super::encode_all(&doc, "png", &serde_json::json!({"useArtboards": true})));
|
||||
assert!(png.is_ok(), "{:?}", png.err());
|
||||
assert_eq!(p.counts(), (1, 2), "the step under way when the last artboard started");
|
||||
p.cancel();
|
||||
let r = scope(&p, || super::super::encode_all(&doc, "png", &serde_json::json!({"useArtboards": true})));
|
||||
assert!(matches!(r, Err(crate::EngineError::Cancelled)), "{:?}", r.err());
|
||||
assert!(!cancel::is_cancelled(), "the scope ended");
|
||||
}
|
||||
}
|
||||
@@ -371,7 +371,7 @@ impl<W: std::io::Write> std::io::Write for Counted<W> {
|
||||
/// `separateArtboards`, those `range` names (default all).
|
||||
fn separate_boards(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<Vec<usize>> {
|
||||
// A legacy .ai file is a file per artboard already.
|
||||
if !is_native(f) || !bool_or(p, "separateArtboards", false) || super::legacy_ai::wanted(cmd, p)? {
|
||||
if !is_native(f) || !bool_or(p, "separateArtboards", false) || super::legacy_ai::wanted(cmd, f, p)? {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let range = str_param(p, "range").filter(|r| !r.trim().is_empty()).map(str::to_string);
|
||||
@@ -424,7 +424,7 @@ const NOT_PDF_COMPATIBLE: &str = "saved without PDF content: VectorCraft opens i
|
||||
/// The shared Save and Export encoder for PDF-compatible `.ai` files (and `.ait` templates, the
|
||||
/// same file).
|
||||
pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<Encoded> {
|
||||
if super::legacy_ai::wanted(cmd, p)? {
|
||||
if super::legacy_ai::wanted(cmd, f, p)? {
|
||||
return super::legacy_ai::encode(cmd, f, doc, p);
|
||||
}
|
||||
let mut params = if p.is_object() { p.clone() } else { json!({}) };
|
||||
@@ -508,7 +508,7 @@ fn job_for_stamped(s: &Session, st: &DocState, plan: SavePlan, stamped: Option<i
|
||||
}
|
||||
let boards = separate_boards(cmd, plan.format, &doc, ¶ms)?;
|
||||
let mut params = super::pdf::expand_preset(s, cmd, ¶ms)?.into_owned();
|
||||
let legacy = super::legacy_ai::wanted(cmd, ¶ms)?;
|
||||
let legacy = super::legacy_ai::wanted(cmd, plan.format, ¶ms)?;
|
||||
if let (Some(o), "ai" | "ait") = (params.as_object_mut(), plan.format.id) {
|
||||
// Save keeps every artboard; exports may choose a subset instead. A legacy .ai file per
|
||||
// artboard keeps a range given: the artboards to save.
|
||||
@@ -549,7 +549,10 @@ impl SaveJob {
|
||||
let one = self.encode_one(&artboard_doc(&self.doc, b))?;
|
||||
enc.files.extend(one.files.into_iter().map(|(_, bytes)| (Some(b), bytes)));
|
||||
}
|
||||
enc.warnings.splice(0..0, fidelity_warning(self.plan.format));
|
||||
// Lost artboards first: the status bar shows only the first warning.
|
||||
let f = self.plan.format;
|
||||
let lost = if is_svg(f) { super::svg::lost_artboards_warning(&self.doc, &self.params, f.label) } else { None };
|
||||
enc.warnings.splice(0..0, lost.into_iter().chain(fidelity_warning(f)));
|
||||
Ok(enc)
|
||||
}
|
||||
|
||||
@@ -558,7 +561,7 @@ impl SaveJob {
|
||||
let (cmd, f) = (self.plan.mode.command(), self.plan.format);
|
||||
let enc = if matches!(f.id, "ai" | "ait") { encode_ai(cmd, f, doc, &self.params)? } else { encode_all(doc, f.id, &self.params)? };
|
||||
// A legacy .ai file per artboard.
|
||||
let per_artboard = f.id == "ai" && super::legacy_ai::wanted(cmd, &self.params)?;
|
||||
let per_artboard = f.id == "ai" && super::legacy_ai::wanted(cmd, f, &self.params)?;
|
||||
if enc.files.len() != 1 && !per_artboard {
|
||||
return Err(bad(cmd, "Save writes one artboard: name one, or export several with document.export"));
|
||||
}
|
||||
@@ -797,7 +800,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
|
||||
"Save as Template…",
|
||||
["File"],
|
||||
None,
|
||||
"{…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}",
|
||||
"{…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; aiVersion 7 is an error); 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)
|
||||
),
|
||||
|
||||
@@ -18,10 +18,11 @@ use vectorcraft_geom::Rect;
|
||||
|
||||
use super::super::*;
|
||||
use super::encode::single_artboard;
|
||||
use super::export::{isolated, selected_artwork, without_artboards};
|
||||
use super::export::{export_crop, 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,
|
||||
ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names, create_dir, distinct_names, encode_with_warnings, unique_file_names,
|
||||
with_black_output, writable, write_file,
|
||||
};
|
||||
|
||||
const C: &str = "document.exportForScreens";
|
||||
@@ -148,10 +149,29 @@ fn safe_name(s: &str) -> Option<String> {
|
||||
(!n.trim_matches('.').is_empty()).then_some(n)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Text that goes into a file name (prefix, suffix, a data set's name in Batch, a name pattern's text
|
||||
/// and the names it puts in): the characters Windows does not allow in a file name (`/`, `\`, `:`,
|
||||
/// `<`, `>`, `"`, `|`, `?`, `*` and the control characters U+0000 to U+001F,
|
||||
/// <https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file>) become `-` on every
|
||||
/// platform, 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()
|
||||
s.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '<' | '>' | '"' | '|' | '?' | '*') || c < ' ' { '-' } else { c }).collect()
|
||||
}
|
||||
|
||||
/// A whole file or folder name (`[email protected]`, a sub-folder) through [`file_part`], without
|
||||
/// trailing periods and spaces, and with `-` after a Windows device name that is the whole name or
|
||||
/// comes before its first period: `CON`, `PRN`, `AUX`, `NUL`, `COM1` to `COM9` and `LPT1` to `LPT9`,
|
||||
/// also with `¹`, `²` or `³` for the digit, in any case (`CON.png` becomes `CON-.png`). The same on
|
||||
/// every platform.
|
||||
pub(crate) fn safe_file_name(s: &str) -> String {
|
||||
let name = file_part(s);
|
||||
let name = name.trim_end_matches(['.', ' ']);
|
||||
let (base, rest) = name.split_at(name.find('.').unwrap_or(name.len()));
|
||||
let upper = base.to_ascii_uppercase();
|
||||
let numbered = |n: &str| n.chars().count() == 1 && n.chars().all(|c| matches!(c, '1'..='9' | '¹' | '²' | '³'));
|
||||
let device = matches!(upper.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|
||||
|| upper.strip_prefix("COM").or_else(|| upper.strip_prefix("LPT")).is_some_and(numbered);
|
||||
if device { format!("{base}-{rest}") } else { name.to_string() }
|
||||
}
|
||||
|
||||
/// Keys of a format row that aren't encoder options.
|
||||
@@ -322,6 +342,97 @@ impl ScreenParams {
|
||||
fn of(p: &Value) -> Result<Self> {
|
||||
if p.is_object() { Self::deserialize(p).map_err(|e| bad(C, e.to_string())) } else { Ok(Self::default()) }
|
||||
}
|
||||
|
||||
/// An error when more than one of `assets`, `layers`, `selection` and `fullDocument` is given.
|
||||
fn one_source(&self) -> Result<()> {
|
||||
let sources = [self.full_document, self.assets.is_some(), self.layers.is_some(), self.selection.is_some()].iter().filter(|b| **b).count();
|
||||
if sources > 1 {
|
||||
return Err(bad(C, "give one of assets, layers, selection or fullDocument"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `selection`: `None` without it, `Some(false)` for `one` file, `Some(true)` for `each` object.
|
||||
fn selection_mode(&self) -> Result<Option<bool>> {
|
||||
match self.selection.as_deref() {
|
||||
None => Ok(None),
|
||||
Some("one") => Ok(Some(false)),
|
||||
Some("each") => Ok(Some(true)),
|
||||
Some(m) => Err(bad(C, format!("selection `{m}`: one or each"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How a batch export names its files and sorts them into folders.
|
||||
struct Naming<'a> {
|
||||
/// The `name` pattern, when it holds more than spaces.
|
||||
pattern: Option<&'a str>,
|
||||
/// The `prefix`, through [`file_part`].
|
||||
prefix: String,
|
||||
/// The document's name, safe as a file name (`<filename>`, the Full Document's item).
|
||||
filename: String,
|
||||
/// The date and time the pattern's tokens use.
|
||||
when: [i64; 6],
|
||||
counter_start: u64,
|
||||
/// The counter counts files rather than items.
|
||||
per_file: bool,
|
||||
folders: Folders,
|
||||
}
|
||||
|
||||
impl<'a> Naming<'a> {
|
||||
/// The naming of params `o` for a document titled `title`.
|
||||
fn of(o: &'a ScreenParams, title: &str) -> Result<Self> {
|
||||
let folders = Folders::of(o)?;
|
||||
// A prefix names files, not folders.
|
||||
let prefix = file_part(o.prefix.as_deref().unwrap_or(""));
|
||||
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::output_time().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 filename = safe_name(&super::file_stem(title)).unwrap_or_else(|| "Untitled".into());
|
||||
let pattern = o.name.as_deref().filter(|n| !n.trim().is_empty());
|
||||
Ok(Self { pattern, prefix, filename, when, counter_start: o.counter_start.unwrap_or(1), per_file, folders })
|
||||
}
|
||||
|
||||
/// The sub-folder (`None` for the export folder itself) and the file name of the file row `r`
|
||||
/// writes of `item`, the `index`th item (from 0), when `files` files came before it.
|
||||
fn file(&self, item: &Item, index: usize, files: u64, r: &Row) -> (Option<String>, String) {
|
||||
let counter = self.counter_start.saturating_add(if self.per_file { files } else { index as u64 });
|
||||
let ext = r.format.extensions.first().copied().unwrap_or(r.format.id);
|
||||
let prefix = &self.prefix;
|
||||
let stem = match self.pattern {
|
||||
Some(pattern) => {
|
||||
let parts = NameParts {
|
||||
filename: &self.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: self.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 = safe_file_name(&format!("{stem}.{ext}"));
|
||||
let sub = match self.folders {
|
||||
Folders::None => None,
|
||||
Folders::Rows => Some(safe_file_name(&r.folder)),
|
||||
Folders::Format => Some(ext.to_string()),
|
||||
Folders::Item => Some(item.stem.clone()),
|
||||
Folders::ItemFormat => Some(format!("{}/{ext}", item.stem)),
|
||||
};
|
||||
(sub, file)
|
||||
}
|
||||
}
|
||||
|
||||
/// The format rows `p` asks for (`formats`, else `preset`'s, else one PNG at 1x), each with the
|
||||
@@ -541,40 +652,20 @@ pub(super) fn presets_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// 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.
|
||||
/// so do different files the `name` pattern gives one name. The reply's `warnings` lists every
|
||||
/// warning the encoders give for the files, each once.
|
||||
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())?;
|
||||
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 folders = Folders::of(&o)?;
|
||||
// A prefix names files, not folders.
|
||||
let prefix = file_part(o.prefix.as_deref().unwrap_or(""));
|
||||
o.one_source()?;
|
||||
let naming = Naming::of(&o, &s.doc()?.doc.title)?;
|
||||
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 selection = o.selection_mode()?;
|
||||
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();
|
||||
@@ -586,43 +677,26 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
doc
|
||||
};
|
||||
let crop = rows.iter().any(|r| r.format.id != "pdf");
|
||||
let items = items(&doc, &o, selection, &selected, &filename, crop)?;
|
||||
let items = items(&doc, &o, selection, &selected, &naming.filename, crop, true)?;
|
||||
let mut out = Output::default();
|
||||
// The encoders' warnings for every file, each once.
|
||||
let mut warnings: Vec<String> = Vec::new();
|
||||
// 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));
|
||||
let mut write = |doc: &Document, format: &str, options: &Value| -> Result<Vec<u8>> {
|
||||
let (bytes, notes) = encode_with_warnings(doc, format, &with_black_output(s, options))?;
|
||||
for w in notes {
|
||||
if !warnings.contains(&w) {
|
||||
warnings.push(w);
|
||||
}
|
||||
}
|
||||
Ok(bytes)
|
||||
};
|
||||
// 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 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 (sub, file) = naming.file(item, index, files, r);
|
||||
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) {
|
||||
@@ -644,34 +718,80 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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}{filename}.zip");
|
||||
let zip_name = safe_file_name(&format!("{}{}.zip", naming.prefix, naming.filename));
|
||||
return match folder {
|
||||
Some(dir) => {
|
||||
let path = format!("{dir}/{zip_name}");
|
||||
write_file(&path, &zip)?;
|
||||
Ok(json!({ "path": path, "bytes": zip.len(), "files": names }))
|
||||
Ok(json!({ "path": path, "bytes": zip.len(), "files": names, "warnings": warnings }))
|
||||
}
|
||||
None => Ok(json!({ "name": zip_name, "dataBase64": vectorcraft_format::base64_encode(&zip), "bytes": zip.len(), "files": names })),
|
||||
None => Ok(
|
||||
json!({ "name": zip_name, "dataBase64": vectorcraft_format::base64_encode(&zip), "bytes": zip.len(), "files": names, "warnings": warnings }),
|
||||
),
|
||||
};
|
||||
}
|
||||
Ok(match folder {
|
||||
Some(_) => json!({ "files": out.written }),
|
||||
Some(_) => json!({ "files": out.written, "warnings": warnings }),
|
||||
None => {
|
||||
json!({ "files": out.kept.iter().map(|(name, bytes)| json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })).collect::<Vec<_>>() })
|
||||
let files: Vec<Value> =
|
||||
out.kept.iter().map(|(name, bytes)| json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })).collect();
|
||||
json!({ "files": files, "warnings": warnings })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The path, relative to the export folder, of the first file `document.exportForScreens` with
|
||||
/// params `p` writes (sub-folders included: `2x/[email protected]`): the first format row's file of the
|
||||
/// first artboard, asset, layer or selected object, or of the whole document, named as [`export`]
|
||||
/// names it. It encodes and writes nothing and cuts no art out of the document: of the objects
|
||||
/// selected for a file each, it measures only the first ones, up to the first that draws something.
|
||||
/// It does not refuse what the export refuses for drawing nothing: a checked asset or layer, the
|
||||
/// selection as one file, or the Full Document with no visible art.
|
||||
/// The Export for Screens dialog shows it on its Example line.
|
||||
pub fn first_file(s: &Session, p: &Value) -> Result<String> {
|
||||
let p = with_preset(s, p)?;
|
||||
let o = ScreenParams::of(&p)?;
|
||||
let rows = rows_of(s, &p, o.preset.as_deref())?;
|
||||
o.one_source()?;
|
||||
let selection = o.selection_mode()?;
|
||||
let doc = &s.doc()?.doc;
|
||||
let mut selected = if selection.is_some() { selected_artwork(s)? } else { vec![] };
|
||||
// A file per object: objects that draw nothing write no file.
|
||||
if selection == Some(true) {
|
||||
selected = selected.into_iter().find(|id| export_crop(doc, &[*id]).is_some()).into_iter().collect();
|
||||
}
|
||||
let naming = Naming::of(&o, &doc.title)?;
|
||||
let items = items(doc, &o, selection, &selected, &naming.filename, false, false)?;
|
||||
let (Some(item), Some(r)) = (items.first(), rows.first()) else { return Err(bad(C, "nothing to export")) };
|
||||
let (sub, file) = naming.file(item, 0, 0, r);
|
||||
Ok(sub.map_or_else(|| file.clone(), |d| format!("{d}/{file}")))
|
||||
}
|
||||
|
||||
/// 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>> {
|
||||
/// `crop`: some row needs the Full Document's art bounds (a format other than PDF). `art`: each
|
||||
/// asset, layer and selected object is cut out of the document; without it, only the items' names
|
||||
/// are made, every item holds the whole document, and none is left out for drawing nothing.
|
||||
fn items(
|
||||
doc: &Arc<Document>,
|
||||
o: &ScreenParams,
|
||||
selection: Option<bool>,
|
||||
selected: &[NodeId],
|
||||
filename: &str,
|
||||
crop: bool,
|
||||
art: bool,
|
||||
) -> Result<Vec<Item>> {
|
||||
// The art of `ids` alone, as an item named `name`.
|
||||
let alone = |ids: &[NodeId], name: &str| -> Option<(Arc<Document>, Rect)> {
|
||||
if !art {
|
||||
return Some((doc.clone(), Rect::ZERO));
|
||||
}
|
||||
isolated(doc, &doc.paint_order(ids.iter().copied()), name).map(|(d, r)| (Arc::new(d), r))
|
||||
};
|
||||
// Each item's stem follows Windows' file-naming rules, and no two items share one.
|
||||
let safe = |stems: Vec<String>| distinct_names(stems.iter().map(|s| safe_file_name(s)));
|
||||
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())));
|
||||
let stems = safe(unique_file_names(pieces.iter().map(|(n, ..)| (n.as_str(), fallback.to_string()))));
|
||||
pieces
|
||||
.into_iter()
|
||||
.zip(stems)
|
||||
@@ -688,7 +808,8 @@ fn items(doc: &Arc<Document>, o: &ScreenParams, selection: Option<bool>, selecte
|
||||
(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()) }]);
|
||||
let stem = safe_file_name(&name);
|
||||
return Ok(vec![Item { name, stem, doc: whole, artboard: Some(0), region, every_artboard: Some(doc.clone()) }]);
|
||||
}
|
||||
if let Some(ids) = &o.assets {
|
||||
let mut pieces = vec![];
|
||||
@@ -742,7 +863,7 @@ fn items(doc: &Arc<Document>, o: &ScreenParams, selection: Option<bool>, selecte
|
||||
if boards.is_empty() {
|
||||
return Err(bad(C, "the document has no artboard"));
|
||||
}
|
||||
let stems = artboard_file_names(doc, &boards);
|
||||
let stems = safe(artboard_file_names(doc, &boards));
|
||||
Ok(boards
|
||||
.into_iter()
|
||||
.zip(stems)
|
||||
|
||||
@@ -120,7 +120,9 @@ pub fn options_map(p: &Value) -> Result<Map<String, Value>, String> {
|
||||
match o.get("svg") {
|
||||
None | Some(Value::Null) => {}
|
||||
Some(Value::Object(svg)) => m.extend(svg.clone()),
|
||||
Some(_) => return Err("`svg` must be an object of SVG options (see document.formats)".into()),
|
||||
Some(_) => {
|
||||
return Err("`svg` must be an object of SVG options (see document.formats)".into());
|
||||
}
|
||||
}
|
||||
Ok(m)
|
||||
}
|
||||
@@ -147,6 +149,17 @@ fn plan(doc: &Document, p: &Value) -> Result<(ExportOptions, Vec<Option<usize>>,
|
||||
Ok((opts, named.unwrap_or_else(|| vec![0]).into_iter().map(Some).collect(), chosen))
|
||||
}
|
||||
|
||||
/// What a save of `doc` as SVG (format `label`) with options `p` loses of several artboards: the
|
||||
/// file is one artboard and reopens as one, unless it carries the native document (Preserve
|
||||
/// Editing Capabilities).
|
||||
pub(super) fn lost_artboards_warning(doc: &Document, p: &Value, label: &str) -> Option<String> {
|
||||
let n = doc.artboards.len();
|
||||
let preserved = plan(doc, p).is_ok_and(|(opts, ..)| opts.preserve_editing);
|
||||
(n > 1 && !preserved).then(|| {
|
||||
format!("{label} keeps 1 of the document's {n} artboards: the file reopens with one. Turn on Preserve Editing Capabilities to keep them all.")
|
||||
})
|
||||
}
|
||||
|
||||
/// Encode `doc` as SVG (or gzipped, SVGZ): one file per chosen artboard, with the images they
|
||||
/// link to and the writer's warnings.
|
||||
pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result<Encoded, String> {
|
||||
|
||||
@@ -189,6 +189,22 @@ fn planar_tiffs_with_more_strips_than_can_be_counted_are_too_large() {
|
||||
assert_eq!(vectorcraft_doc::cmyk::oversized_tiff(&file), Some((3, 1 << 31 | 1)));
|
||||
}
|
||||
|
||||
/// A 16-bit RGB TIFF whose samples need more than the allowance (6 bytes a pixel), though its RGBA
|
||||
/// pixels would fit, is refused as too large to draw before it is decoded.
|
||||
#[test]
|
||||
fn tiffs_whose_samples_pass_the_allowance_are_too_large() {
|
||||
let plane = |v: u16| vec![v; 6];
|
||||
let mut file = hand_tiff(2, 16, &[], false, &[plane(9000), plane(30_000), plane(60_000)]);
|
||||
// The loop sets ImageWidth, ImageLength and RowsPerStrip (the first, second and eighth entries) to `side`.
|
||||
let side = 20_000u32;
|
||||
assert!(u64::from(side * side) * 6 > MAX_RASTER_ALLOC && u64::from(side * side) * 4 <= MAX_RASTER_ALLOC);
|
||||
for at in [18, 30, 102] {
|
||||
file[at..at + 4].copy_from_slice(&side.to_le_bytes());
|
||||
}
|
||||
let e = raster_image(&file).err().expect("refused");
|
||||
assert_eq!(e.to_string(), "an image of 20000 × 20000 pixels is too large to draw");
|
||||
}
|
||||
|
||||
/// A planar CMYK TIFF opens and places with its ink amounts, stored as a TIFF the image decoder
|
||||
/// reads (#1466).
|
||||
#[test]
|
||||
@@ -208,6 +224,113 @@ fn planar_cmyk_tiffs_open_and_place_with_their_inks() {
|
||||
assert!(image::load_from_memory(&blob.bytes).is_ok(), "the image decoder reads the stored image");
|
||||
}
|
||||
|
||||
/// A 70 × 7 picture, `true` for black: a white row and a black row (runs past 63 take a make-up
|
||||
/// code), then runs of several lengths.
|
||||
fn fax_picture() -> Vec<Vec<bool>> {
|
||||
(0..7).map(|y| (0..70).map(|x| if y < 2 { y == 1 } else { (x * (y + 2) / 5 + y) % 3 == 0 }).collect()).collect()
|
||||
}
|
||||
|
||||
/// The first `rows` rows of [`fax_picture`] as a TIFF compressed with CCITT Group 3 in one strip:
|
||||
/// `compression` 2 or 3 with `t4_options`, white is zero, the highest bit of each byte first.
|
||||
fn group3_tiff(compression: u16, t4_options: u32, rows: usize) -> Vec<u8> {
|
||||
let rows = &fax_picture()[..rows];
|
||||
let g =
|
||||
vectorcraft_testkit::tiff::Group3 { rows, compression, t4_options, photometric: 0, fill_order: 1, rows_per_strip: 7, tile: None, rtc: false };
|
||||
vectorcraft_testkit::tiff::group3(&g)
|
||||
}
|
||||
|
||||
/// 1-bit TIFFs compressed with CCITT Group 3 open and place with their pixels (#1466): Compression
|
||||
/// 2 (modified Huffman) and Compression 3 (ITU-T T.4) with one-dimensional rows or two-dimensional
|
||||
/// ones (T4Options bit 0), with and without fill bits (bit 2) and six EOLs at the end, in one strip,
|
||||
/// three strips or three 32 × 16 tiles, each byte's highest or lowest bit first (FillOrder 1 and 2),
|
||||
/// white or black as zero.
|
||||
#[test]
|
||||
fn ccitt_group3_tiffs_open_and_place_with_their_pixels() {
|
||||
let rows = fax_picture();
|
||||
let mut s = Session::new();
|
||||
for (compression, t4_options, rtc) in [(2, 0, false), (3, 0, false), (3, 4, true), (3, 1, false), (3, 5, true)] {
|
||||
for (rows_per_strip, tile) in [(7, None), (3, None), (7, Some((32, 16)))] {
|
||||
for fill_order in [1, 2] {
|
||||
for photometric in [0, 1] {
|
||||
let what = format!(
|
||||
"Compression {compression}, T4Options {t4_options}, {rows_per_strip} rows a strip, tiles {tile:?}, FillOrder {fill_order}, photometric {photometric}"
|
||||
);
|
||||
let g = vectorcraft_testkit::tiff::Group3 {
|
||||
rows: &rows,
|
||||
compression,
|
||||
t4_options,
|
||||
photometric,
|
||||
fill_order,
|
||||
rows_per_strip,
|
||||
tile,
|
||||
rtc,
|
||||
};
|
||||
let file = vectorcraft_testkit::tiff::group3(&g);
|
||||
assert!(image::load_from_memory(&file).is_err(), "{what}: the image decoder reads Group 3 now");
|
||||
// A black run's pixels are 1 bits, which show as black when white is zero.
|
||||
let want: Vec<u8> = rows.concat().iter().map(|&black| if black == (photometric == 0) { 0 } else { 255 }).collect();
|
||||
let pixels = |s: &Session| {
|
||||
let blob = &s.doc().unwrap().doc.images[&image_of(s).key];
|
||||
image::load_from_memory(&blob.bytes).unwrap_or_else(|e| panic!("{what}: {e}")).to_luma8().into_raw()
|
||||
};
|
||||
open(&mut s, "fax.tif", &file);
|
||||
assert_eq!(pixels(&s), want, "{what}: Open");
|
||||
let mut placed = session(100.0, 100.0, 1);
|
||||
placed.execute("file.place", &json!({"name": "fax.tif", "dataBase64": vectorcraft_format::base64_encode(&file)})).unwrap();
|
||||
assert_eq!(pixels(&placed), want, "{what}: Place");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A Group 3 TIFF whose pixels would take more than the decoding allowance as RGBA is refused as
|
||||
/// too large to draw, without a panic at 2^31 × 2^31 pixels too. One whose data ends inside a row,
|
||||
/// or after a whole row before the strip's last, is refused as damaged, and so is one whose tiles
|
||||
/// are wider than the allowance.
|
||||
#[test]
|
||||
fn ccitt_group3_tiffs_too_large_or_cut_short_are_refused() {
|
||||
// ImageWidth (the directory's first entry, its value at offset 18), ImageLength (the second, at
|
||||
// 30) and RowsPerStrip (the eighth, at 102).
|
||||
for (width, height) in [(70u32, 1u32 << 30), (1 << 31, 1 << 31)] {
|
||||
let mut big = group3_tiff(3, 1, 7);
|
||||
big[18..22].copy_from_slice(&width.to_le_bytes());
|
||||
big[30..34].copy_from_slice(&height.to_le_bytes());
|
||||
big[102..106].copy_from_slice(&height.to_le_bytes());
|
||||
let e = raster_image(&big).err().expect("refused");
|
||||
assert!(e.to_string().contains(&format!("an image of {width} × {height} pixels is too large to draw")), "{e}");
|
||||
}
|
||||
let mut short = group3_tiff(3, 0, 7);
|
||||
short.truncate(short.len() - 6);
|
||||
let e = raster_image(&short).err().expect("refused");
|
||||
assert!(e.to_string().contains("could not decode image: unexpected end of input"), "{e}");
|
||||
// Compression 2 rows start at a byte, so the strip of the first six rows is where the strip of
|
||||
// seven starts. StripByteCounts (the ninth entry, its value at offset 114) gives the six rows'.
|
||||
let (mut seven, six) = (group3_tiff(2, 0, 7), group3_tiff(2, 0, 6));
|
||||
seven[114..118].copy_from_slice(&six[114..118]);
|
||||
assert_eq!(seven[30..34], 7u32.to_le_bytes(), "ImageLength");
|
||||
assert_eq!(seven[78..82], six[78..82], "StripOffsets (the sixth entry, at offset 78)");
|
||||
let e = raster_image(&seven).err().expect("refused");
|
||||
assert!(e.to_string().contains("could not decode image: unexpected end of input"), "{e}");
|
||||
// A tile wider than the allowance: TileWidth (the eighth entry, its value at offset 102).
|
||||
let rows = fax_picture();
|
||||
let g = vectorcraft_testkit::tiff::Group3 {
|
||||
rows: &rows,
|
||||
compression: 3,
|
||||
t4_options: 1,
|
||||
photometric: 0,
|
||||
fill_order: 1,
|
||||
rows_per_strip: 7,
|
||||
tile: Some((80, 16)),
|
||||
rtc: false,
|
||||
};
|
||||
let mut wide = vectorcraft_testkit::tiff::group3(&g);
|
||||
assert_eq!(wide[102..106], 80u32.to_le_bytes(), "TileWidth");
|
||||
wide[102..106].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
let e = raster_image(&wide).err().expect("refused");
|
||||
assert!(e.to_string().contains("could not decode image: arithmetic overflow"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_beats_a_wrong_extension() {
|
||||
let png = image_bytes(2, 2, image::ImageFormat::Png);
|
||||
|
||||
@@ -135,16 +135,42 @@ fn a_pdfs_art_past_the_page_opens_whole_from_its_editing_data() {
|
||||
assert!(s.doc().unwrap().doc.art_bounds().is_some_and(|b| b.x1 < 200.5));
|
||||
}
|
||||
|
||||
/// Editing data that can't be read leaves the file as its PDF part reads, with that part's notes:
|
||||
/// a `.ai`'s art outside its artboards is missing, and a PDF's art past the page is clipped.
|
||||
/// A `.ai` or a PDF whose editing data can't be decompressed opens from its PDF part with a note
|
||||
/// that its layers weren't read, and an export to its own path is refused unless it is given
|
||||
/// `acknowledgeLoss`.
|
||||
#[test]
|
||||
fn a_file_whose_editing_data_cant_be_decompressed_is_not_written_over_unasked() {
|
||||
let d = std::env::temp_dir().join(format!("vc-aiundecoded-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
let damaged = b"%AI12_CompressedDataxx";
|
||||
for (name, file) in [("art.ai", ai::ai(damaged, ai::page_pdf())), ("art.pdf", ai::pdf(damaged, ai::page_pdf()))] {
|
||||
let path = d.join(name);
|
||||
std::fs::write(&path, &file).unwrap();
|
||||
let path = path.to_string_lossy().to_string();
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("document.open", &json!({"path": path})).unwrap();
|
||||
assert!(r["warnings"].to_string().contains("layers weren't read from its editing data (its zlib compression is damaged)"), "{name}: {r}");
|
||||
let e = s.execute("document.export", &json!({"path": path})).unwrap_err().to_string();
|
||||
assert!(e.contains("left things out") && e.contains("layers weren't read"), "{name}: {e}");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), file, "{name}");
|
||||
s.execute("document.export", &json!({"path": path, "acknowledgeLoss": true})).unwrap();
|
||||
assert_ne!(std::fs::read(&path).unwrap(), file, "{name}");
|
||||
}
|
||||
std::fs::remove_dir_all(d).ok();
|
||||
}
|
||||
|
||||
/// Editing data that can't be read leaves the file as its PDF part reads, with that part's notes
|
||||
/// and then a note saying why its layers weren't read: a `.ai`'s art outside its artboards is
|
||||
/// missing, and a PDF's art past the page is clipped.
|
||||
#[test]
|
||||
fn editing_data_that_cant_be_read_keeps_the_pdf_parts_notes() {
|
||||
let damaged = b"%AI12_CompressedData not zlib";
|
||||
let unread = "the file's layers weren't read from its editing data (its zlib compression is damaged): they come from its PDF part";
|
||||
let mut s = Session::new();
|
||||
let r = open(&mut s, "damaged.ai", &ai::ai(damaged, ai::page_pdf()), json!({})).unwrap();
|
||||
assert_eq!(r["warnings"], json!([vectorcraft_pdf::OFF_ARTBOARD_NOTE]), "{r}");
|
||||
assert_eq!(r["warnings"], json!([vectorcraft_pdf::OFF_ARTBOARD_NOTE, unread]), "{r}");
|
||||
let r = open(&mut s, "damaged.pdf", &ai::pdf(damaged, "0 g 150 20 100 60 re f"), json!({})).unwrap();
|
||||
assert_eq!(r["warnings"], json!([vectorcraft_pdf::PAST_PAGE_NOTE]), "{r}");
|
||||
assert_eq!(r["warnings"], json!([vectorcraft_pdf::PAST_PAGE_NOTE, unread]), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -158,6 +184,34 @@ fn what_the_reader_doesnt_read_opens_as_before_with_a_warning() {
|
||||
assert!(r["warnings"].to_string().contains("symbols") && r["warnings"].to_string().contains("PDF part"), "{r}");
|
||||
}
|
||||
|
||||
/// A `.ai` whose document data has a variable library opens with a note that its variables and
|
||||
/// data sets weren't read, whether its layers are read or it comes in as its PDF part.
|
||||
#[test]
|
||||
fn an_ai_file_with_a_variable_library_says_its_variables_werent_read() {
|
||||
const NOTE: &str = "the file's variables and data sets weren't read (variables: 2, data sets: 3): the Variables panel doesn't list them";
|
||||
let library = ai::variable_library(&["Name", "Number"], &["One", "Two", "Three"]);
|
||||
let square = format!("0 1 1 0 1 0 0 Xa\n{}f\n", ai::rect(10.0, 50.0, 40.0, 90.0));
|
||||
let notes =
|
||||
|r: &Value| r["warnings"].as_array().unwrap().iter().filter(|w| w.as_str().unwrap().contains("variables")).cloned().collect::<Vec<_>>();
|
||||
let mut s = Session::new();
|
||||
let layered = ai::editing_data(200.0, 100.0, &format!("{library}{}", ai::layer("Art", &square)));
|
||||
let file = ai::ai(&compressed(layered.as_bytes()), "1 0 0 rg 10 50 30 40 re f");
|
||||
let r = open(&mut s, "variables.ai", &file, json!({})).unwrap();
|
||||
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "{r}");
|
||||
assert_eq!(notes(&r), [json!(NOTE)], "{r}");
|
||||
let r = s.execute("variable.list", &json!({})).unwrap();
|
||||
assert_eq!(r["variables"], json!([]), "{r}");
|
||||
// Its layers can't be read (a symbol): it comes in as its PDF part, with the note.
|
||||
let symbols = ai::editing_data(200.0, 100.0, &format!("{library}{}", ai::layer("Art", "/SymbolInstance :\n(Dot) /SymbolRef ,\n;\n")));
|
||||
let r = open(&mut s, "symbols.ai", &ai::ai(&compressed(symbols.as_bytes()), ai::page_pdf()), json!({})).unwrap();
|
||||
assert!(r["warnings"].to_string().contains("PDF part"), "{r}");
|
||||
assert_eq!(notes(&r), [json!(NOTE)], "{r}");
|
||||
// Without a library, no note.
|
||||
let plain = ai::editing_data(200.0, 100.0, &ai::layer("Art", &square));
|
||||
let r = open(&mut s, "plain.ai", &ai::ai(&compressed(plain.as_bytes()), "1 0 0 rg 10 50 30 40 re f"), json!({})).unwrap();
|
||||
assert!(notes(&r).is_empty(), "{r}");
|
||||
}
|
||||
|
||||
/// A page showing only a placeholder line, as a `.ai` saved without PDF compatibility has.
|
||||
const PLACEHOLDER: &str = "BT /F1 12 Tf 10 50 Td (Saved without its PDF part) Tj ET";
|
||||
|
||||
@@ -175,6 +229,19 @@ fn an_ai_file_saved_without_its_pdf_part_opens_from_its_editing_data() {
|
||||
assert!(e.contains("placeholder"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ai_file_saved_without_its_pdf_part_opens_with_pages_naming_every_page() {
|
||||
let file = ai::ai(&compressed(&ai::sample_data()), PLACEHOLDER);
|
||||
for pages in [json!("1"), json!(1), json!("1-")] {
|
||||
let mut s = Session::new();
|
||||
open(&mut s, "art.ai", &file, json!({"pages": pages})).unwrap();
|
||||
check_sample(&s);
|
||||
}
|
||||
// Without editing data, the placeholder still isn't opened as the art.
|
||||
let e = open(&mut Session::new(), "plain.ai", &ai::ai(b"", PLACEHOLDER), json!({"pages": "1"})).unwrap_err().to_string();
|
||||
assert!(e.contains("placeholder"), "{e}");
|
||||
}
|
||||
|
||||
/// A page of text alone, as text-only art has in its PDF part: no path is painted.
|
||||
const TEXT_ART: &str = "BT /F1 36 Tf 10 40 Td (Text only) Tj ET";
|
||||
|
||||
|
||||
@@ -105,6 +105,17 @@ fn the_counter_counts_items_or_files_from_any_start() {
|
||||
assert_eq!(parts, ["1_Artboard 2_2x@2x_png.png"]);
|
||||
}
|
||||
|
||||
/// Export for Screens writes the characters Windows does not allow in a file name (`<`, `>`, `"`,
|
||||
/// `|`, `?`, `*`, control characters) as `-` in a pattern's text and in the names its tokens stand
|
||||
/// for, on every platform.
|
||||
#[test]
|
||||
fn a_pattern_writes_the_characters_windows_does_not_allow_as_dashes() {
|
||||
let mut s = session(1);
|
||||
s.execute("artboard.setProps", &json!({"index": 0, "name": "a|b?\"c\u{7}"})).unwrap();
|
||||
let r = export(&mut s, json!({"name": "<name>*<x>", "formats": [{"format": "svg"}]}));
|
||||
assert_eq!(names(&r), ["a-b--c---x-.svg"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_and_the_selection_are_exported_by_name() {
|
||||
let mut s = session(1);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! EPS and PostScript files opened and placed: our own EPS files restore the document they carry,
|
||||
//! others are read by the PostScript interpreter or come in as their preview.
|
||||
|
||||
use std::io::Write as _;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::tests_svgedit::{comparable, rich};
|
||||
@@ -93,6 +95,33 @@ fn cmyk_images_keep_their_inks_when_an_eps_opens_or_is_placed() {
|
||||
assert_eq!(images(&s), [want.clone(), want]);
|
||||
}
|
||||
|
||||
/// A 4-bit gray image compressed with Flate and TIFF predictor 2 draws the samples it had before
|
||||
/// differencing, in an EPS and in a PostScript `.ai`.
|
||||
#[test]
|
||||
fn tiff_predicted_4_bit_images_open_from_eps_and_postscript_ai_files() {
|
||||
// Two rows of four samples, 0 5 15 3 and 9 9 2 0, differenced to 0 5 10 4 and 9 0 9 14.
|
||||
let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
z.write_all(&[0x05, 0xa4, 0x90, 0x9e]).unwrap();
|
||||
let hex: String = z.finish().unwrap().iter().map(|b| format!("{b:02x}")).collect();
|
||||
let ps = format!(
|
||||
"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 40 20\n%%EndComments\n40 20 scale 4 2 4 [4 0 0 -2 0 2] currentfile /ASCIIHexDecode filter \
|
||||
<< /Predictor 2 /Colors 1 /BitsPerComponent 4 /Columns 4 >> /FlateDecode filter image\n{hex}>\nshowpage\n%%EOF\n"
|
||||
);
|
||||
for name in ["predicted.eps", "predicted.ai"] {
|
||||
let mut s = Session::new();
|
||||
let r = open(&mut s, name, ps.as_bytes()).unwrap();
|
||||
assert_eq!(r["warnings"], json!([]), "{name}: {r}");
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let mut grays = vec![];
|
||||
d.walk(|n| {
|
||||
if let vectorcraft_doc::NodeKind::Image(im) = &n.kind {
|
||||
grays = image::load_from_memory(&d.images[&im.key].bytes).unwrap().to_luma8().into_raw();
|
||||
}
|
||||
});
|
||||
assert_eq!(grays, [0, 5, 15, 3, 9, 9, 2, 0].map(|v| v * 17), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unreadable_eps_file_comes_in_as_its_preview() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -131,6 +131,56 @@ fn options_not_applied_yet_and_document_features_warn() {
|
||||
assert!(warnings(&s.execute("document.export", &json!({"format": "png"})).unwrap()).is_empty());
|
||||
}
|
||||
|
||||
/// A placed image whose pixel data can't be decoded is left out of every PDF the engine writes,
|
||||
/// and the rest of the page is written. Each reply that carries the PDF's warnings has the note
|
||||
/// raster exports give, once.
|
||||
#[test]
|
||||
fn every_pdf_leaves_out_an_image_that_cant_be_decoded() {
|
||||
const NOTE: &str = "an image couldn't be decoded and is missing from the picture";
|
||||
let mut s = session(1);
|
||||
let cut = crate::tests_menucmds::place_undecodable_png(&mut s);
|
||||
// Whether each object on the PDF's pages is an image. The pages should hold the rectangle and
|
||||
// no image.
|
||||
let drawn = |bytes: &[u8]| {
|
||||
let mut images = vec![];
|
||||
vectorcraft_pdf::import(bytes).unwrap().walk(|n| {
|
||||
if !n.is_container() && !matches!(n.kind, vectorcraft_doc::NodeKind::Path { clipping: true, .. }) {
|
||||
images.push(matches!(n.kind, vectorcraft_doc::NodeKind::Image(_)));
|
||||
}
|
||||
});
|
||||
images
|
||||
};
|
||||
for (cmd, p) in [
|
||||
("document.exportPdf", json!({})),
|
||||
("document.exportPdf", json!({"preset": "High Quality Print"})),
|
||||
("document.exportPdf", json!({"standard": "pdfX1a"})),
|
||||
("document.export", json!({"format": "pdf"})),
|
||||
("file.saveCopy", json!({"format": "pdf"})),
|
||||
("file.saveCopy", json!({"format": "ai"})),
|
||||
("file.print", json!({})),
|
||||
] {
|
||||
let v = s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"));
|
||||
assert_eq!(warnings(&v).iter().filter(|w| *w == NOTE).count(), 1, "{cmd} {p}: {}", v["warnings"]);
|
||||
assert_eq!(drawn(&b64(&v)), [false], "{cmd} {p}");
|
||||
}
|
||||
// The .ai file reopens with the image as it was placed, from the image bytes in its editing data.
|
||||
let ai = b64(&s.execute("file.saveCopy", &json!({"format": "ai"})).unwrap());
|
||||
let placed = s.doc().unwrap().doc.images.clone();
|
||||
s.execute("document.open", &json!({"name": "cut.ai", "dataBase64": vectorcraft_format::base64_encode(&ai)})).unwrap();
|
||||
let reopened = &s.doc().unwrap().doc;
|
||||
assert!(reopened.node(cut).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Image(_))));
|
||||
assert_eq!(reopened.images, placed);
|
||||
// Export for Screens, a native file carrying a PDF, and the PDF that Copy offers are written.
|
||||
let r = s.execute("document.exportForScreens", &json!({"formats": [{"format": "pdf"}]})).unwrap();
|
||||
assert_eq!(drawn(&b64(&r["files"][0])), [false]);
|
||||
assert!(s.execute("file.saveCopy", &json!({"format": "vectorcraft", "pdfCompatible": true})).is_ok());
|
||||
s.execute("select.set", &json!({"ids": [cut.0]})).unwrap();
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
assert!(b64(&s.execute("clipboard.exportPdf", &json!({})).unwrap()).starts_with(b"%PDF"));
|
||||
s.execute("prefs.set", &json!({"key": "copyAsPdf", "value": true})).unwrap();
|
||||
assert!(s.clipboard_flavours().iter().any(|f| f.mime == crate::cmd::clipboard::PDF));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_pdf_writes_a_path() {
|
||||
let mut s = session(2);
|
||||
|
||||
@@ -69,6 +69,33 @@ fn pages_changed_elsewhere_open_as_artwork_with_a_warning() {
|
||||
assert_eq!(r["warnings"][0], load::EDITING_DAMAGED, "{r}");
|
||||
}
|
||||
|
||||
/// `pdf` with its editing file's description rewritten by another app in UTF-16BE with a byte
|
||||
/// order mark: "hash " and the hash it held, padded with spaces to its length.
|
||||
fn utf16_described(pdf: &[u8]) -> Vec<u8> {
|
||||
let from = b"(VectorCraft editing data; page hash ";
|
||||
let at = pdf.windows(from.len()).position(|w| w == from).unwrap_or_else(|| panic!("no description"));
|
||||
let (hash, len) = (&pdf[at + from.len()..at + from.len() + 16], from.len() + 17);
|
||||
let mut desc = b"(\xFE\xFF".to_vec();
|
||||
desc.extend(b"hash ".iter().chain(hash).flat_map(|&c| [0, c]));
|
||||
desc.push(b')');
|
||||
desc.resize(len, b' ');
|
||||
patched(pdf, &pdf[at..at + len], &desc)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pages_changed_elsewhere_are_noticed_with_a_utf16_description() {
|
||||
let mut s = rich();
|
||||
let pdf = b64(&s.execute("document.exportPdf", &json!({"compression": {"compressText": false}})).unwrap());
|
||||
let ai = b64(&s.execute("document.export", &json!({"format": "ai", "compress": false})).unwrap());
|
||||
for (name, file) in [("edited.pdf", pdf), ("edited.ai", ai)] {
|
||||
let r = open(&mut s, name, &utf16_described(&file));
|
||||
assert_eq!((&r["warnings"], &r["restored"]), (&json!([]), &json!(true)), "{name}: the same pages: {r}");
|
||||
let edited = patched(&file, b"/MediaBox[0 0 300 200]", b"/MediaBox[0 0 300 201]");
|
||||
let r = open(&mut s, name, &utf16_described(&edited));
|
||||
assert_eq!((&r["warnings"][0], &r["restored"]), (&json!(load::EDITING_STALE), &json!(false)), "{name}: {r}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_legacy_payload_name_is_accepted() {
|
||||
let mut s = rich();
|
||||
@@ -232,6 +259,52 @@ fn save_as_template_names_its_file_after_the_template_format() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Exports don't write a `.ait`: each export asked for one, by its path or `format`, answers an
|
||||
/// error that names `file.saveAsTemplate`, which writes it, and writes nothing. A format that
|
||||
/// nothing writes keeps its own error.
|
||||
#[test]
|
||||
fn exports_of_an_ait_name_save_as_template() {
|
||||
let mut s = rich();
|
||||
let path = tmp("exported.ait");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
for (cmd, p) in [
|
||||
("document.export", json!({"path": path})),
|
||||
("document.export", json!({"format": "ait"})),
|
||||
("document.serialize", json!({"format": "ait"})),
|
||||
("document.exportSelection", json!({"path": path})),
|
||||
("document.exportForScreens", json!({"formats": [{"format": "ait"}]})),
|
||||
] {
|
||||
let e = s.execute(cmd, &p).unwrap_err().to_string();
|
||||
assert!(e.contains("PDF-compatible .ait template files are written by file.saveAsTemplate"), "{cmd} {p}: {e}");
|
||||
}
|
||||
assert!(!std::path::Path::new(&path).exists(), "nothing written");
|
||||
assert_eq!(writable_format(Some("ps"), None).unwrap_err(), "PostScript files can be opened but not written (see document.formats)");
|
||||
}
|
||||
|
||||
/// Save as Template refuses `aiVersion` 7 for a `.ait`, given in `options` or flat, and writes no
|
||||
/// file; `aiVersion` "pdf" writes the PDF-compatible template.
|
||||
#[test]
|
||||
fn save_as_template_refuses_a_legacy_ait() {
|
||||
let mut s = rich();
|
||||
let path = tmp("legacy-ait/card.ait");
|
||||
let dir = std::path::Path::new(&path).parent().unwrap().to_path_buf();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
for p in [
|
||||
json!({"path": path, "options": {"aiVersion": "7"}}),
|
||||
json!({"path": path, "format": "ait", "aiVersion": 7}),
|
||||
json!({"format": "ait", "options": {"aiVersion": "7"}}),
|
||||
] {
|
||||
let e = s.execute("file.saveAsTemplate", &p).unwrap_err().to_string();
|
||||
assert!(e.contains("aiVersion 7 writes legacy .ai files"), "{p}: {e}");
|
||||
}
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "nothing written");
|
||||
let r = s.execute("file.saveAsTemplate", &json!({"path": path, "options": {"aiVersion": "pdf"}})).unwrap();
|
||||
assert_eq!(r["format"], "ait", "{r}");
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
assert!(bytes.starts_with(b"%PDF") && vectorcraft_pdf::editing(&bytes).is_some_and(|e| e.intact), "PDF-compatible, carrying the document");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standards_and_screens_leave_the_editing_data_out() {
|
||||
let mut s = rich();
|
||||
|
||||
@@ -38,6 +38,64 @@ fn text_opens_as_type_or_outlines() {
|
||||
assert!(e.contains("textAs must be one of text, outlines"), "{e}");
|
||||
}
|
||||
|
||||
fn live_text_pdf() -> Vec<u8> {
|
||||
let page = |text: &str| PdfPage {
|
||||
resources: "/Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >>".into(),
|
||||
..PdfPage::new(200.0, 100.0, &format!("BT /F1 12 Tf 10 50 Td ({text}) Tj ET"))
|
||||
};
|
||||
pdf(&[page("First page"), page("Second page")], None)
|
||||
}
|
||||
|
||||
fn live_texts(s: &Session) -> Vec<String> {
|
||||
let mut texts = vec![];
|
||||
s.doc().unwrap().doc.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
texts.push(t.plain_text());
|
||||
}
|
||||
});
|
||||
texts
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_place_keeps_live_text_from_bytes_and_unlinked_paths() {
|
||||
let bytes = live_text_pdf();
|
||||
let dir = vectorcraft_testkit::temp_dir("pdf-place-live-text");
|
||||
let path = dir.join("text.pdf");
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
for source in [json!({"name": "text.pdf", "dataBase64": vectorcraft_format::base64_encode(&bytes)}), json!({"path": path, "link": false})] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
let mut p = source;
|
||||
p["page"] = json!(2);
|
||||
let r = s.execute("file.place", &p).unwrap();
|
||||
assert_eq!(live_texts(&s), ["Second page"], "{r}");
|
||||
assert!(!r["warnings"].to_string().contains("outlines"), "{r}");
|
||||
}
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_clipboard_paste_keeps_live_text_on_the_selected_page() {
|
||||
let bytes = live_text_pdf();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
let r = s.execute("clipboard.importPdf", &json!({"dataBase64": vectorcraft_format::base64_encode(&bytes), "page": 2})).unwrap();
|
||||
s.execute("edit.paste", &json!({})).unwrap();
|
||||
assert_eq!(live_texts(&s), ["Second page"], "{r}");
|
||||
assert!(!r["warnings"].to_string().contains("outlines"), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_place_honors_explicit_text_outlines() {
|
||||
let bytes = live_text_pdf();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
let r =
|
||||
s.execute("file.place", &json!({"name": "text.pdf", "dataBase64": vectorcraft_format::base64_encode(&bytes), "textAs": "outlines"})).unwrap();
|
||||
assert!(live_texts(&s).is_empty());
|
||||
assert!(r["warnings"].to_string().contains("outlines"), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_option_keeps_or_flattens_optional_content() {
|
||||
let (a, b) = (first_extra(1), first_extra(1) + 1);
|
||||
@@ -59,6 +117,23 @@ fn layers_option_keeps_or_flattens_optional_content() {
|
||||
assert!(open(&mut s, &bytes, json!({"layers": "no"})).unwrap_err().to_string().contains("layers must be true or false"));
|
||||
}
|
||||
|
||||
/// A PDF-compatible `.ai` without the editor's private data opens its layers from its optional
|
||||
/// content groups, whose names without a byte order mark are PDFDocEncoding.
|
||||
#[test]
|
||||
fn ai_layer_names_are_read_as_pdf_doc_encoding() {
|
||||
let (a, b) = (first_extra(1), first_extra(1) + 1);
|
||||
let catalog = format!("/OCProperties << /OCGs [{a} 0 R {b} 0 R] /D << >> >> ");
|
||||
let page = PdfPage {
|
||||
resources: format!("/Properties << /A {a} 0 R /B {b} 0 R >>"),
|
||||
..PdfPage::new(100.0, 100.0, "/OC /A BDC 0 g 0 0 10 10 re f EMC /OC /B BDC 0 g 20 20 10 10 re f EMC")
|
||||
};
|
||||
let bytes = pdf_with_catalog(&[page], &[r"<< /Type /OCG /Name (Art\205final) >>", "<< /Type /OCG /Name (Notes) >>"], &catalog, None);
|
||||
let mut s = Session::new();
|
||||
s.execute("document.open", &json!({"name": "art.ai", "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap();
|
||||
let names: Vec<String> = s.active().unwrap().doc.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect();
|
||||
assert_eq!(names, ["Art\u{2013}final", "Notes"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_options_read_text_and_layers() {
|
||||
let o = LoadOptions::from_params("x", &json!({"textAs": "Outlines", "layers": false})).unwrap();
|
||||
|
||||
@@ -157,27 +157,31 @@ fn a_postscript_ai_file_says_it_cannot_be_opened_yet() {
|
||||
let e = s.execute("document.open", &json!({"name": name, "dataBase64": b64(ps)})).unwrap_err().to_string();
|
||||
assert!(e.contains(name) && e.contains("PostScript") && e.contains("save it as PDF"), "{name}: {e}");
|
||||
}
|
||||
// An EPS with a binary header too, and the PDF info of either.
|
||||
// An EPS with a binary header too, and the PDF info of each. The plain program's PDF info gives
|
||||
// its page, and its thumbnail gives the reader's error; the binary file's header points at no
|
||||
// PostScript, so its PDF info is an error.
|
||||
let mut binary = vec![0xC5, 0xD0, 0xD3, 0xC6];
|
||||
binary.extend_from_slice(&[0; 28]);
|
||||
binary.extend_from_slice(ps);
|
||||
assert!(s.execute("document.open", &json!({"name": "x.eps", "dataBase64": b64(&binary)})).unwrap_err().to_string().contains("PostScript"));
|
||||
assert!(s.execute("document.pdfInfo", &json!({"dataBase64": b64(ps)})).unwrap_err().to_string().contains("PostScript"));
|
||||
assert!(s.execute("document.pdfInfo", &json!({"dataBase64": b64(&binary)})).unwrap_err().to_string().contains("PostScript"));
|
||||
let v = s.execute("document.pdfInfo", &json!({"dataBase64": b64(ps)})).unwrap();
|
||||
assert_eq!((v["pages"].as_u64(), v["pageInfo"][0]["width"].as_f64()), (Some(1), Some(100.0)), "{v}");
|
||||
let e = s.execute("document.pdfInfo", &json!({"dataBase64": b64(ps), "thumbnail": 1})).unwrap_err().to_string();
|
||||
assert!(e.contains("PostScript") && e.contains("frobnicate"), "{e}");
|
||||
assert!(s.active().is_none());
|
||||
}
|
||||
|
||||
/// PostScript given where a PDF is read (`document.pdfInfo`, `clipboard.importPdf`) is refused
|
||||
/// with an error that names File › Open and File › Place, and both of those read the file.
|
||||
/// PostScript given where a PDF is read (`clipboard.importPdf`) is refused with an error that
|
||||
/// names File › Open and File › Place, and both of those read the file.
|
||||
#[test]
|
||||
fn postscript_read_as_a_pdf_names_open_and_place() {
|
||||
let ps = b"%!PS-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n1 0 0 setrgbcolor 10 10 50 50 rectfill\nshowpage\n%%EOF\n";
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
for cmd in ["document.pdfInfo", "clipboard.importPdf"] {
|
||||
let e = s.execute(cmd, &json!({"dataBase64": b64(ps)})).unwrap_err().to_string();
|
||||
assert!(e.contains("PostScript") && e.contains("not a PDF: File › Open and File › Place read it as EPS"), "{cmd}: {e}");
|
||||
assert!(!e.contains("can't be opened"), "{cmd}: {e}");
|
||||
}
|
||||
let e = s.execute("clipboard.importPdf", &json!({"dataBase64": b64(ps)})).unwrap_err().to_string();
|
||||
assert!(e.contains("PostScript") && e.contains("not a PDF: File › Open and File › Place read it as EPS"), "{e}");
|
||||
assert!(!e.contains("can't be opened"), "{e}");
|
||||
for name in ["legacy.ai", "art.eps"] {
|
||||
let r = s.execute("document.open", &json!({"name": name, "dataBase64": b64(ps)})).unwrap();
|
||||
assert_eq!(r["warnings"], json!([]), "{name}: {r}");
|
||||
@@ -186,6 +190,65 @@ fn postscript_read_as_a_pdf_names_open_and_place() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `document.pdfInfo` reads PostScript (an EPS, a `.ai` in PostScript form, a `.ps` file) as one
|
||||
/// page, its bounding box or a letter page without one, and its thumbnail shows that page. Each
|
||||
/// file here opens on that page with `editingData: false`.
|
||||
#[test]
|
||||
fn pdf_info_reads_postscript_as_one_page() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap();
|
||||
let data = |r: Value| vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap();
|
||||
let eps = data(s.execute("document.exportEps", &json!({"useArtboards": true})).unwrap());
|
||||
let ai = data(s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7"})).unwrap());
|
||||
assert!(eps.starts_with(&[0xC5, 0xD0, 0xD3, 0xC6]) && ai.starts_with(b"%!PS"));
|
||||
let hires = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 101 41\n%%HiResBoundingBox: 0 0 100.5 40.25\n%%EndComments\n0 0 1 setrgbcolor 0 0 50 20 rectfill\n";
|
||||
let letter = b"%!PS\n1 0 0 setrgbcolor 72 72 144 72 rectfill\nshowpage\n";
|
||||
let bare = b"%!\n0 0 1 setrgbcolor 100 100 50 50 rectfill\nshowpage\n";
|
||||
for (name, bytes, size) in [
|
||||
("art.eps", &eps[..], (300.0, 200.0)),
|
||||
("legacy.ai", &ai, (101.0, 51.0)),
|
||||
("hires.eps", hires, (100.5, 40.25)),
|
||||
("page.ps", letter, (612.0, 792.0)),
|
||||
("bare.ps", bare, (612.0, 792.0)),
|
||||
] {
|
||||
let v = s.execute("document.pdfInfo", &json!({"dataBase64": b64(bytes), "thumbnail": 1, "thumbnailSize": 100})).unwrap();
|
||||
let png = vectorcraft_format::base64_decode(v["thumbnail"].as_str().unwrap()).unwrap();
|
||||
let img = image::load_from_memory(&png).unwrap();
|
||||
assert_eq!(img.width().max(img.height()), 100, "{name}");
|
||||
let mut v = v;
|
||||
v["thumbnail"] = json!("png");
|
||||
let page = json!({"width": size.0, "height": size.1, "rotation": 0, "boxes": {}});
|
||||
assert_eq!(v, json!({"pages": 1, "needsPassword": false, "pageInfo": [page], "thumbnail": "png"}), "{name}");
|
||||
s.execute("document.open", &json!({"name": name, "dataBase64": b64(bytes), "editingData": false})).unwrap();
|
||||
let board = s.doc().unwrap().doc.artboards[0].rect;
|
||||
assert_eq!((board.width(), board.height()), size, "{name}: the page document.open reads");
|
||||
}
|
||||
let e = s.execute("document.pdfInfo", &json!({"dataBase64": b64(&eps), "thumbnail": 2})).unwrap_err().to_string();
|
||||
assert!(e.contains("no page 2"), "{e}");
|
||||
}
|
||||
|
||||
/// `document.pdfInfo`'s `thumbnail` is a page number from 1, for a PDF and for PostScript alike:
|
||||
/// any other value is an error that names the pages, and an absent or null one gives no thumbnail.
|
||||
#[test]
|
||||
fn pdf_info_refuses_a_thumbnail_that_is_not_a_page_number() {
|
||||
let ps = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 50\n%%EndComments\n0 0 1 setrgbcolor 0 0 50 20 rectfill\n";
|
||||
let mut s = Session::new();
|
||||
for (bytes, pages) in [(&three_pages()[..], "(the PDF has 3)"), (ps, "(the file has 1)")] {
|
||||
for n in [json!(-1), json!(0), json!(1.5), json!("1"), json!(true)] {
|
||||
let e = s.execute("document.pdfInfo", &json!({"dataBase64": b64(bytes), "thumbnail": n})).unwrap_err().to_string();
|
||||
assert!(e.contains(&format!("thumbnail: no page {n} {pages}")), "{n}: {e}");
|
||||
}
|
||||
for none in [json!({"dataBase64": b64(bytes)}), json!({"dataBase64": b64(bytes), "thumbnail": null})] {
|
||||
assert!(s.execute("document.pdfInfo", &none).unwrap().get("thumbnail").is_none(), "{pages}");
|
||||
}
|
||||
let v = s.execute("document.pdfInfo", &json!({"dataBase64": b64(bytes), "thumbnail": 1, "thumbnailSize": 64})).unwrap();
|
||||
let png = vectorcraft_format::base64_decode(v["thumbnail"].as_str().unwrap()).unwrap();
|
||||
let img = image::load_from_memory(&png).unwrap();
|
||||
assert_eq!(img.width().max(img.height()), 64, "{pages}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A page showing only text, with an editor's private data when `private`.
|
||||
fn text_page(private: bool, also: &str) -> PdfPage {
|
||||
PdfPage {
|
||||
|
||||
@@ -110,6 +110,99 @@ fn density_buckets_go_into_sub_folders() {
|
||||
assert_eq!(names(&r), ["Artboard-2-..-x-..-y.svg", "Artboard-2-ok.png"]);
|
||||
}
|
||||
|
||||
/// Windows does not allow `<`, `>`, `"`, `|`, `?`, `*` or control characters in a file name
|
||||
/// (<https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file>): Export for Screens
|
||||
/// writes them as `-` in a prefix and a suffix, on every platform.
|
||||
#[test]
|
||||
fn a_prefix_or_suffix_writes_the_characters_windows_does_not_allow_as_dashes() {
|
||||
let mut s = session(1);
|
||||
let r = export(&mut s, json!({"prefix": "a<b>c\"", "formats": [{"format": "svg", "suffix": "|d?e*f\u{1}\t"}]}));
|
||||
assert_eq!(names(&r), ["a-b-c-Artboard-1-d-e-f--.svg"]);
|
||||
}
|
||||
|
||||
/// A file or folder named after a Windows device (`CON`, `NUL`, `COM1`…, alone or before an
|
||||
/// extension) gets a `-` after the device name, and a folder loses a trailing period, on every
|
||||
/// platform.
|
||||
#[test]
|
||||
fn device_names_and_trailing_periods_follow_windows_naming_rules() {
|
||||
let mut s = session(3);
|
||||
for (index, name) in ["CON", "CON-", "lpt1"].iter().enumerate() {
|
||||
s.execute("artboard.setProps", &json!({"index": index, "name": name})).unwrap();
|
||||
}
|
||||
let r = export(&mut s, json!({"formats": [{"format": "svg"}]}));
|
||||
assert_eq!(names(&r), ["CON-.svg", "CON--2.svg", "lpt1-.svg"]);
|
||||
let r =
|
||||
export(&mut s, json!({"range": "1", "subfolders": true, "formats": [{"format": "svg", "folder": "aux"}, {"format": "pdf", "folder": "x."}]}));
|
||||
assert_eq!(names(&r), ["aux-/CON-.svg", "x/CON-.pdf"]);
|
||||
let r = export(&mut s, json!({"range": "3", "folders": "item", "formats": [{"format": "svg"}]}));
|
||||
assert_eq!(names(&r), ["lpt1-/lpt1-.svg"]);
|
||||
// A name pattern, with an extension after the device name, and the document's own name.
|
||||
let r = export(&mut s, json!({"range": "1", "name": "<name>.tar", "formats": [{"format": "svg"}]}));
|
||||
assert_eq!(names(&r), ["CON-.tar.svg"]);
|
||||
s.execute("file.new", &json!({"width": 40, "height": 30, "name": "nul"})).unwrap();
|
||||
let r = export(&mut s, json!({"fullDocument": true, "folders": "item", "formats": [{"format": "pdf"}]}));
|
||||
assert_eq!(names(&r), ["nul-/nul-.pdf"]);
|
||||
let r = s.execute("document.exportForScreens", &json!({"zip": true, "formats": [{"format": "svg"}]})).unwrap();
|
||||
assert_eq!(r["name"], "nul-.zip");
|
||||
}
|
||||
|
||||
/// `first_file` names the file the export writes first, for the same params: the item's file name,
|
||||
/// a prefix and a suffix with characters Windows does not allow, a range, a Windows device name,
|
||||
/// every kind of folder, presets, size suffixes, a name pattern with its counter and date, an
|
||||
/// export preset, a ZIP, Full Document, Include Bleed, assets, layers and the selection; and it
|
||||
/// refuses what the export refuses.
|
||||
#[test]
|
||||
fn the_first_file_is_the_file_the_export_writes_first() {
|
||||
let mut s = session(3);
|
||||
s.execute("artboard.setProps", &json!({"index": 1, "name": "CON"})).unwrap();
|
||||
let layer = s.doc().unwrap().doc.layers[0].id.0;
|
||||
let square = s.doc().unwrap().doc.layers[0].children().unwrap()[0].id.0;
|
||||
let asset = s.execute("assets.add", &json!({"ids": [square], "name": "Logo?"})).unwrap()["assets"][0].as_u64().unwrap();
|
||||
s.execute("shape.ellipse", &json!({"x": 2, "y": 2, "width": 10, "height": 10})).unwrap();
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
let written = |r: &Value| r["files"][0].as_str().or_else(|| r["files"][0]["name"].as_str()).unwrap_or_default().to_string();
|
||||
let cases = [
|
||||
json!({}),
|
||||
json!({"prefix": "a?b/", "formats": [{"format": "png", "suffix": "<x>"}]}),
|
||||
json!({"range": "2-3", "folders": "item"}),
|
||||
json!({"range": "2", "folders": "itemFormat", "formats": [{"format": "svg", "scale": "2x", "suffix": "@2x"}]}),
|
||||
json!({"preset": "mobile"}),
|
||||
json!({"preset": "density"}),
|
||||
json!({"subfolders": true, "formats": [{"format": "png", "scale": "144ppi", "folder": "a b"}]}),
|
||||
json!({"folders": "format", "formats": [{"format": "jpg", "quality": 50}]}),
|
||||
json!({
|
||||
"name": "<filename>-<name>-<scale>-<index>-###-YYYYMMDD",
|
||||
"counterStart": 7,
|
||||
"counterPer": "file",
|
||||
"date": "2026-10-17",
|
||||
"formats": [{"format": "png", "scale": "2x"}]
|
||||
}),
|
||||
json!({"name": "<suffix>", "formats": [{"format": "png", "suffix": ""}]}),
|
||||
json!({"exportPreset": "Logo Package"}),
|
||||
json!({"zip": true, "formats": [{"format": "svg"}, {"format": "png"}]}),
|
||||
json!({"fullDocument": true, "folders": "item", "formats": [{"format": "pdf"}]}),
|
||||
json!({"fullDocument": true, "formats": [{"format": "png"}]}),
|
||||
json!({"includeBleed": true}),
|
||||
json!({"assets": [asset], "formats": [{"format": "svg"}]}),
|
||||
json!({"layers": [layer], "folders": "item"}),
|
||||
json!({"selection": "one"}),
|
||||
json!({"selection": "each", "name": "<name>"}),
|
||||
];
|
||||
for p in cases {
|
||||
let first = super::screens::first_file(&s, &p).unwrap();
|
||||
assert_eq!(first, written(&export(&mut s, p.clone())), "{p}");
|
||||
}
|
||||
for p in [json!({"layers": [layer], "assets": [asset]}), json!({"selection": "all"}), json!({"counterPer": "row"})] {
|
||||
assert!(super::screens::first_file(&s, &p).is_err() && s.execute("document.exportForScreens", &p).is_err(), "{p}");
|
||||
}
|
||||
// A document named after a Windows device.
|
||||
s.execute("file.new", &json!({"width": 40, "height": 30, "name": "nul"})).unwrap();
|
||||
for p in [json!({"fullDocument": true, "folders": "item", "formats": [{"format": "pdf"}]}), json!({"name": "<filename>"})] {
|
||||
let first = super::screens::first_file(&s, &p).unwrap();
|
||||
assert_eq!(first, written(&export(&mut s, p.clone())), "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn sub_folders_are_made_in_the_export_folder() {
|
||||
@@ -164,6 +257,45 @@ fn a_zip_is_written_into_the_folder() {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// The warnings in `r` that contain `text`.
|
||||
fn warned<'a>(r: &'a Value, text: &str) -> Vec<&'a str> {
|
||||
r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).filter(|w| w.contains(text)).collect()
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn every_reply_says_once_when_an_image_could_not_be_decoded() {
|
||||
let mut s = session(1);
|
||||
let rows = json!([{"format": "png"}, {"format": "jpg", "scale": "2x"}]);
|
||||
assert_eq!(export(&mut s, json!({ "formats": rows }))["warnings"], json!([]));
|
||||
// The renderer can't decode the placed image and draws nothing in its place.
|
||||
crate::tests_menucmds::place_undecodable_png(&mut s);
|
||||
let dir = std::env::temp_dir().join(format!("vc-screens-undecoded-{}", std::process::id()));
|
||||
let folder = dir.to_string_lossy().replace(std::path::MAIN_SEPARATOR, "/");
|
||||
for p in [
|
||||
json!({ "formats": rows }),
|
||||
json!({ "formats": rows, "zip": true }),
|
||||
json!({ "formats": rows, "folder": folder }),
|
||||
json!({ "formats": rows, "folder": folder, "zip": true }),
|
||||
json!({ "formats": rows, "fullDocument": true }),
|
||||
] {
|
||||
let r = export(&mut s, p.clone());
|
||||
assert_eq!(warned(&r, "couldn't be decoded").len(), 1, "{p}: {}", r["warnings"]);
|
||||
}
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 3, "the files are still written");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_in_a_missing_family_is_named_once_for_every_format() {
|
||||
let mut s = session(2);
|
||||
s.execute("text.create", &json!({"x": 5, "y": 20, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap();
|
||||
let r = export(&mut s, json!({"formats": [{"format": "png"}, {"format": "pdf"}, {"format": "eps"}]}));
|
||||
let fallback = warned(&r, "fallback font");
|
||||
assert_eq!(fallback.len(), 1, "{}", r["warnings"]);
|
||||
assert!(fallback[0].contains("Nonexistent Sans Probe"), "{fallback:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_document_gives_one_file_per_format() {
|
||||
let mut s = session(3);
|
||||
|
||||
@@ -145,6 +145,23 @@ fn preserve_editing_reopens_the_native_document() {
|
||||
assert_eq!(s.doc().unwrap().doc.artboards.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_several_artboards_as_plain_svg_warns_they_are_lost() {
|
||||
let artboards = |r: &Value| r["warnings"].as_array().unwrap().iter().filter(|w| w.as_str().unwrap().contains("artboards")).count();
|
||||
let mut s = session(3);
|
||||
let r = s.execute("document.save", &json!({"format": "svg"})).unwrap();
|
||||
assert_eq!(artboards(&r), 1, "{r}");
|
||||
// First: the status bar shows only the first warning.
|
||||
assert!(r["warnings"][0].as_str().unwrap().contains("1 of the document's 3 artboards"), "{r}");
|
||||
assert_eq!(artboards(&s.execute("file.saveAs", &json!({"format": "svgz"})).unwrap()), 1);
|
||||
// Preserve Editing keeps them all (they reopen); a single artboard loses none.
|
||||
for p in [json!({"format": "svg", "preserveEditing": true}), json!({"format": "svg", "svg": {"preserveEditing": true}})] {
|
||||
let r = s.execute("document.save", &p).unwrap();
|
||||
assert_eq!(artboards(&r), 0, "{p}: {r}");
|
||||
}
|
||||
assert_eq!(artboards(&session(1).execute("document.save", &json!({"format": "svg"})).unwrap()), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_writes_svg_and_remembers_its_options() {
|
||||
let mut s = session(1);
|
||||
|
||||
@@ -85,3 +85,19 @@ fn save_for_office_writes_a_png_at_the_resolution() {
|
||||
let spec = crate::cmd::find_command("document.exportForOffice").unwrap();
|
||||
assert_eq!((spec.label, spec.menu), ("Save for Office Documents…", &["File"][..]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_for_office_says_when_an_image_or_a_font_is_missing() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
|
||||
assert_eq!(s.execute("document.exportForOffice", &json!({"ppi": 72})).unwrap()["warnings"], json!([]));
|
||||
// The renderer can't decode the placed image, and the type's family isn't installed.
|
||||
crate::tests_menucmds::place_undecodable_png(&mut s);
|
||||
s.execute("text.create", &json!({"x": 5, "y": 20, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap();
|
||||
let r = s.execute("document.exportForOffice", &json!({"ppi": 72})).unwrap();
|
||||
let warnings: Vec<&str> = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).collect();
|
||||
assert_eq!(warnings.len(), 2, "{warnings:?}");
|
||||
assert!(warnings.contains(&"an image couldn't be decoded and is missing from the picture"), "{warnings:?}");
|
||||
assert!(warnings.iter().any(|w| w.contains("fallback font") && w.contains("Nonexistent Sans Probe")), "{warnings:?}");
|
||||
assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(200), Some(100)), "the PNG is still written");
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
//! TIFF, BMP and Targa export through `document.export`: the options reach the files, impossible
|
||||
//! ones are refused, and the files open again.
|
||||
//! ones are refused, and the files open again; Targa files open and place.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -135,6 +135,152 @@ fn targa_depth_reaches_the_file() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The RGBA pixels of the image `document.open` or `file.place` stored for the first object of the
|
||||
/// active document's first layer.
|
||||
fn stored_pixels(s: &Session) -> image::RgbaImage {
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let im = match &doc.layers[0].children().unwrap()[0].kind {
|
||||
vectorcraft_doc::NodeKind::Image(im) => im.clone(),
|
||||
other => panic!("expected an image, got {other:?}"),
|
||||
};
|
||||
image::load_from_memory(&doc.images[&im.key].bytes).unwrap().to_rgba8()
|
||||
}
|
||||
|
||||
/// A Targa header: no image id or color map, image type `kind`, `w` × `h` pixels of `depth` bits,
|
||||
/// image descriptor `descriptor`.
|
||||
fn targa_header(kind: u8, w: u16, h: u16, depth: u8, descriptor: u8) -> Vec<u8> {
|
||||
let mut f = vec![0, 0, kind, 0, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||
f.extend(w.to_le_bytes());
|
||||
f.extend(h.to_le_bytes());
|
||||
f.extend([depth, descriptor]);
|
||||
f
|
||||
}
|
||||
|
||||
/// Targa files open and place at their pixel size with the transparency they store, by any of the
|
||||
/// four Targa extensions; copied in a file manager they paste as a bitmap; the dialogs list them.
|
||||
#[test]
|
||||
fn targa_files_open_and_place() {
|
||||
use crate::cmd::clipboard::{PASTE_ORDER, file_flavour};
|
||||
let mut s = session();
|
||||
let b64 = vectorcraft_format::base64_encode;
|
||||
// VectorCraft's own exports: the transparent corner stays transparent at 16 and 32 bits (24
|
||||
// bits are flattened on white) and the black rectangle stays black.
|
||||
for depth in [16, 24, 32] {
|
||||
let tga = export(&mut s, json!({"format": "tga", "depth": depth}));
|
||||
let r = s.execute("document.open", &json!({"name": "art.tga", "dataBase64": b64(&tga)})).unwrap();
|
||||
assert_eq!((&r["format"], &r["warnings"]), (&json!("tga"), &json!([])), "{depth} bits: {r}");
|
||||
assert_eq!(s.doc().unwrap().doc.artboards[0].rect.size(), vectorcraft_geom::Size::new(60.0, 40.0));
|
||||
let px = stored_pixels(&s);
|
||||
assert_eq!(px.dimensions(), (60, 40));
|
||||
let corner = if depth == 24 { [255; 4] } else { [0, 0, 0, 0] };
|
||||
assert_eq!(px.get_pixel(5, 5).0[3], corner[3], "{depth} bits: the corner's alpha");
|
||||
if depth == 24 {
|
||||
assert_eq!(px.get_pixel(5, 5).0, corner, "flattened on white");
|
||||
}
|
||||
let black = px.get_pixel(40, 20).0;
|
||||
assert!(black[3] == 255 && black[..3].iter().all(|c| *c < 80), "{depth} bits: {black:?}");
|
||||
// The next export starts from the session's document again.
|
||||
s = session();
|
||||
}
|
||||
let tga = export(&mut s, json!({"format": "tga", "depth": 32}));
|
||||
for name in ["art.vda", "ART.ICB", "art.vst"] {
|
||||
let r = s.execute("document.open", &json!({"name": name, "dataBase64": b64(&tga)})).unwrap();
|
||||
assert_eq!(r["format"], "tga", "{name}: {r}");
|
||||
}
|
||||
s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap();
|
||||
let r = s.execute("file.place", &json!({"name": "art.tga", "dataBase64": b64(&tga)})).unwrap();
|
||||
assert_eq!((&r["format"], r["ids"].as_array().map(Vec::len)), (&json!("tga"), Some(1)), "{r}");
|
||||
assert_eq!(stored_pixels(&s).get_pixel(5, 5).0[3], 0, "placed with its transparency");
|
||||
// Copied in a file manager, a Targa file pastes as a bitmap.
|
||||
assert_eq!(file_flavour("art.tga", &tga, &PASTE_ORDER), Some("image/x-tga"));
|
||||
let r = s.execute("clipboard.importImage", &json!({"mime": "image/x-tga", "dataBase64": b64(&tga)})).unwrap();
|
||||
assert_eq!((&r["count"], &r["pixelWidth"], &r["pixelHeight"]), (&json!(1), &json!(60), &json!(40)), "{r}");
|
||||
// The dialogs list the four extensions; the desktop packaging doesn't associate them.
|
||||
for e in ["tga", "vda", "icb", "vst"] {
|
||||
assert!(OPEN_EXTS.contains(&e) && PLACE_EXTS.contains(&e) && UNASSOCIATED_EXTS.contains(&e), "{e}");
|
||||
}
|
||||
let targa = ("Targa", &["tga", "vda", "icb", "vst"][..]);
|
||||
assert!(open_filters().any(|f| f == targa) && place_filters().any(|f| f == targa));
|
||||
}
|
||||
|
||||
/// Targa pixels in every layout: run-length encoded rows stored top down (as the `image` crate
|
||||
/// writes them), a 16-bit file whose one attribute bit per pixel is its alpha, with runs, stored
|
||||
/// right to left and top down, 32-bit files with no attribute bits (opaque), with eight, and with
|
||||
/// each Attributes Type in an extension area, and color-mapped files whose 16-bit entries carry the
|
||||
/// attribute bit.
|
||||
#[test]
|
||||
fn targa_layouts_and_alpha_read() {
|
||||
let src = image::RgbaImage::from_fn(3, 2, |x, y| image::Rgba([x as u8 * 100, y as u8 * 200, 50, if x == 1 { 128 } else { 255 }]));
|
||||
let mut rle = Vec::new();
|
||||
src.write_to(&mut std::io::Cursor::new(&mut rle), image::ImageFormat::Tga).unwrap();
|
||||
assert_eq!((rle[2], rle[17] & 0x20), (10, 0x20), "run-length encoded true color, stored top down");
|
||||
assert_eq!(raster_image(&rle).map(|r| image::load_from_memory(&r.blob.bytes).unwrap().to_rgba8()).unwrap(), src);
|
||||
|
||||
// 3 × 2, 5-5-5 color and an attribute bit, right to left (bit 4), top down (bit 5): a raw
|
||||
// packet of red, blue and a clear pixel for the first row, then a run of green.
|
||||
let mut f = targa_header(10, 3, 2, 16, 0x31);
|
||||
f.push(0x02);
|
||||
f.extend([0xfc00u16, 0x801f, 0x0000].iter().flat_map(|v| v.to_le_bytes()));
|
||||
f.push(0x82);
|
||||
f.extend(0x83e0u16.to_le_bytes());
|
||||
let img = image::load_from_memory(&raster_image(&f).unwrap().blob.bytes).unwrap().to_rgba8();
|
||||
let px = |x, y| img.get_pixel(x, y).0;
|
||||
assert_eq!((px(2, 0), px(1, 0), px(0, 0)[3]), ([255, 0, 0, 255], [0, 0, 255, 255], 0), "the first row, right to left");
|
||||
assert!((0..3).all(|x| px(x, 1) == [0, 255, 0, 255]), "the run");
|
||||
|
||||
// 2 × 1 at 32 bits with no attribute bits: the fourth byte isn't alpha, whatever it holds.
|
||||
for fourth in [[0, 0], [0, 255]] {
|
||||
let mut f = targa_header(2, 2, 1, 32, 0x20);
|
||||
f.extend([10, 20, 30, fourth[0], 40, 50, 60, fourth[1]]);
|
||||
let img = image::load_from_memory(&raster_image(&f).unwrap().blob.bytes).unwrap().to_rgba8();
|
||||
assert_eq!((img.get_pixel(0, 0).0, img.get_pixel(1, 0).0), ([30, 20, 10, 255], [60, 50, 40, 255]), "{fourth:?}");
|
||||
}
|
||||
// The same with eight attribute bits: the fourth byte is alpha, also when it is 0 everywhere.
|
||||
let mut f = targa_header(2, 2, 1, 32, 0x28);
|
||||
f.extend([10, 20, 30, 0, 40, 50, 60, 0]);
|
||||
let img = image::load_from_memory(&raster_image(&f).unwrap().blob.bytes).unwrap().to_rgba8();
|
||||
assert!(img.pixels().all(|p| p.0[3] == 0));
|
||||
// The extension area's Attributes Type: undefined data (1, 2) isn't alpha, premultiplied colors
|
||||
// (4) are divided by their alpha, and alpha (3) or 0, the field left unset, keeps it.
|
||||
for (kind, want) in [(0, [50, 50, 50, 128]), (1, [50, 50, 50, 255]), (2, [50, 50, 50, 255]), (3, [50, 50, 50, 128]), (4, [100, 100, 100, 128])] {
|
||||
let mut f = targa_header(2, 1, 1, 32, 0x28);
|
||||
f.extend([50, 50, 50, 128]);
|
||||
let mut area = vec![0; 495];
|
||||
area[..2].copy_from_slice(&495u16.to_le_bytes());
|
||||
area[494] = kind;
|
||||
let at = f.len() as u32;
|
||||
f.extend(area);
|
||||
f.extend(at.to_le_bytes());
|
||||
f.extend([0; 4]);
|
||||
f.extend(b"TRUEVISION-XFILE.\0");
|
||||
let img = image::load_from_memory(&raster_image(&f).unwrap().blob.bytes).unwrap().to_rgba8();
|
||||
assert_eq!(img.get_pixel(0, 0).0, want, "Attributes Type {kind}");
|
||||
}
|
||||
|
||||
// Color-mapped, one attribute bit, top down: 16-bit entries 5 (clear black) and 6 (red), as a
|
||||
// raw row of 8-bit indices and as runs.
|
||||
for (kind, data) in [(1, vec![6, 5, 6]), (9, vec![0x80, 6, 0x00, 5, 0x80, 6])] {
|
||||
let mut f = vec![0, 1, kind, 5, 0, 2, 0, 16, 0, 0, 0, 0, 3, 0, 1, 0, 8, 0x21];
|
||||
f.extend([0x0000u16, 0xfc00].iter().flat_map(|v| v.to_le_bytes()));
|
||||
f.extend(data);
|
||||
let img = image::load_from_memory(&raster_image(&f).unwrap().blob.bytes).unwrap().to_rgba8();
|
||||
let row: Vec<[u8; 4]> = (0..3).map(|x| img.get_pixel(x, 0).0).collect();
|
||||
assert_eq!(row, [[255, 0, 0, 255], [0, 0, 0, 0], [255, 0, 0, 255]], "image type {kind}");
|
||||
}
|
||||
|
||||
// A grayscale image the decoder could hold whose RGBA pixels pass the allowance is refused from
|
||||
// its header.
|
||||
let huge = targa_header(11, 32768, 32768, 8, 0);
|
||||
let e = raster_image(&huge).err().expect("refused");
|
||||
assert_eq!(e.to_string(), "an image of 32768 × 32768 pixels is too large to draw");
|
||||
|
||||
// Headers with values the specification doesn't define aren't Targa files.
|
||||
for bad in [targa_header(2, 0, 1, 24, 0), targa_header(1, 1, 1, 8, 0), targa_header(2, 1, 1, 12, 0), targa_header(2, 1, 1, 24, 0xc0)] {
|
||||
assert!(!super::tgaread::is_targa(&bad), "{bad:?}");
|
||||
}
|
||||
assert!(raster_image(b"nothing an image reader knows").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_formats_are_listed_and_write_one_file_per_artboard() {
|
||||
let mut s = Session::new();
|
||||
@@ -143,7 +289,7 @@ fn the_formats_are_listed_and_write_one_file_per_artboard() {
|
||||
let find = |id: &str| r["formats"].as_array().unwrap().iter().find(|f| f["id"] == id).unwrap().clone();
|
||||
let (tiff, bmp, tga) = (find("tiff"), find("bmp"), find("tga"));
|
||||
assert_eq!((tiff["write"].as_bool(), tiff["read"].as_bool()), (Some(true), Some(true)));
|
||||
assert_eq!((bmp["write"].as_bool(), tga["write"].as_bool(), tga["read"].as_bool()), (Some(true), Some(true), Some(false)));
|
||||
assert_eq!((bmp["write"].as_bool(), tga["write"].as_bool(), tga["read"].as_bool()), (Some(true), Some(true), Some(true)));
|
||||
for (f, keys) in [
|
||||
(&tiff, &["colorModel", "lzw", "byteOrder", "embedIcc", "ppi", "antiAlias"][..]),
|
||||
(&bmp, &["colorModel", "depth", "fileFormat", "rle", "flipRows", "reduction", "dither"]),
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
//! Reading Targa files (`.tga`, `.vda`, `.icb`, `.vst`) for File › Open and File › Place, from the
|
||||
//! Truevision TGA File Format Specification, version 2.0. The `image` crate decodes the pixels.
|
||||
//! A Targa file starts with no signature, so [`is_targa`] checks its header's fields instead, and
|
||||
//! [`fix_alpha`] gives the decoded image the transparency the file stores. The file is untrusted:
|
||||
//! every read is checked.
|
||||
|
||||
/// Does `bytes` start with a Targa header? The fields must hold values the specification defines:
|
||||
/// a color map type of 0 or 1; an image type of 1, 2, 3, 9, 10 or 11 (color-mapped, true-color or
|
||||
/// black-and-white, raw or run-length encoded), with a color map for the color-mapped types; a
|
||||
/// color map entry size of 15, 16, 24 or 32 bits when there is a map; a width and a height; a pixel
|
||||
/// depth of 8, 15, 16, 24 or 32 bits; and bits 6 and 7 of the image descriptor clear.
|
||||
pub(super) fn is_targa(bytes: &[u8]) -> bool {
|
||||
let [_, map_type, image_type, _, _, _, _, entry_bits, _, _, _, _, w0, w1, h0, h1, depth, descriptor, ..] = *bytes else {
|
||||
return false;
|
||||
};
|
||||
matches!(map_type, 0 | 1)
|
||||
&& matches!(image_type, 1 | 2 | 3 | 9 | 10 | 11)
|
||||
&& (map_type == 1 || !matches!(image_type, 1 | 9))
|
||||
&& (map_type == 0 || matches!(entry_bits, 15 | 16 | 24 | 32))
|
||||
&& u16::from_le_bytes([w0, w1]) > 0
|
||||
&& u16::from_le_bytes([h0, h1]) > 0
|
||||
&& matches!(depth, 8 | 15 | 16 | 24 | 32)
|
||||
&& descriptor & 0xc0 == 0
|
||||
}
|
||||
|
||||
/// Give `img`, decoded from Targa file `bytes`, the transparency the file stores. Bits 0 to 3 of
|
||||
/// the image descriptor give the number of attribute bits per pixel, and the extension area's
|
||||
/// Attributes Type, when the file has one, says what they hold:
|
||||
/// - with no attribute bits, or attribute data the Attributes Type calls undefined (1 or 2), the
|
||||
/// image is opaque, whatever a 32-bit pixel's fourth byte holds (the decoder reads it as alpha);
|
||||
/// - with one bit, a 16-bit true-color pixel takes that bit as its alpha (clear: transparent), and
|
||||
/// a color-mapped pixel takes the bit of the 16-bit color map entry it indexes; the decoder drops
|
||||
/// the bit;
|
||||
/// - with more, the decoder's alpha stays, and colors the Attributes Type calls premultiplied (4)
|
||||
/// are divided by it.
|
||||
///
|
||||
/// An Attributes Type of 0 ("no alpha") with attribute bits in the descriptor counts as alpha.
|
||||
pub(super) fn fix_alpha(bytes: &[u8], img: &mut image::RgbaImage) {
|
||||
let Some(bits) = bytes.get(17).map(|d| d & 0x0f) else { return };
|
||||
let kind = attributes_type(bytes);
|
||||
if bits == 0 || matches!(kind, Some(1 | 2)) {
|
||||
img.pixels_mut().for_each(|p| p.0[3] = 255);
|
||||
return;
|
||||
}
|
||||
if bits == 1 {
|
||||
one_bit_alpha(bytes, img);
|
||||
}
|
||||
if kind == Some(4) {
|
||||
for p in img.pixels_mut() {
|
||||
let a = u16::from(p.0[3]);
|
||||
for c in &mut p.0[..3] {
|
||||
// A fully transparent pixel keeps its colors.
|
||||
if let Some(v) = (u16::from(*c) * 255 + a / 2).checked_div(a) {
|
||||
*c = v.min(255) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Attributes Type of a Targa 2.0 file's extension area: `None` without the footer's
|
||||
/// `TRUEVISION-XFILE.` signature, an extension area, or one of the 495 bytes the specification
|
||||
/// gives it.
|
||||
fn attributes_type(bytes: &[u8]) -> Option<u8> {
|
||||
let footer = bytes.len().checked_sub(26)?;
|
||||
if bytes.get(footer + 8..)? != b"TRUEVISION-XFILE.\0" {
|
||||
return None;
|
||||
}
|
||||
let at = usize::try_from(u32::from_le_bytes(bytes.get(footer..footer + 4)?.try_into().ok()?)).ok()?;
|
||||
// The area follows the header and ends before the footer; its first field is its size.
|
||||
if at < 18 || at.checked_add(495)? > footer || u16::from_le_bytes([*bytes.get(at)?, *bytes.get(at + 1)?]) != 495 {
|
||||
return None;
|
||||
}
|
||||
bytes.get(at + 494).copied()
|
||||
}
|
||||
|
||||
/// The attribute bit of each pixel of a Targa image with one attribute bit per pixel, as `img`'s
|
||||
/// alpha: the top bit of a 16-bit true-color pixel, or of the 16-bit color map entry an 8- or
|
||||
/// 16-bit index names, raw or run-length encoded. Rows and columns are stored in the order the
|
||||
/// image descriptor's bits 5 (top to bottom) and 4 (right to left) give. Other images, and pixels
|
||||
/// the data doesn't reach, keep the alpha they have.
|
||||
fn one_bit_alpha(bytes: &[u8], img: &mut image::RgbaImage) {
|
||||
let [id_len, map_type, image_type, o0, o1, m0, m1, entry_bits, _, _, _, _, w0, w1, h0, h1, depth, descriptor, ..] = *bytes else {
|
||||
return;
|
||||
};
|
||||
let (w, h) = (u32::from(u16::from_le_bytes([w0, w1])), u32::from(u16::from_le_bytes([h0, h1])));
|
||||
let mapped = matches!(image_type, 1 | 9);
|
||||
// The number of bytes each pixel takes.
|
||||
let size = match image_type {
|
||||
2 | 10 if depth == 16 => 2,
|
||||
1 | 9 if map_type == 1 && entry_bits == 16 && matches!(depth, 8 | 16) => usize::from(depth / 8),
|
||||
_ => return,
|
||||
};
|
||||
if img.dimensions() != (w, h) {
|
||||
return;
|
||||
}
|
||||
let (first, entries) = (u16::from_le_bytes([o0, o1]), u16::from_le_bytes([m0, m1]));
|
||||
let map_at = 18 + usize::from(id_len);
|
||||
let map_len = if map_type == 1 { usize::from(entries) * usize::from(entry_bits).div_ceil(8) } else { 0 };
|
||||
// The attribute bit is the top bit of the little-endian pixel or color map entry.
|
||||
let attribute = |at: usize| -> Option<bool> {
|
||||
let px = bytes.get(at..at.checked_add(size)?)?;
|
||||
let high = if mapped {
|
||||
let index = match *px {
|
||||
[i] => u16::from(i),
|
||||
[lo, hi] => u16::from_le_bytes([lo, hi]),
|
||||
_ => return None,
|
||||
};
|
||||
let entry = index.checked_sub(first).filter(|e| *e < entries)?;
|
||||
*bytes.get(map_at + usize::from(entry) * 2 + 1)?
|
||||
} else {
|
||||
*px.get(1)?
|
||||
};
|
||||
Some(high & 0x80 != 0)
|
||||
};
|
||||
let (top_down, right_to_left) = (descriptor & 0x20 != 0, descriptor & 0x10 != 0);
|
||||
let total = u64::from(w) * u64::from(h);
|
||||
let mut set = |i: u64, opaque: bool| {
|
||||
let (row, col) = ((i / u64::from(w)) as u32, (i % u64::from(w)) as u32);
|
||||
let y = if top_down { row } else { h - 1 - row };
|
||||
let x = if right_to_left { w - 1 - col } else { col };
|
||||
img.get_pixel_mut(x, y).0[3] = if opaque { 255 } else { 0 };
|
||||
};
|
||||
let mut at = map_at + map_len;
|
||||
let mut i = 0;
|
||||
while i < total {
|
||||
if image_type < 9 {
|
||||
let Some(opaque) = attribute(at) else { return };
|
||||
set(i, opaque);
|
||||
(i, at) = (i + 1, at + size);
|
||||
continue;
|
||||
}
|
||||
let Some(&packet) = bytes.get(at) else { return };
|
||||
let count = u64::from(packet & 0x7f) + 1;
|
||||
at += 1;
|
||||
if packet & 0x80 != 0 {
|
||||
// A run packet repeats one pixel.
|
||||
let Some(opaque) = attribute(at) else { return };
|
||||
at += size;
|
||||
for _ in 0..count.min(total - i) {
|
||||
set(i, opaque);
|
||||
i += 1;
|
||||
}
|
||||
} else {
|
||||
for _ in 0..count.min(total - i) {
|
||||
let Some(opaque) = attribute(at) else { return };
|
||||
set(i, opaque);
|
||||
(i, at) = (i + 1, at + size);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
//! A store-only (uncompressed) ZIP archive: several exported files as one download. The files are
|
||||
//! already compressed (PNG, JPEG, PDF…), so storing them keeps the archive fast to write and read.
|
||||
//!
|
||||
//! [`Archive`] reads the files of a ZIP archive, stored or deflated, as PKWARE's APPNOTE.TXT
|
||||
//! describes the format (<https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT>).
|
||||
|
||||
use std::io::Read as _;
|
||||
|
||||
use flate2::Crc;
|
||||
|
||||
@@ -65,6 +70,145 @@ pub fn store<N: AsRef<str>, B: AsRef<[u8]>>(files: &[(N, B)]) -> Result<Vec<u8>,
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Does `bytes` start with a ZIP archive's first local file header?
|
||||
pub fn is_zip(bytes: &[u8]) -> bool {
|
||||
bytes.starts_with(&0x0403_4b50u32.to_le_bytes())
|
||||
}
|
||||
|
||||
/// Why [`Archive`] can't read an archive or one of its files.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum ReadError {
|
||||
/// Not a ZIP archive: no end of central directory record.
|
||||
NotZip,
|
||||
/// More files than the reader was asked to accept.
|
||||
TooManyFiles,
|
||||
/// The archive needs the 64-bit (ZIP64) records, which the reader does not read.
|
||||
Zip64,
|
||||
/// The file is encrypted.
|
||||
Encrypted(String),
|
||||
/// The file is compressed with a method other than stored (0) or deflated (8).
|
||||
Method(String, u16),
|
||||
/// The file unpacks to more bytes than the reader was asked to accept.
|
||||
TooLarge(String),
|
||||
/// The archive (empty name) or the file is truncated, inconsistent or fails its CRC.
|
||||
Damaged(String),
|
||||
}
|
||||
|
||||
/// One file of an archive, as its central directory record describes it.
|
||||
struct Entry {
|
||||
name: String,
|
||||
flags: u16,
|
||||
method: u16,
|
||||
crc: u32,
|
||||
packed: usize,
|
||||
size: usize,
|
||||
local: usize,
|
||||
}
|
||||
|
||||
/// A ZIP archive's files, listed from its central directory.
|
||||
pub struct Archive<'a> {
|
||||
zip: &'a [u8],
|
||||
entries: Vec<Entry>,
|
||||
}
|
||||
|
||||
impl<'a> Archive<'a> {
|
||||
/// The archive `zip`, with at most `max_files` files.
|
||||
pub fn open(zip: &'a [u8], max_files: usize) -> Result<Self, ReadError> {
|
||||
let damaged = || ReadError::Damaged(String::new());
|
||||
let u16_at = |at: usize| at.checked_add(2).and_then(|end| zip.get(at..end)).and_then(|b| b.try_into().ok()).map(u16::from_le_bytes);
|
||||
let u32_at = |at: usize| at.checked_add(4).and_then(|end| zip.get(at..end)).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes);
|
||||
// The end record is 22 bytes followed by a comment of at most 65535 bytes: the signature
|
||||
// whose comment ends the archive, else the last signature (the archive has bytes after it).
|
||||
let last = zip.len().checked_sub(22).ok_or(ReadError::NotZip)?;
|
||||
let candidates = || (last.saturating_sub(0xFFFF)..=last).rev().filter(|&at| u32_at(at) == Some(0x0605_4b50));
|
||||
let end =
|
||||
candidates().find(|&at| u16_at(at + 20).map(usize::from) == Some(last - at)).or_else(|| candidates().next()).ok_or(ReadError::NotZip)?;
|
||||
let count = u16_at(end + 10).ok_or_else(damaged)?;
|
||||
let start = u32_at(end + 16).ok_or_else(damaged)?;
|
||||
if count == 0xFFFF || start == 0xFFFF_FFFF || u32_at(end + 12) == Some(0xFFFF_FFFF) {
|
||||
return Err(ReadError::Zip64);
|
||||
}
|
||||
let count = usize::from(count);
|
||||
if count > max_files {
|
||||
return Err(ReadError::TooManyFiles);
|
||||
}
|
||||
let mut at = start as usize;
|
||||
let mut entries = Vec::with_capacity(count);
|
||||
for _ in 0..count {
|
||||
if u32_at(at) != Some(0x0201_4b50) {
|
||||
return Err(damaged());
|
||||
}
|
||||
let short = |off: usize| at.checked_add(off).and_then(u16_at).ok_or_else(damaged);
|
||||
let long = |off: usize| at.checked_add(off).and_then(u32_at).ok_or_else(damaged);
|
||||
let (name_len, extra, comment) = (usize::from(short(28)?), usize::from(short(30)?), usize::from(short(32)?));
|
||||
let name_at = at.checked_add(46).ok_or_else(damaged)?;
|
||||
let name = name_at.checked_add(name_len).and_then(|end| zip.get(name_at..end)).ok_or_else(damaged)?;
|
||||
entries.push(Entry {
|
||||
name: String::from_utf8_lossy(name).into_owned(),
|
||||
flags: short(8)?,
|
||||
method: short(10)?,
|
||||
crc: long(16)?,
|
||||
packed: long(20)? as usize,
|
||||
size: long(24)? as usize,
|
||||
local: long(42)? as usize,
|
||||
});
|
||||
at = name_at.checked_add(name_len + extra + comment).ok_or_else(damaged)?;
|
||||
}
|
||||
Ok(Self { zip, entries })
|
||||
}
|
||||
|
||||
/// The file `name`, matched in any ASCII case.
|
||||
fn entry(&self, name: &str) -> Option<&Entry> {
|
||||
self.entries.iter().find(|e| e.name.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
/// Does the archive hold the file `name` (in any ASCII case)?
|
||||
pub fn contains(&self, name: &str) -> bool {
|
||||
self.entry(name).is_some()
|
||||
}
|
||||
|
||||
/// The bytes of the file `name` (matched in any ASCII case; None: the archive has no such
|
||||
/// file), when it unpacks to at most `max` bytes and passes its CRC.
|
||||
pub fn read(&self, name: &str, max: usize) -> Result<Option<Vec<u8>>, ReadError> {
|
||||
let Some(e) = self.entry(name) else { return Ok(None) };
|
||||
let damaged = || ReadError::Damaged(e.name.clone());
|
||||
if e.flags & 1 != 0 {
|
||||
return Err(ReadError::Encrypted(e.name.clone()));
|
||||
}
|
||||
if e.size > max {
|
||||
return Err(ReadError::TooLarge(e.name.clone()));
|
||||
}
|
||||
// The local header: its signature, then its name and extra field lengths at 26 and 28.
|
||||
let header = e.local.checked_add(30).and_then(|end| self.zip.get(e.local..end)).ok_or_else(damaged)?;
|
||||
let (Some(sig), Some(name_len), Some(extra)) = (header.get(..4), header.get(26..28), header.get(28..30)) else { return Err(damaged()) };
|
||||
if sig != 0x0403_4b50u32.to_le_bytes() {
|
||||
return Err(damaged());
|
||||
}
|
||||
let len = |b: &[u8]| b.try_into().map(|b| usize::from(u16::from_le_bytes(b))).map_err(|_| damaged());
|
||||
let start = e.local + 30 + len(name_len)? + len(extra)?;
|
||||
let data = start.checked_add(e.packed).and_then(|end| self.zip.get(start..end)).ok_or_else(damaged)?;
|
||||
let bytes = match e.method {
|
||||
0 if e.packed != e.size => return Err(damaged()),
|
||||
0 => data.to_vec(),
|
||||
8 => {
|
||||
let mut out = Vec::with_capacity(e.size);
|
||||
flate2::read::DeflateDecoder::new(data).take((max as u64).saturating_add(1)).read_to_end(&mut out).map_err(|_| damaged())?;
|
||||
if out.len() > max {
|
||||
return Err(ReadError::TooLarge(e.name.clone()));
|
||||
}
|
||||
out
|
||||
}
|
||||
m => return Err(ReadError::Method(e.name.clone(), m)),
|
||||
};
|
||||
let mut crc = Crc::new();
|
||||
crc.update(&bytes);
|
||||
if bytes.len() != e.size || crc.sum() != e.crc {
|
||||
return Err(damaged());
|
||||
}
|
||||
Ok(Some(bytes))
|
||||
}
|
||||
}
|
||||
|
||||
/// The entries of a store-only archive written by [`store`] (read through its central directory,
|
||||
/// checking every CRC) → `(name, bytes)`.
|
||||
#[cfg(test)]
|
||||
|
||||
Loaded 100 of 459 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user