mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-16 12:16:54 -04:00
Compare commits
590
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bc6532928 | ||
|
|
4791c88809 | ||
|
|
06b33b0d2d | ||
|
|
4664907fdc | ||
|
|
35b9d5a645 | ||
|
|
18b472092e | ||
|
|
32a3362e7b | ||
|
|
4bb469563b | ||
|
|
d59733937a | ||
|
|
1799fa533e | ||
|
|
b32017d44f | ||
|
|
0c9b65c931 | ||
|
|
2d230dc31a | ||
|
|
c06c794e5a | ||
|
|
143e4d78e7 | ||
|
|
cb3915d230 | ||
|
|
93913c7906 | ||
|
|
c459285f42 | ||
|
|
e31a39ec40 | ||
|
|
36deb67f20 | ||
|
|
fec2ac402b | ||
|
|
8593c15b90 | ||
|
|
6d4ad17954 | ||
|
|
c401df19d0 | ||
|
|
743801b255 | ||
|
|
f39b01174c | ||
|
|
0f786df95e | ||
|
|
4ee4bff9b6 | ||
|
|
233ad71bd6 | ||
|
|
0e97cf1d4b | ||
|
|
830dea80e0 | ||
|
|
6fc79c33b7 | ||
|
|
24583effe0 | ||
|
|
d21040d746 | ||
|
|
9e44139641 | ||
|
|
314bfe802f | ||
|
|
b795d15d69 | ||
|
|
05b160b32e | ||
|
|
84bd59de36 | ||
|
|
065e237f7f | ||
|
|
adea2d307d | ||
|
|
dfe215ca30 | ||
|
|
bc8e42d8c7 | ||
|
|
d973805269 | ||
|
|
56acb8d3b6 | ||
|
|
0ca6503f09 | ||
|
|
ca0673e0bc | ||
|
|
917dcdeeef | ||
|
|
25d348f391 | ||
|
|
403b335039 | ||
|
|
ddfd08f321 | ||
|
|
f0d3f81f3b | ||
|
|
3b683bbeec | ||
|
|
5527418234 | ||
|
|
38dd835af6 | ||
|
|
2269a58887 | ||
|
|
7bbb63e4bc | ||
|
|
6b06cba32d | ||
|
|
6af7769f08 | ||
|
|
8c21a7710d | ||
|
|
84e2da4bdd | ||
|
|
878d1f231a | ||
|
|
012395d49d | ||
|
|
ef32703362 | ||
|
|
156b492119 | ||
|
|
236e908dc9 | ||
|
|
6dc14c30a9 | ||
|
|
eb50f50599 | ||
|
|
700df5dbe4 | ||
|
|
7fee2c762f | ||
|
|
6cdd63638f | ||
|
|
2df4fede2a | ||
|
|
ee1203d463 | ||
|
|
98a988eaf4 | ||
|
|
e69b25faa6 | ||
|
|
c69463782a | ||
|
|
07e7d14f43 | ||
|
|
17736b6fe3 | ||
|
|
d12f75afcd | ||
|
|
5e01868020 | ||
|
|
6f129beb86 | ||
|
|
a282e4d2bf | ||
|
|
cafe3d658c | ||
|
|
9493e6f91d | ||
|
|
5cb8b1ba07 | ||
|
|
21b4e70150 | ||
|
|
6dfdf643d9 | ||
|
|
ad1e8d1112 | ||
|
|
f46b0f7dc4 | ||
|
|
cc3299dc0f | ||
|
|
467c47b719 | ||
|
|
03ffe790be | ||
|
|
ebc2857549 | ||
|
|
fb13a1e1f1 | ||
|
|
d049e69b95 | ||
|
|
288037543e | ||
|
|
f554c699a1 | ||
|
|
9336111655 | ||
|
|
bb66070841 | ||
|
|
f88242f576 | ||
|
|
b9069e761e | ||
|
|
88088e99bb | ||
|
|
54a8d60dae | ||
|
|
a3adaca19e | ||
|
|
837d2b5667 | ||
|
|
04806a98bf | ||
|
|
c91405dbea | ||
|
|
80b7aee756 | ||
|
|
d838af024b | ||
|
|
e510190867 | ||
|
|
ee6cd6de55 | ||
|
|
f14f8d8eb7 | ||
|
|
3b00334c41 | ||
|
|
e9edc49977 | ||
|
|
f9092edfe3 | ||
|
|
54e43d0a97 | ||
|
|
c5e38a309c | ||
|
|
842656f240 | ||
|
|
a64a5a9911 | ||
|
|
ae6dc17ffe | ||
|
|
2fa10c957c | ||
|
|
deff5b8f42 | ||
|
|
c334aa7e66 | ||
|
|
3f33e854d4 | ||
|
|
257217a2e3 | ||
|
|
f08f3619e3 | ||
|
|
8770d2a96c | ||
|
|
c64c0120f5 | ||
|
|
40fe3154ee | ||
|
|
a812bb7dcc | ||
|
|
756ec5c5e3 | ||
|
|
0bd4ce2260 | ||
|
|
06fdeeef6c | ||
|
|
9da787f951 | ||
|
|
2853d78e66 | ||
|
|
3352e02a2b | ||
|
|
915a172b9a | ||
|
|
59c872baaa | ||
|
|
89a7992c98 | ||
|
|
15819868a2 | ||
|
|
e41f7818d6 | ||
|
|
7316cf7e39 | ||
|
|
114960482e | ||
|
|
23cfe935db | ||
|
|
b04e3ac366 | ||
|
|
60c8b8f4e2 | ||
|
|
75ee324e6c | ||
|
|
4265c2f301 | ||
|
|
a14942de9c | ||
|
|
d05ccffebc | ||
|
|
789a151ff9 | ||
|
|
a8e612d3e2 | ||
|
|
67a278b053 | ||
|
|
a6f56caa0e | ||
|
|
7e9d0be723 | ||
|
|
da43c662e0 | ||
|
|
146c43267a | ||
|
|
3b327d9512 | ||
|
|
28cb75cc59 | ||
|
|
50111c87fd | ||
|
|
716a968e7a | ||
|
|
c586cecf75 | ||
|
|
261b64251d | ||
|
|
d1089270ba | ||
|
|
ee5b0938aa | ||
|
|
76d5ca8bfa | ||
|
|
080d90ed7f | ||
|
|
68887a21c5 | ||
|
|
50152c9941 | ||
|
|
f29390c590 | ||
|
|
872e86d3f6 | ||
|
|
d6e4366885 | ||
|
|
094393959d | ||
|
|
ea164c1543 | ||
|
|
280de29c86 | ||
|
|
b68c467b45 | ||
|
|
afe0239600 | ||
|
|
a91129e422 | ||
|
|
ced45ec157 | ||
|
|
e12a670c40 | ||
|
|
82db3344bf | ||
|
|
96ce65954e | ||
|
|
80178d9902 | ||
|
|
28fe30ce58 | ||
|
|
5f7f9f6d37 | ||
|
|
1060bc6bed | ||
|
|
1ea496a04f | ||
|
|
a302783f32 | ||
|
|
bb66ec13e2 | ||
|
|
9ab58ba536 | ||
|
|
756d05d7ca | ||
|
|
6dc60a6697 | ||
|
|
5d3d28d56f | ||
|
|
832ca236d3 | ||
|
|
82d5e31be4 | ||
|
|
3fb463052e | ||
|
|
65c06f3a57 | ||
|
|
93e92d8a91 | ||
|
|
5e2dd693ae | ||
|
|
dcb759011e | ||
|
|
13a1b60d28 | ||
|
|
2c1bb5f839 | ||
|
|
f7ab3277e0 | ||
|
|
93d6411cce | ||
|
|
4614821372 | ||
|
|
671136fa66 | ||
|
|
2ce3411493 | ||
|
|
8412b4658f | ||
|
|
7ca2763587 | ||
|
|
08dde8783e | ||
|
|
b3e484702f | ||
|
|
799f94ecf1 | ||
|
|
a38c824066 | ||
|
|
0fe70314b8 | ||
|
|
568008dd47 | ||
|
|
192a671d4f | ||
|
|
dc390aba37 | ||
|
|
5ef090a0c2 | ||
|
|
699856f7d5 | ||
|
|
41c8bd2a2d | ||
|
|
c8310edea1 | ||
|
|
848d902354 | ||
|
|
80090e7623 | ||
|
|
ed6d474042 | ||
|
|
74573d6bad | ||
|
|
67f9d134ed | ||
|
|
a1b3c4804f | ||
|
|
9eddeeb952 | ||
|
|
5e75d26d29 | ||
|
|
3ae6cf28e4 | ||
|
|
20325b1a89 | ||
|
|
ba254a7481 | ||
|
|
49fcfbd145 | ||
|
|
5fc7072600 | ||
|
|
74c391c7ba | ||
|
|
e7aacec852 | ||
|
|
736abd811d | ||
|
|
265552d5f4 | ||
|
|
e6b28f0e80 | ||
|
|
e2df2e774c | ||
|
|
2843687ef4 | ||
|
|
32dbc4b641 | ||
|
|
a69371656b | ||
|
|
4b8a987445 | ||
|
|
52b072873d | ||
|
|
0c9ce426e7 | ||
|
|
98220d1929 | ||
|
|
c141d54dad | ||
|
|
f16f542b9b | ||
|
|
0db9d6f757 | ||
|
|
a23a682960 | ||
|
|
2d1e09cf32 | ||
|
|
1838a4e89a | ||
|
|
d02802f391 | ||
|
|
20525c9346 | ||
|
|
3ece6428b3 | ||
|
|
e67a9f558a | ||
|
|
a9d72d01b1 | ||
|
|
81e2c81890 | ||
|
|
fe5c78f6a5 | ||
|
|
57a3cfab73 | ||
|
|
4dcb0900a6 | ||
|
|
80be476b04 | ||
|
|
cddc5a6848 | ||
|
|
d6f6cb9b5f | ||
|
|
5aab51bf87 | ||
|
|
3bcce6593a | ||
|
|
192ca0b8e6 | ||
|
|
b64c88b4d9 | ||
|
|
78c5973609 | ||
|
|
99a6c06681 | ||
|
|
c42b31f48a | ||
|
|
4f794ea8db | ||
|
|
cc467d1ef4 | ||
|
|
bfe6670539 | ||
|
|
bf10fd64f6 | ||
|
|
2346a4acfb | ||
|
|
5b9a9b6d6c | ||
|
|
82d9143502 | ||
|
|
cebc4ed93e | ||
|
|
29bf7b7e06 | ||
|
|
e965c0daa2 | ||
|
|
aaeb658292 | ||
|
|
d541eb9476 | ||
|
|
cf0be249ba | ||
|
|
0594f04e30 | ||
|
|
b15c28b7c0 | ||
|
|
1ab3940b3d | ||
|
|
2abc1d61f7 | ||
|
|
1e8f5025c6 | ||
|
|
83f40f135a | ||
|
|
8b5e9e4927 | ||
|
|
3a7d422046 | ||
|
|
ad3531d464 | ||
|
|
4913418d1c | ||
|
|
9e1590769e | ||
|
|
55fadb6afe | ||
|
|
28daaf473c | ||
|
|
fc51d66659 | ||
|
|
7b8730ec14 | ||
|
|
c54a9b0bef | ||
|
|
a86ff7f177 | ||
|
|
7135a377f8 | ||
|
|
91e1434cbf | ||
|
|
5a43337a42 | ||
|
|
5252c3d86a | ||
|
|
1ba448f7f3 | ||
|
|
032b7b7be1 | ||
|
|
8fffa4f6f9 | ||
|
|
dbcd32d21a | ||
|
|
5d8b5ddc6c | ||
|
|
75b91e9ef3 | ||
|
|
4714567240 | ||
|
|
477ff16357 | ||
|
|
c7aac7c250 | ||
|
|
fd8cd62d17 | ||
|
|
04ed30a23d | ||
|
|
6f9ee7825b | ||
|
|
580aa8daa5 | ||
|
|
f14fbded0e | ||
|
|
446ccb1fd0 | ||
|
|
4440463315 | ||
|
|
6928f422d3 | ||
|
|
c6837ca3ae | ||
|
|
b34b9dadcc | ||
|
|
867e72b085 | ||
|
|
0ffae0c3fa | ||
|
|
9e832d32da | ||
|
|
29eb490381 | ||
|
|
697f238b60 | ||
|
|
8ca25c955e | ||
|
|
5a5f1a0995 | ||
|
|
799155cdd0 | ||
|
|
1f6ef9dcf3 | ||
|
|
fc0e926cda | ||
|
|
940f425a01 | ||
|
|
ea59b066d4 | ||
|
|
427cd65f30 | ||
|
|
ed96a12196 | ||
|
|
be97caf90b | ||
|
|
c48aba77e3 | ||
|
|
6a56c7a5f6 | ||
|
|
4a6d52e7ea | ||
|
|
fda7c9fae3 | ||
|
|
0b10aa67a8 | ||
|
|
2133a21096 | ||
|
|
ca8fb9a543 | ||
|
|
faba555c63 | ||
|
|
97d7e7488a | ||
|
|
029ab098a3 | ||
|
|
2422d0ea5b | ||
|
|
e24edbab41 | ||
|
|
16af375873 | ||
|
|
c246fc2f0e | ||
|
|
c5a1504e99 | ||
|
|
b9341d3b3b | ||
|
|
f3b4c3ec78 | ||
|
|
136810bee2 | ||
|
|
90e6ff3de1 | ||
|
|
dc179c3671 | ||
|
|
8af0459232 | ||
|
|
e93ba4c83f | ||
|
|
9ebd30b8bb | ||
|
|
31ffbb2152 | ||
|
|
ca145c5265 | ||
|
|
16b8aeb6b7 | ||
|
|
76888366e7 | ||
|
|
1682d59b65 | ||
|
|
6545d8b886 | ||
|
|
fb6fc437c3 | ||
|
|
d5a672fda2 | ||
|
|
312a939646 | ||
|
|
dfdb98463f | ||
|
|
6feec01d85 | ||
|
|
2cdd00cf7b | ||
|
|
8958dfe60a | ||
|
|
e5c12d0487 | ||
|
|
e797b68220 | ||
|
|
f4b0e0f27b | ||
|
|
2480017af0 | ||
|
|
312456a769 | ||
|
|
bbda88bc71 | ||
|
|
a324b0e25f | ||
|
|
39faa02fe5 | ||
|
|
57ec0e6d7f | ||
|
|
6341519444 | ||
|
|
d4fa50f262 | ||
|
|
6c4804383a | ||
|
|
263fcff7b1 | ||
|
|
3c9fda57df | ||
|
|
59dfe0834c | ||
|
|
b70ba0f82b | ||
|
|
3a0d1ce970 | ||
|
|
6048fd6b8e | ||
|
|
e479e62317 | ||
|
|
8f1d730ccb | ||
|
|
043f2dff3c | ||
|
|
b8d1bdea40 | ||
|
|
8ca5615dca | ||
|
|
0a0d22df57 | ||
|
|
f3eab99283 | ||
|
|
f253d2b3e3 | ||
|
|
70723a799b | ||
|
|
9bbe74bd19 | ||
|
|
f3363bddc2 | ||
|
|
80f084d8cf | ||
|
|
f614428a92 | ||
|
|
7f899ef293 | ||
|
|
e3e290d43b | ||
|
|
b069019cdd | ||
|
|
e55122afc5 | ||
|
|
73f3731dbf | ||
|
|
5e374712dc | ||
|
|
d726f6596c | ||
|
|
98511401a0 | ||
|
|
f2f1945973 | ||
|
|
d35ed26b0f | ||
|
|
aaa7359c7b | ||
|
|
7608639021 | ||
|
|
e6170de1a2 | ||
|
|
a0f8a88e82 | ||
|
|
6d898558fd | ||
|
|
5ff34782cb | ||
|
|
07c6b938bc | ||
|
|
cb9522f7cd | ||
|
|
0155eaf468 | ||
|
|
5a83bcfafa | ||
|
|
66dbe40ea6 | ||
|
|
53c59f06de | ||
|
|
513685a221 | ||
|
|
4a1323ad07 | ||
|
|
8da47f11f8 | ||
|
|
18f8a1bdcf | ||
|
|
95d27fe390 | ||
|
|
ff7f47119d | ||
|
|
01c66e51b7 | ||
|
|
23a0a5f857 | ||
|
|
651e2e1a57 | ||
|
|
5a5bb73aa9 | ||
|
|
88e950a0d7 | ||
|
|
99bdcc6633 | ||
|
|
ef53890c28 | ||
|
|
011a1a0314 | ||
|
|
17c512f5c9 | ||
|
|
ec602d7e27 | ||
|
|
bb5f16c101 | ||
|
|
b8041d3745 | ||
|
|
0205a3332f | ||
|
|
a7425f27c1 | ||
|
|
fb803dbdae | ||
|
|
81420b87c8 | ||
|
|
57b5931c83 | ||
|
|
ce02a42f35 | ||
|
|
4b60fcead7 | ||
|
|
3bef84aaa6 | ||
|
|
98d1d738a2 | ||
|
|
1c30cbf88c | ||
|
|
d069f0fe98 | ||
|
|
01b3599227 | ||
|
|
00b02c1bdb | ||
|
|
ff33e0556f | ||
|
|
35f43d631f | ||
|
|
bbad808a02 | ||
|
|
d68e14a393 | ||
|
|
4d63227223 | ||
|
|
720ce5ed73 | ||
|
|
b5a0fee1ea | ||
|
|
103c6ebdfb | ||
|
|
25df070576 | ||
|
|
a14abc3177 | ||
|
|
a3081b9f53 | ||
|
|
710b6058ca | ||
|
|
0a4e9ea8c8 | ||
|
|
e22b6a5d45 | ||
|
|
5473e3a5c5 | ||
|
|
8e58b9a23f | ||
|
|
0c3a5929c7 | ||
|
|
7960fa722f | ||
|
|
ac4d69032c | ||
|
|
83759c0e94 | ||
|
|
37c3eb0d01 | ||
|
|
f5fcb30745 | ||
|
|
53eb76f001 | ||
|
|
2ebc607ed2 | ||
|
|
a8e9b9d86e | ||
|
|
5162f830d0 | ||
|
|
62ab3bd797 | ||
|
|
18c1bb0d55 | ||
|
|
1524ceae09 | ||
|
|
a59621fa78 | ||
|
|
c53e161e37 | ||
|
|
8b22b07e09 | ||
|
|
8a8b21385b | ||
|
|
ccc8a246ae | ||
|
|
fda8434d93 | ||
|
|
62089aa333 | ||
|
|
0a09706453 | ||
|
|
d939e46542 | ||
|
|
47f826b16f | ||
|
|
0ccc501ef9 | ||
|
|
bdff89b5c1 | ||
|
|
d47e5cf461 | ||
|
|
18186dbeb7 | ||
|
|
45922c40ef | ||
|
|
f6b9ab905e | ||
|
|
e9fc3c57ae | ||
|
|
256b052981 | ||
|
|
93742621bc | ||
|
|
8d4a814c81 | ||
|
|
f53e7a4c52 | ||
|
|
10b35a4218 | ||
|
|
ceb69c20d1 | ||
|
|
5bcf040659 | ||
|
|
8e72916a69 | ||
|
|
07b45e8dd3 | ||
|
|
8d288337f5 | ||
|
|
d0c302c1a3 | ||
|
|
d425aede37 | ||
|
|
7beebfabb9 | ||
|
|
3af1976d19 | ||
|
|
d5abc71677 | ||
|
|
6986d87771 | ||
|
|
26a9c7a4cc | ||
|
|
ff79f332a7 | ||
|
|
fc14c38883 | ||
|
|
f5eaf75097 | ||
|
|
21749106d3 | ||
|
|
cc0aec12ae | ||
|
|
a23b2370bc | ||
|
|
e18efebf60 | ||
|
|
ef959c00bd | ||
|
|
e2d796b555 | ||
|
|
268c2f2703 | ||
|
|
c9ead98f1c | ||
|
|
baadbe2c67 | ||
|
|
ce133351fd | ||
|
|
313b1a52b9 | ||
|
|
7844f46c65 | ||
|
|
06221726fb | ||
|
|
37cebc47ee | ||
|
|
7217dcd121 | ||
|
|
6f23190925 | ||
|
|
67e17c1b66 | ||
|
|
8aea665c4e | ||
|
|
c2d832ebc0 | ||
|
|
6ce1a622be | ||
|
|
9d813e906a | ||
|
|
9fc7fd7038 | ||
|
|
32df15a554 | ||
|
|
91ed4853a8 | ||
|
|
bcae7ca486 | ||
|
|
eb320593dc | ||
|
|
b5b9264d71 | ||
|
|
0c11ff656c | ||
|
|
433d78284d | ||
|
|
51673cd098 | ||
|
|
f703e6bd85 | ||
|
|
173c88d2a1 | ||
|
|
8354dbcd74 | ||
|
|
cf98b7f368 | ||
|
|
9e90464f20 | ||
|
|
a69ddea930 | ||
|
|
cbe793e059 | ||
|
|
210b98e902 | ||
|
|
56df5969f5 | ||
|
|
b12a52f724 | ||
|
|
05f6e2e6df | ||
|
|
0946bc5b74 | ||
|
|
0a33cff3f6 | ||
|
|
d80950b3f3 | ||
|
|
664e2cec5f | ||
|
|
2ecba1fb9c | ||
|
|
52960faf63 | ||
|
|
a067177778 | ||
|
|
d9c9526898 | ||
|
|
8c889279f8 | ||
|
|
d1108bdbca | ||
|
|
04244fa2a2 | ||
|
|
612a13b39c | ||
|
|
f1e634af19 | ||
|
|
8c14609370 | ||
|
|
b076025a31 | ||
|
|
3bb87989df | ||
|
|
a40e8f5aff | ||
|
|
1a8e0456cb | ||
|
|
7f1ad31a7b | ||
|
|
6fd31d4dbd | ||
|
|
6bc0222074 | ||
|
|
e01a65d8a9 | ||
|
|
a699a26221 |
No files matched your search
@@ -1,19 +1,25 @@
|
||||
# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner
|
||||
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
|
||||
# The `package` job checks packaging/freebsd/package.sh (the release job runs it for real).
|
||||
# FreeBSD build and package check in a QEMU VM.
|
||||
name: FreeBSD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
paths:
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
- "**/Cargo.toml"
|
||||
- .github/workflows/freebsd.yml
|
||||
- packaging/freebsd/**
|
||||
- packaging/env.sh
|
||||
- ".github/workflows/freebsd.yml"
|
||||
- "packaging/freebsd/**"
|
||||
- "packaging/env.sh"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "crates/**"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- ".github/workflows/freebsd.yml"
|
||||
- "packaging/freebsd/**"
|
||||
- "packaging/env.sh"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "crates/**"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
@@ -25,80 +31,9 @@ permissions:
|
||||
|
||||
jobs:
|
||||
freebsd:
|
||||
name: FreeBSD 14 (build + test)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 300
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# The optional craft-fonts build input (Japanese fonts), so the tests that need it run here
|
||||
# rather than skip. Pinned; bump deliberately (craftrules standards/fonts.md).
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
path: craft-fonts
|
||||
- name: Build and test in a FreeBSD VM
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
|
||||
copyback: false
|
||||
prepare: |
|
||||
pkg install -y git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
|
||||
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
run: |
|
||||
. "$HOME/.cargo/env"
|
||||
# The checkout is synced in under another owner; let tests that call git read it.
|
||||
git config --global --add safe.directory "*"
|
||||
rustc --version
|
||||
# The checkout above is synced into the VM with the workspace.
|
||||
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked
|
||||
|
||||
package:
|
||||
name: FreeBSD 14 (package)
|
||||
# The release build takes a while: only for PRs that touch FreeBSD packaging (or dependencies)
|
||||
# and on demand, not on every push to main.
|
||||
if: github.event_name != 'push'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 150
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Japanese, Chinese, Arabic and Thai fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR in the VM).
|
||||
# Bump the commit deliberately; see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
path: craft-fonts
|
||||
- name: Package in a FreeBSD VM
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
mem: 12288
|
||||
prepare: |
|
||||
pkg install -y bash curl git pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
|
||||
run: |
|
||||
. "$HOME/.cargo/env"
|
||||
git config --global --add safe.directory "$PWD"
|
||||
# Build outside the checkout so only dist/ is copied back out of the VM.
|
||||
# The craft-fonts checkout is synced into the VM with the workspace.
|
||||
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
|
||||
export CARGO_TARGET_DIR=/var/tmp/vectorcraft-target CARGO_BUILD_JOBS=4
|
||||
bash packaging/freebsd/package.sh
|
||||
# Every part the package must have, the craft-fonts licences included.
|
||||
tar -tzf dist/release/vectorcraft-*-freebsd-x86_64.tar.gz >/var/tmp/contents.txt
|
||||
for want in '/bin/vectorcraft$' '/bin/vectorcraft-cli$' '/share/applications/.*\.desktop$' '/share/icons/hicolor/' '/share/doc/vectorcraft/NOTICE$' '/share/doc/vectorcraft/OFL-.*\.txt$'; do
|
||||
grep -Eq "$want" /var/tmp/contents.txt || { echo "missing from the package: $want"; exit 1; }
|
||||
done
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: freebsd-package
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
|
||||
with:
|
||||
require-craft-fonts: true
|
||||
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
run-package-check: true
|
||||
@@ -0,0 +1,23 @@
|
||||
# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation
|
||||
# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice.
|
||||
name: Nix
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
|
||||
pull_request:
|
||||
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: nix-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
nix:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
|
||||
@@ -19,48 +19,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ACTIONLINT_VERSION: 1.7.12
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml
|
||||
curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
|
||||
| tar -xz -C "$RUNNER_TEMP" actionlint
|
||||
- name: actionlint
|
||||
run: |
|
||||
"$RUNNER_TEMP/actionlint" -color
|
||||
- name: shellcheck
|
||||
run: shellcheck packaging/*.sh packaging/*/*.sh
|
||||
- name: PowerShell
|
||||
shell: pwsh
|
||||
run: |
|
||||
foreach ($f in Get-ChildItem packaging/windows/*.ps1) {
|
||||
$errors = $null
|
||||
[System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null
|
||||
if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 }
|
||||
Write-Output "ok $($f.Name)"
|
||||
}
|
||||
- name: XML, desktop entry, AppStream, Flatpak manifest
|
||||
run: |
|
||||
xmllint --noout packaging/windows/vectorcraft.wxs packaging/windows/vectorcraft.en-us.wxl packaging/macos/*.plist* packaging/linux/*.xml*
|
||||
# Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon.
|
||||
if grep -oE '<Icon Id="[^"]*"' packaging/windows/vectorcraft.wxs | grep -vE '\.(ico|exe)"$'; then
|
||||
echo "::error::Icon Id must end in .ico or .exe"; exit 1
|
||||
fi
|
||||
desktop-file-validate packaging/linux/ai.storyteller.vectorcraft.desktop
|
||||
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.vectorcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.vectorcraft.metainfo.xml"
|
||||
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.vectorcraft.metainfo.xml"
|
||||
python3 - <<'EOF'
|
||||
import yaml
|
||||
src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.vectorcraft.yml"))
|
||||
bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.vectorcraft.bundle.yml"))
|
||||
for m in (src, bundle):
|
||||
assert m["id"] == "ai.storyteller.vectorcraft" and m["command"] == "vectorcraft", m["id"]
|
||||
for key in ("runtime", "runtime-version", "sdk", "finish-args"):
|
||||
assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}"
|
||||
print("Flatpak manifests ok")
|
||||
EOF
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
|
||||
with:
|
||||
extra-checks-script: packaging/check-app-specific.sh
|
||||
+23
-488
@@ -1,11 +1,5 @@
|
||||
# Release pipeline: every push to `release` builds signed installers for every platform and
|
||||
# creates or updates the draft GitHub Release "VectorCraft v<version>". See docs/releasing.md.
|
||||
#
|
||||
# Jobs that sign (macOS, Windows) and the job that creates the release run in the `release`
|
||||
# environment (restricted to the `release` branch), which holds the signing secrets. Every secret
|
||||
# is optional: missing ones produce unsigned artifacts and a warning, never a failed build. The
|
||||
# other jobs sign nothing and get no environment, so a `workflow_dispatch` on any branch is a dry
|
||||
# run: they build and upload artifacts, the signed jobs are refused, and no release is drafted.
|
||||
name: Release
|
||||
|
||||
on:
|
||||
@@ -19,492 +13,33 @@ on:
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
# Never cancel a run mid-notarization or mid-upload; a newer push waits its turn.
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
RUST_BACKTRACE: 1
|
||||
VECTORCRAFT_BUILD_SHA: ${{ github.sha }}
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
|
||||
jobs:
|
||||
version:
|
||||
name: version
|
||||
runs-on: ubuntu-latest
|
||||
# No `environment: release`: no secrets here.
|
||||
outputs:
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
date: ${{ steps.v.outputs.date }}
|
||||
prerelease: ${{ steps.v.outputs.prerelease }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- id: v
|
||||
name: Resolve version
|
||||
run: |
|
||||
. packaging/env.sh
|
||||
v="${INPUT_VERSION#v}"
|
||||
v="${v:-$VERSION}"
|
||||
if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
|
||||
echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1"
|
||||
exit 1
|
||||
fi
|
||||
case "$v" in *-*) pre=true ;; *) pre=false ;; esac
|
||||
{
|
||||
echo "version=$v"
|
||||
echo "date=$(date -u +%Y-%m-%d)"
|
||||
echo "prerelease=$pre"
|
||||
} >>"$GITHUB_OUTPUT"
|
||||
echo "### VectorCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
|
||||
|
||||
macos:
|
||||
name: macOS universal
|
||||
needs: version
|
||||
runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend)
|
||||
environment: release
|
||||
env:
|
||||
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Japanese, Chinese, Arabic and Thai fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
|
||||
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin, x86_64-apple-darwin
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-macos-universal
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$VECTORCRAFT_VERSION"
|
||||
- name: Import signing certificate
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
run: packaging/macos/import-cert.sh
|
||||
- name: Build, sign, notarize
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: packaging/macos/package.sh --arch universal
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-macos
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
- name: Remove temporary keychain
|
||||
if: always()
|
||||
run: |
|
||||
if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi
|
||||
|
||||
windows:
|
||||
name: Windows ${{ matrix.arch }}
|
||||
needs: version
|
||||
runs-on: windows-latest
|
||||
environment: release
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x64, target: x86_64-pc-windows-msvc }
|
||||
- { arch: x86, target: i686-pc-windows-msvc }
|
||||
# Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above.
|
||||
# .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware.
|
||||
- { arch: arm64, target: aarch64-pc-windows-msvc }
|
||||
env:
|
||||
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Japanese, Chinese, Arabic and Thai fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
|
||||
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-windows-${{ matrix.arch }}
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
shell: bash
|
||||
run: cargo xtask version set "$VECTORCRAFT_VERSION"
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: |
|
||||
dotnet tool install --global wix --version 5.0.2
|
||||
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
|
||||
- name: Build, sign, package
|
||||
shell: pwsh
|
||||
env:
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-windows-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
linux:
|
||||
name: Linux ${{ matrix.arch }}
|
||||
needs: version
|
||||
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
|
||||
# Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md.
|
||||
runs-on: ${{ matrix.runner }}
|
||||
# No `environment: release`: Linux packages are not signed (checksums in SHA256SUMS.txt).
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
|
||||
- { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
|
||||
env:
|
||||
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
NFPM_VERSION: 2.47.0
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Japanese, Chinese, Arabic and Thai fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
|
||||
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
path: craft-fonts
|
||||
- name: Install build and packaging tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \
|
||||
desktop-file-utils appstream python3-yaml zsync
|
||||
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
|
||||
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
|
||||
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-linux-${{ matrix.arch }}
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$VECTORCRAFT_VERSION"
|
||||
- name: Build and package (AppImage, deb, rpm, tar.gz)
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: packaging/linux/package.sh
|
||||
- name: Check the packages
|
||||
run: |
|
||||
dpkg-deb --info dist/release/*.deb
|
||||
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/vectorcraft$|metainfo|applications'
|
||||
ldd target/release/vectorcraft
|
||||
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.vectorcraft.yml')); assert m['id'] == 'ai.storyteller.vectorcraft'"
|
||||
for appimage in dist/release/*.AppImage; do
|
||||
chmod +x "$appimage"
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
|
||||
# Update information is embedded, and the .zsync for delta updates sits beside it.
|
||||
# No APPIMAGE_EXTRACT_AND_RUN here: with it the runtime passes --appimage-* flags on
|
||||
# to the app instead of answering them itself.
|
||||
"$appimage" --appimage-updateinformation \
|
||||
| grep -F "gh-releases-zsync|${GITHUB_REPOSITORY%%/*}|${GITHUB_REPOSITORY#*/}|latest|vectorcraft-*-linux-${{ matrix.arch }}.AppImage.zsync"
|
||||
test -s "$appimage.zsync"
|
||||
done
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
flatpak:
|
||||
name: Flatpak ${{ matrix.arch }}
|
||||
needs: [version, linux]
|
||||
# Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
|
||||
# build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships.
|
||||
runs-on: ${{ matrix.runner }}
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, runner: ubuntu-24.04 }
|
||||
- { arch: aarch64, runner: ubuntu-24.04-arm }
|
||||
env:
|
||||
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/linux
|
||||
- name: Install flatpak-builder
|
||||
run: |
|
||||
sudo apt-get update
|
||||
# librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's
|
||||
# `appstreamcli compose`, which renders the scalable icon and fails without it.
|
||||
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common
|
||||
# Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs.
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
- name: Build the Flatpak bundle
|
||||
run: packaging/linux/flatpak-bundle.sh "dist/linux/vectorcraft-$VECTORCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-flatpak-${{ matrix.arch }}
|
||||
path: dist/release/*.flatpak
|
||||
if-no-files-found: error
|
||||
|
||||
freebsd:
|
||||
name: FreeBSD x86_64
|
||||
needs: version
|
||||
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM (same image and packages as
|
||||
# freebsd.yml).
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 150
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
env:
|
||||
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Japanese, Chinese, Arabic and Thai fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR in the VM).
|
||||
# Bump the commit deliberately; see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
if: inputs.version != ''
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$VECTORCRAFT_VERSION"
|
||||
- name: Build and package in a FreeBSD VM
|
||||
# v1.5.9, pinned by commit.
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
# Passed through by name; never templated into the script.
|
||||
envs: "VECTORCRAFT_VERSION VECTORCRAFT_BUILD_DATE VECTORCRAFT_BUILD_SHA"
|
||||
prepare: |
|
||||
pkg install -y bash curl git pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
|
||||
run: |
|
||||
. "$HOME/.cargo/env"
|
||||
git config --global --add safe.directory "$PWD"
|
||||
# Build outside the checkout so only dist/ is copied back out of the VM.
|
||||
# The craft-fonts checkout is synced into the VM with the workspace.
|
||||
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
|
||||
export CARGO_TARGET_DIR=/var/tmp/vectorcraft-target CARGO_BUILD_JOBS=4
|
||||
bash packaging/freebsd/package.sh
|
||||
tar -tzf dist/release/vectorcraft-*-freebsd-x86_64.tar.gz \
|
||||
| grep -E '/bin/vectorcraft$|/bin/vectorcraft-cli$|/share/applications/ai\.storyteller\.vectorcraft\.desktop$'
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-freebsd
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
web:
|
||||
name: Web (wasm)
|
||||
needs: version
|
||||
runs-on: ubuntu-latest
|
||||
# No `environment: release`: no secrets here.
|
||||
env:
|
||||
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
TRUNK_VERSION: 0.21.14
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Japanese, Chinese, Arabic and Thai fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
|
||||
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-web
|
||||
- name: Install trunk
|
||||
run: |
|
||||
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
|
||||
| tar -xz -C "$HOME/.cargo/bin" trunk
|
||||
trunk --version
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$VECTORCRAFT_VERSION"
|
||||
- name: Build and zip
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: packaging/web/package.sh
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-web
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
linux-riscv64:
|
||||
name: Linux riscv64
|
||||
needs: version
|
||||
# Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release
|
||||
# from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's
|
||||
# riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the
|
||||
# CLI is smoke-tested under QEMU (package.sh runs `vectorcraft-cli --version` through $EMULATOR).
|
||||
runs-on: ubuntu-24.04
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
env:
|
||||
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Japanese, Chinese, Arabic and Thai fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
|
||||
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: riscv64gc-unknown-linux-gnu
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-linux-riscv64
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$VECTORCRAFT_VERSION"
|
||||
- name: Install cross-compilation dependencies
|
||||
run: |
|
||||
. /etc/os-release
|
||||
sudo dpkg --add-architecture riscv64
|
||||
# Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports.
|
||||
if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then
|
||||
sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources
|
||||
fi
|
||||
if [ -f /etc/apt/sources.list ]; then
|
||||
sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list
|
||||
sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list
|
||||
fi
|
||||
for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do
|
||||
echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse"
|
||||
done | sudo tee /etc/apt/sources.list.d/riscv64.list
|
||||
sudo apt-get update
|
||||
# The riscv64 variants of the linux job's -dev packages.
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \
|
||||
libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \
|
||||
libxi-dev:riscv64 libgl1-mesa-dev:riscv64 \
|
||||
desktop-file-utils appstream
|
||||
- name: Build and package (tar.gz)
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc
|
||||
CC_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-gcc
|
||||
CXX_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-g++
|
||||
AR_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-ar
|
||||
PKG_CONFIG_SYSROOT_DIR: /
|
||||
PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig
|
||||
PKG_CONFIG_ALLOW_CROSS: "1"
|
||||
CROSS_ARCH: riscv64
|
||||
CROSS_TARGET: riscv64gc-unknown-linux-gnu
|
||||
CROSS_COMPILE: riscv64-linux-gnu-
|
||||
EMULATOR: qemu-riscv64
|
||||
QEMU_LD_PREFIX: /usr/riscv64-linux-gnu
|
||||
run: packaging/linux/package.sh --formats tar
|
||||
- name: Check the package
|
||||
run: |
|
||||
tar -tzf dist/release/vectorcraft-*-linux-riscv64.tar.gz \
|
||||
| grep -E '/bin/vectorcraft$|/bin/vectorcraft-cli$|/share/applications/ai\.storyteller\.vectorcraft\.desktop$'
|
||||
file target/riscv64gc-unknown-linux-gnu/release/vectorcraft | grep -F 'RISC-V'
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-linux-riscv64
|
||||
path: dist/release/*.tar.gz
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
name: Draft GitHub Release
|
||||
# Needs the signed jobs, so a branch dry run (where the environment refuses them) never
|
||||
# drafts a release.
|
||||
needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web]
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
VERSION: ${{ needs.version.outputs.version }}
|
||||
PRERELEASE: ${{ needs.version.outputs.prerelease }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
steps:
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
pattern: release-*
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
- name: Checksums
|
||||
working-directory: artifacts
|
||||
run: |
|
||||
sha256sum -- * | tee SHA256SUMS.txt
|
||||
- name: Create or update the draft release
|
||||
working-directory: artifacts
|
||||
run: |
|
||||
tag="v$VERSION"
|
||||
title="VectorCraft v$VERSION"
|
||||
pre=()
|
||||
if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi
|
||||
if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then
|
||||
if [ "$draft" != "true" ]; then
|
||||
echo "::error::Release $tag is already published. Bump the version (cargo xtask version set) before pushing to release."
|
||||
exit 1
|
||||
fi
|
||||
echo "Updating draft $tag: replacing its assets"
|
||||
gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do
|
||||
gh release delete-asset "$tag" "$asset" --yes
|
||||
done
|
||||
gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE"
|
||||
gh release upload "$tag" --clobber -- *
|
||||
else
|
||||
echo "Creating draft $tag"
|
||||
gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- *
|
||||
fi
|
||||
{
|
||||
echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:"
|
||||
echo
|
||||
sed 's/^/ /' SHA256SUMS.txt
|
||||
} >>"$GITHUB_STEP_SUMMARY"
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
|
||||
with:
|
||||
app-name: vectorcraft
|
||||
app-title: VectorCraft
|
||||
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
version: ${{ inputs.version }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -1,22 +1,27 @@
|
||||
# Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64
|
||||
# runner, unsigned here), then install that MSI on a Windows 11 ARM64 runner, check the installed
|
||||
# binaries are ARM64, run the CLI natively, and uninstall. (A native ARM64 `cargo test` job is left
|
||||
# out for now: PdfCraft saw an intermittent access violation in egui UI tests there.)
|
||||
# Windows ARM64 build and smoke test.
|
||||
#
|
||||
# Release packages on arm64-windows are produced on x64 runners via cross-compilation
|
||||
# (target: aarch64-pc-windows-msvc) using MSVC's ARM64 cross tools, then installed and run
|
||||
# on GitHub Actions' native windows-11-arm runner.
|
||||
name: Windows ARM64
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
paths:
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
- "**/Cargo.toml"
|
||||
- "**/build.rs"
|
||||
- .github/workflows/windows-arm64.yml
|
||||
- packaging/windows/**
|
||||
schedule:
|
||||
- cron: "41 4 * * 1" # weekly, so drift in the runner images shows up
|
||||
- ".github/workflows/windows-arm64.yml"
|
||||
- "packaging/windows/**"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "crates/**"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- ".github/workflows/windows-arm64.yml"
|
||||
- "packaging/windows/**"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "crates/**"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
@@ -26,72 +31,10 @@ concurrency:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
|
||||
jobs:
|
||||
package:
|
||||
name: package (cross-compiled on x64)
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: aarch64-pc-windows-msvc
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: windows-arm64-package
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: |
|
||||
dotnet tool install --global wix --version 5.0.2
|
||||
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
|
||||
- name: Build and package (unsigned)
|
||||
shell: pwsh
|
||||
env:
|
||||
DIST: ${{ github.workspace }}\dist\arm64
|
||||
run: ./packaging/windows/package.ps1 -Arch arm64
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: vectorcraft-windows-arm64
|
||||
path: dist/arm64/*
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
install:
|
||||
name: install and run on ARM64
|
||||
needs: package
|
||||
runs-on: windows-11-arm
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: vectorcraft-windows-arm64
|
||||
path: dist
|
||||
- name: Install the MSI, check it, run it, uninstall it
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName
|
||||
$p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru
|
||||
if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" }
|
||||
$dir = Join-Path $env:ProgramFiles 'VectorCraft'
|
||||
foreach ($exe in 'vectorcraft.exe', 'vectorcraft-cli.exe') {
|
||||
$path = Join-Path $dir $exe
|
||||
if (-not (Test-Path $path)) { throw "$exe not installed in $dir" }
|
||||
$bytes = [System.IO.File]::ReadAllBytes($path)
|
||||
$machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4)
|
||||
if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" }
|
||||
Write-Output "ok $path (ARM64)"
|
||||
}
|
||||
# The installed CLI runs natively on ARM64.
|
||||
$cli = Join-Path $dir 'vectorcraft-cli.exe'
|
||||
$out = & $cli --version 2>&1 | Out-String
|
||||
if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "vectorcraft-cli --version exited $LASTEXITCODE" }
|
||||
Write-Output "ok vectorcraft-cli --version: $($out.Trim())"
|
||||
$p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru
|
||||
if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" }
|
||||
if (Test-Path (Join-Path $dir 'vectorcraft.exe')) { throw 'uninstall left vectorcraft.exe behind' }
|
||||
Write-Output 'ok uninstalled'
|
||||
windows-arm64:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
|
||||
with:
|
||||
app-name: vectorcraft
|
||||
app-dir: VectorCraft
|
||||
@@ -117,6 +117,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
||||
| `assets/icons/dc-cube.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-dash-align.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-dash-exact.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-dimension.svg` | Osama Amin | Original work, drawn for VectorCraft: a dimension line with arrowheads between two extension lines | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-dir-off.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-dir-on.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-direct.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
@@ -133,6 +134,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
||||
| `assets/icons/dc-draw-normal.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-ellipse.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-fill-none.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-filter.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-flare.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-folder.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-free-transform.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
@@ -323,6 +325,11 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
||||
| `docs/images/shot-3-sheet.png` | VectorCraft contributors | Screenshot of VectorCraft itself, editing `examples/feature-sheet.vectorcraft` | MIT OR Apache-2.0 | |
|
||||
| `docs/images/shot-4-bezier.png` | VectorCraft contributors | Screenshot of VectorCraft itself, editing `examples/feature-sheet.vectorcraft` | MIT OR Apache-2.0 | |
|
||||
| `docs/images/shot-5-perspective.png` | VectorCraft contributors | Screenshot of VectorCraft itself, editing `examples/perspective-city.vectorcraft` | MIT OR Apache-2.0 | |
|
||||
| `docs/images/shot-6-revolve.png` | VectorCraft contributors | Screenshot of VectorCraft itself, editing `examples/still-life.vectorcraft` | MIT OR Apache-2.0 | |
|
||||
| `docs/images/shot-7-dimensions.png` | VectorCraft contributors | Screenshot of VectorCraft itself, editing `examples/bracket-drawing.vectorcraft` | MIT OR Apache-2.0 | |
|
||||
| `docs/images/art-still-life.png` | VectorCraft contributors | Rendered by VectorCraft from `examples/still-life.vectorcraft` | MIT OR Apache-2.0 | |
|
||||
| `examples/still-life.vectorcraft` | VectorCraft contributors | Original artwork built through the VectorCraft command API | MIT OR Apache-2.0 | |
|
||||
| `examples/bracket-drawing.vectorcraft` | VectorCraft contributors | Original drawing built through the VectorCraft command API | MIT OR Apache-2.0 | |
|
||||
| `docs/images/art-neon-drive.png` | VectorCraft contributors | Rendered by VectorCraft from `examples/neon-drive.vectorcraft` | MIT OR Apache-2.0 | |
|
||||
| `docs/images/art-ribbons.png` | VectorCraft contributors | Rendered by VectorCraft from `examples/ribbons.vectorcraft` | MIT OR Apache-2.0 | |
|
||||
| `docs/images/art-pathfinder.png` | VectorCraft contributors | Rendered by VectorCraft from `examples/feature-sheet.vectorcraft` (artboard 1) | MIT OR Apache-2.0 | |
|
||||
@@ -333,3 +340,27 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
||||
| `examples/ribbons.vectorcraft` | VectorCraft contributors | Original artwork built through the VectorCraft command API | MIT OR Apache-2.0 | |
|
||||
| `examples/feature-sheet.vectorcraft` | VectorCraft contributors | Original artwork built through the VectorCraft command API | MIT OR Apache-2.0 | |
|
||||
| `examples/perspective-city.vectorcraft` | VectorCraft contributors | Original artwork built through the VectorCraft command API | MIT OR Apache-2.0 | |
|
||||
|
||||
## Assets embedded by the PDF backend
|
||||
|
||||
The shared PDF backend keeps the existing standard-font and CMap features. These files remain in dependency sources; no font files are copied into this repository. Hashes identify the pinned dependency contents.
|
||||
|
||||
| Crate / file | Author | Source | Licence | SHA-256 | Use |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitDingbats.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitDingbats.pfb | BSD-3-Clause | `845c752392b6c914fb989c75a08b7792b88f542d2499042ef2889f8c814a16ed` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitFixed.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitFixed.pfb | BSD-3-Clause | `b6c8fe53f134b8b6d4578cd2d544df4cee9624c4efa8d51a560fb40ea296101b` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitFixedBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitFixedBold.pfb | BSD-3-Clause | `f1b7159702973f54fc86254ea38bcf3712b2a736eb3a0995751e9f4bb45ad603` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitFixedBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitFixedBoldItalic.pfb | BSD-3-Clause | `8a000945843bd31add06aee63bf9fd41b7578b4f3242a4b7cd46349ad9d24d4d` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitFixedItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitFixedItalic.pfb | BSD-3-Clause | `5007faf8320fa1fcc08b8894b356ad976f60b992ba29ee5272578bbdebaf3876` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitSans.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSans.pfb | BSD-3-Clause | `a02339e95d20db14cbdfc1c6baf359cd36a091d5434925c3a4040f60e1a1af9e` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitSansBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSansBold.pfb | BSD-3-Clause | `7bdfa36de1d5757dcfaf8b8a4be95c6e41c40fa70dbed9d78bf979a7b9959054` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitSansBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSansBoldItalic.pfb | BSD-3-Clause | `555ce45924338e2e4b32821910221ce85da691fd59a9723c4c971a7c5627fc8b` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitSansItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSansItalic.pfb | BSD-3-Clause | `7751cbefeda351708dae8c4a88db043411bc39ab2ae2097685da845d4d16f307` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitSerif.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSerif.pfb | BSD-3-Clause | `4f57d2b9d884af8f907bf22df6019b52d86cbf6214fdbd02b1ae05472a543f35` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitSerifBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSerifBold.pfb | BSD-3-Clause | `0bdf4b04e964139818d51eda03d566ba999fc3ba2421b1c6c51f9dc969022e80` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitSerifBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSerifBoldItalic.pfb | BSD-3-Clause | `a406cac82583bf98175cb62c87ed5e95c45fbb34eece63a10b0a13e793cb2e10` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitSerifItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSerifItalic.pfb | BSD-3-Clause | `610ae0687198045c4db5d1a6650fd5e92536631706382eb8b72e38df578d0ae9` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitSymbol.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSymbol.pfb | BSD-3-Clause | `47967d055530e7357088a08403115425643ec2cdfd6201ba8af0fbd7116c1539` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/CGATS001Compat-v2-micro.icc` | Clinton Ingram (saucecontrol/Compact-ICC-Profiles) | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/CGATS001Compat-v2-micro.icc | CC0-1.0 | `73e1ba37d2bad5bab2a964f40a9eed96209666efc067c3322626214bbef234a0` | Default CMYK colour conversion (pinned hayro-interpret) |
|
||||
| `hayro-cmap 0.1.0 / assets/cmaps.brotli` | Adobe | https://crates.io/crates/hayro-cmap/0.1.0 | BSD-3-Clause | `ddf37af521d904d5646c2fc37b2b7e912dc1b56f0d210190b61f598154758e46` | Character-code mapping tables required to read CJK PDFs. Non-visual dependency data; existing embedding unchanged |
|
||||
| `hayro-interpret 0.7.0 / src/font/generated/metrics.rs` | Adobe (Core 14 AFM metrics, PDF specification encodings); tables by The Hayro Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/src/font/generated/metrics.rs | MIT OR Apache-2.0 | `2ec9e608573eda459de1e1d9bda3bb494315e36bee885acc88912a11fe0caa41` | Glyph widths and encodings for non-embedded standard fonts. Non-visual dependency data; existing embedding unchanged |
|
||||
Generated
+82
-77
@@ -806,6 +806,17 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d"
|
||||
dependencies = [
|
||||
"hayro-interpret",
|
||||
"hayro-syntax",
|
||||
"krilla",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-print"
|
||||
version = "0.1.0"
|
||||
@@ -1304,12 +1315,6 @@ version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
|
||||
|
||||
[[package]]
|
||||
name = "fearless_simd"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3296eb97d869113eaf148a6bdf01884e90b3828ad7ae2340b0f93362fc158611"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.14"
|
||||
@@ -1378,24 +1383,13 @@ dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fontconfig-parser"
|
||||
version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646"
|
||||
dependencies = [
|
||||
"roxmltree 0.20.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fontdb"
|
||||
version = "0.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2660c5e9157bf76d2db1294e4a9feba604ef610819a3b591088d0d8392a3290f"
|
||||
dependencies = [
|
||||
"fontconfig-parser",
|
||||
"log",
|
||||
"memmap2",
|
||||
"slotmap",
|
||||
"tinyvec",
|
||||
]
|
||||
@@ -1766,9 +1760,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hayro-ccitt"
|
||||
version = "0.4.0"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2866f0d976e76497480b853d5e258dab6e4b89ecfe991124e08f15dfa8fd03ad"
|
||||
checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a"
|
||||
|
||||
[[package]]
|
||||
name = "hayro-cmap"
|
||||
@@ -1782,9 +1776,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hayro-interpret"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac9df074ff163a63864cec9b0bf640ccbff8abf4e44481df7369ce0ac172ffbe"
|
||||
version = "0.7.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"hayro-cmap",
|
||||
@@ -1794,28 +1787,27 @@ dependencies = [
|
||||
"phf",
|
||||
"rustc-hash 2.1.3",
|
||||
"siphasher",
|
||||
"skrifa 0.46.2",
|
||||
"skrifa 0.47.0",
|
||||
"smallvec",
|
||||
"yoke",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hayro-jbig2"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "000e025850f4592ddcaee85640db70704151f2bafd43b6f431aa5b6a2c6a35a9"
|
||||
version = "0.3.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d"
|
||||
dependencies = [
|
||||
"fearless_simd 1.1.0",
|
||||
"fearless_simd",
|
||||
"hayro-ccitt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hayro-jpeg2000"
|
||||
version = "0.4.1"
|
||||
version = "0.3.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "122c5f834c37a2260b9c851bb8be685cf539b2a8b94b704a931ef7d2525d5835"
|
||||
checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1"
|
||||
dependencies = [
|
||||
"fearless_simd 1.1.0",
|
||||
"fearless_simd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1826,9 +1818,8 @@ checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa"
|
||||
|
||||
[[package]]
|
||||
name = "hayro-syntax"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6cdc818d8d265dcb42081d488f7599df4f73fb79e9d347ecedcd3c23809d2026"
|
||||
version = "0.7.2"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hayro-ccitt",
|
||||
@@ -1837,6 +1828,7 @@ dependencies = [
|
||||
"memchr",
|
||||
"rustc-hash 2.1.3",
|
||||
"smallvec",
|
||||
"stringprep",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
@@ -2227,8 +2219,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "krilla"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27da593198b20eeba65caeb73c2bbeec3e53ab08fa549898312ce81c4fce5e33"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bumpalo",
|
||||
@@ -3839,16 +3830,6 @@ dependencies = [
|
||||
"read-fonts 0.41.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "skrifa"
|
||||
version = "0.46.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4febebda507fb889c545a37a135517769d1391941013561292897961d1887d6"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"read-fonts 0.43.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "skrifa"
|
||||
version = "0.47.0"
|
||||
@@ -4011,6 +3992,17 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stringprep"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
|
||||
dependencies = [
|
||||
"unicode-bidi",
|
||||
"unicode-normalization",
|
||||
"unicode-properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subsetter"
|
||||
version = "0.2.6"
|
||||
@@ -4395,6 +4387,15 @@ version = "1.0.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-normalization"
|
||||
version = "0.1.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
|
||||
dependencies = [
|
||||
"tinyvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-properties"
|
||||
version = "0.1.4"
|
||||
@@ -4506,7 +4507,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"block2 0.6.2",
|
||||
@@ -4541,7 +4542,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-affinity"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
@@ -4552,7 +4553,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-brush"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde",
|
||||
@@ -4564,7 +4565,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cad"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4580,7 +4581,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cli"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"objc2-core-foundation",
|
||||
@@ -4602,7 +4603,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-color"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"moxcms",
|
||||
@@ -4614,7 +4615,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-doc"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"jpeg-encoder",
|
||||
@@ -4631,8 +4632,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-effects"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
"serde_json",
|
||||
"vectorcraft-color",
|
||||
@@ -4646,7 +4648,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-engine"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
@@ -4682,7 +4684,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-eps"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4703,7 +4705,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-format"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"proptest",
|
||||
@@ -4718,7 +4720,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-geom"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"proptest",
|
||||
@@ -4730,7 +4732,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-mcp"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4749,7 +4751,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-metafile"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4763,7 +4765,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pathops"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"linesweeper",
|
||||
@@ -4775,18 +4777,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pdf"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"craft-pdf",
|
||||
"flate2",
|
||||
"hayro-interpret",
|
||||
"hayro-syntax",
|
||||
"image",
|
||||
"jpeg-encoder",
|
||||
"krilla",
|
||||
"kurbo 0.13.1",
|
||||
"log",
|
||||
"md-5",
|
||||
"roxmltree 0.21.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
@@ -4805,7 +4807,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-plugins"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4819,7 +4821,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-render"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"gif",
|
||||
@@ -4835,23 +4837,25 @@ dependencies = [
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-effects",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-plugins",
|
||||
"vectorcraft-testkit",
|
||||
"vectorcraft-text",
|
||||
"vello_common 0.2.0",
|
||||
"vello_cpu 0.2.0",
|
||||
"wat",
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-scriptcheck"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-svg"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4875,7 +4879,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-testkit"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4892,7 +4896,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-text"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"harfrust 0.13.3",
|
||||
"icu_segmenter",
|
||||
@@ -4912,7 +4916,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-three-d"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
@@ -4920,7 +4924,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-tools"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4928,6 +4932,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-effects",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
"vectorcraft-text",
|
||||
@@ -4935,7 +4940,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-trace"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4947,7 +4952,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-ui-egui"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"egui",
|
||||
@@ -4981,7 +4986,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-web"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5004,7 +5009,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"fearless_simd 0.4.1",
|
||||
"fearless_simd",
|
||||
"guillotiere",
|
||||
"log",
|
||||
"peniko",
|
||||
@@ -5019,7 +5024,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"fearless_simd 0.4.1",
|
||||
"fearless_simd",
|
||||
"guillotiere",
|
||||
"log",
|
||||
"peniko",
|
||||
@@ -6091,7 +6096,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+18
-4
@@ -4,13 +4,14 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
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-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
# internal
|
||||
@@ -20,7 +21,9 @@ vectorcraft-doc = { path = "crates/doc" }
|
||||
vectorcraft-pathops = { path = "crates/pathops" }
|
||||
vectorcraft-brush = { path = "crates/brush" }
|
||||
vectorcraft-trace = { path = "crates/trace" }
|
||||
vectorcraft-text = { path = "crates/text" }
|
||||
# Without its default `bundled-fonts`: the engine turns them on (its own default feature), so a
|
||||
# library crate never forces them on an embedder that supplies its fonts at run time.
|
||||
vectorcraft-text = { path = "crates/text", default-features = false }
|
||||
vectorcraft-effects = { path = "crates/effects" }
|
||||
vectorcraft-three-d = { path = "crates/three-d" }
|
||||
vectorcraft-render = { path = "crates/render" }
|
||||
@@ -58,9 +61,14 @@ wasmi = { version = "2.0.0", default-features = false, features = ["std", "valid
|
||||
wat = "1.228"
|
||||
|
||||
# rendering / formats
|
||||
vello_cpu = { version = "0.2", features = ["f32_pipeline"] }
|
||||
# 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"] }
|
||||
usvg = "0.48"
|
||||
# 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.
|
||||
usvg = { version = "0.48", default-features = false, features = ["svgz"] }
|
||||
# Without `simple-text` (its text shaping, the only user of rustybuzz and ttf-parser, which are
|
||||
# unmaintained, #1730): the PDF writer places glyphs itself. A test of the PDF crate turns it on.
|
||||
krilla = { version = "0.8", default-features = false, features = ["raster-images"] }
|
||||
@@ -111,3 +119,9 @@ opt-level = 1
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
|
||||
[patch.crates-io]
|
||||
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d" }
|
||||
hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d" }
|
||||
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d" }
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d" }
|
||||
@@ -72,3 +72,23 @@ DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
Shared PDF backend
|
||||
|
||||
The patched hayro-interpret 0.7.0, hayro-syntax 0.7.2 and hayro-jbig2 0.3.0
|
||||
are fetched from https://github.com/storytold/craft-libs at revision
|
||||
ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d. Their upstream project is
|
||||
https://github.com/LaurenzV/hayro, licensed MIT OR Apache-2.0. The snapshot retains each
|
||||
crate's upstream licence files. The patch ledger is at
|
||||
https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/README.craft-reader.md.
|
||||
|
||||
Assets already bundled through Hayro's default features retain their licences:
|
||||
- Foxit standard-14 substitutes: Copyright 2014 PDFium Authors. All rights reserved.
|
||||
Original code copyright 2014 Foxit Software Inc. BSD-3-Clause; see
|
||||
licenses/pdf-backend/hayro-interpret/assets/LICENSE_FOXIT.
|
||||
- CGATS001Compat-v2-micro.icc: CC0-1.0, Clinton Ingram; see
|
||||
licenses/pdf-backend/hayro-interpret/assets/CGATS_LICENSE.txt.
|
||||
- Predefined CMaps: Copyright 1990-2023 Adobe. All rights reserved. BSD-3-Clause,
|
||||
non-visual character-mapping data; see licenses/pdf-backend/hayro-cmap/LICENSE.txt.
|
||||
Adobe and contributor names do not endorse or promote this product.
|
||||
Per-file hashes and sources are recorded in ASSETS.md.
|
||||
@@ -73,6 +73,16 @@
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<img src="docs/images/shot-6-revolve.png" alt="A still life of a vase, a bowl, a sphere and a bottle made with 3D Revolve; the vase is selected and the Revolve dialog shows its rotation, cap and view settings beside the rotation gizmo" width="100%">
|
||||
<p align="center"><sub><b>3D Revolve</b>: smooth vector shading, caps and a rotation gizmo · <code>examples/still-life.vectorcraft</code></sub></p>
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<img src="docs/images/shot-7-dimensions.png" alt="A bracket drawing in the light theme with linear, angular, diameter and radius dimensions; the Dimension tool is active and the Properties panel shows the selected dimension's units, precision and style" width="100%">
|
||||
<p align="center"><sub><b>Dimension tool</b>: lengths, angles and radii that follow the art they measure · <code>examples/bracket-drawing.vectorcraft</code></sub></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<img src="docs/images/shot-5-perspective.png" alt="Two lit building facades drawn on the left and right planes of a two-point perspective grid at sunset, with the Perspective Selection tool and the Plane Switching Widget" width="100%">
|
||||
<p align="center"><sub><b>Perspective Grid</b>: art attached to its planes stays editable in perspective · <code>examples/perspective-city.vectorcraft</code></sub></p>
|
||||
@@ -108,6 +118,12 @@ exposes to agents, and exported by VectorCraft's own renderer. The source files
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" valign="top">
|
||||
<img src="docs/images/art-still-life.png" alt="Still life: a teal vase, a coral bowl, a white sphere and a mustard bottle on a warm table, all made with 3D Revolve" width="100%">
|
||||
<p align="center"><sub><b>Still Life</b>: four revolved profiles with smooth vector shading</sub></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<img src="docs/images/art-repeat.png" alt="Radial Repeat mandala: teal petals around a coral center, ringed by yellow dots" width="100%">
|
||||
<p align="center"><sub><b>Radial Repeat</b>: a live mandala</sub></p>
|
||||
@@ -211,7 +227,7 @@ and what's next; [`docs/target-app-parity.md`](docs/target-app-parity.md) the fu
|
||||
all of Illustrator): 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, EPS, DXF, EMF/WMF, raster formats, PSD out and PSD/PSB in as their merged image, Print, Package).
|
||||
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).
|
||||
Affinity documents (`.af` from Affinity 3, `.afdesign`, `.afpub` and, by their content, `.afphoto` from Affinity 1 and 2) open
|
||||
and place natively: layers, groups, artboards and pages, curves and shapes, fills, gradients and strokes, clipping
|
||||
and masks, text and images, with what didn't come in (effects, adjustments, brushes, master pages…) listed in the
|
||||
@@ -220,7 +236,7 @@ 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, Russian and Ukrainian (and Czech and Brazilian Portuguese in the menus);
|
||||
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;
|
||||
[localization-parity.md](docs/localization-parity.md) has the details.
|
||||
The scores are self-assessed from our code against Illustrator's public documentation;
|
||||
[target-app-parity.md](docs/target-app-parity.md#how-this-was-measured-2026-10-10) explains how far to trust them.
|
||||
@@ -229,7 +245,7 @@ The scores are self-assessed from our code against Illustrator's public document
|
||||
- reliable exchange of Illustrator's own `.ai` files (layer structure in real files; Illustrator's editing data isn't written);
|
||||
- an interaction-fidelity pass covering every tool's modifiers and small behaviours;
|
||||
- 3D and Materials beyond the initial live Revolve (Extrude & Bevel, Inflate, Rotate, materials);
|
||||
- about half of the Photoshop-style raster effects (28 of 57 still missing; Film Grain is available under Artistic) and the Effect Gallery;
|
||||
- about half of the Photoshop-style raster effects (27 of 57 still missing; Film Grain is 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.
|
||||
|
||||
+19
-8
@@ -1,12 +1,15 @@
|
||||
# VectorCraft Roadmap
|
||||
|
||||
- PDF.R1: editable PDF import uses the shared reader, scans page content in windows, and rejects known incomplete input before returning a document.
|
||||
- PDF.W1: PDF export and print composition use the shared optional writer for configuration, metadata, font preparation and guarded finalization; drawing and output policy remain app-owned.
|
||||
|
||||
- UI.5: Shared docking adds tab reordering, docking into other groups, dock splits and resizable floating panel groups. Panel positions and return locations persist with workspaces; the native icon rail and Tools panel remain available.
|
||||
|
||||
- UI.4: Dock and floating-panel headers use shared accessible tabs with bounded overflow. Widget-state theme application uses `craft-ui`; all four palettes, font stacks and preferences remain app-owned.
|
||||
|
||||
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (later 2026-10-14 merges: per-corner radius, #1682; Width Point Edit from the Width tool, #1814, #1630; guides, #1782, #1836; anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; macOS save sheets, #1855, the Format menu withdrawn; rendering caches, #1667–#1792; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; Dim Images, #1683; headline numbers unchanged; Image Trace: Shapes, stroke caps, enhanced presets, gradients over noise, #1845 to #1848, headline numbers unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; merges of 2026-10-14: anchor Align and Distribute, #1636; Direct Selection fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks and a Precise Cursors command, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; a guide's position in the Transform panel is a convenience, not a parity claim, #1659; headline numbers unchanged; later merges of 2026-10-13: SVG import of CSS transforms, percentage lengths in symbols, Inkscape flowed text and pages, #1565, #1596, #1568, #1601; Warp Arc, #1578; Pathfinder Trim, Merge and Crop strokes, #1570; saves to folders that refuse the temporary file, #1620 in part; OpenType stylistic sets, #1607; intersect Smart Guides, #1602 in part; headline numbers unchanged; control transport deadlines, #1726; Image Trace edges, strokes, memory and three new options, measured in `docs/image-trace-quality.md`, headline numbers unchanged; community PRs and fixes of 2026-10-13: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; `.ai`, PDF, EPS and SVG import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; headline numbers unchanged; community PRs and fixes of late 2026-10-12: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; French catalog complete again, #1511; headline numbers unchanged; Image Trace colour and photo presets, measured in `docs/image-trace-quality.md`, headline numbers unchanged; community PRs and fixes of 2026-10-12: Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; many-object edits, #1429 in part; headline numbers unchanged; Image Trace places edges to a fraction of a pixel, measured in `docs/image-trace-quality.md`, headline numbers unchanged; Transform dialogs preview and the transform tools act on direct-selected anchors, #1412 and #1408; `.ai` compound path paint, #1410, overprint, #1388, and text-only PDF parts, #1387; community PRs: strokes wider than their shape, corner drags without the bounding box, pasted pictures, compound paths in `document.inspect`, MCP guide; headline numbers unchanged; community PRs: `.ai` editing-data follow-ups (gradient stops, type of objects with an appearance, short area-type frames), PDF bleed with editing data, dialog keyboard focus, art flush with an artboard, font rescan, symbol instance redraw, `--control` port check; headline numbers unchanged; Rectangular Grid and Polar Grid tool options, #1377 in part; `.ai` files with placed (linked) pictures open from their editing data, #1264; community PRs: `.ai` and PDF bleed and editing-data import, gradient shapes imported as paths, ruler origin, Align To, Live Corners corner selection; headline numbers unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; community PRs: graph designs, Graph Data worksheet and Graph Type options; Align both centres; Pen endpoint fixes; earlier: Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%; `.ai` gradients, meshes and artboard names from the editing data; headline numbers unchanged; `.ai` art styles and clipping fills from the editing data; headline numbers unchanged; `editingData: "always"`; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (about 160 community PRs of 2026-10-15/16: PDF import crash fixes, Dimension tool, legacy `.ai` save, Intertwine, web pen input, Image Trace fixes, `.ai`/EPS/PDF import fidelity, Bulgarian, Greek, Swedish and Finnish catalogs; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
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
|
||||
@@ -26,7 +29,7 @@ blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)).
|
||||
|
||||
| | Value | Kind |
|
||||
|---|---|---|
|
||||
| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 44/50, Photoshop effects 29/57, 3D 4/4 (initial types, no materials) |
|
||||
| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 44/50, Photoshop effects 30/57, 3D 4/4 (initial types, no materials) |
|
||||
| **Feature depth** (weighted by use, scored by behaviour) | **~75%** | estimated |
|
||||
| **To beta** | **~170–270 h** one agent · ~50–80 h with 4–6 agents | estimated |
|
||||
| **To full parity** | **~360–590 h** one agent · ~95–165 h with 4–6 agents | estimated |
|
||||
@@ -35,7 +38,7 @@ blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)).
|
||||
|
||||
| Audience | Ready % | Opus 5.5 agent hours to ~95% (one agent) | With 4–6 agents | Work that dominates |
|
||||
|---|---:|---:|---:|---|
|
||||
| **Full Illustrator** (ready for real work; decides the stage) | **~62%** (57–67%) | **~340–560 h** | ~95–160 h | the mainstream work below, plus 3D (50–85 h), raster effects (23–39 h), advanced type (20–28 h), generative AI (30–60 h), localization (55–90 h) and ecosystem |
|
||||
| **Full Illustrator** (ready for real work; decides the stage) | **~62%** (57–67%) | **~340–560 h** | ~95–160 h | the mainstream work below, plus 3D (50–85 h), raster effects (23–39 h), advanced type (20–28 h), generative AI (30–60 h), localization (50–85 h) and ecosystem |
|
||||
| **Mainstream illustrator** | **~65%** (60–70%) | **~230–380 h** | ~65–110 h | the interaction-fidelity pass (60–90 h), hardening and QA (30–50 h), stability (20–35 h), performance budgets (15–25 h), `.ai` exchange (10–20 h), the remaining depth of the everyday areas |
|
||||
| **Essentials user** | **~75%** (70–80%) | **~60–105 h** | ~25–45 h | launch and stability on Windows (20–35 h), in-app help and onboarding (8–15 h), basic QA (10–15 h), Shape Builder regions (4–8 h), UI polish |
|
||||
|
||||
@@ -52,7 +55,7 @@ everything is measured from our code against Illustrator's public documentation.
|
||||
| UI/UX fidelity | 55% | 70–110 | [ui-parity.md](docs/ui-parity.md) |
|
||||
| File formats | 75% | 15–30 | [file-format-parity.md](docs/file-format-parity.md) |
|
||||
| Hardware (GPU, pen, touch, displays) | 35% | 25–45 | [hardware-parity.md](docs/hardware-parity.md) |
|
||||
| Localization (12 key languages) | 71% (measured) | 55–90 | [localization-parity.md](docs/localization-parity.md) |
|
||||
| Localization (12 key languages) | 74% (measured) | 50–85 | [localization-parity.md](docs/localization-parity.md) |
|
||||
| Performance | ~60% | 15–25 | [gaps.md › G15](docs/gaps.md#g15-performance-budgets-and-a-gpu-renderer) |
|
||||
| Stability | ~60% | 30–50 | [gaps.md › G10](docs/gaps.md#g10-launch-and-stability-on-real-machines) |
|
||||
| Platforms | 80% | 15–25 | [gaps.md › G8](docs/gaps.md#g8-hardening-at-scale) |
|
||||
@@ -77,7 +80,7 @@ The dimension hours overlap (feature rows hold some UI, format and AI work), so
|
||||
| 3D and Materials | ~25% | 30–55 |
|
||||
| Type core — [type-parity.md](docs/type-parity.md) | 80% | 14–19 |
|
||||
| Type advanced (CJK, spelling, Touch Type) | 50% | 20–28 |
|
||||
| Symbols, blends, envelopes, Repeat, perspective | 85% | 6–10 |
|
||||
| Symbols, blends, envelopes, Repeat, perspective | 87% | 5–9 |
|
||||
| Image Trace, graphs, image tools | 75% | 5–8 |
|
||||
| Layers, artboards, document setup | 78% | 5–8 |
|
||||
| View & navigation | 70% | 9–14 |
|
||||
@@ -102,14 +105,14 @@ Weights, evidence and the missing items per area: [target-app-parity.md](docs/ta
|
||||
| Hindi | `hi` | none | 0% |
|
||||
| Arabic | `ar` | none (no right-to-left interface) | 0% |
|
||||
| French | `fr` | full | 100% |
|
||||
| Portuguese (Brazil) | `pt-br` | partial | 68% |
|
||||
| Portuguese (Brazil) | `pt-br` | full | 100% |
|
||||
| Indonesian | `id` | none | 0% |
|
||||
| Japanese | `ja` | full | 100% |
|
||||
| German | `de` | full | 98% |
|
||||
| Korean | `ko` | full | 100% |
|
||||
| Vietnamese | `vi` | full | 100% |
|
||||
|
||||
Also shipped: Traditional Chinese (partial, 80%), Hungarian, Italian, Russian and Ukrainian (full), Czech (catalog complete; render-site gaps reduced; native review pending).
|
||||
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).
|
||||
|
||||
## Upcoming
|
||||
@@ -125,11 +128,17 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
|
||||
## Progress log
|
||||
|
||||
- 2026-10-14: 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-13: PngSuite downloads use pinned HTTPS files and a SHA-256 manifest; no archive extraction (#1729).
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-14 | Corner Radius has a link button in Properties and Transform: unlinked, a live rectangle shows a radius field for each corner (#1682). With the Width tool, double-clicking a stroke opens Width Point Edit to create a width point there, and Shift-double-click on a width point opens it with Adjust Adjoining Width Points ticked (#1814, #1630). Holding Alt while dragging a guide out of a ruler switches it between horizontal and vertical (#1782), and a marquee with the Selection or Direct Selection tool also selects the unlocked ruler guides it crosses (#1836). The Transform panel's, Properties panel's and Control bar's X/Y/W/H show and edit only the anchor points selected with Direct Selection (#1839, fixes #1824). With Snap to Grid on, anchors and objects snap to the grid even when Snap to Pixel is also ticked (#1840; #1816 waits for the reporter). Artboards are deleted the same way from the Artboards panel, its menu, Properties and Delete/Backspace (#1631). The toolbar shows no group labels by default (a preference brings them back) and runs on in one or two columns; the All Tools drawer lists the tools by flyout group with their shortcuts, has a menu (Reset, Basic, Advanced) and a Show section for the Fill/Stroke, Coloring, Draw Mode and Screen Mode controls (#1751). An action clicked in the Actions panel stays selected, so Play Current Selection and Delete Selection act on it (#1853, part of #1629). Redo during a drag keeps the drag as a step (#1856, #1719). Preferences are written as soon as OK is pressed (#1857; #1747 waits for the reporter). Preferences › Appearance of Black › Printing / Exporting and Units › Identify Objects By act (#1750, #1809). The paragraph alignment buttons show none selected for paragraphs with different alignments (#1784); redefining a character style to Auto leading updates the text that uses it and keeps local overrides (#1633); New and Redefine Character Style take the format of the selected text range, also while typing (#1634). Type inside symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back when a native file opens, so warped type in a symbol looks the same after save and reopen (#1833, #1738). Editing, renaming, deleting or merging a global swatch reaches the art of opacity masks (#1830, #1735). macOS: Save As, Save a Copy and the first Save show their sheet again; the sheet's Format menu (#866, reopened) is withdrawn until it can be set before the sheet opens (#1855). |
|
||||
| 2026-10-16 | Symbol Options › Export Type (Graphic or Movie Clip, default Movie Clip) with Registration; `symbol.options` / `symbol.list` (G5).
|
||||
| 2026-10-16 | About 160 community PRs landed, each through the full gate. Crashes and hostile files: damaged PDF dictionaries and arrays no longer crash import (#2133, #2240, #2244, #2271), area type with huge row or column counts opens (#2109), hostile mesh edges are bounded (#2051). New: Dimension tool (#1788), legacy version 7 `.ai` save (#1794), Object › Intertwine (#2095), Apple Pencil and touch input on the web (#1740), Sample Color from Screen (#2257), Drop Shadow Darkness (#2252), Smart Blur edge modes (#2154), Scribble variations (#2180), Free Distort corners in its dialog (#2134), Rotate View presets (#2106), tritanopia proofing (#2234), Layers panel filters (#2087), Hide Template (#2117), File › Automate Batch through the Actions panel (#1938), dataset and graph CSV (#1929, #1941), Reset Tracking and Kerning (#2274), Alt+Enter copies in Transform fields (#2236), Shift+Tab panels (#2254), Control bar isolation, Recolor and image Replace (#2258, #2079, #2081). Revolve renders smooth with Cap on by default (#1193); Image Trace keeps tiny parts, thin outlines and shared corners, and Grayscale gradients are grey (#2012, #2025, #2263, #2264, #2265, #1879). Import: many `.ai`, EPS and PDF fidelity fixes (#2014–#2016, #2029, #2030, #2044, #2046, #2050–#2052, #2058, #2129, #2199, #2202, #2218), images up to 1 GB decoded (#2060), `.ai`/`.ait`/`.svgz` files paste from a file manager (#2148, #2201). Fixes across fields (arithmetic #2069, #2209, #2022; caret #1403), New Document (#2158, #2176), menus (#2232), shortcuts (#2235, #2146), Transform Each axes (#2227), Paste in Place (#1926), Color Picker tab order (#1937, #1939) and more. Languages: Bulgarian, Greek, Swedish and Finnish complete under the tests, pt-br completed. Details per PR in the PRs; headline numbers unchanged (estimated). |
|
||||
| 2026-10-14 | Brazilian Portuguese (`pt-br`) covers the whole interface and status/error messages under the completeness tests (#1914); Localization 71% → 74% (measured), hours 55–90 → 50–85. |
|
||||
| 2026-10-14 | Direct Selection: deleting an interior anchor of an open path leaves separate surviving runs. Delete/Backspace and history regressions cover the gap; explicit Remove Anchors remains connected. Headline numbers unchanged. |
|
||||
| 2026-10-14 | Corner Radius has a link button in Properties and Transform: unlinked, a live rectangle shows a radius field for each corner (#1682). With the Width tool, double-clicking a stroke opens Width Point Edit to create a width point there, and Shift-double-click on a width point opens it with Adjust Adjoining Width Points ticked (#1814, #1630). Holding Alt while dragging a guide out of a ruler switches it between horizontal and vertical (#1782), and a marquee with the Selection or Direct Selection tool also selects the unlocked ruler guides it crosses (#1836). The Transform panel's, Properties panel's and Control bar's X/Y/W/H show and edit only the anchor points selected with Direct Selection (#1839, fixes #1824). With the Direct Selection tool, double-clicking type (including type nested inside a group) switches to the Type tool and puts the caret at the clicked character; pressing Escape finishes editing and returns to the selection tool used before double-clicking, and in isolation mode the first Escape exits editing while preserving isolation until a second Escape. With Snap to Grid on, anchors and objects snap to the grid even when Snap to Pixel is also ticked (#1840; #1816 waits for the reporter). Artboards are deleted the same way from the Artboards panel, its menu, Properties and Delete/Backspace (#1631). The toolbar shows no group labels by default (a preference brings them back) and runs on in one or two columns; the All Tools drawer lists the tools by flyout group with their shortcuts, has a menu (Reset, Basic, Advanced) and a Show section for the Fill/Stroke, Coloring, Draw Mode and Screen Mode controls (#1751). An action clicked in the Actions panel stays selected, so Play Current Selection and Delete Selection act on it (#1853, part of #1629). Redo during a drag keeps the drag as a step (#1856, #1719). Preferences are written as soon as OK is pressed (#1857; #1747 waits for the reporter). Preferences › Appearance of Black › Printing / Exporting and Units › Identify Objects By act (#1750, #1809). The paragraph alignment buttons show none selected for paragraphs with different alignments (#1784); redefining a character style to Auto leading updates the text that uses it and keeps local overrides (#1633); New and Redefine Character Style take the format of the selected text range, also while typing (#1634). Type inside symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back when a native file opens, so warped type in a symbol looks the same after save and reopen (#1833, #1738). Editing, renaming, deleting or merging a global swatch reaches the art of opacity masks (#1830, #1735). macOS: Save As, Save a Copy and the first Save show their sheet again; the sheet's Format menu (#866, reopened) is withdrawn until it can be set before the sheet opens (#1855). |
|
||||
| 2026-10-14 | Rendering: copies, symbol instances and repeats that share an appearance keep their stroke outlines between frames (#1667); the canvas no longer reads its whole picture back for each non-isolated group that blends (#1687); envelopes, blends, repeats and meshes redraw when a pattern, symbol, font or colour setting they show changes (#1688); Outline view draws from geometry kept between frames (#1689); CMYK documents with transparency compose their ink planes on several threads (#1755); an edit re-flows a threaded story only when it changes one of the story's frames (#1792); opening a file reads its linked files and lays out its type on the opening thread (#1767). Instances of imported symbols are measured, selected, aligned, culled and exported by their art instead of a 20 pt square (#1744). Export for Screens and the Asset Export panel offer TIFF (#1632). SVG import looks style rules up by id, class and type (#1690), applies `:not()` selectors (#1799, #1557), no longer stalls on stacked descendant selectors over deep nesting (#1834, #1703) and reads linked files within the automation roots again (#1870). DXF export writes the VPORT view direction once (#1789). Untrusted files: nesting is capped at 32 objects in SVG, PDF, EPS and native files and symbols or patterns that use themselves open without the offending instance or paint (#1852); deeply nested PDF and `.ai` data, page trees holding themselves and functions or colour spaces built on themselves (#1871), decompression bombs and impossible predictor parameters (#1873, #1700, #1701), brushes that draw themselves (#1872) and hostile EPS, Affinity and native data (#1863: #1715, #1714, #1704, #1699) no longer crash or exhaust the app; the save fallback keeps the old file until the new one is in (#1695). Files whose ids sit at their largest value, a failed `command.batch`, control requests that timed out and images too large as RGBA for the Eyedropper are handled (#1862: #1718, #1697, #1725, #1717). Effects: wide shadows and glows at high zoom stay within the raster budget, stacked Transform effects are bounded, 3D keeps the area a nonzero compound path fills, pattern tiles redraw when the art they show changes, and a template layer dims its images by Dim Images (#1864: #1706, #1705, #1724, #1723, #1683). A failed save or export is reported in a dialog that names the file and stays until closed (#1865, #1685); a browser download that can't start fails the save (#1693); a file that finishes opening waits while a dialog is open (#1694). krilla's text shaping is no longer built in (#1874, #1730); the CI gate compiles the web app for wasm (#1875). |
|
||||
| 2026-10-14 | Image Trace: Shapes finds rounded rectangles and shapes in blurred and scanned art (98 to 100% of the plain-shape inputs, from 57 to 62%; #1845), square-ended lines get square caps (Outline's largest distance 2.94 px to 0.88; #1846), each documented preset has an enhanced version with Auto Grouping and Shapes that measures no worse than the preset (#1847), and blurred or noisy linear gradients trace as one gradient-filled path instead of 3 to 56 (#1848) ([`docs/image-trace-quality.md`](docs/image-trace-quality.md)). No headline number moves. |
|
||||
| 2026-10-13 | Synthetic control input has finite click/drag counts and pending-event limits, checked before queue mutation; control-channel bursts yield after 64 requests per frame (#1709). |
|
||||
@@ -168,6 +177,8 @@ The full pre-standard record of what landed is kept in [docs/roadmap.md](docs/ro
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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. |
|
||||
| 2026-10-13 | minor | Pinned PngSuite HTTPS files verified by SHA-256 before publication; bounded downloads replace archive extraction (#1729). Scores and hours unchanged. |
|
||||
| 2026-10-13 | minor | Bounded synthetic control input (#1709): counts and queued events validated before mutation; control-channel bursts yield between frames. Scores and hours unchanged. |
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless] [--automation-read-root DIR] [--automation-write-root DIR]
|
||||
//! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}']]... [--export out.svg]... [--scale 2]
|
||||
//! vectorcraft-cli commands
|
||||
//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data]
|
||||
//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit]
|
||||
//! vectorcraft-cli info FILE
|
||||
//! vectorcraft-cli bench FILE [--size 2880x1800] [--iters 5]
|
||||
//! vectorcraft-cli perf [--paths 50000] [--deep]
|
||||
@@ -84,7 +84,7 @@ USAGE:
|
||||
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]
|
||||
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
|
||||
unless one of them is given, EPS the bounds of the art, the other formats the first artboard.
|
||||
@@ -95,6 +95,11 @@ USAGE:
|
||||
--replace-lossy is given. --editing-data opens a .ai from its editing copy even where that
|
||||
draws differently from its PDF part (which can be older), as long as it can be read. The
|
||||
result has the export's warnings, and importWarnings for what reading IN left out.
|
||||
--ai-version 7 writes a .ai as the legacy version 7 (layers, groups and type kept; one file
|
||||
per artboard, OUT-<artboard>.ai when there are several; default pdf, PDF-compatible), with
|
||||
--hidden-objects (default layer: a hidden layer above each layer's hidden objects; omit;
|
||||
visible), --transparent-images (default flatten, with the art under them; omit) and
|
||||
--outline-text (type as outlines instead of text).
|
||||
|
||||
vectorcraft-cli info FILE
|
||||
Print a JSON summary: the import warnings (what didn't come in as it was, such as an EPS
|
||||
@@ -109,7 +114,8 @@ USAGE:
|
||||
Check the performance budgets (render, pan, hit test, save/load, SVG, Pathfinder, handle
|
||||
and segment drags) on a synthetic N-path document (default 50000). Exits non-zero if a
|
||||
budget is exceeded, unless the report says the machine is busy. --deep also times work with
|
||||
no budget yet and prints memory use (the working set on Windows).
|
||||
no budget yet and prints memory use (the working set on Windows), with the peak so far on
|
||||
Windows and Linux.
|
||||
";
|
||||
|
||||
/// The usage text plus the formats `document.open` reads and `document.export` writes.
|
||||
@@ -223,6 +229,9 @@ fn convert(args: &[String]) -> Result<(), String> {
|
||||
let (mut scale, mut artboard, mut range, mut outline_text, mut replace_lossy, mut editing_data) =
|
||||
(1.0f64, None::<u64>, None::<String>, false, false, false);
|
||||
let mut decimals = None::<u64>;
|
||||
// Legacy `.ai` options, passed on as the export's own (`aiVersion`, `hiddenObjects`,
|
||||
// `transparentImages`); the engine checks their values.
|
||||
let mut legacy: Vec<(&str, String)> = vec![];
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
match a.as_str() {
|
||||
@@ -233,6 +242,9 @@ fn convert(args: &[String]) -> Result<(), String> {
|
||||
"--decimals" => decimals = Some(it.next().and_then(|v| v.parse::<u64>().ok()).ok_or("--decimals needs a number")?),
|
||||
"--replace-lossy" => replace_lossy = true,
|
||||
"--editing-data" => editing_data = true,
|
||||
"--ai-version" => legacy.push(("aiVersion", it.next().cloned().ok_or("--ai-version needs 7 or pdf")?)),
|
||||
"--hidden-objects" => legacy.push(("hiddenObjects", it.next().cloned().ok_or("--hidden-objects needs layer, omit or visible")?)),
|
||||
"--transparent-images" => legacy.push(("transparentImages", it.next().cloned().ok_or("--transparent-images needs flatten or omit")?)),
|
||||
f => files.push(f.to_string()),
|
||||
}
|
||||
}
|
||||
@@ -250,6 +262,9 @@ fn convert(args: &[String]) -> Result<(), String> {
|
||||
if let Some(decimals) = decimals {
|
||||
params["decimals"] = json!(decimals);
|
||||
}
|
||||
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}"))?;
|
||||
// What reading the input left out (a missing linked file, an unsupported filter…) matters as
|
||||
// much as what writing the output did.
|
||||
|
||||
@@ -136,31 +136,63 @@ fn edit_drags(n: usize) -> Result<(f64, f64), String> {
|
||||
Ok((handle, segment))
|
||||
}
|
||||
|
||||
/// Resident memory of this process in MB (`ps`), if available.
|
||||
/// Resident memory of this process in MB (`ps`), if available, with the most it has held so far
|
||||
/// on Linux (`VmHWM` in `/proc/self/status`). The peak is None on macOS, which has no `/proc`.
|
||||
#[cfg(not(windows))]
|
||||
fn rss_mb() -> Option<f64> {
|
||||
fn rss_mb() -> Option<(f64, Option<f64>)> {
|
||||
let pid = std::process::id().to_string();
|
||||
let out = std::process::Command::new("ps").args(["-o", "rss=", "-p", &pid]).output().ok().filter(|o| o.status.success())?;
|
||||
String::from_utf8_lossy(&out.stdout).trim().parse::<f64>().ok().map(|kb| kb / 1024.0)
|
||||
let peak = std::fs::read_to_string("/proc/self/status").ok().and_then(|status| vm_hwm_mb(&status));
|
||||
String::from_utf8_lossy(&out.stdout).trim().parse::<f64>().ok().map(|kb| (kb / 1024.0, peak))
|
||||
}
|
||||
|
||||
/// The peak resident set size in MB from the `VmHWM` line of a `/proc/<pid>/status` text, which
|
||||
/// gives it in kB. It is None when the text has no `VmHWM` line in kB.
|
||||
#[cfg(any(not(windows), test))]
|
||||
fn vm_hwm_mb(status: &str) -> Option<f64> {
|
||||
let kb = status.lines().find_map(|line| line.strip_prefix("VmHWM:"))?.trim().strip_suffix("kB")?.trim().parse::<f64>().ok()?;
|
||||
Some(kb / 1024.0)
|
||||
}
|
||||
|
||||
/// The working set of this process in MB (`GetProcessMemoryInfo`), if available: its pages that
|
||||
/// are in physical memory, the figure `ps` reports as resident memory elsewhere.
|
||||
/// are in physical memory, the figure `ps` reports as resident memory elsewhere. The same call
|
||||
/// gives the peak working set, the most the working set has held so far.
|
||||
#[cfg(windows)]
|
||||
fn rss_mb() -> Option<f64> {
|
||||
fn rss_mb() -> Option<(f64, Option<f64>)> {
|
||||
let counters = winsafe::HPROCESS::GetCurrentProcess().GetProcessMemoryInfo().ok()?;
|
||||
Some(counters.WorkingSetSize as f64 / (1024.0 * 1024.0))
|
||||
let mb = |bytes: usize| bytes as f64 / (1024.0 * 1024.0);
|
||||
Some((mb(counters.WorkingSetSize), Some(mb(counters.PeakWorkingSetSize))))
|
||||
}
|
||||
|
||||
/// A tracked row of `--deep`: the work's name and its median in ms. Names are padded to 54
|
||||
/// characters.
|
||||
fn tracked_row(name: &str, ms: f64) -> String {
|
||||
format!(" {name:<54} {ms:>10.2} ms")
|
||||
}
|
||||
|
||||
/// A memory row of `--deep`: the reading's name and value in MB, then the peak so far when
|
||||
/// there is one. Names are padded to 56 characters.
|
||||
fn memory_row(name: &str, mb: f64, peak: Option<f64>) -> String {
|
||||
let peak = peak.map_or_else(String::new, |peak| format!(" peak {peak:>8.0} MB"));
|
||||
format!(" {name:<56} {mb:>8.0} MB{peak}")
|
||||
}
|
||||
|
||||
/// A row of the budget table: the budget's name, the median and the budget in ms, then "ok" or
|
||||
/// "OVER". Names are padded to 42 characters, as in the table's header.
|
||||
fn budget_row(name: &str, ms: f64, budget: f64, ok: bool) -> String {
|
||||
format!(" {name:<42} {ms:>8.2} ms {budget:>7.0} ms {}", if ok { "ok" } else { "OVER" })
|
||||
}
|
||||
|
||||
/// `--deep`: tracked rows (no budget yet) for work the budgets above don't reach: editing a whole
|
||||
/// selection, live effects, every Pathfinder operation, typing in a long story, and memory.
|
||||
/// selection, live effects, every Pathfinder operation, typing in a long story, and memory, with
|
||||
/// the peak so far where `rss_mb` reads one.
|
||||
fn deep(n: usize) -> Result<(), String> {
|
||||
let err = |e: vectorcraft_engine::EngineError| e.to_string();
|
||||
let mut rows: Vec<(String, f64)> = vec![];
|
||||
let mut mem: Vec<(String, f64)> = vec![];
|
||||
let mut mem: Vec<(String, f64, Option<f64>)> = vec![];
|
||||
let mut note = |what: &str| {
|
||||
if let Some(m) = rss_mb() {
|
||||
mem.push((what.to_string(), m));
|
||||
if let Some((m, peak)) = rss_mb() {
|
||||
mem.push((what.to_string(), m, peak));
|
||||
}
|
||||
};
|
||||
note("start");
|
||||
@@ -271,14 +303,15 @@ fn deep(n: usize) -> Result<(), String> {
|
||||
let saved = vectorcraft_format::save_file(&doc);
|
||||
let reopened = vectorcraft_format::load(&saved).map_err(|e| e.to_string())?;
|
||||
outln!(" output hash: SVG export {:016x}, save→open→save {:016x}", fnv(svg.as_bytes()), fnv(&vectorcraft_format::save_file(&reopened)));
|
||||
note("after the SVG export and save→open→save");
|
||||
outln!(" tracked (no budget yet)");
|
||||
for (name, ms) in &rows {
|
||||
outln!(" {name:<52} {ms:>10.2} ms");
|
||||
outln!("{}", tracked_row(name, *ms));
|
||||
}
|
||||
// `rss_mb` reads the working set on Windows.
|
||||
let memory = if cfg!(windows) { "working set" } else { "memory" };
|
||||
for (what, mb) in &mem {
|
||||
outln!(" {:<52} {mb:>8.0} MB", format!("{memory} {what}"));
|
||||
for (what, mb, peak) in &mem {
|
||||
outln!("{}", memory_row(&format!("{memory} {what}"), *mb, *peak));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -381,11 +414,11 @@ pub fn run(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
|
||||
let mut over = 0;
|
||||
outln!(" {:<40} {:>10} {:>10}", "", "measured", "budget");
|
||||
outln!(" {:<42} {:>10} {:>10}", "", "measured", "budget");
|
||||
for (name, ms, budget) in &rows {
|
||||
let ok = ms <= budget;
|
||||
over += usize::from(!ok);
|
||||
outln!(" {name:<40} {ms:>8.2} ms {budget:>7.0} ms {}", if ok { "ok" } else { "OVER" });
|
||||
outln!("{}", budget_row(name, *ms, *budget, ok));
|
||||
}
|
||||
match (over, noisy) {
|
||||
(0, _) => Ok(()),
|
||||
@@ -427,7 +460,57 @@ mod tests {
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_reads_the_working_set() {
|
||||
let mb = rss_mb();
|
||||
let mb = rss_mb().map(|(mb, _)| mb);
|
||||
assert!(mb.is_some_and(|mb| mb > 0.0 && mb < 10_000.0), "{mb:?}");
|
||||
}
|
||||
|
||||
/// On Windows and Linux `rss_mb` also gives the peak: after this test writes 64 MB and frees
|
||||
/// it, the peak is no more than 16 MB below the resident memory read while it held the 64 MB.
|
||||
#[cfg(any(windows, target_os = "linux"))]
|
||||
#[test]
|
||||
fn freed_memory_stays_in_the_peak() {
|
||||
let buffer = std::hint::black_box(vec![1u8; 64 << 20]);
|
||||
let held = rss_mb();
|
||||
drop(buffer);
|
||||
let (Some((held, _)), Some((now, peak))) = (held, rss_mb()) else {
|
||||
eprintln!("no memory reading (Linux without `ps`): nothing to check");
|
||||
return;
|
||||
};
|
||||
assert!(peak.is_some_and(|peak| peak > held - 16.0), "holding 64 MB: {held:.1} MB, after freeing it: {now:.1} MB, peak {peak:?}");
|
||||
}
|
||||
|
||||
/// `vm_hwm_mb` reads the peak resident set size from the `VmHWM` line of a status text that
|
||||
/// also has a `VmRSS` line.
|
||||
#[test]
|
||||
fn vm_hwm_is_the_peak_resident_set_size() {
|
||||
let status = "Name:\tvectorcraft-cli\nVmPeak:\t 2097152 kB\nVmSize:\t 1572864 kB\nVmHWM:\t 819200 kB\nVmRSS:\t 204800 kB\n";
|
||||
assert_eq!(vm_hwm_mb(status), Some(800.0));
|
||||
// A text without a `VmHWM` line gives no peak.
|
||||
assert_eq!(vm_hwm_mb("Name:\tkthreadd\nState:\tS (sleeping)\n"), None);
|
||||
}
|
||||
|
||||
/// A memory row ends with the peak when there is one; without a peak it ends with the value
|
||||
/// in MB. With the longest name on Windows, the value and the peak line up with the other rows.
|
||||
#[test]
|
||||
fn memory_rows_end_with_the_peak_when_there_is_one() {
|
||||
let row = memory_row("working set start", 346.4, Some(470.2));
|
||||
assert!(row.starts_with(" working set start ") && row.ends_with(" 346 MB peak 470 MB"), "{row}");
|
||||
let longest = memory_row("working set after rendering the photos with 3 renderers", 2514.0, Some(2541.0));
|
||||
assert_eq!(longest.chars().count(), row.chars().count(), "{longest}\n{row}");
|
||||
let row = memory_row("memory start", 346.4, None);
|
||||
assert!(row.starts_with(" memory start ") && row.ends_with(" 346 MB") && !row.contains("peak"), "{row}");
|
||||
}
|
||||
|
||||
/// With the longest names, the values of a tracked row and of the budget table's rows line
|
||||
/// up with the rows that have short names.
|
||||
#[test]
|
||||
fn the_longest_tracked_and_budget_names_fit_their_columns() {
|
||||
let longest = tracked_row("render 4 embedded 24 MP photos with 3 renderers (cold)", 774.61);
|
||||
let short = tracked_row("Pathfinder crop, 300 paths", 30.94);
|
||||
assert_eq!(longest.chars().count(), short.chars().count(), "{longest}\n{short}");
|
||||
for name in ["handle drag, 5,000-anchor path (per move)", "segment drag, 5,000-anchor path (per move)"] {
|
||||
let (longest, short) = (budget_row(name, 0.12, 8.0, true), budget_row("export SVG", 7.81, 500.0, true));
|
||||
assert_eq!(longest.chars().count(), short.chars().count(), "{longest}\n{short}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,10 @@ fn help_lists_readable_formats() {
|
||||
#[test]
|
||||
fn help_lists_perf_deep() {
|
||||
let out = ok(&["--help"]);
|
||||
assert!(out.contains("vectorcraft-cli perf [--paths N] [--deep]") && out.contains("--deep also times"), "{out}");
|
||||
assert!(
|
||||
out.contains("vectorcraft-cli perf [--paths N] [--deep]") && out.contains("--deep also times") && out.contains("with the peak so far on"),
|
||||
"{out}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `info` reports the import warnings: an EPS read only up to an error names it.
|
||||
@@ -115,3 +118,57 @@ fn convert_reports_import_warnings() {
|
||||
let w = out["importWarnings"].as_array().unwrap_or_else(|| panic!("{out}"));
|
||||
assert!(w.iter().any(|w| w.as_str().unwrap().contains("nowhere-to-be-found.png")), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_writes_legacy_ai_with_its_options() {
|
||||
// Two artboards, a shown rectangle and a hidden one.
|
||||
let src = tmp("legacy.vectorcraft");
|
||||
ok(&[
|
||||
"run",
|
||||
"--cmd",
|
||||
"file.new",
|
||||
"--params",
|
||||
r#"{"width":120,"height":80,"artboards":2}"#,
|
||||
"--cmd",
|
||||
"shape.rectangle",
|
||||
"--params",
|
||||
r#"{"x":10,"y":10,"width":20,"height":10}"#,
|
||||
"--cmd",
|
||||
"shape.rectangle",
|
||||
"--params",
|
||||
r#"{"x":40,"y":10,"width":20,"height":10}"#,
|
||||
"--cmd",
|
||||
"object.hide",
|
||||
"--export",
|
||||
&src,
|
||||
]);
|
||||
// One file per artboard, named after it; hidden objects in a hidden layer by default.
|
||||
let out = tmp("legacy.ai");
|
||||
let r: Value = serde_json::from_str(&ok(&["convert", &src, &out, "--ai-version", "7"])).unwrap();
|
||||
let files: Vec<&str> = r["files"].as_array().unwrap().iter().filter_map(Value::as_str).collect();
|
||||
assert_eq!(files.len(), 2, "{r}");
|
||||
assert!(files.iter().all(|f| std::fs::read(f).unwrap().starts_with(b"%!PS-Adobe-3.0")), "{r}");
|
||||
assert!(r["warnings"].to_string().contains("hidden layer"), "{r}");
|
||||
// One artboard is the file itself; the hidden rectangle left out.
|
||||
let one = tmp("legacy-one.ai");
|
||||
let r: Value = serde_json::from_str(&ok(&[
|
||||
"convert",
|
||||
&src,
|
||||
&one,
|
||||
"--ai-version",
|
||||
"7",
|
||||
"--range",
|
||||
"1",
|
||||
"--hidden-objects",
|
||||
"omit",
|
||||
"--transparent-images",
|
||||
"omit",
|
||||
]))
|
||||
.unwrap();
|
||||
assert!(r["warnings"].to_string().contains("hidden objects are left out"), "{r}");
|
||||
assert!(std::fs::read(&one).unwrap().starts_with(b"%!PS-Adobe-3.0"));
|
||||
// A value the engine doesn't take is an error.
|
||||
let bad = Command::new(BIN).args(["convert", &src, &tmp("bad.ai"), "--ai-version", "7", "--hidden-objects", "behind"]).output().unwrap();
|
||||
assert!(!bad.status.success());
|
||||
assert!(String::from_utf8_lossy(&bad.stderr).contains("hiddenObjects"), "{}", String::from_utf8_lossy(&bad.stderr));
|
||||
}
|
||||
@@ -28,4 +28,4 @@ rfd = { workspace = true }
|
||||
wasm-bindgen = "0.2"
|
||||
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", "DragEvent", "Storage", "Navigator", "RequestInit", "Response", "AbortController", "AbortSignal"] }
|
||||
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"] }
|
||||
@@ -2,12 +2,25 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-title" content="VectorCraft">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
<meta name="theme-color" content="#262626">
|
||||
<title>VectorCraft</title>
|
||||
<link data-trunk rel="rust" data-bin="vectorcraft-web" data-wasm-opt="z" data-weak-refs />
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262626; }
|
||||
#vectorcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
/* iPad: no page scroll, bounce, pinch zoom, text selection or callout; the app takes the gestures. */
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262626;
|
||||
overscroll-behavior: none; -webkit-user-select: none; user-select: none; -webkit-touch-callout: none;
|
||||
-webkit-tap-highlight-color: transparent; -webkit-text-size-adjust: 100%; }
|
||||
/* Inside the safe areas (notch, home indicator) of a full-screen app. */
|
||||
#vectorcraft_canvas { position: absolute; top: env(safe-area-inset-top, 0); right: env(safe-area-inset-right, 0);
|
||||
bottom: env(safe-area-inset-bottom, 0); left: env(safe-area-inset-left, 0);
|
||||
width: calc(100% - env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px));
|
||||
height: calc(100% - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px));
|
||||
display: block; touch-action: none; }
|
||||
#vectorcraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
color: #b8b8b8; font: 14px system-ui, sans-serif; pointer-events: none; }
|
||||
</style>
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
//! - File → Open uses the browser file picker; bytes arrive asynchronously through
|
||||
//! `Services::inbox`;
|
||||
//! - Save / Export trigger a browser download;
|
||||
//! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox.
|
||||
//! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox;
|
||||
//! - pen and finger input is read from Pointer Events by `pencil` (Apple Pencil pressure and
|
||||
//! hover, palm rejection, fingers navigate once a pen has been seen).
|
||||
//!
|
||||
//! 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.
|
||||
@@ -18,6 +20,8 @@
|
||||
mod fonts;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod locks;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod pencil;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
//! Pen and touch input (an iPad with an Apple Pencil): the shell reads Pointer Events itself, in
|
||||
//! the capture phase, before eframe's own touch and pointer handlers, which can't tell a pen from
|
||||
//! a finger and see each touch twice (as a pointer and as a touch event).
|
||||
//!
|
||||
//! - A pen drives egui's pointer and is a touch contact with the pen's pressure as its force (the
|
||||
//! canvas reads it), so holding it still opens the context menu (egui's long touch). It hovers
|
||||
//! where the pen can (Apple Pencil hover).
|
||||
//! - Fingers are touch contacts: two pinch and pan the canvas, and tap to undo or redo. The first
|
||||
//! finger also drives the pointer, so it taps the panels and menus; on the canvas it navigates
|
||||
//! only once a pen has been seen ([`touch::set_source`]), for this page session only.
|
||||
//! - Palm rejection: while a pen is down, fingers are ignored; a pen landing cancels the fingers
|
||||
//! already down (a palm resting first).
|
||||
//!
|
||||
//! Mouse and trackpad events (a Magic Keyboard) are left to eframe. Until the first pen
|
||||
//! contact, fingers and their DOM touch events also stay with eframe (including its text agent).
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use std::cell::RefCell;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use std::rc::Rc;
|
||||
|
||||
use egui::{Event, PointerButton, Pos2, TouchDeviceId, TouchId, TouchPhase};
|
||||
use vectorcraft_ui_egui::touch::{self, Source};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use wasm_bindgen::JsCast as _;
|
||||
|
||||
/// The pen's touch device (fingers use another, so egui doesn't pinch a pen with a finger).
|
||||
const PEN_DEVICE: TouchDeviceId = TouchDeviceId(1);
|
||||
const FINGER_DEVICE: TouchDeviceId = TouchDeviceId(2);
|
||||
|
||||
/// The pen and finger contacts followed, and the egui events they made since the last frame.
|
||||
#[derive(Default)]
|
||||
pub struct Pointers {
|
||||
events: Vec<Event>,
|
||||
/// The pen pressing, by pointer id.
|
||||
pen: Option<i32>,
|
||||
/// The fingers down, by pointer id.
|
||||
fingers: Vec<i32>,
|
||||
/// The contact driving egui's pointer.
|
||||
primary: Option<i32>,
|
||||
button: Option<PointerButton>,
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub type SharedPointers = Rc<RefCell<Pointers>>;
|
||||
|
||||
/// Browser-independent snapshot, also used by the routing regression tests.
|
||||
struct Input<'a> {
|
||||
pointer_type: &'a str,
|
||||
id: i32,
|
||||
button: i16,
|
||||
pressure: f32,
|
||||
modifiers: egui::Modifiers,
|
||||
}
|
||||
|
||||
// Match eframe's button_from_mouse_event, including barrel/right and auxiliary buttons.
|
||||
fn button(number: i16) -> Option<PointerButton> {
|
||||
match number {
|
||||
0 => Some(PointerButton::Primary),
|
||||
1 => Some(PointerButton::Middle),
|
||||
2 => Some(PointerButton::Secondary),
|
||||
3 => Some(PointerButton::Extra1),
|
||||
4 => Some(PointerButton::Extra2),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl Pointers {
|
||||
/// The events since the last frame, for the frame's raw input.
|
||||
pub fn take(&mut self) -> Vec<Event> {
|
||||
std::mem::take(&mut self.events)
|
||||
}
|
||||
|
||||
fn touch(&mut self, device: TouchDeviceId, id: i32, phase: TouchPhase, pos: Pos2, force: Option<f32>) {
|
||||
self.events.push(Event::Touch { device_id: device, id: TouchId::from(id as u64), phase, pos, force });
|
||||
}
|
||||
|
||||
fn press(&mut self, id: i32, pos: Pos2, button: PointerButton, modifiers: egui::Modifiers) {
|
||||
self.primary = Some(id);
|
||||
self.button = Some(button);
|
||||
self.events.push(Event::ModifiersChanged(modifiers));
|
||||
self.events.push(Event::PointerMoved(pos));
|
||||
self.events.push(Event::PointerButton { pos, button, pressed: true, modifiers });
|
||||
}
|
||||
|
||||
fn release(&mut self, pos: Pos2, modifiers: egui::Modifiers) {
|
||||
self.primary = None;
|
||||
if let Some(button) = self.button.take() {
|
||||
self.events.push(Event::ModifiersChanged(modifiers));
|
||||
self.events.push(Event::PointerButton { pos, button, pressed: false, modifiers });
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel every finger down (a palm under a pen landing).
|
||||
fn cancel_fingers(&mut self, pos: Pos2, modifiers: egui::Modifiers) {
|
||||
for id in std::mem::take(&mut self.fingers) {
|
||||
self.touch(FINGER_DEVICE, id, TouchPhase::Cancel, pos, None);
|
||||
if self.primary == Some(id) {
|
||||
self.release(pos, modifiers);
|
||||
self.events.push(Event::PointerGone);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The paired DOM touch events must follow the same routing as pointer events.
|
||||
fn captures_touch(&self, on_canvas: bool) -> bool {
|
||||
touch::pen_seen() && (on_canvas || self.pen.is_some() || !self.fingers.is_empty())
|
||||
}
|
||||
|
||||
/// Follow one pointer event; `true` if it was the pen's or a finger's (taken from eframe).
|
||||
fn feed(&mut self, kind: &str, e: &Input<'_>, pos: Pos2, coalesced: &[(Pos2, f32)], on_canvas: bool) -> bool {
|
||||
let id = e.id;
|
||||
match e.pointer_type {
|
||||
"pen" => {
|
||||
let force = Some(e.pressure.clamp(0.0, 1.0));
|
||||
match kind {
|
||||
"pointerdown" => {
|
||||
if !on_canvas && self.pen.is_none() {
|
||||
return false;
|
||||
}
|
||||
let Some(button) = button(e.button) else { return false };
|
||||
touch::set_source(Source::Pen);
|
||||
self.cancel_fingers(pos, e.modifiers);
|
||||
self.pen = Some(id);
|
||||
self.touch(PEN_DEVICE, id, TouchPhase::Start, pos, force);
|
||||
self.press(id, pos, button, e.modifiers);
|
||||
}
|
||||
"pointermove" if self.pen == Some(id) => {
|
||||
// The positions the pen reported between two events (up to 240 a second
|
||||
// on an iPad), each with its pressure, so fast strokes keep their curve.
|
||||
for c in coalesced.iter().take(coalesced.len().saturating_sub(1)) {
|
||||
self.touch(PEN_DEVICE, id, TouchPhase::Move, c.0, Some(c.1));
|
||||
self.events.push(Event::PointerMoved(c.0));
|
||||
}
|
||||
self.touch(PEN_DEVICE, id, TouchPhase::Move, pos, force);
|
||||
self.events.push(Event::PointerMoved(pos));
|
||||
}
|
||||
// Hovering (Apple Pencil hover): the pointer follows without pressing.
|
||||
"pointermove" => {
|
||||
if self.primary.is_some() {
|
||||
return true;
|
||||
}
|
||||
if !on_canvas {
|
||||
return false;
|
||||
}
|
||||
self.events.push(Event::PointerMoved(pos));
|
||||
}
|
||||
"pointerup" | "pointercancel" if self.pen == Some(id) => {
|
||||
let phase = if kind == "pointerup" { TouchPhase::End } else { TouchPhase::Cancel };
|
||||
self.pen = None;
|
||||
self.touch(PEN_DEVICE, id, phase, pos, None);
|
||||
self.release(pos, e.modifiers);
|
||||
}
|
||||
// A pen leaving the hover range, or an event of a press we didn't take.
|
||||
_ => {
|
||||
if kind == "pointerleave" && self.primary.is_none() && on_canvas {
|
||||
self.events.push(Event::PointerGone);
|
||||
}
|
||||
return self.pen.is_some();
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
// Keep eframe's touch/IME path unchanged on touch-only devices. Reloading the
|
||||
// page resets this policy; an old localStorage marker is deliberately ignored.
|
||||
"touch" if !touch::pen_seen() => false,
|
||||
"touch" => {
|
||||
match kind {
|
||||
"pointerdown" => {
|
||||
// Palm rejection: nothing presses while the pen does.
|
||||
if self.pen.is_some() {
|
||||
return true;
|
||||
}
|
||||
if !on_canvas && self.fingers.is_empty() {
|
||||
return false;
|
||||
}
|
||||
self.fingers.push(id);
|
||||
self.touch(FINGER_DEVICE, id, TouchPhase::Start, pos, None);
|
||||
if self.primary.is_none() && self.fingers.len() == 1 {
|
||||
touch::set_source(Source::Finger);
|
||||
self.press(id, pos, PointerButton::Primary, e.modifiers);
|
||||
}
|
||||
}
|
||||
"pointermove" if self.fingers.contains(&id) => {
|
||||
self.touch(FINGER_DEVICE, id, TouchPhase::Move, pos, None);
|
||||
if self.primary == Some(id) {
|
||||
self.events.push(Event::PointerMoved(pos));
|
||||
}
|
||||
}
|
||||
"pointerup" | "pointercancel" if self.fingers.contains(&id) => {
|
||||
let phase = if kind == "pointerup" { TouchPhase::End } else { TouchPhase::Cancel };
|
||||
self.fingers.retain(|f| *f != id);
|
||||
self.touch(FINGER_DEVICE, id, phase, pos, None);
|
||||
if self.primary == Some(id) {
|
||||
self.release(pos, e.modifiers);
|
||||
self.events.push(Event::PointerGone);
|
||||
}
|
||||
}
|
||||
// A rejected palm's later events, or one we never took.
|
||||
_ => return self.pen.is_some() || !self.fingers.is_empty() || on_canvas,
|
||||
}
|
||||
true
|
||||
}
|
||||
"mouse" => {
|
||||
if kind == "pointerdown" {
|
||||
touch::set_source(Source::Mouse);
|
||||
}
|
||||
false
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Take pen and finger Pointer Events on the page from eframe into `pointers` (and the touch
|
||||
/// events that follow them), for the runner's frames. Mouse events stay eframe's.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn install(runner: &eframe::WebRunner, canvas: &web_sys::HtmlCanvasElement, pointers: &SharedPointers) -> Result<(), wasm_bindgen::JsValue> {
|
||||
let window: web_sys::EventTarget = web_sys::window().ok_or("no window")?.into();
|
||||
let options = web_sys::AddEventListenerOptions::new();
|
||||
options.set_capture(true);
|
||||
options.set_passive(false);
|
||||
for kind in ["pointerdown", "pointermove", "pointerup", "pointercancel", "pointerleave"] {
|
||||
let (pointers, canvas) = (pointers.clone(), canvas.clone());
|
||||
runner.add_event_listener_ex(&window, kind, &options, move |e: web_sys::PointerEvent, runner, _| {
|
||||
let on_canvas = e.target().is_some_and(|t| &t == canvas.unchecked_ref::<web_sys::EventTarget>());
|
||||
let rect = canvas.get_bounding_client_rect();
|
||||
let z = runner.egui_ctx().zoom_factor();
|
||||
let pos = egui::pos2((e.client_x() as f32 - rect.left() as f32) / z, (e.client_y() as f32 - rect.top() as f32) / z);
|
||||
let at = |x: i32, y: i32| egui::pos2((x as f32 - rect.left() as f32) / z, (y as f32 - rect.top() as f32) / z);
|
||||
let coalesced: Vec<(Pos2, f32)> = if kind == "pointermove"
|
||||
&& e.pointer_type() == "pen"
|
||||
// Safari before 17 hasn't it, and calling a missing method would abort the app.
|
||||
&& js_sys::Reflect::has(&e, &"getCoalescedEvents".into()).unwrap_or(false)
|
||||
{
|
||||
e.get_coalesced_events()
|
||||
.iter()
|
||||
.filter_map(|c| c.dyn_into::<web_sys::PointerEvent>().ok())
|
||||
.map(|c| (at(c.client_x(), c.client_y()), c.pressure().clamp(0.0, 1.0)))
|
||||
.collect()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
let pointer_type = e.pointer_type();
|
||||
let input = Input {
|
||||
pointer_type: &pointer_type,
|
||||
id: e.pointer_id(),
|
||||
button: e.button(),
|
||||
pressure: e.pressure(),
|
||||
// Same semantics as eframe's modifiers_from_mouse_event.
|
||||
modifiers: egui::Modifiers {
|
||||
alt: e.alt_key(),
|
||||
ctrl: e.ctrl_key(),
|
||||
shift: e.shift_key(),
|
||||
mac_cmd: e.meta_key(),
|
||||
command: e.ctrl_key() || e.meta_key(),
|
||||
},
|
||||
};
|
||||
if !pointers.borrow_mut().feed(kind, &input, pos, &coalesced, on_canvas) {
|
||||
return;
|
||||
}
|
||||
e.stop_immediate_propagation();
|
||||
// No compatibility mouse events, no text selection or magnifier.
|
||||
e.prevent_default();
|
||||
if kind == "pointerup" {
|
||||
// Keys typed next (a Magic Keyboard) go to the app.
|
||||
canvas.focus().ok();
|
||||
// Safari lets a page open the share sheet, a download or the on-screen keyboard
|
||||
// only during the user's gesture: handle the release now.
|
||||
runner.logic();
|
||||
}
|
||||
runner.egui_ctx().request_repaint();
|
||||
})?;
|
||||
}
|
||||
// The touch events behind those pointer events: eframe would see each touch a second time,
|
||||
// and Safari would scroll, zoom or select.
|
||||
for kind in ["touchstart", "touchmove", "touchend", "touchcancel"] {
|
||||
let (pointers, canvas) = (pointers.clone(), canvas.clone());
|
||||
runner.add_event_listener_ex(&window, kind, &options, move |e: web_sys::TouchEvent, _runner, _| {
|
||||
let on_canvas = e.target().is_some_and(|t| &t == canvas.unchecked_ref::<web_sys::EventTarget>());
|
||||
let p = pointers.borrow();
|
||||
if p.captures_touch(on_canvas) {
|
||||
e.stop_immediate_propagation();
|
||||
if e.cancelable() {
|
||||
e.prevent_default();
|
||||
}
|
||||
}
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn input(pointer_type: &str, button: i16) -> Input<'_> {
|
||||
Input { pointer_type, id: 7, button, pressure: 0.6, modifiers: egui::Modifiers::default() }
|
||||
}
|
||||
|
||||
fn reset() {
|
||||
touch::set_pen_seen(false);
|
||||
touch::set_source(Source::Mouse);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pen_buttons_and_modifiers_survive_press_release_and_cancel() {
|
||||
for (number, expected) in [
|
||||
(0, PointerButton::Primary),
|
||||
(1, PointerButton::Middle),
|
||||
(2, PointerButton::Secondary),
|
||||
(3, PointerButton::Extra1),
|
||||
(4, PointerButton::Extra2),
|
||||
] {
|
||||
for end in ["pointerup", "pointercancel"] {
|
||||
reset();
|
||||
let mut p = Pointers::default();
|
||||
let mut e = input("pen", number);
|
||||
e.modifiers = egui::Modifiers { shift: true, ctrl: true, alt: true, mac_cmd: true, command: true };
|
||||
let pos = Pos2::new(30.0, 40.0);
|
||||
assert!(p.feed("pointerdown", &e, pos, &[], true));
|
||||
assert!(p.feed(end, &e, pos, &[], false));
|
||||
let buttons: Vec<_> = p
|
||||
.take()
|
||||
.into_iter()
|
||||
.filter_map(|e| match e {
|
||||
Event::PointerButton { button, pressed, modifiers, .. } => Some((button, pressed, modifiers)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(buttons, [(expected, true, e.modifiers), (expected, false, e.modifiers)]);
|
||||
assert!(p.pen.is_none() && p.primary.is_none());
|
||||
}
|
||||
}
|
||||
reset();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn touch_only_and_mouse_input_stay_with_eframe_and_session_can_reset() {
|
||||
reset();
|
||||
let mut p = Pointers::default();
|
||||
let pos = Pos2::new(10.0, 20.0);
|
||||
for pointer_type in ["touch", "mouse"] {
|
||||
for kind in ["pointerdown", "pointermove", "pointerup", "pointercancel"] {
|
||||
assert!(!p.feed(kind, &input(pointer_type, 0), pos, &[], true));
|
||||
}
|
||||
}
|
||||
assert!(p.take().is_empty());
|
||||
assert!(!p.captures_touch(true), "eframe must also receive DOM touch events before a pen");
|
||||
assert!(p.feed("pointerdown", &input("pen", 0), pos, &[], true));
|
||||
assert!(p.feed("pointerup", &input("pen", 0), pos, &[], true));
|
||||
assert!(p.captures_touch(true));
|
||||
assert!(p.feed("pointerdown", &input("touch", 0), pos, &[], true));
|
||||
assert!(p.feed("pointerup", &input("touch", 0), pos, &[], true));
|
||||
// A new page starts from false, without reading any old persistent marker.
|
||||
reset();
|
||||
let mut next_page = Pointers::default();
|
||||
assert!(!next_page.feed("pointerdown", &input("touch", 0), pos, &[], true));
|
||||
assert!(next_page.take().is_empty());
|
||||
assert!(!next_page.captures_touch(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finger_modifiers_and_palm_cancellation_are_preserved() {
|
||||
reset();
|
||||
touch::set_source(Source::Pen);
|
||||
let mut p = Pointers::default();
|
||||
let pos = Pos2::new(10.0, 20.0);
|
||||
let mut finger = input("touch", 0);
|
||||
finger.id = 10;
|
||||
finger.modifiers.shift = true;
|
||||
assert!(p.feed("pointerdown", &finger, pos, &[], true));
|
||||
assert!(p.take().iter().any(|e| matches!(e, Event::PointerButton { pressed: true, modifiers, .. } if modifiers.shift)));
|
||||
let pen = input("pen", 0);
|
||||
assert!(p.feed("pointerdown", &pen, pos, &[], true));
|
||||
assert!(p.take().iter().any(|e| matches!(e, Event::Touch { phase: TouchPhase::Cancel, .. })));
|
||||
assert!(p.fingers.is_empty());
|
||||
assert!(p.feed("pointermove", &finger, pos, &[], true));
|
||||
assert!(p.take().is_empty(), "a resting palm cannot move the pen");
|
||||
assert!(p.feed("pointerup", &pen, pos, &[], true));
|
||||
assert!(p.feed("pointerdown", &finger, pos, &[], true));
|
||||
assert!(p.feed("pointerup", &finger, pos, &[], true));
|
||||
assert!(p.take().iter().any(|e| matches!(e, Event::PointerButton { pressed: false, modifiers, .. } if modifiers.shift)));
|
||||
reset();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_pen_buttons_are_not_reinterpreted_as_primary() {
|
||||
reset();
|
||||
let mut p = Pointers::default();
|
||||
assert!(!p.feed("pointerdown", &input("pen", 5), Pos2::ZERO, &[], true));
|
||||
assert!(p.take().is_empty());
|
||||
assert!(!touch::pen_seen());
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ use vectorcraft_ui_egui::{Services, VectorcraftApp};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
|
||||
use crate::locks::WebLocks;
|
||||
use crate::pencil::SharedPointers;
|
||||
|
||||
type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
@@ -47,6 +48,8 @@ struct Host {
|
||||
place_inbox: PlaceInbox,
|
||||
drag: DragPos,
|
||||
pasted: Rc<RefCell<Pasted>>,
|
||||
/// Pen and finger input read from the page's Pointer Events (`crate::pencil`).
|
||||
pointers: SharedPointers,
|
||||
/// The page's Web Locks for Data Recovery, started once before the first frame: a graphics
|
||||
/// restart keeps them, so the tab stays the owner of its recovery area.
|
||||
locks: Option<WebLocks>,
|
||||
@@ -73,6 +76,7 @@ pub fn start() {
|
||||
place_inbox: Arc::default(),
|
||||
drag: DragPos::default(),
|
||||
pasted: Rc::default(),
|
||||
pointers: SharedPointers::default(),
|
||||
locks,
|
||||
restarts: 0,
|
||||
};
|
||||
@@ -96,6 +100,7 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option<Movi
|
||||
// The app until the new runner takes it (still here if starting fails before).
|
||||
let slot = Rc::new(RefCell::new(moving.map(|m| m.app)));
|
||||
let (runner, taken, page) = (host.runner.clone(), slot.clone(), canvas.clone());
|
||||
let (pointers, pen_canvas) = (host.pointers.clone(), canvas.clone());
|
||||
let result = runner
|
||||
.start(
|
||||
canvas,
|
||||
@@ -127,6 +132,9 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option<Movi
|
||||
.await;
|
||||
match (result, kept) {
|
||||
(Ok(()), _) => {
|
||||
if let Err(e) = crate::pencil::install(&runner, &pen_canvas, &pointers) {
|
||||
log::error!("vectorcraft-web: no pen input: {}", js_err(e));
|
||||
}
|
||||
if let Some(el) = element(LOADING_ID) {
|
||||
el.remove();
|
||||
}
|
||||
@@ -310,7 +318,7 @@ fn notice(lines: &[&str]) {
|
||||
|
||||
/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously)
|
||||
/// and feed them through the inboxes: placed where they were dropped on the canvas, else opened
|
||||
/// (libraries, presets and plug-ins open wherever they are dropped).
|
||||
/// (libraries, presets, plug-ins and shortcut sets open wherever they are dropped).
|
||||
/// When the graphics are lost it hands the app to a new runner on a new canvas.
|
||||
struct WebShell {
|
||||
/// `None` once handed on.
|
||||
@@ -381,6 +389,7 @@ impl eframe::App for WebShell {
|
||||
}
|
||||
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
raw.events.extend(self.host.pointers.borrow_mut().take());
|
||||
if let Some(app) = &mut self.app {
|
||||
app.raw_input_hook(raw);
|
||||
}
|
||||
@@ -397,6 +406,7 @@ fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context, locks: Op
|
||||
let open_inbox = inbox.clone();
|
||||
let picked = place_inbox.clone();
|
||||
let place_ctx = ctx.clone();
|
||||
let screen_color = eye_dropper(ctx.clone());
|
||||
Services {
|
||||
// File → Place…: the picked files go to the Place dialog.
|
||||
place_async: Some(Box::new(move || {
|
||||
@@ -434,6 +444,8 @@ fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context, locks: Op
|
||||
ctx.request_repaint();
|
||||
});
|
||||
})),
|
||||
// Sample Color from Screen: the browser's EyeDropper, where it has one.
|
||||
screen_color,
|
||||
// A download that couldn't be started fails the save or export it is for (#1693).
|
||||
download: Some(Box::new(|name: &str, bytes: &[u8]| download(name, bytes).inspect_err(|e| log::error!("download of {name} failed: {e}")))),
|
||||
inbox: Some(inbox),
|
||||
@@ -444,6 +456,41 @@ fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context, locks: Op
|
||||
}
|
||||
}
|
||||
|
||||
/// Sample Color from Screen in the browser: the EyeDropper API
|
||||
/// (<https://developer.mozilla.org/en-US/docs/Web/API/EyeDropper>), which only some browsers have,
|
||||
/// on secure (HTTPS) pages; `None` without it, and the command is then gray. `open()` needs a
|
||||
/// transient user activation (otherwise it fails with a `NotAllowedError`) and resolves with the
|
||||
/// picked color's `sRGBHex`; Escape rejects it with an `AbortError`.
|
||||
fn eye_dropper(ctx: egui::Context) -> Option<vectorcraft_ui_egui::screen_color::StartSampler> {
|
||||
use js_sys::{Function, Promise, Reflect};
|
||||
use wasm_bindgen::JsValue;
|
||||
let window = web_sys::window()?;
|
||||
let constructor = Reflect::get(&window, &JsValue::from_str("EyeDropper")).ok()?.dyn_into::<Function>().ok()?;
|
||||
Some(Box::new(move || {
|
||||
let eye = Reflect::construct(&constructor, &js_sys::Array::new()).map_err(js_err)?;
|
||||
let open = Reflect::get(&eye, &JsValue::from_str("open")).map_err(js_err)?.dyn_into::<Function>().map_err(js_err)?;
|
||||
let promise = open.call0(&eye).map_err(js_err)?.dyn_into::<Promise>().map_err(js_err)?;
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let ctx = ctx.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let answer = match wasm_bindgen_futures::JsFuture::from(promise).await {
|
||||
Ok(result) => Reflect::get(&result, &JsValue::from_str("sRGBHex"))
|
||||
.ok()
|
||||
.and_then(|hex| hex.as_string())
|
||||
.and_then(|hex| vectorcraft_ui_egui::screen_color::srgb_from_hex(&hex))
|
||||
.map(Some)
|
||||
.ok_or_else(|| "the browser's EyeDropper answered no color".to_string()),
|
||||
Err(e) if Reflect::get(&e, &JsValue::from_str("name")).ok().and_then(|n| n.as_string()).as_deref() == Some("AbortError") => Ok(None),
|
||||
Err(e) => Err(js_err(e)),
|
||||
};
|
||||
// The UI may be gone (the page closing): nothing waits for the color then.
|
||||
let _ = tx.send(answer);
|
||||
ctx.request_repaint();
|
||||
});
|
||||
Ok(rx)
|
||||
}))
|
||||
}
|
||||
|
||||
/// Data Recovery's store on the web: the browser's local storage (kept across visits and shared by
|
||||
/// the site's tabs), each entry as base64 under [`RECOVERY_PREFIX`]`<area>/<name>`. It has no
|
||||
/// locks: each tab holds its area with a heartbeat, judged by the browser's clock, and with a Web
|
||||
|
||||
@@ -55,7 +55,7 @@ windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite"] }
|
||||
# with default features off). Pinned exactly: review the diff before bumping.
|
||||
muda = { version = "=0.21.1", default-features = false }
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSButton", "NSControl", "NSMenu", "NSEvent", "NSPasteboard", "NSPopUpButton", "NSResponder", "NSSavePanel", "NSTextField", "NSTextInputContext", "NSView", "NSWindow"] }
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "block2", "objc2-core-foundation", "NSApplication", "NSButton", "NSColor", "NSColorSampler", "NSColorSpace", "NSControl", "NSMenu", "NSEvent", "NSPasteboard", "NSPopUpButton", "NSResponder", "NSSavePanel", "NSTextField", "NSTextInputContext", "NSView", "NSWindow"] }
|
||||
# Files opened from Finder and the Dock (src/open_documents.rs) and pasted bitmaps (src/clipboard.rs); both already come with winit, muda and rfd.
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSData", "NSGeometry", "NSNotification", "NSOperation", "NSString", "NSURL"] }
|
||||
block2 = "0.6"
|
||||
|
||||
+538
-12
@@ -37,9 +37,23 @@
|
||||
//! and the app stayed in the background without a window (#964). [`watch_first_frame`] gives the
|
||||
//! first frame [`FIRST_FRAME_TIMEOUT`] once the app is created and, when it doesn't come, starts
|
||||
//! the app again without that adapter in the same way.
|
||||
//!
|
||||
//! A driver can also crash the whole process, and then no code in it runs to start it again:
|
||||
//! Intel's Direct3D 12 driver (`igd12umd64.dll`) ended 0.9.0 before its window appeared (#2243).
|
||||
//! So each start writes [`START_FILE`] in the log folder before the graphics start, adds the
|
||||
//! adapter once one is picked, and clears both once the first frame reaches the screen or the app
|
||||
//! ends without crashing ([`Startup::track`]). The next start that finds a start still recorded
|
||||
//! there leaves that adapter out, as a restart does, and keeps leaving it out; when the crash came
|
||||
//! while the adapters were being listed, before one was picked, it lists OpenGL's adapters alone
|
||||
//! (except on macOS, where wgpu has none). A start that `WGPU_BACKEND` or `WGPU_ADAPTER_NAME`
|
||||
//! chooses leaves nothing out, and the crashes are forgotten when the app version or
|
||||
//! Preferences › Performance › Graphics Processor changes, when a start shows its first frame on
|
||||
//! the adapter that crashed, or when no adapter is left to show the window.
|
||||
|
||||
use std::io::{Read as _, Seek as _, Write as _};
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::sync::{Arc, Mutex, OnceLock, PoisonError};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use eframe::egui_wgpu::NativeAdapterSelectorMethod;
|
||||
@@ -50,6 +64,14 @@ use eframe::wgpu::{self, Backend, Backends, DeviceType, PowerPreference};
|
||||
/// again.
|
||||
pub const SKIP_ENV: &str = "VECTORCRAFT_GPU_SKIP";
|
||||
|
||||
/// The file in the log folder that records each start of the window until its first frame
|
||||
/// reaches the screen, and the adapters earlier starts crashed on ([`Startup::track`]).
|
||||
pub const START_FILE: &str = "graphics-start.json";
|
||||
|
||||
/// The most bytes of [`START_FILE`] read, and the most crashed adapters it keeps.
|
||||
const START_FILE_LIMIT: u64 = 64 * 1024;
|
||||
const CRASHED_LIMIT: usize = 16;
|
||||
|
||||
/// wgpu's variable for choosing an adapter by (part of) its name, any case.
|
||||
const NAME_ENV: &str = "WGPU_ADAPTER_NAME";
|
||||
|
||||
@@ -348,6 +370,226 @@ fn parse_skip(v: &str) -> Vec<String> {
|
||||
v.split(',').map(str::trim).filter(|k| !k.is_empty()).map(String::from).collect()
|
||||
}
|
||||
|
||||
/// Whether `WGPU_BACKEND` or `WGPU_ADAPTER_NAME` is set: then a start uses the backends and the
|
||||
/// adapter they name, and leaves out none that earlier starts crashed on.
|
||||
pub fn explicit_choice() -> bool {
|
||||
std::env::var_os("WGPU_BACKEND").is_some() || std::env::var_os(NAME_ENV).is_some()
|
||||
}
|
||||
|
||||
/// How the last start ended when it ended before the window's first frame reached the screen,
|
||||
/// with no error or panic to report: a crash in the graphics driver (#2243), or the process was
|
||||
/// ended from outside.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Crash {
|
||||
/// While the adapters were being listed, before one was picked.
|
||||
Listing,
|
||||
/// On the adapter with this [`key`] and name.
|
||||
Adapter { key: String, name: String },
|
||||
}
|
||||
|
||||
/// What a start leaves out because earlier starts crashed ([`Startup::track`]).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct LeftOut {
|
||||
/// The adapters ([`key`]s) that crashed a start.
|
||||
pub adapters: Vec<String>,
|
||||
/// A start crashed while listing the adapters: list OpenGL's alone. Never on macOS, where wgpu
|
||||
/// has no OpenGL adapters to fall back to.
|
||||
pub opengl_only: bool,
|
||||
/// The crash the last start ended in, when this start leaves something out for it.
|
||||
pub crash: Option<Crash>,
|
||||
}
|
||||
|
||||
impl LeftOut {
|
||||
/// `backends` to list: OpenGL alone after a crash while listing, when it is among them;
|
||||
/// otherwise `backends`.
|
||||
pub fn backends(&self, backends: Backends) -> Backends {
|
||||
if self.opengl_only && backends.contains(Backends::GL) { Backends::GL } else { backends }
|
||||
}
|
||||
}
|
||||
|
||||
/// An adapter in [`START_FILE`]: its [`key`] and name.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
struct Recorded {
|
||||
key: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
/// The start in progress, in [`START_FILE`].
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
struct Starting {
|
||||
/// The adapter picked; `None` while the adapters are being listed.
|
||||
adapter: Option<Recorded>,
|
||||
/// The listing found an adapter of a backend other than OpenGL.
|
||||
native: bool,
|
||||
}
|
||||
|
||||
/// What [`START_FILE`] holds.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
struct StartRecord {
|
||||
/// The app version and the Graphics Processor preference (`gpuPreference`) it was written
|
||||
/// under: the crashes count only for the same ones.
|
||||
version: String,
|
||||
preference: String,
|
||||
/// The start in progress, from before the graphics start until its first frame reaches the
|
||||
/// screen or the app ends without crashing.
|
||||
starting: Option<Starting>,
|
||||
/// The adapters that crashed a start, oldest first.
|
||||
crashed: Vec<Recorded>,
|
||||
/// A start crashed while listing the adapters.
|
||||
listing_crashed: bool,
|
||||
}
|
||||
|
||||
impl StartRecord {
|
||||
/// This start's record, made from the one the last start left (`previous`; `None` when there
|
||||
/// is none or it can't be read), and the crash that start ended in, if it did. A record
|
||||
/// written by another version or under another `preference` is dropped.
|
||||
fn begin(previous: Option<Self>, version: &str, preference: &str) -> (Self, Option<Crash>) {
|
||||
let mut record = previous.filter(|r| r.version == version && r.preference == preference).unwrap_or_default();
|
||||
let crash = record.starting.take().map(|s| match s.adapter {
|
||||
Some(Recorded { key, name }) => Crash::Adapter { key, name },
|
||||
None => Crash::Listing,
|
||||
});
|
||||
match &crash {
|
||||
Some(Crash::Adapter { key, name }) if !record.crashed.iter().any(|c| &c.key == key) => {
|
||||
record.crashed.push(Recorded { key: key.clone(), name: name.clone() });
|
||||
if record.crashed.len() > CRASHED_LIMIT {
|
||||
record.crashed.remove(0);
|
||||
}
|
||||
}
|
||||
Some(Crash::Listing) => record.listing_crashed = true,
|
||||
_ => {}
|
||||
}
|
||||
record.version = version.to_string();
|
||||
record.preference = preference.to_string();
|
||||
record.starting = Some(Starting::default());
|
||||
(record, crash)
|
||||
}
|
||||
|
||||
/// What this start leaves out: nothing when `explicit` ([`explicit_choice`]). `crash` is the
|
||||
/// one [`begin`](Self::begin) found.
|
||||
fn left_out(&self, crash: Option<Crash>, explicit: bool) -> LeftOut {
|
||||
if explicit {
|
||||
return LeftOut::default();
|
||||
}
|
||||
let opengl_only = self.listing_crashed && !cfg!(target_os = "macos");
|
||||
let crash = crash.filter(|c| matches!(c, Crash::Adapter { .. }) || opengl_only);
|
||||
LeftOut { adapters: self.crashed.iter().map(|c| c.key.clone()).collect(), opengl_only, crash }
|
||||
}
|
||||
|
||||
/// The adapter this start picked, after a listing that found one of a backend other than
|
||||
/// OpenGL when `native`.
|
||||
fn picked(&mut self, key: &str, name: &str, native: bool) {
|
||||
self.starting = Some(Starting { adapter: Some(Recorded { key: key.to_string(), name: name.trim().to_string() }), native });
|
||||
}
|
||||
|
||||
/// The listing found no adapter that can show the window: nothing is left to leave out, so the
|
||||
/// crashes are forgotten and the next start tries every adapter again.
|
||||
fn none_left(&mut self) {
|
||||
self.crashed.clear();
|
||||
self.listing_crashed = false;
|
||||
}
|
||||
|
||||
/// The window's first frame reached the screen: the adapter it was drawn on, and the listing
|
||||
/// when it found an adapter of a backend other than OpenGL, no longer count as crashed.
|
||||
fn presented(&mut self) {
|
||||
let Some(starting) = self.starting.take() else { return };
|
||||
if let Some(adapter) = starting.adapter {
|
||||
self.crashed.retain(|c| c.key != adapter.key);
|
||||
}
|
||||
if starting.native {
|
||||
self.listing_crashed = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// The start ended without crashing: a normal exit, an error, or a restart on another adapter.
|
||||
fn ended(&mut self) {
|
||||
self.starting = None;
|
||||
}
|
||||
|
||||
fn to_json(&self) -> String {
|
||||
let recorded = |r: &Recorded| serde_json::json!({ "key": r.key, "name": r.name });
|
||||
let mut v = serde_json::json!({
|
||||
"version": self.version,
|
||||
"gpuPreference": self.preference,
|
||||
"crashed": self.crashed.iter().map(recorded).collect::<Vec<_>>(),
|
||||
"listingCrashed": self.listing_crashed,
|
||||
});
|
||||
if let (Some(s), Some(map)) = (&self.starting, v.as_object_mut()) {
|
||||
map.insert("starting".into(), serde_json::json!({ "adapter": s.adapter.as_ref().map(recorded), "native": s.native }));
|
||||
}
|
||||
v.to_string()
|
||||
}
|
||||
|
||||
/// The record in `text`; `None` when it isn't one.
|
||||
fn parse(text: &str) -> Option<Self> {
|
||||
let v: serde_json::Value = serde_json::from_str(text).ok()?;
|
||||
let recorded = |v: &serde_json::Value| -> Option<Recorded> {
|
||||
Some(Recorded { key: v.get("key")?.as_str()?.to_string(), name: v.get("name")?.as_str()?.to_string() })
|
||||
};
|
||||
let starting = match v.get("starting") {
|
||||
None => None,
|
||||
Some(s) => Some(Starting {
|
||||
adapter: match s.get("adapter") {
|
||||
None | Some(serde_json::Value::Null) => None,
|
||||
Some(a) => Some(recorded(a)?),
|
||||
},
|
||||
native: s.get("native")?.as_bool()?,
|
||||
}),
|
||||
};
|
||||
let crashed = v.get("crashed")?.as_array()?;
|
||||
Some(Self {
|
||||
version: v.get("version")?.as_str()?.to_string(),
|
||||
preference: v.get("gpuPreference")?.as_str()?.to_string(),
|
||||
starting,
|
||||
crashed: crashed.iter().take(CRASHED_LIMIT).map(recorded).collect::<Option<_>>()?,
|
||||
listing_crashed: v.get("listingCrashed")?.as_bool()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// [`START_FILE`], open and locked while the start runs, so that a second copy of the app starting
|
||||
/// at the same time doesn't take it for the record of a start that crashed.
|
||||
struct StartFile {
|
||||
file: std::fs::File,
|
||||
record: StartRecord,
|
||||
}
|
||||
|
||||
impl StartFile {
|
||||
/// Open `dir`'s [`START_FILE`], with the record the last start left in it (`None` when there is
|
||||
/// none or it can't be read). `Ok(None)` when another copy of the app holds it.
|
||||
fn open(dir: &Path) -> std::io::Result<Option<(std::fs::File, Option<StartRecord>)>> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let file = std::fs::OpenOptions::new().read(true).write(true).create(true).truncate(false).open(dir.join(START_FILE))?;
|
||||
match file.try_lock() {
|
||||
Ok(()) => {}
|
||||
Err(std::fs::TryLockError::WouldBlock) => return Ok(None),
|
||||
Err(std::fs::TryLockError::Error(e)) => return Err(e),
|
||||
}
|
||||
let mut text = String::new();
|
||||
// Text that isn't UTF-8, or is longer than any record, is ignored like a damaged record.
|
||||
let previous = (&file).take(START_FILE_LIMIT).read_to_string(&mut text).ok().and_then(|_| StartRecord::parse(&text));
|
||||
Ok(Some((file, previous)))
|
||||
}
|
||||
|
||||
fn write(&mut self) {
|
||||
let text = self.record.to_json();
|
||||
let written = self.file.set_len(0).and_then(|()| self.file.rewind()).and_then(|()| self.file.write_all(text.as_bytes()));
|
||||
if let Err(e) = written {
|
||||
log::warn!("{START_FILE}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The value of `gpuPreference` the crashes are recorded under: any value this version doesn't
|
||||
/// know reads as `automatic`, as in [`power_preference`].
|
||||
fn preference_name(pref: Option<&str>) -> &'static str {
|
||||
match pref {
|
||||
Some("highPerformance") => "highPerformance",
|
||||
Some("lowPower") => "lowPower",
|
||||
_ => "automatic",
|
||||
}
|
||||
}
|
||||
|
||||
/// What the window's start-up leaves for [`finish`].
|
||||
#[derive(Default)]
|
||||
pub struct Startup {
|
||||
@@ -357,6 +599,8 @@ pub struct Startup {
|
||||
ui: OnceLock<egui::Context>,
|
||||
/// When the app was created, the files named at the start opened: the first frame is due.
|
||||
ready: OnceLock<Instant>,
|
||||
/// [`START_FILE`] while the start runs ([`Self::track`]).
|
||||
start: Mutex<Option<StartFile>>,
|
||||
}
|
||||
|
||||
impl Startup {
|
||||
@@ -371,6 +615,82 @@ impl Startup {
|
||||
// Set once, like `created`.
|
||||
let _ = self.ready.set(Instant::now());
|
||||
}
|
||||
|
||||
/// Record this start in `dir`'s [`START_FILE`] until the window's first frame reaches the
|
||||
/// screen or the app ends without crashing, and say what it leaves out for the starts that
|
||||
/// crashed before (see the module docs). `preference` is Preferences › Performance › Graphics
|
||||
/// Processor, and `explicit` is [`explicit_choice`]. When the file can't be used (it can't be
|
||||
/// written, or another copy of the app is starting), nothing is recorded or left out.
|
||||
pub fn track(&self, dir: &Path, preference: Option<&str>, explicit: bool) -> LeftOut {
|
||||
let (file, previous) = match StartFile::open(dir) {
|
||||
Ok(Some(opened)) => opened,
|
||||
Ok(None) => {
|
||||
log::info!("{START_FILE}: another VectorCraft is starting, so this start isn't recorded");
|
||||
return LeftOut::default();
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("{START_FILE}: {e}");
|
||||
return LeftOut::default();
|
||||
}
|
||||
};
|
||||
let (record, crash) = StartRecord::begin(previous, env!("CARGO_PKG_VERSION"), preference_name(preference));
|
||||
let left_out = record.left_out(crash.clone(), explicit);
|
||||
match &crash {
|
||||
Some(Crash::Adapter { key, name }) => {
|
||||
log::warn!("the last start ended on graphics adapter {name} ({key}) before the window's first frame reached the screen")
|
||||
}
|
||||
Some(Crash::Listing) => log::warn!("the last start ended while it listed the graphics adapters"),
|
||||
None => {}
|
||||
}
|
||||
if explicit && (!record.crashed.is_empty() || record.listing_crashed) {
|
||||
log::info!("WGPU_BACKEND or WGPU_ADAPTER_NAME is set: no adapter is left out for the starts that crashed");
|
||||
}
|
||||
if !left_out.adapters.is_empty() {
|
||||
log::warn!("leaving out the graphics adapters earlier starts crashed on: {}", left_out.adapters.join(", "));
|
||||
}
|
||||
if left_out.opengl_only {
|
||||
log::warn!("an earlier start crashed while it listed the graphics adapters: listing OpenGL's alone");
|
||||
}
|
||||
let mut file = StartFile { file, record };
|
||||
file.write();
|
||||
*self.start.lock().unwrap_or_else(PoisonError::into_inner) = Some(file);
|
||||
left_out
|
||||
}
|
||||
|
||||
/// [`selector`] picked the adapter with this [`key`] and name; `native` when the listing found
|
||||
/// an adapter of a backend other than OpenGL.
|
||||
fn picked(&self, key: &str, name: &str, native: bool) {
|
||||
// Set once; eframe picks the adapter once.
|
||||
let _ = self.adapter.set(key.to_string());
|
||||
self.update(|r| r.picked(key, name, native), false);
|
||||
}
|
||||
|
||||
/// [`selector`] found no adapter that can show the window.
|
||||
fn none_left(&self) {
|
||||
self.update(StartRecord::none_left, false);
|
||||
}
|
||||
|
||||
/// The window's first frame reached the screen, or the window started minimized.
|
||||
fn presented(&self) {
|
||||
self.update(StartRecord::presented, true);
|
||||
}
|
||||
|
||||
/// The app ends, or starts again, without crashing.
|
||||
fn ended(&self) {
|
||||
self.update(StartRecord::ended, true);
|
||||
}
|
||||
|
||||
/// Change the record and write it; `last` releases the file for the next start.
|
||||
fn update(&self, change: impl FnOnce(&mut StartRecord), last: bool) {
|
||||
let mut start = self.start.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if let Some(file) = start.as_mut() {
|
||||
change(&mut file.record);
|
||||
file.write();
|
||||
}
|
||||
if last {
|
||||
*start = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`watch_first_frame`] makes of the window's start.
|
||||
@@ -396,10 +716,10 @@ fn first_frame(frames: u64, since: Duration, minimized: bool) -> FirstFrame {
|
||||
}
|
||||
}
|
||||
|
||||
/// Watch the window's first frame from another thread: when it hasn't reached the screen
|
||||
/// [`FIRST_FRAME_TIMEOUT`] after the app was created ([`Startup::ready`]), start the app again
|
||||
/// without the adapter, as [`finish`] does for one that failed, and end this process, whose UI
|
||||
/// thread is stuck in the graphics driver.
|
||||
/// Watch the window's first frame from another thread: once it reaches the screen, clear the start
|
||||
/// from [`START_FILE`]; when it hasn't reached the screen [`FIRST_FRAME_TIMEOUT`] after the app was
|
||||
/// created ([`Startup::ready`]), start the app again without the adapter, as [`finish`] does for
|
||||
/// one that failed, and end this process, whose UI thread is stuck in the graphics driver.
|
||||
pub fn watch_first_frame(startup: Arc<Startup>) {
|
||||
let watch = move || loop {
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
@@ -409,7 +729,10 @@ pub fn watch_first_frame(startup: Arc<Startup>) {
|
||||
let minimized = ui.and_then(|c| c.input(|i| i.viewport().minimized)).unwrap_or(false);
|
||||
match first_frame(frames, due.elapsed(), minimized) {
|
||||
FirstFrame::Wait => continue,
|
||||
FirstFrame::Shown => return,
|
||||
FirstFrame::Shown => {
|
||||
startup.presented();
|
||||
return;
|
||||
}
|
||||
FirstFrame::Hung => {}
|
||||
}
|
||||
let mut skip = skipped();
|
||||
@@ -418,6 +741,8 @@ pub fn watch_first_frame(startup: Arc<Startup>) {
|
||||
"the window's first frame didn't reach the screen in {}s on graphics adapter {failed}: starting again without it",
|
||||
FIRST_FRAME_TIMEOUT.as_secs()
|
||||
);
|
||||
// The new app records its own start.
|
||||
startup.ended();
|
||||
skip.push(failed.to_string());
|
||||
match restart(&skip.join(",")) {
|
||||
Ok(()) => std::process::exit(0),
|
||||
@@ -433,20 +758,22 @@ pub fn watch_first_frame(startup: Arc<Startup>) {
|
||||
}
|
||||
|
||||
/// eframe's adapter choice: the first of [`adapter_order`] for `power`, the GPU the `compositor`
|
||||
/// draws with, the GPUs that drive a `displays`, `WGPU_ADAPTER_NAME` and the adapters a restart
|
||||
/// left out. Every adapter, the displays and the choice are logged: which GPU draws is the first
|
||||
/// question in every black-window report.
|
||||
/// draws with, the GPUs that drive a `displays`, `WGPU_ADAPTER_NAME`, the adapters a restart
|
||||
/// left out and those `crashed` ([`LeftOut::adapters`]). Every adapter, the displays and the
|
||||
/// choice are logged: which GPU draws is the first question in every black-window report.
|
||||
pub fn selector(
|
||||
power: PowerPreference,
|
||||
displays: Vec<DisplayGpu>,
|
||||
compositor: Option<(u32, u32)>,
|
||||
crashed: Vec<String>,
|
||||
startup: Arc<Startup>,
|
||||
) -> NativeAdapterSelectorMethod {
|
||||
let named = std::env::var(NAME_ENV).ok();
|
||||
let skip = skipped();
|
||||
let left_out: Vec<String> = skip.iter().chain(&crashed).cloned().collect();
|
||||
Arc::new(move |adapters, surface| {
|
||||
let candidates: Vec<Candidate> = adapters.iter().map(|a| Candidate::new(a, surface)).collect();
|
||||
let order = adapter_order(&candidates, power, &displays, compositor, named.as_deref(), &skip);
|
||||
let order = adapter_order(&candidates, power, &displays, compositor, named.as_deref(), &left_out);
|
||||
let listed: Vec<String> = candidates
|
||||
.iter()
|
||||
.map(|c| {
|
||||
@@ -455,6 +782,8 @@ pub fn selector(
|
||||
", can't show the window"
|
||||
} else if skip.contains(&c.key) {
|
||||
", failed before"
|
||||
} else if crashed.contains(&c.key) {
|
||||
", crashed an earlier start"
|
||||
} else if c.composites(compositor) {
|
||||
", the compositor draws with it"
|
||||
} else if drives_primary {
|
||||
@@ -470,6 +799,7 @@ pub fn selector(
|
||||
log::info!("graphics adapters: {}", listed.join("; "));
|
||||
let first = order.first().and_then(|&i| adapters.get(i).zip(candidates.get(i)));
|
||||
let Some((adapter, chosen)) = first else {
|
||||
startup.none_left();
|
||||
return Err(format!("no graphics adapter can show the window (power preference {power:?}, {} adapters)", adapters.len()));
|
||||
};
|
||||
let why = match chosen.drives(&displays) {
|
||||
@@ -480,8 +810,7 @@ pub fn selector(
|
||||
(false, false) => format!("no adapter matches a display GPU, so power preference {power:?}"),
|
||||
};
|
||||
log::info!("drawing on {} ({:?}, {:?}): {why}", chosen.name.trim(), chosen.backend, chosen.device_type);
|
||||
// Set once; eframe picks the adapter once.
|
||||
let _ = startup.adapter.set(chosen.key.clone());
|
||||
startup.picked(&chosen.key, &chosen.name, candidates.iter().any(|c| c.backend != Backend::Gl));
|
||||
Ok(adapter.clone())
|
||||
})
|
||||
}
|
||||
@@ -514,6 +843,8 @@ fn restart_without<'a>(graphics_failed: bool, frames: u64, picked: Option<&'a st
|
||||
/// docs), otherwise why it failed. `outcome` is eframe's result, or the message of a panic that
|
||||
/// ended it.
|
||||
pub fn finish(outcome: Result<eframe::Result, String>, startup: &Startup) -> Result<(), String> {
|
||||
// Whatever follows, this start didn't crash; a new app records its own start.
|
||||
startup.ended();
|
||||
let (why, graphics_failed) = match outcome {
|
||||
Ok(Ok(())) => return Ok(()),
|
||||
Ok(Err(e @ eframe::Error::Wgpu(_))) => (e.to_string(), true),
|
||||
@@ -1101,4 +1432,199 @@ mod tests {
|
||||
GRAPHICS_PANIC.store(false, Ordering::Relaxed);
|
||||
assert_eq!(finish(Err("a bug".into()), &startup), Err("a bug".into()));
|
||||
}
|
||||
|
||||
/// A log folder of its own for each test.
|
||||
fn start_dir(tag: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("vectorcraft-gpu-start-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
d
|
||||
}
|
||||
|
||||
/// What a start in `dir` leaves out; it then ends without crashing.
|
||||
fn next_start(dir: &std::path::Path, explicit: bool) -> LeftOut {
|
||||
let s = Startup::default();
|
||||
let left_out = s.track(dir, None, explicit);
|
||||
s.ended();
|
||||
left_out
|
||||
}
|
||||
|
||||
/// The machine of #2243: one Intel UHD Graphics 605, listed through DX12 and OpenGL, and
|
||||
/// Windows' software adapter.
|
||||
fn issue_2243() -> Vec<Candidate> {
|
||||
vec![
|
||||
pci_gpu("Intel(R) UHD Graphics 605", Backend::Dx12, DeviceType::IntegratedGpu, (0x8086, 0x3184)),
|
||||
pci_gpu("Microsoft Basic Render Driver", Backend::Dx12, DeviceType::Cpu, (0x1414, 0x008c)),
|
||||
pci_gpu("Intel(R) UHD Graphics 605", Backend::Gl, DeviceType::Other, (0x8086, 0)),
|
||||
]
|
||||
}
|
||||
|
||||
/// #2243: the driver ends the process after the adapter was picked, before the first frame.
|
||||
/// The next start leaves that adapter out and draws through OpenGL, and so do the starts after
|
||||
/// it, without a status-bar note.
|
||||
#[test]
|
||||
fn a_start_that_never_showed_a_frame_leaves_its_adapter_out_of_the_next() {
|
||||
let dir = start_dir("crash");
|
||||
let c = issue_2243();
|
||||
let first = Startup::default();
|
||||
assert_eq!(first.track(&dir, None, false), LeftOut::default());
|
||||
first.picked(&c[0].key, &c[0].name, true);
|
||||
// The process ends here: its file is closed and unlocked, and the start is still recorded.
|
||||
drop(first);
|
||||
|
||||
let second = Startup::default();
|
||||
let left_out = second.track(&dir, Some("automatic"), false);
|
||||
assert_eq!(left_out.adapters, ["Dx12:8086:3184"]);
|
||||
assert_eq!(left_out.crash, Some(Crash::Adapter { key: "Dx12:8086:3184".into(), name: "Intel(R) UHD Graphics 605".into() }));
|
||||
assert!(!left_out.opengl_only);
|
||||
assert_eq!(left_out.backends(Backends::DX12 | Backends::GL), Backends::DX12 | Backends::GL);
|
||||
let order = adapter_order(&c, AUTOMATIC, &[], None, None, &left_out.adapters);
|
||||
assert_eq!(order, [2, 1], "OpenGL on the same GPU before the software adapter");
|
||||
second.picked(&c[2].key, &c[2].name, true);
|
||||
second.presented();
|
||||
|
||||
let third = Startup::default();
|
||||
let left_out = third.track(&dir, None, false);
|
||||
assert_eq!(left_out.adapters, ["Dx12:8086:3184"], "kept");
|
||||
assert_eq!(left_out.crash, None, "the note is shown once");
|
||||
third.ended();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A start whose first frame reached the screen, or that ended without crashing (a normal exit,
|
||||
/// an error, a restart on another adapter), leaves nothing out of the next.
|
||||
#[test]
|
||||
fn a_start_that_showed_its_first_frame_or_ended_is_cleared() {
|
||||
let dir = start_dir("shown");
|
||||
let c = issue_2243();
|
||||
for end in [Startup::presented as fn(&Startup), Startup::ended] {
|
||||
let first = Startup::default();
|
||||
first.track(&dir, None, false);
|
||||
first.picked(&c[0].key, &c[0].name, true);
|
||||
end(&first);
|
||||
drop(first);
|
||||
assert_eq!(next_start(&dir, false), LeftOut::default());
|
||||
}
|
||||
// So does a start that ended before it picked an adapter.
|
||||
let first = Startup::default();
|
||||
first.track(&dir, None, false);
|
||||
assert!(finish(Ok(Ok(())), &first).is_ok());
|
||||
drop(first);
|
||||
assert_eq!(next_start(&dir, false), LeftOut::default());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The process ended while the adapters were being listed (wgpu's DX12 backend creates a
|
||||
/// device on each GPU to list it): the next starts list OpenGL's adapters alone, except on
|
||||
/// macOS, where wgpu has none.
|
||||
#[test]
|
||||
fn a_crash_while_listing_lists_opengl_alone() {
|
||||
let dir = start_dir("listing");
|
||||
let first = Startup::default();
|
||||
first.track(&dir, None, false);
|
||||
drop(first);
|
||||
let second = Startup::default();
|
||||
let left_out = second.track(&dir, None, false);
|
||||
assert!(left_out.adapters.is_empty());
|
||||
if cfg!(target_os = "macos") {
|
||||
assert_eq!(left_out, LeftOut::default());
|
||||
} else {
|
||||
assert_eq!(left_out.crash, Some(Crash::Listing));
|
||||
assert_eq!(left_out.backends(Backends::DX12 | Backends::GL), Backends::GL);
|
||||
assert_eq!(left_out.backends(Backends::PRIMARY | Backends::GL), Backends::GL);
|
||||
assert_eq!(left_out.backends(Backends::METAL), Backends::METAL, "no OpenGL to fall back to");
|
||||
}
|
||||
// Listing OpenGL alone found nothing that can show the window: the next start lists every
|
||||
// backend again.
|
||||
second.none_left();
|
||||
second.ended();
|
||||
assert_eq!(next_start(&dir, false), LeftOut::default());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// `WGPU_BACKEND` or `WGPU_ADAPTER_NAME` choose for a start whatever crashed before; when that
|
||||
/// start shows its first frame on the adapter that crashed, the adapter counts again.
|
||||
#[test]
|
||||
fn wgpu_backend_or_adapter_name_ignores_the_crashes() {
|
||||
let dir = start_dir("explicit");
|
||||
let c = issue_2243();
|
||||
let first = Startup::default();
|
||||
first.track(&dir, None, false);
|
||||
first.picked(&c[0].key, &c[0].name, true);
|
||||
drop(first);
|
||||
assert_eq!(next_start(&dir, true), LeftOut::default());
|
||||
assert_eq!(next_start(&dir, false).adapters, ["Dx12:8086:3184"], "still left out without them");
|
||||
|
||||
let explicit = Startup::default();
|
||||
explicit.track(&dir, None, true);
|
||||
explicit.picked(&c[0].key, &c[0].name, true);
|
||||
explicit.presented();
|
||||
assert_eq!(next_start(&dir, false), LeftOut::default(), "a new driver shows the window");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A damaged record, a file that can't be opened and one another starting copy of the app holds
|
||||
/// are ignored: the start goes on with nothing left out.
|
||||
#[test]
|
||||
fn a_damaged_or_unreadable_start_file_is_ignored() {
|
||||
let dir = start_dir("damaged");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let damaged: [&[u8]; 9] = [
|
||||
b"",
|
||||
b"{",
|
||||
b"[]",
|
||||
b"null",
|
||||
b"{\"version\":1}",
|
||||
b"{\"starting\":{}}",
|
||||
b"\xef\xbb\xbf\0",
|
||||
b"\xff\xfe{",
|
||||
&[b' '; START_FILE_LIMIT as usize + 1],
|
||||
];
|
||||
for text in damaged {
|
||||
std::fs::write(dir.join(START_FILE), text).unwrap();
|
||||
assert_eq!(next_start(&dir, false), LeftOut::default(), "{:?}", String::from_utf8_lossy(&text[..text.len().min(20)]));
|
||||
}
|
||||
|
||||
// Another copy of the app is starting: this start neither reads nor writes the file.
|
||||
let c = issue_2243();
|
||||
let starting = Startup::default();
|
||||
starting.track(&dir, None, false);
|
||||
starting.picked(&c[0].key, &c[0].name, true);
|
||||
assert_eq!(next_start(&dir, false), LeftOut::default());
|
||||
drop(starting);
|
||||
assert_eq!(next_start(&dir, false).adapters, ["Dx12:8086:3184"]);
|
||||
|
||||
// A folder in the file's place.
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join(START_FILE)).unwrap();
|
||||
assert_eq!(next_start(&dir, false), LeftOut::default());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crashes_are_forgotten_in_another_version_or_under_another_preference() {
|
||||
let starting = |key: &str| Starting { adapter: Some(Recorded { key: key.into(), name: "GPU".into() }), native: true };
|
||||
let (mut record, crash) = StartRecord::begin(None, "0.9.0", "automatic");
|
||||
assert_eq!(crash, None);
|
||||
record.starting = Some(starting("Dx12:8086:3184"));
|
||||
let (record, crash) = StartRecord::begin(Some(record), "0.9.0", "automatic");
|
||||
assert_eq!(crash, Some(Crash::Adapter { key: "Dx12:8086:3184".into(), name: "GPU".into() }));
|
||||
assert_eq!(record.left_out(None, false).adapters, ["Dx12:8086:3184"]);
|
||||
// Written and read back.
|
||||
assert_eq!(StartRecord::parse(&record.to_json()), Some(record.clone()));
|
||||
assert!(StartRecord::begin(Some(record.clone()), "0.9.1", "automatic").0.crashed.is_empty());
|
||||
assert!(StartRecord::begin(Some(record.clone()), "0.9.0", "highPerformance").0.crashed.is_empty());
|
||||
assert_eq!(preference_name(None), "automatic");
|
||||
assert_eq!(preference_name(Some("powerSaving")), "automatic");
|
||||
assert_eq!(preference_name(Some("lowPower")), "lowPower");
|
||||
// An adapter is listed once, and the list is bounded.
|
||||
let mut record = record;
|
||||
for n in 0..CRASHED_LIMIT + 4 {
|
||||
for _ in 0..2 {
|
||||
record.starting = Some(starting(&format!("Dx12:10de:{n:04x}")));
|
||||
record = StartRecord::begin(Some(record), "0.9.0", "automatic").0;
|
||||
}
|
||||
}
|
||||
assert_eq!(record.crashed.len(), CRASHED_LIMIT);
|
||||
assert_eq!(record.crashed.last().map(|c| c.key.as_str()), Some("Dx12:10de:0013"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//! macOS: Sample Color from Screen (`vectorcraft_ui_egui::screen_color`) through the system's color
|
||||
//! sampler, `NSColorSampler` (macOS 10.15 and later), which "displays the system’s color-sampling
|
||||
//! interface and returns the selected color to your app"
|
||||
//! (<https://developer.apple.com/documentation/appkit/nscolorsampler>). AppKit calls the handler on
|
||||
//! the main thread with the color the person clicked, or with nil when they pressed Escape. The
|
||||
//! color is converted to sRGB before it is sent to the UI; AppKit clips a color outside sRGB to it
|
||||
//! (Display P3 red arrives as sRGB red).
|
||||
//!
|
||||
//! `showSamplerWithSelectionHandler:` has no safe binding; this module allows `unsafe_code`.
|
||||
#![allow(unsafe_code)]
|
||||
|
||||
use std::sync::mpsc::channel;
|
||||
|
||||
use block2::RcBlock;
|
||||
use objc2::MainThreadMarker;
|
||||
use objc2_app_kit::{NSColor, NSColorSampler, NSColorSpace};
|
||||
use vectorcraft_ui_egui::screen_color::{Answer, StartSampler};
|
||||
|
||||
/// The system's color sampler; `ui` is woken when it answers.
|
||||
pub fn sampler(ui: egui::Context) -> StartSampler {
|
||||
Box::new(move || {
|
||||
MainThreadMarker::new().ok_or("the color sampler can only be shown from the main thread")?;
|
||||
let (tx, rx) = channel();
|
||||
let ui = ui.clone();
|
||||
let handler = RcBlock::new(move |color: *mut NSColor| {
|
||||
// SAFETY: AppKit passes the selected color, or nil when the person canceled, and keeps
|
||||
// it alive while the handler runs.
|
||||
let answer: Answer = match unsafe { color.as_ref() } {
|
||||
None => Ok(None),
|
||||
Some(color) => srgb(color).map(Some).ok_or_else(|| "the sampled color has no sRGB value".into()),
|
||||
};
|
||||
// The UI may be gone (the app quitting): nothing waits for the color then.
|
||||
let _ = tx.send(answer);
|
||||
ui.request_repaint();
|
||||
});
|
||||
let sampler = NSColorSampler::new();
|
||||
// SAFETY: on the main thread (checked above); the handler has the signature AppKit calls it
|
||||
// with, and AppKit keeps the block and the sampler until the session ends.
|
||||
unsafe { sampler.showSamplerWithSelectionHandler(&handler) };
|
||||
Ok(rx)
|
||||
})
|
||||
}
|
||||
|
||||
/// The components of `color` in sRGB, or `None` when it has no RGB form (a pattern color).
|
||||
fn srgb(color: &NSColor) -> Option<[f32; 3]> {
|
||||
let c = color.colorUsingColorSpace(&NSColorSpace::sRGBColorSpace())?;
|
||||
Some([c.redComponent() as f32, c.greenComponent() as f32, c.blueComponent() as f32])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A color in another space arrives as its sRGB components.
|
||||
#[test]
|
||||
fn sampled_colors_convert_to_srgb() {
|
||||
let red = NSColor::colorWithSRGBRed_green_blue_alpha(1.0, 0.0, 0.0, 1.0);
|
||||
assert_eq!(srgb(&red), Some([1.0, 0.0, 0.0]));
|
||||
// Display P3 (0.6, 0.4, 0.3) lies inside sRGB with other numbers: more red, less green and blue.
|
||||
let p3 = NSColor::colorWithDisplayP3Red_green_blue_alpha(0.6, 0.4, 0.3, 1.0);
|
||||
let [r, g, b] = srgb(&p3).unwrap();
|
||||
assert!(r > 0.61 && g < 0.39 && b < 0.29, "converted, not read as sRGB numbers: {r} {g} {b}");
|
||||
// A color outside sRGB is clipped to it.
|
||||
assert_eq!(srgb(&NSColor::colorWithDisplayP3Red_green_blue_alpha(1.0, 0.0, 0.0, 1.0)), Some([1.0, 0.0, 0.0]));
|
||||
let gray = NSColor::colorWithWhite_alpha(0.5, 1.0);
|
||||
let [r, g, b] = srgb(&gray).unwrap();
|
||||
assert!((r - g).abs() < 1e-4 && (g - b).abs() < 1e-4, "{r} {g} {b}");
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
//! VectorCraft desktop app.
|
||||
//!
|
||||
//! Usage: `vectorcraft [--control <port> [--automation-read-root <dir>] [--automation-write-root <dir>]]
|
||||
//! [--in-window-menus] [files…]`
|
||||
//! [--in-window-menus] [files…]`, `vectorcraft --version`, `vectorcraft --help` (or `-h`)
|
||||
//!
|
||||
//! `--in-window-menus` (or `VECTORCRAFT_IN_WINDOW_MENUS=1`) keeps the menus inside the window on
|
||||
//! macOS instead of the macOS menu bar (`mac_menu`).
|
||||
@@ -35,6 +35,8 @@ mod mac_fonts;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac_menu;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac_screen_color;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac_window;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod open_documents;
|
||||
@@ -504,6 +506,8 @@ fn services(parent: Parent, ui: egui::Context) -> Services {
|
||||
use winit::raw_window_handle::{HasWindowHandle as _, RawWindowHandle};
|
||||
let wayland = parent.as_ref().and_then(|w| w.window_handle().ok()).is_some_and(|h| matches!(h.as_raw(), RawWindowHandle::Wayland(_)));
|
||||
let (p1, p2, p3, p4, p5) = (parent.clone(), parent.clone(), parent.clone(), parent.clone(), parent);
|
||||
#[cfg(target_os = "macos")]
|
||||
let sampled_ui = ui.clone();
|
||||
Services {
|
||||
pick_open: Some(Box::new(move |pick: &FilePick| pick_now(PickRequest::Open(pick.clone()), &p1).into_iter().next())),
|
||||
pick_open_multi: Some(Box::new(move || pick_now(PickRequest::OpenMany, &p2))),
|
||||
@@ -535,6 +539,9 @@ fn services(parent: Parent, ui: egui::Context) -> Services {
|
||||
// Links panel: Edit Original; Package: Show Package. Relink to Folder and Package pick folders.
|
||||
open_file: Some(Box::new(open_file)),
|
||||
pick_folder: Some(Box::new(move || pick_now(PickRequest::Folder, &p4).into_iter().next())),
|
||||
// Sample Color from Screen: the system's color sampler.
|
||||
#[cfg(target_os = "macos")]
|
||||
screen_color: Some(mac_screen_color::sampler(sampled_ui)),
|
||||
// File → Print: the system's printers and print queue.
|
||||
print: Some(Box::new(printing::SystemPrint)),
|
||||
..Default::default()
|
||||
@@ -618,6 +625,26 @@ fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
|
||||
}
|
||||
}
|
||||
|
||||
/// `vectorcraft --help`. Headless work (convert, run, MCP) is `vectorcraft-cli`'s.
|
||||
const USAGE: &str = "\
|
||||
Usage: vectorcraft [options] [files…]
|
||||
|
||||
Opens the VectorCraft desktop app with the given files.
|
||||
|
||||
Options:
|
||||
--control <port> Start the localhost JSON-lines control server on <port>
|
||||
(or VECTORCRAFT_CONTROL_PORT)
|
||||
--automation-read-root <dir> Confine the files control requests read to <dir>
|
||||
--automation-write-root <dir> Confine the files control requests write to <dir>
|
||||
--in-window-menus Keep the menus inside the window on macOS
|
||||
(or VECTORCRAFT_IN_WINDOW_MENUS=1)
|
||||
--version Print the version and exit
|
||||
-h, --help Print this help and exit
|
||||
|
||||
Without a window (converting, scripting, the MCP server), use vectorcraft-cli:
|
||||
vectorcraft-cli --help
|
||||
";
|
||||
|
||||
fn main() -> std::process::ExitCode {
|
||||
// Validate roots before logging, font scans, preferences or the window: a missing value
|
||||
// must not clear an environment root and start the control channel without confinement.
|
||||
@@ -643,6 +670,14 @@ fn main() -> std::process::ExitCode {
|
||||
println!("vectorcraft {}", env!("CARGO_PKG_VERSION"));
|
||||
return std::process::ExitCode::SUCCESS;
|
||||
}
|
||||
// Before anything starts, like `--version`: from a terminal or an agent's shell the app
|
||||
// used to open (or hang without a display) instead (#2110).
|
||||
"--help" | "-h" => {
|
||||
// Not `print!`, which panics when standard output is closed (`| head -0`):
|
||||
// there is nothing left to tell then.
|
||||
let _ = std::io::Write::write_all(&mut std::io::stdout(), USAGE.as_bytes());
|
||||
return std::process::ExitCode::SUCCESS;
|
||||
}
|
||||
_ => files.push(a),
|
||||
}
|
||||
}
|
||||
@@ -726,6 +761,16 @@ fn main() -> std::process::ExitCode {
|
||||
}
|
||||
#[cfg(feature = "wgpu")]
|
||||
let startup = std::sync::Arc::new(gpu::Startup::default());
|
||||
// A driver that crashes the process while the window starts leaves this start recorded in the
|
||||
// log folder; the next start leaves that adapter out (#2243). Runs without preferences, which
|
||||
// write no log file, don't record their starts.
|
||||
#[cfg(feature = "wgpu")]
|
||||
let left_out = match log_dir().filter(|_| prefs_enabled()) {
|
||||
Some(dir) => startup.track(&dir, gpu_pref, gpu::explicit_choice()),
|
||||
None => gpu::LeftOut::default(),
|
||||
};
|
||||
#[cfg(feature = "wgpu")]
|
||||
let crash = left_out.crash.clone();
|
||||
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
|
||||
@@ -755,13 +800,14 @@ fn main() -> std::process::ExitCode {
|
||||
options.wgpu_options.surface = eframe::egui_wgpu::SurfaceConfig::LOW_LATENCY;
|
||||
// Only adapters that can show the window, in the order `gpu` gives (#306, #502).
|
||||
if let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
|
||||
create.native_adapter_selector = Some(gpu::selector(power, displays, compositor, startup.clone()));
|
||||
create.native_adapter_selector = Some(gpu::selector(power, displays, compositor, left_out.adapters.clone(), startup.clone()));
|
||||
// Nothing draws or dispatches indirectly, so wgpu's check of indirect arguments only
|
||||
// costs a compute shader at start-up, one some drivers can't compile (OCLP-patched
|
||||
// Metal on an Iris Pro, #651). `WGPU_VALIDATION_INDIRECT_CALL=1` turns it back on.
|
||||
create.instance_descriptor.flags =
|
||||
(eframe::wgpu::InstanceFlags::from_build_config() - eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env();
|
||||
create.instance_descriptor.backends = gpu::backends(create.instance_descriptor.backends, std::env::var_os("WGPU_BACKEND").is_some());
|
||||
create.instance_descriptor.backends =
|
||||
left_out.backends(gpu::backends(create.instance_descriptor.backends, std::env::var_os("WGPU_BACKEND").is_some()));
|
||||
// DX12 shaders compile with FXC, part of Windows: wgpu's default loads whichever
|
||||
// dxcompiler.dll the search path finds, and one without its dxil.dll fails every shader,
|
||||
// losing the device before the first frame (#1194). `WGPU_DX12_COMPILER` picks another.
|
||||
@@ -836,6 +882,15 @@ fn main() -> std::process::ExitCode {
|
||||
app.status(format!(
|
||||
"The graphics processor tried first couldn't show the window, so VectorCraft started again on {summary}{hint}"
|
||||
));
|
||||
} else if let Some(crash) = &crash {
|
||||
app.status(match crash {
|
||||
gpu::Crash::Adapter { name, .. } => format!(
|
||||
"The last start of VectorCraft ended on {name} before its window appeared, so it started on {summary}. To try that graphics processor again, change Preferences › Performance › Graphics Processor or start with WGPU_BACKEND set"
|
||||
),
|
||||
gpu::Crash::Listing => format!(
|
||||
"The last start of VectorCraft ended while it listed the graphics processors, so it started on {summary} with OpenGL alone. To try the other graphics backends again, change Preferences › Performance › Graphics Processor or start with WGPU_BACKEND set"
|
||||
),
|
||||
});
|
||||
}
|
||||
app.graphics_adapter = Some(summary);
|
||||
let (loss, ctx) = (graphics_loss.clone(), cc.egui_ctx.clone());
|
||||
|
||||
+338
-14
@@ -1,29 +1,184 @@
|
||||
//! Native profile paths. Isolated profiles never migrate preferences from a user's old profile.
|
||||
//!
|
||||
//! In order:
|
||||
//! 1. `VECTORCRAFT_CONFIG_DIR`, when set (tests, agents, custom setups).
|
||||
//! 2. **Portable mode** (#1366), as in PhotoCraft: a marker file ([`PORTABLE_MARKERS`]) in the
|
||||
//! install folder puts the settings in `VectorCraftData` beside it, so a portable copy leaves
|
||||
//! nothing in `%APPDATA%`. The install folder is the executable's; on macOS the one holding
|
||||
//! `VectorCraft.app` (nothing is written inside a signed bundle), for an AppImage the one holding
|
||||
//! the `.AppImage` file (`$APPIMAGE`; the executable runs from a read-only mount). The Windows
|
||||
//! portable zip ships with `portable.txt`; the MSI doesn't. The first time `VectorCraftData` is
|
||||
//! created, the settings in the user's profile are copied into it, so a portable zip that gains
|
||||
//! the marker keeps its users' settings. If the folder can't be written (read-only medium,
|
||||
//! Program Files), a warning is logged and the platform folder is used. A portable copy never
|
||||
//! reads the old DrawCraft profile, and a run without preferences (`VECTORCRAFT_NO_PREFS`) never
|
||||
//! looks for the marker.
|
||||
//! 3. The platform convention.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Files in the install folder that switch on portable mode (either one; contents are ignored).
|
||||
pub const PORTABLE_MARKERS: [&str; 2] = ["portable.txt", "VectorCraft.portable"];
|
||||
/// The settings folder created in the install folder in portable mode.
|
||||
pub const PORTABLE_DATA_DIR: &str = "VectorCraftData";
|
||||
|
||||
pub fn prefs_path(legacy: bool) -> Option<PathBuf> {
|
||||
prefs_path_with(std::env::consts::OS, legacy, |key| std::env::var_os(key))
|
||||
prefs_path_with(std::env::consts::OS, legacy, |key| std::env::var_os(key), portable_dir)
|
||||
}
|
||||
|
||||
fn prefs_path_with(os: &str, legacy: bool, env: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
|
||||
/// `portable` is the portable settings folder, if any: only asked for when no override is set and
|
||||
/// preferences are in use.
|
||||
fn prefs_path_with(os: &str, legacy: bool, env: impl Fn(&str) -> Option<OsString>, portable: impl FnOnce() -> Option<PathBuf>) -> Option<PathBuf> {
|
||||
if let Some(root) = env("VECTORCRAFT_CONFIG_DIR") {
|
||||
// Never fall back to DrawCraft's real profile when the isolated file does not exist.
|
||||
return (!legacy && !root.is_empty()).then(|| PathBuf::from(root).join("ui.json"));
|
||||
}
|
||||
// A run without preferences reads and writes nothing, so it must not create the portable folder.
|
||||
if env("VECTORCRAFT_NO_PREFS").is_none()
|
||||
&& let Some(dir) = portable()
|
||||
{
|
||||
// A portable copy is self-contained: DrawCraft's old profile is not read into it.
|
||||
return (!legacy).then(|| dir.join("ui.json"));
|
||||
}
|
||||
platform_dir(os, legacy, &env).map(|p| p.join("ui.json"))
|
||||
}
|
||||
|
||||
/// The platform's settings folder of VectorCraft, or of DrawCraft (`legacy`).
|
||||
fn platform_dir(os: &str, legacy: bool, env: &impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
|
||||
let (name, lower) = if legacy { ("DrawCraft", "drawcraft") } else { ("VectorCraft", "vectorcraft") };
|
||||
let base = match os {
|
||||
match os {
|
||||
"macos" => env("HOME").map(|p| PathBuf::from(p).join("Library/Application Support").join(name)),
|
||||
"windows" => env("APPDATA").map(|p| PathBuf::from(p).join(name)),
|
||||
_ => env("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| env("HOME").map(|p| PathBuf::from(p).join(".config"))).map(|p| p.join(lower)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The portable settings folder of this copy, looked for once per process (it probes the disk, and
|
||||
/// the settings path is asked for many times).
|
||||
fn portable_dir() -> Option<PathBuf> {
|
||||
static DIR: OnceLock<Option<PathBuf>> = OnceLock::new();
|
||||
DIR.get_or_init(|| {
|
||||
let os = std::env::consts::OS;
|
||||
let env = |key: &str| std::env::var_os(key);
|
||||
let install = install_dir(os, &std::env::current_exe().ok()?, &env)?;
|
||||
portable_dir_in(&install, platform_dir(os, false, &env).as_deref()).unwrap_or_else(|warning| {
|
||||
// Only the log: `eprintln!` panics when standard error is a closed pipe, and the logger
|
||||
// keeps early records for the log file.
|
||||
log::warn!("{warning}");
|
||||
None
|
||||
})
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// The folder the user sees the app in, where the portable marker is looked for: the executable's;
|
||||
/// on macOS the one holding `VectorCraft.app` when the executable sits in its `Contents/MacOS` (a
|
||||
/// signed bundle is never written into); on Linux the one holding the `.AppImage` file when
|
||||
/// `$APPIMAGE` names it (the executable itself runs from a read-only mount).
|
||||
fn install_dir(os: &str, exe: &Path, env: &impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
|
||||
let exe_dir = exe.parent()?;
|
||||
let dir = match os {
|
||||
"macos" => app_bundle_parent(exe_dir).unwrap_or(exe_dir).to_path_buf(),
|
||||
"windows" => exe_dir.to_path_buf(),
|
||||
_ => {
|
||||
let appimage = env("APPIMAGE").map(PathBuf::from);
|
||||
let beside = appimage.as_deref().and_then(Path::parent).filter(|p| !p.as_os_str().is_empty());
|
||||
beside.unwrap_or(exe_dir).to_path_buf()
|
||||
}
|
||||
};
|
||||
base.map(|p| p.join("ui.json"))
|
||||
Some(dir)
|
||||
}
|
||||
|
||||
/// The folder holding `X.app` when `exe_dir` is `X.app/Contents/MacOS`.
|
||||
fn app_bundle_parent(exe_dir: &Path) -> Option<&Path> {
|
||||
let contents = exe_dir.parent().filter(|_| exe_dir.file_name() == Some("MacOS".as_ref()))?;
|
||||
let bundle = contents.parent().filter(|_| contents.file_name() == Some("Contents".as_ref()))?;
|
||||
bundle.extension().filter(|ext| *ext == "app").and(bundle.parent())
|
||||
}
|
||||
|
||||
/// `<install_dir>/VectorCraftData` when a portable marker sits in `install_dir`, created if missing;
|
||||
/// `None` without a marker, and the warning to log when there is one but that folder can't be
|
||||
/// written. The first time the folder is created, the settings in `previous` (the user's profile
|
||||
/// folder, when it exists) are copied into it, so a portable zip that gains the marker keeps its
|
||||
/// users' settings. `previous` is never changed; a copy that fails halfway is logged and left as it
|
||||
/// is, and a later start finds the folder and copies nothing.
|
||||
fn portable_dir_in(install_dir: &Path, previous: Option<&Path>) -> Result<Option<PathBuf>, String> {
|
||||
let Some(marker) = PORTABLE_MARKERS.iter().map(|m| install_dir.join(m)).find(|p| p.is_file()) else { return Ok(None) };
|
||||
let data = install_dir.join(PORTABLE_DATA_DIR);
|
||||
let first_start = !data.exists();
|
||||
if let Err(e) = ensure_writable(&data) {
|
||||
return Err(format!(
|
||||
"portable mode ({} found) but {} isn't writable ({e}); settings are kept in the user's settings folder instead",
|
||||
marker.display(),
|
||||
data.display()
|
||||
));
|
||||
}
|
||||
if first_start && let Some(previous) = previous.filter(|p| p.is_dir()) {
|
||||
match copy_dir(previous, &data) {
|
||||
Ok(()) => log::info!("portable mode: copied the settings from {} to {}", previous.display(), data.display()),
|
||||
Err(e) => log::warn!("portable mode: the settings in {} were only partly copied to {}: {e}", previous.display(), data.display()),
|
||||
}
|
||||
}
|
||||
Ok(Some(data))
|
||||
}
|
||||
|
||||
/// Create `dir` and prove a file can be written in it (a folder can exist yet be read-only).
|
||||
fn ensure_writable(dir: &Path) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let probe = dir.join(format!(".write-test-{}", std::process::id()));
|
||||
std::fs::write(&probe, b"")?;
|
||||
// A leftover probe is harmless; don't give up portable mode over it.
|
||||
let _ = std::fs::remove_file(&probe);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Copy the contents of `from` into `to` (which exists), going on past entries that can't be
|
||||
/// copied; the error says how many were skipped and why the first one was.
|
||||
fn copy_dir(from: &Path, to: &Path) -> Result<(), String> {
|
||||
let mut skipped = 0usize;
|
||||
let mut first: Option<String> = None;
|
||||
copy_dir_into(from, to, &mut |why| {
|
||||
skipped += 1;
|
||||
first.get_or_insert(why);
|
||||
});
|
||||
match first {
|
||||
None => Ok(()),
|
||||
Some(why) => Err(format!("{skipped} skipped, the first because {why}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_dir_into(from: &Path, to: &Path, skip: &mut impl FnMut(String)) {
|
||||
let entries = match std::fs::read_dir(from) {
|
||||
Ok(entries) => entries,
|
||||
Err(e) => return skip(format!("{}: {e}", from.display())),
|
||||
};
|
||||
for entry in entries {
|
||||
let entry = match entry {
|
||||
Ok(entry) => entry,
|
||||
Err(e) => {
|
||||
skip(format!("{}: {e}", from.display()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let (src, dst) = (entry.path(), to.join(entry.file_name()));
|
||||
// A symbolic link is copied as what it points to, or skipped when that is a folder.
|
||||
let copied = match entry.file_type() {
|
||||
Ok(kind) if kind.is_dir() => std::fs::create_dir_all(&dst).map(|()| copy_dir_into(&src, &dst, skip)),
|
||||
Ok(_) => std::fs::copy(&src, &dst).map(|_| ()),
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
if let Err(e) = copied {
|
||||
skip(format!("{}: {e}", src.display()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
fn environment(key: &str) -> Option<OsString> {
|
||||
match key {
|
||||
@@ -34,33 +189,202 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// No portable marker in the install folder.
|
||||
fn installed() -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isolated_profile_has_no_legacy_fallback_on_any_platform() {
|
||||
for os in ["macos", "windows", "linux"] {
|
||||
let env = |key: &str| {
|
||||
if key == "VECTORCRAFT_CONFIG_DIR" { Some("/isolated".into()) } else { environment(key) }
|
||||
};
|
||||
assert_eq!(prefs_path_with(os, false, env), Some("/isolated/ui.json".into()));
|
||||
assert_eq!(prefs_path_with(os, true, env), None);
|
||||
assert_eq!(prefs_path_with(os, false, env, installed), Some("/isolated/ui.json".into()));
|
||||
assert_eq!(prefs_path_with(os, true, env, installed), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_override_does_not_fall_back_to_user_files() {
|
||||
let env = |key: &str| if key == "VECTORCRAFT_CONFIG_DIR" { Some(OsString::new()) } else { environment(key) };
|
||||
assert_eq!(prefs_path_with("macos", false, env), None);
|
||||
assert_eq!(prefs_path_with("macos", true, env), None);
|
||||
assert_eq!(prefs_path_with("macos", false, env, installed), None);
|
||||
assert_eq!(prefs_path_with("macos", true, env, installed), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unset_override_keeps_platform_paths_and_legacy_migration() {
|
||||
assert_eq!(prefs_path_with("macos", false, environment), Some("/user/Library/Application Support/VectorCraft/ui.json".into()));
|
||||
assert_eq!(prefs_path_with("macos", true, environment), Some("/user/Library/Application Support/DrawCraft/ui.json".into()));
|
||||
assert_eq!(prefs_path_with("windows", false, environment), Some("/roaming/VectorCraft/ui.json".into()));
|
||||
assert_eq!(prefs_path_with("linux", false, environment), Some("/xdg-config/vectorcraft/ui.json".into()));
|
||||
assert_eq!(prefs_path_with("macos", false, environment, installed), Some("/user/Library/Application Support/VectorCraft/ui.json".into()));
|
||||
assert_eq!(prefs_path_with("macos", true, environment, installed), Some("/user/Library/Application Support/DrawCraft/ui.json".into()));
|
||||
assert_eq!(prefs_path_with("windows", false, environment, installed), Some("/roaming/VectorCraft/ui.json".into()));
|
||||
assert_eq!(prefs_path_with("linux", false, environment, installed), Some("/xdg-config/vectorcraft/ui.json".into()));
|
||||
assert_eq!(
|
||||
prefs_path_with("linux", true, |key| if key == "HOME" { environment(key) } else { None }),
|
||||
prefs_path_with("linux", true, |key| if key == "HOME" { environment(key) } else { None }, installed),
|
||||
Some("/user/.config/drawcraft/ui.json".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portable_mode_keeps_the_settings_beside_the_executable_and_skips_the_drawcraft_profile() {
|
||||
let portable = || Some(PathBuf::from("/usb/VectorCraft/VectorCraftData"));
|
||||
for os in ["macos", "windows", "linux"] {
|
||||
assert_eq!(prefs_path_with(os, false, environment, portable), Some("/usb/VectorCraft/VectorCraftData/ui.json".into()), "{os}");
|
||||
// A portable copy is self-contained: the one-time read of DrawCraft's old settings is skipped.
|
||||
assert_eq!(prefs_path_with(os, true, environment, portable), None, "{os}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_config_dir_override_wins_over_portable_mode_without_probing_it() {
|
||||
let env = |key: &str| if key == "VECTORCRAFT_CONFIG_DIR" { Some("/isolated".into()) } else { environment(key) };
|
||||
let portable = || -> Option<PathBuf> { panic!("the portable folder is not looked for under VECTORCRAFT_CONFIG_DIR") };
|
||||
assert_eq!(prefs_path_with("windows", false, env, portable), Some("/isolated/ui.json".into()));
|
||||
assert_eq!(prefs_path_with("windows", true, env, portable), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_without_preferences_never_looks_for_the_portable_folder() {
|
||||
let env = |key: &str| if key == "VECTORCRAFT_NO_PREFS" { Some("1".into()) } else { environment(key) };
|
||||
let portable = || -> Option<PathBuf> { panic!("the portable folder is not looked for under VECTORCRAFT_NO_PREFS") };
|
||||
assert_eq!(prefs_path_with("windows", false, env, portable), Some("/roaming/VectorCraft/ui.json".into()));
|
||||
assert_eq!(prefs_path_with("windows", true, env, portable), Some("/roaming/DrawCraft/ui.json".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn on_macos_the_marker_is_looked_for_beside_the_app_bundle() {
|
||||
let bundled = Path::new("/Apps/VectorCraft.app/Contents/MacOS/vectorcraft");
|
||||
assert_eq!(install_dir("macos", bundled, &environment), Some("/Apps".into()));
|
||||
// A bare executable (the CLI zip) and other platforms: the executable's own folder.
|
||||
assert_eq!(install_dir("macos", Path::new("/Apps/vectorcraft-cli"), &environment), Some("/Apps".into()));
|
||||
assert_eq!(install_dir("windows", bundled, &environment), Some("/Apps/VectorCraft.app/Contents/MacOS".into()));
|
||||
assert_eq!(install_dir("linux", bundled, &environment), Some("/Apps/VectorCraft.app/Contents/MacOS".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_appimage_looks_for_the_marker_beside_the_appimage_file() {
|
||||
let mounted = Path::new("/tmp/.mount_vc/usr/bin/vectorcraft");
|
||||
let appimage = |key: &str| if key == "APPIMAGE" { Some("/home/user/Apps/VectorCraft.AppImage".into()) } else { environment(key) };
|
||||
assert_eq!(install_dir("linux", mounted, &appimage), Some("/home/user/Apps".into()));
|
||||
assert_eq!(install_dir("linux", mounted, &environment), Some("/tmp/.mount_vc/usr/bin".into()));
|
||||
let empty = |key: &str| if key == "APPIMAGE" { Some(OsString::new()) } else { environment(key) };
|
||||
assert_eq!(install_dir("linux", mounted, &empty), Some("/tmp/.mount_vc/usr/bin".into()));
|
||||
// The variable means nothing on other platforms.
|
||||
assert_eq!(install_dir("windows", mounted, &appimage), Some("/tmp/.mount_vc/usr/bin".into()));
|
||||
assert_eq!(install_dir("macos", mounted, &appimage), Some("/tmp/.mount_vc/usr/bin".into()));
|
||||
}
|
||||
|
||||
fn temp(tag: &str) -> PathBuf {
|
||||
static N: AtomicUsize = AtomicUsize::new(0);
|
||||
let d = std::env::temp_dir().join(format!("vectorcraft-profile-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn either_marker_beside_the_executable_makes_a_writable_data_folder() {
|
||||
for marker in PORTABLE_MARKERS {
|
||||
let exe = temp("portable");
|
||||
std::fs::write(exe.join(marker), b"").unwrap();
|
||||
let dir = portable_dir_in(&exe, None).unwrap().unwrap();
|
||||
assert_eq!(dir, exe.join(PORTABLE_DATA_DIR), "{marker}");
|
||||
assert!(dir.is_dir(), "the data folder is created");
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "the write probe is removed");
|
||||
let _ = std::fs::remove_dir_all(&exe);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fake_app_bundle_keeps_the_marker_and_the_data_outside_the_bundle() {
|
||||
let folder = temp("bundle");
|
||||
let exe = folder.join("VectorCraft.app/Contents/MacOS/vectorcraft");
|
||||
std::fs::create_dir_all(exe.parent().unwrap()).unwrap();
|
||||
std::fs::write(folder.join("portable.txt"), b"").unwrap();
|
||||
let install = install_dir("macos", &exe, &environment).unwrap();
|
||||
assert_eq!(portable_dir_in(&install, None), Ok(Some(folder.join(PORTABLE_DATA_DIR))));
|
||||
assert!(!exe.parent().unwrap().join(PORTABLE_DATA_DIR).exists(), "nothing is written inside the bundle");
|
||||
// A marker dropped inside the bundle is not looked at.
|
||||
std::fs::remove_file(folder.join("portable.txt")).unwrap();
|
||||
std::fs::write(exe.parent().unwrap().join("portable.txt"), b"").unwrap();
|
||||
let _ = std::fs::remove_dir_all(folder.join(PORTABLE_DATA_DIR));
|
||||
assert_eq!(portable_dir_in(&install, None), Ok(None));
|
||||
let _ = std::fs::remove_dir_all(&folder);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_marker_file_there_is_no_portable_folder() {
|
||||
let exe = temp("installed");
|
||||
assert_eq!(portable_dir_in(&exe, None), Ok(None));
|
||||
// A folder named like the marker is not a marker.
|
||||
std::fs::create_dir_all(exe.join("portable.txt")).unwrap();
|
||||
assert_eq!(portable_dir_in(&exe, None), Ok(None));
|
||||
assert!(!exe.join(PORTABLE_DATA_DIR).exists(), "no data folder without a marker");
|
||||
let _ = std::fs::remove_dir_all(&exe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unwritable_portable_folder_is_reported_instead_of_used() {
|
||||
let exe = temp("unwritable");
|
||||
std::fs::write(exe.join("portable.txt"), b"").unwrap();
|
||||
// A file where the data folder should go: it can't be created, on every platform.
|
||||
std::fs::write(exe.join(PORTABLE_DATA_DIR), b"in the way").unwrap();
|
||||
let warning = portable_dir_in(&exe, None).unwrap_err();
|
||||
assert!(warning.contains("portable") && warning.contains(PORTABLE_DATA_DIR), "{warning}");
|
||||
let _ = std::fs::remove_dir_all(&exe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_start_copies_the_user_profile_into_the_data_folder_once() {
|
||||
let exe = temp("upgrade");
|
||||
std::fs::write(exe.join("portable.txt"), b"").unwrap();
|
||||
let previous = temp("previous-profile");
|
||||
std::fs::write(previous.join("ui.json"), b"{\"theme\":\"dark\"}").unwrap();
|
||||
std::fs::create_dir_all(previous.join("Swatches/Mine")).unwrap();
|
||||
std::fs::write(previous.join("Swatches/Mine/warm.ase"), b"ase").unwrap();
|
||||
|
||||
let data = portable_dir_in(&exe, Some(&previous)).unwrap().unwrap();
|
||||
assert_eq!(data, exe.join(PORTABLE_DATA_DIR));
|
||||
assert_eq!(std::fs::read(data.join("ui.json")).unwrap(), b"{\"theme\":\"dark\"}", "the settings file is copied");
|
||||
assert_eq!(std::fs::read(data.join("Swatches/Mine/warm.ase")).unwrap(), b"ase", "nested folders are copied");
|
||||
assert_eq!(std::fs::read(previous.join("ui.json")).unwrap(), b"{\"theme\":\"dark\"}", "the source is left as it is");
|
||||
assert!(previous.join("Swatches/Mine/warm.ase").is_file(), "the source is left as it is");
|
||||
|
||||
// A second start finds the folder and copies nothing, whatever changed on either side.
|
||||
std::fs::write(data.join("ui.json"), b"{\"theme\":\"light\"}").unwrap();
|
||||
std::fs::write(previous.join("ui.json"), b"{\"theme\":\"newer\"}").unwrap();
|
||||
std::fs::write(previous.join("later.txt"), b"").unwrap();
|
||||
assert_eq!(portable_dir_in(&exe, Some(&previous)), Ok(Some(data.clone())));
|
||||
assert_eq!(std::fs::read(data.join("ui.json")).unwrap(), b"{\"theme\":\"light\"}", "the portable settings are not overwritten");
|
||||
assert!(!data.join("later.txt").exists(), "nothing is copied after the first start");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&exe);
|
||||
let _ = std::fs::remove_dir_all(&previous);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_user_profile_the_data_folder_starts_empty() {
|
||||
let exe = temp("fresh");
|
||||
std::fs::write(exe.join("portable.txt"), b"").unwrap();
|
||||
let missing = exe.join("no-such-profile");
|
||||
let data = portable_dir_in(&exe, Some(&missing)).unwrap().unwrap();
|
||||
assert_eq!(std::fs::read_dir(&data).unwrap().count(), 0);
|
||||
let _ = std::fs::remove_dir_all(&exe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copy_that_fails_halfway_reports_what_it_skipped_and_keeps_the_rest() {
|
||||
let from = temp("copy-from");
|
||||
let to = temp("copy-to");
|
||||
std::fs::write(from.join("kept.txt"), b"kept").unwrap();
|
||||
std::fs::create_dir_all(from.join("blocked")).unwrap();
|
||||
std::fs::write(from.join("blocked/inner.txt"), b"").unwrap();
|
||||
// A file where the sub-folder should go: that entry can't be copied, the others can.
|
||||
std::fs::write(to.join("blocked"), b"in the way").unwrap();
|
||||
let error = copy_dir(&from, &to).unwrap_err();
|
||||
assert!(error.starts_with("1 skipped"), "{error}");
|
||||
assert!(error.contains("blocked"), "{error}");
|
||||
assert_eq!(std::fs::read(to.join("kept.txt")).unwrap(), b"kept");
|
||||
let _ = std::fs::remove_dir_all(&from);
|
||||
let _ = std::fs::remove_dir_all(&to);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,13 @@
|
||||
//! same in storytold/photocraft#2401). [`service`] strips the two styles and has Windows
|
||||
//! recalculate the frame; winit puts them back whenever it changes the window flags (maximize,
|
||||
//! restore, …), so the returned guard runs every frame.
|
||||
//!
|
||||
//! Windows sizes a maximize by the frame its styles describe, so a window maximized while its
|
||||
//! caption is gone is sized without one: its rect covers the whole monitor, taskbar included, and
|
||||
//! the shell reads a caption-less window covering its monitor as a full-screen app (#1977). The
|
||||
//! guard therefore also trims a parked maximize's rect back to the work area plus its own frame,
|
||||
//! clear of the taskbar, as PhotoCraft does (storytold/photocraft#3308). winit pins the client
|
||||
//! area to the work area either way (`WM_NCCALCSIZE`), so the app's pixels do not move.
|
||||
|
||||
/// Run once per frame; a no-op once the window is borderless.
|
||||
pub type WindowFrameFn = Box<dyn FnMut()>;
|
||||
@@ -30,8 +37,15 @@ mod platform {
|
||||
_ => return None,
|
||||
};
|
||||
let hwnd = find_window(target)?;
|
||||
strip(&hwnd);
|
||||
Some(Box::new(move || strip(&hwnd)))
|
||||
reapply(&hwnd);
|
||||
Some(Box::new(move || reapply(&hwnd)))
|
||||
}
|
||||
|
||||
/// One pass of the per-frame guard: the styles winit put back come off again, and a parked
|
||||
/// maximize stays clear of the taskbar.
|
||||
fn reapply(hwnd: &HWND) {
|
||||
strip(hwnd);
|
||||
keep_clear_of_the_taskbar(hwnd);
|
||||
}
|
||||
|
||||
/// The top-level window with the given raw handle: `EnumWindows` hands out real `HWND`s, so
|
||||
@@ -65,6 +79,29 @@ mod platform {
|
||||
co::SWP::FRAMECHANGED | co::SWP::NOMOVE | co::SWP::NOSIZE | co::SWP::NOZORDER | co::SWP::NOACTIVATE,
|
||||
);
|
||||
}
|
||||
|
||||
/// [`super::clamped_height`] for this frame's window: trim a maximized window whose rect runs
|
||||
/// under the taskbar back to the work area plus its frame. No-op otherwise.
|
||||
fn keep_clear_of_the_taskbar(hwnd: &HWND) {
|
||||
if !hwnd.IsZoomed() {
|
||||
return;
|
||||
}
|
||||
let (Ok(r), Ok(mi)) = (hwnd.GetWindowRect(), hwnd.MonitorFromWindow(co::MONITOR::DEFAULTTONEAREST).GetMonitorInfo()) else {
|
||||
return;
|
||||
};
|
||||
let (m, w) = (mi.rcMonitor, mi.rcWork);
|
||||
let Some(height) =
|
||||
super::clamped_height((r.left, r.top, r.right, r.bottom), (m.left, m.top, m.right, m.bottom), (w.left, w.top, w.right, w.bottom))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let _ = hwnd.SetWindowPos(
|
||||
HwndPlace::None,
|
||||
POINT::new(),
|
||||
SIZE { cx: r.right - r.left, cy: height },
|
||||
co::SWP::NOMOVE | co::SWP::NOZORDER | co::SWP::NOACTIVATE,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(all(windows, feature = "wgpu")))]
|
||||
@@ -73,3 +110,65 @@ mod platform {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// How far a parked maximize reaches past its monitor on the left, top and right: the resize border
|
||||
/// (about 8 pt at 100 %, more with DPI). Past that, a maximized window's rect is not parked but
|
||||
/// being dragged or snapped, and is left alone.
|
||||
const FRAME: i32 = 32;
|
||||
|
||||
/// The height to trim a maximize to so its rect stops at the taskbar, or `None` when it already
|
||||
/// does or `rect` (`left, top, right, bottom`, desktop coordinates) is not parked at its maximize
|
||||
/// place. `monitor` is the monitor's full rect and `work` its work area (the monitor less the
|
||||
/// taskbar) in the same coordinates. The parked check is against the full monitor, because that is
|
||||
/// where Windows puts a caption-less maximize; the trim is against the work area. Platform-neutral
|
||||
/// so it is tested everywhere.
|
||||
#[cfg_attr(not(all(windows, feature = "wgpu")), allow(dead_code))]
|
||||
fn clamped_height(rect: (i32, i32, i32, i32), monitor: (i32, i32, i32, i32), work: (i32, i32, i32, i32)) -> Option<i32> {
|
||||
let (l, t, r, b) = rect;
|
||||
let (ml, mt, mr, _) = monitor;
|
||||
let (_, _, _, wb) = work;
|
||||
let side = ml - l;
|
||||
if !(0..=FRAME).contains(&side) || !(0..=FRAME).contains(&(mt - t)) || !(0..=FRAME).contains(&(r - mr)) {
|
||||
return None;
|
||||
}
|
||||
if b <= wb + side {
|
||||
return None;
|
||||
}
|
||||
Some(wb + side - t)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::clamped_height;
|
||||
|
||||
#[test]
|
||||
fn a_maximize_covering_the_taskbar_is_trimmed_to_the_work_area() {
|
||||
// 1920 × 1080 with a 34 px taskbar: a caption-less maximize covers the whole monitor,
|
||||
// taskbar included (#1977). Trimmed so the rect ends at the work area plus the frame the
|
||||
// window was parked with.
|
||||
let monitor = (0, 0, 1920, 1080);
|
||||
let work = (0, 0, 1920, 1046);
|
||||
assert_eq!(clamped_height((-7, -7, 1927, 1087), monitor, work), Some(1046 + 7 - (-7)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_maximize_already_clear_of_the_taskbar_is_left_alone() {
|
||||
// What Windows sizes a framed maximize to: the work area plus the frame.
|
||||
assert_eq!(clamped_height((-8, -8, 1928, 1054), (0, 0, 1920, 1080), (0, 0, 1920, 1046)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_maximize_beside_a_side_taskbar_is_left_alone() {
|
||||
// Taskbar on the left: the work area starts further right, but the monitor's bottom is
|
||||
// still clear, so the rect reaches nothing.
|
||||
assert_eq!(clamped_height((-8, -8, 1928, 1088), (0, 0, 1920, 1080), (48, 0, 1920, 1080)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_maximize_being_dragged_away_is_left_alone() {
|
||||
let monitor = (0, 0, 1920, 1080);
|
||||
let work = (0, 0, 1920, 1046);
|
||||
assert_eq!(clamped_height((100, 200, 1100, 900), monitor, work), None, "moved");
|
||||
assert_eq!(clamped_height((-500, -7, 1427, 1087), monitor, work), None, "resized");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
//! `vectorcraft --help` prints the usage and exits before the app starts (#2110): run from a
|
||||
//! terminal, SSH or an agent's shell it used to open the window (or hang without a display).
|
||||
|
||||
use std::process::{Command, Output, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft");
|
||||
|
||||
/// Runs the desktop binary without a display, so a regression fails instead of opening a window.
|
||||
fn run(args: &[&str], env: &[(&str, &str)]) -> Output {
|
||||
let mut child = Command::new(BIN)
|
||||
.args(args)
|
||||
.env_remove("DISPLAY")
|
||||
.env_remove("WAYLAND_DISPLAY")
|
||||
.env_remove("WAYLAND_SOCKET")
|
||||
.env_remove("VECTORCRAFT_CONTROL_PORT")
|
||||
.env("VECTORCRAFT_NO_PREFS", "1")
|
||||
.envs(env.iter().copied())
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while child.try_wait().unwrap().is_none() {
|
||||
if Instant::now() >= deadline {
|
||||
child.kill().unwrap();
|
||||
panic!("{args:?}: the app started instead of printing its usage");
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
child.wait_with_output().unwrap()
|
||||
}
|
||||
|
||||
fn prints_usage(args: &[&str], env: &[(&str, &str)]) {
|
||||
let out = run(args, env);
|
||||
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||
assert!(out.status.success(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
assert!(out.stderr.is_empty(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
assert!(stdout.starts_with("Usage: vectorcraft "), "{args:?}: {stdout}");
|
||||
for flag in ["--control", "--automation-read-root", "--automation-write-root", "--in-window-menus", "--version", "--help"] {
|
||||
assert!(stdout.contains(flag), "{args:?}: usage doesn't mention {flag}: {stdout}");
|
||||
}
|
||||
// Headless work is the CLI's: the usage says where to go.
|
||||
assert!(stdout.contains("vectorcraft-cli"), "{args:?}: {stdout}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_prints_usage_and_exits_before_startup() {
|
||||
prints_usage(&["--help"], &[]);
|
||||
prints_usage(&["-h"], &[]);
|
||||
// After a file or a flag, too: nothing is opened.
|
||||
prints_usage(&["drawing.svg", "--help"], &[]);
|
||||
prints_usage(&["--in-window-menus", "-h"], &[]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_wins_over_a_bad_control_port_variable() {
|
||||
prints_usage(&["--help"], &[("VECTORCRAFT_CONTROL_PORT", "abc")]);
|
||||
}
|
||||
@@ -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="M4 5v14M20 5v14"/><path d="M4 12h16"/><path d="M7.5 9 4.5 12l3 3M16.5 9l3 3-3 3"/></svg>
|
||||
|
After Width: | Height: | Size: 281 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M4 5H20L14 12.5V18.5L10 20V12.5Z" stroke-width="1.5" /></svg>
|
||||
|
After Width: | Height: | Size: 253 B |
@@ -98,3 +98,25 @@ sync_github_name = false
|
||||
real_name = "Tomasz Lewandowski"
|
||||
display_name = "Tomasz Lewandowski"
|
||||
sync_github_name = false
|
||||
|
||||
[people.osamaamin2011]
|
||||
real_name = "Osama Amin"
|
||||
display_name = "Osama Amin"
|
||||
sync_github_name = false
|
||||
|
||||
[people.giang17]
|
||||
real_name = "Giang Nguyen"
|
||||
|
||||
[people.Silvertree2010]
|
||||
display_name = "Netter"
|
||||
sync_github_name = false
|
||||
|
||||
[people.limackk]
|
||||
real_name = "Kamil Kwiatkowski"
|
||||
display_name = "Kamil Kwiatkowski"
|
||||
sync_github_name = false
|
||||
|
||||
[people.marcoarnulfo]
|
||||
real_name = "Marco Arnulfo"
|
||||
display_name = "Marco Arnulfo"
|
||||
sync_github_name = false
|
||||
@@ -21,3 +21,5 @@ image = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
# The tests lay type out in the bundled fonts (the workspace leaves turning them on to the engine).
|
||||
vectorcraft-text = { workspace = true, features = ["bundled-fonts"] }
|
||||
@@ -965,6 +965,41 @@ fn drawings_the_export_writes_read_back() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_control_point_splines_stay_closed_through_export_and_import() {
|
||||
// (explicitly closed, ends where it starts): coincident endpoints alone do not close a path.
|
||||
let cases = [(true, true), (true, false), (false, true), (false, false)];
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let layer = doc.layers[0].id;
|
||||
for (i, &(closed, returns)) in cases.iter().enumerate() {
|
||||
let mut b = kurbo::BezPath::new();
|
||||
b.move_to((20.0, 20.0));
|
||||
b.curve_to((80.0, 0.0), (80.0, 80.0), (20.0, 80.0));
|
||||
if returns {
|
||||
b.curve_to((0.0, 80.0), (0.0, 20.0), (20.0, 20.0));
|
||||
}
|
||||
if closed {
|
||||
b.close_path();
|
||||
}
|
||||
let id = doc.alloc_id();
|
||||
let black = Appearance::basic(Paint::None, Paint::solid(Color::rgb8(0, 0, 0)), 1.0);
|
||||
doc.insert(Some(layer), i, Node::path(id, vectorcraft_geom::PathData::from_bezpath(&b), black)).unwrap();
|
||||
}
|
||||
for version in crate::DxfVersion::ALL.into_iter().filter(|v| *v >= crate::DxfVersion::R13) {
|
||||
let opts = crate::DxfOptions { version, unit: Unit::Points, region: doc.artboards[0].rect, ..crate::DxfOptions::default() };
|
||||
let out = crate::export(&doc, &opts).unwrap();
|
||||
let text = std::str::from_utf8(&out.bytes).unwrap();
|
||||
assert_eq!(text.matches("\nSPLINE\n").count(), cases.len(), "{version:?}: curves are written as SPLINE entities");
|
||||
let back = leaves(&open(&out.bytes, &points()).document);
|
||||
assert_eq!(back.len(), cases.len(), "{version:?}");
|
||||
for (n, &(closed, returns)) in back.iter().zip(&cases) {
|
||||
let path = n.path_data().unwrap();
|
||||
assert_eq!(path.subpaths.len(), 1, "{version:?}");
|
||||
assert_eq!(path.subpaths[0].closed, closed, "{version:?}: closed {closed}, returns {returns}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_lists_layers_layouts_and_version() {
|
||||
let tables = [(0, "TABLE"), (2, "LAYER"), (0, "LAYER"), (2, "0"), (62, "7"), (0, "LAYER"), (2, "Walls"), (62, "1"), (0, "ENDTAB")];
|
||||
|
||||
@@ -320,7 +320,12 @@ impl<'a> Scene<'a> {
|
||||
}
|
||||
}
|
||||
// Live blends, envelopes, meshes and repeats are written as their evaluated art.
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) => {
|
||||
NodeKind::Blend { .. }
|
||||
| NodeKind::Envelope { .. }
|
||||
| NodeKind::Mesh(_)
|
||||
| NodeKind::Repeat(_)
|
||||
| NodeKind::PlacedDocument(_)
|
||||
| NodeKind::Intertwine { .. } => {
|
||||
let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n);
|
||||
for c in g.children().into_iter().flatten() {
|
||||
self.node(c, false);
|
||||
|
||||
@@ -515,6 +515,7 @@ impl Cms {
|
||||
ProofTarget::LegacyMacRgb => srgb.map(|v| lab::linear_to_srgb(v.powf(1.8))),
|
||||
ProofTarget::Protanopia => simulate_cvd(srgb, &PROTANOPIA),
|
||||
ProofTarget::Deuteranopia => simulate_cvd(srgb, &DEUTERANOPIA),
|
||||
ProofTarget::Tritanopia => TRITANOPIA.simulate(srgb),
|
||||
ProofTarget::WorkingCmyk => {
|
||||
let cmyk = self.srgb_to_cmyk(srgb, proof.intent);
|
||||
self.cmyk_to_srgb(cmyk, proof.simulate_paper)
|
||||
@@ -582,6 +583,106 @@ fn simulate_cvd(srgb: [f32; 3], m: &[[f32; 3]; 3]) -> [f32; 3] {
|
||||
lab::mat_mul(m, lab::srgb_linear(srgb)).map(|v| lab::linear_to_srgb(v.clamp(0.0, 1.0)))
|
||||
}
|
||||
|
||||
// Tritanopia: Brettel, Viénot & Mollon (1997), "Computerized simulation of color appearance for
|
||||
// dichromats", JOSA A 14(10), 2647-2655, https://doi.org/10.1364/JOSAA.14.002647. A color keeps its
|
||||
// L and M cone responses, and its S response is replaced so that it lies on one of two half-planes in
|
||||
// LMS space. Both half-planes contain the neutral axis (black to the display's white, so grays keep
|
||||
// their value); one passes through the 485 nm spectral color and the other through the 660 nm one. A
|
||||
// color uses the half-plane on its own side of the plane through the neutral axis and the S axis.
|
||||
// The matrices are computed at compile time from these inputs:
|
||||
// - linear sRGB → CIE XYZ: IEC 61966-2-1 (D65);
|
||||
// - CIE XYZ → LMS: Smith & Pokorny (1975), as printed in https://daltonlens.org/understanding-cvd-simulation/;
|
||||
// - the 485 nm and 660 nm colors: the Judd-Vos color matching functions,
|
||||
// http://www.cvrl.org/database/data/cmfs/ciexyzjv.csv.
|
||||
const LINEAR_SRGB_TO_XYZ: [[f64; 3]; 3] = [[0.4124, 0.3576, 0.1805], [0.2126, 0.7152, 0.0722], [0.0193, 0.1192, 0.9505]];
|
||||
const XYZ_TO_LMS: [[f64; 3]; 3] = [[0.155_14, 0.543_12, -0.032_86], [-0.155_14, 0.456_84, 0.032_86], [0.0, 0.0, 0.016_08]];
|
||||
const XYZ_485_NM: [f64; 3] = [0.056_985, 0.169_87, 0.586_4];
|
||||
const XYZ_660_NM: [f64; 3] = [0.161_61, 0.061, 0.000_011_906];
|
||||
const TRITANOPIA: Dichromat = Dichromat::tritanope();
|
||||
|
||||
/// A dichromat simulation with two half-planes, as matrices on linear sRGB. `near` applies to colors
|
||||
/// whose dot product with `side` is 0 or more, and `far` applies to the others.
|
||||
struct Dichromat {
|
||||
side: [f32; 3],
|
||||
near: [[f32; 3]; 3],
|
||||
far: [[f32; 3]; 3],
|
||||
}
|
||||
|
||||
impl Dichromat {
|
||||
const fn tritanope() -> Self {
|
||||
let to_lms = mat3_mul(&XYZ_TO_LMS, &LINEAR_SRGB_TO_XYZ);
|
||||
let to_rgb = mat3_inverse(&to_lms);
|
||||
let white = mat3_apply(&to_lms, [1.0; 3]);
|
||||
let a485 = mat3_apply(&XYZ_TO_LMS, XYZ_485_NM);
|
||||
let a660 = mat3_apply(&XYZ_TO_LMS, XYZ_660_NM);
|
||||
let mut side = cross(white, [0.0, 0.0, 1.0]);
|
||||
if dot(side, a485) < 0.0 {
|
||||
side = [-side[0], -side[1], -side[2]];
|
||||
}
|
||||
// A color on the 485 nm side lands on that half-plane.
|
||||
let near = mat3_mul(&to_rgb, &mat3_mul(&keep_lm_on_plane(cross(white, a485)), &to_lms));
|
||||
let far = mat3_mul(&to_rgb, &mat3_mul(&keep_lm_on_plane(cross(white, a660)), &to_lms));
|
||||
// `side · lms(rgb)` is `(to_lmsᵀ · side) · rgb`.
|
||||
let t = &to_lms;
|
||||
let side_rgb = [
|
||||
t[0][0] * side[0] + t[1][0] * side[1] + t[2][0] * side[2],
|
||||
t[0][1] * side[0] + t[1][1] * side[1] + t[2][1] * side[2],
|
||||
t[0][2] * side[0] + t[1][2] * side[1] + t[2][2] * side[2],
|
||||
];
|
||||
Dichromat { side: vec3_f32(side_rgb), near: mat3_f32(&near), far: mat3_f32(&far) }
|
||||
}
|
||||
|
||||
fn simulate(&self, srgb: [f32; 3]) -> [f32; 3] {
|
||||
let rgb = lab::srgb_linear(srgb);
|
||||
let s = self.side;
|
||||
let m = if s[0] * rgb[0] + s[1] * rgb[1] + s[2] * rgb[2] >= 0.0 { &self.near } else { &self.far };
|
||||
lab::mat_mul(m, rgb).map(|v| lab::linear_to_srgb(v.clamp(0.0, 1.0)))
|
||||
}
|
||||
}
|
||||
|
||||
/// LMS → LMS: keeps L and M and sets S so the result lies in the plane with normal `n`.
|
||||
const fn keep_lm_on_plane(n: [f64; 3]) -> [[f64; 3]; 3] {
|
||||
[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [-n[0] / n[2], -n[1] / n[2], 0.0]]
|
||||
}
|
||||
|
||||
const fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
|
||||
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
|
||||
}
|
||||
|
||||
const fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
|
||||
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
||||
}
|
||||
|
||||
const fn mat3_apply(m: &[[f64; 3]; 3], v: [f64; 3]) -> [f64; 3] {
|
||||
[dot(m[0], v), dot(m[1], v), dot(m[2], v)]
|
||||
}
|
||||
|
||||
const fn mat3_mul(a: &[[f64; 3]; 3], b: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
|
||||
let mut out = [[0.0; 3]; 3];
|
||||
let mut i = 0;
|
||||
while i < 3 {
|
||||
out[i] = [dot(a[i], [b[0][0], b[1][0], b[2][0]]), dot(a[i], [b[0][1], b[1][1], b[2][1]]), dot(a[i], [b[0][2], b[1][2], b[2][2]])];
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
const fn mat3_inverse(m: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
|
||||
let c0 = cross(m[1], m[2]);
|
||||
let c1 = cross(m[2], m[0]);
|
||||
let c2 = cross(m[0], m[1]);
|
||||
let det = dot(m[0], c0);
|
||||
[[c0[0] / det, c1[0] / det, c2[0] / det], [c0[1] / det, c1[1] / det, c2[1] / det], [c0[2] / det, c1[2] / det, c2[2] / det]]
|
||||
}
|
||||
|
||||
const fn vec3_f32(v: [f64; 3]) -> [f32; 3] {
|
||||
[v[0] as f32, v[1] as f32, v[2] as f32]
|
||||
}
|
||||
|
||||
const fn mat3_f32(m: &[[f64; 3]; 3]) -> [[f32; 3]; 3] {
|
||||
[vec3_f32(m[0]), vec3_f32(m[1]), vec3_f32(m[2])]
|
||||
}
|
||||
|
||||
/// View → Proof Setup target.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", content = "profile", rename_all = "camelCase")]
|
||||
@@ -601,6 +702,8 @@ pub enum ProofTarget {
|
||||
Protanopia,
|
||||
/// Colour blindness — deuteranopia type.
|
||||
Deuteranopia,
|
||||
/// Color blindness, tritanopia type.
|
||||
Tritanopia,
|
||||
}
|
||||
|
||||
impl ProofTarget {
|
||||
@@ -615,6 +718,7 @@ impl ProofTarget {
|
||||
"monitorRgb" | "monitor" => ProofTarget::MonitorRgb,
|
||||
"protanopia" => ProofTarget::Protanopia,
|
||||
"deuteranopia" => ProofTarget::Deuteranopia,
|
||||
"tritanopia" => ProofTarget::Tritanopia,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -627,6 +731,7 @@ impl ProofTarget {
|
||||
ProofTarget::MonitorRgb => "monitorRgb".into(),
|
||||
ProofTarget::Protanopia => "protanopia".into(),
|
||||
ProofTarget::Deuteranopia => "deuteranopia".into(),
|
||||
ProofTarget::Tritanopia => "tritanopia".into(),
|
||||
}
|
||||
}
|
||||
/// Whether the target simulates a CMYK press (separations make sense).
|
||||
|
||||
@@ -194,6 +194,35 @@ fn colour_blindness_proofs() {
|
||||
assert_eq!(ProofTarget::parse(&ProofTarget::Cmyk("X".into()).id()), Some(ProofTarget::Cmyk("X".into())));
|
||||
}
|
||||
|
||||
/// The expected colors were computed separately with NumPy from the same published inputs (linear
|
||||
/// sRGB to XYZ from IEC 61966-2-1, Smith & Pokorny's XYZ to LMS, Judd-Vos XYZ at 485 nm and 660 nm),
|
||||
/// following Brettel, Viénot & Mollon (1997) with the display's white as the neutral axis.
|
||||
#[test]
|
||||
fn tritanopia_proof_follows_brettel_1997() {
|
||||
let c = cms();
|
||||
let t = ProofSetup { target: ProofTarget::Tritanopia, ..Default::default() };
|
||||
assert_eq!(ProofTarget::parse("tritanopia"), Some(ProofTarget::Tritanopia));
|
||||
assert_eq!(ProofTarget::Tritanopia.id(), "tritanopia");
|
||||
for g in [0.0, 0.25, 0.5, 0.75, 1.0] {
|
||||
let out = c.proof_srgb([g; 3], &t);
|
||||
assert!(out.iter().all(|v| (v - g).abs() < 1e-4), "gray {g} stays gray: {out:?}");
|
||||
}
|
||||
let cases = [
|
||||
// The 660 nm half-plane.
|
||||
([1.0, 0.0, 0.0], [1.0, 0.0, 0.299_231]),
|
||||
([1.0, 0.0, 1.0], [0.934_206, 0.388_351, 0.467_178]),
|
||||
([0.9, 0.6, 0.1], [0.926_825, 0.557_667, 0.599_649]),
|
||||
// The 485 nm half-plane.
|
||||
([0.0, 0.0, 1.0], [0.0, 0.377_268, 0.519_933]),
|
||||
([0.0, 1.0, 0.0], [0.480_503, 0.920_568, 1.0]),
|
||||
([0.2, 0.4, 0.8], [0.0, 0.464_73, 0.563_516]),
|
||||
];
|
||||
for (rgb, want) in cases {
|
||||
let got = c.proof_srgb(rgb, &t);
|
||||
assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-3), "{rgb:?}: {got:?}, expected {want:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_icc_rejected() {
|
||||
assert!(matches!(register_icc(b"not a profile", None), Err(CmsError::BadProfile(_))));
|
||||
|
||||
@@ -282,6 +282,23 @@ impl RowMap {
|
||||
self.key
|
||||
}
|
||||
|
||||
/// The tint (0..1) of the new colour current colour `c` takes, when it is a tint of the new
|
||||
/// colour: what a colour linked to a global or spot swatch keeps as its tint. `None` when the
|
||||
/// method makes it something else (a shade, or another hue).
|
||||
pub fn tint(&self, c: Color) -> Option<f32> {
|
||||
match self.method {
|
||||
Method::Exact => Some(1.0),
|
||||
Method::PreserveTints if is_tint(c, self.key) => Some(tint_of(c, self.key)),
|
||||
Method::PreserveTints => Some(1.0),
|
||||
Method::ScaleTints => Some(tint_of(c, self.key)),
|
||||
Method::TintsShades => {
|
||||
let d = c.to_lab().l - self.mean_l;
|
||||
(d >= 0.0).then(|| 1.0 - d / (100.0 - self.mean_l).max(1e-3))
|
||||
}
|
||||
Method::HueShift => (c == self.key).then_some(1.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// The new colour of current colour `c`, in `c`'s model.
|
||||
pub fn map(&self, c: Color) -> Color {
|
||||
let to = keep_model(c, self.to);
|
||||
|
||||
@@ -0,0 +1,573 @@
|
||||
//! Persistent dimension recipes. Children are ordinary paths and type for all renderers/exporters.
|
||||
use crate::{Document, NodeId, Unit};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use vectorcraft_geom::{Affine, Point, Vec2};
|
||||
|
||||
/// What a dimension measures.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum DimensionMode {
|
||||
#[default]
|
||||
Linear,
|
||||
Angular,
|
||||
Radius,
|
||||
Diameter,
|
||||
}
|
||||
|
||||
impl DimensionMode {
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Self::Linear => "linear",
|
||||
Self::Angular => "angular",
|
||||
Self::Radius => "radius",
|
||||
Self::Diameter => "diameter",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The unit a length is labelled in.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum LengthUnit {
|
||||
#[default]
|
||||
Pt,
|
||||
Pc,
|
||||
Px,
|
||||
In,
|
||||
Mm,
|
||||
Cm,
|
||||
M,
|
||||
Ft,
|
||||
Yd,
|
||||
}
|
||||
|
||||
impl LengthUnit {
|
||||
pub const ALL: [LengthUnit; 9] = [
|
||||
LengthUnit::In,
|
||||
LengthUnit::Mm,
|
||||
LengthUnit::Pt,
|
||||
LengthUnit::Pc,
|
||||
LengthUnit::Px,
|
||||
LengthUnit::Cm,
|
||||
LengthUnit::M,
|
||||
LengthUnit::Ft,
|
||||
LengthUnit::Yd,
|
||||
];
|
||||
pub fn unit(self) -> Unit {
|
||||
match self {
|
||||
Self::Pt => Unit::Points,
|
||||
Self::Pc => Unit::Picas,
|
||||
Self::Px => Unit::Pixels,
|
||||
Self::In => Unit::Inches,
|
||||
Self::Mm => Unit::Millimeters,
|
||||
Self::Cm => Unit::Centimeters,
|
||||
Self::M => Unit::Meters,
|
||||
Self::Ft => Unit::Feet,
|
||||
Self::Yd => Unit::Yards,
|
||||
}
|
||||
}
|
||||
/// The suffix written after a length.
|
||||
pub fn suffix(self) -> &'static str {
|
||||
match self {
|
||||
Self::Pt => "pt",
|
||||
Self::Pc => "pc",
|
||||
Self::Px => "px",
|
||||
Self::In => "in",
|
||||
Self::Mm => "mm",
|
||||
Self::Cm => "cm",
|
||||
Self::M => "m",
|
||||
Self::Ft => "ft",
|
||||
Self::Yd => "yd",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum LineType {
|
||||
#[default]
|
||||
Solid,
|
||||
Dashed,
|
||||
Dotted,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ArrowStyle {
|
||||
#[default]
|
||||
Filled,
|
||||
Open,
|
||||
Tick,
|
||||
None,
|
||||
}
|
||||
|
||||
/// Where the label sits across the dimension line.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TextPosition {
|
||||
/// On the side of the dimension line away from the measured object.
|
||||
#[default]
|
||||
Away,
|
||||
/// Between the dimension line and the measured object.
|
||||
Between,
|
||||
}
|
||||
|
||||
/// Where the label sits along the dimension line.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TextAlignment {
|
||||
Left,
|
||||
#[default]
|
||||
Center,
|
||||
Right,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct DimensionOptions {
|
||||
pub units: LengthUnit,
|
||||
pub precision: usize,
|
||||
/// Drawing : real size. 1:10 multiplies the drawn length by ten.
|
||||
pub scale: String,
|
||||
pub hide_units: bool,
|
||||
pub line_weight: f64,
|
||||
pub line_type: LineType,
|
||||
pub line_color: [u8; 3],
|
||||
pub arrow_style: ArrowStyle,
|
||||
pub arrow_scale: f64,
|
||||
pub extension_weight: f64,
|
||||
pub extension_type: LineType,
|
||||
pub extension_color: [u8; 3],
|
||||
pub extension_offset: f64,
|
||||
pub extension_length: f64,
|
||||
pub hide_extensions: bool,
|
||||
pub font_family: String,
|
||||
pub font_style: String,
|
||||
pub font_size: f64,
|
||||
pub text_color: [u8; 3],
|
||||
pub text_position: TextPosition,
|
||||
pub text_alignment: TextAlignment,
|
||||
pub text_offset: f64,
|
||||
}
|
||||
|
||||
impl Default for DimensionOptions {
|
||||
fn default() -> Self {
|
||||
// The document's own type defaults: a font every build has, black, in points.
|
||||
let type_style = crate::CharStyle::default();
|
||||
Self {
|
||||
units: LengthUnit::Pt,
|
||||
precision: 2,
|
||||
scale: "1:1".into(),
|
||||
hide_units: false,
|
||||
line_weight: 0.5,
|
||||
line_type: LineType::Solid,
|
||||
line_color: [0, 0, 0],
|
||||
arrow_style: ArrowStyle::Filled,
|
||||
arrow_scale: 100.0,
|
||||
extension_weight: 0.5,
|
||||
extension_type: LineType::Solid,
|
||||
extension_color: [0, 0, 0],
|
||||
extension_offset: 4.0,
|
||||
extension_length: 6.0,
|
||||
hide_extensions: false,
|
||||
font_family: type_style.font_family,
|
||||
font_style: type_style.font_style,
|
||||
font_size: type_style.size,
|
||||
text_color: [0, 0, 0],
|
||||
text_position: TextPosition::Away,
|
||||
text_alignment: TextAlignment::Center,
|
||||
text_offset: 4.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DimensionOptions {
|
||||
/// Options given to a command or a tool: unknown keys are refused there, while files load
|
||||
/// tolerantly so a newer version's options don't stop a document opening.
|
||||
pub fn from_params(v: serde_json::Value) -> Result<Self, String> {
|
||||
if let (Some(given), Ok(serde_json::Value::Object(known))) = (v.as_object(), serde_json::to_value(Self::default()))
|
||||
&& let Some(k) = given.keys().find(|k| !known.contains_key(*k))
|
||||
{
|
||||
return Err(format!("Unknown dimension option {k}"));
|
||||
}
|
||||
let o: Self = serde_json::from_value(v).map_err(|e| e.to_string())?;
|
||||
o.validate()?;
|
||||
Ok(o)
|
||||
}
|
||||
pub fn factor(&self) -> Result<f64, String> {
|
||||
let (a, b) = self.scale.split_once(':').ok_or("Scale must be drawing:real, for example 1:10")?;
|
||||
let parse = |s: &str| s.trim().parse::<f64>().ok().filter(|n| n.is_finite() && *n > 0.0 && *n <= 1e9);
|
||||
let a = parse(a).ok_or("Invalid drawing scale")?;
|
||||
let b = parse(b).ok_or("Invalid real scale")?;
|
||||
let f = b / a;
|
||||
if !(1e-6..=1e6).contains(&f) {
|
||||
return Err("Scale ratio must be between 1:1000000 and 1000000:1".into());
|
||||
}
|
||||
Ok(f)
|
||||
}
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
self.factor()?;
|
||||
if self.precision > 6 {
|
||||
return Err("Choose a precision from 0 to 6".into());
|
||||
}
|
||||
for (v, min, max) in [
|
||||
(self.line_weight, 0.1, 100.0),
|
||||
(self.extension_weight, 0.1, 100.0),
|
||||
(self.arrow_scale, 1.0, 1000.0),
|
||||
(self.font_size, 1.0, 1000.0),
|
||||
(self.extension_offset, 0.0, 10000.0),
|
||||
(self.extension_length, 0.0, 10000.0),
|
||||
(self.text_offset, 0.0, 10000.0),
|
||||
] {
|
||||
if !v.is_finite() || v < min || v > max {
|
||||
return Err("Dimension size is outside its supported range".into());
|
||||
}
|
||||
}
|
||||
if self.font_family.is_empty() || self.font_family.len() > 256 || self.font_style.len() > 128 {
|
||||
return Err("Invalid dimension font name".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// `value` in points for a length, in degrees for an angle.
|
||||
pub fn label(&self, value: f64, angle: bool) -> Result<String, String> {
|
||||
self.validate()?;
|
||||
let value = if angle { value } else { value * self.factor()? / self.units.unit().points() };
|
||||
if !value.is_finite() {
|
||||
return Err("Non-finite dimension".into());
|
||||
}
|
||||
let suffix = if self.hide_units {
|
||||
""
|
||||
} else if angle {
|
||||
"°"
|
||||
} else {
|
||||
self.units.suffix()
|
||||
};
|
||||
let sep = if suffix.is_empty() || angle { "" } else { " " };
|
||||
Ok(format!("{:.*}{sep}{suffix}", self.precision, value))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SegmentRef {
|
||||
pub node: NodeId,
|
||||
pub subpath: usize,
|
||||
pub segment: usize,
|
||||
}
|
||||
|
||||
impl SegmentRef {
|
||||
/// The segment's two end points.
|
||||
pub fn endpoints(&self, doc: &Document) -> Option<(Point, Point)> {
|
||||
let sp = doc.node(self.node)?.path_data()?.subpaths.get(self.subpath)?;
|
||||
if self.segment >= sp.segment_count() {
|
||||
return None;
|
||||
}
|
||||
let seg = sp.segment(self.segment);
|
||||
Some((seg.p0, seg.p3))
|
||||
}
|
||||
/// Whether the segment is a straight line.
|
||||
pub fn is_straight(&self, doc: &Document) -> bool {
|
||||
doc.node(self.node)
|
||||
.and_then(|n| n.path_data())
|
||||
.and_then(|p| p.subpaths.get(self.subpath))
|
||||
.is_some_and(|sp| self.segment < sp.segment_count() && sp.segment_is_line(self.segment))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum DimensionSource {
|
||||
Segment {
|
||||
edge: SegmentRef,
|
||||
},
|
||||
Angle {
|
||||
first: SegmentRef,
|
||||
second: SegmentRef,
|
||||
},
|
||||
Circle {
|
||||
node: NodeId,
|
||||
},
|
||||
/// One segment that is a perfect arc.
|
||||
Arc {
|
||||
edge: SegmentRef,
|
||||
},
|
||||
Points {
|
||||
a: Option<PointRef>,
|
||||
b: Option<PointRef>,
|
||||
vertex: Option<PointRef>,
|
||||
},
|
||||
}
|
||||
|
||||
impl DimensionSource {
|
||||
pub fn nodes(&self) -> Vec<NodeId> {
|
||||
match self {
|
||||
Self::Segment { edge } | Self::Arc { edge } => vec![edge.node],
|
||||
Self::Angle { first, second } => vec![first.node, second.node],
|
||||
Self::Circle { node } => vec![*node],
|
||||
Self::Points { a, b, vertex } => [a, b, vertex].into_iter().filter_map(|p| p.as_ref().map(|r| r.node)).collect(),
|
||||
}
|
||||
}
|
||||
/// Anchor indexes cannot safely follow a topology replacement. Ordinary moves and handle
|
||||
/// edits keep their topology and remain linked.
|
||||
pub fn topology_changed(&self, before: &Document, after: &Document) -> bool {
|
||||
if matches!(self, Self::Circle { .. }) {
|
||||
return false;
|
||||
}
|
||||
self.nodes().iter().any(|id| {
|
||||
let counts = |doc: &Document| {
|
||||
doc.node(*id).and_then(|n| n.path_data()).map(|p| p.subpaths.iter().map(|sp| (sp.anchors.len(), sp.closed)).collect::<Vec<_>>())
|
||||
};
|
||||
let old = counts(before);
|
||||
old.is_some() && old != counts(after)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PointRef {
|
||||
pub node: NodeId,
|
||||
pub subpath: usize,
|
||||
pub anchor: usize,
|
||||
}
|
||||
|
||||
impl PointRef {
|
||||
pub fn position(&self, doc: &Document) -> Option<Point> {
|
||||
Some(doc.node(self.node)?.path_data()?.subpaths.get(self.subpath)?.anchors.get(self.anchor)?.p)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Dimension {
|
||||
pub mode: DimensionMode,
|
||||
pub a: Point,
|
||||
pub b: Point,
|
||||
/// Angular vertex. For radius/diameter a is the centre and b lies on the circle.
|
||||
pub vertex: Point,
|
||||
pub offset: f64,
|
||||
pub length: f64,
|
||||
pub options: DimensionOptions,
|
||||
pub source: Option<DimensionSource>,
|
||||
/// How far a linked dimension was moved on its own since the last refresh: the refresh turns
|
||||
/// it into a new placement unless the measured art moved too.
|
||||
#[serde(skip)]
|
||||
pub moved: Option<Vec2>,
|
||||
}
|
||||
|
||||
impl Dimension {
|
||||
/// A point on the drawn dimension line (linear), arc (angular) or radius line.
|
||||
pub fn handle(&self) -> Point {
|
||||
match self.mode {
|
||||
DimensionMode::Linear => self.a.midpoint(self.b) + self.normal() * self.offset,
|
||||
DimensionMode::Angular => self.vertex + self.bisector() * self.offset.abs().max(12.0),
|
||||
DimensionMode::Radius | DimensionMode::Diameter => self.b,
|
||||
}
|
||||
}
|
||||
/// The left normal of a → b.
|
||||
pub fn normal(&self) -> Vec2 {
|
||||
let u = self.b - self.a;
|
||||
let len = u.hypot();
|
||||
if len > 1e-9 { Vec2::new(-u.y, u.x) / len } else { Vec2::new(0.0, 1.0) }
|
||||
}
|
||||
fn bisector(&self) -> Vec2 {
|
||||
let start = (self.a - self.vertex).atan2();
|
||||
Vec2::from_angle(start + self.angle() * 0.5)
|
||||
}
|
||||
/// Places the dimension through `p`: the line's distance from the art (linear), the arc's
|
||||
/// radius (angular) or the label's direction (radial).
|
||||
pub fn place(&mut self, p: Point) {
|
||||
match self.mode {
|
||||
DimensionMode::Linear => self.offset = (p - self.a).dot(self.normal()),
|
||||
DimensionMode::Angular => self.offset = p.distance(self.vertex).max(12.0),
|
||||
DimensionMode::Radius | DimensionMode::Diameter => {
|
||||
if p.distance(self.a) > 1e-6 {
|
||||
let r = self.a.distance(self.b);
|
||||
let u = p - self.a;
|
||||
self.b = self.a + u / u.hypot() * r;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn transform(&mut self, xf: Affine) {
|
||||
if self.source.is_some() {
|
||||
// The art it measures decides where its ends are: record the move, and the refresh
|
||||
// after this edit turns it into a new placement unless the art moved with it.
|
||||
let at = self.handle();
|
||||
self.moved = Some((xf * at) - at + self.moved.unwrap_or_default());
|
||||
return;
|
||||
}
|
||||
self.a = xf * self.a;
|
||||
self.b = xf * self.b;
|
||||
self.vertex = xf * self.vertex;
|
||||
if self.mode == DimensionMode::Linear {
|
||||
self.length = self.a.distance(self.b);
|
||||
}
|
||||
}
|
||||
pub fn resolve(&mut self, doc: &Document) -> Result<(), String> {
|
||||
match &self.source {
|
||||
Some(DimensionSource::Points { a, b, vertex }) => {
|
||||
for (reference, point) in [(a, &mut self.a), (b, &mut self.b), (vertex, &mut self.vertex)] {
|
||||
if let Some(r) = reference {
|
||||
*point = r.position(doc).ok_or("Measured anchor no longer exists")?;
|
||||
}
|
||||
}
|
||||
self.length = self.a.distance(self.b);
|
||||
}
|
||||
Some(DimensionSource::Segment { edge }) => {
|
||||
let (a, b) = edge.endpoints(doc).ok_or("Measured segment no longer exists")?;
|
||||
self.a = a;
|
||||
self.b = b;
|
||||
self.length = a.distance(b);
|
||||
}
|
||||
Some(DimensionSource::Angle { first, second }) => {
|
||||
for edge in [first, second] {
|
||||
if !edge.is_straight(doc) {
|
||||
return Err("Angles require two straight segments".into());
|
||||
}
|
||||
}
|
||||
let (a, b) = first.endpoints(doc).ok_or("First measured segment no longer exists")?;
|
||||
let (c, d) = second.endpoints(doc).ok_or("Second measured segment no longer exists")?;
|
||||
let u = b - a;
|
||||
let v = d - c;
|
||||
let den = u.cross(v);
|
||||
if den.abs() < 1e-9 {
|
||||
return Err("Choose two non-parallel straight segments".into());
|
||||
}
|
||||
let p = a + u * ((c - a).cross(v) / den);
|
||||
self.vertex = p;
|
||||
self.a = if a.distance(p) > b.distance(p) { a } else { b };
|
||||
self.b = if c.distance(p) > d.distance(p) { c } else { d };
|
||||
}
|
||||
Some(DimensionSource::Circle { node }) => {
|
||||
let (center, radius) = circle(doc, *node).ok_or("Choose a circle or a perfect arc")?;
|
||||
self.set_radial(center, radius);
|
||||
}
|
||||
Some(DimensionSource::Arc { edge }) => {
|
||||
let (center, radius) = arc(doc, edge).ok_or("Choose a circle or a perfect arc")?;
|
||||
self.set_radial(center, radius);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
self.validate()
|
||||
}
|
||||
fn set_radial(&mut self, center: Point, radius: f64) {
|
||||
let direction = self.b - self.a;
|
||||
let dir = if direction.hypot() > 1e-9 { direction / direction.hypot() } else { Vec2::new(1.0, 0.0) };
|
||||
self.a = center;
|
||||
self.b = center + dir * radius;
|
||||
self.length = radius;
|
||||
}
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
self.options.validate()?;
|
||||
for p in [self.a, self.b, self.vertex] {
|
||||
if !p.x.is_finite() || !p.y.is_finite() || p.x.abs() > 1e8 || p.y.abs() > 1e8 {
|
||||
return Err("Dimension point is outside the canvas".into());
|
||||
}
|
||||
}
|
||||
if !self.offset.is_finite() || self.offset.abs() > 1e6 || !self.length.is_finite() || self.length < 0.0 || self.length > 1e9 {
|
||||
return Err("Invalid dimension distance".into());
|
||||
}
|
||||
if self.a.distance(self.b) < 1e-6 {
|
||||
return Err("Choose two different points".into());
|
||||
}
|
||||
if self.mode == DimensionMode::Angular && (self.a.distance(self.vertex) < 1e-6 || self.b.distance(self.vertex) < 1e-6) {
|
||||
return Err("Choose two nonzero angle arms".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn angle(&self) -> f64 {
|
||||
let u = self.a - self.vertex;
|
||||
let v = self.b - self.vertex;
|
||||
u.cross(v).atan2(u.dot(v))
|
||||
}
|
||||
pub fn label(&self) -> Result<String, String> {
|
||||
let value = match self.mode {
|
||||
DimensionMode::Angular => self.angle().abs().to_degrees(),
|
||||
DimensionMode::Radius => self.a.distance(self.b),
|
||||
DimensionMode::Diameter => self.a.distance(self.b) * 2.0,
|
||||
DimensionMode::Linear => self.a.distance(self.b),
|
||||
};
|
||||
self.options.label(value, self.mode == DimensionMode::Angular)
|
||||
}
|
||||
}
|
||||
|
||||
/// Accept circular paths (including imported circles), reject ellipses and arbitrary closed shapes.
|
||||
pub fn circle(doc: &Document, id: NodeId) -> Option<(Point, f64)> {
|
||||
use vectorcraft_geom::kurbo::ParamCurve;
|
||||
let path = doc.node(id)?.path_data()?;
|
||||
let sp = path.subpaths.first()?;
|
||||
if path.subpaths.len() != 1 || !sp.closed || !(2..=64).contains(&sp.segment_count()) {
|
||||
return None;
|
||||
}
|
||||
let bounds = path.bounds()?;
|
||||
let center = bounds.center();
|
||||
let radius = bounds.width() * 0.5;
|
||||
if radius < 1e-6 || (bounds.height() * 0.5 - radius).abs() > radius * 0.002 {
|
||||
return None;
|
||||
}
|
||||
for i in 0..sp.segment_count() {
|
||||
for t in [0.0, 0.25, 0.5, 0.75] {
|
||||
if (sp.segment(i).eval(t).distance(center) - radius).abs() > radius * 0.002 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some((center, radius))
|
||||
}
|
||||
|
||||
/// A curved segment that follows one circle (a perfect arc): its centre and radius.
|
||||
pub fn arc(doc: &Document, edge: &SegmentRef) -> Option<(Point, f64)> {
|
||||
use vectorcraft_geom::kurbo::ParamCurve;
|
||||
let sp = doc.node(edge.node)?.path_data()?.subpaths.get(edge.subpath)?;
|
||||
if edge.segment >= sp.segment_count() || sp.segment_is_line(edge.segment) {
|
||||
return None;
|
||||
}
|
||||
let seg = sp.segment(edge.segment);
|
||||
let (p, q, r) = (seg.p0, seg.eval(0.5), seg.p3);
|
||||
// The circle through the ends and the middle of the segment.
|
||||
let d = 2.0 * (p.x * (q.y - r.y) + q.x * (r.y - p.y) + r.x * (p.y - q.y));
|
||||
if d.abs() < 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let sq = |a: Point| a.x * a.x + a.y * a.y;
|
||||
let center = Point::new(
|
||||
(sq(p) * (q.y - r.y) + sq(q) * (r.y - p.y) + sq(r) * (p.y - q.y)) / d,
|
||||
(sq(p) * (r.x - q.x) + sq(q) * (p.x - r.x) + sq(r) * (q.x - p.x)) / d,
|
||||
);
|
||||
let radius = center.distance(p);
|
||||
if !radius.is_finite() || !(1e-6..=1e8).contains(&radius) {
|
||||
return None;
|
||||
}
|
||||
for t in [0.125, 0.25, 0.375, 0.625, 0.75, 0.875] {
|
||||
if (seg.eval(t).distance(center) - radius).abs() > radius * 0.002 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some((center, radius))
|
||||
}
|
||||
|
||||
/// The two straight segments that meet at a corner anchor, if both are straight.
|
||||
pub fn corner(doc: &Document, node: NodeId, subpath: usize, anchor: usize) -> Option<(SegmentRef, SegmentRef)> {
|
||||
let sp = doc.node(node)?.path_data()?.subpaths.get(subpath)?;
|
||||
let count = sp.segment_count();
|
||||
let n = sp.anchors.len();
|
||||
if count < 2 || anchor >= n {
|
||||
return None;
|
||||
}
|
||||
// Segment i runs from anchor i to anchor i + 1 (wrapping on closed paths).
|
||||
let before = if anchor > 0 {
|
||||
anchor - 1
|
||||
} else if sp.closed {
|
||||
count - 1
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
let after = anchor;
|
||||
if after >= count {
|
||||
return None;
|
||||
}
|
||||
let first = SegmentRef { node, subpath, segment: before };
|
||||
let second = SegmentRef { node, subpath, segment: after };
|
||||
(first.is_straight(doc) && second.is_straight(doc)).then_some((first, second))
|
||||
}
|
||||
@@ -422,6 +422,32 @@ impl GraphSpec {
|
||||
cells.iter().enumerate().flat_map(|(c, r)| r.iter().enumerate().filter(|(_, v)| v.is_none()).map(move |(s, _)| [c, s])).collect();
|
||||
self.rows = cells.into_iter().map(|r| r.into_iter().map(|v| v.unwrap_or(0.0)).collect()).collect();
|
||||
}
|
||||
|
||||
/// CSV of this graph's data (header row with series names, then category + values), as Graph
|
||||
/// Data and `graph.setData` use. Always writes a header so a blank first category isn't
|
||||
/// misread as one.
|
||||
pub fn to_csv(&self) -> String {
|
||||
let cells = self.cells();
|
||||
let width = cells.iter().map(Vec::len).max().unwrap_or(0).max(self.series.len());
|
||||
let mut out = String::from(",");
|
||||
out.push_str(&(0..width).map(|s| self.series.get(s).cloned().unwrap_or_default()).collect::<Vec<_>>().join(","));
|
||||
out.push('\n');
|
||||
let labelled = !self.categories.is_empty();
|
||||
for (i, r) in cells.iter().enumerate() {
|
||||
if labelled {
|
||||
let c = self.categories.get(i).map(String::as_str).unwrap_or_default();
|
||||
if c.trim().is_empty() || c.trim().parse::<f64>().is_ok() || c != c.trim() {
|
||||
out.push_str(&format!("\"{c}\""));
|
||||
} else {
|
||||
out.push_str(c);
|
||||
}
|
||||
out.push(',');
|
||||
}
|
||||
out.push_str(&r.iter().map(|v| v.map(|v| v.to_string()).unwrap_or_default()).collect::<Vec<_>>().join(","));
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for GraphSpec {
|
||||
|
||||
+160
-3
@@ -145,7 +145,7 @@ fn objects_at_layer(layer: &Node, p: Point, opt: HitOptions, symbols: &SymbolBox
|
||||
let skip = |id| seen.contains(&id);
|
||||
let mut chain = Vec::new();
|
||||
let hit = match &c.kind {
|
||||
NodeKind::Group { .. } => hit_children(c, p, opt, symbols, &skip, &mut chain, true).is_some(),
|
||||
NodeKind::Group { .. } | NodeKind::Intertwine { .. } => hit_children(c, p, opt, symbols, &skip, &mut chain, true).is_some(),
|
||||
NodeKind::Blend { .. } => hit_children(c, p, opt, symbols, &skip, &mut chain, true).is_some() || hit_leaf(c, p, opt, symbols).is_some(),
|
||||
_ => hit_leaf(c, p, opt, symbols).is_some(),
|
||||
};
|
||||
@@ -169,6 +169,80 @@ fn objects_at_repeated(doc: &Document, p: Point, opt: HitOptions, scope: Option<
|
||||
tops
|
||||
}
|
||||
|
||||
/// The most objects [`locked_objects_at`] lists.
|
||||
pub const MAX_LOCKED_AT: usize = 20;
|
||||
|
||||
/// The locked objects under `p`, topmost first, at most [`MAX_LOCKED_AT`]: each object whose own
|
||||
/// lock is on and that a click at `p` would hit if it were not locked. A locked group is listed,
|
||||
/// not what it holds. Hidden objects and everything on hidden, locked and template layers are left
|
||||
/// out. While a pattern is edited only its tile layer is searched, and in isolation mode
|
||||
/// (`opt.scope`) only what is inside the isolated group or object.
|
||||
pub fn locked_objects_at(doc: &Document, p: Point, opt: HitOptions) -> Vec<NodeId> {
|
||||
let symbols = doc.symbol_boxes();
|
||||
let mut out = Vec::new();
|
||||
if let Some(scope) = opt.scope
|
||||
&& let Some(isolated) = doc.node(scope)
|
||||
{
|
||||
if isolated.is_container() {
|
||||
locked_children(isolated, p, opt, &symbols, &mut out);
|
||||
} else if isolated.visible && isolated.locked && locked_hit(isolated, p, opt, &symbols) {
|
||||
out.push(isolated.id);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
for layer in doc.layers.iter().rev() {
|
||||
if !layer.visible || layer.locked || layer.is_template() || doc.pattern_edit.as_ref().is_some_and(|e| e.layer != layer.id) {
|
||||
continue;
|
||||
}
|
||||
let opt = if matches!(layer.kind, NodeKind::Layer { preview: false, .. }) { HitOptions { outline: true, ..opt } } else { opt };
|
||||
locked_children(layer, p, opt, &symbols, &mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// [`locked_objects_at`] through the children of `parent`, front first: a locked child is tested
|
||||
/// whole, and an unlocked layer, group, blend or envelope in Edit Contents is searched inside.
|
||||
fn locked_children(parent: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes, out: &mut Vec<NodeId>) {
|
||||
let Some(children) = parent.children() else { return };
|
||||
if parent.clips() && children.first().is_some_and(|clip| !clip_contains(clip, p)) {
|
||||
return;
|
||||
}
|
||||
for i in hit_order(parent, children, p) {
|
||||
if out.len() >= MAX_LOCKED_AT {
|
||||
return;
|
||||
}
|
||||
let Some(c) = children.get(i) else { continue };
|
||||
if !may_hit(parent, i, c, p, opt, symbols, false) || c.is_layer() && (c.locked || c.is_template()) {
|
||||
continue;
|
||||
}
|
||||
if c.locked {
|
||||
if locked_hit(c, p, opt, symbols) {
|
||||
out.push(c.id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match &c.kind {
|
||||
NodeKind::Layer { preview: false, .. } => locked_children(c, p, HitOptions { outline: true, ..opt }, symbols, out),
|
||||
NodeKind::Layer { .. }
|
||||
| NodeKind::Group { .. }
|
||||
| NodeKind::Intertwine { .. }
|
||||
| NodeKind::Blend { .. }
|
||||
| NodeKind::Envelope { editing: true, .. } => locked_children(c, p, opt, symbols, out),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Would a click at `p` hit the locked object `n` if it were not locked?
|
||||
fn locked_hit(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> bool {
|
||||
let inside = || hit_children(n, p, opt, symbols, &|_| false, &mut Vec::new(), false).is_some();
|
||||
match &n.kind {
|
||||
NodeKind::Group { .. } | NodeKind::Intertwine { .. } | NodeKind::Envelope { editing: true, .. } => inside(),
|
||||
NodeKind::Blend { .. } => inside() || hit_leaf(n, p, opt, symbols).is_some(),
|
||||
_ => hit_leaf(n, p, opt, symbols).is_some(),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`hit_test`] passing over the nodes `skip` names (and their contents). Symbol instances are
|
||||
/// measured by their art ([`Node::geometric_bounds_with`]).
|
||||
fn hit_test_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(NodeId) -> bool, editable_only: bool) -> Option<Hit> {
|
||||
@@ -262,7 +336,8 @@ fn hit_children(
|
||||
{
|
||||
return None;
|
||||
}
|
||||
for (i, c) in children.iter().enumerate().rev() {
|
||||
for i in hit_order(parent, children, p) {
|
||||
let Some(c) = children.get(i) else { continue };
|
||||
if !may_hit(parent, i, c, p, opt, symbols, editable_only) || skip(c.id) {
|
||||
continue;
|
||||
}
|
||||
@@ -270,7 +345,9 @@ fn hit_children(
|
||||
let hit = match &c.kind {
|
||||
// A sublayer whose Preview is off is clicked in outline, like an outline view.
|
||||
NodeKind::Layer { preview: false, .. } => hit_children(c, p, HitOptions { outline: true, ..opt }, symbols, skip, chain, editable_only),
|
||||
NodeKind::Layer { .. } | NodeKind::Group { .. } => hit_children(c, p, opt, symbols, skip, chain, editable_only),
|
||||
NodeKind::Layer { .. } | NodeKind::Group { .. } | NodeKind::Intertwine { .. } => {
|
||||
hit_children(c, p, opt, symbols, skip, chain, editable_only)
|
||||
}
|
||||
// Edit Contents: the envelope's content hits, undistorted.
|
||||
NodeKind::Envelope { editing: true, .. } => hit_children(c, p, opt, symbols, skip, chain, editable_only),
|
||||
// A blend's key objects first (Direct and Group Selection pick them); its steps hit
|
||||
@@ -287,6 +364,23 @@ fn hit_children(
|
||||
None
|
||||
}
|
||||
|
||||
/// The order a click at `p` tries the children of `parent` in, front first: their stacking order
|
||||
/// from the top, except that in an Intertwine group the members drawn over the others at `p`
|
||||
/// come first (from the top among them too).
|
||||
fn hit_order<'a>(parent: &Node, children: &'a [std::sync::Arc<Node>], p: Point) -> impl Iterator<Item = usize> + 'a {
|
||||
let over: Vec<usize> = if matches!(parent.kind, NodeKind::Intertwine { .. }) {
|
||||
let contains = |a: &vectorcraft_geom::PathData| fill_contains(&a.to_bezpath(), vectorcraft_geom::FillRule::NonZero, p);
|
||||
(0..children.len()).rev().filter(|i| children.get(*i).and_then(|c| crate::intertwine::over_area(c)).is_some_and(contains)).collect()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
let rest = (0..children.len()).rev().filter({
|
||||
let over = over.clone();
|
||||
move |i| !over.contains(i)
|
||||
});
|
||||
over.into_iter().chain(rest)
|
||||
}
|
||||
|
||||
/// A click on the strokes of a path or compound path `n` along `bp`: what any visible stroke
|
||||
/// paints there (arrowheads, alignment, width profile, projecting caps, miter spikes), else its
|
||||
/// outline within the tolerance. Outline mode hits the outline only.
|
||||
@@ -881,6 +975,69 @@ world",
|
||||
assert!(hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).is_none());
|
||||
}
|
||||
|
||||
/// Insert `n` locked at the top of `parent`.
|
||||
fn insert_locked(d: &mut Document, parent: NodeId, mut n: Node) -> NodeId {
|
||||
let id = n.id;
|
||||
n.locked = true;
|
||||
d.insert(Some(parent), usize::MAX, n).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
/// `locked_objects_at`: the locked objects under the point, topmost first. A locked group is
|
||||
/// listed rather than its contents, and a locked object in an unlocked group is listed itself.
|
||||
/// Hidden objects, locked layers and the art outside an isolated group are left out.
|
||||
#[test]
|
||||
fn locked_objects_at_lists_the_locked_stack() {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
let rect =
|
||||
|d: &mut Document, x: f64| Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 0.0, x + 50.0, 50.0)), Appearance::default_art());
|
||||
let (a, b) = (rect(&mut d, 0.0), rect(&mut d, 10.0));
|
||||
let (a, b) = (insert_locked(&mut d, l, a), insert_locked(&mut d, l, b));
|
||||
let (mut inner, locked_group) = (rect(&mut d, 20.0), d.alloc_id());
|
||||
inner.locked = true;
|
||||
let inner_id = inner.id;
|
||||
let mut g = Node::group(locked_group, vec![Arc::new(inner)]);
|
||||
g.locked = true;
|
||||
d.insert(Some(l), usize::MAX, g).unwrap();
|
||||
let open_group = d.alloc_id();
|
||||
let mut child = rect(&mut d, 25.0);
|
||||
child.locked = true;
|
||||
let child_id = child.id;
|
||||
let free = rect(&mut d, 25.0);
|
||||
d.insert(Some(l), usize::MAX, Node::group(open_group, vec![Arc::new(child), Arc::new(free)])).unwrap();
|
||||
let at = |d: &Document, x: f64, scope| locked_objects_at(d, Point::new(x, 25.0), HitOptions { scope, ..HitOptions::default() });
|
||||
assert_eq!(at(&d, 30.0, None), [child_id, locked_group, b, a]);
|
||||
assert!(!at(&d, 30.0, None).contains(&inner_id), "the locked group stands for what it holds");
|
||||
assert_eq!(at(&d, 5.0, None), [a]);
|
||||
assert!(at(&d, 150.0, None).is_empty());
|
||||
// Isolated, only the group's own objects are looked at.
|
||||
assert_eq!(at(&d, 30.0, Some(open_group)), [child_id]);
|
||||
// A hidden object is left out, and nothing on a locked or hidden layer is listed.
|
||||
d.node_mut(b).unwrap().visible = false;
|
||||
assert_eq!(at(&d, 30.0, None), [child_id, locked_group, a]);
|
||||
d.node_mut(l).unwrap().locked = true;
|
||||
assert!(at(&d, 30.0, None).is_empty());
|
||||
d.node_mut(l).unwrap().locked = false;
|
||||
d.node_mut(l).unwrap().visible = false;
|
||||
assert!(at(&d, 30.0, None).is_empty());
|
||||
}
|
||||
|
||||
/// `locked_objects_at` lists at most `MAX_LOCKED_AT` objects, the topmost ones.
|
||||
#[test]
|
||||
fn locked_objects_at_stops_at_its_limit() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let l = d.layers[0].id;
|
||||
let ids: Vec<NodeId> = (0..MAX_LOCKED_AT + 5)
|
||||
.map(|_| {
|
||||
let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art());
|
||||
insert_locked(&mut d, l, n)
|
||||
})
|
||||
.collect();
|
||||
let found = locked_objects_at(&d, Point::new(25.0, 25.0), HitOptions::default());
|
||||
assert_eq!(found, ids.iter().rev().take(MAX_LOCKED_AT).copied().collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_sampling_reads_locked_art_and_layers_but_excludes_hidden_art() {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
//! Object › Intertwine: objects that pass over and under each other.
|
||||
//!
|
||||
//! An Intertwine group ([`NodeKind::Intertwine`]) keeps its members as they are, in their own
|
||||
//! stacking order. A member can be drawn over the other members in part of the group: that area
|
||||
//! is the member's [`crate::node::NodeExtras::intertwine`], in document coordinates. The group
|
||||
//! draws (and exports, and is hit) as its live expansion ([`expand`]): the members, then, for
|
||||
//! each member with an area, a copy of it clipped to that area. Where several members are drawn
|
||||
//! over the others in one place, the upper one in the stacking order ends on top.
|
||||
//!
|
||||
//! The members stay editable, and Release gives them back as they were (the areas let go).
|
||||
//! Behaviour: <https://helpx.adobe.com/illustrator/using/intertwine-objects.html>.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_geom::{FillRule, PathData};
|
||||
|
||||
use crate::{Node, NodeId, NodeKind};
|
||||
|
||||
/// The area where `n` is drawn over the other members of its Intertwine group (empty: nowhere).
|
||||
pub fn over_area(n: &Node) -> Option<&PathData> {
|
||||
n.extras().intertwine.as_deref().filter(|a| !a.is_empty())
|
||||
}
|
||||
|
||||
/// The members of an Intertwine group evaluated as plain art: the members (ids kept), then each
|
||||
/// member with an area drawn over the others, as a clip group of that area around a copy of it
|
||||
/// (ids 0: generated art).
|
||||
pub fn expand(children: &[Arc<Node>]) -> Vec<Node> {
|
||||
let mut out: Vec<Node> = children.iter().map(|c| plain(c)).collect();
|
||||
for c in children {
|
||||
let Some(area) = over_area(c) else { continue };
|
||||
if !c.visible {
|
||||
continue;
|
||||
}
|
||||
let mut clip = Node::new(NodeId(0), NodeKind::Path { path: area.clone(), rule: FillRule::NonZero, live: None, clipping: true, guide: false });
|
||||
clip.appearance = crate::Appearance::default().into();
|
||||
let mut copy = plain(c);
|
||||
zero_ids(&mut copy);
|
||||
out.push(Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(copy)], clip: true }));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `n` without its Intertwine area (the expansion is plain art).
|
||||
fn plain(n: &Node) -> Node {
|
||||
let mut n = n.clone();
|
||||
if n.extras().intertwine.is_some() {
|
||||
n.extras_mut().intertwine = None;
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
fn zero_ids(n: &mut Node) {
|
||||
n.id = NodeId(0);
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
zero_ids(Arc::make_mut(c));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use vectorcraft_geom::{Rect, shapes};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn square(id: u64, x: f64) -> Arc<Node> {
|
||||
Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), crate::Appearance::default()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn members_without_areas_expand_to_themselves() {
|
||||
let kids = vec![square(1, 0.0), square(2, 5.0)];
|
||||
let out = expand(&kids);
|
||||
assert_eq!(out.len(), 2);
|
||||
assert_eq!(out.iter().map(|n| n.id).collect::<Vec<_>>(), [NodeId(1), NodeId(2)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_member_with_an_area_is_drawn_again_clipped_to_it_on_top() {
|
||||
let mut a = (*square(1, 0.0)).clone();
|
||||
a.extras_mut().intertwine = Some(Box::new(shapes::rectangle(Rect::new(5.0, 0.0, 10.0, 10.0))));
|
||||
let kids = vec![Arc::new(a), square(2, 5.0)];
|
||||
let out = expand(&kids);
|
||||
assert_eq!(out.len(), 3, "both members, then the raised copy");
|
||||
assert!(out.iter().all(|n| n.extras().intertwine.is_none()), "the expansion is plain art");
|
||||
let NodeKind::Group { children, clip: true } = &out[2].kind else { panic!("a clip group: {:?}", out[2].kind) };
|
||||
assert!(matches!(children[0].kind, NodeKind::Path { clipping: true, .. }));
|
||||
assert_eq!(children[1].path_data(), kids[0].path_data());
|
||||
assert_eq!((out[2].id, children[1].id), (NodeId(0), NodeId(0)), "generated art has no ids of its own");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hidden_member_is_not_drawn_over_the_others() {
|
||||
let mut a = (*square(1, 0.0)).clone();
|
||||
a.visible = false;
|
||||
a.extras_mut().intertwine = Some(Box::new(shapes::rectangle(Rect::new(5.0, 0.0, 10.0, 10.0))));
|
||||
assert_eq!(expand(&[Arc::new(a), square(2, 5.0)]).len(), 2);
|
||||
}
|
||||
}
|
||||
+152
-11
@@ -12,14 +12,20 @@ pub mod clipnest;
|
||||
pub mod cmyk;
|
||||
pub mod corners;
|
||||
pub mod depth;
|
||||
pub mod dimension;
|
||||
pub mod graph;
|
||||
pub mod hit;
|
||||
mod id_index;
|
||||
pub mod inks;
|
||||
mod inline;
|
||||
pub mod intertwine;
|
||||
pub mod shared;
|
||||
mod symbol_export_type;
|
||||
mod symbol_registration;
|
||||
pub use inline::{SYMBOL_HALF, SYMBOL_SIZES};
|
||||
pub use shared::Shared;
|
||||
pub use symbol_export_type::{SYMBOL_EXPORT_TYPE, SymbolExportType};
|
||||
pub use symbol_registration::{SYMBOL_REGISTRATION, SymbolRegistration};
|
||||
pub mod links;
|
||||
pub mod live;
|
||||
pub mod marks;
|
||||
@@ -106,9 +112,9 @@ pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes};
|
||||
pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOptions, SliceSource};
|
||||
pub use style_libs::StyleLibrary;
|
||||
pub use text::{
|
||||
AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, InlineArt, Justify, Kinsoku, LeadingModel,
|
||||
Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef,
|
||||
TextWrap, VerticalAlign, WrapShape,
|
||||
AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt, Justify,
|
||||
Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, TextObject,
|
||||
TextRun, TextStyleDef, TextWrap, VerticalAlign, WrapShape,
|
||||
};
|
||||
pub use trace::TraceView;
|
||||
pub use variables::{DataSet, DataValue, Variable, VariableKind, Variables};
|
||||
@@ -119,6 +125,9 @@ use serde::{Deserialize, Serialize};
|
||||
use vectorcraft_color::{Swatch, SwatchGroup};
|
||||
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
|
||||
|
||||
/// Coordinates beyond this many points exceed the supported document range.
|
||||
pub const MAX_COORD: f64 = 4.0e6;
|
||||
|
||||
#[derive(Debug, thiserror::Error, PartialEq)]
|
||||
pub enum DocError {
|
||||
#[error("no such object {0}")]
|
||||
@@ -232,7 +241,10 @@ impl Unit {
|
||||
/// Parse `12`, `12pt`, `1in`, `3 mm`, `2p6` (picas+points), simple `+ - * /` arithmetic.
|
||||
///
|
||||
/// A unit after a `*` or `/` operand measures the whole expression (`1080/2 px` is 540 px), so
|
||||
/// math typed before a field's unit suffix works.
|
||||
/// math typed before a field's unit suffix works. After `*` or `/`, a percentage scales the term
|
||||
/// before it (`3cm*50%` is 1.5 cm, `3cm/50%` is 6 cm). After `+` or `-`, it adds or takes away
|
||||
/// that share of everything before it (`50pt+25%` is 62.5 pt, `100pt+20pt-50%` is 60 pt). A
|
||||
/// percentage on its own (`50%`) reads as nothing.
|
||||
pub fn parse(self, s: &str) -> Option<f64> {
|
||||
parse_measure(s, self).filter(|v| v.is_finite())
|
||||
}
|
||||
@@ -269,20 +281,41 @@ impl Unit {
|
||||
fn parse_measure(s: &str, default: Unit) -> Option<f64> {
|
||||
let s = s.trim();
|
||||
// Simple arithmetic on the right (Illustrator fields accept "10+5", "100/2", "3in*2").
|
||||
for op in ['+', '-', '*', '/'] {
|
||||
// `+` and `-` share one precedence level, as do `*` and `/`, so each level splits at the last
|
||||
// of its operators (left to right).
|
||||
for ops in [&['+', '-'][..], &['*', '/'][..]] {
|
||||
// Past the first character, so a leading sign isn't an operator.
|
||||
let skip = s.chars().next().map_or(0, char::len_utf8);
|
||||
if let Some(i) = s[skip..].rfind(op).map(|i| i + skip) {
|
||||
// A `+` or `-` right after another operator is the sign of its operand (`10*-2`, `5--3`).
|
||||
let is_sign = |i: usize| {
|
||||
s.get(i..).and_then(|r| r.chars().next()).is_some_and(|c| matches!(c, '+' | '-'))
|
||||
&& s.get(..i).and_then(|l| l.trim_end().chars().last()).is_some_and(|c| "+-*/".contains(c))
|
||||
};
|
||||
let found = s.char_indices().rev().find(|&(i, c)| i >= skip && ops.contains(&c) && !is_sign(i));
|
||||
if let Some(i) = found.map(|(i, _)| i) {
|
||||
let (l, r) = (&s[..i], &s[i + 1..]);
|
||||
if l.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(op) = s[i..].chars().next() else { continue };
|
||||
return match op {
|
||||
'+' => Some(parse_measure(l, default)? + parse_measure(r, default)?),
|
||||
'-' => Some(parse_measure(l, default)? - parse_measure(r, default)?),
|
||||
'+' | '-' => {
|
||||
let a = parse_measure(l, default)?;
|
||||
// A percentage added or taken away is a share of everything before it:
|
||||
// `50pt+25%` is 62.5 pt, `100pt+20%-10pt` is 110 pt.
|
||||
let b = match percent(r) {
|
||||
Some(k) => a * k,
|
||||
None => parse_measure(r, default)?,
|
||||
};
|
||||
Some(if op == '+' { a + b } else { a - b })
|
||||
}
|
||||
_ => {
|
||||
// A factor or divisor is a plain number; a unit after it is the expression's.
|
||||
let (k, unit) = number_unit(r)?;
|
||||
// A factor or divisor is a plain number or a percentage (`3cm*50%` is 1.5 cm);
|
||||
// a unit after it is the expression's.
|
||||
let (k, unit) = match percent(r) {
|
||||
Some(k) => (k, None),
|
||||
None => number_unit(r)?,
|
||||
};
|
||||
let a = parse_measure(l, unit.unwrap_or(default))?;
|
||||
if op == '*' {
|
||||
Some(a * k)
|
||||
@@ -302,7 +335,10 @@ fn parse_measure(s: &str, default: Unit) -> Option<f64> {
|
||||
&& !s.ends_with("pt")
|
||||
&& !s.ends_with("px")
|
||||
{
|
||||
return Some(p.trim().parse::<f64>().ok()? * 12.0 + pt.trim().parse::<f64>().unwrap_or(0.0));
|
||||
// The sign belongs to the whole value: `-2p6` is -(2 picas + 6 points).
|
||||
let sign = if p.trim_start().starts_with('-') { -1.0 } else { 1.0 };
|
||||
let picas = p.trim().parse::<f64>().ok()?.abs();
|
||||
return Some(sign * (picas * 12.0 + pt.trim().parse::<f64>().unwrap_or(0.0)));
|
||||
}
|
||||
let (v, unit) = number_unit(s)?;
|
||||
Some(unit.unwrap_or(default).to_pt(v))
|
||||
@@ -329,6 +365,14 @@ fn number_unit(s: &str) -> Option<(f64, Option<Unit>)> {
|
||||
Some((v, unit))
|
||||
}
|
||||
|
||||
/// A number followed by `%` (`50%`, `12.5 %`) → its fraction (`0.5`, `0.125`).
|
||||
fn percent(s: &str) -> Option<f64> {
|
||||
match number_unit(s.trim().strip_suffix('%')?)? {
|
||||
(p, None) => Some(p / 100.0),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A unitless field value (percent, degrees, counts) with the same `+ - * /` arithmetic as
|
||||
/// [`Unit::parse`] (`45*2`, `100/3`); text other than digits, `.` and operators reads as nothing.
|
||||
pub fn parse_number(s: &str) -> Option<f64> {
|
||||
@@ -686,6 +730,10 @@ pub struct Document {
|
||||
/// state, never saved.
|
||||
#[serde(skip)]
|
||||
pub puppet: Option<Arc<PuppetPins>>,
|
||||
/// Some object is a dimension (the Dimension tool): edits refresh dimensions only while this
|
||||
/// is set, so documents without any pay nothing for them.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub dimensions: bool,
|
||||
/// Where each node is in the tree, for lookups by id ([`id_index`]): a cache, never saved.
|
||||
#[serde(skip)]
|
||||
index: id_index::IdIndex,
|
||||
@@ -706,6 +754,13 @@ type Places = BTreeMap<Vec<usize>, Vec<(usize, usize)>>;
|
||||
pub const FORMAT_VERSION: u32 = 1;
|
||||
|
||||
impl Document {
|
||||
/// 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 {
|
||||
let origin = self.ruler_origin;
|
||||
if origin.is_finite() && origin.x.abs() <= MAX_COORD && origin.y.abs() <= MAX_COORD { origin } else { Point::ZERO }
|
||||
}
|
||||
|
||||
/// A new RGB document with one artboard of `size` and one layer ("Layer 1").
|
||||
pub fn new(width: f64, height: f64) -> Self {
|
||||
Self::new_with_mode(width, height, ColorMode::Rgb)
|
||||
@@ -766,6 +821,7 @@ impl Document {
|
||||
print_setup: None,
|
||||
assets: vec![],
|
||||
puppet: None,
|
||||
dimensions: false,
|
||||
index: Default::default(),
|
||||
};
|
||||
let id = d.alloc_id();
|
||||
@@ -1399,6 +1455,13 @@ impl Document {
|
||||
pub fn reid(&mut self, node: &Node) -> Node {
|
||||
let mut n = node.clone();
|
||||
n.id = self.alloc_id();
|
||||
// A copied annotation must never bind its source ids to unrelated art in a different
|
||||
// document. Keep the measured geometry; new measurements can be linked after pasting.
|
||||
if let Some(e) = n.extras_if_any()
|
||||
&& let Some(dimension) = &mut e.dimension
|
||||
{
|
||||
dimension.source = None;
|
||||
}
|
||||
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();
|
||||
@@ -1493,6 +1556,76 @@ impl Document {
|
||||
}
|
||||
(2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string())
|
||||
}
|
||||
|
||||
/// Symbol Options › Registration for `name` (default: centre).
|
||||
pub fn symbol_registration(&self, name: &str) -> SymbolRegistration {
|
||||
self.unknown
|
||||
.get(SYMBOL_REGISTRATION)
|
||||
.and_then(|m| m.get(name))
|
||||
.and_then(|v| v.as_u64())
|
||||
.and_then(SymbolRegistration::from_u64)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Set or clear (when centre) the registration point stored for `name`.
|
||||
pub fn set_symbol_registration(&mut self, name: &str, reg: SymbolRegistration) {
|
||||
let m = self.unknown.entry(SYMBOL_REGISTRATION.to_string()).or_insert_with(|| serde_json::json!({}));
|
||||
let Some(o) = m.as_object_mut() else { return };
|
||||
if reg.is_center() {
|
||||
o.remove(name);
|
||||
if o.is_empty() {
|
||||
self.unknown.remove(SYMBOL_REGISTRATION);
|
||||
}
|
||||
} else {
|
||||
o.insert(name.into(), serde_json::json!(reg.0));
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the stored registration when a symbol is renamed.
|
||||
pub fn rename_symbol_registration(&mut self, from: &str, to: &str) {
|
||||
if from == to {
|
||||
return;
|
||||
}
|
||||
let reg = self.symbol_registration(from);
|
||||
self.set_symbol_registration(from, SymbolRegistration::CENTER);
|
||||
self.set_symbol_registration(to, reg);
|
||||
}
|
||||
|
||||
/// Symbol Options › Export Type for `name` (default: Movie Clip).
|
||||
pub fn symbol_export_type(&self, name: &str) -> SymbolExportType {
|
||||
self.unknown.get(SYMBOL_EXPORT_TYPE).and_then(|m| m.get(name)).and_then(SymbolExportType::from_value).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Set or clear (when Movie Clip, the default) the export type stored for `name`.
|
||||
pub fn set_symbol_export_type(&mut self, name: &str, kind: SymbolExportType) {
|
||||
if kind.is_default() {
|
||||
self.clear_symbol_export_type(name);
|
||||
return;
|
||||
}
|
||||
let m = self.unknown.entry(SYMBOL_EXPORT_TYPE.to_string()).or_insert_with(|| serde_json::json!({}));
|
||||
let Some(o) = m.as_object_mut() else { return };
|
||||
o.insert(name.into(), serde_json::json!(kind.id()));
|
||||
}
|
||||
|
||||
/// Drop any stored export type for `name` (Movie Clip default).
|
||||
pub fn clear_symbol_export_type(&mut self, name: &str) {
|
||||
let Some(m) = self.unknown.get_mut(SYMBOL_EXPORT_TYPE) else { return };
|
||||
let Some(o) = m.as_object_mut() else { return };
|
||||
o.remove(name);
|
||||
if o.is_empty() {
|
||||
self.unknown.remove(SYMBOL_EXPORT_TYPE);
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the stored export type when a symbol is renamed.
|
||||
pub fn rename_symbol_export_type(&mut self, from: &str, to: &str) {
|
||||
if from == to {
|
||||
return;
|
||||
}
|
||||
let kind = self.symbol_export_type(from);
|
||||
self.clear_symbol_export_type(from);
|
||||
self.set_symbol_export_type(to, kind);
|
||||
}
|
||||
}
|
||||
|
||||
fn default_graphic_styles() -> Vec<GraphicStyle> {
|
||||
@@ -1768,6 +1901,14 @@ mod tests {
|
||||
assert_eq!(Unit::Points.format(12.5), "12.5 pt");
|
||||
assert_eq!(Unit::Inches.format(144.0), "2 in");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_picas_and_points_sign_applies_to_whole_value() {
|
||||
assert_eq!(Unit::Points.parse("-2p6"), Some(-30.0));
|
||||
assert_eq!(Unit::Points.parse("-0p6"), Some(-6.0));
|
||||
assert_eq!(Unit::Points.parse("-2p"), Some(-24.0));
|
||||
assert_eq!(Unit::Points.parse("2p6"), Some(30.0));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+11
-2
@@ -1437,7 +1437,7 @@ fn map_node(n: &Node, w: &Warper) -> Node {
|
||||
}
|
||||
}
|
||||
NodeKind::Mesh(m) => m.map(w.f),
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) => {
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) | NodeKind::Intertwine { .. } => {
|
||||
if let Some(d) = &deeper {
|
||||
return map_node(&expanded_group_hooks(n, w.hooks), d);
|
||||
}
|
||||
@@ -1616,7 +1616,15 @@ pub fn envelope_bounds(content: &[Arc<Node>], kind: &EnvelopeKind, frame: Affine
|
||||
|
||||
/// Is this one of the live kinds?
|
||||
pub fn is_live(n: &Node) -> bool {
|
||||
matches!(n.kind, NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_))
|
||||
matches!(
|
||||
n.kind,
|
||||
NodeKind::Blend { .. }
|
||||
| NodeKind::Envelope { .. }
|
||||
| NodeKind::Mesh(_)
|
||||
| NodeKind::Repeat(_)
|
||||
| NodeKind::PlacedDocument(_)
|
||||
| NodeKind::Intertwine { .. }
|
||||
)
|
||||
}
|
||||
|
||||
/// Mesh tessellation as filled quad paths (no stroke), `n`×`n` per patch.
|
||||
@@ -1651,6 +1659,7 @@ pub fn expand_live_hooks(n: &Node, hooks: Hooks) -> Vec<Node> {
|
||||
NodeKind::Mesh(m) => mesh_quad_nodes(m, 8),
|
||||
NodeKind::Repeat(r) => r.expand(),
|
||||
NodeKind::PlacedDocument(p) => p.art(n),
|
||||
NodeKind::Intertwine { children } => crate::intertwine::expand(children),
|
||||
_ => vec![n.clone()],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,10 +251,14 @@ fn node_bytes(n: &Node, other: Option<&Node>) -> usize {
|
||||
b += ALLOC + size_of::<NodeExtras>();
|
||||
b += boxed(&e.mask) + boxed(&e.graph) + boxed(&e.shaper) + boxed(&e.attrs) + boxed(&e.slice) + boxed(&e.perspective);
|
||||
b += 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 += match &n.kind {
|
||||
NodeKind::Path { path, live, .. } => path_bytes(path) + boxed(live),
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => vec_bytes(children),
|
||||
NodeKind::Layer { children, .. }
|
||||
| NodeKind::Group { children, .. }
|
||||
| NodeKind::Compound { children, .. }
|
||||
| NodeKind::Intertwine { children } => vec_bytes(children),
|
||||
NodeKind::Blend { children, .. } => vec_bytes(children) + ALLOC + size_of::<BlendSpec>(),
|
||||
NodeKind::Envelope { content, .. } => vec_bytes(content) + 2 * ALLOC + size_of::<EnvelopeKind>() + size_of::<Affine>(),
|
||||
NodeKind::Repeat(r) => vec_bytes(&r.source) + OTHER_KIND,
|
||||
|
||||
+120
-5
@@ -3,7 +3,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use vectorcraft_color::BlendMode;
|
||||
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};
|
||||
@@ -486,6 +486,12 @@ pub enum NodeKind {
|
||||
/// A placed document: an artboard of another VectorCraft file, linked and locked (see
|
||||
/// [`crate::placed_document`]).
|
||||
PlacedDocument(Box<crate::placed_document::PlacedDocument>),
|
||||
/// Object › Intertwine: the objects keep their own stacking order, except where one of them
|
||||
/// is drawn over the others: the area [`NodeExtras::intertwine`] of that member (see
|
||||
/// [`crate::intertwine`]).
|
||||
Intertwine {
|
||||
children: Vec<Arc<Node>>,
|
||||
},
|
||||
}
|
||||
|
||||
fn yes() -> bool {
|
||||
@@ -543,6 +549,9 @@ pub struct NodeExtras {
|
||||
/// Index of the graph series this generated group belongs to. Axes, the legend and other
|
||||
/// children leave it unset, so a series named "Legend" or "Axes" is not mistaken for them.
|
||||
pub series_index: Option<u32>,
|
||||
/// The group holds the glyph outlines of type. Geometry effects that reshape it move and scale
|
||||
/// the placed gradients in it with the outlines.
|
||||
pub type_outlines: bool,
|
||||
/// Editable Shaper composition; the original art is retained in its source child.
|
||||
pub shaper: Option<Box<crate::shaper::ShaperSpec>>,
|
||||
/// The [`crate::GraphicStyle::id`] last applied to this object. It stays linked while it keeps
|
||||
@@ -555,6 +564,12 @@ pub struct NodeExtras {
|
||||
pub slice: Option<Box<crate::SliceOptions>>,
|
||||
/// Object › Perspective: the perspective grid plane the object is attached to.
|
||||
pub perspective: Option<Box<crate::PerspectiveAttachment>>,
|
||||
/// Dimension object: the live measurement it draws; its ordinary group children hold its
|
||||
/// exportable appearance (Object › Dimensions, the Dimension tool).
|
||||
pub dimension: Option<Box<crate::dimension::Dimension>>,
|
||||
/// Object › Intertwine: as a member of an Intertwine group, the area (document coordinates,
|
||||
/// filled non-zero) where this object is drawn over the group's other members.
|
||||
pub intertwine: Option<Box<PathData>>,
|
||||
}
|
||||
|
||||
/// No extras: what [`Node::extras`] gives for a node without any.
|
||||
@@ -564,11 +579,14 @@ static NO_EXTRAS: NodeExtras = NodeExtras {
|
||||
wrap: None,
|
||||
graph: None,
|
||||
series_index: None,
|
||||
type_outlines: false,
|
||||
shaper: None,
|
||||
graphic_style: None,
|
||||
attrs: None,
|
||||
slice: None,
|
||||
perspective: None,
|
||||
dimension: None,
|
||||
intertwine: None,
|
||||
};
|
||||
|
||||
/// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal
|
||||
@@ -692,6 +710,7 @@ impl Serialize for Node {
|
||||
e.wrap.is_none(),
|
||||
e.graph.is_none(),
|
||||
e.series_index.is_none(),
|
||||
is_default(&e.type_outlines),
|
||||
e.shaper.is_none(),
|
||||
false,
|
||||
e.graphic_style.is_none(),
|
||||
@@ -699,6 +718,8 @@ impl Serialize for Node {
|
||||
e.slice.is_none(),
|
||||
is_default(&self.bbox_angle),
|
||||
e.perspective.is_none(),
|
||||
e.dimension.is_none(),
|
||||
e.intertwine.is_none(),
|
||||
];
|
||||
let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?;
|
||||
let mut skip = skips.into_iter();
|
||||
@@ -726,6 +747,7 @@ impl Serialize for Node {
|
||||
field!("wrap", &e.wrap);
|
||||
field!("graph", &e.graph);
|
||||
field!("series_index", &e.series_index);
|
||||
field!("type_outlines", &e.type_outlines);
|
||||
field!("shaper", &e.shaper);
|
||||
field!("kind", &self.kind);
|
||||
field!("graphic_style", &e.graphic_style);
|
||||
@@ -733,6 +755,8 @@ impl Serialize for Node {
|
||||
field!("slice", &e.slice);
|
||||
field!("bbox_angle", &self.bbox_angle);
|
||||
field!("perspective", &e.perspective);
|
||||
field!("dimension", &e.dimension);
|
||||
field!("intertwine", &e.intertwine);
|
||||
st.end()
|
||||
}
|
||||
}
|
||||
@@ -772,6 +796,8 @@ struct NodeWire {
|
||||
#[serde(default)]
|
||||
series_index: Option<u32>,
|
||||
#[serde(default)]
|
||||
type_outlines: bool,
|
||||
#[serde(default)]
|
||||
shaper: Option<Box<crate::shaper::ShaperSpec>>,
|
||||
kind: NodeKind,
|
||||
#[serde(default)]
|
||||
@@ -784,6 +810,10 @@ struct NodeWire {
|
||||
bbox_angle: f64,
|
||||
#[serde(default)]
|
||||
perspective: Option<Box<crate::PerspectiveAttachment>>,
|
||||
#[serde(default)]
|
||||
dimension: Option<Box<crate::dimension::Dimension>>,
|
||||
#[serde(default)]
|
||||
intertwine: Option<Box<PathData>>,
|
||||
}
|
||||
|
||||
impl From<NodeWire> for Node {
|
||||
@@ -794,11 +824,14 @@ impl From<NodeWire> for Node {
|
||||
wrap: w.wrap,
|
||||
graph: w.graph,
|
||||
series_index: w.series_index,
|
||||
type_outlines: w.type_outlines,
|
||||
shaper: w.shaper,
|
||||
graphic_style: w.graphic_style,
|
||||
attrs: w.attrs,
|
||||
slice: w.slice,
|
||||
perspective: w.perspective,
|
||||
dimension: w.dimension,
|
||||
intertwine: w.intertwine,
|
||||
};
|
||||
Node {
|
||||
id: w.id,
|
||||
@@ -843,6 +876,38 @@ impl OpacityMask {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every paint of `n` (and its children) white, opacities kept: an alpha mask's art as an opacity
|
||||
/// mask's, whose luminance over the black outside the art is then the art's alpha.
|
||||
pub fn whiten(n: &mut Node) {
|
||||
let white = |p: &mut Paint| match p {
|
||||
Paint::Solid { color, swatch, tint } => {
|
||||
(*color, *swatch, *tint) = (Color::WHITE, None, 1.0);
|
||||
}
|
||||
Paint::Gradient(g) => {
|
||||
for s in &mut g.gradient.stops {
|
||||
(s.color, s.swatch, s.tint) = (Color::WHITE, None, 1.0);
|
||||
}
|
||||
}
|
||||
Paint::None | Paint::Pattern { .. } => {}
|
||||
};
|
||||
for item in &mut n.appearance.items {
|
||||
white(item.paint_mut());
|
||||
}
|
||||
match &mut n.kind {
|
||||
NodeKind::Mesh(m) => m.points.iter_mut().for_each(|p| p.color = Color::WHITE),
|
||||
NodeKind::Text(t) => t.runs.iter_mut().for_each(|r| {
|
||||
white(&mut r.style.fill);
|
||||
white(&mut r.style.stroke);
|
||||
}),
|
||||
_ => {}
|
||||
}
|
||||
if let Some(ch) = n.children_mut() {
|
||||
for c in ch {
|
||||
whiten(Arc::make_mut(c));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Node {
|
||||
pub fn new(id: NodeId, kind: NodeKind) -> Self {
|
||||
Self {
|
||||
@@ -884,7 +949,8 @@ impl Node {
|
||||
| NodeKind::Compound { children, .. }
|
||||
| NodeKind::Blend { children, .. }
|
||||
| NodeKind::Envelope { content: children, .. }
|
||||
| NodeKind::Repeat(RepeatSpec { source: children, .. }) => Some(children),
|
||||
| NodeKind::Repeat(RepeatSpec { source: children, .. })
|
||||
| NodeKind::Intertwine { children } => Some(children),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -895,7 +961,8 @@ impl Node {
|
||||
| NodeKind::Compound { children, .. }
|
||||
| NodeKind::Blend { children, .. }
|
||||
| NodeKind::Envelope { content: children, .. }
|
||||
| NodeKind::Repeat(RepeatSpec { source: children, .. }) => Some(children),
|
||||
| NodeKind::Repeat(RepeatSpec { source: children, .. })
|
||||
| NodeKind::Intertwine { children } => Some(children),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -963,6 +1030,7 @@ impl Node {
|
||||
NodeKind::Mesh(_) => "Mesh",
|
||||
NodeKind::Repeat(r) => r.kind.label(),
|
||||
NodeKind::PlacedDocument(_) => "Placed Document",
|
||||
NodeKind::Intertwine { .. } => "Intertwine",
|
||||
}
|
||||
}
|
||||
/// Name shown in the Layers panel: explicit name or `<Kind>`.
|
||||
@@ -1074,6 +1142,8 @@ impl Node {
|
||||
// copies out by the source's bounds measured without the document.
|
||||
NodeKind::Repeat(r) => r.copies_bounds(union(&r.source)?),
|
||||
NodeKind::PlacedDocument(p) => Some(p.bounds()),
|
||||
// The members drawn over the others are drawn where they are.
|
||||
NodeKind::Intertwine { children } => union(children),
|
||||
}
|
||||
}
|
||||
/// Visual bounds: what the object paints, its strokes included (paths and compound paths
|
||||
@@ -1108,6 +1178,7 @@ impl Node {
|
||||
clip.geometric(symbols)
|
||||
}
|
||||
}
|
||||
NodeKind::Intertwine { children } => children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols))),
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => {
|
||||
// The container's own strokes paint around its members.
|
||||
let o = self.appearance.outset();
|
||||
@@ -1146,6 +1217,13 @@ impl Node {
|
||||
let o = self.appearance.outset() + symbols.map_or(0.0, |s| s.brush_pad(symbol));
|
||||
self.projected(Some(b)).map(|b| b.inflate(o, o))
|
||||
}
|
||||
NodeKind::Text(t) => {
|
||||
// The characters' strokes paint around the glyphs, scaled with the type.
|
||||
let k = Scaling::factor(t.xf).unwrap_or(1.0);
|
||||
let chars = t.runs.iter().map(|r| r.style.stroke_outset()).fold(0.0, f64::max) * k;
|
||||
let o = self.appearance.outset().max(chars);
|
||||
self.geometric(symbols).map(|b| b.inflate(o, o))
|
||||
}
|
||||
_ => {
|
||||
let o = self.appearance.outset();
|
||||
self.geometric(symbols).map(|b| b.inflate(o, o))
|
||||
@@ -1159,6 +1237,12 @@ impl Node {
|
||||
self.transform_scaled(a, &scaling.into());
|
||||
}
|
||||
fn transform_scaled(&mut self, a: Affine, sc: &Scaling) {
|
||||
// A dimension's measurement moves with it.
|
||||
if let Some(e) = self.extras_if_any()
|
||||
&& let Some(dimension) = &mut e.dimension
|
||||
{
|
||||
dimension.transform(a);
|
||||
}
|
||||
if !self.is_layer() {
|
||||
self.bbox_angle = crate::orient::transformed_angle(self.bbox_angle, a);
|
||||
}
|
||||
@@ -1197,7 +1281,10 @@ impl Node {
|
||||
}
|
||||
}
|
||||
}
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => {
|
||||
NodeKind::Layer { children, .. }
|
||||
| NodeKind::Group { children, .. }
|
||||
| NodeKind::Compound { children, .. }
|
||||
| NodeKind::Intertwine { children } => {
|
||||
for c in children.iter_mut() {
|
||||
Arc::make_mut(c).transform_scaled(a, sc);
|
||||
}
|
||||
@@ -1251,6 +1338,10 @@ impl Node {
|
||||
{
|
||||
Arc::make_mut(&mut m.art).transform_scaled(a, sc);
|
||||
}
|
||||
// The area where an Intertwine member is drawn over the others moves with it.
|
||||
if let Some(area) = self.extras_if_any().and_then(|e| e.intertwine.as_mut()) {
|
||||
area.transform(a);
|
||||
}
|
||||
}
|
||||
/// Visit this node and all descendants depth first (paint order).
|
||||
pub fn walk<'a>(&'a self, f: &mut impl FnMut(&'a Node)) {
|
||||
@@ -1379,7 +1470,7 @@ impl Node {
|
||||
let frame = shapes::rectangle(p.natural()).transformed(p.xf);
|
||||
out.push((frame.to_bezpath(), FillRule::NonZero));
|
||||
}
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => {
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::Intertwine { .. } => {
|
||||
crate::live::expand_deep(self, text).push_clip_shapes(text, out);
|
||||
}
|
||||
}
|
||||
@@ -1794,6 +1885,9 @@ mod kind_de {
|
||||
Mesh(GradientMesh),
|
||||
Repeat(RepeatSpec),
|
||||
PlacedDocument(Box<crate::placed_document::PlacedDocument>),
|
||||
Intertwine {
|
||||
children: Vec<Arc<Node>>,
|
||||
},
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for NodeKind {
|
||||
@@ -1923,6 +2017,7 @@ mod tests {
|
||||
e.mask = Some(Box::new(OpacityMask::new(Node::group(NodeId(8), vec![]), true)));
|
||||
e.trace = Some(Box::new(serde_json::json!({"preset": "p"})));
|
||||
e.series_index = Some(2);
|
||||
e.type_outlines = true;
|
||||
e.graphic_style = Some(3);
|
||||
e.attrs = Some(Box::default());
|
||||
e.slice = Some(Box::default());
|
||||
@@ -1961,6 +2056,7 @@ mod tests {
|
||||
"mask",
|
||||
"trace",
|
||||
"series_index",
|
||||
"type_outlines",
|
||||
"kind",
|
||||
"graphic_style",
|
||||
"attrs",
|
||||
@@ -2247,6 +2343,25 @@ mod tests {
|
||||
assert_eq!(p.visual_bounds(), Some(Rect::new(-0.5, -0.5, 10.5, 10.5)));
|
||||
}
|
||||
|
||||
/// Type's visual bounds grow by how far its character strokes paint past the glyphs: half the
|
||||
/// weight centred, the whole weight outside, nothing inside; scaled with the type.
|
||||
#[test]
|
||||
fn type_visual_bounds_grow_with_the_character_stroke() {
|
||||
use crate::appearance::StrokeAlign;
|
||||
use crate::text::{CharStyle, TextObject};
|
||||
let style = CharStyle { stroke: vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), stroke_width: 4.0, ..Default::default() };
|
||||
let mut t = TextObject::point(vectorcraft_geom::Point::new(0.0, 0.0), "Ab", style);
|
||||
t.xf = Affine::scale(2.0) * t.xf;
|
||||
let node = |t: &TextObject| Node::new(NodeId(1), NodeKind::Text(Box::new(t.clone())));
|
||||
let g = node(&t).geometric_bounds().unwrap();
|
||||
for (align, grow) in [(StrokeAlign::Center, 4.0), (StrokeAlign::Outside, 8.0), (StrokeAlign::Inside, 0.0)] {
|
||||
t.runs[0].style.stroke_align = align;
|
||||
assert_eq!(node(&t).visual_bounds(), Some(g.inflate(grow, grow)), "{align:?}");
|
||||
}
|
||||
t.runs[0].style.stroke = vectorcraft_color::Paint::None;
|
||||
assert_eq!(node(&t).visual_bounds(), Some(g), "no stroke, no growth");
|
||||
}
|
||||
|
||||
/// A group holding a path, a compound, an image and a nested group, as a file writes it.
|
||||
fn nested_group() -> Node {
|
||||
let path = |id| Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
|
||||
|
||||
@@ -19,6 +19,11 @@ impl<T> Shared<T> {
|
||||
pub fn ptr_eq(a: &Self, b: &Self) -> bool {
|
||||
Arc::ptr_eq(&a.0, &b.0)
|
||||
}
|
||||
/// A weak reference to the value: it keeps the allocation, so the value's address names it
|
||||
/// while the reference is kept, but not the value.
|
||||
pub fn downgrade(this: &Self) -> std::sync::Weak<T> {
|
||||
Arc::downgrade(&this.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> std::ops::Deref for Shared<T> {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
//! Symbol Options › Export Type: Graphic or Movie Clip
|
||||
//! ([helpx Symbol Options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html)).
|
||||
//!
|
||||
//! For Illustrator-only work the choice has no canvas effect; it is kept for Animate / Flash /
|
||||
//! HTML5 handoff. Illustrator's default is Movie Clip. Stored under [`SYMBOL_EXPORT_TYPE`] keyed
|
||||
//! by symbol name; the default (Movie Clip) is omitted from the map.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
/// Document unknown key: `{ symbolName: "graphic"|"movieClip" }`.
|
||||
pub const SYMBOL_EXPORT_TYPE: &str = "symbolExportType";
|
||||
|
||||
/// Symbol Options › Export Type.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum SymbolExportType {
|
||||
/// Static / graphic symbol instance (Animate Graphic).
|
||||
Graphic,
|
||||
/// Default: Movie Clip for Animate / game handoff; instances still place the same on the canvas.
|
||||
#[default]
|
||||
MovieClip,
|
||||
}
|
||||
|
||||
impl SymbolExportType {
|
||||
pub const IDS: [&'static str; 2] = ["movieClip", "graphic"];
|
||||
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Self::Graphic => "graphic",
|
||||
Self::MovieClip => "movieClip",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_id(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"graphic" => Some(Self::Graphic),
|
||||
"movieClip" => Some(Self::MovieClip),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// True when this is the document default (nothing stored for the symbol).
|
||||
pub fn is_default(self) -> bool {
|
||||
matches!(self, Self::MovieClip)
|
||||
}
|
||||
|
||||
pub fn from_value(v: &Value) -> Option<Self> {
|
||||
v.as_str().and_then(Self::from_id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! Symbol Options › Registration: the 9-point grid (0 = top-left … 4 = centre … 8 = bottom-right)
|
||||
//! that `symbol.place` uses as the point that lands on the click.
|
||||
|
||||
use vectorcraft_geom::Vec2;
|
||||
|
||||
/// Document unknown key: `{ symbolName: 0..8 }` for each symbol's registration point.
|
||||
pub const SYMBOL_REGISTRATION: &str = "symbolRegistration";
|
||||
|
||||
/// Index on the 9-point grid (row-major, 4 = centre).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct SymbolRegistration(pub u8);
|
||||
|
||||
impl Default for SymbolRegistration {
|
||||
fn default() -> Self {
|
||||
Self(4)
|
||||
}
|
||||
}
|
||||
|
||||
impl SymbolRegistration {
|
||||
pub const CENTER: Self = Self(4);
|
||||
|
||||
pub fn from_u64(v: u64) -> Option<Self> {
|
||||
(v <= 8).then_some(Self(v as u8))
|
||||
}
|
||||
|
||||
pub fn is_center(self) -> bool {
|
||||
self.0 == 4
|
||||
}
|
||||
|
||||
/// Offset of this registration point from the symbol's centre, in natural-size units
|
||||
/// (right +, down +).
|
||||
pub fn offset_from_center(self, w: f64, h: f64) -> Vec2 {
|
||||
let (col, row) = (self.0 % 3, self.0 / 3);
|
||||
Vec2::new((f64::from(col) - 1.0) * (w * 0.5), (f64::from(row) - 1.0) * (h * 0.5))
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Units: format/parse round-trips in every unit, typed units winning over the field's unit, names
|
||||
//! and canvas readouts.
|
||||
//! Units: format/parse round-trips in every unit, typed units winning over the field's unit,
|
||||
//! percentages in arithmetic, names and canvas readouts.
|
||||
|
||||
use proptest::prelude::*;
|
||||
|
||||
use super::Unit;
|
||||
|
||||
@@ -34,13 +36,54 @@ fn typed_units_win_over_the_fields_unit() {
|
||||
assert_eq!(Unit::Pixels.parse("-4"), Some(-4.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_percentage_is_a_share_of_the_value_before_it() {
|
||||
let (mm, cm) = (72.0 / 25.4, 72.0 / 2.54);
|
||||
for (u, s, want) in [
|
||||
(Unit::Centimeters, "3cm*50%", 1.5 * cm),
|
||||
(Unit::Points, "50pt+25%", 62.5),
|
||||
(Unit::Points, "50pt-25%", 37.5),
|
||||
(Unit::Millimeters, "10*50%", 5.0 * mm),
|
||||
(Unit::Centimeters, "3cm/50%", 6.0 * cm),
|
||||
(Unit::Points, "50 pt + 25 %", 62.5),
|
||||
(Unit::Points, "10+20+50%", 45.0),
|
||||
// A percentage added or taken away is a share of everything before it. A percentage factor
|
||||
// is a share of the term before it.
|
||||
(Unit::Points, "100pt+20pt-50%", 60.0),
|
||||
(Unit::Points, "100pt-20pt+50%", 120.0),
|
||||
(Unit::Points, "100pt+20%-10pt", 110.0),
|
||||
(Unit::Points, "10+20*50%", 20.0),
|
||||
(Unit::Points, "10*2+50%", 30.0),
|
||||
(Unit::Points, "10*2/50%", 40.0),
|
||||
(Unit::Points, "10+-50%", 5.0),
|
||||
(Unit::Points, "50pt--25%", 62.5),
|
||||
(Unit::Centimeters, "3cm*-50%", -1.5 * cm),
|
||||
] {
|
||||
let got = u.parse(s).unwrap_or_else(|| panic!("{u:?} can't read `{s}`"));
|
||||
assert!((got - want).abs() < 1e-9, "{u:?}: `{s}` → {got}, want {want}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn odd_text_reads_as_nothing_without_panicking() {
|
||||
for s in ["", "é", "é5", "°+1", "×2", "5 ×", "—3", "mm", "1/0", "--", "+"] {
|
||||
for s in ["", "é", "é5", "°+1", "×2", "5 ×", "—3", "mm", "1/0", "--", "+", "50%", "%", "5%%", "%5", "1/0%", "10+%", "10*5pt%"] {
|
||||
assert_eq!(Unit::Millimeters.parse(s), None, "{s}");
|
||||
}
|
||||
}
|
||||
|
||||
proptest! {
|
||||
/// Field text made of digits, operators, `%`, spaces and unit letters reads as a finite value
|
||||
/// or as nothing and does not panic.
|
||||
#[test]
|
||||
fn arithmetic_text_reads_as_a_finite_value_or_nothing(s in "[0-9.+*/% ptxinmcfyd-]{0,24}") {
|
||||
for u in Unit::ALL {
|
||||
if let Some(v) = u.parse(&s) {
|
||||
prop_assert!(v.is_finite(), "{u:?}: `{s}` → {v}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn units_are_named_by_label_key_or_suffix() {
|
||||
for u in Unit::ALL {
|
||||
@@ -66,3 +109,23 @@ fn readouts_show_two_decimals() {
|
||||
assert_eq!((Unit::Inches.number(595.2756), Unit::Points.number(595.2756)), ("8.2677".into(), "595.276".into()));
|
||||
assert_eq!(Unit::Points.number(-0.00001), "0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_negative_operand_after_an_operator_is_a_sign() {
|
||||
assert_eq!(Unit::Points.parse("10*-2"), Some(-20.0));
|
||||
assert_eq!(Unit::Points.parse("-10/-2"), Some(5.0));
|
||||
assert_eq!(Unit::Points.parse("5--3"), Some(8.0));
|
||||
assert_eq!(Unit::Points.parse("5 - -3"), Some(8.0));
|
||||
assert_eq!(Unit::Points.parse("5+-3"), Some(2.0));
|
||||
assert_eq!(Unit::Points.parse("10-2"), Some(8.0));
|
||||
assert_eq!(super::parse_number("90*-1"), Some(-90.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_mul_div() {
|
||||
// `*` and `/` share one precedence level and run left to right.
|
||||
assert_eq!(Unit::Points.parse("10/2*5"), Some(25.0));
|
||||
assert_eq!(crate::parse_number("100*3/4"), Some(75.0));
|
||||
assert_eq!(Unit::Points.parse("10*2/5"), Some(4.0));
|
||||
assert_eq!(Unit::Points.parse("10*2/5 px"), Some(Unit::Pixels.to_pt(4.0)));
|
||||
}
|
||||
+79
-5
@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_geom::{Affine, PathData, Point, Rect, Vec2};
|
||||
|
||||
use crate::appearance::{Appearance, AppearanceItem, Dash, FillLayer, LineCap, LineJoin, StrokeLayer};
|
||||
use crate::appearance::{Appearance, AppearanceItem, Dash, FillLayer, LineCap, LineJoin, StrokeAlign, StrokeLayer};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Justify {
|
||||
@@ -84,6 +84,10 @@ pub struct CharStyle {
|
||||
pub stroke_miter_limit: f64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub stroke_dash: Option<Dash>,
|
||||
/// Align Stroke of the character stroke (Stroke panel, with type selected): centred on the
|
||||
/// glyph outlines, inside or outside them, as for object strokes; the type stays live.
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub stroke_align: StrokeAlign,
|
||||
/// Superscript or subscript (Character panel).
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub position: CharPosition,
|
||||
@@ -249,6 +253,7 @@ impl Default for CharStyle {
|
||||
stroke_join: LineJoin::Miter,
|
||||
stroke_miter_limit: 10.0,
|
||||
stroke_dash: None,
|
||||
stroke_align: StrokeAlign::Center,
|
||||
position: CharPosition::Normal,
|
||||
char_align: CharAlign::RomanBaseline,
|
||||
proportional_metrics: false,
|
||||
@@ -266,8 +271,9 @@ impl CharStyle {
|
||||
pub fn has_stroke(&self) -> bool {
|
||||
!self.stroke.is_none() && self.stroke_width > 0.0
|
||||
}
|
||||
/// The character stroke as a stroke layer (paint, weight, cap, join, miter limit, dashes and
|
||||
/// overprint), so type strokes share the object strokes' geometry, rendering and export.
|
||||
/// The character stroke as a stroke layer (paint, weight, cap, join, miter limit, dashes,
|
||||
/// alignment and overprint), so type strokes share the object strokes' geometry, rendering
|
||||
/// and export.
|
||||
pub fn stroke_layer(&self) -> StrokeLayer {
|
||||
StrokeLayer {
|
||||
overprint: self.overprint_stroke,
|
||||
@@ -275,11 +281,13 @@ impl CharStyle {
|
||||
join: self.stroke_join,
|
||||
miter_limit: self.stroke_miter_limit,
|
||||
dash: self.stroke_dash.clone(),
|
||||
align: self.stroke_align,
|
||||
..StrokeLayer::new(self.stroke.clone(), self.stroke_width)
|
||||
}
|
||||
}
|
||||
/// Take the paint, weight, cap, join, miter limit and dashes of `st` as the character stroke
|
||||
/// (the options characters have no use for, such as alignment and arrowheads, are dropped).
|
||||
/// Take the paint, weight, cap, join, miter limit, dashes and alignment of `st` as the
|
||||
/// character stroke (the options characters have no use for, such as arrowheads and width
|
||||
/// profiles, are dropped).
|
||||
pub fn set_stroke_layer(&mut self, st: &StrokeLayer) {
|
||||
self.stroke = st.paint.clone();
|
||||
self.stroke_width = st.width;
|
||||
@@ -287,6 +295,19 @@ impl CharStyle {
|
||||
self.stroke_join = st.join;
|
||||
self.stroke_miter_limit = st.miter_limit;
|
||||
self.stroke_dash = st.dash.clone();
|
||||
self.stroke_align = st.align;
|
||||
}
|
||||
/// How far the character stroke paints past the glyph outlines (text space): half the weight
|
||||
/// centred, the whole weight outside, nothing inside (miter spikes aside).
|
||||
pub fn stroke_outset(&self) -> f64 {
|
||||
if !self.has_stroke() {
|
||||
return 0.0;
|
||||
}
|
||||
match self.stroke_align {
|
||||
StrokeAlign::Center => self.stroke_width / 2.0,
|
||||
StrokeAlign::Outside => self.stroke_width,
|
||||
StrokeAlign::Inside => 0.0,
|
||||
}
|
||||
}
|
||||
/// The character fill as a fill layer, overprinting as the characters do.
|
||||
fn fill_layer(&self) -> FillLayer {
|
||||
@@ -360,6 +381,37 @@ impl Composer {
|
||||
}
|
||||
}
|
||||
|
||||
/// A paragraph's hyphenation options (Paragraph panel menu › Hyphenation…): which words automatic
|
||||
/// hyphenation may break, where, and on how many lines in a row. Saved only when they differ from
|
||||
/// the defaults: words longer than 5 letters, 3 letters on each side of the hyphen, no hyphen
|
||||
/// limit, no hyphenation zone, and capitalized words hyphenated.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct HyphenationOptions {
|
||||
/// Words Longer Than: only words with more letters than this are hyphenated.
|
||||
pub words_longer_than: u32,
|
||||
/// After First: the fewest letters a word keeps before the hyphen.
|
||||
pub after_first: u32,
|
||||
/// Before Last: the fewest letters a word keeps after the hyphen.
|
||||
pub before_last: u32,
|
||||
/// Hyphen Limit: the most consecutive lines that automatic hyphenation may end with a hyphen;
|
||||
/// 0 sets no limit.
|
||||
pub hyphen_limit: u32,
|
||||
/// Hyphenation Zone (pt): with the Single-line Composer, a line whose text before the next
|
||||
/// word already ends within this distance of its right edge breaks before that word instead
|
||||
/// of hyphenating it. 0 allows every hyphenation.
|
||||
pub zone: f64,
|
||||
/// Hyphenate Capitalized Words: when it is off, words that start with a capital letter are not
|
||||
/// hyphenated. Words in capitals only are never hyphenated.
|
||||
pub capitalized: bool,
|
||||
}
|
||||
|
||||
impl Default for HyphenationOptions {
|
||||
fn default() -> Self {
|
||||
Self { words_longer_than: 5, after_first: 3, before_last: 3, hyphen_limit: 0, zone: 0.0, capitalized: true }
|
||||
}
|
||||
}
|
||||
|
||||
/// Paragraph attributes (the Paragraph panel).
|
||||
///
|
||||
/// Saved through [`ParaStyleFile`], which keeps files with text openable by builds from before
|
||||
@@ -374,6 +426,8 @@ pub struct ParaStyle {
|
||||
pub space_before: f64,
|
||||
pub space_after: f64,
|
||||
pub hyphenate: bool,
|
||||
/// The limits automatic hyphenation keeps to (Paragraph panel menu › Hyphenation…).
|
||||
pub hyphenation: HyphenationOptions,
|
||||
/// Line breaking: Single-line or Every-line Composer.
|
||||
pub composer: Composer,
|
||||
/// Tab stops (Tabs panel), sorted by position.
|
||||
@@ -421,6 +475,8 @@ struct ParaStyleFile {
|
||||
space_after: f64,
|
||||
#[serde(default)]
|
||||
hyphenate: bool,
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
hyphenation: HyphenationOptions,
|
||||
#[serde(default, skip_serializing_if = "Composer::is_default")]
|
||||
composer: Composer,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
@@ -454,6 +510,7 @@ impl From<ParaStyle> for ParaStyleFile {
|
||||
space_before,
|
||||
space_after,
|
||||
hyphenate,
|
||||
hyphenation,
|
||||
composer,
|
||||
tabs,
|
||||
style_name,
|
||||
@@ -473,6 +530,7 @@ impl From<ParaStyle> for ParaStyleFile {
|
||||
space_before,
|
||||
space_after,
|
||||
hyphenate,
|
||||
hyphenation,
|
||||
composer,
|
||||
tabs,
|
||||
style_name,
|
||||
@@ -495,6 +553,7 @@ impl From<ParaStyleFile> for ParaStyle {
|
||||
space_before,
|
||||
space_after,
|
||||
hyphenate,
|
||||
hyphenation,
|
||||
composer,
|
||||
tabs,
|
||||
style_name,
|
||||
@@ -513,6 +572,7 @@ impl From<ParaStyleFile> for ParaStyle {
|
||||
space_before,
|
||||
space_after,
|
||||
hyphenate,
|
||||
hyphenation,
|
||||
composer,
|
||||
tabs,
|
||||
style_name,
|
||||
@@ -734,7 +794,9 @@ impl PathAlign {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct AreaOptions {
|
||||
#[serde(deserialize_with = "de_rows_or_columns")]
|
||||
pub rows: usize,
|
||||
#[serde(deserialize_with = "de_rows_or_columns")]
|
||||
pub columns: usize,
|
||||
/// Gutter between rows/columns in points.
|
||||
pub gutter: f64,
|
||||
@@ -765,6 +827,18 @@ impl Default for AreaOptions {
|
||||
}
|
||||
}
|
||||
|
||||
impl AreaOptions {
|
||||
/// The most rows, and the most columns, area type can have.
|
||||
pub const MAX_ROWS_COLUMNS: usize = 100;
|
||||
}
|
||||
|
||||
/// [`AreaOptions::rows`] or [`AreaOptions::columns`], read as a `u64` and clamped to
|
||||
/// 1..=[`AreaOptions::MAX_ROWS_COLUMNS`].
|
||||
fn de_rows_or_columns<'de, D: serde::Deserializer<'de>>(d: D) -> Result<usize, D::Error> {
|
||||
let n = usize::try_from(u64::deserialize(d)?).unwrap_or(usize::MAX);
|
||||
Ok(n.clamp(1, AreaOptions::MAX_ROWS_COLUMNS))
|
||||
}
|
||||
|
||||
/// How area type and its frame fit each other. Serialized as `"none"`, `"autoHeight"` or
|
||||
/// `{"shrinkText": {"minPercent": 50}}`.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
+35
-12
@@ -17,15 +17,21 @@ pub enum VariableKind {
|
||||
Text,
|
||||
/// Whether bound objects show.
|
||||
Visibility,
|
||||
/// A linked image's file path (the pixels swap; the object's bounds stay).
|
||||
Image,
|
||||
/// A graph object's data (CSV of categories and series, as Graph Data uses).
|
||||
Graph,
|
||||
}
|
||||
|
||||
impl VariableKind {
|
||||
/// Parse the `kind` of `variable.define`: `text` or `visibility` (`visible` is accepted
|
||||
/// as the same thing, as the panel and the docs call it both).
|
||||
/// Parse the `kind` of `variable.define`: `text`, `visibility` (`visible` is accepted
|
||||
/// as the same thing, as the panel and the docs call it both), `image` or `graph`.
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"text" => Some(Self::Text),
|
||||
"visibility" | "visible" => Some(Self::Visibility),
|
||||
"image" => Some(Self::Image),
|
||||
"graph" => Some(Self::Graph),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -35,6 +41,8 @@ impl VariableKind {
|
||||
match self {
|
||||
Self::Text => "text",
|
||||
Self::Visibility => "visibility",
|
||||
Self::Image => "image",
|
||||
Self::Graph => "graph",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,13 +60,23 @@ pub struct Variable {
|
||||
pub enum DataValue {
|
||||
Text(String),
|
||||
Visible(bool),
|
||||
/// Absolute path of the image file an image variable should show.
|
||||
Image(String),
|
||||
/// CSV of a graph's categories and series (same shape as Graph Data / `graph.setData`).
|
||||
Graph(String),
|
||||
}
|
||||
|
||||
impl DataValue {
|
||||
/// Whether this value fits the variable's kind (a dataset may carry values for
|
||||
/// variables of either kind; each binding takes what fits it).
|
||||
pub fn fits(&self, kind: VariableKind) -> bool {
|
||||
matches!((self, kind), (Self::Text(_), VariableKind::Text) | (Self::Visible(_), VariableKind::Visibility))
|
||||
matches!(
|
||||
(self, kind),
|
||||
(Self::Text(_), VariableKind::Text)
|
||||
| (Self::Visible(_), VariableKind::Visibility)
|
||||
| (Self::Image(_), VariableKind::Image)
|
||||
| (Self::Graph(_), VariableKind::Graph)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,9 +102,10 @@ impl DataSet {
|
||||
///
|
||||
/// An object may be driven by several variables — the reference app binds the selected object
|
||||
/// to the selected variable, one click at a time, and never says a second binding replaces the
|
||||
/// first — so a binding is a set. `dataset.select` applies them in the order the variables are
|
||||
/// defined, which is what makes two text variables on one object deterministic: the last one
|
||||
/// wins, and `applied` counts both.
|
||||
/// first — so a binding is a set. `dataset.select` applies the text values in the order the
|
||||
/// variables are defined, which is what makes two text variables on one object deterministic:
|
||||
/// the last one wins, and `applied` counts both. Of two visibility variables on one object, the
|
||||
/// last one defined wins too.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Variables {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
@@ -173,9 +192,10 @@ impl Document {
|
||||
self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| !n.locked && (n.visible || *i == id))))
|
||||
}
|
||||
|
||||
/// What the object `id` holds for the variable of `kind`: its characters, or whether it
|
||||
/// shows. `None` when the object is gone, is not of that kind, or is locked or on a hidden
|
||||
/// or locked layer (the document's value is whatever it was left at).
|
||||
/// What the object `id` holds for the variable of `kind`: its characters, whether it shows,
|
||||
/// or a linked image's path. `None` when the object is gone, is not of that kind, has no
|
||||
/// link path yet (an embedded image), or is locked or on a hidden or locked layer (the
|
||||
/// document's value is whatever it was left at).
|
||||
pub fn variable_value(&self, id: NodeId, kind: VariableKind) -> Option<DataValue> {
|
||||
if !self.variable_target(id) {
|
||||
return None;
|
||||
@@ -184,14 +204,17 @@ impl Document {
|
||||
match (kind, &node.kind) {
|
||||
(VariableKind::Text, NodeKind::Text(t)) => Some(DataValue::Text(t.plain_text())),
|
||||
(VariableKind::Visibility, _) => Some(DataValue::Visible(node.visible)),
|
||||
(VariableKind::Text, _) => None,
|
||||
(VariableKind::Image, NodeKind::Image(im)) => im.link.as_ref().map(|l| DataValue::Image(l.path.clone())),
|
||||
(VariableKind::Graph, _) => node.extras().graph.as_ref().map(|g| DataValue::Graph(g.to_csv())),
|
||||
(VariableKind::Text | VariableKind::Image, _) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The values a dataset captured right now: what each bound object holds, in the order the
|
||||
/// variables are defined. `dataset.capture` writes these into a new row and
|
||||
/// `dataset.update` into the active one; the Variables panel compares the active row with
|
||||
/// them to tell whether the art still matches what it says.
|
||||
/// `dataset.update` into the active one, for the variables that row holds (`all: true` adds
|
||||
/// the rest); the Variables panel compares the active row with them to tell whether the art
|
||||
/// still matches what it says.
|
||||
pub fn captured_values(&self) -> BTreeMap<String, DataValue> {
|
||||
let mut out = BTreeMap::new();
|
||||
for v in &self.variables.variables {
|
||||
|
||||
@@ -15,6 +15,7 @@ vectorcraft-doc = { workspace = true }
|
||||
vectorcraft-pathops = { workspace = true }
|
||||
vectorcraft-text = { workspace = true }
|
||||
vectorcraft-plugins = { workspace = true }
|
||||
image = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
|
||||
+534
-10
@@ -9,18 +9,26 @@
|
||||
//! Colour adjustments recolour the objects ([`adjust_in_document`]; recoloured embedded images
|
||||
//! become images of their own).
|
||||
//!
|
||||
//! Type that Transform effects on it, or on its group or layer, only move and copy stays type in
|
||||
//! each place when its characters are in solid colors and the copies do not overlap ([`moved_type`]
|
||||
//! and [`container_keeping_type`] list the other conditions). The renderer still draws its outlines.
|
||||
//!
|
||||
//! Object → Expand Appearance goes further ([`expand_leaf`]): every fill and stroke becomes an
|
||||
//! object of its own, strokes turned into filled art by the caller (their outlines or brush art).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_geom::{FillRule, PathData};
|
||||
use vectorcraft_doc::color::Paint;
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, Knockout, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_geom::{Affine, FillRule, PathData, Rect, Vec2};
|
||||
|
||||
use crate::distort::{transform_move, transform_offsets};
|
||||
use crate::group::{has_own_paint, paints};
|
||||
use crate::reshape::{as_art, geometry_effects, glyph_outlines, outline_nested_text, reshape_leaves};
|
||||
use crate::{
|
||||
GeomContext, adjust_in_document, apply_geometry_with, crop_marks_art, evaluate_container, has_adjustment, has_crop_marks, has_geometry,
|
||||
has_pathfinder, is_geometry, needs_outline, reshape,
|
||||
has_item_geometry, has_pathfinder, is_geometry, is_pathfinder, merged_params, needs_outline, outline_text, reshape,
|
||||
};
|
||||
|
||||
fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] {
|
||||
@@ -98,8 +106,13 @@ 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.
|
||||
if needs_outline(n) && (has_geometry(&n.appearance.effects) || n.projection().is_some()) {
|
||||
// Type, images, symbol instances and live objects: reshaped through their outlines, and so is
|
||||
// type whose own fills or strokes have geometry effects ([`has_item_geometry`]). Type that
|
||||
// Transform effects only move and copy stays type, moved to each place.
|
||||
if needs_outline(n) && (has_geometry(&n.appearance.effects) || n.projection().is_some() || has_item_geometry(n)) {
|
||||
if let Some(m) = moved_type_group(n) {
|
||||
return Some(bake_pieces(d, m));
|
||||
}
|
||||
let symbol = match &n.kind {
|
||||
NodeKind::SymbolInstance { symbol, .. } => d.symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()),
|
||||
_ => None,
|
||||
@@ -107,9 +120,10 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
|
||||
let m = reshape(n, symbol.as_deref())?;
|
||||
return Some(bake_node(d, &m).unwrap_or(m));
|
||||
}
|
||||
// Groups and layers: Pathfinder, geometry effects and their own fills and strokes. The new
|
||||
// pieces (results, outlines, the fills' and strokes' art) get ids of their own.
|
||||
if let Some(m) = evaluate_container(n) {
|
||||
// Groups and layers: Pathfinder, geometry effects and their own fills and strokes (type that
|
||||
// Transform effects only move stays type). The new pieces (results, outlines, copies, the
|
||||
// fills' and strokes' art) get ids of their own.
|
||||
if let Some(m) = container_keeping_type(n).or_else(|| evaluate_container(n)) {
|
||||
return Some(bake_pieces(d, m));
|
||||
}
|
||||
if let Some(ch) = n.children() {
|
||||
@@ -159,6 +173,201 @@ pub fn fresh_ids(d: &mut Document, n: &mut Node, seen: &mut std::collections::Ha
|
||||
}
|
||||
}
|
||||
|
||||
/// Most copies of one type object that stay type, the original included ([`moved_type`]).
|
||||
const MAX_TYPE_COPIES: usize = 10_000;
|
||||
/// Most bytes that the copies of one type object that stay type take together in the document's
|
||||
/// JSON form, the original included ([`moved_type`]): its text, frame or path, wrap shapes and the
|
||||
/// rest of the object, once per copy.
|
||||
const MAX_TYPE_COPY_BYTES: usize = 20_000_000;
|
||||
|
||||
/// The params of geometry effects `fx` when there are some and they are all Transform effects that
|
||||
/// only move ([`transform_move`]), in order; `None` otherwise.
|
||||
fn transform_moves(fx: &[&Effect]) -> Option<Vec<serde_json::Value>> {
|
||||
if fx.is_empty() {
|
||||
return None;
|
||||
}
|
||||
fx.iter().map(|e| (e.id == "distort.transform").then(|| merged_params(&e.id, &e.params)).filter(|p| transform_move(p).is_some())).collect()
|
||||
}
|
||||
|
||||
/// Type `n` in each place that Transform effects that only move ([`transform_moves`]) copy its
|
||||
/// glyph outlines to: `n` moved there, the original's place first. `None` for other objects; for
|
||||
/// type that is hidden, in perspective, has effects, transparency, an opacity mask or fills and
|
||||
/// strokes of its own (beyond its characters'), has characters painted with a gradient or pattern,
|
||||
/// underlined or struck through, has character strokes and is scaled or sheared, holds inline
|
||||
/// graphics or has no glyphs; when the effects would stop copies of its outlines early
|
||||
/// ([`transform_offsets`]); when the copies would pass [`MAX_TYPE_COPIES`] or
|
||||
/// [`MAX_TYPE_COPY_BYTES`]; and when the copies overlap: what one copy paints (its
|
||||
/// character strokes included) reaches into another's box.
|
||||
fn moved_type(n: &Node, moves: &[serde_json::Value]) -> Option<Vec<Node>> {
|
||||
let NodeKind::Text(t) = &n.kind else { return None };
|
||||
let flat = |p: &Paint| matches!(p, Paint::None | Paint::Solid { .. });
|
||||
let [a, b, c, d, _, _] = t.xf.as_coeffs();
|
||||
let rigid = (a * a + b * b - 1.0).abs() < 1e-9 && (c * c + d * d - 1.0).abs() < 1e-9 && (a * c + b * d).abs() < 1e-9;
|
||||
let stroked = t.runs.iter().any(|r| r.style.has_stroke());
|
||||
if !n.visible
|
||||
|| (stroked && !rigid)
|
||||
|| n.projection().is_some()
|
||||
|| !n.has_default_transparency()
|
||||
|| !n.appearance.effects.is_empty()
|
||||
|| n.appearance.items.iter().any(|i| paints(i) || !item_effects(i).is_empty())
|
||||
|| t.runs.iter().any(|r| r.inline.is_some() || r.style.underline || r.style.strikethrough || !flat(&r.style.fill) || !flat(&r.style.stroke))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let (_, all) = glyph_outlines(t);
|
||||
let outline = PathData::from_bezpath(&all).transformed(t.xf);
|
||||
let ink = outline.bounds()?;
|
||||
let offsets = transform_offsets(moves, &outline)?;
|
||||
if offsets.len() > MAX_TYPE_COPIES {
|
||||
return None;
|
||||
}
|
||||
let size = serde_json::to_vec(n).ok()?.len();
|
||||
if size.saturating_mul(offsets.len()) > MAX_TYPE_COPY_BYTES {
|
||||
return None;
|
||||
}
|
||||
let strokes = t.runs.iter().map(|r| r.style.appearance().outset()).fold(0.0, f64::max);
|
||||
if !apart(&offsets, ink.inflate(strokes, strokes)) {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
offsets
|
||||
.into_iter()
|
||||
.map(|o| {
|
||||
let mut c = n.clone();
|
||||
c.transform(Affine::translate(o), false);
|
||||
c
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Do the copies of box `b` moved by each of `offsets` stay clear of each other? Copies may touch.
|
||||
/// False as well when an offset is 10^15 or more times the box's width or height.
|
||||
fn apart(offsets: &[Vec2], b: Rect) -> bool {
|
||||
let (w, h) = (b.width(), b.height());
|
||||
if offsets.len() < 2 {
|
||||
return true;
|
||||
}
|
||||
if !(w > 0.0 && h > 0.0 && w.is_finite() && h.is_finite()) {
|
||||
return false;
|
||||
}
|
||||
// Copies closer than `w` across and `h` down lie in the same or a neighboring cell.
|
||||
let cell = |o: Vec2| {
|
||||
let (x, y) = ((o.x / w).floor(), (o.y / h).floor());
|
||||
(x.abs() < 1e15 && y.abs() < 1e15).then_some((x as i64, y as i64))
|
||||
};
|
||||
let mut cells: HashMap<(i64, i64), Vec2> = HashMap::new();
|
||||
for &o in offsets {
|
||||
let Some((x, y)) = cell(o) else { return false };
|
||||
for dx in -1..=1 {
|
||||
for dy in -1..=1 {
|
||||
if let Some(p) = cells.get(&(x + dx, y + dy))
|
||||
&& (p.x - o.x).abs() < w
|
||||
&& (p.y - o.y).abs() < h
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
cells.insert((x, y), o);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Type `n` whose own geometry effects only move it ([`transform_moves`]) as a group of the moved
|
||||
/// type ([`moved_type`]). The group keeps `n`'s id, name, visibility, transparency, opacity mask and
|
||||
/// remaining (raster) effects, and the type in it has none of them. `None` otherwise.
|
||||
fn moved_type_group(n: &Node) -> Option<Node> {
|
||||
if !matches!(n.kind, NodeKind::Text(_)) {
|
||||
return None;
|
||||
}
|
||||
let moves = transform_moves(&geometry_effects(n))?;
|
||||
let mut plain = n.clone();
|
||||
plain.visible = true;
|
||||
plain.opacity = 1.0;
|
||||
plain.blend = Default::default();
|
||||
plain.isolate = false;
|
||||
plain.knockout_shape = false;
|
||||
plain.extras_mut().mask = None;
|
||||
plain.appearance.effects.clear();
|
||||
let copies = moved_type(&plain, &moves)?;
|
||||
let mut g = Node::group(n.id, copies.into_iter().map(Arc::new).collect());
|
||||
g.name = n.name.clone();
|
||||
g.visible = n.visible;
|
||||
g.opacity = n.opacity;
|
||||
g.blend = n.blend;
|
||||
g.isolate = n.isolate;
|
||||
// One element: its pieces never knock each other out.
|
||||
g.knockout = Knockout::Off;
|
||||
g.knockout_shape = n.knockout_shape;
|
||||
g.extras_mut().mask = n.extras().mask.clone();
|
||||
g.appearance.effects = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect();
|
||||
Some(g)
|
||||
}
|
||||
|
||||
/// `n`, a group or layer without Pathfinder effects or fills and strokes that paint, whose geometry
|
||||
/// effects only move its members ([`transform_moves`]), as plain art in which the type it holds
|
||||
/// stays type: each type object moved, or replaced by a group of its copies ([`moved_type`]).
|
||||
/// Type in a clipping path, other type that [`moved_type`] doesn't copy, and type inside live
|
||||
/// objects or inside groups with Pathfinder or geometry effects or fills and strokes that paint, is
|
||||
/// outlined as [`evaluate_container`] outlines it, and the other members become what it makes of
|
||||
/// them. `None` when no type stays type.
|
||||
fn container_keeping_type(n: &Node) -> Option<Node> {
|
||||
fn keep(n: &mut Node, moves: &[serde_json::Value], clipping: bool, kept: &mut bool) {
|
||||
let clips = n.clips();
|
||||
let Some(ch) = n.children_mut() else { return };
|
||||
for (i, c) in ch.iter_mut().enumerate() {
|
||||
let clipping = clipping || (clips && i == 0);
|
||||
if !matches!(c.kind, NodeKind::Text(_)) {
|
||||
if has_pathfinder(c) || has_own_paint(c) || !geometry_effects(c).is_empty() {
|
||||
outline_nested_text(Arc::make_mut(c), clipping);
|
||||
} else if c.children().is_some() {
|
||||
keep(Arc::make_mut(c), moves, clipping, kept);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Type that isn't copied is outlined as `outline_nested_text` outlines it.
|
||||
let copies = if clipping { None } else { moved_type(c, moves) };
|
||||
let art = match copies {
|
||||
Some(mut copies) => {
|
||||
*kept = true;
|
||||
match copies.pop() {
|
||||
Some(only) if copies.is_empty() => Some(only),
|
||||
last => Some(Node::group(c.id, copies.into_iter().chain(last).map(Arc::new).collect())),
|
||||
}
|
||||
}
|
||||
None if clipping => outline_text(c).map(|o| Node { visible: c.visible, ..o }),
|
||||
None => as_art(c, None),
|
||||
};
|
||||
if let Some(o) = art {
|
||||
*c = Arc::new(o);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) || has_pathfinder(n) || has_own_paint(n) {
|
||||
return None;
|
||||
}
|
||||
let fx = geometry_effects(n);
|
||||
let moves = transform_moves(&fx)?;
|
||||
let mut text = false;
|
||||
n.walk(&mut |c| text |= matches!(c.kind, NodeKind::Text(_)));
|
||||
if !text {
|
||||
return None;
|
||||
}
|
||||
let hook: &dyn Fn(&Node) -> Option<Node> = &outline_text;
|
||||
let mut m = vectorcraft_doc::live::expand_deep(n, Some(hook));
|
||||
let mut kept = false;
|
||||
keep(&mut m, &moves, false, &mut kept);
|
||||
if !kept {
|
||||
return None;
|
||||
}
|
||||
reshape_leaves(&mut m, &fx);
|
||||
m.appearance.effects.retain(|e| !is_pathfinder(&e.id) && !is_geometry(&e.id));
|
||||
m.appearance.items.clear();
|
||||
m.appearance.contents_index = None;
|
||||
Some(m)
|
||||
}
|
||||
|
||||
/// A new path of `n`'s (its geometry `path`, filled under `rule`) painted by `item` alone, with
|
||||
/// none of `n`'s transparency.
|
||||
fn piece(d: &mut Document, n: &Node, path: PathData, rule: FillRule, item: AppearanceItem) -> Node {
|
||||
@@ -359,8 +568,9 @@ pub fn bake_document(doc: &Document) -> Option<Document> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::{Effect, NodeId};
|
||||
use vectorcraft_geom::{Rect, shapes};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{CharStyle, Effect, NodeId, TextObject};
|
||||
use vectorcraft_geom::{Point, Rect, shapes};
|
||||
|
||||
fn fx(id: &str, params: serde_json::Value) -> Effect {
|
||||
Effect { id: id.into(), params, visible: true }
|
||||
@@ -408,4 +618,318 @@ mod tests {
|
||||
assert!((ch[1].geometric_bounds().unwrap().width() - 110.0).abs() < 0.5);
|
||||
assert!(ch[0].id != ch[1].id && ch[0].id != NodeId(50));
|
||||
}
|
||||
|
||||
/// Black 24 pt "Type" with its baseline at (20, 60).
|
||||
fn type_node(id: u64) -> Node {
|
||||
let style = CharStyle { size: 24.0, fill: Paint::solid(Color::BLACK), ..Default::default() };
|
||||
Node::new(NodeId(id), NodeKind::Text(Box::new(TextObject::point(Point::new(20.0, 60.0), "Type", style))))
|
||||
}
|
||||
|
||||
/// The origins of the type objects in `d`, in paint order.
|
||||
fn type_origins(d: &Document) -> Vec<(f64, f64)> {
|
||||
let mut out = vec![];
|
||||
d.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
let c = t.xf.as_coeffs();
|
||||
out.push((c[4], c[5]));
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// A group of the type and a 10 pt square with `effects`.
|
||||
fn type_group(effects: Vec<Effect>) -> Node {
|
||||
let square = Node::path(NodeId(51), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
|
||||
let mut g = Node::group(NodeId(52), vec![Arc::new(type_node(50)), Arc::new(square)]);
|
||||
g.appearance.effects = effects;
|
||||
g
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_moved_or_copied_by_a_groups_transform_effect_stays_type() {
|
||||
for (params, origins, square) in [
|
||||
(json!({"moveH": 30}), vec![(50.0, 60.0)], Rect::new(30.0, 0.0, 40.0, 10.0)),
|
||||
(json!({"moveH": 200, "copies": 1}), vec![(20.0, 60.0), (220.0, 60.0)], Rect::new(0.0, 0.0, 210.0, 10.0)),
|
||||
(json!({"moveV": 50, "copies": 2}), vec![(20.0, 60.0), (20.0, 110.0), (20.0, 160.0)], Rect::new(0.0, 0.0, 10.0, 110.0)),
|
||||
] {
|
||||
let g = type_group(vec![fx("distort.transform", params.clone()), fx("stylize.dropShadow", json!({}))]);
|
||||
let d = bake_document(&doc_with(g.clone())).unwrap();
|
||||
assert_eq!(type_origins(&d), origins, "{params}");
|
||||
let baked = d.node(NodeId(52)).unwrap();
|
||||
assert_eq!(baked.appearance.effects.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["stylize.dropShadow"]);
|
||||
// The square moves with the type, as one path holding its copies.
|
||||
let b = baked.children().unwrap().last().and_then(|s| s.geometric_bounds()).unwrap();
|
||||
assert!([b.x0 - square.x0, b.y0 - square.y0, b.x1 - square.x1, b.y1 - square.y1].iter().all(|v| v.abs() < 1e-9), "{params}: {b:?}");
|
||||
let mut ids = std::collections::HashSet::new();
|
||||
baked.walk(&mut |n| assert!(ids.insert(n.id), "{params}: duplicate id {}", n.id));
|
||||
// The renderer and Expand Appearance still draw it as outlines.
|
||||
let mut text = 0;
|
||||
evaluate_container(&g).unwrap().walk(&mut |n| text += usize::from(matches!(n.kind, NodeKind::Text(_))));
|
||||
assert_eq!(text, 0, "{params}");
|
||||
}
|
||||
// Stacked effects that only move: every copy of a copy.
|
||||
let d = bake_document(&doc_with(type_group(vec![
|
||||
fx("distort.transform", json!({"moveH": 100, "copies": 1})),
|
||||
fx("distort.transform", json!({"moveV": 100, "copies": 1})),
|
||||
])))
|
||||
.unwrap();
|
||||
assert_eq!(type_origins(&d), [(20.0, 60.0), (120.0, 60.0), (20.0, 160.0), (120.0, 160.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_type_in_a_group_that_keeps_its_type_stays_hidden() {
|
||||
let mut g = type_group(vec![fx("distort.transform", json!({"moveH": 200, "copies": 1}))]);
|
||||
let mut hidden = type_node(55);
|
||||
hidden.visible = false;
|
||||
g.children_mut().unwrap().push(Arc::new(hidden));
|
||||
let d = bake_document(&doc_with(g)).unwrap();
|
||||
assert_eq!(type_origins(&d), [(20.0, 60.0), (220.0, 60.0)]);
|
||||
assert!(!d.node(NodeId(55)).unwrap().visible);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_outlined_next_to_type_that_stays_type_keeps_its_look() {
|
||||
let mut g = type_group(vec![fx("distort.transform", json!({"moveH": 200, "copies": 1}))]);
|
||||
let mut shadowed = type_node(55);
|
||||
shadowed.opacity = 0.5;
|
||||
shadowed.appearance.effects.push(fx("stylize.dropShadow", json!({})));
|
||||
// Type that clips a group is outlined as the plain outlines it clips by.
|
||||
let square = Node::path(NodeId(57), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art());
|
||||
let clip = Node::new(NodeId(58), NodeKind::Group { children: vec![Arc::new(type_node(56)), Arc::new(square)], clip: true });
|
||||
g.children_mut().unwrap().extend([Arc::new(shadowed), Arc::new(clip)]);
|
||||
let d = bake_document(&doc_with(g)).unwrap();
|
||||
assert_eq!(type_origins(&d), [(20.0, 60.0), (220.0, 60.0)]);
|
||||
let o = d.node(NodeId(55)).unwrap();
|
||||
assert!(matches!(o.kind, NodeKind::Group { .. }) && o.opacity == 0.5, "{o:?}");
|
||||
assert_eq!(o.appearance.effects.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["stylize.dropShadow"]);
|
||||
// The outlines are copied with the type.
|
||||
let b = o.geometric_bounds().unwrap();
|
||||
assert!(b.x0 < 30.0 && b.x1 > 220.0, "{b:?}");
|
||||
let clipped = d.node(NodeId(58)).and_then(|n| n.children()).and_then(|c| c.first()).unwrap();
|
||||
assert!(matches!(clipped.kind, NodeKind::Group { .. }) && clipped.opacity == 1.0, "{clipped:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_in_a_clipping_group_with_a_fill_of_its_own_is_outlined_plain() {
|
||||
let mut g = type_group(vec![fx("distort.transform", json!({"moveH": 200, "copies": 1}))]);
|
||||
// The clipping path is a group with a fill of its own, holding type with an effect of its own.
|
||||
let mut moved = type_node(60);
|
||||
moved.appearance.effects.push(fx("distort.transform", json!({"moveV": 300})));
|
||||
let mut path = Node::group(NodeId(59), vec![Arc::new(moved)]);
|
||||
path.appearance.items.push(AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::BLACK))));
|
||||
let square = Node::path(NodeId(57), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art());
|
||||
let clip = Node::new(NodeId(58), NodeKind::Group { children: vec![Arc::new(path), Arc::new(square)], clip: true });
|
||||
g.children_mut().unwrap().push(Arc::new(clip));
|
||||
let d = bake_document(&doc_with(g)).unwrap();
|
||||
assert_eq!(type_origins(&d), [(20.0, 60.0), (220.0, 60.0)]);
|
||||
// The type clips by its plain outlines, as the canvas clips by them: its own effect is left out.
|
||||
let b = d.node(NodeId(59)).and_then(|n| n.geometric_bounds()).unwrap();
|
||||
assert!(b.y0 > 30.0 && b.y1 < 70.0, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_with_its_own_transform_effect_copies_stays_type() {
|
||||
let mut n = type_node(50);
|
||||
n.opacity = 0.5;
|
||||
n.appearance.effects = vec![fx("distort.transform", json!({"moveV": 50, "copies": 2})), fx("stylize.dropShadow", json!({}))];
|
||||
let d = bake_document(&doc_with(n)).unwrap();
|
||||
assert_eq!(type_origins(&d), [(20.0, 60.0), (20.0, 110.0), (20.0, 160.0)]);
|
||||
// One group keeps the object's transparency and its other effects; the copies are plain.
|
||||
let g = d.node(NodeId(50)).unwrap();
|
||||
assert!(matches!(g.kind, NodeKind::Group { .. }) && g.opacity == 0.5);
|
||||
assert_eq!(g.appearance.effects.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["stylize.dropShadow"]);
|
||||
for c in g.children().unwrap() {
|
||||
assert!(c.opacity == 1.0 && c.appearance.effects.is_empty() && c.id != g.id, "{c:?}");
|
||||
}
|
||||
// Hidden type stays hidden.
|
||||
let mut n = type_node(50);
|
||||
n.visible = false;
|
||||
n.appearance.effects = vec![fx("distort.transform", json!({"moveV": 50, "copies": 1}))];
|
||||
let d = bake_document(&doc_with(n)).unwrap();
|
||||
assert_eq!(type_origins(&d).len(), 2);
|
||||
assert!(!d.node(NodeId(50)).unwrap().visible);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_under_other_geometry_effects_is_still_outlined() {
|
||||
for effects in [
|
||||
vec![fx("distort.transform", json!({"moveH": 30, "random": true}))],
|
||||
vec![fx("distort.transform", json!({"moveH": 30, "scaleH": 50}))],
|
||||
vec![fx("distort.transform", json!({"moveH": 30, "rotate": 10, "copies": 1}))],
|
||||
vec![fx("distort.transform", json!({"moveH": 30, "reflectX": true}))],
|
||||
vec![fx("distort.transform", json!({"moveH": 30})), fx("distort.roughen", json!({"size": 4}))],
|
||||
// Copies that would reach past the effect's limit, which stops them early.
|
||||
vec![fx("distort.transform", json!({"moveH": 1.0e7, "copies": 1000}))],
|
||||
// Copies that overlap, or land on each other.
|
||||
vec![fx("distort.transform", json!({"moveH": 5, "copies": 1}))],
|
||||
vec![fx("distort.transform", json!({"moveH": 100, "copies": 1})), fx("distort.transform", json!({"moveH": -100, "copies": 1}))],
|
||||
] {
|
||||
let mut own = type_node(50);
|
||||
own.appearance.effects = effects.clone();
|
||||
for n in [type_group(effects.clone()), own] {
|
||||
let d = bake_document(&doc_with(n)).unwrap();
|
||||
assert!(type_origins(&d).is_empty(), "{effects:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_whose_copies_paint_over_each_other_is_still_outlined() {
|
||||
let moves = vec![fx("distort.transform", json!({"moveV": 40, "copies": 1}))];
|
||||
assert_eq!(type_origins(&bake_document(&doc_with(type_group(moves.clone()))).unwrap()).len(), 2);
|
||||
// A 20 pt stroke on the characters reaches the copy below.
|
||||
let mut stroked = type_node(50);
|
||||
if let NodeKind::Text(t) = &mut stroked.kind {
|
||||
t.runs[0].style.stroke = Paint::solid(Color::BLACK);
|
||||
t.runs[0].style.stroke_width = 20.0;
|
||||
}
|
||||
let mut g = type_group(moves);
|
||||
g.children_mut().unwrap()[0] = Arc::new(stroked);
|
||||
assert!(type_origins(&bake_document(&doc_with(g)).unwrap()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_with_a_look_of_its_own_in_a_moved_group_is_still_outlined() {
|
||||
let moves = vec![fx("distort.transform", json!({"moveV": 40, "copies": 1}))];
|
||||
let mut translucent = type_node(50);
|
||||
translucent.opacity = 0.5;
|
||||
let mut underlined = type_node(50);
|
||||
if let NodeKind::Text(t) = &mut underlined.kind {
|
||||
t.runs[0].style.underline = true;
|
||||
}
|
||||
let mut shadowed = type_node(50);
|
||||
shadowed.appearance.effects.push(fx("stylize.dropShadow", json!({})));
|
||||
let mut gradient = type_node(50);
|
||||
if let NodeKind::Text(t) = &mut gradient.kind {
|
||||
t.runs[0].style.fill = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default())));
|
||||
}
|
||||
let mut reshaped = type_node(50);
|
||||
let mut fill = FillLayer::new(Paint::solid(Color::BLACK));
|
||||
fill.effects.push(fx("path.offsetPath", json!({"offset": 2})));
|
||||
reshaped.appearance.items.push(AppearanceItem::Fill(fill));
|
||||
// A stroke of the type object's own (beyond its characters').
|
||||
let mut stroked = type_node(50);
|
||||
stroked.appearance.items.push(AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::BLACK), 1.0)));
|
||||
// Character strokes scale with type scaled by its transform, where the outlines keep theirs.
|
||||
let mut scaled = type_node(50);
|
||||
if let NodeKind::Text(t) = &mut scaled.kind {
|
||||
t.runs[0].style.stroke = Paint::solid(Color::BLACK);
|
||||
t.runs[0].style.stroke_width = 1.0;
|
||||
t.xf *= Affine::scale(2.0);
|
||||
}
|
||||
let mut turned = scaled.clone();
|
||||
if let NodeKind::Text(t) = &mut turned.kind {
|
||||
t.xf = Affine::translate((20.0, 60.0)) * Affine::rotate(0.5);
|
||||
}
|
||||
let far = vec![fx("distort.transform", json!({"moveH": 300, "copies": 1}))];
|
||||
for (t, kept) in [(turned, 2), (scaled, 0)] {
|
||||
let mut g = type_group(far.clone());
|
||||
g.children_mut().unwrap()[0] = Arc::new(t);
|
||||
assert_eq!(type_origins(&bake_document(&doc_with(g)).unwrap()).len(), kept);
|
||||
}
|
||||
for t in [translucent, underlined, shadowed, gradient, reshaped, stroked] {
|
||||
let mut g = type_group(moves.clone());
|
||||
g.children_mut().unwrap()[0] = Arc::new(t);
|
||||
assert!(type_origins(&bake_document(&doc_with(g)).unwrap()).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_in_a_moved_group_with_a_fill_of_its_own_is_still_outlined() {
|
||||
let moves = vec![fx("distort.transform", json!({"moveV": 100, "copies": 1}))];
|
||||
let fill = AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::BLACK)));
|
||||
let mut g = type_group(moves.clone());
|
||||
g.appearance.items.push(fill.clone());
|
||||
assert!(type_origins(&bake_document(&doc_with(g)).unwrap()).is_empty());
|
||||
// A group with a fill of its own inside the moved group.
|
||||
let mut inner = type_group(vec![]);
|
||||
inner.id = NodeId(53);
|
||||
inner.appearance.items.push(fill);
|
||||
let mut outer = Node::group(NodeId(54), vec![Arc::new(inner)]);
|
||||
outer.appearance.effects = moves;
|
||||
assert!(type_origins(&bake_document(&doc_with(outer)).unwrap()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_in_a_moved_group_inside_a_moved_group_is_still_outlined() {
|
||||
// The inner copy lands back on the original.
|
||||
let mut inner = type_group(vec![fx("distort.transform", json!({"moveH": -200, "copies": 1}))]);
|
||||
inner.id = NodeId(53);
|
||||
let mut outer = Node::group(NodeId(54), vec![Arc::new(inner)]);
|
||||
outer.appearance.effects.push(fx("distort.transform", json!({"moveH": 200, "copies": 1})));
|
||||
assert!(type_origins(&bake_document(&doc_with(outer)).unwrap()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_whose_copies_hold_too_much_is_still_outlined() {
|
||||
let spaced = |spaces: usize| {
|
||||
let mut n = type_node(50);
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
t.runs[0].text = format!("a{}", " ".repeat(spaces));
|
||||
}
|
||||
Arc::new(n)
|
||||
};
|
||||
// 1001 copies of about 2,000 bytes stay type; of about 21,000 bytes each, past 20,000,000
|
||||
// bytes together, they are outlined.
|
||||
let copies = vec![fx("distort.transform", json!({"moveH": 100, "copies": 1000}))];
|
||||
for (spaces, kept) in [(1000, 1001), (20_000, 0)] {
|
||||
let mut g = type_group(copies.clone());
|
||||
g.children_mut().unwrap()[0] = spaced(spaces);
|
||||
assert_eq!(type_origins(&bake_document(&doc_with(g)).unwrap()).len(), kept, "{spaces} spaces");
|
||||
}
|
||||
// 1001 × 21 copies, past 10,000.
|
||||
let mut g = type_group(vec![
|
||||
fx("distort.transform", json!({"moveH": 100, "copies": 1000})),
|
||||
fx("distort.transform", json!({"moveV": 100, "copies": 20})),
|
||||
]);
|
||||
g.children_mut().unwrap()[0] = spaced(0);
|
||||
assert!(type_origins(&bake_document(&doc_with(g)).unwrap()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copies_are_apart_when_their_boxes_only_touch() {
|
||||
let b = Rect::new(0.0, 0.0, 10.0, 5.0);
|
||||
assert!(apart(&[Vec2::ZERO, Vec2::new(10.0, 0.0), Vec2::new(0.0, 5.0), Vec2::new(-10.0, -5.0)], b));
|
||||
assert!(!apart(&[Vec2::ZERO, Vec2::new(9.0, 4.0)], b));
|
||||
assert!(!apart(&[Vec2::new(100.0, 0.0), Vec2::new(30.0, 0.0), Vec2::new(100.0, 0.0)], b));
|
||||
assert!(!apart(&[Vec2::new(-1.0, -1.0), Vec2::new(8.5, 3.5)], b));
|
||||
// Offsets too far for the cells' range count as overlapping.
|
||||
assert!(!apart(&[Vec2::ZERO, Vec2::new(1.0e9, 0.0)], Rect::new(0.0, 0.0, 1.0e-9, 1.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_in_a_pathfinder_group_inside_a_moved_group_is_still_outlined() {
|
||||
let mut inner = type_group(vec![fx("pathfinder.add", json!({}))]);
|
||||
inner.id = NodeId(53);
|
||||
let mut outer = Node::group(NodeId(54), vec![Arc::new(inner)]);
|
||||
outer.appearance.effects.push(fx("distort.transform", json!({"moveV": 100, "copies": 1})));
|
||||
let d = bake_document(&doc_with(outer)).unwrap();
|
||||
assert!(type_origins(&d).is_empty());
|
||||
// The glyphs take part in Add: the result reaches past the 10 pt square.
|
||||
let b = d.node(NodeId(54)).and_then(|n| n.geometric_bounds()).unwrap();
|
||||
assert!(b.width() > 30.0, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_is_copied_only_when_the_effect_makes_every_copy() {
|
||||
// A 10 pt line with `n` anchors.
|
||||
let line = |n: usize| {
|
||||
let pts: Vec<Point> = (0..n).map(|i| Point::new(i as f64 * 10.0 / n as f64, 0.0)).collect();
|
||||
PathData::new(vec![vectorcraft_geom::SubPath::polyline(&pts, false)])
|
||||
};
|
||||
let right = json!({"moveH": 10, "copies": 1000});
|
||||
let down = json!({"moveV": 10, "copies": 1});
|
||||
// 1000 copies of a path stay within 2,000,000 anchors up to 1998 anchors.
|
||||
assert_eq!(transform_offsets(std::slice::from_ref(&right), &line(1998)).map(|o| o.len()), Some(1001));
|
||||
assert!(transform_offsets(std::slice::from_ref(&right), &line(1999)).is_none());
|
||||
// A second effect copies every copy.
|
||||
assert_eq!(transform_offsets(&[right.clone(), down.clone()], &line(999)).map(|o| o.len()), Some(2002));
|
||||
assert!(transform_offsets(&[right, down], &line(1000)).is_none());
|
||||
// Copies stop before one reaches 1,000,000,000 pt from the origin.
|
||||
let square = shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0));
|
||||
assert_eq!(transform_offsets(&[json!({"moveH": 1.0e7, "copies": 99})], &square).map(|o| o.len()), Some(100));
|
||||
assert!(transform_offsets(&[json!({"moveH": 1.0e7, "copies": 100})], &square).is_none());
|
||||
// An effect that also scales.
|
||||
assert!(transform_offsets(&[json!({"moveH": 10, "scaleH": 50})], &square).is_none());
|
||||
}
|
||||
}
|
||||
@@ -103,6 +103,39 @@ pub fn roughen(path: &PathData, b: Rect, p: &Value) -> PathData {
|
||||
PathData::new(subs)
|
||||
}
|
||||
|
||||
/// The move and the number of copies of a Transform effect with params `p` that only moves what
|
||||
/// it applies to, as [`transform_map`] reads them; `None` when it also scales, rotates or reflects,
|
||||
/// or moves by a random share.
|
||||
pub(crate) fn transform_move(p: &Value) -> Option<(Vec2, usize)> {
|
||||
let (m, copies) = transform_map(Rect::ZERO, p, 0);
|
||||
let [a, b, c, d, x, y] = m.as_coeffs();
|
||||
(!flag(p, "random", false) && [a, b, c, d] == [1.0, 0.0, 0.0, 1.0]).then_some((Vec2::new(x, y), copies))
|
||||
}
|
||||
|
||||
/// The offsets at which Transform effects with params `ps`, applied in order, place `path`, the
|
||||
/// original's place first, when each of them only moves it ([`transform_move`]): each effect moves
|
||||
/// every place it is given, or with copies keeps them and adds each copy's. `None` when one of
|
||||
/// them does more than move, or when [`transform`] stops its copies of `path` early.
|
||||
pub(crate) fn transform_offsets(ps: &[Value], path: &PathData) -> Option<Vec<Vec2>> {
|
||||
let mut offsets = vec![Vec2::ZERO];
|
||||
let mut all = path.clone();
|
||||
for p in ps {
|
||||
let (v, copies) = transform_move(p)?;
|
||||
let next = transform(&all, Rect::ZERO, p, 0);
|
||||
if next.anchor_count() != all.anchor_count().saturating_mul(copies + 1) {
|
||||
return None;
|
||||
}
|
||||
all = next;
|
||||
if copies == 0 {
|
||||
offsets.iter_mut().for_each(|o| *o += v);
|
||||
continue;
|
||||
}
|
||||
let from = offsets.clone();
|
||||
offsets.extend((1..=copies).flat_map(|k| from.iter().map(move |o| *o + v * k as f64)));
|
||||
}
|
||||
Some(offsets)
|
||||
}
|
||||
|
||||
/// Largest scale (%) and move (pt) the Transform effect takes.
|
||||
const MAX_SCALE: f64 = 100_000.0;
|
||||
const MAX_MOVE: f64 = 1.0e7;
|
||||
@@ -112,6 +145,17 @@ const MAX_REACH: f64 = 1.0e9;
|
||||
/// a 2000-anchor path. Stacked Transform effects multiply their copies (three of 1000 copies make
|
||||
/// a thousand million of one square), so the limit is on the result, not on each effect's count.
|
||||
pub(crate) const MAX_COPY_ANCHORS: usize = 2_000_000;
|
||||
/// Most image and symbol instance nodes a Transform effect's copies make of an art, the originals
|
||||
/// included: 1000 copies of a 2000-image art. Stacked Transform effects multiply their copies
|
||||
/// (three of 1000 copies make a thousand million of one image), so the limit is on the result,
|
||||
/// not on each effect's count.
|
||||
pub(crate) const MAX_COPY_OBJECTS: usize = 2_000_000;
|
||||
|
||||
/// Does `r` stay within [`MAX_REACH`] of the origin? A Transform effect's copies stop before one
|
||||
/// doesn't.
|
||||
pub(crate) fn within_reach(r: Rect) -> bool {
|
||||
[r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.abs() <= MAX_REACH)
|
||||
}
|
||||
|
||||
/// Transform effect: scale, rotate and reflect about reference point `reference` of the bounds
|
||||
/// (the 9-point grid, 4 = centre), then move; `copies` more times, each from the last. With
|
||||
@@ -124,6 +168,36 @@ pub fn transform(path: &PathData, b: Rect, p: &Value, seed: u64) -> PathData {
|
||||
|
||||
/// [`transform`] whose copies make at most `max` anchors of the path, the original included.
|
||||
pub(crate) fn transform_within(path: &PathData, b: Rect, p: &Value, seed: u64, max: usize) -> PathData {
|
||||
let (m, copies) = transform_map(b, p, seed);
|
||||
if copies == 0 {
|
||||
return path.transformed(m);
|
||||
}
|
||||
// Each copy adds the path once more, and a Transform after this one copies the lot again:
|
||||
// copies stop where the result would pass [`MAX_COPY_ANCHORS`] (#1705).
|
||||
let each = path.anchor_count();
|
||||
let mut anchors = each;
|
||||
let mut out = path.clone();
|
||||
let mut cur = path.clone();
|
||||
for _ in 0..copies {
|
||||
anchors = anchors.saturating_add(each);
|
||||
if anchors > max {
|
||||
break;
|
||||
}
|
||||
cur = cur.transformed(m);
|
||||
if !cur.bounds().is_some_and(within_reach) {
|
||||
break;
|
||||
}
|
||||
out.subpaths.extend(cur.subpaths.iter().cloned());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The Transform effect's map and how many copies of what it is applied to it makes, for bounds
|
||||
/// `b` (the reference point lies on them), params `p` and the object's `seed` for Random: the map
|
||||
/// applies to the object itself when `copies` is 0, and once more to each of `copies` copies
|
||||
/// otherwise (see [`transform_within`], and images, whose copies count nodes against
|
||||
/// [`MAX_COPY_OBJECTS`]).
|
||||
pub(crate) fn transform_map(b: Rect, p: &Value, seed: u64) -> (Affine, usize) {
|
||||
let random = flag(p, "random", false);
|
||||
// The share (0..1) of value `k` applied.
|
||||
let share = |k: u64| if random { (noise(seed, k, 0, 0) + 1.0) / 2.0 } else { 1.0 };
|
||||
@@ -137,28 +211,7 @@ pub(crate) fn transform_within(path: &PathData, b: Rect, p: &Value, seed: u64, m
|
||||
* Affine::scale_non_uniform(scale("scaleH", 0) * reflect("reflectX"), scale("scaleV", 1) * reflect("reflectY"))
|
||||
* Affine::translate(-o);
|
||||
let copies = num(p, "copies", 0.0).clamp(0.0, 1000.0) as usize;
|
||||
if copies == 0 {
|
||||
return path.transformed(m);
|
||||
}
|
||||
let within = |r: Rect| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.abs() <= MAX_REACH);
|
||||
// Each copy adds the path once more, and a Transform after this one copies the lot again:
|
||||
// copies stop where the result would pass [`MAX_COPY_ANCHORS`] (#1705).
|
||||
let each = path.anchor_count();
|
||||
let mut anchors = each;
|
||||
let mut out = path.clone();
|
||||
let mut cur = path.clone();
|
||||
for _ in 0..copies {
|
||||
anchors = anchors.saturating_add(each);
|
||||
if anchors > max {
|
||||
break;
|
||||
}
|
||||
cur = cur.transformed(m);
|
||||
if !cur.bounds().is_some_and(within) {
|
||||
break;
|
||||
}
|
||||
out.subpaths.extend(cur.subpaths.iter().cloned());
|
||||
}
|
||||
out
|
||||
(m, copies)
|
||||
}
|
||||
|
||||
/// Tweak: random displacement of anchors and/or control points.
|
||||
|
||||
+139
-6
@@ -46,7 +46,9 @@ pub fn has_pathfinder(n: &Node) -> bool {
|
||||
&& n.appearance.effects.iter().any(|e| e.visible && is_pathfinder(&e.id))
|
||||
}
|
||||
|
||||
/// Converts objects that aren't paths (e.g. text) to an outline path node, if possible.
|
||||
/// Converts objects that aren't paths (e.g. text) to outline art, if possible. The paths and
|
||||
/// compound paths in that art, at every depth of its groups, take part in the Pathfinder operation
|
||||
/// with the object's opacity and blend mode.
|
||||
pub type OutlineHook<'a> = &'a dyn Fn(&Node) -> Option<Node>;
|
||||
|
||||
fn leaves(n: &Node, hook: Option<OutlineHook>, out: &mut Vec<Node>) {
|
||||
@@ -61,8 +63,14 @@ fn leaves(n: &Node, hook: Option<OutlineHook>, out: &mut Vec<Node>) {
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(p) = hook.and_then(|h| h(n)) {
|
||||
out.push(p);
|
||||
// Every path of the outline art, at any depth, takes the object's opacity and blend
|
||||
// mode.
|
||||
if let Some(art) = hook.and_then(|h| h(n)) {
|
||||
let first = out.len();
|
||||
leaves(&art, None, out);
|
||||
for l in out.iter_mut().skip(first) {
|
||||
(l.opacity, l.blend) = (n.opacity, n.blend);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -281,9 +289,9 @@ pub fn evaluate_container(n: &Node) -> Option<Node> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Effect, NodeId};
|
||||
use vectorcraft_geom::{Rect, shapes};
|
||||
use vectorcraft_color::{BlendMode, Color, Paint};
|
||||
use vectorcraft_doc::{CharStyle, Effect, NodeId, TextObject};
|
||||
use vectorcraft_geom::{Point, Rect, shapes};
|
||||
|
||||
fn sq(id: u64, x: f64, color: Color) -> Arc<Node> {
|
||||
Arc::new(Node::path(
|
||||
@@ -319,6 +327,71 @@ mod tests {
|
||||
assert_eq!(div.len(), 3);
|
||||
}
|
||||
|
||||
/// Returns an "H" in blue type at 100 pt on a baseline at y = 80, with its stems on either
|
||||
/// side of x = 100.
|
||||
fn type_h() -> Node {
|
||||
let style = CharStyle { size: 100.0, fill: Paint::solid(Color::rgb(0.0, 0.0, 1.0)), ..CharStyle::default() };
|
||||
Node::new(NodeId(2), NodeKind::Text(Box::new(TextObject::point(Point::new(70.0, 80.0), "H", style))))
|
||||
}
|
||||
|
||||
/// Returns a group with `effect` that holds a red 100 x 100 square and `front` in front of it.
|
||||
fn square_and(front: Option<Node>, effect: &str) -> Node {
|
||||
let red = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
|
||||
let square = Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), red);
|
||||
let mut g = Node::group(NodeId(10), [Some(square), front].into_iter().flatten().map(Arc::new).collect());
|
||||
g.appearance.effects.push(Effect { id: effect.into(), params: serde_json::json!({}), visible: true });
|
||||
g
|
||||
}
|
||||
|
||||
/// Returns the Pathfinder results of `g`, with type outlined as the renderer outlines it.
|
||||
fn results(g: &Node) -> Vec<Arc<Node>> {
|
||||
pathfinder_children(g, crate::text_outliner()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_changes_the_result_of_every_pathfinder_effect() {
|
||||
let glyphs = crate::outline_text(&type_h()).unwrap();
|
||||
let b = glyphs.geometric_bounds().unwrap();
|
||||
assert!(b.x0 < 100.0 && b.x1 > 100.0 && b.y0 > 0.0 && b.y1 < 100.0, "{b:?}");
|
||||
// `measure` gives the number of results and their total area.
|
||||
let measure = |front: Option<Node>, effect: &str| {
|
||||
let r = results(&square_and(front, effect));
|
||||
(r.len(), r.iter().map(|n| area(n)).sum::<f64>())
|
||||
};
|
||||
let unchanged: Vec<_> = PATHFINDER_EFFECTS
|
||||
.iter()
|
||||
.map(|(id, label, _)| (label, measure(Some(type_h()), id), measure(None, id)))
|
||||
.filter(|(_, with, without)| with.0 == without.0 && (with.1 - without.1).abs() <= 1.0)
|
||||
.collect();
|
||||
assert!(unchanged.is_empty(), "(label, with the type, without): {unchanged:?}");
|
||||
// The shape modes take the glyph's whole area: the part inside the square and the part outside.
|
||||
let glyph_area: f64 = glyphs.children().unwrap().iter().map(|n| area(n)).sum();
|
||||
let with_type = |id: &str| measure(Some(type_h()), id).1;
|
||||
assert!((with_type("pathfinder.add") - with_type("pathfinder.subtract") - glyph_area).abs() < 0.5, "{glyph_area}");
|
||||
assert!((with_type("pathfinder.intersect") + with_type("pathfinder.minusBack") - glyph_area).abs() < 0.5, "{glyph_area}");
|
||||
// Outline art nested one group deeper takes part as well.
|
||||
let nested: OutlineHook = &|n: &Node| crate::outline_text(n).map(|g| Node::group(n.id, vec![Arc::new(g)]));
|
||||
let sub = pathfinder_children(&square_and(Some(type_h()), "pathfinder.subtract"), Some(nested)).unwrap();
|
||||
assert!((sub.iter().map(|n| area(n)).sum::<f64>() - with_type("pathfinder.subtract")).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pieces_of_type_keep_its_paint_opacity_and_blend_mode() {
|
||||
let mut t = type_h();
|
||||
(t.opacity, t.blend) = (0.5, BlendMode::Multiply);
|
||||
let look = |p: &Arc<Node>| (p.appearance.fill_paint(), p.opacity, p.blend);
|
||||
let square = (Paint::solid(Color::rgb(1.0, 0.0, 0.0)), 1.0, BlendMode::Normal);
|
||||
let glyph = (Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 0.5, BlendMode::Multiply);
|
||||
let looks: Vec<_> = results(&square_and(Some(t.clone()), "pathfinder.divide")).iter().map(look).collect();
|
||||
assert!(looks.contains(&square) && looks.contains(&glyph) && looks.iter().all(|l| *l == square || *l == glyph), "{looks:?}");
|
||||
// In the shape modes a group with the type in front is one shape, styled like the type.
|
||||
let green = Appearance::basic(Paint::solid(Color::rgb(0.0, 1.0, 0.0)), Paint::None, 0.0);
|
||||
let behind = Node::path(NodeId(3), shapes::rectangle(Rect::new(50.0, 50.0, 150.0, 150.0)), green);
|
||||
let both = Node::group(NodeId(4), vec![Arc::new(behind), Arc::new(t)]);
|
||||
let added: Vec<_> = results(&square_and(Some(both), "pathfinder.add")).iter().map(look).collect();
|
||||
assert_eq!(added, [glyph]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_random_transform_on_a_group_moves_its_members_together() {
|
||||
let mut g = group_with("distort.transform");
|
||||
@@ -330,6 +403,40 @@ mod tests {
|
||||
assert!((b.0 - a.0 - 5.0).abs() < 1e-9 && (b.1 - a.1).abs() < 1e-9, "{a:?} {b:?}");
|
||||
}
|
||||
|
||||
/// What [`evaluate_container`] makes of black 40 pt type "IIII" (id 3), changed by `style`,
|
||||
/// in a group with a square and Offset Path 0, which leaves every path as it is.
|
||||
fn type_in_group(style: impl FnOnce(&mut Node)) -> Node {
|
||||
let ts = vectorcraft_doc::CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), ..Default::default() };
|
||||
let text = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(5.0, 60.0), "IIII", ts);
|
||||
let mut t = Node::new(NodeId(3), NodeKind::Text(Box::new(text)));
|
||||
style(&mut t);
|
||||
let mut g = Node::group(NodeId(10), vec![sq(1, 0.0, Color::BLACK), Arc::new(t)]);
|
||||
g.appearance.effects.push(Effect { id: "path.offsetPath".into(), params: serde_json::json!({"offset": 0}), visible: true });
|
||||
let art = evaluate_container(&g).unwrap();
|
||||
(*art.children().unwrap()[1]).clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_in_a_group_keeps_its_look_when_the_groups_geometry_effects_outline_it() {
|
||||
use vectorcraft_color::BlendMode;
|
||||
let art = type_in_group(|t| (t.opacity, t.blend) = (0.5, BlendMode::Multiply));
|
||||
assert!(art.id == NodeId(3) && !matches!(art.kind, NodeKind::Text(_)), "{art:?}");
|
||||
assert_eq!((art.opacity, art.blend), (0.5, BlendMode::Multiply));
|
||||
// Its opacity mask, its own shadow, its knockout shape option and its visibility stay.
|
||||
let mask_art = Node::path(NodeId(4), shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default());
|
||||
let mask = vectorcraft_doc::OpacityMask::new(mask_art, true);
|
||||
assert_eq!(type_in_group(|t| t.extras_mut().mask = Some(Box::new(mask.clone()))).extras().mask.as_deref(), Some(&mask));
|
||||
let shadow = Effect { id: "stylize.dropShadow".into(), params: serde_json::json!({}), visible: true };
|
||||
assert_eq!(type_in_group(|t| t.appearance.effects.push(shadow.clone())).appearance.effects, vec![shadow]);
|
||||
assert!(type_in_group(|t| t.knockout_shape = true).knockout_shape);
|
||||
assert!(!type_in_group(|t| t.visible = false).visible);
|
||||
// Its own Transform moves its outlines 30 pt before the group's effect reshapes them.
|
||||
let x0 = |n: Node| n.geometric_bounds().unwrap().x0;
|
||||
let transform = Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30}), visible: true };
|
||||
let moved = x0(type_in_group(|t| t.appearance.effects.push(transform)));
|
||||
assert!((moved - x0(type_in_group(|_| {})) - 30.0).abs() < 1e-6, "{moved}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_effect_or_plain_group_is_untouched() {
|
||||
let mut g = group_with("pathfinder.add");
|
||||
@@ -337,4 +444,30 @@ mod tests {
|
||||
assert!(pathfinder_children(&g, None).is_none());
|
||||
assert!(!has_pathfinder(&sq(1, 0.0, Color::BLACK)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_transform_on_a_group_moves_the_gradients_of_its_type_and_not_those_placed_on_its_paths() {
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
|
||||
let ramp = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops });
|
||||
// The type's characters fit the gradient to its layout; the square has it placed across itself.
|
||||
let style = vectorcraft_doc::CharStyle { size: 40.0, fill: Paint::Gradient(Box::new(ramp.clone())), ..Default::default() };
|
||||
let text = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(5.0, 60.0), "IIII", style);
|
||||
let t = Arc::new(Node::new(NodeId(3), NodeKind::Text(Box::new(text))));
|
||||
let geom = GradientGeom { start: (0.0, 5.0).into(), end: (10.0, 5.0).into(), aspect: 1.0, focal: None };
|
||||
let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }));
|
||||
let square = Arc::new(Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(placed, Paint::None, 0.0)));
|
||||
let start = |n: &Node| match n.appearance.fill_paint() {
|
||||
Paint::Gradient(g) => g.geom.unwrap().start.x,
|
||||
p => panic!("a gradient: {p:?}"),
|
||||
};
|
||||
let before = start(&crate::outline_text(&t).unwrap().children().unwrap()[0]);
|
||||
let mut g = Node::group(NodeId(10), vec![square, t]);
|
||||
g.appearance.effects.push(Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30}), visible: true });
|
||||
let art = evaluate_container(&g).unwrap();
|
||||
let members = art.children().unwrap();
|
||||
let after = start(&members[1].children().unwrap()[0]);
|
||||
assert!((after - before - 30.0).abs() < 1e-6, "the type's gradient starts at {before}, then {after}");
|
||||
assert_eq!(start(&members[0]), 0.0, "the square's gradient stays where it was placed");
|
||||
}
|
||||
}
|
||||
+65
-15
@@ -6,7 +6,7 @@
|
||||
//! - **Geometry effects** (Distort & Transform, Path, Convert to Shape, Round Corners, Scribble,
|
||||
//! Warp) rewrite a path: [`apply_geometry`] evaluates them in stack order.
|
||||
//! - **Raster effects** (Drop Shadow, Inner/Outer Glow, Feather, Gaussian Blur, and the
|
||||
//! Photoshop-style filters of [`pixel`]: Radial Blur, Smart Blur, Color Halftone, Crystallize, Mezzotint, Pointillize,
|
||||
//! Photoshop-style filters of [`pixel`]: Radial Blur, Smart Blur, Color Halftone, Crystallize, Mezzotint, Pointillize, Halftone Pattern,
|
||||
//! Unsharp Mask and Glowing Edges) are described by
|
||||
//! [`raster_effects`] and painted by the renderer; [`outset`] says how far they reach beyond
|
||||
//! the geometry.
|
||||
@@ -69,7 +69,7 @@ pub use live::{expand_live, expand_live_deep, expanded_live_group, text_outliner
|
||||
pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks};
|
||||
pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace};
|
||||
pub use raster::{RasterFx, outset, raster_effects};
|
||||
pub use reshape::{expand_outlined, needs_outline, outline_art, outline_text, reshape};
|
||||
pub use reshape::{expand_outlined, has_item_geometry, needs_outline, outline_art, outline_text, reshape};
|
||||
pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve};
|
||||
pub use solids::{
|
||||
EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SOLIDS, extrude_options, has_solid, inflate_options, is_three_d, solid_art, validate_solid,
|
||||
@@ -134,7 +134,7 @@ fn lengths_of(id: &str) -> Lengths {
|
||||
"threeD.inflate" => always(&["depth"]),
|
||||
"path.outlineStroke" => always(&["width"]),
|
||||
"stylize.roundCorners" | "stylize.feather" | "blur.gaussian" | "blur.smart" | "sharpen.unsharpMask" => always(&["radius"]),
|
||||
"stylize.scribble" => always(&["overlap", "strokeWidth", "spacing", "variation"]),
|
||||
"stylize.scribble" => always(&["overlap", "overlapVariation", "strokeWidth", "spacing", "spacingVariation"]),
|
||||
"stylize.dropShadow" => always(&["x", "y", "blur"]),
|
||||
"stylize.innerGlow" | "stylize.outerGlow" => always(&["blur"]),
|
||||
"stylize.glowingEdges" => always(&["edgeWidth", "smoothness"]),
|
||||
@@ -149,6 +149,7 @@ fn lengths_of(id: &str) -> Lengths {
|
||||
"texture.mosaicTiles" => always(&["tileSize"]),
|
||||
"texture.patchwork" => always(&["squareSize"]),
|
||||
"texture.stainedGlass" => always(&["cellSize"]),
|
||||
"sketch.halftonePattern" => always(&["size"]),
|
||||
_ => Lengths::default(),
|
||||
}
|
||||
}
|
||||
@@ -165,6 +166,7 @@ const ARTISTIC: &[&str] = &["Effect", "Artistic"];
|
||||
const BRUSH_STROKES: &[&str] = &["Effect", "Brush Strokes"];
|
||||
const DISTORT: &[&str] = &["Effect", "Distort"];
|
||||
const SHARPEN: &[&str] = &["Effect", "Sharpen"];
|
||||
const SKETCH: &[&str] = &["Effect", "Sketch"];
|
||||
const PIXELATE: &[&str] = &["Effect", "Pixelate"];
|
||||
const TEXTURE: &[&str] = &["Effect", "Texture"];
|
||||
const VIDEO: &[&str] = &["Effect", "Video"];
|
||||
@@ -200,8 +202,8 @@ 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), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance)}. Open paths create uncapped surfaces; 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,"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), 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}),
|
||||
),
|
||||
g(
|
||||
EXTRUDE,
|
||||
@@ -289,6 +291,13 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"{offset: pt (10; negative insets), joins: \"miter\"|\"round\"|\"bevel\", miterLimit: (4)}",
|
||||
json!({"offset": 10.0, "joins": "miter", "miterLimit": 4.0}),
|
||||
),
|
||||
g(
|
||||
"path.outlineObject",
|
||||
"Outline Object",
|
||||
PATH,
|
||||
"{} type is drawn, exported and expanded as its glyph outlines and stays editable, and the effects after it reshape those outlines; paths and images do not change",
|
||||
json!({}),
|
||||
),
|
||||
g(
|
||||
"path.outlineStroke",
|
||||
"Outline Stroke",
|
||||
@@ -301,15 +310,15 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"stylize.scribble",
|
||||
"Scribble…",
|
||||
STYLIZE,
|
||||
"{angle: deg (30), overlap: pt (0), strokeWidth: pt (3), curviness: % (5), spacing: pt (5), variation: pt (0.5), seed: int (0)} (simplified: a single hatching scribble outlined to a filled shape)",
|
||||
json!({"angle": 30.0, "overlap": 0.0, "strokeWidth": 3.0, "curviness": 5.0, "spacing": 5.0, "variation": 0.5, "seed": 0}),
|
||||
"{angle: deg (30; stored above -180 and up to 180: 270 becomes -90, -180 becomes 180), overlap: pt (0; Path Overlap: a positive value extends each line beyond the path, a negative one ends it inside), overlapVariation: pt (0..100, 0.5; how far each line's ends move along it), strokeWidth: pt (3), curviness: % (5), curvinessVariation: % (0..100, 0; how far each turn's curviness moves from curviness), spacing: pt (5), spacingVariation: pt (0..100, 0.5; how far each line's ends move across it), variation: pt (the earlier form, read as overlapVariation and spacingVariation unless those are given), seed: int (0)} (simplified: a single hatching scribble outlined to a filled shape)",
|
||||
json!({"angle": 30.0, "overlap": 0.0, "overlapVariation": 0.5, "strokeWidth": 3.0, "curviness": 5.0, "curvinessVariation": 0.0, "spacing": 5.0, "spacingVariation": 0.5, "seed": 0}),
|
||||
),
|
||||
r(
|
||||
"stylize.dropShadow",
|
||||
"Drop Shadow…",
|
||||
STYLIZE,
|
||||
"{mode: blend mode (\"multiply\"), opacity: % (75), x: pt (7), y: pt (7), blur: pt (5), color: \"#rrggbb\" (\"#000000\")}",
|
||||
json!({"mode": "multiply", "opacity": 75.0, "x": 7.0, "y": 7.0, "blur": 5.0, "color": "#000000"}),
|
||||
"{mode: blend mode (\"multiply\"), opacity: % (75), x: pt (7), y: pt (7), blur: pt (5), shade: \"color\"|\"darkness\" (\"color\"), color: \"#rrggbb\" (\"#000000\"), darkness: % (100)} (shade \"darkness\": the shadow is the object's own colors with `darkness` percent of black added instead of `color`: in a CMYK document their cyan, magenta and yellow stay and their black K becomes K + darkness × (1 − K); in an RGB document each channel is multiplied by 1 − darkness)",
|
||||
json!({"mode": "multiply", "opacity": 75.0, "x": 7.0, "y": 7.0, "blur": 5.0, "shade": "color", "color": "#000000", "darkness": 100.0}),
|
||||
),
|
||||
r(
|
||||
"stylize.innerGlow",
|
||||
@@ -345,8 +354,8 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"blur.smart",
|
||||
"Smart Blur…",
|
||||
BLUR,
|
||||
"{radius: pt 0.1..100 (3), threshold: levels 0.1..100 (25; only colours closer than this blur together, so edges stay sharp), quality: \"low\"|\"medium\"|\"high\" (\"medium\"; 5, 7 or 9 samples across)} (Normal mode)",
|
||||
json!({"radius": 3.0, "threshold": 25.0, "quality": "medium"}),
|
||||
"{radius: pt 0.1..100 (3), threshold: levels 0.1..100 (25; only colours closer than this blur together, so edges stay sharp), quality: \"low\"|\"medium\"|\"high\" (\"medium\"; 5, 7 or 9 samples across), blurMode: \"normal\"|\"edgeOnly\"|\"overlayEdge\" (\"normal\"; edgeOnly: the edges in white on black, overlayEdge: the edges in white over the blur)}. A pixel is on an edge when one of its four nearest pixels differs from it by more than the threshold in a channel and its channels (alpha included) add up to at least that pixel's; the white and the black keep each pixel's coverage, and the radius changes only the blur",
|
||||
json!({"radius": 3.0, "threshold": 25.0, "quality": "medium", "blurMode": "normal"}),
|
||||
),
|
||||
r(
|
||||
"brushStrokes.accentedEdges",
|
||||
@@ -415,7 +424,7 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"distort.glass",
|
||||
"Glass…",
|
||||
DISTORT,
|
||||
"{distortion: 0..20 (5), smoothness: 1..15 (3), texture: \"blocks\"|\"canvas\"|\"frosted\"|\"tinyLens\" (\"frosted\"; surfaces made in code), scaling: % 50..200 (100), invert: bool (false; turns the surface's heights over)} makes the object look as if seen through glass",
|
||||
"{distortion: 0..20 (5), smoothness: 1..15 (3), texture: \"blocks\"|\"canvas\"|\"frosted\"|\"tinyLens\"|\"loaded\" (\"frosted\"; \"loaded\" draws the image in textureImage and draws Frosted when there is none; the others are surfaces made in code), textureImage?: {path} | {name?, dataBase64} | string (Load Texture…: an image file that File › Place reads, within the renderer's pixel allowance, stored as its gray levels in a grayscale PNG in base64; a file sets texture to \"loaded\" unless texture is given, and the stored string, as effect.list gives it, is kept as it is), scaling: % 50..200 (100), invert: bool (false; turns the surface's heights over)} makes the object look as if seen through glass. A loaded texture lies one image pixel per point at 100 %, centered on the object's center and repeated; each pixel's luma is the surface's height, from 0 for black to 1 pt for white, and a transparent pixel counts as middle gray",
|
||||
json!({"distortion": 5.0, "smoothness": 3.0, "texture": "frosted", "scaling": 100.0, "invert": false}),
|
||||
),
|
||||
r(
|
||||
@@ -432,6 +441,13 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"{amount: % 1..500 (50), radius: pt 0.1..250 (1; σ of the blur edges are found against), threshold: levels 0..255 (0; smaller differences are left alone)}",
|
||||
json!({"amount": 50.0, "radius": 1.0, "threshold": 0.0}),
|
||||
),
|
||||
r(
|
||||
"sketch.halftonePattern",
|
||||
"Halftone Pattern…",
|
||||
SKETCH,
|
||||
"{size: pt 1..12 (1; how far apart the screen's dots, rings or lines are), contrast: 0..50 (5; 50 draws pure black and white), patternType: \"circle\"|\"dot\"|\"line\" (\"dot\"; rings round the object's centre, dots on a square grid, horizontal lines)} redraws the object in black ink on white paper through a halftone screen whose ink covers as much as the colour is dark, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
|
||||
json!({"size": 1.0, "contrast": 5.0, "patternType": "dot"}),
|
||||
),
|
||||
r(
|
||||
"stylize.glowingEdges",
|
||||
"Glowing Edges…",
|
||||
@@ -642,8 +658,9 @@ pub fn default_params(id: &str) -> Option<Value> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `params` layered over the defaults of `id` (unknown keys are kept), with the 3D effects'
|
||||
/// earlier `perspective` read as `perspectiveAngle` (see [`upgrade_params`]).
|
||||
/// `params` layered over the defaults of `id` (unknown keys are kept), in their current form (see
|
||||
/// [`upgrade_params`]). The 3D effects' earlier `perspective` is read as `perspectiveAngle`, and
|
||||
/// Scribble's earlier `variation` is read as `overlapVariation` and `spacingVariation`.
|
||||
pub fn merged_params(id: &str, params: &Value) -> Value {
|
||||
let mut out = match catalog_index().get(id).map(|e| &e.defaults) {
|
||||
Some(Value::Object(m)) => m.clone(),
|
||||
@@ -660,14 +677,17 @@ pub fn merged_params(id: &str, params: &Value) -> Value {
|
||||
/// `params` for effect `id` in their current form. The 3D effects took Perspective as a 0–100 %
|
||||
/// strength, `perspective`, before the documented 0°–160° angle, `perspectiveAngle`, and the light
|
||||
/// as `lightAzimuth` and `lightElevation` before the documented Rotation and Height
|
||||
/// (`lightRotation`, `lightHeight`, `lightBehind`). Earlier values (from a file or a script of
|
||||
/// (`lightRotation`, `lightHeight`, `lightBehind`). Scribble took one `variation` before its
|
||||
/// separate `overlapVariation` and `spacingVariation`. Earlier values (from a file or a script of
|
||||
/// then) become the current ones that show exactly the same, unless those are given too, which win.
|
||||
/// Scribble's angle is also stored above -180° and up to 180° (270° becomes -90°).
|
||||
pub fn upgrade_params(id: &str, params: &Value) -> Value {
|
||||
match params {
|
||||
Value::Object(m) => {
|
||||
let mut m = m.clone();
|
||||
upgrade_perspective(id, &mut m);
|
||||
upgrade_light(id, &mut m);
|
||||
upgrade_scribble(id, &mut m);
|
||||
Value::Object(m)
|
||||
}
|
||||
other => other.clone(),
|
||||
@@ -700,6 +720,21 @@ fn upgrade_perspective(id: &str, m: &mut Map<String, Value>) {
|
||||
}
|
||||
}
|
||||
|
||||
fn upgrade_scribble(id: &str, m: &mut Map<String, Value>) {
|
||||
if id != "stylize.scribble" {
|
||||
return;
|
||||
}
|
||||
if let Some(variation) = m.remove("variation") {
|
||||
for key in ["overlapVariation", "spacingVariation"] {
|
||||
m.entry(key).or_insert_with(|| variation.clone());
|
||||
}
|
||||
}
|
||||
if let Some(angle) = m.get("angle").and_then(Value::as_f64).filter(|a| a.is_finite() && (*a <= -180.0 || *a > 180.0)) {
|
||||
let wrapped = angle.rem_euclid(360.0);
|
||||
m.insert("angle".into(), json!(if wrapped > 180.0 { wrapped - 360.0 } else { wrapped }));
|
||||
}
|
||||
}
|
||||
|
||||
/// A new effect record with default parameters (overridden by `params`).
|
||||
pub fn new_effect(id: &str, params: &Value) -> Option<Effect> {
|
||||
if !catalog_index().contains_key(id) {
|
||||
@@ -749,6 +784,18 @@ pub fn has_geometry(effects: &[Effect]) -> bool {
|
||||
effects.iter().any(|e| e.visible && is_geometry(&e.id))
|
||||
}
|
||||
|
||||
/// Does `n` have a visible plug-in effect, its own or a fill's or stroke's? Such an effect draws
|
||||
/// what the plug-in installed under its id draws ([`plugin_revision`]).
|
||||
pub fn has_plugin_effect(n: &Node) -> bool {
|
||||
let plugin = |e: &Effect| e.visible && vectorcraft_plugins::effect::plugin_id(&e.id).is_some();
|
||||
n.appearance.effects.iter().any(plugin) || n.appearance.items.iter().any(|i| i.effects().iter().any(plugin))
|
||||
}
|
||||
|
||||
/// The plug-in registry's revision, which changes whenever a plug-in is installed or removed.
|
||||
pub fn plugin_revision() -> u64 {
|
||||
vectorcraft_plugins::registry::revision()
|
||||
}
|
||||
|
||||
/// Context the geometry effects may need from the object's appearance.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct GeomContext<'a> {
|
||||
@@ -834,6 +881,9 @@ pub(crate) fn apply_one(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &Geo
|
||||
}
|
||||
vectorcraft_pathops::offset_path(path, off, join(p, "joins"), num(p, "miterLimit", 4.0).clamp(1.0, 500.0))
|
||||
}
|
||||
// Outline Object returns the geometry unchanged. Type reaches every geometry effect as its
|
||||
// glyph outlines (`reshape::reshape`), so type that carries it is drawn as those outlines.
|
||||
"path.outlineObject" => path.clone(),
|
||||
"path.outlineStroke" => {
|
||||
let fallback;
|
||||
let base = match ctx.stroke {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Blur › Radial Blur and Smart Blur.
|
||||
//! Blur › Radial Blur and Smart Blur (Normal, Edge Only and Overlay Edge).
|
||||
|
||||
use vectorcraft_geom::{Affine, Point, Vec2};
|
||||
|
||||
use super::{PixelSpace, Px16, box_blur, to8, to16};
|
||||
use super::{PixelSpace, Px16, box_blur, pick, to8, to16};
|
||||
|
||||
/// Zoom's total spread, in log scale: `amount` 100 scales the content between 1/√2 and √2.
|
||||
pub(super) fn zoom_extent(amount: f64) -> f64 {
|
||||
@@ -69,6 +69,39 @@ pub(super) fn radial(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace,
|
||||
/// The largest radius (pixels) Smart Blur looks over: farther neighbours are as good as gone.
|
||||
const MAX_SMART: f64 = 4096.0;
|
||||
|
||||
/// Smart Blur's modes, in its Mode menu's order: (label, parameter value).
|
||||
pub const SMART_BLUR_MODES: [(&str, &str); 3] = [("Normal", "normal"), ("Edge Only", "edgeOnly"), ("Overlay Edge", "overlayEdge")];
|
||||
|
||||
/// What Smart Blur draws ([`SMART_BLUR_MODES`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SmartBlurMode {
|
||||
/// The object blurred.
|
||||
Normal,
|
||||
/// The edges in white on black.
|
||||
EdgeOnly,
|
||||
/// The edges in white over the object blurred.
|
||||
OverlayEdge,
|
||||
}
|
||||
|
||||
impl SmartBlurMode {
|
||||
const ALL: [SmartBlurMode; 3] = [SmartBlurMode::Normal, SmartBlurMode::EdgeOnly, SmartBlurMode::OverlayEdge];
|
||||
|
||||
/// The mode of `value` (a [`SMART_BLUR_MODES`] value, any case); Normal otherwise.
|
||||
pub fn parse(value: &str) -> Self {
|
||||
pick(&SMART_BLUR_MODES, &Self::ALL, value, SmartBlurMode::Normal)
|
||||
}
|
||||
}
|
||||
|
||||
/// Smart Blur's `threshold` (8-bit levels) in the levels of [`Px16`].
|
||||
fn levels(threshold: f64) -> i32 {
|
||||
(threshold.clamp(0.0, 255.0) * 257.0) as i32
|
||||
}
|
||||
|
||||
/// Do all of `a`'s channels lie within `t` levels of `b`'s?
|
||||
fn close(a: &Px16, b: &Px16, t: i32) -> bool {
|
||||
a.iter().zip(b).all(|(a, b)| (*a as i32 - *b as i32).abs() <= t)
|
||||
}
|
||||
|
||||
/// Smart Blur (Normal mode): each pixel averaged with itself and the neighbours within `radius`
|
||||
/// pixels whose premultiplied channels all lie within `threshold` levels of its own. Wide radii
|
||||
/// look at `samples` × `samples` neighbours spread over the disc, read from a copy box-blurred
|
||||
@@ -102,7 +135,7 @@ pub(super) fn smart(px: &mut [[u8; 4]], w: usize, h: usize, radius: f64, thresho
|
||||
} else {
|
||||
&src
|
||||
};
|
||||
let t = (threshold.clamp(0.0, 255.0) * 257.0) as i32;
|
||||
let t = levels(threshold);
|
||||
for (y, (row, out)) in src.chunks_exact(w).zip(px.chunks_exact_mut(w)).enumerate().take(h) {
|
||||
for (x, (c, o)) in row.iter().zip(out.iter_mut()).enumerate() {
|
||||
let mut acc = c.map(u64::from);
|
||||
@@ -115,8 +148,7 @@ pub(super) fn smart(px: &mut [[u8; 4]], w: usize, h: usize, radius: f64, thresho
|
||||
let i = sy as usize * w + sx as usize;
|
||||
let (Some(s), Some(own)) = (near.get(i), src.get(i)) else { continue };
|
||||
// The neighbour itself and its surroundings both close enough: edges stay sharp.
|
||||
let close = |p: &Px16| p.iter().zip(c).all(|(a, b)| (*a as i32 - *b as i32).abs() <= t);
|
||||
if close(own) && close(s) {
|
||||
if close(own, c, t) && close(s, c, t) {
|
||||
for (a, v) in acc.iter_mut().zip(s) {
|
||||
*a += *v as u64;
|
||||
}
|
||||
@@ -127,3 +159,45 @@ pub(super) fn smart(px: &mut [[u8; 4]], w: usize, h: usize, radius: f64, thresho
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Smart Blur's Edge Only (`overlay` false) and Overlay Edge (`overlay` true) modes: the pixels on
|
||||
/// an edge drawn white, over black or over the Normal mode's result ([`smart`]). A pixel is on an
|
||||
/// edge when one of its four nearest neighbors (transparent beyond the raster) has a premultiplied
|
||||
/// channel more than `threshold` levels from its own and its channels add up to at least the
|
||||
/// neighbor's, so a step between two colors gives a line one pixel wide on the side whose channels
|
||||
/// add up to more, and two pixels wide when the sums are equal. The white and the black keep the
|
||||
/// pixel's coverage.
|
||||
pub(super) fn smart_edges(px: &mut [[u8; 4]], w: usize, h: usize, radius: f64, threshold: f64, samples: u32, overlay: bool) {
|
||||
let src = to16(px);
|
||||
let t = levels(threshold);
|
||||
let sum = |p: &Px16| p.iter().map(|v| u32::from(*v)).sum::<u32>();
|
||||
let at = |x: usize, y: usize, dx: isize, dy: isize| -> Px16 {
|
||||
let (Some(x), Some(y)) = (x.checked_add_signed(dx), y.checked_add_signed(dy)) else { return [0; 4] };
|
||||
if x >= w || y >= h {
|
||||
return [0; 4];
|
||||
}
|
||||
src.get(y * w + x).copied().unwrap_or([0; 4])
|
||||
};
|
||||
let edge: Vec<bool> = src
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, c)| {
|
||||
let (x, y) = (i % w.max(1), i / w.max(1));
|
||||
[(1, 0), (-1, 0), (0, 1), (0, -1)].iter().any(|(dx, dy)| {
|
||||
let n = at(x, y, *dx, *dy);
|
||||
!close(&n, c, t) && sum(c) >= sum(&n)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
if overlay {
|
||||
smart(px, w, h, radius, threshold, samples);
|
||||
}
|
||||
for ((p, c), on_edge) in px.iter_mut().zip(&src).zip(&edge) {
|
||||
let a = to8(c[3]);
|
||||
if *on_edge {
|
||||
*p = [a; 4];
|
||||
} else if !overlay {
|
||||
*p = [0, 0, 0, a];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,11 @@
|
||||
//! white grain. Every surface lies in document space around the object's centre, as the Texture
|
||||
//! filters' do.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_geom::{Point, Vec2};
|
||||
|
||||
use super::loaded::{LOADED, LoadedTexture, texture};
|
||||
use super::pixelate::{Place, value_noise};
|
||||
use super::texture::{Texture, cell_noise, luma, premultiply, smooth_noise, straight};
|
||||
use super::{PixelSpace, blur_plane, pick};
|
||||
@@ -20,13 +23,19 @@ const SALT_RIPPLE: u64 = 136;
|
||||
/// Glass's surfaces, in its Texture menu's order: (label, parameter value).
|
||||
pub const GLASS_TEXTURES: [(&str, &str); 4] = [("Blocks", "blocks"), ("Canvas", "canvas"), ("Frosted", "frosted"), ("Tiny Lens", "tinyLens")];
|
||||
|
||||
/// Glass's Texture menu once a texture is loaded: [`GLASS_TEXTURES`], then the loaded one.
|
||||
pub const GLASS_TEXTURES_LOADED: [(&str, &str); 5] =
|
||||
[("Blocks", "blocks"), ("Canvas", "canvas"), ("Frosted", "frosted"), ("Tiny Lens", "tinyLens"), ("Loaded Texture", LOADED)];
|
||||
|
||||
/// A Glass surface.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum GlassTexture {
|
||||
Blocks,
|
||||
Canvas,
|
||||
Frosted,
|
||||
TinyLens,
|
||||
/// An image the user loaded ([`LoadedTexture`]).
|
||||
Loaded(Arc<LoadedTexture>),
|
||||
}
|
||||
|
||||
impl GlassTexture {
|
||||
@@ -37,13 +46,23 @@ impl GlassTexture {
|
||||
pick(&GLASS_TEXTURES, &Self::ALL, value, GlassTexture::Frosted)
|
||||
}
|
||||
|
||||
/// The surface of `value` with `stored` the effect's `textureImage`: the loaded texture
|
||||
/// for [`LOADED`] (any case) when `stored` holds one, else as [`GlassTexture::parse`].
|
||||
pub fn parse_with(value: &str, stored: Option<&str>) -> Self {
|
||||
match stored.filter(|_| value.eq_ignore_ascii_case(LOADED)).and_then(texture) {
|
||||
Some(t) => GlassTexture::Loaded(t),
|
||||
None => Self::parse(value),
|
||||
}
|
||||
}
|
||||
|
||||
/// The surface's height at `p` (points at 100 %), in points: slopes of about 1 at most.
|
||||
fn height(self, p: Point) -> f64 {
|
||||
fn height(&self, p: Point) -> f64 {
|
||||
match self {
|
||||
GlassTexture::Blocks => blocks(p),
|
||||
GlassTexture::Canvas => Texture::Canvas.height(p),
|
||||
GlassTexture::Frosted => frosted(p),
|
||||
GlassTexture::TinyLens => tiny_lens(p),
|
||||
GlassTexture::Loaded(t) => t.height(p),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,7 +122,7 @@ pub(super) fn glass(
|
||||
space: &PixelSpace,
|
||||
distortion: f64,
|
||||
smoothness: f64,
|
||||
texture: GlassTexture,
|
||||
texture: &GlassTexture,
|
||||
scaling: f64,
|
||||
invert: bool,
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
//! Load Texture (Effect › Distort › Glass): a surface made from an image the user loads.
|
||||
//!
|
||||
//! The effect keeps the image's gray levels in its parameters ([`TEXTURE_IMAGE`]: a grayscale PNG,
|
||||
//! base64), so the document saves the texture with the effect, and copies of the effect carry it.
|
||||
//! Each pixel's gray is the surface's height there: white stands [`RELIEF`] above black. The image
|
||||
//! lies one pixel per point at 100 %, centered on the object's center and repeated in both
|
||||
//! directions.
|
||||
|
||||
use std::io::Cursor;
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
use super::texture::luma;
|
||||
|
||||
/// The effect parameter that holds a loaded texture: its gray levels as a grayscale PNG, base64.
|
||||
pub const TEXTURE_IMAGE: &str = "textureImage";
|
||||
|
||||
/// The Texture menu's value for the loaded texture.
|
||||
pub const LOADED: &str = "loaded";
|
||||
|
||||
/// How far (points) a loaded texture's white stands above its black: a black pixel beside a white
|
||||
/// one gives a slope of about 1, the most the built-in surfaces have.
|
||||
const RELIEF: f64 = 1.0;
|
||||
|
||||
/// How many decoded textures [`texture`] keeps.
|
||||
const KEEP: usize = 4;
|
||||
|
||||
/// A loaded texture: `width` × `height` gray levels, row by row from the top.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct LoadedTexture {
|
||||
width: u32,
|
||||
height: u32,
|
||||
gray: Vec<u8>,
|
||||
}
|
||||
|
||||
impl LoadedTexture {
|
||||
/// The texture of `width` × `height` straight (not premultiplied) RGBA pixels: each pixel's
|
||||
/// luma over middle gray, as much as its alpha covers it (a transparent pixel is middle gray).
|
||||
/// `None` when the image is empty or `rgba` doesn't hold exactly that many pixels.
|
||||
pub fn from_rgba(width: u32, height: u32, rgba: &[u8]) -> Option<Self> {
|
||||
let n = (width as usize).checked_mul(height as usize).filter(|n| *n > 0)?;
|
||||
let (px, rest) = rgba.as_chunks::<4>();
|
||||
if px.len() != n || !rest.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let gray = px
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let a = f32::from(p[3]) / 255.0;
|
||||
let l = luma([p[0], p[1], p[2]].map(|v| f32::from(v) / 255.0));
|
||||
((a * l + (1.0 - a) * 0.5) * 255.0).round().clamp(0.0, 255.0) as u8
|
||||
})
|
||||
.collect();
|
||||
Some(Self { width, height, gray })
|
||||
}
|
||||
|
||||
/// The size in pixels.
|
||||
pub fn size(&self) -> (u32, u32) {
|
||||
(self.width, self.height)
|
||||
}
|
||||
|
||||
/// Its [`TEXTURE_IMAGE`] value: the gray levels as a grayscale PNG, base64; `None` when the
|
||||
/// PNG can't be written.
|
||||
pub fn to_stored(&self) -> Option<String> {
|
||||
let img = image::GrayImage::from_raw(self.width, self.height, self.gray.clone())?;
|
||||
let mut png = Vec::new();
|
||||
img.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).ok()?;
|
||||
Some(base64_encode(&png))
|
||||
}
|
||||
|
||||
/// The texture of a PNG's gray levels, decoded within [`vectorcraft_doc::MAX_RASTER_ALLOC`];
|
||||
/// `None` when `png` isn't a PNG or holds no pixels.
|
||||
fn from_png(png: &[u8]) -> Option<Self> {
|
||||
let mut reader = image::ImageReader::with_format(Cursor::new(png), image::ImageFormat::Png);
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_alloc = Some(vectorcraft_doc::MAX_RASTER_ALLOC);
|
||||
reader.limits(limits);
|
||||
let img = reader.decode().ok()?.to_luma8();
|
||||
let (width, height) = img.dimensions();
|
||||
(width > 0 && height > 0).then(|| Self { width, height, gray: img.into_raw() })
|
||||
}
|
||||
|
||||
/// The surface's height (points) at `p` (points at 100 %, from the object's center): the gray
|
||||
/// levels around it interpolated, 0 for black and [`RELIEF`] for white.
|
||||
pub(super) fn height(&self, p: Point) -> f64 {
|
||||
let (w, h) = (f64::from(self.width), f64::from(self.height));
|
||||
// Pixel centers lie at whole coordinates, and the image's center lies at the origin.
|
||||
let (x, y) = (p.x + 0.5 * w - 0.5, p.y + 0.5 * h - 0.5);
|
||||
let (x0, y0) = (x.floor(), y.floor());
|
||||
let (fx, fy) = (x - x0, y - y0);
|
||||
let at = |i: f64, j: f64| {
|
||||
let (i, j) = (i.rem_euclid(w) as usize, j.rem_euclid(h) as usize);
|
||||
self.gray.get(j * self.width as usize + i).map_or(0.5, |g| f64::from(*g) / 255.0)
|
||||
};
|
||||
let top = at(x0, y0) + (at(x0 + 1.0, y0) - at(x0, y0)) * fx;
|
||||
let bottom = at(x0, y0 + 1.0) + (at(x0 + 1.0, y0 + 1.0) - at(x0, y0 + 1.0)) * fx;
|
||||
RELIEF * (top + (bottom - top) * fy)
|
||||
}
|
||||
}
|
||||
|
||||
/// The textures [`texture`] decoded last, with their stored form, the newest last.
|
||||
static DECODED: Mutex<Vec<(String, Arc<LoadedTexture>)>> = Mutex::new(Vec::new());
|
||||
|
||||
/// The texture a [`TEXTURE_IMAGE`] value holds; `None` when it isn't base64 of a PNG. The last
|
||||
/// [`KEEP`] textures decoded are kept for the next call.
|
||||
pub fn texture(stored: &str) -> Option<Arc<LoadedTexture>> {
|
||||
let mut decoded = DECODED.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if let Some(i) = decoded.iter().position(|(s, _)| s == stored) {
|
||||
let entry = decoded.remove(i);
|
||||
let t = entry.1.clone();
|
||||
decoded.push(entry);
|
||||
return Some(t);
|
||||
}
|
||||
let t = Arc::new(LoadedTexture::from_png(&base64_decode(stored)?)?);
|
||||
if decoded.len() >= KEEP {
|
||||
decoded.remove(0);
|
||||
}
|
||||
decoded.push((stored.to_string(), t.clone()));
|
||||
Some(t)
|
||||
}
|
||||
|
||||
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
||||
/// Standard base64 (RFC 4648, with padding), written as `vectorcraft_format::base64_encode`
|
||||
/// writes it.
|
||||
fn base64_encode(data: &[u8]) -> String {
|
||||
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
|
||||
for c in data.chunks(3) {
|
||||
let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32;
|
||||
out.push(B64[(n >> 18) as usize & 63] as char);
|
||||
out.push(B64[(n >> 12) as usize & 63] as char);
|
||||
out.push(if c.len() > 1 { B64[(n >> 6) as usize & 63] as char } else { '=' });
|
||||
out.push(if c.len() > 2 { B64[n as usize & 63] as char } else { '=' });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Standard base64 (RFC 4648, with padding; whitespace skipped), read as
|
||||
/// `vectorcraft_format::base64_decode` reads it; `None` when it isn't base64.
|
||||
fn base64_decode(s: &str) -> Option<Vec<u8>> {
|
||||
let val = |c: u8| B64.iter().position(|&b| b == c).map(|p| p as u32);
|
||||
let clean: Vec<u8> = s.bytes().filter(|b| !b.is_ascii_whitespace()).collect();
|
||||
if !clean.len().is_multiple_of(4) {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::with_capacity(clean.len() / 4 * 3);
|
||||
for c in clean.chunks(4) {
|
||||
let mut n = 0u32;
|
||||
let mut pad = 0;
|
||||
for &b in c {
|
||||
n <<= 6;
|
||||
if b == b'=' {
|
||||
pad += 1;
|
||||
} else {
|
||||
n |= val(b)?;
|
||||
}
|
||||
}
|
||||
out.push((n >> 16) as u8);
|
||||
if pad < 2 {
|
||||
out.push((n >> 8) as u8);
|
||||
}
|
||||
if pad < 1 {
|
||||
out.push(n as u8);
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Photoshop-style raster effects (Effect › Artistic › Film Grain, Blur › Radial Blur and Smart Blur, Brush Strokes ›
|
||||
//! Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes
|
||||
//! and Sumi-e, Distort › Diffuse Glow, Glass and Ocean Ripple, Pixelate › Color Halftone,
|
||||
//! Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, Stylize › Glowing Edges, Texture
|
||||
//! Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, Sketch › Halftone Pattern, Stylize ›
|
||||
//! Glowing Edges, Texture
|
||||
//! › Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer, and Video ›
|
||||
//! De-Interlace and NTSC Colors): filters over premultiplied RGBA8 pixels.
|
||||
//!
|
||||
@@ -17,8 +18,10 @@ mod blur;
|
||||
mod brushstrokes;
|
||||
mod distort;
|
||||
mod edges;
|
||||
mod loaded;
|
||||
mod pixelate;
|
||||
mod sharpen;
|
||||
mod sketch;
|
||||
mod texture;
|
||||
mod video;
|
||||
|
||||
@@ -27,13 +30,16 @@ use vectorcraft_geom::{Affine, Point, Rect};
|
||||
|
||||
use crate::util::{flag, num, text};
|
||||
|
||||
pub use blur::{SMART_BLUR_MODES, SmartBlurMode};
|
||||
pub use brushstrokes::{STROKE_DIRECTIONS, StrokeDirection};
|
||||
pub use distort::{GLASS_TEXTURES, GlassTexture};
|
||||
pub use distort::{GLASS_TEXTURES, GLASS_TEXTURES_LOADED, GlassTexture};
|
||||
pub use loaded::{LOADED, LoadedTexture, TEXTURE_IMAGE, texture};
|
||||
pub use pixelate::{MEZZOTINT_TYPES, Mezzotint};
|
||||
pub use sketch::{HALFTONE_TYPES, HalftonePattern};
|
||||
pub use texture::{GRAIN_TYPES, Grain, LIGHT_DIRECTIONS, Light, TEXTURES, Texture};
|
||||
|
||||
/// The Photoshop-style effect ids (all raster effects, see [`crate::is_raster`]).
|
||||
pub const PIXEL_EFFECTS: [&str; 28] = [
|
||||
pub const PIXEL_EFFECTS: [&str; 29] = [
|
||||
"artistic.filmGrain",
|
||||
"blur.radial",
|
||||
"blur.smart",
|
||||
@@ -53,6 +59,7 @@ pub const PIXEL_EFFECTS: [&str; 28] = [
|
||||
"pixelate.mezzotint",
|
||||
"pixelate.pointillize",
|
||||
"sharpen.unsharpMask",
|
||||
"sketch.halftonePattern",
|
||||
"stylize.glowingEdges",
|
||||
"texture.craquelure",
|
||||
"texture.grain",
|
||||
@@ -66,7 +73,7 @@ pub const PIXEL_EFFECTS: [&str; 28] = [
|
||||
|
||||
/// A Photoshop-style raster effect with its parameters read and clamped to their ranges. Lengths
|
||||
/// are in document units.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum PixelFx {
|
||||
/// Artistic › Film Grain: monochrome grain in shadows and midtones, a wider bright
|
||||
/// highlight area (0..20), and tonal intensity (0..10), preserving coverage.
|
||||
@@ -77,7 +84,10 @@ pub enum PixelFx {
|
||||
/// Blur › Smart Blur (Normal mode): each pixel averaged with the neighbours within `radius`
|
||||
/// whose colour differs from its own by no more than `threshold` levels, over a grid of
|
||||
/// `samples` × `samples` neighbours at most.
|
||||
SmartBlur { radius: f64, threshold: f64, samples: u32 },
|
||||
/// The Edge Only `mode` draws the edges where neighboring pixels differ by more than
|
||||
/// `threshold` levels in white on black, and Overlay Edge draws them in white over the Normal
|
||||
/// result.
|
||||
SmartBlur { radius: f64, threshold: f64, samples: u32, mode: SmartBlurMode },
|
||||
/// Brush Strokes › Accented Edges: the edges, about `width` (1..14) wide, accented towards
|
||||
/// white chalk or black ink as `brightness` (0..50) says; `smoothness` (1..15) softens them.
|
||||
AccentedEdges { width: f64, brightness: f64, smoothness: f64 },
|
||||
@@ -107,9 +117,9 @@ pub enum PixelFx {
|
||||
/// Distort › Diffuse Glow: the highlights glow white as strongly as `glow` (0..20), from the
|
||||
/// brightness `clear` (0..20) leaves clear, under white grain as dense as `graininess` (0..10).
|
||||
DiffuseGlow { graininess: f64, glow: f64, clear: f64 },
|
||||
/// Distort › Glass: the object seen through a `texture` surface scaled by `scaling` (0.5..2),
|
||||
/// bent by `distortion` (0..20), smoothed by `smoothness` (1..15), its heights turned over when
|
||||
/// `invert`.
|
||||
/// Distort › Glass: the object seen through a `texture` surface (a built-in one, or an image
|
||||
/// the user loaded) scaled by `scaling` (0.5..2), bent by `distortion` (0..20), smoothed by
|
||||
/// `smoothness` (1..15), its heights turned over when `invert`.
|
||||
Glass { distortion: f64, smoothness: f64, texture: GlassTexture, scaling: f64, invert: bool },
|
||||
/// Distort › Ocean Ripple: the object under ripples of `size` (1..15) shifting it as much as
|
||||
/// `magnitude` (0..20) says.
|
||||
@@ -117,6 +127,9 @@ pub enum PixelFx {
|
||||
/// Sharpen › Unsharp Mask: colours pushed away from a Gaussian blur of σ = `radius` by `amount`
|
||||
/// (1 = 100 %), where they differ from it by at least `threshold` levels.
|
||||
UnsharpMask { amount: f64, radius: f64, threshold: f64 },
|
||||
/// Sketch › Halftone Pattern: black ink on white paper through a `kind` screen of cells `size`
|
||||
/// (1..12) apart, its edges as hard as `contrast` (0..50) says.
|
||||
HalftonePattern { size: f64, contrast: f64, kind: HalftonePattern },
|
||||
/// Stylize › Glowing Edges: bright coloured Sobel outlines on black.
|
||||
GlowingEdges { width: f64, brightness: f64, smoothness: f64 },
|
||||
/// Pixelate › Color Halftone: each colour channel screened at its angle (`angles`° of channels
|
||||
@@ -211,6 +224,7 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
|
||||
radius: num(p, "radius", 3.0).clamp(0.1, 100.0),
|
||||
threshold: num(p, "threshold", 25.0).clamp(0.1, 100.0),
|
||||
samples: quality([("low", 5), ("medium", 7), ("high", 9)], 7),
|
||||
mode: SmartBlurMode::parse(text(p, "blurMode", "normal")),
|
||||
},
|
||||
"brushStrokes.accentedEdges" => PixelFx::AccentedEdges {
|
||||
width: num(p, "edgeWidth", 2.0).clamp(1.0, 14.0),
|
||||
@@ -258,7 +272,7 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
|
||||
"distort.glass" => PixelFx::Glass {
|
||||
distortion: num(p, "distortion", 5.0).clamp(0.0, 20.0),
|
||||
smoothness: num(p, "smoothness", 3.0).clamp(1.0, 15.0),
|
||||
texture: GlassTexture::parse(text(p, "texture", "frosted")),
|
||||
texture: GlassTexture::parse_with(text(p, "texture", "frosted"), p.get(TEXTURE_IMAGE).and_then(Value::as_str)),
|
||||
scaling: num(p, "scaling", 100.0).clamp(50.0, 200.0) / 100.0,
|
||||
invert: flag(p, "invert", false),
|
||||
},
|
||||
@@ -270,6 +284,11 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
|
||||
radius: num(p, "radius", 1.0).clamp(0.1, 250.0),
|
||||
threshold: num(p, "threshold", 0.0).clamp(0.0, 255.0),
|
||||
},
|
||||
"sketch.halftonePattern" => PixelFx::HalftonePattern {
|
||||
size: num(p, "size", 1.0).clamp(1.0, 12.0),
|
||||
contrast: num(p, "contrast", 5.0).clamp(0.0, 50.0),
|
||||
kind: HalftonePattern::parse(text(p, "patternType", "dot")),
|
||||
},
|
||||
"stylize.glowingEdges" => PixelFx::GlowingEdges {
|
||||
width: num(p, "edgeWidth", 2.0).clamp(1.0, 14.0),
|
||||
brightness: num(p, "edgeBrightness", 6.0).clamp(0.0, 20.0),
|
||||
@@ -346,7 +365,7 @@ impl PixelFx {
|
||||
// Content shifted outwards reaches past the bounds by as much as the shift.
|
||||
PixelFx::Glass { distortion, .. } => distort::glass_shift(distortion),
|
||||
PixelFx::OceanRipple { size, magnitude } => distort::ripple_shift(size, magnitude),
|
||||
PixelFx::UnsharpMask { .. } => 0.0,
|
||||
PixelFx::UnsharpMask { .. } | PixelFx::HalftonePattern { .. } => 0.0,
|
||||
PixelFx::GlowingEdges { width, smoothness, .. } => width.max(smoothness * 3.0),
|
||||
PixelFx::FilmGrain { .. }
|
||||
| PixelFx::ColorHalftone { .. }
|
||||
@@ -389,7 +408,7 @@ impl PixelFx {
|
||||
// The cells around the pixel's, and the cells around theirs.
|
||||
PixelFx::ColorHalftone { max_radius, .. } => Some(3.0 * std::f64::consts::SQRT_2 * max_radius),
|
||||
PixelFx::Crystallize { cell } => Some(1.5 * cell),
|
||||
PixelFx::FilmGrain { .. } | PixelFx::Mezzotint { .. } => Some(0.0),
|
||||
PixelFx::FilmGrain { .. } | PixelFx::Mezzotint { .. } | PixelFx::HalftonePattern { .. } => Some(0.0),
|
||||
// The dots' points, and the softened colour around them.
|
||||
PixelFx::Pointillize { cell } => Some(2.5 * cell),
|
||||
// The softened tones around the pixel.
|
||||
@@ -412,7 +431,12 @@ impl PixelFx {
|
||||
match *self {
|
||||
PixelFx::FilmGrain { grain, highlight, intensity } => artistic::film_grain(px, w, h, space, grain, highlight, intensity),
|
||||
PixelFx::RadialBlur { amount, zoom, passes } => blur::radial(px, w, h, space, amount, zoom, passes),
|
||||
PixelFx::SmartBlur { radius, threshold, samples } => blur::smart(px, w, h, to_px(radius), threshold, samples),
|
||||
PixelFx::SmartBlur { radius, threshold, samples, mode: SmartBlurMode::Normal } => {
|
||||
blur::smart(px, w, h, to_px(radius), threshold, samples)
|
||||
}
|
||||
PixelFx::SmartBlur { radius, threshold, samples, mode } => {
|
||||
blur::smart_edges(px, w, h, to_px(radius), threshold, samples, mode == SmartBlurMode::OverlayEdge)
|
||||
}
|
||||
PixelFx::AccentedEdges { width, brightness, smoothness } => brushstrokes::accented_edges(px, w, h, space, width, brightness, smoothness),
|
||||
PixelFx::AngledStrokes { balance, length, sharpness } => brushstrokes::angled_strokes(px, w, h, space, balance, length, sharpness),
|
||||
PixelFx::Crosshatch { length, sharpness, strength } => brushstrokes::crosshatch(px, w, h, space, length, sharpness, strength),
|
||||
@@ -422,11 +446,12 @@ impl PixelFx {
|
||||
PixelFx::SprayedStrokes { length, radius, direction } => brushstrokes::sprayed_strokes(px, w, h, space, length, radius, direction),
|
||||
PixelFx::SumiE { width, pressure, contrast } => brushstrokes::sumi_e(px, w, h, space, width, pressure, contrast),
|
||||
PixelFx::DiffuseGlow { graininess, glow, clear } => distort::diffuse_glow(px, w, h, space, graininess, glow, clear),
|
||||
PixelFx::Glass { distortion, smoothness, texture, scaling, invert } => {
|
||||
PixelFx::Glass { distortion, smoothness, ref texture, scaling, invert } => {
|
||||
distort::glass(px, w, h, space, distortion, smoothness, texture, scaling, invert)
|
||||
}
|
||||
PixelFx::OceanRipple { size, magnitude } => distort::ocean_ripple(px, w, h, space, size, magnitude),
|
||||
PixelFx::UnsharpMask { amount, radius, threshold } => sharpen::unsharp(px, w, h, amount, to_px(radius), threshold),
|
||||
PixelFx::HalftonePattern { size, contrast, kind } => sketch::halftone_pattern(px, w, h, space, size, contrast, kind),
|
||||
PixelFx::GlowingEdges { width, brightness, smoothness } => edges::glow(px, w, h, to_px(width), brightness, to_px(smoothness)),
|
||||
PixelFx::ColorHalftone { max_radius, angles } => pixelate::color_halftone(px, w, h, space, max_radius, angles),
|
||||
PixelFx::Crystallize { cell } => pixelate::crystallize(px, w, h, space, cell),
|
||||
@@ -448,8 +473,8 @@ impl PixelFx {
|
||||
|
||||
/// The entry of `all` whose value in `table` (the same order) is `value`, any case; `default`
|
||||
/// otherwise.
|
||||
fn pick<T: Copy>(table: &[(&str, &str)], all: &[T], value: &str, default: T) -> T {
|
||||
table.iter().zip(all).find(|((_, v), _)| v.eq_ignore_ascii_case(value)).map_or(default, |(_, t)| *t)
|
||||
fn pick<T: Clone>(table: &[(&str, &str)], all: &[T], value: &str, default: T) -> T {
|
||||
table.iter().zip(all).find(|((_, v), _)| v.eq_ignore_ascii_case(value)).map_or(default, |(_, t)| t.clone())
|
||||
}
|
||||
|
||||
/// `data` as `w` × `h` RGBA pixels, `None` when it isn't that size (or is empty).
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
//! Effect › Sketch › Halftone Pattern. Original filter built from public behaviour documentation:
|
||||
//! <https://helpx.adobe.com/illustrator/using/summary-effects.html> (Sketch effects redraw in
|
||||
//! black and white; Halftone Pattern simulates a halftone screen while keeping the range of tones)
|
||||
//! and <https://helpx.adobe.com/illustrator/using/effects.html> (only Size depends on the raster
|
||||
//! resolution). A visual approximation, not a reproduction of a proprietary algorithm.
|
||||
|
||||
use super::pixelate::Place;
|
||||
use super::texture::{luma, premultiply, straight};
|
||||
use super::{PixelSpace, pick};
|
||||
|
||||
/// Halftone Pattern's screens, in its Pattern Type menu's order: (label, parameter value).
|
||||
pub const HALFTONE_TYPES: [(&str, &str); 3] = [("Circle", "circle"), ("Dot", "dot"), ("Line", "line")];
|
||||
|
||||
/// A Halftone Pattern screen ([`HALFTONE_TYPES`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum HalftonePattern {
|
||||
/// Rings round the object's centre.
|
||||
Circle,
|
||||
/// Dots on a square grid.
|
||||
Dot,
|
||||
/// Horizontal lines.
|
||||
Line,
|
||||
}
|
||||
|
||||
impl HalftonePattern {
|
||||
const ALL: [HalftonePattern; 3] = [HalftonePattern::Circle, HalftonePattern::Dot, HalftonePattern::Line];
|
||||
|
||||
/// The screen of Pattern Type `value` (a [`HALFTONE_TYPES`] value, any case); Dot otherwise.
|
||||
pub fn parse(value: &str) -> Self {
|
||||
pick(&HALFTONE_TYPES, &Self::ALL, value, HalftonePattern::Dot)
|
||||
}
|
||||
}
|
||||
|
||||
/// Halftone Pattern: the object redrawn in black ink on white paper through a `kind` screen whose
|
||||
/// cells are `size` points apart, anchored at the object's centre. The ink covers as much of a
|
||||
/// cell as the colour is dark; `contrast` (0..50) hardens the screen's soft edges, to pure black
|
||||
/// and white at 50. Coverage is kept.
|
||||
pub(super) fn halftone_pattern(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, size: f64, contrast: f64, kind: HalftonePattern) {
|
||||
if !(size.is_finite() && size > 0.0 && contrast.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
// The width of the screen's soft edge, in tones: 0 is a hard threshold.
|
||||
let soft = (0.5 * (1.0 - contrast / 50.0)).clamp(0.0, 0.5);
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let q = place.at(x, y);
|
||||
let band = |v: f64| (2.0 * ((v / size).rem_euclid(1.0) - 0.5)).abs();
|
||||
// Where in its cell the pixel lies, 0 where the ink starts to 1 where it ends.
|
||||
let screen = match kind {
|
||||
HalftonePattern::Line => band(q.y),
|
||||
HalftonePattern::Circle => band(q.x.hypot(q.y)),
|
||||
HalftonePattern::Dot => 0.5 * (band(q.x).powi(2) + band(q.y).powi(2)),
|
||||
};
|
||||
let dark = f64::from(1.0 - luma(straight(*p))).clamp(0.0, 1.0);
|
||||
let ink = if dark >= 1.0 {
|
||||
1.0
|
||||
} else if soft > 0.0 {
|
||||
// The threshold kept inside [soft / 2, 1 - soft / 2], so white stays white and black black.
|
||||
let threshold = 0.5 * soft + screen * (1.0 - soft);
|
||||
((dark - threshold) / soft + 0.5).clamp(0.0, 1.0)
|
||||
} else if dark > screen {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
*p = premultiply([(1.0 - ink) as f32; 3], f32::from(p[3]) / 255.0);
|
||||
});
|
||||
}
|
||||
@@ -36,7 +36,7 @@ fn at(d: &[u8], w: usize, x: usize, y: usize) -> [u8; 4] {
|
||||
fn fx(id: &str, p: serde_json::Value) -> PixelFx {
|
||||
let e = Effect { id: id.into(), params: p, visible: true };
|
||||
match raster_effects(&[e]).first() {
|
||||
Some(RasterFx::Pixel(p)) => *p,
|
||||
Some(RasterFx::Pixel(p)) => p.clone(),
|
||||
other => panic!("{id}: {other:?}"),
|
||||
}
|
||||
}
|
||||
@@ -465,11 +465,22 @@ fn params_take_defaults_and_stay_in_range() {
|
||||
PixelFx::RadialBlur { amount: 100.0, zoom: true, passes: 8 }
|
||||
);
|
||||
assert_eq!(fx("blur.radial", json!({"amount": -5, "quality": "nonsense"})), PixelFx::RadialBlur { amount: 1.0, zoom: false, passes: 6 });
|
||||
assert_eq!(fx("blur.smart", json!({})), PixelFx::SmartBlur { radius: 3.0, threshold: 25.0, samples: 7 });
|
||||
assert_eq!(fx("blur.smart", json!({})), PixelFx::SmartBlur { radius: 3.0, threshold: 25.0, samples: 7, mode: SmartBlurMode::Normal });
|
||||
assert_eq!(
|
||||
fx("blur.smart", json!({"radius": "1e999", "threshold": -1, "quality": "low"})),
|
||||
PixelFx::SmartBlur { radius: 3.0, threshold: 0.1, samples: 5 }
|
||||
fx("blur.smart", json!({"radius": "1e999", "threshold": -1, "quality": "low", "blurMode": "EDGEONLY"})),
|
||||
PixelFx::SmartBlur { radius: 3.0, threshold: 0.1, samples: 5, mode: SmartBlurMode::EdgeOnly }
|
||||
);
|
||||
assert_eq!(
|
||||
fx("blur.smart", json!({"blurMode": "overlayEdge"})),
|
||||
PixelFx::SmartBlur { radius: 3.0, threshold: 25.0, samples: 7, mode: SmartBlurMode::OverlayEdge }
|
||||
);
|
||||
assert_eq!(fx("blur.smart", json!({"blurMode": "overlay"})), fx("blur.smart", json!({})));
|
||||
// Every Mode menu value is documented in the catalogue and parses to a mode of its own.
|
||||
let doc = crate::effect_info("blur.smart").unwrap().params;
|
||||
for (i, (_, v)) in SMART_BLUR_MODES.iter().enumerate() {
|
||||
assert!(doc.contains(&format!("\"{v}\"")), "{v}");
|
||||
assert_eq!(SmartBlurMode::parse(v), [SmartBlurMode::Normal, SmartBlurMode::EdgeOnly, SmartBlurMode::OverlayEdge][i], "{v}");
|
||||
}
|
||||
assert_eq!(fx("sharpen.unsharpMask", json!({})), PixelFx::UnsharpMask { amount: 0.5, radius: 1.0, threshold: 0.0 });
|
||||
assert_eq!(
|
||||
fx("sharpen.unsharpMask", json!({"amount": 1e9, "radius": 0, "threshold": 1e9})),
|
||||
@@ -616,6 +627,105 @@ fn smart_blur_smooths_noise_and_keeps_edges() {
|
||||
assert_eq!(at(&all, w, 20, 10)[0], 255, "a 155-level step is still past 100");
|
||||
}
|
||||
|
||||
/// FNV-1a of `bytes`.
|
||||
fn digest(bytes: &[u8]) -> u64 {
|
||||
bytes.iter().fold(0xcbf2_9ce4_8422_2325, |h, b| (h ^ u64::from(*b)).wrapping_mul(0x0100_0000_01b3))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smart_blur_normal_mode_keeps_its_pixels() {
|
||||
let (w, h) = (48, 40);
|
||||
let src = image(w, h, |x, y| {
|
||||
let a = if x > 4 && y < 36 { 255 } else { 128 };
|
||||
[((x * 5 + y) as u8).min(a), ((y * 6) as u8).min(a), (((x * y) % 97) as u8).min(a), a]
|
||||
});
|
||||
let mut sums = Vec::new();
|
||||
for p in [json!({}), json!({"radius": 2, "threshold": 10, "quality": "high"}), json!({"radius": 12, "threshold": 40, "quality": "low"})] {
|
||||
let mut d = src.clone();
|
||||
fx("blur.smart", p.clone()).apply(&mut d, w, h, &space(w, h));
|
||||
let mut normal = src.clone();
|
||||
let mut p = p;
|
||||
p["blurMode"] = json!("normal");
|
||||
fx("blur.smart", p).apply(&mut normal, w, h, &space(w, h));
|
||||
assert_eq!(d, normal);
|
||||
sums.push(digest(&d));
|
||||
}
|
||||
// The pixels Smart Blur gave before it had a mode.
|
||||
assert_eq!(sums, [11495339692959753241, 14281410954765351056, 5111933201350526354]);
|
||||
}
|
||||
|
||||
/// Smart Blur's test image: grays 56 and 64 in a checkerboard for x < 3 and 66 and 74 for x in 3..6
|
||||
/// (steps of 2 or 18 levels at x = 3, under the threshold), gray 200 from x = 6 on (steps of 126 or
|
||||
/// 134 levels). Its edges at threshold 25 are the pixels on the raster's border (transparency is
|
||||
/// beyond it) and column 6, the step's lighter side.
|
||||
fn smart_blur_steps() -> (usize, usize, Vec<u8>, impl Fn(usize, usize) -> bool) {
|
||||
let (w, h) = (12, 8);
|
||||
let src = image(w, h, |x, y| match x {
|
||||
0..3 => grey(if (x + y) % 2 == 0 { 56 } else { 64 }),
|
||||
3..6 => grey(if (x + y) % 2 == 0 { 66 } else { 74 }),
|
||||
_ => grey(200),
|
||||
});
|
||||
(w, h, src, move |x, y| x == 0 || y == 0 || x == w - 1 || y == h - 1 || x == 6)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smart_blur_edge_only_draws_the_edges_white_on_black() {
|
||||
let (w, h, src, edge) = smart_blur_steps();
|
||||
let mut d = src.clone();
|
||||
fx("blur.smart", json!({"blurMode": "edgeOnly"})).apply(&mut d, w, h, &space(w, h));
|
||||
let want = image(w, h, |x, y| if edge(x, y) { [255; 4] } else { [0, 0, 0, 255] });
|
||||
assert_eq!(d, want);
|
||||
// The radius changes only the blur.
|
||||
let mut wide = src.clone();
|
||||
fx("blur.smart", json!({"blurMode": "edgeOnly", "radius": 40})).apply(&mut wide, w, h, &space(w, h));
|
||||
assert_eq!(wide, want);
|
||||
// A threshold under the checkerboard's 8 levels makes its lighter squares edges too.
|
||||
let mut low = src.clone();
|
||||
fx("blur.smart", json!({"blurMode": "edgeOnly", "threshold": 5})).apply(&mut low, w, h, &space(w, h));
|
||||
for y in 1..h - 1 {
|
||||
for x in 1..6 {
|
||||
let lighter = matches!(at(&src, w, x, y)[0], 64 | 74);
|
||||
assert_eq!(at(&low, w, x, y)[0] == 255, lighter, "({x}, {y})");
|
||||
}
|
||||
}
|
||||
// White and black keep each pixel's coverage, and transparency stays.
|
||||
let (w, h) = (8, 8);
|
||||
let shape = image(w, h, |x, y| if (2..6).contains(&x) && (2..6).contains(&y) { [64, 0, 0, 128] } else { [0; 4] });
|
||||
let mut s = shape.clone();
|
||||
fx("blur.smart", json!({"blurMode": "edgeOnly"})).apply(&mut s, w, h, &space(w, h));
|
||||
let ring = |x: usize, y: usize| (2..6).contains(&x) && (2..6).contains(&y) && !((3..5).contains(&x) && (3..5).contains(&y));
|
||||
let inside = |x: usize, y: usize| (3..5).contains(&x) && (3..5).contains(&y);
|
||||
let want = image(w, h, |x, y| match (ring(x, y), inside(x, y)) {
|
||||
(true, _) => [128; 4],
|
||||
(_, true) => [0, 0, 0, 128],
|
||||
_ => [0; 4],
|
||||
});
|
||||
assert_eq!(s, want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smart_blur_overlay_edge_draws_the_edges_white_over_the_blur() {
|
||||
let (w, h, src, edge) = smart_blur_steps();
|
||||
let mut normal = src.clone();
|
||||
fx("blur.smart", json!({})).apply(&mut normal, w, h, &space(w, h));
|
||||
assert_ne!(normal, src, "the checkerboard is blurred");
|
||||
let mut d = src.clone();
|
||||
fx("blur.smart", json!({"blurMode": "overlayEdge"})).apply(&mut d, w, h, &space(w, h));
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let want = if edge(x, y) { [255; 4] } else { at(&normal, w, x, y) };
|
||||
assert_eq!(at(&d, w, x, y), want, "({x}, {y})");
|
||||
}
|
||||
}
|
||||
// Parameters past their ranges, at any resolution, still finish.
|
||||
for mode in ["edgeOnly", "overlayEdge"] {
|
||||
for s in [PixelSpace { px: 1e-9, ..space(w, h) }, PixelSpace { px: 1e9, ..space(w, h) }] {
|
||||
let mut e = src.clone();
|
||||
fx("blur.smart", json!({"blurMode": mode, "radius": 1e308, "threshold": 1e308, "quality": "high"})).apply(&mut e, w, h, &s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsharp_mask_overshoots_at_edges() {
|
||||
let (w, h) = (40, 10);
|
||||
@@ -1309,6 +1419,68 @@ fn glass_bends_the_image_by_its_texture() {
|
||||
assert_ne!(run(json!({"distortion": 12, "smoothness": 15})), run(json!({"distortion": 12, "smoothness": 1})));
|
||||
}
|
||||
|
||||
/// The stored form of a `w` × `h` texture whose straight RGBA pixels `f` gives.
|
||||
fn stored(w: usize, h: usize, f: impl Fn(usize, usize) -> [u8; 4]) -> String {
|
||||
LoadedTexture::from_rgba(w as u32, h as u32, &image(w, h, f)).and_then(|t| t.to_stored()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_loaded_texture_is_its_gray_over_middle_gray() {
|
||||
let t = stored(3, 1, |x, _| [[255, 255, 255, 255], [0, 0, 0, 0], [255, 0, 0, 255]][x]);
|
||||
let loaded = texture(&t).unwrap();
|
||||
assert_eq!(loaded.size(), (3, 1));
|
||||
assert_eq!(texture(&t), Some(loaded.clone()), "read again from the cache");
|
||||
// A white pixel is 1 high, a transparent one is middle gray and a red one is its luma. Heights
|
||||
// between pixel centers are interpolated, and the image repeats: x = -1 is the first pixel's
|
||||
// center, and x = 2 is the same pixel again.
|
||||
let h = |x: f64| loaded.height(Point::new(x, 0.0));
|
||||
assert_eq!(h(-1.0), 1.0);
|
||||
assert_eq!(h(0.0), 128.0 / 255.0);
|
||||
assert_eq!(h(1.0), 76.0 / 255.0);
|
||||
assert_eq!(h(2.0), 1.0);
|
||||
assert_eq!(h(-0.5), 0.5 * (1.0 + 128.0 / 255.0));
|
||||
assert!(LoadedTexture::from_rgba(0, 4, &[]).is_none() && LoadedTexture::from_rgba(2, 2, &[0; 12]).is_none());
|
||||
assert!(texture("not base64!").is_none() && texture("aGVsbG8=").is_none(), "base64 of a PNG only");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glass_bends_the_image_by_a_loaded_texture() {
|
||||
let (w, h) = (48, 40);
|
||||
let src = stripes(w, h);
|
||||
let run = |p: serde_json::Value| {
|
||||
let mut d = src.clone();
|
||||
fx("distort.glass", p).apply(&mut d, w, h, &space(w, h));
|
||||
d
|
||||
};
|
||||
// Rings around the texture's center, 9 × 7 pixels, repeated.
|
||||
let rings = stored(9, 7, |x, y| grey((((x as f64 - 4.0).hypot(y as f64 - 3.0) * 60.0) as u32 % 256) as u8));
|
||||
let loaded = json!({"texture": "loaded", "textureImage": rings, "distortion": 12});
|
||||
let bent = run(loaded.clone());
|
||||
assert_ne!(bent, src);
|
||||
for (_, texture) in GLASS_TEXTURES {
|
||||
assert_ne!(bent, run(json!({"texture": texture, "distortion": 12})), "differs from {texture}");
|
||||
}
|
||||
assert_ne!(run(json!({"texture": "loaded", "textureImage": rings, "distortion": 12, "invert": true})), bent, "inverted");
|
||||
assert_ne!(run(json!({"texture": "loaded", "textureImage": rings, "distortion": 12, "scaling": 200})), bent, "scaled");
|
||||
for (x, y) in (6..w - 6).flat_map(|x| (6..h - 6).map(move |y| (x, y))) {
|
||||
let p = at(&bent, w, x, y);
|
||||
assert!(p[3] == 255 && p[0] >= 20 && p[0] <= 230, "at ({x}, {y}): {p:?}");
|
||||
}
|
||||
assert_eq!(run(json!({"texture": "loaded", "textureImage": rings, "distortion": 0})), src, "no distortion, no change");
|
||||
// A flat texture bends nothing, whatever its gray; so does a transparent one (middle gray).
|
||||
for flat in [grey(0), grey(200), [0, 0, 0, 0]] {
|
||||
assert_eq!(run(json!({"texture": "loaded", "textureImage": stored(5, 5, |_, _| flat), "distortion": 20})), src, "{flat:?}");
|
||||
}
|
||||
// The image is kept but not used with a built-in texture; without one, "loaded" is Frosted.
|
||||
assert_eq!(run(json!({"texture": "blocks", "textureImage": rings, "distortion": 12})), run(json!({"texture": "blocks", "distortion": 12})));
|
||||
assert_eq!(fx("distort.glass", json!({"texture": "loaded"})), fx("distort.glass", json!({})));
|
||||
assert_eq!(fx("distort.glass", json!({"texture": "LOADED", "textureImage": "not a texture"})), fx("distort.glass", json!({})));
|
||||
assert_eq!(
|
||||
fx("distort.glass", loaded),
|
||||
PixelFx::Glass { distortion: 12.0, smoothness: 3.0, texture: GlassTexture::Loaded(texture(&rings).unwrap()), scaling: 1.0, invert: false }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ocean_ripple_moves_content_no_farther_than_its_magnitude() {
|
||||
let (w, h) = (64, 32);
|
||||
@@ -1721,3 +1893,119 @@ fn brush_strokes_follow_the_object_at_any_resolution() {
|
||||
assert_eq!(shifted, one, "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Halftone Pattern of `p` over `w` × `h` pixels of `src`, laid out by `s`.
|
||||
fn halftone(p: serde_json::Value, src: &[u8], w: usize, h: usize, s: &PixelSpace) -> Vec<u8> {
|
||||
let mut out = src.to_vec();
|
||||
fx("sketch.halftonePattern", p).apply(&mut out, w, h, s);
|
||||
out
|
||||
}
|
||||
|
||||
/// The smallest period of `values` (their length when they don't repeat).
|
||||
fn period(values: &[u8]) -> usize {
|
||||
(1..values.len()).find(|p| values.iter().zip(&values[*p..]).all(|(a, b)| a == b)).unwrap_or(values.len())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn halftone_pattern_params_take_defaults_and_stay_in_range() {
|
||||
assert_eq!(fx("sketch.halftonePattern", json!({})), PixelFx::HalftonePattern { size: 1.0, contrast: 5.0, kind: HalftonePattern::Dot });
|
||||
assert_eq!(
|
||||
fx("sketch.halftonePattern", json!({"size": 1e9, "contrast": -3, "patternType": "LINE"})),
|
||||
PixelFx::HalftonePattern { size: 12.0, contrast: 0.0, kind: HalftonePattern::Line }
|
||||
);
|
||||
assert_eq!(
|
||||
fx("sketch.halftonePattern", json!({"size": 0, "contrast": 1e9, "patternType": "circle"})),
|
||||
PixelFx::HalftonePattern { size: 1.0, contrast: 50.0, kind: HalftonePattern::Circle }
|
||||
);
|
||||
for junk in [json!({"patternType": 2}), json!({"patternType": "nonsense"})] {
|
||||
assert_eq!(fx("sketch.halftonePattern", junk), fx("sketch.halftonePattern", json!({})));
|
||||
}
|
||||
let doc = crate::effect_info("sketch.halftonePattern").unwrap().params;
|
||||
let kinds: Vec<HalftonePattern> = HALFTONE_TYPES.iter().map(|(_, v)| HalftonePattern::parse(v)).collect();
|
||||
assert_eq!(kinds, [HalftonePattern::Circle, HalftonePattern::Dot, HalftonePattern::Line]);
|
||||
assert!(HALFTONE_TYPES.iter().all(|(_, v)| doc.contains(&format!("\"{v}\""))), "{doc}");
|
||||
let f = fx("sketch.halftonePattern", json!({"size": 12}));
|
||||
assert_eq!(f.outset(Rect::new(0.0, 0.0, 100.0, 50.0)), 0.0);
|
||||
assert_eq!(f.reach(), Some(0.0));
|
||||
}
|
||||
|
||||
/// Line's rows repeat every Size points: Size pixels at 72 ppi, twice as many at 144 ppi.
|
||||
#[test]
|
||||
fn halftone_line_period_is_the_size_at_any_resolution() {
|
||||
let p = json!({"size": 4, "contrast": 50, "patternType": "line"});
|
||||
let (w, h) = (48, 48);
|
||||
let out = halftone(p.clone(), &image(w, h, |_, _| grey(128)), w, h, &space(w, h));
|
||||
let column: Vec<u8> = (0..h).map(|y| at(&out, w, 0, y)[0]).collect();
|
||||
assert!(column.contains(&0) && column.contains(&255), "{column:?}");
|
||||
assert_eq!(period(&column), 4, "{column:?}");
|
||||
let fine = PixelSpace { to_doc: Affine::scale(0.5), px: 0.5, ..space(w, h) };
|
||||
let out = halftone(p, &image(2 * w, 2 * h, |_, _| grey(128)), 2 * w, 2 * h, &fine);
|
||||
let column: Vec<u8> = (0..2 * h).map(|y| at(&out, 2 * w, 0, y)[0]).collect();
|
||||
assert_eq!(period(&column), 8, "{column:?}");
|
||||
}
|
||||
|
||||
/// Circle draws rings round the object's centre, Line rows that don't change along them, and
|
||||
/// Dot neither.
|
||||
#[test]
|
||||
fn halftone_pattern_type_changes_the_pattern() {
|
||||
let (w, h) = (64, 64);
|
||||
let src = image(w, h, |_, _| grey(100));
|
||||
// The centre on a pixel's centre: pixel (32 + dx, 32 + dy) lies at (dx, dy) from it.
|
||||
let s = PixelSpace { center: Point::new(32.5, 32.5), ..space(w, h) };
|
||||
let draw = |kind: &str| halftone(json!({"size": 8, "contrast": 50, "patternType": kind}), &src, w, h, &s);
|
||||
let (circle, dot, line) = (draw("circle"), draw("dot"), draw("line"));
|
||||
assert!(circle != dot && dot != line && line != circle);
|
||||
// Pixels at the same distance from the centre in different directions.
|
||||
let same_radius: [&[(i64, i64)]; 6] = [
|
||||
&[(5, 0), (3, 4), (-4, 3), (0, -5), (-3, -4)],
|
||||
&[(10, 0), (6, 8), (-8, -6), (0, 10)],
|
||||
&[(13, 0), (5, 12), (-12, 5), (0, -13)],
|
||||
&[(15, 0), (9, 12), (-12, 9)],
|
||||
&[(17, 0), (8, 15), (-15, -8)],
|
||||
&[(25, 0), (7, 24), (-15, 20), (-20, -15), (24, -7)],
|
||||
];
|
||||
let value = |d: &[u8], (dx, dy): (i64, i64)| at(d, w, (32 + dx) as usize, (32 + dy) as usize)[0];
|
||||
let radial = |d: &[u8]| same_radius.iter().all(|ring| ring.iter().all(|q| value(d, *q) == value(d, ring[0])));
|
||||
assert!(radial(&circle), "Circle is the same all round");
|
||||
assert!(!radial(&dot) && !radial(&line));
|
||||
for y in 0..h {
|
||||
assert!((0..w).all(|x| at(&line, w, x, y) == at(&line, w, 0, y)), "row {y} of Line is uniform");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn halftone_contrast_sharpens_the_screen() {
|
||||
let (w, h) = (64, 32);
|
||||
let src = image(w, h, |x, _| grey((x * 4) as u8));
|
||||
let mid = |d: &[u8]| share(d, |p| p[0] != 0 && p[0] != 255);
|
||||
let soft = halftone(json!({"size": 6, "contrast": 0}), &src, w, h, &space(w, h));
|
||||
let hard = halftone(json!({"size": 6, "contrast": 50}), &src, w, h, &space(w, h));
|
||||
assert!(mid(&soft) > 0.3, "{}", mid(&soft));
|
||||
assert_eq!(mid(&hard), 0.0);
|
||||
}
|
||||
|
||||
/// Halftone Pattern redraws in black ink on white paper, whatever the object's colours, and
|
||||
/// keeps its coverage.
|
||||
#[test]
|
||||
fn halftone_draws_in_black_and_white_only() {
|
||||
let (w, h) = (48, 48);
|
||||
let src = image(w, h, |x, y| {
|
||||
let c = [[230u8, 20, 20], [20, 200, 40], [30, 60, 220], [240, 220, 30]][(x / 12) % 4];
|
||||
let a: u16 = if y < 24 { 255 } else { 128 };
|
||||
[c[0], c[1], c[2]].map(|v| (u16::from(v) * a / 255) as u8).into_iter().chain([a as u8]).collect::<Vec<_>>().try_into().unwrap()
|
||||
});
|
||||
for kind in ["circle", "dot", "line"] {
|
||||
let out = halftone(json!({"size": 6, "contrast": 50, "patternType": kind}), &src, w, h, &space(w, h));
|
||||
for (p, q) in out.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0) {
|
||||
assert_eq!(p[3], q[3], "{kind}: coverage kept");
|
||||
assert!(p[0] == p[1] && p[1] == p[2] && (p[0] == 0 || p[0] == p[3]), "{kind}: {p:?} from {q:?}");
|
||||
}
|
||||
assert!(share(&out, |p| p[0] == 0) > 0.1 && share(&out, |p| p[0] == p[3]) > 0.1, "{kind}: ink and paper");
|
||||
let soft = halftone(json!({"size": 6, "contrast": 0, "patternType": kind}), &src, w, h, &space(w, h));
|
||||
assert!(soft.as_chunks::<4>().0.iter().all(|p| p[0] == p[1] && p[1] == p[2]), "{kind}: greys only");
|
||||
for (tone, expect) in [(0, 0), (255, 255)] {
|
||||
let out = halftone(json!({"size": 6, "contrast": 0, "patternType": kind}), &image(w, h, |_, _| grey(tone)), w, h, &space(w, h));
|
||||
assert!(out.as_chunks::<4>().0.iter().all(|p| p[0] == expect), "{kind}: {tone} stays {expect}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,9 @@ pub enum RasterFx {
|
||||
dy: f64,
|
||||
blur: f64,
|
||||
color: Color,
|
||||
/// Darkness (0..1): the shadow is the object's own colors with this share of black added
|
||||
/// instead of `color`; `None` when it is `color`.
|
||||
darkness: Option<f32>,
|
||||
},
|
||||
OuterGlow {
|
||||
mode: BlendMode,
|
||||
@@ -111,6 +114,9 @@ pub fn raster_effects(effects: &[Effect]) -> Vec<RasterFx> {
|
||||
dy: num(&p, "y", 7.0).clamp(-1e4, 1e4),
|
||||
blur,
|
||||
color: color_param(&p, Color::BLACK),
|
||||
darkness: text(&p, "shade", "color")
|
||||
.eq_ignore_ascii_case("darkness")
|
||||
.then(|| (num(&p, "darkness", 100.0) / 100.0).clamp(0.0, 1.0) as f32),
|
||||
},
|
||||
"stylize.outerGlow" => RasterFx::OuterGlow {
|
||||
mode: blend_mode(text(&p, "mode", "screen")),
|
||||
|
||||
+237
-23
@@ -5,12 +5,14 @@
|
||||
//! (glyph outlines, the evaluated live object, the symbol's art) and [`reshape`] runs each effect
|
||||
//! over all of them as one piece, with the whole art's bounds as the effect's reference box, so a
|
||||
//! warp bends a line of type as a whole. An image is clipped by its reshaped frame. Raster effects
|
||||
//! (shadows, glows, blur, feather) stay on the result for the renderer and exporters.
|
||||
//! (shadows, glows, blur, feather) stay on the result for the renderer and exporters. Type without
|
||||
//! inline graphics whose own fills or strokes have geometry effects is outlined too: each of those
|
||||
//! effects reshapes the whole outline that its fill or stroke paints.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, Node, NodeKind};
|
||||
use vectorcraft_geom::{FillRule, PathData, Rect, shapes};
|
||||
use vectorcraft_geom::{Affine, FillRule, PathData, Rect, shapes};
|
||||
|
||||
use crate::{GeomContext, apply_one, is_geometry, merged_params};
|
||||
|
||||
@@ -30,9 +32,25 @@ pub fn needs_outline(n: &Node) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// The glyph outlines of type `t` in text space: one path per run, and all of them as one.
|
||||
/// Is `n` type without inline graphics that has a fill or stroke of its own that paints and has a
|
||||
/// visible geometry effect? Such an effect reshapes the whole outline that its fill or stroke
|
||||
/// paints ([`outline_text`], which leaves inline graphics out).
|
||||
pub fn has_item_geometry(n: &Node) -> bool {
|
||||
let NodeKind::Text(t) = &n.kind else { return false };
|
||||
t.runs.iter().all(|r| r.inline.is_none()) && n.appearance.items.iter().any(|i| crate::paints(i) && crate::has_geometry(i.effects()))
|
||||
}
|
||||
|
||||
/// The glyph outlines of type `t` in text space with its underline and strikethrough bars: one
|
||||
/// path per run, and all of them as one.
|
||||
pub(crate) fn glyph_outlines(t: &vectorcraft_doc::TextObject) -> (Vec<kurbo::BezPath>, kurbo::BezPath) {
|
||||
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
let (runs, all, _) = laid_out(t);
|
||||
(runs, all)
|
||||
}
|
||||
|
||||
/// [`glyph_outlines`] and the bounds of `t`'s layout in text space.
|
||||
fn laid_out(t: &vectorcraft_doc::TextObject) -> (Vec<kurbo::BezPath>, kurbo::BezPath, Rect) {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let layout = vectorcraft_text::layout(db, t);
|
||||
let mut runs = vec![kurbo::BezPath::new(); t.runs.len()];
|
||||
let mut all = kurbo::BezPath::new();
|
||||
for g in &layout.glyphs {
|
||||
@@ -41,25 +59,49 @@ pub(crate) fn glyph_outlines(t: &vectorcraft_doc::TextObject) -> (Vec<kurbo::Bez
|
||||
}
|
||||
all.extend(g.outline.iter());
|
||||
}
|
||||
(runs, all)
|
||||
// Underline and strikethrough bars, painted as their run's type is (#847).
|
||||
for (run, bar) in vectorcraft_text::decorations(&layout, db, t) {
|
||||
if let Some(r) = runs.get_mut(run) {
|
||||
r.extend(bar.iter());
|
||||
}
|
||||
all.extend(bar.iter());
|
||||
}
|
||||
(runs, all, layout.bounds)
|
||||
}
|
||||
|
||||
/// Type as glyph outlines in document space: one path per run with the run's fill and stroke,
|
||||
/// and (when the object has its own fills/strokes) the whole outline with the object's items below
|
||||
/// the characters under them and the others over them, as the renderer paints them.
|
||||
/// the characters under them and the others over them, as the renderer paints them. Each run's
|
||||
/// path is placed by the type's transform, which also scales its stroke weights and dashes and
|
||||
/// transforms its pattern tiles; its unplaced gradients are first fixed to the layout's bounds in
|
||||
/// text space. The object's own unplaced gradients are fixed to the bounding box of those bounds
|
||||
/// in the document. The group is marked as type's outlines
|
||||
/// ([`vectorcraft_doc::NodeExtras::type_outlines`]).
|
||||
pub fn outline_text(n: &Node) -> Option<Node> {
|
||||
let NodeKind::Text(t) = &n.kind else { return None };
|
||||
let (runs, all) = glyph_outlines(t);
|
||||
let (runs, all, bounds) = laid_out(t);
|
||||
let path = |bp: &kurbo::BezPath| PathData::from_bezpath(bp).transformed(t.xf);
|
||||
let scaling = vectorcraft_doc::Scaling { strokes: true, patterns: true, ..Default::default() };
|
||||
let mut children: Vec<Arc<Node>> = runs
|
||||
.iter()
|
||||
.zip(&t.runs)
|
||||
.filter(|(bp, _)| !bp.elements().is_empty())
|
||||
.map(|(bp, run)| Arc::new(Node::path(n.id, path(bp), run.style.appearance())))
|
||||
.map(|(bp, run)| {
|
||||
let mut ap = run.style.appearance();
|
||||
ap.pin_gradients(bounds);
|
||||
let mut piece = Node::path(n.id, PathData::from_bezpath(bp), ap);
|
||||
piece.transform(t.xf, scaling);
|
||||
Arc::new(piece)
|
||||
})
|
||||
.collect();
|
||||
if !n.appearance.items.is_empty() && !all.elements().is_empty() {
|
||||
let (below, above) = n.appearance.split_contents();
|
||||
let glyphs = |items: &[AppearanceItem]| Arc::new(Node::path(n.id, path(&all), Appearance { items: items.to_vec(), ..Default::default() }));
|
||||
let tb = t.xf.transform_rect_bbox(bounds);
|
||||
let glyphs = |items: &[AppearanceItem]| {
|
||||
let mut ap = Appearance { items: items.to_vec(), ..Default::default() };
|
||||
ap.pin_gradients(tb);
|
||||
Arc::new(Node::path(n.id, path(&all), ap))
|
||||
};
|
||||
if !below.is_empty() {
|
||||
children.insert(0, glyphs(below));
|
||||
}
|
||||
@@ -67,7 +109,9 @@ pub fn outline_text(n: &Node) -> Option<Node> {
|
||||
children.push(glyphs(above));
|
||||
}
|
||||
}
|
||||
Some(Node::group(n.id, children))
|
||||
let mut g = Node::group(n.id, children);
|
||||
g.extras_mut().type_outlines = true;
|
||||
Some(g)
|
||||
}
|
||||
|
||||
/// `n` as plain art that geometry effects can reshape, without its transparency and object
|
||||
@@ -86,7 +130,7 @@ pub fn outline_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
_ if vectorcraft_doc::live::is_live(n) => vectorcraft_doc::live::expand_deep(n, Some(hook)),
|
||||
_ => return None,
|
||||
};
|
||||
outline_nested_text(&mut art);
|
||||
outline_nested_text(&mut art, false);
|
||||
art.opacity = 1.0;
|
||||
art.blend = Default::default();
|
||||
art.isolate = false;
|
||||
@@ -97,24 +141,37 @@ pub fn outline_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
Some(art)
|
||||
}
|
||||
|
||||
/// Replace type anywhere inside `n` by its outlines.
|
||||
fn outline_nested_text(n: &mut Node) {
|
||||
/// Replace type anywhere inside `n` by its outlines, as art that keeps the type's look
|
||||
/// ([`as_art`]). Type in a clip group's clipping path (all of `n` when `clipping` is set) becomes
|
||||
/// its plain outlines ([`outline_text`]) with its visibility, which is what
|
||||
/// [`crate::clip_outline`] clips by.
|
||||
pub(crate) fn outline_nested_text(n: &mut Node, clipping: bool) {
|
||||
let clips = n.clips();
|
||||
let Some(ch) = n.children_mut() else { return };
|
||||
for c in ch.iter_mut() {
|
||||
if let Some(o) = outline_text(c) {
|
||||
for (i, c) in ch.iter_mut().enumerate() {
|
||||
let clipping = clipping || (clips && i == 0);
|
||||
let art = match &c.kind {
|
||||
NodeKind::Text(_) if clipping => outline_text(c).map(|o| Node { visible: c.visible, ..o }),
|
||||
NodeKind::Text(_) => as_art(c, None),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(o) = art {
|
||||
*c = Arc::new(o);
|
||||
} else if c.children().is_some() {
|
||||
outline_nested_text(Arc::make_mut(c));
|
||||
outline_nested_text(Arc::make_mut(c), clipping);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `n` (see [`needs_outline`]) with its object-level geometry effects applied: its [`outline_art`]
|
||||
/// reshaped as one piece, or for an image the image clipped by its reshaped frame. The result is
|
||||
/// a group with `n`'s id, name, transparency, opacity mask and remaining (raster) effects; `None`
|
||||
/// when `n` has no visible geometry effect or no art (a symbol instance without `symbol_art`).
|
||||
/// `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`).
|
||||
pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
if (geometry_effects(n).is_empty() && n.projection().is_none()) || !needs_outline(n) {
|
||||
if (geometry_effects(n).is_empty() && n.projection().is_none() && !has_item_geometry(n)) || !needs_outline(n) {
|
||||
return None;
|
||||
}
|
||||
as_art(n, symbol_art)
|
||||
@@ -154,11 +211,13 @@ pub(crate) fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
// The art is a group with `n`'s id (a clip group for an image).
|
||||
let mut out = art;
|
||||
out.name = n.name.clone();
|
||||
out.visible = n.visible;
|
||||
out.opacity = n.opacity;
|
||||
out.blend = n.blend;
|
||||
out.isolate = n.isolate;
|
||||
// One element: its pieces never knock each other out.
|
||||
out.knockout = vectorcraft_doc::Knockout::Off;
|
||||
out.knockout_shape = n.knockout_shape;
|
||||
out.extras_mut().mask = n.extras().mask.clone();
|
||||
out.appearance.effects = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect();
|
||||
Some(out)
|
||||
@@ -197,9 +256,15 @@ pub(crate) fn geometry_effects(n: &Node) -> Vec<&Effect> {
|
||||
}
|
||||
|
||||
/// Apply `fx` to every path under `art` as one piece: each effect takes the whole art's bounds as
|
||||
/// its reference box and the art's seed (a random Transform moves the pieces together).
|
||||
/// its reference box and the art's seed (a random Transform moves the pieces together). Images and
|
||||
/// symbol instances follow a Transform effect the same way ([`transform_placed`]); other geometry
|
||||
/// effects leave them as they are (a raster follows a non-affine map only as a mesh warp).
|
||||
pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect]) {
|
||||
let seed = art.id.0;
|
||||
// The placed gradients of type's outlines in `art` (`outline_text`) move and scale with the
|
||||
// pieces, from the outlines' bounds before the effects to their bounds after them.
|
||||
let mut outlines = vec![];
|
||||
type_outline_bounds(art, &mut outlines);
|
||||
for e in fx {
|
||||
let Some(bounds) = art.geometric_bounds() else { break };
|
||||
let params = merged_params(&e.id, &e.params);
|
||||
@@ -208,7 +273,156 @@ pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect]) {
|
||||
let out = apply_one(&e.id, ¶ms, &path, bounds, &GeomContext { seed, ..GeomContext::of(leaf) });
|
||||
set_geometry(leaf, out, rule);
|
||||
});
|
||||
if e.id == "distort.transform" {
|
||||
let (m, copies) = crate::distort::transform_map(bounds, ¶ms, seed);
|
||||
transform_placed(art, m, copies);
|
||||
}
|
||||
}
|
||||
if !fx.is_empty() && outlines.iter().any(Option::is_some) {
|
||||
rebase_type_outlines(art, &mut outlines.into_iter());
|
||||
}
|
||||
}
|
||||
|
||||
/// The bounds of each group of type's outlines under `n` ([`outline_text`]), `n` included, in
|
||||
/// order. A group inside one of them is part of it.
|
||||
fn type_outline_bounds(n: &Node, out: &mut Vec<Option<Rect>>) {
|
||||
if n.extras().type_outlines {
|
||||
out.push(n.geometric_bounds());
|
||||
} else if let NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } = &n.kind {
|
||||
for c in children {
|
||||
type_outline_bounds(c, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Move and scale the placed gradients of each group of type's outlines under `n` from its bounds
|
||||
/// in `before` ([`type_outline_bounds`] before its paths changed) to its bounds now.
|
||||
fn rebase_type_outlines(n: &mut Node, before: &mut std::vec::IntoIter<Option<Rect>>) {
|
||||
if !n.extras().type_outlines {
|
||||
if let NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } = &mut n.kind {
|
||||
for c in children.iter_mut() {
|
||||
rebase_type_outlines(Arc::make_mut(c), before);
|
||||
}
|
||||
}
|
||||
} else if let Some(Some(from)) = before.next()
|
||||
&& let Some(to) = n.geometric_bounds()
|
||||
&& from != to
|
||||
{
|
||||
for_each_leaf(n, &mut |leaf| {
|
||||
if leaf.appearance.has_placed_gradient() {
|
||||
follow_boxes(&mut leaf.appearance, from, to);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Map the placed gradients of `ap` from box `from` to box `to` through the map that takes one box
|
||||
/// onto the other (strokes: their stroke-inflated boxes). The start and end go where
|
||||
/// [`Appearance::rebase_gradients`] puts them, and a radial's ellipse and a linear's isolines are
|
||||
/// mapped with them.
|
||||
fn follow_boxes(ap: &mut Appearance, from: Rect, to: Rect) {
|
||||
for i in &mut ap.items {
|
||||
match i {
|
||||
AppearanceItem::Fill(f) => {
|
||||
if let vectorcraft_doc::color::Paint::Gradient(g) = &mut f.paint {
|
||||
g.transform(box_map(from, to));
|
||||
}
|
||||
}
|
||||
AppearanceItem::Stroke(s) => {
|
||||
let m = box_map(s.paint_bounds(from), s.paint_bounds(to));
|
||||
if let vectorcraft_doc::color::Paint::Gradient(g) = &mut s.paint {
|
||||
g.transform(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The map that takes box `from` onto box `to` along each axis (along an axis `from` has no extent
|
||||
/// on, a move from its center to `to`'s).
|
||||
fn box_map(from: Rect, to: Rect) -> vectorcraft_geom::Affine {
|
||||
let axis = |a0: f64, a1: f64, b0: f64, b1: f64| {
|
||||
if (a1 - a0).abs() > 1e-12 {
|
||||
let s = (b1 - b0) / (a1 - a0);
|
||||
(s, b0 - a0 * s)
|
||||
} else {
|
||||
(1.0, (b0 + b1 - a0 - a1) / 2.0)
|
||||
}
|
||||
};
|
||||
let (sx, tx) = axis(from.x0, from.x1, to.x0, to.x1);
|
||||
let (sy, ty) = axis(from.y0, from.y1, to.y0, to.y1);
|
||||
vectorcraft_geom::Affine::new([sx, 0.0, 0.0, sy, tx, ty])
|
||||
}
|
||||
|
||||
/// Apply the Transform effect's map `m` to every image and symbol instance under `n`, as
|
||||
/// [`for_each_leaf`] does to paths: the map goes into the object's placement, and `copies` more
|
||||
/// follow, each mapped once more than the last. Without copies the object itself takes the map,
|
||||
/// as a path does.
|
||||
fn transform_placed(n: &mut Node, m: Affine, copies: usize) {
|
||||
transform_placed_within(n, m, copies, crate::distort::MAX_COPY_OBJECTS)
|
||||
}
|
||||
|
||||
/// [`transform_placed`] whose copies make at most `max` image and symbol instance nodes of the art,
|
||||
/// the originals included: each copy adds the object once more, and a Transform after this one
|
||||
/// copies the lot again, so copies stop where the result would pass `max` (#2042).
|
||||
pub(crate) fn transform_placed_within(n: &mut Node, m: Affine, copies: usize, max: usize) {
|
||||
let mut room = max.saturating_sub(placed_count(n));
|
||||
copy_placed(n, m, copies, &mut room);
|
||||
}
|
||||
|
||||
/// An object that a Transform effect places through its transform rather than reshapes: an image
|
||||
/// (pixel space to document) or a symbol instance (the symbol's art to document).
|
||||
fn is_placed(n: &Node) -> bool {
|
||||
matches!(n.kind, NodeKind::Image(_) | NodeKind::SymbolInstance { .. })
|
||||
}
|
||||
|
||||
/// The walk of [`transform_placed`]: the map and its copies go to each image and symbol instance in
|
||||
/// document order, each copy taking one from `room` (what the art can still grow by), until the
|
||||
/// budget is full. As with a path, the copies stop before one reaches past
|
||||
/// [`crate::distort::within_reach`].
|
||||
fn copy_placed(n: &mut Node, m: Affine, copies: usize, room: &mut usize) {
|
||||
let Some(children) = n.children_mut() else { return };
|
||||
for c in children.iter_mut() {
|
||||
if !is_placed(c) {
|
||||
copy_placed(Arc::make_mut(c), m, copies, room);
|
||||
continue;
|
||||
}
|
||||
let src = (**c).clone();
|
||||
let placed = |xf: Affine| {
|
||||
let mut one = src.clone();
|
||||
match &mut one.kind {
|
||||
NodeKind::Image(im) => im.xf = xf * im.xf,
|
||||
NodeKind::SymbolInstance { xf: at, .. } => *at = xf * *at,
|
||||
_ => {}
|
||||
}
|
||||
one
|
||||
};
|
||||
if copies == 0 {
|
||||
*c = Arc::new(placed(m));
|
||||
continue;
|
||||
}
|
||||
// The copies become nodes of their own, under a group with the object's id, name and place
|
||||
// in the art (as the runs an outlined text gets).
|
||||
let mut placements: Vec<Arc<Node>> = vec![Arc::new(placed(Affine::IDENTITY))];
|
||||
let mut xf = Affine::IDENTITY;
|
||||
for _ in 0..copies.min(*room) {
|
||||
xf *= m;
|
||||
let copy = placed(xf);
|
||||
if !copy.geometric_bounds().is_some_and(crate::distort::within_reach) {
|
||||
break;
|
||||
}
|
||||
placements.push(Arc::new(copy));
|
||||
}
|
||||
*room -= placements.len() - 1;
|
||||
let mut g = Node::group(src.id, placements);
|
||||
g.name = src.name.clone();
|
||||
*c = Arc::new(g);
|
||||
}
|
||||
}
|
||||
|
||||
/// How many images and symbol instances `n` holds below itself.
|
||||
fn placed_count(n: &Node) -> usize {
|
||||
n.children().into_iter().flatten().map(|c| usize::from(is_placed(c)) + placed_count(c)).sum()
|
||||
}
|
||||
|
||||
/// `n` with type anywhere inside it outlined and live objects evaluated, so geometry effects on
|
||||
@@ -216,7 +430,7 @@ pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect]) {
|
||||
pub(crate) fn outlined_members(n: &Node) -> Node {
|
||||
let hook: &dyn Fn(&Node) -> Option<Node> = &outline_text;
|
||||
let mut art = vectorcraft_doc::live::expand_deep(n, Some(hook));
|
||||
outline_nested_text(&mut art);
|
||||
outline_nested_text(&mut art, false);
|
||||
art
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_doc::color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, LineJoin, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_geom::{PathData, Rect};
|
||||
use vectorcraft_three_d::{Edge, Revolve};
|
||||
|
||||
@@ -38,6 +38,8 @@ pub fn revolve_options(p: &Value) -> Revolve {
|
||||
light_softness: num(p, "lightSoftness", 0.0) / 100.0,
|
||||
ambient: num(p, "ambient", 25.0) / 100.0,
|
||||
shade: flag(p, "shade", true),
|
||||
smooth_shading: true,
|
||||
cap_ends: flag(p, "cap", true),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +54,9 @@ pub fn validate_revolve(n: &Node, p: &Value) -> Result<(), String> {
|
||||
if p.get("edge").is_some_and(|v| v.as_str() != Some("left") && v.as_str() != Some("right")) {
|
||||
return Err("Revolve edge must be left or right".into());
|
||||
}
|
||||
if p.get("cap").is_some_and(|v| v.as_bool().is_none()) {
|
||||
return Err("Revolve cap must be a boolean".into());
|
||||
}
|
||||
vectorcraft_three_d::revolve(&path, b, revolve_options(p)).map(|_| ())
|
||||
}
|
||||
|
||||
@@ -95,7 +100,10 @@ fn surface(
|
||||
.then(|| vectorcraft_three_d::visible_faces(&faces).ok())
|
||||
.flatten();
|
||||
let faces = visible.unwrap_or_else(|| {
|
||||
faces.into_iter().map(|f| vectorcraft_three_d::VisibleFace { path: f.path(), brightness: f.brightness, depth: f.depth }).collect()
|
||||
faces
|
||||
.into_iter()
|
||||
.map(|f| vectorcraft_three_d::VisibleFace { path: f.path(), brightness: f.brightness, depth: f.depth, gradient: f.gradient })
|
||||
.collect()
|
||||
});
|
||||
let children = faces
|
||||
.into_iter()
|
||||
@@ -104,13 +112,17 @@ fn surface(
|
||||
let mut items = vec![];
|
||||
if let Some(c) = paint.color() {
|
||||
let [r, g, b] = c.to_rgb();
|
||||
let s = face.brightness as f32;
|
||||
fill.paint = Paint::solid(Color::rgb(r * s, g * s, b * s));
|
||||
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));
|
||||
items.push(AppearanceItem::Fill(fill));
|
||||
} else {
|
||||
items.push(AppearanceItem::Fill(fill));
|
||||
let mut shadow = FillLayer::new(Paint::solid(Color::BLACK));
|
||||
shadow.opacity = (1.0 - face.brightness) as f32;
|
||||
if let Some(g) = face.gradient {
|
||||
shadow.paint = shading_paint(g, |_| Color::BLACK, |s| (1.0 - s) as f32);
|
||||
} else {
|
||||
shadow.opacity = (1.0 - face.brightness) as f32;
|
||||
}
|
||||
items.push(AppearanceItem::Fill(shadow));
|
||||
}
|
||||
// Independently antialiased adjacent polygons otherwise leave a hairline grid.
|
||||
@@ -121,6 +133,9 @@ fn surface(
|
||||
.flat_map(|item| {
|
||||
let AppearanceItem::Fill(fill) = &item else { return vec![item] };
|
||||
let mut edge = StrokeLayer::new(fill.paint.clone(), 0.75);
|
||||
// Acute face corners must stay within the overlap width. Miter joins
|
||||
// amplify this seam stroke into visible spikes along the silhouette.
|
||||
edge.join = LineJoin::Round;
|
||||
edge.opacity = fill.opacity;
|
||||
vec![item, AppearanceItem::Stroke(edge)]
|
||||
})
|
||||
@@ -134,6 +149,15 @@ fn surface(
|
||||
Some(group)
|
||||
}
|
||||
|
||||
fn shading_paint(g: vectorcraft_three_d::ShadeGradient, color: impl Fn(f64) -> Color, opacity: impl Fn(f64) -> f32) -> Paint {
|
||||
use vectorcraft_doc::color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||
let stops =
|
||||
g.brightness.into_iter().enumerate().map(|(i, s)| GradientStop { opacity: opacity(s), ..GradientStop::new(i as f32, color(s)) }).collect();
|
||||
let mut paint = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops });
|
||||
paint.geom = Some(GradientGeom { start: g.start, end: g.end, aspect: 1.0, focal: None });
|
||||
Paint::Gradient(Box::new(paint))
|
||||
}
|
||||
|
||||
fn paint_of(item: &AppearanceItem) -> (Paint, f32, vectorcraft_doc::color::BlendMode) {
|
||||
match item {
|
||||
AppearanceItem::Fill(f) => (f.paint.clone(), f.opacity, f.blend),
|
||||
@@ -224,6 +248,85 @@ mod tests {
|
||||
crate::new_effect(REVOLVE, &json!({})).unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn seam_strokes_stay_within_half_a_stroke_width_of_each_face() {
|
||||
use kurbo::Shape;
|
||||
for shade in [false, true] {
|
||||
for angle in [110.0, 360.0] {
|
||||
let mut n = profile_node();
|
||||
n.appearance.effects.push(
|
||||
crate::new_effect(
|
||||
REVOLVE,
|
||||
&json!({
|
||||
"offset":20,"rotationX":-18,"segments":24,"cap":true,
|
||||
"shade":shade,"angle":angle
|
||||
}),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
for art in [revolve_art(&n).unwrap(), expand_revolve_art(&n).unwrap()] {
|
||||
let mut checked = 0;
|
||||
art.walk(&mut |face| {
|
||||
let Some(path) = face.path_data() else { return };
|
||||
for item in &face.appearance.items {
|
||||
let AppearanceItem::Stroke(stroke) = item else { continue };
|
||||
let bounds = path.bounds().unwrap().inflate(stroke.width / 2.0 + 0.005, stroke.width / 2.0 + 0.005);
|
||||
let ink = crate::stroke::line_outline(&path.to_bezpath(), stroke, stroke.width, 0.001).bounding_box();
|
||||
assert!(
|
||||
bounds.contains_rect(ink),
|
||||
"seam protrudes beyond its overlap: {ink:?}, {bounds:?}, shade {shade}, sweep {angle}"
|
||||
);
|
||||
checked += 1;
|
||||
}
|
||||
});
|
||||
assert!(checked > 20);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn smooth_shading_is_automatic_and_non_solid_paints_keep_their_base_paint() {
|
||||
let mut n = profile_node();
|
||||
n.appearance.effects.push(fx());
|
||||
assert!(!n.appearance.effects[0].params.as_object().unwrap().contains_key("smoothShading"));
|
||||
let smooth = revolve_art(&n).unwrap();
|
||||
assert!(
|
||||
smooth.children().unwrap()[0].children().unwrap().iter().any(|face| {
|
||||
matches!(face.appearance.items.first(), Some(AppearanceItem::Fill(fill)) if matches!(fill.paint, Paint::Gradient(_)))
|
||||
})
|
||||
);
|
||||
n.appearance.effects[0].params["smoothShading"] = json!(false);
|
||||
assert_eq!(revolve_art(&n), Some(smooth.clone()));
|
||||
n.appearance.effects[0].params["smoothShading"] = json!(true);
|
||||
assert_eq!(revolve_art(&n), Some(smooth));
|
||||
use vectorcraft_doc::color::{Gradient, GradientPaint};
|
||||
let base = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())));
|
||||
n.appearance.set_fill(base.clone());
|
||||
let art = revolve_art(&n).unwrap();
|
||||
let faces = art.children().unwrap()[0].children().unwrap();
|
||||
assert!(faces.iter().all(|f| matches!(f.appearance.items.first(), Some(AppearanceItem::Fill(fill)) if fill.paint == base)));
|
||||
assert!(faces.iter().any(|f| f.appearance.items.iter().any(|item| {
|
||||
matches!(item, AppearanceItem::Fill(fill) if matches!(&fill.paint, Paint::Gradient(g) if g.gradient.stops.iter().all(|s| s.color == Color::BLACK) && g.gradient.stops[0].opacity != g.gradient.stops[1].opacity))
|
||||
})));
|
||||
assert_eq!(expand_revolve_art(&n), Some(art), "unknown paint opacity keeps complete surfaces");
|
||||
}
|
||||
#[test]
|
||||
fn missing_cap_parameter_defaults_on_and_explicit_false_restores_open_ends() {
|
||||
let mut n = profile_node();
|
||||
let mut effect = fx();
|
||||
effect.params["offset"] = json!(20);
|
||||
effect.params["rotationX"] = json!(-35);
|
||||
effect.params.as_object_mut().unwrap().remove("cap");
|
||||
n.appearance.effects.push(effect);
|
||||
let capped = revolve_art(&n).unwrap();
|
||||
n.appearance.effects[0].params["cap"] = json!(false);
|
||||
let uncapped = revolve_art(&n).unwrap();
|
||||
assert_ne!(uncapped, capped);
|
||||
n.appearance.effects[0].params["cap"] = json!(true);
|
||||
assert_eq!(revolve_art(&n), Some(capped));
|
||||
n.appearance.effects[0].params["cap"] = json!(false);
|
||||
assert_eq!(revolve_art(&n), Some(uncapped));
|
||||
}
|
||||
#[test]
|
||||
fn source_stays_editable_surface_moves_and_hidden_effect_restores_it() {
|
||||
let mut n = profile_node();
|
||||
let before = n.path_data().unwrap().clone();
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use serde_json::Value;
|
||||
use vectorcraft_geom::kurbo;
|
||||
use vectorcraft_geom::shapes::{self, KAPPA};
|
||||
use vectorcraft_geom::{Affine, Anchor, PathData, Point, Rect, SubPath};
|
||||
use vectorcraft_geom::{Affine, Anchor, PathData, Point, Rect, SubPath, corners};
|
||||
|
||||
use crate::util::*;
|
||||
|
||||
@@ -24,15 +24,21 @@ pub fn convert_to_shape(id: &str, b: Rect, p: &Value) -> PathData {
|
||||
}
|
||||
}
|
||||
|
||||
/// Round Corners: every sharp, handle-less corner becomes a circular-ish arc of `radius`.
|
||||
/// Round Corners: every sharp, handle-less corner becomes a circular-ish arc of `radius`. A corner
|
||||
/// whose anchor carries a handle (where a curve meets a line, or two curves meet) gets Live
|
||||
/// Corners' round cut instead ([`corners::cut_corners`], #1144): it meets each side `radius` from
|
||||
/// the corner, measured along the side, and trims a curved side to what is left of it.
|
||||
pub fn round_corners(path: &PathData, radius: f64) -> PathData {
|
||||
if radius <= 0.0 {
|
||||
return path.clone();
|
||||
}
|
||||
let mut subs = vec![];
|
||||
// Per anchor of the result: an anchor with a handle, kept as it was, for the cut below.
|
||||
let mut with_handle = vec![];
|
||||
for sp in &path.subpaths {
|
||||
let n = sp.anchors.len();
|
||||
if n < 3 && (n != 2 || sp.closed) {
|
||||
with_handle.extend(sp.anchors.iter().map(|a| a.has_in() || a.has_out()));
|
||||
subs.push(sp.clone());
|
||||
continue;
|
||||
}
|
||||
@@ -41,6 +47,7 @@ pub fn round_corners(path: &PathData, radius: f64) -> PathData {
|
||||
let a = sp.anchors[i];
|
||||
let interior = sp.closed || (i > 0 && i + 1 < n);
|
||||
if !interior || a.has_in() || a.has_out() {
|
||||
with_handle.push(a.has_in() || a.has_out());
|
||||
out.push(a);
|
||||
continue;
|
||||
}
|
||||
@@ -52,12 +59,14 @@ pub fn round_corners(path: &PathData, radius: f64) -> PathData {
|
||||
let (vp, vn) = (tp - a.p, tn - a.p);
|
||||
let (lp, ln) = (vp.hypot(), vn.hypot());
|
||||
if lp < 1e-9 || ln < 1e-9 {
|
||||
with_handle.push(false);
|
||||
out.push(a);
|
||||
continue;
|
||||
}
|
||||
let (up, un) = (vp / lp, vn / ln);
|
||||
// Nearly straight: nothing to round.
|
||||
if up.dot(un) < -0.9999 {
|
||||
with_handle.push(false);
|
||||
out.push(a);
|
||||
continue;
|
||||
}
|
||||
@@ -65,12 +74,36 @@ pub fn round_corners(path: &PathData, radius: f64) -> PathData {
|
||||
let pa = a.p + up * d;
|
||||
let pb = a.p + un * d;
|
||||
let k = d * KAPPA;
|
||||
with_handle.extend([false, false]);
|
||||
out.push(Anchor::with_handles(pa, pa, pa - up * k));
|
||||
out.push(Anchor::with_handles(pb, pb - un * k, pb));
|
||||
}
|
||||
subs.push(SubPath::new(out, sp.closed));
|
||||
}
|
||||
PathData::new(subs)
|
||||
let rounded = PathData::new(subs);
|
||||
// The corners left: those whose anchor has a handle. Their cut reaches `radius` along each
|
||||
// side, as above; setback = r · (1 + cos) / sin, solved for the arc's radius r.
|
||||
let mut radii = vec![0.0; with_handle.len()];
|
||||
for c in corners::path_corners(&rounded) {
|
||||
if with_handle.get(c.index) == Some(&true)
|
||||
&& let Some(r) = radii.get_mut(c.index)
|
||||
{
|
||||
*r = radius * c.sin() / (1.0 + c.cos());
|
||||
}
|
||||
}
|
||||
if radii.iter().all(|r| *r == 0.0) {
|
||||
return rounded;
|
||||
}
|
||||
let (mut out, sources) = corners::cut_corners(&rounded, &radii, &[]);
|
||||
// `cut_corners` starts a closed subpath whose first corner it cuts at the end of that cut;
|
||||
// start at its start instead, as the rounding above does, so what depends on where a path
|
||||
// starts (dashes, arrowheads after the effect) doesn't move.
|
||||
for (sp, from) in out.subpaths.iter_mut().zip(&sources) {
|
||||
if sp.closed && sp.anchors.len() > 1 && from.first() == from.last() {
|
||||
sp.anchors.rotate_right(1);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn flatten(path: &PathData) -> Vec<Vec<Point>> {
|
||||
@@ -112,14 +145,19 @@ fn crosses_outline(p: Point, q: Point, polys: &[Vec<Point>]) -> bool {
|
||||
}
|
||||
|
||||
/// Scribble (simplified): a zig-zag hatching across the filled area at `angle`, one per region
|
||||
/// of the shape, outlined to `strokeWidth` so the fill paints it.
|
||||
/// of the shape, outlined to `strokeWidth` so the fill paints it. `overlapVariation` moves each
|
||||
/// line's ends along it by up to that many points, `spacingVariation` moves them across it, and
|
||||
/// each turn's curviness moves from `curviness` by up to `curvinessVariation` percent.
|
||||
pub fn scribble(path: &PathData, b: Rect, p: &Value) -> PathData {
|
||||
let angle = num(p, "angle", 30.0).to_radians();
|
||||
let overlap = num(p, "overlap", 0.0);
|
||||
let width = num(p, "strokeWidth", 3.0).clamp(0.01, 1000.0);
|
||||
let curvy = num(p, "curviness", 5.0).clamp(0.0, 100.0) / 100.0;
|
||||
let curviness = num(p, "curviness", 5.0).clamp(0.0, 100.0);
|
||||
let curvy = curviness / 100.0;
|
||||
let curvy_var = num(p, "curvinessVariation", 0.0).clamp(0.0, 100.0);
|
||||
let spacing = num(p, "spacing", 5.0).max(0.1);
|
||||
let variation = num(p, "variation", 0.5).clamp(0.0, 100.0);
|
||||
let along = num(p, "overlapVariation", 0.5).clamp(0.0, 100.0);
|
||||
let across = num(p, "spacingVariation", 0.5).clamp(0.0, 100.0);
|
||||
let seed = seed(p);
|
||||
let c = b.center();
|
||||
let to_local = Affine::translate(c.to_vec2()) * Affine::rotate(angle) * Affine::translate(-c.to_vec2());
|
||||
@@ -140,6 +178,8 @@ pub fn scribble(path: &PathData, b: Rect, p: &Value) -> PathData {
|
||||
// stays inside: one chain, as before.
|
||||
struct Chain {
|
||||
pts: Vec<Point>,
|
||||
// Each point's curviness noise in [-1, 1].
|
||||
bends: Vec<f64>,
|
||||
end: Point,
|
||||
row: u64,
|
||||
}
|
||||
@@ -160,10 +200,12 @@ pub fn scribble(path: &PathData, b: Rect, p: &Value) -> PathData {
|
||||
let spans: Vec<(f64, f64)> = xs.as_chunks::<2>().0.iter().map(|s| (s[0] - overlap, s[1] + overlap)).collect();
|
||||
let open = chains.len();
|
||||
for (a, bx) in spans {
|
||||
let jitter = |k: u64| variation * noise(seed, row, k, 3);
|
||||
let (l, r) = (Point::new(a + jitter(0), y + jitter(1)), Point::new(bx + jitter(2), y + jitter(3)));
|
||||
let jitter = |k: u64, v: f64| v * noise(seed, row, k, 3);
|
||||
let (l, r) = (Point::new(a + jitter(0, along), y + jitter(1, across)), Point::new(bx + jitter(2, along), y + jitter(3, across)));
|
||||
let even = row.is_multiple_of(2);
|
||||
let pair = if even { [l, r] } else { [r, l] };
|
||||
let bend = [noise(seed, row, 4, 3), noise(seed, row, 5, 3)];
|
||||
let bend = if even { bend } else { [bend[1], bend[0]] };
|
||||
// Un-jittered ends of the span in drawing order.
|
||||
let (start, end) = if even { (Point::new(a, y), Point::new(bx, y)) } else { (Point::new(bx, y), Point::new(a, y)) };
|
||||
// The nearest chain of the previous row that this span can join without leaving
|
||||
@@ -176,9 +218,10 @@ pub fn scribble(path: &PathData, b: Rect, p: &Value) -> PathData {
|
||||
Some(k) => {
|
||||
let c = &mut chains[k];
|
||||
c.pts.extend(pair);
|
||||
c.bends.extend(bend);
|
||||
(c.end, c.row) = (end, row);
|
||||
}
|
||||
None => chains.push(Chain { pts: pair.to_vec(), end, row }),
|
||||
None => chains.push(Chain { pts: pair.to_vec(), bends: bend.to_vec(), end, row }),
|
||||
}
|
||||
}
|
||||
row += 1;
|
||||
@@ -187,7 +230,15 @@ pub fn scribble(path: &PathData, b: Rect, p: &Value) -> PathData {
|
||||
let lines: Vec<SubPath> = chains
|
||||
.into_iter()
|
||||
.filter(|c| c.pts.len() >= 2)
|
||||
.map(|c| if curvy > 0.0 { catmull_rom(&c.pts, false, curvy * 2.0) } else { SubPath::polyline(&c.pts, false) })
|
||||
.map(|c| {
|
||||
if curvy_var > 0.0 {
|
||||
varied_curves(&c.pts, &c.bends, curviness, curvy_var)
|
||||
} else if curvy > 0.0 {
|
||||
catmull_rom(&c.pts, false, curvy * 2.0)
|
||||
} else {
|
||||
SubPath::polyline(&c.pts, false)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if lines.is_empty() {
|
||||
return PathData::default();
|
||||
@@ -198,3 +249,17 @@ pub fn scribble(path: &PathData, b: Rect, p: &Value) -> PathData {
|
||||
let outline = kurbo::stroke(center.to_bezpath(), &stroke, &kurbo::StrokeOpts::default(), 0.05);
|
||||
PathData::from_bezpath(&outline)
|
||||
}
|
||||
|
||||
/// A scribble line through `pts` whose curviness at each point is `curviness` percent plus
|
||||
/// `variation` percent times that point's `bends` noise (in -1..1), kept within 0 to 100 percent.
|
||||
fn varied_curves(pts: &[Point], bends: &[f64], curviness: f64, variation: f64) -> SubPath {
|
||||
// A tension of 6 makes each handle the whole Catmull-Rom tangent; each point then scales its
|
||||
// own to the tension `catmull_rom` takes for that curviness (curviness / 100 × 2).
|
||||
let mut line = catmull_rom(pts, false, 6.0);
|
||||
for (a, bend) in line.anchors.iter_mut().zip(bends) {
|
||||
let k = (curviness + variation * bend).clamp(0.0, 100.0) / 300.0;
|
||||
a.h_in = a.p + (a.h_in - a.p) * k;
|
||||
a.h_out = a.p + (a.h_out - a.p) * k;
|
||||
}
|
||||
line
|
||||
}
|
||||
+336
-2
@@ -1,6 +1,6 @@
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::Effect;
|
||||
use vectorcraft_geom::{PathData, Point, Rect, Shape, SubPath, shapes};
|
||||
use vectorcraft_geom::{Affine, PathData, Point, Rect, Shape, SubPath, shapes};
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -8,6 +8,26 @@ fn fx(id: &str, params: Value) -> Effect {
|
||||
Effect { id: id.into(), params, visible: true }
|
||||
}
|
||||
|
||||
/// Every image under `n`, in document order (its placement's x).
|
||||
fn image_xs(n: &Node, out: &mut Vec<f64>) {
|
||||
if let NodeKind::Image(im) = &n.kind {
|
||||
out.push(im.xf.as_coeffs()[4]);
|
||||
}
|
||||
for c in n.children().into_iter().flatten() {
|
||||
image_xs(c, out);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every symbol instance under `n`, in document order (its placement's x).
|
||||
fn instance_xs(n: &Node, out: &mut Vec<f64>) {
|
||||
if let NodeKind::SymbolInstance { xf, .. } = &n.kind {
|
||||
out.push(xf.as_coeffs()[4]);
|
||||
}
|
||||
for c in n.children().into_iter().flatten() {
|
||||
instance_xs(c, out);
|
||||
}
|
||||
}
|
||||
|
||||
fn square() -> PathData {
|
||||
shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0))
|
||||
}
|
||||
@@ -224,12 +244,186 @@ fn stacked_transform_copies_stay_within_the_anchor_budget() {
|
||||
assert_eq!(run("distort.transform", p, &square()).subpaths.len(), 1001);
|
||||
}
|
||||
|
||||
/// #2042: stacked Transform effects multiply their image copies as they do their anchors; the
|
||||
/// image nodes they make are bounded, whatever each one's count.
|
||||
#[test]
|
||||
fn stacked_transform_copies_stay_within_the_image_budget() {
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::{ImageObject, NodeId};
|
||||
let id = NodeId(9);
|
||||
let image = || {
|
||||
Node::new(
|
||||
id,
|
||||
NodeKind::Image(Box::new(ImageObject {
|
||||
key: "k".into(),
|
||||
width: 40,
|
||||
height: 40,
|
||||
xf: Affine::translate((10.0, 0.0)),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
})),
|
||||
)
|
||||
};
|
||||
let count = |art: &Node| {
|
||||
let mut xs = vec![];
|
||||
image_xs(art, &mut xs);
|
||||
xs.len()
|
||||
};
|
||||
let m = Affine::translate((70.0, 0.0));
|
||||
let mut art = Node::group(id, vec![Arc::new(image())]);
|
||||
let mut counts = vec![];
|
||||
for _ in 0..3 {
|
||||
crate::reshape::transform_placed_within(&mut art, m, 1000, 100);
|
||||
counts.push(count(&art));
|
||||
}
|
||||
assert_eq!(counts, [100, 100, 100], "99 copies, then no room for a copy of them");
|
||||
// Within the budget every copy is made, and the effect's own budget takes a thousand copies.
|
||||
let mut art = Node::group(id, vec![Arc::new(image())]);
|
||||
crate::reshape::transform_placed_within(&mut art, m, 24, 100);
|
||||
assert_eq!(count(&art), 25);
|
||||
let mut g = Node::group(id, vec![Arc::new(image())]);
|
||||
g.appearance.effects.push(fx("distort.transform", json!({"moveH": 70, "copies": 1000})));
|
||||
assert_eq!(count(&evaluate_container(&g).unwrap()), 1001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_copies_stop_before_they_overflow() {
|
||||
let out = run("distort.transform", json!({"scaleH": 100000, "scaleV": 100000, "copies": 1000}), &square());
|
||||
assert!(all_finite(&out) && out.subpaths.len() < 10, "{}", out.subpaths.len());
|
||||
}
|
||||
|
||||
/// #2023: a Transform effect on a group moves, scales and copies its images like its paths: the
|
||||
/// map goes into each image's placement, and every copy is an image node of its own.
|
||||
#[test]
|
||||
fn transform_on_a_group_moves_and_copies_its_images() {
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::{Appearance, ImageObject, Node, NodeId, NodeKind};
|
||||
let id = NodeId(9);
|
||||
let sq = Node::path(id, square(), Appearance::default());
|
||||
let image = Node::new(
|
||||
id,
|
||||
NodeKind::Image(Box::new(ImageObject {
|
||||
key: "k".into(),
|
||||
width: 40,
|
||||
height: 40,
|
||||
xf: Affine::translate((10.0, 0.0)),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
})),
|
||||
);
|
||||
let mut g = Node::group(id, vec![Arc::new(sq), Arc::new(image)]);
|
||||
g.appearance.effects.push(fx("distort.transform", json!({"moveH": 70, "copies": 2})));
|
||||
let art = evaluate_container(&g).unwrap();
|
||||
let mut xs = vec![];
|
||||
image_xs(&art, &mut xs);
|
||||
xs.sort_by(f64::total_cmp);
|
||||
assert_eq!(xs, [10.0, 80.0, 150.0], "the image and its two copies, 70 pt apart");
|
||||
// Without copies the image itself takes the transform, as a path does.
|
||||
let mut g = Node::group(
|
||||
id,
|
||||
vec![Arc::new(Node::new(
|
||||
id,
|
||||
NodeKind::Image(Box::new(ImageObject {
|
||||
key: "k".into(),
|
||||
width: 40,
|
||||
height: 40,
|
||||
xf: Affine::translate((10.0, 0.0)),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
})),
|
||||
))],
|
||||
);
|
||||
g.appearance.effects.push(fx("distort.transform", json!({"scaleH": 50, "scaleV": 50, "reference": 8})));
|
||||
let art = evaluate_container(&g).unwrap();
|
||||
let mut xs = vec![];
|
||||
image_xs(&art, &mut xs);
|
||||
// The bounds are the image itself, so the bottom-right reference is its own: it halves in place.
|
||||
assert_eq!(xs, [30.0], "scaled about its own bottom-right corner");
|
||||
}
|
||||
|
||||
/// #2023: a Transform effect on a group moves and copies its symbol instances as it does its paths
|
||||
/// and images: the map goes into each instance's placement, and every copy is an instance of its
|
||||
/// own.
|
||||
#[test]
|
||||
fn transform_on_a_group_moves_and_copies_its_symbol_instances() {
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::{Appearance, Node, NodeId, NodeKind};
|
||||
let id = NodeId(9);
|
||||
let instance = || Node::new(id, NodeKind::SymbolInstance { symbol: "s".into(), xf: Affine::translate((5.0, 40.0)) });
|
||||
let xs = |art: &Node| {
|
||||
let mut xs = vec![];
|
||||
instance_xs(art, &mut xs);
|
||||
xs.sort_by(f64::total_cmp);
|
||||
xs
|
||||
};
|
||||
let mut g = Node::group(id, vec![Arc::new(Node::path(id, square(), Appearance::default())), Arc::new(instance())]);
|
||||
g.appearance.effects.push(fx("distort.transform", json!({"moveH": 70, "copies": 2})));
|
||||
assert_eq!(xs(&evaluate_container(&g).unwrap()), [5.0, 75.0, 145.0], "the instance and its two copies, 70 pt apart");
|
||||
// Without copies the instance itself takes the transform, as a path does.
|
||||
let mut g = Node::group(id, vec![Arc::new(instance())]);
|
||||
g.appearance.effects.push(fx("distort.transform", json!({"moveH": 70})));
|
||||
assert_eq!(xs(&evaluate_container(&g).unwrap()), [75.0]);
|
||||
}
|
||||
|
||||
/// Images and symbol instances share the copy budget: copies of one leave less room for the other.
|
||||
#[test]
|
||||
fn images_and_symbol_instances_share_the_copy_budget() {
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::{ImageObject, NodeId};
|
||||
let id = NodeId(9);
|
||||
let image = Node::new(
|
||||
id,
|
||||
NodeKind::Image(Box::new(ImageObject {
|
||||
key: "k".into(),
|
||||
width: 40,
|
||||
height: 40,
|
||||
xf: Affine::translate((10.0, 0.0)),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
})),
|
||||
);
|
||||
let instance = Node::new(id, NodeKind::SymbolInstance { symbol: "s".into(), xf: Affine::IDENTITY });
|
||||
let mut art = Node::group(id, vec![Arc::new(image), Arc::new(instance)]);
|
||||
crate::reshape::transform_placed_within(&mut art, Affine::translate((70.0, 0.0)), 1000, 100);
|
||||
let (mut images, mut instances) = (vec![], vec![]);
|
||||
image_xs(&art, &mut images);
|
||||
instance_xs(&art, &mut instances);
|
||||
// The image takes the 98 copies there is room for; none is left for the instance.
|
||||
assert_eq!((images.len(), instances.len()), (99, 1));
|
||||
}
|
||||
|
||||
/// As a path's do, a Transform effect's copies of an image stop before one reaches further than
|
||||
/// `MAX_REACH` from the origin.
|
||||
#[test]
|
||||
fn transform_copies_of_images_stop_at_the_reach_limit() {
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::{ImageObject, NodeId};
|
||||
let id = NodeId(9);
|
||||
let image = Node::new(
|
||||
id,
|
||||
NodeKind::Image(Box::new(ImageObject {
|
||||
key: "k".into(),
|
||||
width: 40,
|
||||
height: 40,
|
||||
xf: Affine::translate((10.0, 0.0)),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
})),
|
||||
);
|
||||
let mut g = Node::group(id, vec![Arc::new(image)]);
|
||||
// The largest move: every copy is 1e7 pt further right, so the 100th would end past 1e9.
|
||||
g.appearance.effects.push(fx("distort.transform", json!({"moveH": 1.0e7, "copies": 1000})));
|
||||
let mut xs = vec![];
|
||||
image_xs(&evaluate_container(&g).unwrap(), &mut xs);
|
||||
assert_eq!(xs.len(), 100, "the image and the 99 copies that stay within reach");
|
||||
assert!(xs.iter().all(|x| x + 40.0 <= 1.0e9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pucker_bloat_moves_anchors_in_opposite_directions() {
|
||||
let bloat = run("distort.puckerBloat", json!({"amount": 50}), &square());
|
||||
@@ -378,6 +572,18 @@ fn outline_stroke_uses_context_stroke() {
|
||||
assert!(close(out.bounds().unwrap().width(), 101.0, 0.1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_object_is_a_geometry_effect_that_keeps_the_path() {
|
||||
let info = effect_info("path.outlineObject").unwrap();
|
||||
assert_eq!((info.label, info.menu, &info.defaults, info.raster), ("Outline Object", &["Effect", "Path"][..], &json!({}), false));
|
||||
assert!(is_geometry("path.outlineObject"));
|
||||
assert_eq!(run("path.outlineObject", json!({}), &square()), square());
|
||||
// The effects after it reshape the same geometry.
|
||||
let b = square().bounds().unwrap();
|
||||
let offset = fx("path.offsetPath", json!({"offset": 5}));
|
||||
assert_eq!(apply_geometry(&[fx("path.outlineObject", json!({})), offset.clone()], &square(), b), apply_geometry(&[offset], &square(), b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_to_shape_sizes() {
|
||||
let r = run("convertToShape.rectangle", json!({"extraW": 20, "extraH": 10}), &square()).bounds().unwrap();
|
||||
@@ -400,6 +606,66 @@ fn round_corners_replaces_corners() {
|
||||
assert_eq!(run("stylize.roundCorners", json!({}), &el), el);
|
||||
}
|
||||
|
||||
/// A D: a straight back, a curved bowl, and two corners where the bowl meets the straight
|
||||
/// sides (the anchors there carry the bowl's handles).
|
||||
fn d_shape() -> PathData {
|
||||
use vectorcraft_geom::Anchor;
|
||||
let p = Point::new;
|
||||
PathData::new(vec![SubPath::new(
|
||||
vec![
|
||||
Anchor::corner(p(0.0, 0.0)),
|
||||
Anchor::with_handles(p(50.0, 0.0), p(50.0, 0.0), p(80.0, 20.0)),
|
||||
Anchor::with_handles(p(50.0, 100.0), p(80.0, 80.0), p(50.0, 100.0)),
|
||||
Anchor::corner(p(0.0, 100.0)),
|
||||
],
|
||||
true,
|
||||
)])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_corners_also_rounds_where_a_curve_meets_a_line() {
|
||||
let d = d_shape();
|
||||
let out = run("stylize.roundCorners", json!({"radius": 8}), &d);
|
||||
// All four corners are cut, the two curve-line ones too: two anchors each.
|
||||
assert_eq!(out.anchor_count(), 8);
|
||||
assert!(all_finite(&out));
|
||||
// Every cut leaves both sides smoothly: no corner is left anywhere.
|
||||
assert!(vectorcraft_geom::corners::path_corners(&out).is_empty(), "{out:?}");
|
||||
let (before, after) = (d.to_bezpath().area().abs(), out.to_bezpath().area().abs());
|
||||
assert!(after < before && after > before - 4.0 * 8.0 * 8.0, "{before} -> {after}");
|
||||
// The cut at a curve-line corner meets the straight side 8 from the corner.
|
||||
let p = Point::new;
|
||||
assert!(out.subpaths[0].anchors.iter().any(|a| a.p.distance(p(42.0, 0.0)) < 1e-9), "{out:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_corners_keeps_straight_corners_as_they_were() {
|
||||
// The D's straight corners are rounded as before, next to the new cuts: same anchors, same
|
||||
// quarter-circle handles, and the path still starts at its first corner's rounding.
|
||||
use vectorcraft_geom::Anchor;
|
||||
let p = Point::new;
|
||||
let k = 8.0 * shapes::KAPPA;
|
||||
let out = stylize::round_corners(&d_shape(), 8.0);
|
||||
let a = &out.subpaths[0].anchors;
|
||||
assert_eq!(a[0], Anchor::with_handles(p(0.0, 8.0), p(0.0, 8.0), p(0.0, 8.0 - k)));
|
||||
assert_eq!(a[1], Anchor::with_handles(p(8.0, 0.0), p(8.0 - k, 0.0), p(8.0, 0.0)));
|
||||
// A path of straight corners only is rounded exactly as before.
|
||||
assert_eq!(stylize::round_corners(&square(), 10.0).subpaths[0].anchors.len(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_corners_keeps_where_a_closed_path_starts() {
|
||||
// The D starting at a curve-line corner: the result starts where that corner's cut begins,
|
||||
// on the side coming from the previous corner, as a straight corner's rounding does.
|
||||
let mut d = d_shape();
|
||||
d.subpaths[0].anchors.rotate_left(1);
|
||||
let out = stylize::round_corners(&d, 8.0);
|
||||
let start = out.subpaths[0].anchors[0].p;
|
||||
assert!(start.distance(Point::new(42.0, 0.0)) < 1e-9, "{start:?}");
|
||||
// An infinite radius rounds as much as fits, curve-line corners too.
|
||||
assert_eq!(stylize::round_corners(&d, f64::INFINITY), stylize::round_corners(&d, 1e9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn twist_fixes_the_outer_circle_and_rotates_inside() {
|
||||
let out = run("distort.twist", json!({"angle": 0}), &square());
|
||||
@@ -436,6 +702,59 @@ fn scribble_produces_filled_outline_inside_bounds() {
|
||||
assert_eq!(out, run("stylize.scribble", json!({}), &square()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scribble_variations_are_separate() {
|
||||
// At 0° the lines run along x: the Path Overlap variation moves their ends in x only, the
|
||||
// Spacing variation moves them in y only, and the Curviness variation curves some of the turns.
|
||||
let base =
|
||||
json!({"angle": 0, "curviness": 0, "strokeWidth": 0.5, "spacing": 10, "overlapVariation": 0, "spacingVariation": 0, "curvinessVariation": 0});
|
||||
let with = |key: &str, v: f64| {
|
||||
let mut p = base.clone();
|
||||
p[key] = json!(v);
|
||||
run("stylize.scribble", p, &square())
|
||||
};
|
||||
let plain = run("stylize.scribble", base.clone(), &square());
|
||||
let b = plain.bounds().unwrap();
|
||||
let moved = |a: f64, b: f64| (a - b).abs() > 0.5;
|
||||
let along = with("overlapVariation", 4.0).bounds().unwrap();
|
||||
assert!(close(along.y0, b.y0, 0.06) && close(along.y1, b.y1, 0.06), "{along:?} {b:?}");
|
||||
assert!(moved(along.x0, b.x0) || moved(along.x1, b.x1), "{along:?} {b:?}");
|
||||
let across = with("spacingVariation", 4.0).bounds().unwrap();
|
||||
assert!(close(across.x0, b.x0, 0.06) && close(across.x1, b.x1, 0.06), "{across:?} {b:?}");
|
||||
assert!(moved(across.y0, b.y0) || moved(across.y1, b.y1), "{across:?} {b:?}");
|
||||
assert_ne!(with("curvinessVariation", 50.0), plain);
|
||||
// On a curviness of its own, the variation changes how much each turn curves.
|
||||
let mut p = base.clone();
|
||||
p["curviness"] = json!(20);
|
||||
let even = run("stylize.scribble", p.clone(), &square());
|
||||
p["curvinessVariation"] = json!(20);
|
||||
assert_ne!(run("stylize.scribble", p, &square()), even);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scribble_reads_its_earlier_single_variation() {
|
||||
// Before the separate variations, one `variation` moved the ends both along and across.
|
||||
let old = run("stylize.scribble", json!({"variation": 3}), &square());
|
||||
assert_eq!(old, run("stylize.scribble", json!({"overlapVariation": 3, "spacingVariation": 3}), &square()));
|
||||
assert_eq!(upgrade_params("stylize.scribble", &json!({"variation": 3})), json!({"overlapVariation": 3, "spacingVariation": 3}));
|
||||
// A current key given with it wins.
|
||||
assert_eq!(
|
||||
upgrade_params("stylize.scribble", &json!({"variation": 3, "spacingVariation": 1})),
|
||||
json!({"overlapVariation": 3, "spacingVariation": 1})
|
||||
);
|
||||
assert!(default_params("stylize.scribble").unwrap().get("variation").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scribble_angle_is_stored_above_minus_180_and_up_to_180() {
|
||||
for (given, stored) in [(270.0, -90.0), (-180.0, 180.0), (180.0, 180.0), (540.0, 180.0), (-270.0, 90.0), (-200.0, 160.0)] {
|
||||
assert_eq!(new_effect("stylize.scribble", &json!({"angle": given})).unwrap().params["angle"].as_f64(), Some(stored), "{given}");
|
||||
}
|
||||
// An angle in range is kept as given; other effects' angles are not changed.
|
||||
assert_eq!(upgrade_params("stylize.scribble", &json!({"angle": 45})), json!({"angle": 45}));
|
||||
assert_eq!(upgrade_params("distort.twist", &json!({"angle": 270})), json!({"angle": 270}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effects_chain_in_order_and_skip_hidden_and_raster() {
|
||||
let chain = [fx("distort.transform", json!({"moveH": 10})), fx("distort.transform", json!({"scaleH": 200}))];
|
||||
@@ -462,11 +781,12 @@ fn raster_effects_parse_and_outset() {
|
||||
let r = raster_effects(&list);
|
||||
assert_eq!(r.len(), 5);
|
||||
match &r[0] {
|
||||
RasterFx::DropShadow { mode, opacity, dx, dy, blur, color } => {
|
||||
RasterFx::DropShadow { mode, opacity, dx, dy, blur, color, darkness } => {
|
||||
assert_eq!(*mode, vectorcraft_doc::color::BlendMode::Multiply);
|
||||
assert!(close(*opacity as f64, 0.5, 1e-6));
|
||||
assert_eq!((*dx, *dy, *blur), (10.0, -4.0, 6.0));
|
||||
assert_eq!(color.to_rgb(), [1.0, 0.0, 0.0]);
|
||||
assert_eq!(*darkness, None);
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
@@ -475,6 +795,20 @@ fn raster_effects_parse_and_outset() {
|
||||
assert_eq!(outset(&[], vectorcraft_geom::Rect::ZERO), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_shadow_darkness_replaces_its_color_when_chosen() {
|
||||
let darkness = |params: Value| match raster_effects(&[fx("stylize.dropShadow", params)]).as_slice() {
|
||||
[RasterFx::DropShadow { darkness, .. }] => *darkness,
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
assert_eq!(darkness(json!({"shade": "darkness", "darkness": 60})), Some(0.6));
|
||||
assert_eq!(darkness(json!({"shade": "darkness"})), Some(1.0));
|
||||
assert_eq!(darkness(json!({"shade": "darkness", "darkness": 250})), Some(1.0));
|
||||
// Color is the default: a Darkness value alone leaves the shadow its color.
|
||||
assert_eq!(darkness(json!({"darkness": 60})), None);
|
||||
assert_eq!(darkness(json!({})), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_mode_names() {
|
||||
use vectorcraft_doc::color::BlendMode;
|
||||
|
||||
@@ -8,6 +8,12 @@ description = "VectorCraft engine: sessions, history, selection, the command reg
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
default = ["bundled-fonts"]
|
||||
# The fonts vectorcraft-text bundles (see its `bundled-fonts`). Embedders that supply every font at
|
||||
# run time (`vectorcraft_text::FontDb::add_font`) build with `default-features = false`.
|
||||
bundled-fonts = ["vectorcraft-text/bundled-fonts"]
|
||||
|
||||
[dependencies]
|
||||
vectorcraft-geom = { workspace = true }
|
||||
vectorcraft-color = { workspace = true }
|
||||
|
||||
@@ -22,7 +22,7 @@ use vectorcraft_color::{BlendMode, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, CharStyle, FillLayer, Node, NodeKind, ParaStyle, StrokeLayer};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::edit::{outermost, selected_roots};
|
||||
use super::opacitymask::percent;
|
||||
use super::paint::paint_from;
|
||||
use super::*;
|
||||
@@ -294,15 +294,15 @@ pub(crate) fn subject_roots(s: &Session) -> Result<Vec<NodeId>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Objects whose own appearance stack a command edits: `ids`/`id` (layers too), else the
|
||||
/// [`subject_roots`]; with `target: "contents"` the painted objects inside the groups and layers
|
||||
/// among them ([`leaf_targets`]).
|
||||
/// Objects whose own appearance stack a command edits: `ids`/`id` (layers too; an id inside
|
||||
/// another given one is dropped), else the [`subject_roots`]; with `target: "contents"` the painted
|
||||
/// objects inside the groups and layers among them ([`leaf_targets`]).
|
||||
pub(crate) fn appearance_targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
|
||||
let roots = if p.get("ids").is_none() && p.get("id").is_none() {
|
||||
subject_roots(s)?
|
||||
} else {
|
||||
let d = &s.doc()?.doc;
|
||||
targets(s, p)?.into_iter().filter(|id| d.node(*id).is_some()).collect()
|
||||
outermost(d, targets(s, p)?.into_iter().filter(|id| d.node(*id).is_some()).collect())
|
||||
};
|
||||
if targets_contents(p)? { leaf_targets(s, &roots) } else { Ok(roots) }
|
||||
}
|
||||
@@ -560,7 +560,7 @@ fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
};
|
||||
if let Some(pa) = &paint {
|
||||
*pp = super::gradient::place_paint(pa, p, bounds);
|
||||
*pp = super::gradient::place_paint(&super::paint::keep_pattern_xf(pa, p, Some(&*pp)), p, bounds);
|
||||
}
|
||||
if let Some(o) = p.get("opacity").and_then(Value::as_f64) {
|
||||
*op = percent(o);
|
||||
@@ -745,7 +745,7 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (reverse, append) = (bool_or(p, "reverse", false), bool_or(p, "append", false));
|
||||
let attrs = s.prefs.eyedropper.attrs(p).map_err(|e| bad(C, e))?;
|
||||
let selected = match ids_param(p, "ids") {
|
||||
Some(v) => v,
|
||||
Some(v) => outermost(&s.doc()?.doc, v),
|
||||
None => selected_roots(s)?,
|
||||
};
|
||||
// Reversed, the first selected object's attributes go onto the clicked object.
|
||||
|
||||
@@ -7,7 +7,7 @@ use serde_json::{Map, Value, json};
|
||||
use vectorcraft_doc::{ExportAsset, NodeId};
|
||||
|
||||
use super::edit::{roots_of, selected_roots};
|
||||
use super::fileio::{SCREEN_SETTINGS_KEYS, check_screen_settings, export_screens, store_screen_settings};
|
||||
use super::fileio::{SCREEN_SETTINGS_KEYS, check_screen_settings, export_crop, export_screens, store_screen_settings};
|
||||
use super::*;
|
||||
|
||||
/// The longest asset name kept (characters).
|
||||
@@ -20,7 +20,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Collect for Export",
|
||||
["Object", "Collect for Export"],
|
||||
None,
|
||||
"{ids?: [id…] (default: the selected objects), multiple?: true (an asset per object; false: one asset of them all), name?: (the asset's name, when it makes one)} collect art as assets for Window › Asset Export and Export for Screens' Assets tab, named after the object (else Asset 1, Asset 2…); art already collected as such an asset keeps it → {assets: [asset id…] (in order), added: n}. Assets follow their art: moving it moves the crop, deleting it removes it from the asset",
|
||||
"{ids?: [id…] (default: the selected objects; a layer id collects the art under it, including in its sublayers), multiple?: true (an asset per object; false: one asset of them all), name?: (the asset's name, when it makes one)} collect art as assets for Window › Asset Export and Export for Screens' Assets tab, named after the object (else Asset 1, Asset 2…); art already collected as such an asset keeps it → {assets: [asset id…] (in order), added: n}. Assets follow their art: moving it moves the crop, deleting it removes it from the asset",
|
||||
has_doc,
|
||||
add
|
||||
),
|
||||
@@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Assets",
|
||||
[],
|
||||
None,
|
||||
"{} → {assets: [{id, name, nodes: [id…], bounds: [x, y, width, height] | null (the crop: the visual bounds of its art)}]} the Asset Export panel's assets, in order",
|
||||
"{} → {assets: [{id, name, nodes: [id…], bounds: [x, y, width, height] | null (the crop: the bounds the renderer measures for what its art draws, effects included)}]} the Asset Export panel's assets, in order",
|
||||
has_doc,
|
||||
list
|
||||
),
|
||||
@@ -78,13 +78,32 @@ fn clean_name(name: &str) -> Option<String> {
|
||||
(!n.is_empty()).then_some(n)
|
||||
}
|
||||
|
||||
/// Ids of art under `id`: the id itself when it is not a layer, else every non-layer under it
|
||||
/// (sublayers unfolded). Layers alone have nothing to collect.
|
||||
pub(crate) fn art_under(doc: &vectorcraft_doc::Document, id: NodeId, out: &mut Vec<NodeId>) {
|
||||
let Some(n) = doc.node(id) else { return };
|
||||
if n.is_layer() {
|
||||
if let Some(kids) = n.children() {
|
||||
for c in kids {
|
||||
art_under(doc, c.id, out);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
out.push(id);
|
||||
}
|
||||
|
||||
fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "assets.add";
|
||||
let ids = if p.get("ids").is_some_and(|v| !v.is_null()) {
|
||||
// An explicit list is authoritative: never silently drop malformed or stale ids.
|
||||
let ids = checked_ids_param(s, p, "ids", C)?;
|
||||
let d = &s.doc()?.doc;
|
||||
roots_of(d, d.paint_order(ids))
|
||||
let mut art = Vec::new();
|
||||
for id in &ids {
|
||||
art_under(d, *id, &mut art);
|
||||
}
|
||||
roots_of(d, d.paint_order(art))
|
||||
} else {
|
||||
selected_roots(s)?
|
||||
};
|
||||
@@ -161,7 +180,7 @@ fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
.assets
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let bounds = a.nodes.iter().filter_map(|id| d.node(*id)?.visual_bounds()).reduce(|x, y| x.union(y));
|
||||
let bounds = export_crop(d, &a.nodes);
|
||||
json!({
|
||||
"id": a.id,
|
||||
"name": a.name,
|
||||
|
||||
@@ -11,10 +11,10 @@ use std::sync::Arc;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_brush::{self as brush, Brush};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, Symbol};
|
||||
use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, Symbol, SymbolExportType, SymbolRegistration};
|
||||
use vectorcraft_geom::{Affine, Point, Vec2};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::edit::{outermost, selected_roots};
|
||||
use super::pathops::{remove_each, separate};
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
@@ -95,22 +95,31 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
brush_freehand
|
||||
),
|
||||
// ----- symbols -----
|
||||
cmd!(query "symbol.list", "Symbols", [], None, "{} → {symbols: [{name, instances, size: [w,h]}], current}", has_doc, symbol_list),
|
||||
cmd!(query "symbol.list", "Symbols", [], None, "{} → {symbols: [{name, instances, size: [w,h], registration: 0..8, exportType: movieClip|graphic}], current}", has_doc, symbol_list),
|
||||
cmd!(
|
||||
"symbol.new",
|
||||
"New Symbol…",
|
||||
[],
|
||||
None,
|
||||
"{name?, ids?|id?} make a symbol from the selection and replace it with an instance → {name, id}",
|
||||
"{name?, ids?|id?, registration?: 0..8 (9-point grid, 4 = centre; Symbol Options › Registration), exportType?: movieClip (default)|graphic} make a symbol from the selection and replace it with an instance → {name, id, registration, exportType}",
|
||||
has_selection,
|
||||
symbol_new
|
||||
),
|
||||
cmd!(
|
||||
"symbol.options",
|
||||
"Symbol Options…",
|
||||
[],
|
||||
None,
|
||||
"{name?, newName?, registration?: 0..8, exportType?: movieClip|graphic} rename a symbol and/or set Symbol Options › Registration and Export Type (default name: current) → {name, registration, exportType}",
|
||||
has_doc,
|
||||
symbol_options
|
||||
),
|
||||
cmd!(
|
||||
"symbol.place",
|
||||
"Place Symbol Instance",
|
||||
[],
|
||||
None,
|
||||
"{name?, x?, y?} place an instance centred at (x, y) (default: artboard centre; name default: current) → {id}",
|
||||
"{name?, x?, y?} place an instance so its registration point is at (x, y) (default: artboard centre; name default: current) → {id}",
|
||||
has_doc,
|
||||
symbol_place
|
||||
),
|
||||
@@ -198,11 +207,11 @@ fn ids_json(ids: &[NodeId]) -> Value {
|
||||
fn roots(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
|
||||
match ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])) {
|
||||
Some(mut v) => {
|
||||
// Explicit ids: only existing, non-layer objects.
|
||||
// Explicit ids: only existing, non-layer objects, none inside another of them.
|
||||
let d = &s.doc()?.doc;
|
||||
v.retain(|id| d.node(*id).is_some_and(|n| !n.is_layer()));
|
||||
v.dedup();
|
||||
Ok(v)
|
||||
Ok(outermost(d, v))
|
||||
}
|
||||
None => selected_roots(s),
|
||||
}
|
||||
@@ -511,11 +520,39 @@ fn set_natural_size(d: &mut Document, name: &str, size: Option<(f64, f64)>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Instance transform for a symbol of natural size `(w, h)` centred at `c`.
|
||||
fn place_xf(c: Point, (w, h): (f64, f64)) -> Affine {
|
||||
/// Instance transform for a symbol of natural size `(w, h)` whose registration point is at `at`.
|
||||
fn place_xf(at: Point, (w, h): (f64, f64), reg: SymbolRegistration) -> Affine {
|
||||
let c = at - reg.offset_from_center(w, h);
|
||||
Affine::translate(c.to_vec2()) * Affine::scale_non_uniform(w / (2.0 * HALF), h / (2.0 * HALF))
|
||||
}
|
||||
|
||||
fn registration_param(p: &Value, cmd: &str) -> Result<Option<SymbolRegistration>> {
|
||||
match p.get("registration") {
|
||||
None => Ok(None),
|
||||
Some(v) => v.as_u64().and_then(SymbolRegistration::from_u64).map(Some).ok_or_else(|| bad(cmd, "registration must be 0..8")),
|
||||
}
|
||||
}
|
||||
|
||||
fn export_type_param(p: &Value, cmd: &str) -> Result<Option<SymbolExportType>> {
|
||||
match str_param(p, "exportType") {
|
||||
None => Ok(None),
|
||||
Some(s) => SymbolExportType::from_id(s).map(Some).ok_or_else(|| bad(cmd, format!("exportType must be movieClip or graphic, not `{s}`"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn rename_natural_size(d: &mut Document, from: &str, to: &str) {
|
||||
if from == to {
|
||||
return;
|
||||
}
|
||||
let size = d.unknown.get(SIZES_KEY).and_then(|m| m.get(from)).cloned();
|
||||
set_natural_size(d, from, None);
|
||||
if let Some(v) = size
|
||||
&& let (Some(w), Some(h)) = (v.get(0).and_then(Value::as_f64), v.get(1).and_then(Value::as_f64))
|
||||
{
|
||||
set_natural_size(d, to, Some((w, h)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalise art for a symbol definition: returns (art, natural size, centre in the document).
|
||||
fn normalize(mut art: Node) -> Result<(Node, (f64, f64), Point)> {
|
||||
let b = art.geometric_bounds().ok_or_else(|| EngineError::Other("the art has no bounds".into()))?;
|
||||
@@ -553,7 +590,13 @@ fn symbol_list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
.iter()
|
||||
.map(|sym| {
|
||||
let (w, h) = natural_size(d, &sym.name);
|
||||
json!({"name": sym.name, "instances": instances_of(d, Some(&sym.name)).len(), "size": [w, h]})
|
||||
json!({
|
||||
"name": sym.name,
|
||||
"instances": instances_of(d, Some(&sym.name)).len(),
|
||||
"size": [w, h],
|
||||
"registration": d.symbol_registration(&sym.name).0,
|
||||
"exportType": d.symbol_export_type(&sym.name).id(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({ "symbols": list, "current": current_symbol(d).or_else(|| symbol_names(d).into_iter().next()) }))
|
||||
@@ -576,20 +619,78 @@ fn replace_with_instance(d: &mut Document, ids: &[NodeId], name: &str, xf: Affin
|
||||
}
|
||||
|
||||
fn symbol_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "symbol.new";
|
||||
let ids = roots(s, p)?;
|
||||
let art = selection_art(s, p)?.ok_or_else(|| EngineError::Other("nothing selected".into()))?;
|
||||
let (art, size, c) = normalize(art)?;
|
||||
let name = s.doc()?.doc.unique_symbol_name(str_param(p, "name").filter(|n| !n.trim().is_empty()).unwrap_or("New Symbol"));
|
||||
let reg = registration_param(p, C)?.unwrap_or(SymbolRegistration::CENTER);
|
||||
let export = export_type_param(p, C)?.unwrap_or_default();
|
||||
// Keep the art where it was: the geometric centre stays at `c`, whatever the registration is.
|
||||
let place = place_xf(c + reg.offset_from_center(size.0, size.1), size, reg);
|
||||
let id = s.edit("New Symbol", |d, sel| {
|
||||
let art = d.reid(&art);
|
||||
d.symbols.push(Symbol { name: name.clone(), art: Arc::new(art) });
|
||||
set_natural_size(d, &name, Some(size));
|
||||
d.set_symbol_registration(&name, reg);
|
||||
d.set_symbol_export_type(&name, export);
|
||||
d.unknown.insert(CURRENT_SYMBOL.into(), json!(name));
|
||||
let id = replace_with_instance(d, &ids, &name, place_xf(c, size))?;
|
||||
let id = replace_with_instance(d, &ids, &name, place)?;
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})?;
|
||||
Ok(json!({ "name": name, "id": id.0 }))
|
||||
Ok(json!({ "name": name, "id": id.0, "registration": reg.0, "exportType": export.id() }))
|
||||
}
|
||||
|
||||
fn symbol_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "symbol.options";
|
||||
let name = resolve_symbol(&s.doc()?.doc, p, C)?;
|
||||
let export = export_type_param(p, C)?;
|
||||
let reg = registration_param(p, C)?;
|
||||
let new_name = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
|
||||
if new_name.is_none() && export.is_none() && reg.is_none() {
|
||||
return Err(bad(C, "pass newName, registration and/or exportType"));
|
||||
}
|
||||
if let Some(ref n) = new_name
|
||||
&& n != &name
|
||||
&& s.doc()?.doc.symbols.iter().any(|x| x.name == *n)
|
||||
{
|
||||
return Err(bad(C, format!("a symbol named `{n}` already exists")));
|
||||
}
|
||||
let final_name = new_name.clone().unwrap_or_else(|| name.clone());
|
||||
s.edit("Symbol Options", |d, _| {
|
||||
if let Some(ref n) = new_name
|
||||
&& n != &name
|
||||
{
|
||||
if let Some(sym) = d.symbols.iter_mut().find(|x| x.name == name) {
|
||||
sym.name = n.clone();
|
||||
}
|
||||
for id in instances_of(d, Some(&name)) {
|
||||
if let Some(NodeKind::SymbolInstance { symbol, .. }) = d.node_mut(id).map(|node| &mut node.kind) {
|
||||
*symbol = n.clone();
|
||||
}
|
||||
}
|
||||
rename_natural_size(d, &name, n);
|
||||
d.rename_symbol_registration(&name, n);
|
||||
d.rename_symbol_export_type(&name, n);
|
||||
if d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str) == Some(name.as_str()) {
|
||||
d.unknown.insert(CURRENT_SYMBOL.into(), json!(n));
|
||||
}
|
||||
}
|
||||
if let Some(r) = reg {
|
||||
d.set_symbol_registration(&final_name, r);
|
||||
}
|
||||
if let Some(kind) = export {
|
||||
d.set_symbol_export_type(&final_name, kind);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
let d = &s.doc()?.doc;
|
||||
Ok(json!({
|
||||
"name": final_name,
|
||||
"registration": d.symbol_registration(&final_name).0,
|
||||
"exportType": d.symbol_export_type(&final_name).id(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn resolve_symbol(d: &Document, p: &Value, cmd: &str) -> Result<String> {
|
||||
@@ -606,12 +707,13 @@ fn symbol_place(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = &s.doc()?.doc;
|
||||
let name = resolve_symbol(d, p, "symbol.place")?;
|
||||
let centre = d.artboards.first().map(|a| a.rect.center()).unwrap_or(Point::ZERO);
|
||||
let c = Point::new(f64_or(p, "x", centre.x), f64_or(p, "y", centre.y));
|
||||
let at = Point::new(f64_or(p, "x", centre.x), f64_or(p, "y", centre.y));
|
||||
let size = natural_size(d, &name);
|
||||
let reg = d.symbol_registration(&name);
|
||||
let parent = s.doc()?.target_parent()?;
|
||||
let id = s.edit("Place Symbol Instance", |d, sel| {
|
||||
let id = d.alloc_id();
|
||||
d.insert(parent, usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: name.clone(), xf: place_xf(c, size) }))?;
|
||||
d.insert(parent, usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: name.clone(), xf: place_xf(at, size, reg) }))?;
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})?;
|
||||
@@ -720,7 +822,8 @@ fn symbol_update(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
*xf *= fix;
|
||||
}
|
||||
}
|
||||
let id = replace_with_instance(d, &ids, &name, place_xf(c, size))?;
|
||||
let reg = d.symbol_registration(&name);
|
||||
let id = replace_with_instance(d, &ids, &name, place_xf(c + reg.offset_from_center(size.0, size.1), size, reg))?;
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})?;
|
||||
@@ -743,6 +846,8 @@ fn symbol_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
d.symbols.retain(|x| x.name != name);
|
||||
set_natural_size(d, &name, None);
|
||||
d.set_symbol_registration(&name, SymbolRegistration::CENTER);
|
||||
d.clear_symbol_export_type(&name);
|
||||
if d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str) == Some(name.as_str()) {
|
||||
d.unknown.remove(CURRENT_SYMBOL);
|
||||
}
|
||||
@@ -758,13 +863,17 @@ fn symbol_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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 size = natural_size(d, &name);
|
||||
let reg = d.symbol_registration(&name);
|
||||
let export = d.symbol_export_type(&name);
|
||||
s.edit("Duplicate Symbol", |d, _| {
|
||||
let art = d.reid(&sym.art);
|
||||
d.symbols.push(Symbol { name: new.clone(), art: Arc::new(art) });
|
||||
set_natural_size(d, &new, Some(size));
|
||||
d.set_symbol_registration(&new, reg);
|
||||
d.set_symbol_export_type(&new, export);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "name": new }))
|
||||
Ok(json!({ "name": new, "registration": reg.0, "exportType": export.id() }))
|
||||
}
|
||||
|
||||
fn symbol_replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -882,13 +991,14 @@ fn symbol_spray(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
placed.push(c);
|
||||
}
|
||||
let count = placed.len();
|
||||
let reg = s.doc()?.doc.symbol_registration(&name);
|
||||
let id = s.edit("Symbol Sprayer", |d, sel| {
|
||||
let mut nodes = vec![];
|
||||
for (i, c) in placed.iter().enumerate() {
|
||||
let (_, _, js) = jitter(i + 7, *c);
|
||||
let k = scale * (1.0 + js * 0.1);
|
||||
let id = d.alloc_id();
|
||||
nodes.push(Node::new(id, NodeKind::SymbolInstance { symbol: name.clone(), xf: place_xf(*c, size) * Affine::scale(k) }));
|
||||
nodes.push(Node::new(id, NodeKind::SymbolInstance { symbol: name.clone(), xf: place_xf(*c, size, reg) * Affine::scale(k) }));
|
||||
}
|
||||
let gid = match set {
|
||||
Some(g) => {
|
||||
|
||||
@@ -24,7 +24,7 @@ use vectorcraft_tools::builder::{
|
||||
};
|
||||
use vectorcraft_trace as tr;
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::edit::{outermost, selected_roots};
|
||||
use super::paint::paint_from;
|
||||
use super::pathops::{Beside, shape_node};
|
||||
use super::*;
|
||||
@@ -192,7 +192,7 @@ fn root_ids(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
|
||||
if ids.is_empty() {
|
||||
return Err(EngineError::Other("select objects, not layers".into()));
|
||||
}
|
||||
Ok(sorted_roots(d, &ids))
|
||||
Ok(outermost(d, sorted_roots(d, &ids)))
|
||||
}
|
||||
|
||||
fn points_param(p: &Value, key: &str) -> Option<Vec<Point>> {
|
||||
|
||||
@@ -80,7 +80,8 @@ pub struct Clipboard {
|
||||
pub para_styles: Vec<TextStyleDef>,
|
||||
/// Brushes the objects' strokes use.
|
||||
pub brushes: Vec<Brush>,
|
||||
/// The artboard the objects were copied from: Paste on All Artboards keeps their offset to it.
|
||||
/// The artboard the objects were copied from (none when they lie on no artboard): Paste on All
|
||||
/// Artboards keeps their offset to it.
|
||||
pub source_artboard: Option<Rect>,
|
||||
/// Per object: the name of the layer it was copied from (Paste Remembers Layers).
|
||||
pub source_layers: Vec<Option<String>>,
|
||||
@@ -109,8 +110,14 @@ impl Clipboard {
|
||||
source_layers.push(anc.iter().rev().skip(1).find_map(|a| d.node(*a).filter(|l| l.is_layer())).and_then(|l| l.name.clone()));
|
||||
}
|
||||
let bounds = nodes.iter().filter_map(Node::visual_bounds).reduce(|a, b| a.union(b));
|
||||
let board = bounds.and_then(|b| d.artboard_at(b.center())).unwrap_or(0);
|
||||
Self { source_artboard: d.artboards.get(board).map(|a| a.rect), source_layers, source_doc: Some(st.uid), ..Self::gather(d, nodes) }
|
||||
// Art on no artboard has no source artboard: in-place pastes put it back where it was (#1924).
|
||||
let board = bounds.and_then(|b| d.artboard_at(b.center()));
|
||||
Self {
|
||||
source_artboard: board.and_then(|i| d.artboards.get(i)).map(|a| a.rect),
|
||||
source_layers,
|
||||
source_doc: Some(st.uid),
|
||||
..Self::gather(d, nodes)
|
||||
}
|
||||
}
|
||||
|
||||
/// Every object of `src` (each top-level layer's contents) with its resources, for Place and
|
||||
@@ -168,15 +175,13 @@ impl Clipboard {
|
||||
d.fix_next_id();
|
||||
d
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The internal clipboard as standalone SVG (`None` when it is empty).
|
||||
pub fn clipboard_svg(&self) -> Option<String> {
|
||||
if self.clipboard.is_empty() {
|
||||
/// The objects as standalone SVG (`None` when there are none).
|
||||
pub fn svg(&self) -> Option<String> {
|
||||
if self.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let d = self.clipboard.to_document();
|
||||
let d = self.to_document();
|
||||
// SVG has no filters for the Photoshop-style effects: their objects go in as images.
|
||||
let flat = super::rasterfx::flatten_pixel_effects(&d);
|
||||
Some(vectorcraft_svg::export(
|
||||
@@ -186,6 +191,13 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The internal clipboard as standalone SVG (`None` when it is empty).
|
||||
pub fn clipboard_svg(&self) -> Option<String> {
|
||||
self.clipboard.svg()
|
||||
}
|
||||
}
|
||||
|
||||
fn export_svg(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({ "svg": s.clipboard_svg() }))
|
||||
}
|
||||
|
||||
@@ -71,13 +71,19 @@ pub const FILE_HEAD: usize = 4096;
|
||||
|
||||
/// What a file copied in a file manager pastes as, if one of `mimes` (`image/*`: any bitmap):
|
||||
/// SVG, PDF, EPS, a Windows metafile or a bitmap (its own `image/…` type), told from its first
|
||||
/// [`FILE_HEAD`] bytes and its name. The file's bytes are that flavour's data.
|
||||
/// [`FILE_HEAD`] bytes and its name. A `.ai` or `.ait` file pastes as PDF when its bytes are a
|
||||
/// PDF and as EPS when they are PostScript. The file's bytes are that flavour's data.
|
||||
pub fn file_flavour(name: &str, head: &[u8], mimes: &[&str]) -> Option<&'static str> {
|
||||
let f = fileio::detect(name, head)?;
|
||||
let (asked, mime) = match f.id {
|
||||
"svg" => (SVG, SVG),
|
||||
// `detect` names gzip data SVGZ (a compressed native file aside); `clipboard.importSvg`
|
||||
// reads SVGZ data.
|
||||
"svgz" if unpacks_to_svg(head) => (SVG, SVG),
|
||||
"pdf" => (PDF, PDF),
|
||||
"eps" => (EPS, EPS),
|
||||
"ai" | "ait" if head.starts_with(b"%PDF") => (PDF, PDF),
|
||||
"ai" | "ait" if vectorcraft_pdf::is_postscript(head) => (EPS, EPS),
|
||||
"emf" | "wmf" => (EMF, EMF),
|
||||
_ if f.raster => (BITMAP, f.mime),
|
||||
_ => return None,
|
||||
@@ -85,6 +91,25 @@ pub fn file_flavour(name: &str, head: &[u8], mimes: &[&str]) -> Option<&'static
|
||||
mimes.contains(&asked).then_some(mime)
|
||||
}
|
||||
|
||||
/// Does gzip data `head` (a file's first bytes, often a stream cut short) unpack to SVG markup?
|
||||
/// The first [`FILE_HEAD`] bytes it unpacks to are read as UTF-16 after a UTF-16 byte order mark,
|
||||
/// else as UTF-8, and checked with [`looks_like_svg`].
|
||||
fn unpacks_to_svg(head: &[u8]) -> bool {
|
||||
use std::io::Read as _;
|
||||
let mut raw = Vec::with_capacity(FILE_HEAD);
|
||||
// A stream cut short ends in an error after the bytes that unpacked: judge those.
|
||||
let _ = flate2::read::GzDecoder::new(head).take(FILE_HEAD as u64).read_to_end(&mut raw);
|
||||
let utf16 = |rest: &[u8], unit: fn([u8; 2]) -> u16| -> String {
|
||||
char::decode_utf16(rest.as_chunks::<2>().0.iter().map(|u| unit(*u))).map(|c| c.unwrap_or(char::REPLACEMENT_CHARACTER)).collect()
|
||||
};
|
||||
let text = match raw.as_slice() {
|
||||
[0xfe, 0xff, rest @ ..] => utf16(rest, u16::from_be_bytes),
|
||||
[0xff, 0xfe, rest @ ..] => utf16(rest, u16::from_le_bytes),
|
||||
_ => String::from_utf8_lossy(&raw).into_owned(),
|
||||
};
|
||||
looks_like_svg(&text)
|
||||
}
|
||||
|
||||
/// One representation of the clipboard's contents.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Flavour {
|
||||
@@ -192,11 +217,10 @@ fn type_only(n: &Node) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The copied type objects' text, one object per line (`None` unless the clipboard holds type
|
||||
/// only).
|
||||
pub fn clipboard_text(&self) -> Option<String> {
|
||||
let nodes = &self.clipboard.nodes;
|
||||
impl Clipboard {
|
||||
/// The copied type objects' text, one object per line (`None` unless it holds type only).
|
||||
pub fn text(&self) -> Option<String> {
|
||||
let nodes = &self.nodes;
|
||||
if nodes.is_empty() || !nodes.iter().all(type_only) {
|
||||
return None;
|
||||
}
|
||||
@@ -211,64 +235,94 @@ impl Session {
|
||||
Some(lines.join("\n"))
|
||||
}
|
||||
|
||||
/// The clipboard as a document whose one artboard is the objects' visual bounds (`None` when
|
||||
/// it is empty or has no extent).
|
||||
fn clipboard_cropped(&self) -> Option<(Document, vectorcraft_geom::Rect)> {
|
||||
let b = self.clipboard.bounds().filter(|b| b.width() > 0.0 && b.height() > 0.0)?;
|
||||
Some((fileio::with_single_artboard(self.clipboard.to_document(), b, "Clipboard"), b))
|
||||
/// The objects as a document whose one artboard is their visual bounds (`None` when there are
|
||||
/// none or they have no extent).
|
||||
fn cropped(&self) -> Option<(Document, vectorcraft_geom::Rect)> {
|
||||
let b = self.bounds().filter(|b| b.width() > 0.0 && b.height() > 0.0)?;
|
||||
Some((fileio::with_single_artboard(self.to_document(), b, "Clipboard"), b))
|
||||
}
|
||||
|
||||
/// 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 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)>> {
|
||||
const C: &str = "clipboard.exportPng";
|
||||
let Some((doc, b)) = self.clipboard_cropped() else { return Ok(None) };
|
||||
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)))
|
||||
}
|
||||
|
||||
/// 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>>> {
|
||||
let Some((doc, _)) = self.clipboard_cropped() else { return Ok(None) };
|
||||
/// 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>>> {
|
||||
let Some((doc, _)) = self.cropped() else { return Ok(None) };
|
||||
fileio::encode(&doc, "pdf", &json!({ "preserveEditing": false })).map(Some)
|
||||
}
|
||||
|
||||
/// The MIME types of [`Self::clipboard_flavours`], without making them.
|
||||
pub fn clipboard_flavour_types(&self) -> Vec<&'static str> {
|
||||
if self.clipboard.is_empty() {
|
||||
/// The MIME types of [`Self::flavours`], without making them.
|
||||
pub fn flavour_types(&self, svg: bool, pdf: bool) -> Vec<&'static str> {
|
||||
if self.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
let (svg, pdf) = (self.prefs.copy_as_svg, self.prefs.copy_as_pdf);
|
||||
let text = svg || self.clipboard.nodes.iter().all(type_only);
|
||||
let text = svg || self.nodes.iter().all(type_only);
|
||||
// Art without an area (a lone guide-like line) has no page or picture.
|
||||
let area = self.clipboard.bounds().is_some_and(|b| b.width() > 0.0 && b.height() > 0.0);
|
||||
let area = self.bounds().is_some_and(|b| b.width() > 0.0 && b.height() > 0.0);
|
||||
[(TEXT, text), (SVG, svg), (PDF, pdf && area), (PNG, area)].into_iter().filter_map(|(m, on)| on.then_some(m)).collect()
|
||||
}
|
||||
|
||||
/// What Copy offers other apps, best first (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 clipboard_flavours(&self) -> Vec<Flavour> {
|
||||
let types = self.clipboard_flavour_types();
|
||||
let svg = types.contains(&SVG).then(|| self.clipboard_svg()).flatten();
|
||||
/// What Copy offers other apps, best first, with Include SVG Code (`svg`) and On Copy: PDF
|
||||
/// (`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> {
|
||||
let types = self.flavour_types(svg, pdf);
|
||||
let svg = types.contains(&SVG).then(|| self.svg()).flatten();
|
||||
let mut out = vec![];
|
||||
for mime in types {
|
||||
let data = match mime {
|
||||
TEXT => self.clipboard_text().or_else(|| svg.clone()).map(String::into_bytes),
|
||||
TEXT => self.text().or_else(|| svg.clone()).map(String::into_bytes),
|
||||
SVG => svg.clone().map(String::into_bytes),
|
||||
PDF => self.clipboard_pdf().inspect_err(|e| log::warn!("clipboard PDF: {e}")).ok().flatten(),
|
||||
PDF => self.pdf().inspect_err(|e| log::warn!("clipboard PDF: {e}")).ok().flatten(),
|
||||
_ => {
|
||||
let side = self.clipboard.bounds().map_or(1.0, |b| b.width().max(b.height()));
|
||||
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.clipboard_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, ..)| png)
|
||||
}
|
||||
};
|
||||
out.extend(data.map(|data| Flavour { mime, data }));
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The copied type objects' text, one object per line (`None` unless the clipboard holds type
|
||||
/// only).
|
||||
pub fn clipboard_text(&self) -> Option<String> {
|
||||
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)>> {
|
||||
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>>> {
|
||||
self.clipboard.pdf()
|
||||
}
|
||||
|
||||
/// The MIME types of [`Self::clipboard_flavours`], without making them.
|
||||
pub fn clipboard_flavour_types(&self) -> Vec<&'static str> {
|
||||
self.clipboard.flavour_types(self.prefs.copy_as_svg, self.prefs.copy_as_pdf)
|
||||
}
|
||||
|
||||
/// What Copy offers other apps, best first (see `clipboard.flavours`): [`Clipboard::flavours`]
|
||||
/// by the Clipboard Handling preferences.
|
||||
pub fn clipboard_flavours(&self) -> Vec<Flavour> {
|
||||
self.clipboard.flavours(self.prefs.copy_as_svg, self.prefs.copy_as_pdf)
|
||||
}
|
||||
|
||||
/// Replace the clipboard with `clip`, centred on `p`'s `center` (default: the first
|
||||
/// artboard's centre) → the number of objects.
|
||||
|
||||
@@ -204,6 +204,12 @@ impl<'a> Recolor<'a> {
|
||||
Self { links: Some(links), ..Self::new(Scope { images: false, ..scope }, &same) }
|
||||
}
|
||||
|
||||
/// A pass that maps solid colours and gradient stops with their swatch link and tint
|
||||
/// (`links`), and meshes, images and patterns through `f`.
|
||||
pub(crate) fn with_links(scope: Scope, f: &'a dyn Fn(Color) -> Color, links: &'a LinkMap<'a>) -> Self {
|
||||
Self { links: Some(links), ..Self::new(scope, f) }
|
||||
}
|
||||
|
||||
/// Recolour the objects `ids` and everything inside them (each once), add the new images and
|
||||
/// pattern swatches, and return the number of paints, meshes and images changed.
|
||||
pub(crate) fn run(mut self, d: &mut Document, ids: &[NodeId]) -> usize {
|
||||
|
||||
@@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Load Color Profile…",
|
||||
[],
|
||||
None,
|
||||
"{path: .icc/.icm file} register an ICC profile (native only) → {name, kind}",
|
||||
"{path: .icc/.icm file} register an ICC profile until the app quits or the `run` ends (native only; it isn't remembered, so load it again in each run that names it) → {name, kind}",
|
||||
always,
|
||||
load_profile
|
||||
),
|
||||
@@ -84,7 +84,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Proof Setup",
|
||||
["View", "Proof Setup"],
|
||||
None,
|
||||
"{target?: \"workingCmyk\"|\"cmyk:<profile>\"|\"legacyMacRgb\"|\"srgb\"|\"monitorRgb\"|\"protanopia\"|\"deuteranopia\", intent?, simulatePaper?, proof?: bool (also turn Proof Colors on)} → proof state",
|
||||
"{target?: \"workingCmyk\"|\"cmyk:<profile>\"|\"legacyMacRgb\"|\"srgb\"|\"monitorRgb\"|\"protanopia\"|\"deuteranopia\"|\"tritanopia\", intent?, simulatePaper?, proof?: bool (also turn Proof Colors on)} → proof state",
|
||||
always,
|
||||
proof_setup
|
||||
),
|
||||
|
||||
@@ -95,6 +95,12 @@ pub(crate) fn add_node(s: &mut Session, label: &str, kind: NodeKind, appearance:
|
||||
|
||||
/// Insert a new object looking like `look` (drawing modes apply); select it.
|
||||
pub(crate) fn add_look(s: &mut Session, label: &str, kind: NodeKind, look: NewArt, name: Option<String>) -> Result<Value> {
|
||||
insert_look(s, label, kind, look, name, true)
|
||||
}
|
||||
|
||||
/// [`add_look`] that selects the new object only when `select` is set; otherwise the selection
|
||||
/// stays as it was.
|
||||
pub(crate) fn insert_look(s: &mut Session, label: &str, kind: NodeKind, look: NewArt, name: Option<String>, select: bool) -> Result<Value> {
|
||||
let parent = s.doc()?.target_parent()?;
|
||||
let mode = s.draw_mode;
|
||||
let inside = s.draw_inside;
|
||||
@@ -137,7 +143,9 @@ pub(crate) fn add_look(s: &mut Session, label: &str, kind: NodeKind, look: NewAr
|
||||
d.insert(parent, usize::MAX, n)?;
|
||||
}
|
||||
}
|
||||
sel.set([id]);
|
||||
if select {
|
||||
sel.set([id]);
|
||||
}
|
||||
Ok(id)
|
||||
})?;
|
||||
Ok(json!({ "id": id.0 }))
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
//! Linked dimensions: generated groups share the existing text/path render and export pipeline.
|
||||
use super::*;
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::dimension::{ArrowStyle, Dimension, DimensionMode, DimensionOptions, DimensionSource, LineType, TextAlignment, TextPosition};
|
||||
use vectorcraft_doc::{Appearance, Arrowhead, CharStyle, Dash, Document, Node, NodeId, NodeKind, TextObject};
|
||||
use vectorcraft_geom::{Affine, BezPath, PathData, Point, Vec2};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"dimension.create",
|
||||
"Create Dimension",
|
||||
[],
|
||||
None,
|
||||
"{mode?: linear|angular|radius|diameter, a?: [x,y], b?: [x,y], vertex?: [x,y], source?: {kind: segment, edge: {node,subpath,segment}}|{kind: angle, first: edge, second: edge}|{kind: circle, node}|{kind: arc, edge}|{kind: points, a?, b?, vertex?: {node,subpath,anchor}}, offset?: pt, options?: {units: in|mm|pt|pc|px|cm|m|ft|yd, precision: 0-6, scale: drawing:real, hideUnits, lineWeight, lineType: solid|dashed|dotted, lineColor: [r,g,b], arrowStyle: filled|open|tick|none, arrowScale: %, extensionWeight, extensionType, extensionColor, extensionOffset, extensionLength, hideExtensions, fontFamily, fontStyle, fontSize, textColor, textPosition: away|between, textAlignment: left|center|right, textOffset}} → {id,label}; linked measurements follow source edits",
|
||||
has_doc,
|
||||
create
|
||||
),
|
||||
cmd!(
|
||||
"dimension.update",
|
||||
"Update Dimensions",
|
||||
[],
|
||||
None,
|
||||
"{ids?: [dimension ids] (default selection), options?: dimension options, offset?: pt} restyle or move existing dimensions → {ids}",
|
||||
has_doc,
|
||||
update
|
||||
),
|
||||
cmd!(
|
||||
"dimension.applyToAll",
|
||||
"Apply to All",
|
||||
[],
|
||||
None,
|
||||
"{id?: dimension id (default the first selected dimension)} gives every dimension in the document that dimension's style (its scale too), as one undo step → {ids}",
|
||||
has_doc,
|
||||
apply_to_all
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn error(s: impl ToString) -> EngineError {
|
||||
EngineError::Other(s.to_string())
|
||||
}
|
||||
|
||||
fn options(s: &Session, p: &Value) -> Result<DimensionOptions> {
|
||||
let default = s.tool_options_of("dimension");
|
||||
let value = p.get("options").cloned().unwrap_or_else(|| default.get("style").cloned().unwrap_or(json!({})));
|
||||
DimensionOptions::from_params(value).map_err(error)
|
||||
}
|
||||
|
||||
fn point(p: &Value, key: &str) -> Result<Point> {
|
||||
let a = p.get(key).and_then(Value::as_array).filter(|a| a.len() == 2).ok_or_else(|| error(format!("{key} must be [x,y]")))?;
|
||||
let x = a.first().and_then(Value::as_f64).ok_or_else(|| error("Invalid point"))?;
|
||||
let y = a.get(1).and_then(Value::as_f64).ok_or_else(|| error("Invalid point"))?;
|
||||
Ok(Point::new(x, y))
|
||||
}
|
||||
|
||||
fn distance(p: &Value, fallback: f64) -> Result<f64> {
|
||||
match p.get("offset") {
|
||||
Some(v) => v.as_f64().filter(|n| n.is_finite() && n.abs() <= 1e6).ok_or_else(|| error("Invalid dimension offset")),
|
||||
None => Ok(fallback),
|
||||
}
|
||||
}
|
||||
|
||||
fn recipe(s: &Session, p: &Value) -> Result<Dimension> {
|
||||
let mode: DimensionMode =
|
||||
p.get("mode").map(|v| serde_json::from_value(v.clone()).map_err(|_| error("Unknown dimension mode"))).transpose()?.unwrap_or_default();
|
||||
let source: Option<DimensionSource> = p.get("source").map(|v| serde_json::from_value(v.clone()).map_err(error)).transpose()?;
|
||||
if let Some(source) = &source {
|
||||
let doc = &s.doc()?.doc;
|
||||
for id in source.nodes() {
|
||||
if doc.ancestry(id).is_some_and(|path| path.iter().any(|id| doc.node(*id).is_some_and(|n| n.extras().dimension.is_some()))) {
|
||||
return Err(error("Choose original artwork, not a dimension's generated paths"));
|
||||
}
|
||||
}
|
||||
let compatible = matches!(
|
||||
(mode, source),
|
||||
(DimensionMode::Linear, DimensionSource::Segment { .. })
|
||||
| (DimensionMode::Angular, DimensionSource::Angle { .. })
|
||||
| (DimensionMode::Radius | DimensionMode::Diameter, DimensionSource::Circle { .. } | DimensionSource::Arc { .. })
|
||||
| (DimensionMode::Linear | DimensionMode::Angular, DimensionSource::Points { .. })
|
||||
);
|
||||
if !compatible {
|
||||
return Err(error("Dimension mode does not match its source"));
|
||||
}
|
||||
}
|
||||
let a = if source.is_none() || p.get("a").is_some() { point(p, "a")? } else { Point::ZERO };
|
||||
let b = if source.is_none() || p.get("b").is_some() { point(p, "b")? } else { Point::new(1.0, 0.0) };
|
||||
let vertex = if mode == DimensionMode::Angular && (source.is_none() || matches!(source, Some(DimensionSource::Points { .. }))) {
|
||||
point(p, "vertex")?
|
||||
} else {
|
||||
Point::ZERO
|
||||
};
|
||||
let mut d = Dimension { mode, a, b, vertex, offset: distance(p, 30.0)?, length: a.distance(b), options: options(s, p)?, source, moved: None };
|
||||
d.resolve(&s.doc()?.doc).map_err(error)?;
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
fn insert(doc: &mut Document, parent: Option<NodeId>, dim: Dimension) -> Result<NodeId> {
|
||||
let id = doc.alloc_id();
|
||||
let mut group = Node::new(id, NodeKind::Group { children: vec![], clip: false });
|
||||
group.name = Some(format!("Dimension · {}", dim.label().map_err(error)?));
|
||||
group.kind = NodeKind::Group { children: artwork(doc, &dim, &[])?, clip: false };
|
||||
doc.dimensions = true;
|
||||
group.extras_mut().dimension = Some(Box::new(dim));
|
||||
doc.insert(parent, usize::MAX, group)?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let dim = recipe(s, p)?;
|
||||
let label = dim.label().map_err(error)?;
|
||||
let parent = s.doc()?.insertion_parent();
|
||||
s.note_journal("options", serde_json::to_value(&dim.options).map_err(error)?);
|
||||
let id = s.edit("Create Dimension", |doc, sel| {
|
||||
let id = insert(doc, parent, dim)?;
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})?;
|
||||
Ok(json!({"id":id.0,"label":label}))
|
||||
}
|
||||
|
||||
fn dimension_of(doc: &Document, id: NodeId) -> Result<Dimension> {
|
||||
doc.node(id).and_then(|n| n.extras().dimension.as_deref()).cloned().ok_or_else(|| error("Select only dimensions"))
|
||||
}
|
||||
|
||||
fn update(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids: Vec<NodeId> = match p.get("ids") {
|
||||
Some(v) => serde_json::from_value(v.clone()).map_err(error)?,
|
||||
None => s.doc()?.selection.objects.clone(),
|
||||
};
|
||||
if ids.is_empty() || ids.len() > 10_000 {
|
||||
return Err(error("Select dimensions to update"));
|
||||
}
|
||||
let o = if p.get("options").is_some() { Some(options(s, p)?) } else { None };
|
||||
s.edit("Update Dimensions", |doc, _| {
|
||||
for id in &ids {
|
||||
if !doc.is_editable(*id) {
|
||||
return Err(error("Dimension is locked or hidden"));
|
||||
}
|
||||
let mut dim = dimension_of(doc, *id)?;
|
||||
if let Some(o) = &o {
|
||||
dim.options = o.clone();
|
||||
}
|
||||
dim.offset = distance(p, dim.offset)?;
|
||||
dim.resolve(doc).map_err(error)?;
|
||||
replace(doc, *id, dim)?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"ids":ids}))
|
||||
}
|
||||
|
||||
fn apply_to_all(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let from = match p.get("id") {
|
||||
Some(v) => NodeId(v.as_u64().ok_or_else(|| error("id must be a dimension id"))?),
|
||||
None => *st
|
||||
.selection
|
||||
.objects
|
||||
.iter()
|
||||
.find(|id| st.doc.node(**id).is_some_and(|n| n.extras().dimension.is_some()))
|
||||
.ok_or_else(|| error("Select a dimension first"))?,
|
||||
};
|
||||
let o = dimension_of(&st.doc, from)?.options;
|
||||
let mut ids = vec![];
|
||||
st.doc.walk(|n| {
|
||||
if n.extras().dimension.is_some() && st.doc.is_editable(n.id) {
|
||||
ids.push(n.id);
|
||||
}
|
||||
});
|
||||
s.edit("Apply to All", |doc, _| {
|
||||
for id in &ids {
|
||||
let mut dim = dimension_of(doc, *id)?;
|
||||
dim.options = o.clone();
|
||||
dim.resolve(doc).map_err(error)?;
|
||||
replace(doc, *id, dim)?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"ids":ids}))
|
||||
}
|
||||
|
||||
fn replace(doc: &mut Document, id: NodeId, dim: Dimension) -> Result<()> {
|
||||
let old: Vec<NodeId> = doc.node(id).and_then(Node::children).map(|ch| ch.iter().map(|n| n.id).collect()).unwrap_or_default();
|
||||
let children = artwork(doc, &dim, &old)?;
|
||||
if let Some(n) = doc.node_mut(id) {
|
||||
n.kind = NodeKind::Group { children, clip: false };
|
||||
n.name = Some(format!("Dimension · {}", dim.label().map_err(error)?));
|
||||
n.extras_mut().dimension = Some(Box::new(dim));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Called inside every edit, so dimensions and their undo history stay in step with the art they
|
||||
/// measure. Returns at once in documents without dimensions.
|
||||
pub(crate) fn refresh(before: &Document, doc: &mut Document) -> Result<()> {
|
||||
if !doc.dimensions {
|
||||
return Ok(());
|
||||
}
|
||||
let mut dimensions = Vec::new();
|
||||
doc.walk(|n| {
|
||||
if let Some(d) = &n.extras().dimension {
|
||||
dimensions.push((n.id, (**d).clone()));
|
||||
}
|
||||
});
|
||||
doc.dimensions = !dimensions.is_empty();
|
||||
for (id, old) in dimensions {
|
||||
let mut next = old.clone();
|
||||
let moved = next.moved.take();
|
||||
if next.source.as_ref().is_some_and(|s| s.topology_changed(before, doc)) || next.resolve(doc).is_err() {
|
||||
// A deleted/degenerate source has no valid measurement. Remove its annotation in
|
||||
// this same undo step instead of keeping a silently wrong number.
|
||||
doc.remove(id)?;
|
||||
continue;
|
||||
}
|
||||
// Moved on its own (the art it measures stayed put): it keeps its new place.
|
||||
if let Some(v) = moved
|
||||
&& (next.a, next.b, next.vertex) == (old.a, old.b, old.vertex)
|
||||
{
|
||||
next.place(next.handle() + v);
|
||||
if next.resolve(doc).is_err() {
|
||||
next = old.clone();
|
||||
next.moved = None;
|
||||
}
|
||||
}
|
||||
if next != old || before.node(id).and_then(|n| n.extras().dimension.as_deref()).is_some_and(|d| d != &next) {
|
||||
replace(doc, id, next)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn paint(c: [u8; 3]) -> Paint {
|
||||
Paint::solid(Color::rgb8(c[0], c[1], c[2]))
|
||||
}
|
||||
fn line(a: Point, b: Point) -> BezPath {
|
||||
let mut p = BezPath::new();
|
||||
p.move_to(a);
|
||||
p.line_to(b);
|
||||
p
|
||||
}
|
||||
|
||||
fn appearance(o: &DimensionOptions, ext: bool, arrows: bool) -> Appearance {
|
||||
let (color, width, kind) =
|
||||
if ext { (o.extension_color, o.extension_weight, o.extension_type) } else { (o.line_color, o.line_weight, o.line_type) };
|
||||
let mut a = Appearance::basic(Paint::None, paint(color), width);
|
||||
if let Some(st) = a.stroke_mut() {
|
||||
st.dash = match kind {
|
||||
LineType::Dashed => Some(Dash { pattern: vec![width * 5.0, width * 3.0], ..Default::default() }),
|
||||
LineType::Dotted => {
|
||||
st.cap = vectorcraft_doc::LineCap::Round;
|
||||
Some(Dash { pattern: vec![0.0, width * 3.0], ..Default::default() })
|
||||
}
|
||||
LineType::Solid => None,
|
||||
};
|
||||
if arrows {
|
||||
let head = match o.arrow_style {
|
||||
ArrowStyle::Filled => Some(Arrowhead::Triangle),
|
||||
ArrowStyle::Open => Some(Arrowhead::TriangleOpen),
|
||||
ArrowStyle::Tick => Some(Arrowhead::Slash),
|
||||
ArrowStyle::None => None,
|
||||
};
|
||||
st.start_arrow = head;
|
||||
st.end_arrow = head;
|
||||
st.arrow_scale = (o.arrow_scale, o.arrow_scale);
|
||||
st.arrow_align = vectorcraft_doc::appearance::ArrowAlign::Tip;
|
||||
}
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
/// How far along the line (0 start, 0.5 middle, 1 end) the label sits, kept clear of the arrows.
|
||||
fn along(o: &DimensionOptions) -> f64 {
|
||||
match o.text_alignment {
|
||||
TextAlignment::Left => 0.2,
|
||||
TextAlignment::Center => 0.5,
|
||||
TextAlignment::Right => 0.8,
|
||||
}
|
||||
}
|
||||
|
||||
fn artwork(doc: &mut Document, d: &Dimension, old: &[NodeId]) -> Result<Vec<Arc<Node>>> {
|
||||
d.validate().map_err(error)?;
|
||||
let o = &d.options;
|
||||
let label = d.label().map_err(error)?;
|
||||
let mut paths: Vec<(BezPath, Appearance)> = Vec::new();
|
||||
let mut angle = 0.0;
|
||||
let text_at;
|
||||
// Away from the object: past the dimension line; between: on the object's side of it.
|
||||
let away = o.text_position == TextPosition::Away;
|
||||
if d.mode == DimensionMode::Angular {
|
||||
let start = (d.a - d.vertex).atan2();
|
||||
let sweep = d.angle();
|
||||
let r = d.offset.abs().max(12.0);
|
||||
let on = |t: f64| d.vertex + Vec2::from_angle(start + sweep * t) * r;
|
||||
let mut arc = BezPath::new();
|
||||
arc.move_to(on(0.0));
|
||||
for i in 1..=64 {
|
||||
arc.line_to(on(i as f64 / 64.0));
|
||||
}
|
||||
paths.push((arc, appearance(o, false, true)));
|
||||
if !o.hide_extensions {
|
||||
for p in [d.a, d.b] {
|
||||
let dir = (p - d.vertex) / (p - d.vertex).hypot();
|
||||
paths.push((line(d.vertex + dir * o.extension_offset, d.vertex + dir * (r + o.extension_length)), appearance(o, true, false)));
|
||||
}
|
||||
}
|
||||
let dir = Vec2::from_angle(start + sweep * along(o));
|
||||
let gap = o.text_offset + o.font_size * 0.5;
|
||||
text_at = d.vertex + dir * if away { r + gap } else { (r - gap).max(o.font_size * 0.5) };
|
||||
} else {
|
||||
let delta = d.b - d.a;
|
||||
let u = delta / delta.hypot();
|
||||
let n = Vec2::new(-u.y, u.x);
|
||||
let (a, b) = match d.mode {
|
||||
DimensionMode::Radius => (d.a, d.b),
|
||||
DimensionMode::Diameter => (d.a - delta, d.b),
|
||||
_ => (d.a + n * d.offset, d.b + n * d.offset),
|
||||
};
|
||||
// The side of the line facing away from the object.
|
||||
let outward = match d.mode {
|
||||
DimensionMode::Linear => {
|
||||
if d.offset < 0.0 {
|
||||
-n
|
||||
} else {
|
||||
n
|
||||
}
|
||||
}
|
||||
_ => n,
|
||||
};
|
||||
if d.mode == DimensionMode::Linear && !o.hide_extensions {
|
||||
for (p, q) in [(d.a, a), (d.b, b)] {
|
||||
paths.push((line(p + outward * o.extension_offset, q + outward * o.extension_length), appearance(o, true, false)));
|
||||
}
|
||||
}
|
||||
angle = u.atan2();
|
||||
if !(-std::f64::consts::FRAC_PI_2..=std::f64::consts::FRAC_PI_2).contains(&angle) {
|
||||
angle += std::f64::consts::PI;
|
||||
}
|
||||
let shift = o.text_offset + o.font_size * 0.5;
|
||||
text_at = a.lerp(b, along(o)) + outward * if away { shift } else { -shift };
|
||||
paths.push((line(a, b), appearance(o, false, true)));
|
||||
}
|
||||
let style = CharStyle {
|
||||
font_family: o.font_family.clone(),
|
||||
font_style: o.font_style.clone(),
|
||||
size: o.font_size,
|
||||
fill: paint(o.text_color),
|
||||
..Default::default()
|
||||
};
|
||||
let mut text = TextObject::point(Point::ZERO, &label, style);
|
||||
text.para.justify = vectorcraft_doc::Justify::Center;
|
||||
super::typecmd::refresh_bounds(&mut text);
|
||||
// Centre the label's box on its anchor point, along the line.
|
||||
let bounds = text.cached_bounds.unwrap_or_else(|| text.estimate_bounds());
|
||||
let middle = Affine::translate(-bounds.center().to_vec2());
|
||||
text.xf = Affine::translate(text_at.to_vec2()) * Affine::rotate(angle) * middle;
|
||||
let mut children = Vec::new();
|
||||
for (path, a) in paths {
|
||||
let id = old.get(children.len()).copied().unwrap_or_else(|| doc.alloc_id());
|
||||
children.push(Arc::new(Node::path(id, PathData::from_bezpath(&path), a)));
|
||||
}
|
||||
let id = old.get(children.len()).copied().unwrap_or_else(|| doc.alloc_id());
|
||||
let mut node = Node::new(id, NodeKind::Text(Box::new(text)));
|
||||
node.appearance = Appearance::basic(paint(o.text_color), Paint::None, 0.0).into();
|
||||
children.push(Arc::new(node));
|
||||
Ok(children)
|
||||
}
|
||||
@@ -13,7 +13,7 @@ use vectorcraft_tools::distort::liquify::{Dabber, LiquifyParams, PathStroke, Sam
|
||||
use vectorcraft_tools::distort::perspective::{self as persp, PerspectiveGrid, Plane};
|
||||
use vectorcraft_tools::distort::{PinSet, arap, collect_points, mesh_for, project_node, stroke_owner, warp_from_rest, warp_node_with};
|
||||
|
||||
use super::edit::{duplicate_in, selected_roots};
|
||||
use super::edit::{duplicate_in, outermost, selected_roots};
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
|
||||
@@ -257,7 +257,22 @@ fn place(points: &mut Vec<(f64, f64, f64)>, mut pt: (f64, f64, f64), from_below:
|
||||
/// points at other positions either side change in proportion (by the same amount from zero).
|
||||
fn adjust_adjoining(points: &mut [(f64, f64, f64)], i: usize, old: (f64, f64, f64), new: (f64, f64, f64)) {
|
||||
let t = points[i].0;
|
||||
let scale = |v: f64, o: f64, n: f64| if o > 1e-9 { v * n / o } else { v + n - o }.max(0.0);
|
||||
let scale = |v: f64, o: f64, n: f64| {
|
||||
let width = if o > 1e-9 {
|
||||
let product = v * n;
|
||||
if product.is_finite() {
|
||||
// Keep the existing rounding, including small products that n/o would lose.
|
||||
product / o
|
||||
} else {
|
||||
// With finite nonnegative factors, an overflowing product has both factors
|
||||
// above one. Divide the larger first so this fallback cannot underflow.
|
||||
(v.max(n) / o) * v.min(n)
|
||||
}
|
||||
} else {
|
||||
v + n - o
|
||||
};
|
||||
width.max(0.0)
|
||||
};
|
||||
let before = (0..i).rev().find(|j| (points[*j].0 - t).abs() > SAME_T);
|
||||
let after = (i + 1..points.len()).find(|j| (points[*j].0 - t).abs() > SAME_T);
|
||||
for j in before.into_iter().chain(after) {
|
||||
@@ -293,8 +308,11 @@ fn width_point_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = weighted_stroke(d, id)?;
|
||||
let before = st.profile.clone();
|
||||
let half = st.width / 2.0;
|
||||
let mut prof = st.profile.take().unwrap_or_else(uniform);
|
||||
let pt = (t, left / half, right / half);
|
||||
if !half.is_finite() || half <= 0.0 || !pt.1.is_finite() || !pt.2.is_finite() {
|
||||
return Err(bad(C, "width factors must be finite"));
|
||||
}
|
||||
let mut prof = st.profile.take().unwrap_or_else(uniform);
|
||||
pos = match point_index(p, &prof, C)? {
|
||||
Some(i) => {
|
||||
let old = prof.points[i];
|
||||
@@ -320,6 +338,11 @@ fn width_point_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
i
|
||||
}
|
||||
};
|
||||
// Validate on edit_counted's copy: a failed adjoining adjustment must not change
|
||||
// the document, its dirty state or either side of its history.
|
||||
if prof.points.iter().any(|p| !p.1.is_finite() || !p.2.is_finite()) {
|
||||
return Err(bad(C, "width factors must be finite"));
|
||||
}
|
||||
st.profile = Some(prof);
|
||||
Ok(usize::from(!same_points(st.profile.as_ref(), before.as_ref())))
|
||||
})?;
|
||||
@@ -683,7 +706,8 @@ fn puppet_warp(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if pins.iter().chain(&moved).any(|q| !q.x.is_finite() || !q.y.is_finite()) {
|
||||
return Err(bad(C, "pins must be finite"));
|
||||
}
|
||||
let ids = if rest { puppet_ids(p, || Ok(s.doc()?.selection.objects.clone()))? } else { puppet_ids(p, || selected_roots(s))? };
|
||||
let ids =
|
||||
if rest { puppet_ids(p, || Ok(s.doc()?.selection.objects.clone()))? } else { outermost(&s.doc()?.doc, puppet_ids(p, || selected_roots(s))?) };
|
||||
let expand = f64_or(p, "expand", 3.0).clamp(0.0, 1000.0);
|
||||
let angles: Vec<Option<f64>> = match p.get("angles") {
|
||||
None | Some(Value::Null) => vec![None; pins.len()],
|
||||
@@ -823,7 +847,7 @@ fn plane_param(g: &PerspectiveGrid, p: &Value, cmd: &str) -> Result<Plane> {
|
||||
|
||||
fn roots(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
|
||||
match ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])) {
|
||||
Some(v) => Ok(v),
|
||||
Some(v) => Ok(outermost(&s.doc()?.doc, v)),
|
||||
None => selected_roots(s),
|
||||
}
|
||||
}
|
||||
@@ -913,9 +937,18 @@ fn beyond_horizon() -> EngineError {
|
||||
EngineError::Other("the pointer is beyond the plane's horizon".into())
|
||||
}
|
||||
|
||||
/// Whether the grid stored in `d` lists attachments of its own, as grids stored before attachments
|
||||
/// were kept on the objects do ([`PerspectiveGrid::adopt_stored_attachments`]).
|
||||
fn stores_attachments(d: &Document) -> bool {
|
||||
!PerspectiveGrid::current(d).attached.is_empty()
|
||||
}
|
||||
|
||||
/// Edit objects in perspective (one undo step `label`): with `copy` their duplicates (which stay
|
||||
/// attached), each mapped by `step` for its own plane and depth; objects not in perspective attach
|
||||
/// to `fallback` first. → the edited objects and where the first one was (plane, depth).
|
||||
/// to `fallback` first. → the edited objects and where the first one was (plane, depth). In an
|
||||
/// interaction whose document is still its snapshot, the duplicates are the copies the interaction
|
||||
/// keeps ([`super::object::preview_copies`]), unless the snapshot's grid lists attachments of its
|
||||
/// own ([`stores_attachments`]).
|
||||
fn persp_edit(
|
||||
s: &mut Session,
|
||||
label: &str,
|
||||
@@ -928,9 +961,20 @@ fn persp_edit(
|
||||
return Err(EngineError::Other("select the objects to transform in perspective".into()));
|
||||
}
|
||||
let mut g = grid_of(&s.doc()?.doc);
|
||||
let kept = if copy && !stores_attachments(&s.doc()?.doc) { super::object::preview_copies(s, ids, false)? } else { None };
|
||||
s.edit(label, |d, sel| {
|
||||
g.adopt_stored_attachments(d);
|
||||
let targets = if copy { duplicate_in(d, sel, ids, Affine::IDENTITY)? } else { ids.to_vec() };
|
||||
let targets = match &kept {
|
||||
// The document becomes the snapshot with the kept copies, not yet mapped, and the loop
|
||||
// below maps only the copies.
|
||||
Some(k) => {
|
||||
*d = Document::clone(&k.doc);
|
||||
sel.clone_from(&k.selection);
|
||||
k.ids.clone()
|
||||
}
|
||||
None if copy => duplicate_in(d, sel, ids, Affine::IDENTITY)?,
|
||||
None => ids.to_vec(),
|
||||
};
|
||||
let mut first = None;
|
||||
for id in &targets {
|
||||
let (plane, depth) = match g.attachment_of(d, *id) {
|
||||
@@ -1116,12 +1160,32 @@ fn plane_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = if objects == "move" { on_plane(&s.doc()?.doc) } else { vec![] };
|
||||
return Ok(json!({ "plane": plane.id(), "offset": to, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }));
|
||||
}
|
||||
// In an interaction whose document is still its snapshot, the copies of the objects on the
|
||||
// plane are the copies the interaction keeps (`preview_copies`), unless the snapshot's grid
|
||||
// lists attachments of its own (`stores_attachments`).
|
||||
let kept = if objects == "copy" && s.in_interaction() && !stores_attachments(&s.doc()?.doc) {
|
||||
let on = on_plane(&s.doc()?.doc);
|
||||
super::object::preview_copies(s, &on, false)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let ids = s.edit(label, |d, sel| {
|
||||
old.adopt_stored_attachments(d);
|
||||
let mut ids = vec![];
|
||||
if objects != "none" {
|
||||
let on = on_plane(d);
|
||||
ids = if objects == "copy" { duplicate_in(d, sel, &on, Affine::IDENTITY)? } else { on };
|
||||
ids = match &kept {
|
||||
// The document becomes the snapshot with the kept copies, not yet moved, and the
|
||||
// loop below moves only the copies.
|
||||
Some(k) => {
|
||||
*d = Document::clone(&k.doc);
|
||||
sel.clone_from(&k.selection);
|
||||
k.ids.clone()
|
||||
}
|
||||
None => {
|
||||
let on = on_plane(d);
|
||||
if objects == "copy" { duplicate_in(d, sel, &on, Affine::IDENTITY)? } else { on }
|
||||
}
|
||||
};
|
||||
let m = old.transform_map(plane, from, Affine::IDENTITY, to - from).ok_or_else(beyond_horizon)?;
|
||||
for id in &ids {
|
||||
warp_checked(d, *id, &m)?;
|
||||
|
||||
@@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Document Info",
|
||||
["Window", "Document Info"],
|
||||
None,
|
||||
"{selectionOnly?, category?: document|objects|graphicStyles|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its licence lets it be embedded)}",
|
||||
"{selectionOnly?, category?: document|objects|graphicStyles|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its licence lets it be embedded)}",
|
||||
has_doc,
|
||||
info
|
||||
),
|
||||
@@ -50,7 +50,7 @@ pub const CATEGORIES: [(&str, &str); 11] = [
|
||||
];
|
||||
|
||||
/// The object counts of `document.info` and their labels.
|
||||
const OBJECT_LABELS: [(&str, &str); 17] = [
|
||||
const OBJECT_LABELS: [(&str, &str); 18] = [
|
||||
("paths", "Paths"),
|
||||
("compoundPaths", "Compound Paths"),
|
||||
("groups", "Groups"),
|
||||
@@ -65,6 +65,7 @@ const OBJECT_LABELS: [(&str, &str); 17] = [
|
||||
("blends", "Blends"),
|
||||
("envelopes", "Envelopes"),
|
||||
("repeats", "Repeats"),
|
||||
("intertwines", "Intertwine Groups"),
|
||||
("opacityMasks", "Opacity Masks"),
|
||||
("liveEffects", "Objects with Effects"),
|
||||
("guides", "Guides"),
|
||||
@@ -128,7 +129,13 @@ fn sections(d: &Document, info: &Value, fonts: &BTreeSet<super::fonts::UsedFont>
|
||||
rows
|
||||
}
|
||||
"objects" => {
|
||||
OBJECT_LABELS.iter().filter_map(|(k, label)| Some((label.to_string(), info["objects"][k].as_u64()?.to_string()))).collect()
|
||||
let mut rows: Vec<(String, String)> =
|
||||
OBJECT_LABELS.iter().filter_map(|(k, label)| Some((label.to_string(), info["objects"][k].as_u64()?.to_string()))).collect();
|
||||
// The paths' total length, in the document's units, right after their count.
|
||||
if let Some(at) = rows.iter().position(|(label, _)| label == "Paths") {
|
||||
rows.insert(at + 1, ("Path Length".into(), u.format(info["pathLength"].as_f64().unwrap_or(0.0))));
|
||||
}
|
||||
rows
|
||||
}
|
||||
"graphicStyles" => names("graphicStyleNames"),
|
||||
"spotColors" => names("spotColors"),
|
||||
@@ -229,6 +236,9 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let roots: Vec<&Node> =
|
||||
if selection_only { st.selection.objects.iter().filter_map(|id| d.node(*id)).collect() } else { d.layers.iter().map(|l| &**l).collect() };
|
||||
let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
|
||||
// The paths' total length, as Document Info › Objects shows it (the length of a cut line, a
|
||||
// strip of LEDs along the letters, #1958).
|
||||
let mut path_length = 0.0;
|
||||
let mut fonts = BTreeSet::new();
|
||||
let mut images: BTreeMap<String, Value> = BTreeMap::new();
|
||||
let mut image_rows: Vec<ImageRow> = vec![];
|
||||
@@ -243,7 +253,13 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
NodeKind::Group { clip: true, .. } => Some("clipGroups"),
|
||||
NodeKind::Group { .. } => Some("groups"),
|
||||
NodeKind::Path { guide: true, .. } => Some("guides"),
|
||||
NodeKind::Path { .. } => Some("paths"),
|
||||
NodeKind::Path { path, .. } => {
|
||||
let l = path.length();
|
||||
if l.is_finite() {
|
||||
path_length += l;
|
||||
}
|
||||
Some("paths")
|
||||
}
|
||||
NodeKind::Compound { .. } => Some("compoundPaths"),
|
||||
NodeKind::Text(t) => {
|
||||
for run in &t.runs {
|
||||
@@ -269,6 +285,7 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
NodeKind::Mesh(_) => Some("meshes"),
|
||||
NodeKind::Repeat(_) => Some("repeats"),
|
||||
NodeKind::PlacedDocument(_) => Some("placedDocuments"),
|
||||
NodeKind::Intertwine { .. } => Some("intertwines"),
|
||||
};
|
||||
if let Some(k) = k {
|
||||
*counts.entry(k).or_default() += 1;
|
||||
@@ -308,6 +325,7 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut out = json!({
|
||||
"document": document_summary(d),
|
||||
"objects": counts,
|
||||
"pathLength": path_length,
|
||||
"fonts": font_names,
|
||||
"images": images,
|
||||
"symbols": symbols,
|
||||
@@ -354,7 +372,8 @@ fn snap_xf(b: Rect, half: bool) -> Affine {
|
||||
}
|
||||
|
||||
fn pixel_perfect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids: Vec<NodeId> = if p.get("ids").is_some() { targets(s, p)? } else { super::edit::selected_roots(s)? };
|
||||
let ids: Vec<NodeId> =
|
||||
if p.get("ids").is_some() { super::edit::outermost(&s.doc()?.doc, targets(s, p)?) } else { super::edit::selected_roots(s)? };
|
||||
let n = ids.len();
|
||||
s.edit("Make Pixel Perfect", |d, _| {
|
||||
for id in &ids {
|
||||
@@ -405,6 +424,30 @@ mod tests {
|
||||
assert!(i["objects"]["textObjects"].is_null() && i["objects"]["paths"] == 1);
|
||||
}
|
||||
|
||||
/// Document Info › Objects gives the paths' total length in the document's units (#1958): an
|
||||
/// open line, a closed rectangle's perimeter and a compound path's pieces count, a guide doesn't.
|
||||
#[test]
|
||||
fn info_gives_the_paths_total_length() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400, "units": "Millimeters"})).unwrap();
|
||||
let line = s.execute("shape.line", &json!({"x1": 0, "y1": 0, "x2": 72, "y2": 0})).unwrap()["id"].clone();
|
||||
let rect = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 10, "height": 20})).unwrap()["id"].clone();
|
||||
let length = |s: &mut Session, p: Value| s.execute("document.info", &p).unwrap()["pathLength"].as_f64().unwrap();
|
||||
assert!((length(&mut s, json!({})) - 132.0).abs() < 1e-6);
|
||||
s.execute("select.set", &json!({"ids": [line]})).unwrap();
|
||||
assert!((length(&mut s, json!({"selectionOnly": true})) - 72.0).abs() < 1e-6);
|
||||
let rows = s.execute("document.info", &json!({"selectionOnly": true, "category": "objects"})).unwrap()["sections"][0]["rows"].clone();
|
||||
assert_eq!(rows, json!([["Paths", "1"], ["Path Length", "25.4 mm"]]));
|
||||
// A compound path's pieces are paths: their lengths count.
|
||||
let other = s.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 5, "height": 5})).unwrap()["id"].clone();
|
||||
s.execute("select.set", &json!({"ids": [rect, other]})).unwrap();
|
||||
s.execute("object.compoundPath.make", &json!({})).unwrap();
|
||||
assert!((length(&mut s, json!({"selectionOnly": true})) - 80.0).abs() < 1e-6);
|
||||
// Nothing with no paths, and no length row.
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
assert_eq!(length(&mut s, json!({"selectionOnly": true})), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedding_permissions_read_from_fs_type() {
|
||||
assert_eq!(embedding_label(0), "embedding allowed");
|
||||
|
||||
@@ -100,6 +100,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection,
|
||||
save_selection
|
||||
),
|
||||
cmd!(
|
||||
"select.update",
|
||||
"Update Selection",
|
||||
["Select"],
|
||||
None,
|
||||
"{} make the saved selection last recalled (`select.recall`) hold the selected objects instead of its own, in one undo step → {name}",
|
||||
has_recalled_selection,
|
||||
update_selection
|
||||
),
|
||||
cmd!(
|
||||
"select.recall",
|
||||
"Recall Selection",
|
||||
@@ -135,7 +144,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Release Guides",
|
||||
["View", "Guides"],
|
||||
Some("Cmd+Alt+5"),
|
||||
"{} turn the selected guides (or, with none selected, all guide paths) back into paths → {count}",
|
||||
"{index?: a ruler guide's index, ids?: [guide path id…]} turn guides into paths: index releases that ruler guide and ids release those guide paths (and the guide paths inside them); otherwise the selected guide paths and ruler guides are released, and with no guide selected every guide path is released; a ruler guide becomes a straight path with no fill or stroke, across its artboard or, for a canvas guide, across the bounds of all the artboards; index and ids need guides unlocked; one undo step, the released paths selected → {count}",
|
||||
has_doc,
|
||||
release_guides
|
||||
),
|
||||
@@ -342,6 +351,7 @@ fn save_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if existing.is_none() && saved.len() >= SavedSelection::MAX {
|
||||
return Err(bad(C, format!("a document keeps at most {} saved selections", SavedSelection::MAX)));
|
||||
}
|
||||
let mut slots = saved_ids(s)?;
|
||||
s.edit("Save Selection", |d, _| {
|
||||
let entry = SavedSelection { name: name.clone(), objects: ids };
|
||||
match existing.and_then(|i| d.saved_selections.get_mut(i)) {
|
||||
@@ -350,18 +360,26 @@ fn save_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
let st = s.doc_mut()?;
|
||||
if existing.is_none() {
|
||||
slots.push(st.recalled_selection.new_id());
|
||||
}
|
||||
st.recalled_selection.remember(&st.doc.saved_selections, slots);
|
||||
Ok(json!({ "name": name }))
|
||||
}
|
||||
|
||||
fn recall_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "select.recall";
|
||||
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing name"))?;
|
||||
let ids = s.doc()?.doc.saved_selections.iter().find(|x| x.name == name).map(|x| x.objects.clone());
|
||||
let ids = ids.ok_or_else(|| bad(C, format!("no saved selection `{name}`")))?;
|
||||
let saved = &s.doc()?.doc.saved_selections;
|
||||
let i = saved.iter().position(|x| x.name == name).ok_or_else(|| bad(C, format!("no saved selection `{name}`")))?;
|
||||
let ids = saved.get(i).map(|x| x.objects.clone()).unwrap_or_default();
|
||||
let st = s.doc()?;
|
||||
let can = selectable_ids(&st.doc, st.isolation);
|
||||
s.select(|_, sel| sel.set(ids.into_iter().filter(|id| can.contains(id))))?;
|
||||
Ok(json!({ "count": s.doc()?.selection.len() }))
|
||||
let st = s.doc_mut()?;
|
||||
st.recalled_selection.recall(&st.doc.saved_selections, i);
|
||||
Ok(json!({ "count": st.selection.len() }))
|
||||
}
|
||||
|
||||
/// Recall and Edit Selection… need a document with at least one saved selection.
|
||||
@@ -370,6 +388,42 @@ fn has_saved_selection(s: &Session) -> std::result::Result<(), String> {
|
||||
if st.doc.saved_selections.is_empty() { Err("no saved selections".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// The session ids of the active document's saved selections ([`crate::RecalledSelection`]).
|
||||
fn saved_ids(s: &mut Session) -> Result<Vec<u64>> {
|
||||
let st = s.doc_mut()?;
|
||||
Ok(st.recalled_selection.ids(&st.doc.saved_selections))
|
||||
}
|
||||
|
||||
/// Where the saved selection last recalled is, while the document has it.
|
||||
fn recalled_index(st: &crate::DocState) -> Option<usize> {
|
||||
st.recalled_selection.index_in(&st.doc.saved_selections)
|
||||
}
|
||||
|
||||
/// Update Selection needs selected objects and a recalled saved selection the document still has.
|
||||
fn has_recalled_selection(s: &Session) -> std::result::Result<(), String> {
|
||||
has_selection(s)?;
|
||||
let st = s.active().ok_or("no document open")?;
|
||||
if recalled_index(st).is_some() { Ok(()) } else { Err("no saved selection recalled".into()) }
|
||||
}
|
||||
|
||||
fn update_selection(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
const C: &str = "select.update";
|
||||
let st = s.doc()?;
|
||||
let i = recalled_index(st).ok_or_else(|| bad(C, "no saved selection recalled"))?;
|
||||
let ids = st.selection.objects.clone();
|
||||
let name = st.doc.saved_selections.get(i).map(|x| x.name.clone()).unwrap_or_default();
|
||||
let slots = saved_ids(s)?;
|
||||
s.edit("Update Selection", |d, _| {
|
||||
if let Some(x) = d.saved_selections.get_mut(i) {
|
||||
x.objects = ids;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
let st = s.doc_mut()?;
|
||||
st.recalled_selection.remember(&st.doc.saved_selections, slots);
|
||||
Ok(json!({ "name": name }))
|
||||
}
|
||||
|
||||
fn edit_saved(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "select.editSaved";
|
||||
let edits = match p.get("edits").and_then(Value::as_array) {
|
||||
@@ -396,6 +450,7 @@ fn edit_saved(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
x.name = n;
|
||||
}
|
||||
}
|
||||
let slots: Vec<u64> = saved_ids(s)?.into_iter().zip(&after).filter(|(_, x)| x.is_some()).map(|(id, _)| id).collect();
|
||||
let after: Vec<SavedSelection> = after.into_iter().flatten().collect();
|
||||
let mut names = std::collections::HashSet::with_capacity(after.len());
|
||||
for x in &after {
|
||||
@@ -408,6 +463,8 @@ fn edit_saved(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
d.saved_selections = after.into();
|
||||
Ok(())
|
||||
})?;
|
||||
let st = s.doc_mut()?;
|
||||
st.recalled_selection.remember(&st.doc.saved_selections, slots);
|
||||
}
|
||||
ok()
|
||||
}
|
||||
@@ -465,22 +522,77 @@ fn guide_paths(d: &vectorcraft_doc::Document) -> Vec<NodeId> {
|
||||
v
|
||||
}
|
||||
|
||||
fn release_guides(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
/// Turn guides into paths: ruler guide `index` and the guide paths in `ids`, else the selected
|
||||
/// guide paths and ruler guides, else every guide path, in one undo step.
|
||||
fn release_guides(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "view.guides.release";
|
||||
let (index, ids) = (p.get("index"), p.get("ids"));
|
||||
if (index.is_some() || ids.is_some()) && s.menu.guides_locked {
|
||||
return Err(EngineError::Other("guides are locked".into()));
|
||||
}
|
||||
let (rulers, paths): (Vec<usize>, Vec<NodeId>) = if index.is_some() || ids.is_some() {
|
||||
let rulers = match index {
|
||||
Some(v) => vec![editable_guide_index(s, Some(v), C)?],
|
||||
None => vec![],
|
||||
};
|
||||
let roots = if ids.is_some() { checked_ids_param(s, p, "ids", C)? } else { vec![] };
|
||||
let d = &s.doc()?.doc;
|
||||
let mut paths = vec![];
|
||||
for r in roots {
|
||||
if let Some(n) = d.node(r) {
|
||||
n.walk(&mut |c| {
|
||||
if matches!(c.kind, NodeKind::Path { guide: true, .. }) && d.is_editable(c.id) && !paths.contains(&c.id) {
|
||||
paths.push(c.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
(rulers, paths)
|
||||
} else {
|
||||
let st = s.doc()?;
|
||||
let all = guide_paths(&st.doc);
|
||||
let sel: Vec<NodeId> = all.iter().copied().filter(|id| st.selection.contains(*id)).collect();
|
||||
let rulers: Vec<usize> = if s.menu.guides_locked {
|
||||
vec![]
|
||||
} else {
|
||||
st.selection.guides.iter().copied().filter(|i| st.doc.guides.get(*i).is_some_and(|g| st.doc.guide_editable(g))).collect()
|
||||
};
|
||||
if sel.is_empty() && rulers.is_empty() { (vec![], all.into_iter().filter(|id| st.doc.is_editable(*id)).collect()) } else { (rulers, sel) }
|
||||
};
|
||||
// Find each ruler guide's line and its parent: the guide's layer, else where new art goes.
|
||||
let st = s.doc()?;
|
||||
let all = guide_paths(&st.doc);
|
||||
let sel: Vec<NodeId> = all.iter().copied().filter(|id| st.selection.contains(*id)).collect();
|
||||
let targets: Vec<NodeId> = if sel.is_empty() { all.into_iter().filter(|id| st.doc.is_editable(*id)).collect() } else { sel };
|
||||
if targets.is_empty() {
|
||||
let artboards = st.doc.artboards.iter().map(|a| a.rect).reduce(|a, b| a.union(b));
|
||||
let mut lines = Vec::with_capacity(rulers.len());
|
||||
for &i in &rulers {
|
||||
let Some(g) = st.doc.guides.get(i) else { continue };
|
||||
let span = st.doc.guide_span(g).or_else(|| artboards.map(|r| if g.vertical { (r.y0, r.y1) } else { (r.x0, r.x1) }));
|
||||
let Some((a, b)) = span else { continue };
|
||||
let (a, b) = if g.vertical { (Point::new(g.pos, a), Point::new(g.pos, b)) } else { (Point::new(a, g.pos), Point::new(b, g.pos)) };
|
||||
let parent = match g.layer.filter(|l| st.doc.node(*l).is_some_and(Node::is_layer)) {
|
||||
Some(l) => Some(l),
|
||||
None => st.target_parent()?,
|
||||
};
|
||||
lines.push((i, parent, a, b));
|
||||
}
|
||||
let n = paths.len() + lines.len();
|
||||
if n == 0 {
|
||||
return Err(EngineError::Other("Release Guides: no guides".into()));
|
||||
}
|
||||
let n = targets.len();
|
||||
s.edit("Release Guides", |d, sel| {
|
||||
for id in &targets {
|
||||
for id in &paths {
|
||||
if let Some(NodeKind::Path { guide, .. }) = d.node_mut(*id).map(|n| &mut n.kind) {
|
||||
*guide = false;
|
||||
}
|
||||
}
|
||||
sel.set(targets.iter().copied());
|
||||
let mut made = Vec::with_capacity(lines.len());
|
||||
for &(_, parent, a, b) in &lines {
|
||||
let id = d.alloc_id();
|
||||
let look = vectorcraft_doc::Appearance::basic(vectorcraft_color::Paint::None, vectorcraft_color::Paint::None, 0.0);
|
||||
d.insert(parent, usize::MAX, Node::path(id, vectorcraft_geom::shapes::line(a, b), look))?;
|
||||
made.push(id);
|
||||
}
|
||||
d.retain_guides(sel, |i, _| !lines.iter().any(|l| l.0 == i));
|
||||
sel.set(paths.iter().copied().chain(made));
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "count": n }))
|
||||
@@ -659,6 +771,17 @@ pub(crate) fn guide_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad("guide.move", "no guide selected (or give an index)"));
|
||||
}
|
||||
let (dx, dy, copy) = (f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0), bool_or(p, "copy", false));
|
||||
if !s.doc()?.selection.is_empty() {
|
||||
let ids = super::edit::selected_roots(s)?;
|
||||
return super::object::apply_transform(
|
||||
s,
|
||||
if copy { "Copy Guide" } else { "Move Guide" },
|
||||
ids,
|
||||
vectorcraft_geom::Affine::translate((dx, dy)),
|
||||
p,
|
||||
true,
|
||||
);
|
||||
}
|
||||
s.edit(if copy { "Copy Guide" } else { "Move Guide" }, |d, sel| {
|
||||
let mut copies = vec![];
|
||||
for &i in &moving {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Commands backing the drawing tools (pencil, curvature, anchor tools, scissors, knife, eraser,
|
||||
//! blob brush, smooth, path eraser, join).
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use kurbo::{CubicBez, ParamCurve, ParamCurveNearest};
|
||||
@@ -12,7 +13,8 @@ use vectorcraft_geom::{Anchor, AnchorKind, BezPath, FillRule, PathData, Point, R
|
||||
use vectorcraft_pathops as po;
|
||||
use vectorcraft_pathops::{BoolOp, Cap, Join, SimplifyOptions};
|
||||
|
||||
use super::create::{add_look, add_node};
|
||||
use super::create::{add_look, insert_look};
|
||||
use super::newart::NewArt;
|
||||
use super::pathops::{Beside, remove_each};
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
@@ -24,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, 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\"}} fit a freehand stroke → {id}",
|
||||
has_doc,
|
||||
freehand
|
||||
),
|
||||
@@ -42,7 +44,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Curvature",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath?, op: \"move\"|\"insert\"|\"extend\"|\"close\", anchor?, segment?, t?, end?: \"start\"|\"end\", from?: \"keep\"|\"smooth\"|\"corner\", x?, y?} edit a path as the Curvature tool does, its shape kept elsewhere; the anchor edited becomes the direct-selected one → {anchor}. move: anchor to x, y, re-curving only its two segments; insert: a smooth anchor at t on segment, the shape unchanged (with x, y: then moved there); extend: an open subpath goes on from its end (default) or start to a new end at x, y; close: an open subpath closes from that end. The old end bends into the new segment as from says: keep its curve (default), smooth through its neighbours, or a corner",
|
||||
"{id, subpath?, op: \"move\"|\"insert\"|\"extend\"|\"close\", anchor?, segment?, t?, end?: \"start\"|\"end\", from?: \"keep\"|\"smooth\"|\"corner\", corner?, x?, y?} edit a path as the Curvature tool does, its shape kept elsewhere; the anchor edited becomes the direct-selected one → {anchor}. move: anchor to x, y, re-curving only its two segments (a smooth anchor takes the Curvature tool's handles, along the circle through it and its neighbours); insert: a smooth anchor at t on segment, the shape unchanged (with x, y: then moved there); extend: an open subpath goes on from its end (default) or start to a new end at x, y, curving into it unless corner is true; close: an open subpath closes from that end. The old end bends into the new segment as from says: keep its curve (default), smooth through its neighbours, or a corner",
|
||||
has_doc,
|
||||
curvature_edit
|
||||
),
|
||||
@@ -60,7 +62,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Remove Anchor Points",
|
||||
["Object", "Path"],
|
||||
None,
|
||||
"{} remove the direct-selected anchors without opening their paths, refitting the curve round each → {removedObjects}",
|
||||
"{} remove the direct-selected anchors without opening their paths, refitting the curve round each; the anchor after each removed one is selected, so running it again goes on along the path → {removedObjects}",
|
||||
has_anchors,
|
||||
remove_anchors
|
||||
),
|
||||
@@ -69,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)",
|
||||
"{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
|
||||
),
|
||||
@@ -141,7 +143,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Blob Brush",
|
||||
[],
|
||||
None,
|
||||
"{points: [[x,y]…], size?: pt (10), merge?: true} filled brush shape, merged with touching same-colour blobs → {id}",
|
||||
"{points: [[x,y]…], size?: pt (10), merge?: true, mergeOnlySelection?: bool (false: merge with any touching same-color blob; true: only with selected ones), keepSelected?: bool (false: the drawn or merged path is not selected, and the rest of the selection stays; true: it is selected)} filled brush shape, merged with touching same-colour blobs → {id}",
|
||||
has_doc,
|
||||
blob
|
||||
),
|
||||
@@ -544,6 +546,7 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let tol = f64_or(p, "fidelity", 1.5).clamp(0.05, 100.0);
|
||||
let closed = bool_or(p, "closed", false);
|
||||
let brush = str_param(p, "style") == Some("brush");
|
||||
let keep_selected = bool_or(p, "keepSelected", true);
|
||||
if let Some(ext) = p.get("extend").filter(|v| !v.is_null()) {
|
||||
let id = id_param(ext, "id").ok_or_else(|| bad(C, "extend needs id"))?;
|
||||
let at_start = str_param(ext, "end") == Some("start");
|
||||
@@ -605,7 +608,11 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
{
|
||||
st.pressure = if reverse { joined.map(|p| p.reversed()) } else { joined };
|
||||
}
|
||||
sel.set([id]);
|
||||
if keep_selected {
|
||||
sel.set([id]);
|
||||
} else {
|
||||
sel.remove(id);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
return Ok(json!({ "id": id.0 }));
|
||||
@@ -622,7 +629,7 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(st) = look.appearance.stroke_mut() {
|
||||
st.pressure = pressure;
|
||||
}
|
||||
add_look(s, if brush { "Paintbrush" } else { "Pencil" }, path_kind(PathData::single(sp)), look, None)
|
||||
insert_look(s, if brush { "Paintbrush" } else { "Pencil" }, path_kind(PathData::single(sp)), look, None, keep_selected)
|
||||
}
|
||||
|
||||
// ---------- Curvature ----------
|
||||
@@ -643,7 +650,7 @@ fn curvature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if pts.is_empty() {
|
||||
return Err(bad(C, "need at least one point"));
|
||||
}
|
||||
let sp = vectorcraft_tools::draw2::catmull_rom(&pts, bool_or(p, "closed", false));
|
||||
let sp = vectorcraft_tools::draw2::curvature_spline(&pts, bool_or(p, "closed", false));
|
||||
if let Some(id) = id_param(p, "id") {
|
||||
s.edit("Curvature", |d, sel| {
|
||||
*path_mut(d, id)? = PathData::single(sp);
|
||||
@@ -688,6 +695,7 @@ fn curvature_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => EndCurve::Keep,
|
||||
Some(f) => EndCurve::parse(f).ok_or_else(|| bad(C, format!("unknown from {f:?}")))?,
|
||||
};
|
||||
let corner = bool_or(p, "corner", false);
|
||||
let op = match str_param(p, "op") {
|
||||
Some("move") => CurvatureOp::Move(usize_req(p, "anchor", C)?, to()?),
|
||||
Some("insert") => CurvatureOp::Insert(usize_req(p, "segment", C)?, f64_or(p, "t", 0.5), at),
|
||||
@@ -712,7 +720,7 @@ fn curvature_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ai
|
||||
}
|
||||
CurvatureOp::Extend(q) => {
|
||||
curvature_extend(sp, start, q, how);
|
||||
curvature_extend(sp, start, q, how, corner);
|
||||
if start { 0 } else { sp.anchors.len() - 1 }
|
||||
}
|
||||
CurvatureOp::Close => {
|
||||
@@ -766,7 +774,16 @@ fn remove_anchors(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let mut removed = 0;
|
||||
let mut emptied = Beside::replacing(d, &jobs.iter().map(|(id, _)| *id).collect::<Vec<_>>());
|
||||
for (id, anchors) in jobs {
|
||||
removed += usize::from(remove_anchors_of(d, sel, &mut emptied, id, anchors)?);
|
||||
let shape: Vec<(usize, bool)> =
|
||||
d.node(id).and_then(Node::path_data).map(|p| p.subpaths.iter().map(|sp| (sp.anchors.len(), sp.closed)).collect()).unwrap_or_default();
|
||||
let next = following_anchors(&shape, &anchors);
|
||||
if remove_anchors_of(d, sel, &mut emptied, id, anchors)? {
|
||||
removed += 1;
|
||||
} else if !next.is_empty() {
|
||||
// The next anchor along is selected, so the next Remove Anchor Points goes on from
|
||||
// there without picking it first (#2241).
|
||||
sel.anchors.insert(id, next);
|
||||
}
|
||||
}
|
||||
emptied.finish(d)?;
|
||||
Ok(removed)
|
||||
@@ -774,6 +791,30 @@ fn remove_anchors(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({ "removedObjects": removed }))
|
||||
}
|
||||
|
||||
/// Where the selection goes once Remove Anchor Points takes `removed` (subpath, anchor) out of a
|
||||
/// path whose subpaths have `shape` (anchor count, closed): in each subpath, the anchor after its
|
||||
/// last removed one, round the end of a closed subpath, or the one before it at an open subpath's
|
||||
/// end. Indexes are those after the removal, without the subpaths left with fewer than two anchors.
|
||||
fn following_anchors(shape: &[(usize, bool)], removed: &[(usize, usize)]) -> BTreeSet<(usize, usize)> {
|
||||
let mut next = BTreeSet::new();
|
||||
let mut kept = 0;
|
||||
for (si, &(n, closed)) in shape.iter().enumerate() {
|
||||
let gone: BTreeSet<usize> = removed.iter().filter(|&&(s, a)| s == si && a < n).map(|&(_, a)| a).collect();
|
||||
if n - gone.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
if let Some(&last) = gone.last() {
|
||||
let wrap = if closed { 0..last } else { 0..0 };
|
||||
let after = (last + 1..n).chain(wrap).find(|a| !gone.contains(a));
|
||||
if let Some(a) = after.or_else(|| (0..last).rev().find(|a| !gone.contains(a))) {
|
||||
next.insert((kept, a - gone.range(..a).count()));
|
||||
}
|
||||
}
|
||||
kept += 1;
|
||||
}
|
||||
next
|
||||
}
|
||||
|
||||
/// Remove `anchors` (subpath, anchor) of path `id` without opening it, refitting the curve round
|
||||
/// each one. A subpath left with one anchor is removed, and a path left with no subpath is removed
|
||||
/// through `emptied`. True when the path went.
|
||||
@@ -819,14 +860,19 @@ fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.edit("Convert Anchor Point", |d, _| {
|
||||
let path = path_mut(d, id)?;
|
||||
let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
|
||||
if smooth && target.is_none() {
|
||||
if ai >= sp.anchors.len() {
|
||||
return Err(EngineError::Other("no such anchor".into()));
|
||||
}
|
||||
// Without a handle position the anchor curves as the Curvature tool curves a point; one
|
||||
// with no direction to take stays as it is.
|
||||
vectorcraft_tools::draw2::curvature_smooth(sp, ai);
|
||||
return Ok(());
|
||||
}
|
||||
let a = sp.anchors.get_mut(ai).ok_or_else(|| EngineError::Other("no such anchor".into()))?;
|
||||
match target {
|
||||
_ if !smooth => a.retract(),
|
||||
Some(t) if t.distance(a.p) > 1e-9 => *a = Anchor::smooth(a.p, t),
|
||||
Some(_) => a.retract(),
|
||||
None => {
|
||||
sp.smooth_anchor(ai);
|
||||
}
|
||||
Some(t) if smooth && t.distance(a.p) > 1e-9 => *a = Anchor::smooth(a.p, t),
|
||||
_ => a.retract(),
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -1301,11 +1347,14 @@ fn blob(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} else {
|
||||
Paint::solid(Color::BLACK)
|
||||
};
|
||||
let keep_selected = bool_or(p, "keepSelected", false);
|
||||
let only_selection = bool_or(p, "mergeOnlySelection", false);
|
||||
let st = s.doc()?;
|
||||
let mut merge_ids = vec![];
|
||||
if bool_or(p, "merge", true) {
|
||||
if bool_or(p, "merge", true) && !(only_selection && st.selection.is_empty()) {
|
||||
let all = Selection::default();
|
||||
for id in target_paths(&st.doc, &all, poly_bounds(&pts, size)) {
|
||||
let from = if only_selection { &st.selection } else { &all };
|
||||
for id in target_paths(&st.doc, from, poly_bounds(&pts, size)) {
|
||||
let Some(n) = st.doc.node(id) else { continue };
|
||||
if n.appearance.fill_paint() != paint || !n.appearance.stroke_paint().is_none() {
|
||||
continue;
|
||||
@@ -1316,20 +1365,26 @@ fn blob(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let ap = Appearance::basic(paint, Paint::None, 1.0);
|
||||
let ap = NewArt::plain(Appearance::basic(paint, Paint::None, 1.0));
|
||||
if merge_ids.is_empty() {
|
||||
return add_node(s, "Blob Brush", path_kind(region), ap, None);
|
||||
return insert_look(s, "Blob Brush", path_kind(region), ap, None, keep_selected);
|
||||
}
|
||||
let mut inputs: Vec<(&PathData, FillRule)> = vec![(®ion, FillRule::NonZero)];
|
||||
inputs.extend(merge_ids.iter().map(|(_, pd, r)| (pd, *r)));
|
||||
let merged = po::unite_all(&inputs);
|
||||
let Some(((keep, _, _), rest)) = merge_ids.split_last() else { return add_node(s, "Blob Brush", path_kind(region), ap, None) };
|
||||
let Some(((keep, _, _), rest)) = merge_ids.split_last() else {
|
||||
return insert_look(s, "Blob Brush", path_kind(region), ap, None, keep_selected);
|
||||
};
|
||||
let keep = *keep;
|
||||
let others: Vec<NodeId> = rest.iter().map(|m| m.0).collect();
|
||||
s.edit("Blob Brush", |d, sel| {
|
||||
*path_mut(d, keep)? = merged;
|
||||
remove_each(d, &others)?;
|
||||
sel.set([keep]);
|
||||
if keep_selected {
|
||||
sel.set([keep]);
|
||||
} else {
|
||||
sel.remove(keep);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "id": keep.0 }))
|
||||
|
||||
@@ -137,7 +137,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Duplicate",
|
||||
["Edit"],
|
||||
None,
|
||||
"{dx?, dy?} duplicate the selection in place (offset optional)",
|
||||
"{ids?: [id…] (selected first; default: the selection), dx?, dy?} duplicate the selection in place (offset optional)",
|
||||
has_selection,
|
||||
duplicate
|
||||
),
|
||||
@@ -324,9 +324,22 @@ pub(crate) fn selected_roots(s: &Session) -> Result<Vec<NodeId>> {
|
||||
Ok(roots_of(&st.doc, st.selection.in_paint_order(&st.doc)))
|
||||
}
|
||||
|
||||
/// [`selected_roots`] for the commands that move or reshape the art (Move, Transform, Rotate…): in
|
||||
/// an isolated compound path its paths are objects of their own, so the hole of a ring moves
|
||||
/// without the ring. Paint and the rest still go to the compound path, which holds the paint.
|
||||
pub(crate) fn selected_roots_to_transform(s: &Session) -> Result<Vec<NodeId>> {
|
||||
let st = s.doc()?;
|
||||
Ok(roots_within(&st.doc, st.selection.in_paint_order(&st.doc), st.isolation))
|
||||
}
|
||||
|
||||
/// The top-level objects among `ids` (in paint order): compound-path members stand for their
|
||||
/// compound, and ids whose ancestor is also listed, and layers, are dropped.
|
||||
pub(crate) fn roots_of(doc: &Document, ids: Vec<NodeId>) -> Vec<NodeId> {
|
||||
roots_within(doc, ids, None)
|
||||
}
|
||||
|
||||
/// [`roots_of`], less the swap for the members of `isolation` when it is a compound path.
|
||||
fn roots_within(doc: &Document, ids: Vec<NodeId>, isolation: Option<NodeId>) -> Vec<NodeId> {
|
||||
// Every id's place in the tree, found in one walk (a lookup per id is a walk per id).
|
||||
let paths = doc.index_paths(ids.iter().copied());
|
||||
// A compound-path member acts as its compound (compounds are one object).
|
||||
@@ -336,23 +349,37 @@ pub(crate) fn roots_of(doc: &Document, ids: Vec<NodeId>) -> Vec<NodeId> {
|
||||
let path = paths.get(&id);
|
||||
let parent = path.and_then(|p| p.split_last()).map(|(_, up)| up).filter(|up| !up.is_empty());
|
||||
match parent.and_then(|up| doc.node_at(up).map(|n| (up, n))) {
|
||||
Some((up, n)) if matches!(n.kind, vectorcraft_doc::NodeKind::Compound { .. }) => (n.id, Some(up.to_vec())),
|
||||
Some((up, n)) if matches!(n.kind, vectorcraft_doc::NodeKind::Compound { .. }) && Some(n.id) != isolation => (n.id, Some(up.to_vec())),
|
||||
_ => (id, path.cloned()),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
sel.dedup_by_key(|(id, _)| *id);
|
||||
let listed: std::collections::HashSet<NodeId> = sel.iter().map(|(id, _)| *id).collect();
|
||||
sel.iter()
|
||||
.filter(|(_, path)| {
|
||||
let anc = path.as_deref().and_then(|p| doc.ancestry_at(p)).unwrap_or_default();
|
||||
!anc[..anc.len().saturating_sub(1)].iter().any(|a| listed.contains(a))
|
||||
})
|
||||
without_inner(doc, sel)
|
||||
.filter(|(_, path)| path.as_deref().and_then(|p| doc.node_at(p)).is_some_and(|n| !n.is_layer()))
|
||||
.map(|(id, _)| *id)
|
||||
.map(|(id, _)| id)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `ids` in the order given, less those whose ancestor is also listed: an object listed with a
|
||||
/// group that holds it is dropped, as [`roots_of`] drops it from a selection. Nothing else is
|
||||
/// dropped or replaced: unlike `roots_of`, it keeps layers, ids that aren't in the document and
|
||||
/// compound-path members.
|
||||
pub(crate) fn outermost(doc: &Document, ids: Vec<NodeId>) -> Vec<NodeId> {
|
||||
let paths = doc.index_paths(ids.iter().copied());
|
||||
let placed = ids.into_iter().map(|id| (id, paths.get(&id).cloned())).collect();
|
||||
without_inner(doc, placed).map(|(id, _)| id).collect()
|
||||
}
|
||||
|
||||
/// The entries of `placed` (ids with their index paths) whose id has no ancestor among its ids.
|
||||
fn without_inner(doc: &Document, placed: Vec<(NodeId, Option<Vec<usize>>)>) -> impl Iterator<Item = (NodeId, Option<Vec<usize>>)> + '_ {
|
||||
let listed: std::collections::HashSet<NodeId> = placed.iter().map(|(id, _)| *id).collect();
|
||||
placed.into_iter().filter(move |(_, path)| {
|
||||
let anc = path.as_deref().and_then(|p| doc.ancestry_at(p)).unwrap_or_default();
|
||||
!anc[..anc.len().saturating_sub(1)].iter().any(|a| listed.contains(a))
|
||||
})
|
||||
}
|
||||
|
||||
fn copy(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
// The Artboard tool copies its artboard (and the art on it).
|
||||
if s.tool_id() == "artboard" {
|
||||
|
||||
@@ -50,7 +50,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Effect Options",
|
||||
[],
|
||||
None,
|
||||
"{index: int, params?: {…} (merged into the current parameters), visible?: bool, item?: appearance item index|null (as effect.remove), ids?|id?: [..], target?: \"object\"|\"contents\"} → {ids}",
|
||||
"{index: int, params?: {…} (merged into the current parameters), warpStyle?: string (an existing Warp style suffix, e.g. arc or flag; changes only an applied Warp in place), visible?: bool, item?: appearance item index|null (as effect.remove), ids?|id?: [..], target?: \"object\"|\"contents\"} → {ids}",
|
||||
has_doc,
|
||||
set_params
|
||||
),
|
||||
@@ -142,6 +142,9 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if id == effects::CROP_MARKS && params.get("style").is_none() {
|
||||
params["style"] = json!(s.crop_mark_style().id());
|
||||
}
|
||||
if id == GLASS {
|
||||
load_texture(&mut params, C)?;
|
||||
}
|
||||
let effect = effects::new_effect(id, ¶ms).ok_or_else(|| bad(C, format!("unknown effect `{id}`")))?;
|
||||
let label = effects::effect_info(id).map(|e| e.label.trim_end_matches('…').to_string()).unwrap_or_default();
|
||||
if let Some(loose) = loose_for_pathfinder(s, p, id)? {
|
||||
@@ -297,16 +300,30 @@ pub(crate) fn set_params_effects(s: &Session, p: &Value) -> Vec<String> {
|
||||
fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "effect.setParams";
|
||||
let index = index_param(p, "index", C)?;
|
||||
let params = p.get("params").cloned().unwrap_or(Value::Null);
|
||||
let mut params = p.get("params").cloned().unwrap_or(Value::Null);
|
||||
if !params.is_null() && !params.is_object() {
|
||||
return Err(bad(C, "`params` must be an object"));
|
||||
}
|
||||
let warp_id = p
|
||||
.get("warpStyle")
|
||||
.map(|v| -> Result<String> {
|
||||
let style = v.as_str().filter(|style| effects::WarpStyle::from_id(style).is_some()).ok_or_else(|| bad(C, "unknown Warp style"))?;
|
||||
Ok(format!("warp.{style}"))
|
||||
})
|
||||
.transpose()?;
|
||||
let visible = p.get("visible").and_then(Value::as_bool);
|
||||
let item = item_target(s, p, C)?;
|
||||
if set_params_effects(s, p).iter().any(|e| e == GLASS) {
|
||||
load_texture(&mut params, C)?;
|
||||
}
|
||||
for id in appearance_targets(s, p)? {
|
||||
let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?;
|
||||
let target = item.effects_item(&n.appearance, C)?;
|
||||
if let Some(e) = n.appearance.effects_at(target).and_then(|fx| fx.get(index))
|
||||
let effect = n.appearance.effects_at(target).and_then(|fx| fx.get(index));
|
||||
if warp_id.is_some() && effect.is_some_and(|e| e.id.strip_prefix("warp.").and_then(effects::WarpStyle::from_id).is_none()) {
|
||||
return Err(bad(C, "warpStyle can only edit an applied Warp effect"));
|
||||
}
|
||||
if let Some(e) = effect
|
||||
&& effects::is_three_d(&e.id)
|
||||
&& visible != Some(false)
|
||||
{
|
||||
@@ -323,6 +340,9 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let ids = edit_effects(s, p, item, C, "Effect Options", &format!("{C}: no effect at index {index}"), |fx| {
|
||||
let Some(e) = fx.get_mut(index) else { return false };
|
||||
if let Some(id) = &warp_id {
|
||||
e.id.clone_from(id);
|
||||
}
|
||||
if let (Value::Object(new), cur) = (effects::upgrade_params(&e.id, ¶ms), &mut e.params) {
|
||||
if !cur.is_object() {
|
||||
*cur = effects::merged_params(&e.id, &Value::Null);
|
||||
@@ -342,6 +362,31 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "ids": ids_json(&ids) }))
|
||||
}
|
||||
|
||||
// ---------- Load Texture ----------
|
||||
|
||||
/// Effect › Distort › Glass.
|
||||
const GLASS: &str = "distort.glass";
|
||||
|
||||
/// Glass's `textureImage` in `params` as Glass stores it: an image file (`{path}`, or `{name?,
|
||||
/// dataBase64}`) is read with [`fileio::texture_image`] and picked (`texture: "loaded"`) unless
|
||||
/// `params` give a `texture`; a string must be a texture as Glass stores it.
|
||||
fn load_texture(params: &mut Value, cmd: &str) -> Result<()> {
|
||||
let pick = params.get("texture").is_none();
|
||||
let Some(v) = params.get_mut(effects::pixel::TEXTURE_IMAGE) else { return Ok(()) };
|
||||
match v {
|
||||
Value::String(s) if effects::pixel::texture(s).is_some() => {}
|
||||
Value::Object(_) => {
|
||||
let bytes = fileio::source(v, cmd)?.bytes;
|
||||
*v = json!(fileio::texture_image(&bytes).map_err(|e| bad(cmd, format!("textureImage: {e}")))?);
|
||||
if pick {
|
||||
params["texture"] = json!(effects::pixel::LOADED);
|
||||
}
|
||||
}
|
||||
_ => return Err(bad(cmd, "textureImage: give {path} or {name, dataBase64} of an image file, or a texture as effect.list gives it")),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------- Expand Appearance ----------
|
||||
|
||||
/// Has `n` an appearance Expand Appearance turns into objects: a path that isn't basic (several
|
||||
@@ -407,7 +452,7 @@ fn expand_pieces(d: &mut Document, m: &mut Node, all: bool, stroke_art: effects:
|
||||
/// Expand Appearance of `id` (and, recursively, of a group's or layer's members): every fill and
|
||||
/// stroke becomes an object of its own (strokes outlined, brushes as their art), geometry effects
|
||||
/// are baked, raster effects become an embedded image, and the object keeps its transparency.
|
||||
fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_brush::Brush], out: &mut Vec<NodeId>) {
|
||||
pub(crate) fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_brush::Brush], out: &mut Vec<NodeId>) {
|
||||
let Some(mut n) = d.node(id).cloned() else { return };
|
||||
if !expandable(&n) {
|
||||
return;
|
||||
@@ -647,10 +692,98 @@ mod tests {
|
||||
assert_eq!(params(&s), json!({"lightRotation": 0.0, "lightHeight": 60, "lightBehind": false}));
|
||||
}
|
||||
|
||||
/// Scripts from before Scribble's separate variations give one `variation`, which is stored as the
|
||||
/// Path Overlap and Spacing variations. An angle is stored above -180° and up to 180°.
|
||||
#[test]
|
||||
fn scribble_stores_an_earlier_variation_as_two_and_its_angle_within_180() {
|
||||
let (mut s, id) = session_with_rect();
|
||||
let p = json!({"effect": "stylize.scribble", "ids": [id.0], "item": null, "params": {"angle": 270, "variation": 2}});
|
||||
s.execute("effect.apply", &p).unwrap();
|
||||
let params = |s: &Session| node(s, id).appearance.effects[0].params.clone();
|
||||
assert_eq!((params(&s)["angle"].as_f64(), params(&s).get("variation")), (Some(-90.0), None), "{}", params(&s));
|
||||
assert_eq!((¶ms(&s)["overlapVariation"], ¶ms(&s)["spacingVariation"]), (&json!(2), &json!(2)));
|
||||
s.execute("effect.setParams", &json!({"index": 0, "params": {"angle": -200}, "ids": [id.0], "item": null})).unwrap();
|
||||
assert_eq!(params(&s)["angle"].as_f64(), Some(160.0));
|
||||
// A file of then stores its variation; setting one of the two keeps it for the other.
|
||||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().appearance.effects[0].params = json!({"variation": 3});
|
||||
s.execute("effect.setParams", &json!({"index": 0, "params": {"spacingVariation": 1}, "ids": [id.0], "item": null})).unwrap();
|
||||
assert_eq!(params(&s), json!({"overlapVariation": 3, "spacingVariation": 1}));
|
||||
}
|
||||
|
||||
fn effects_for_test(s: &Session, id: NodeId) -> vectorcraft_doc::Node {
|
||||
effects::revolve_art(&node(s, id)).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smooth_revolve_commands_validate_save_expand_and_undo() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width":300,"height":300})).unwrap();
|
||||
let r = s.execute("path.create", &json!({"d":"M150 40 C190 60 190 140 150 160"})).unwrap();
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
s.execute("effect.apply", &json!({"effect":"threeD.revolve","ids":[id.0],"item":null,"params":{"offset":20,"segments":16}})).unwrap();
|
||||
let initial = node(&s, id);
|
||||
let source = initial.path_data().unwrap().clone();
|
||||
assert!(!initial.appearance.effects[0].params.as_object().unwrap().contains_key("smoothShading"));
|
||||
assert!(initial.appearance.effects[0].params["cap"].as_bool().unwrap());
|
||||
let mut gradients = 0;
|
||||
effects_for_test(&s, id).walk(&mut |face| {
|
||||
gradients += face.appearance.items.iter().filter(|item| {
|
||||
matches!(item, vectorcraft_doc::AppearanceItem::Fill(fill) if matches!(fill.paint, vectorcraft_doc::color::Paint::Gradient(_)))
|
||||
}).count();
|
||||
});
|
||||
assert!(gradients > 0, "commands use smooth vector shading by default");
|
||||
s.execute("effect.setParams", &json!({"index":0,"ids":[id.0],"item":null,"params":{"lightAzimuth":30}})).unwrap();
|
||||
let smooth = node(&s, id);
|
||||
assert_eq!(smooth.path_data(), Some(&source));
|
||||
assert_ne!(effects_for_test(&s, id), effects::revolve_art(&initial).unwrap());
|
||||
let restored = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||||
assert_eq!(effects::revolve_art(restored.node(id).unwrap()), Some(effects_for_test(&s, id)));
|
||||
s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap();
|
||||
assert!(!effects::has_revolve(&node(&s, id)));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(node(&s, id), smooth);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(node(&s, id), initial);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revolve_caps_save_export_expand_and_undo_without_changing_the_source() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width":300,"height":300})).unwrap();
|
||||
let r = s.execute("path.create", &json!({"d":"M150 40 L150 160"})).unwrap();
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
let source = node(&s, id).path_data().unwrap().clone();
|
||||
s.execute(
|
||||
"effect.apply",
|
||||
&json!({"effect":"threeD.revolve","ids":[id.0],"item":null,
|
||||
"params":{"offset":30,"rotationX":-35,"segments":16,"expandVisibleOnly":false,"cap":false}}),
|
||||
)
|
||||
.unwrap();
|
||||
let uncapped = node(&s, id);
|
||||
assert_eq!(uncapped.appearance.effects[0].params["cap"], json!(false));
|
||||
let count = |n: &vectorcraft_doc::Node| n.children().unwrap()[0].children().unwrap().len();
|
||||
let open_count = count(&effects_for_test(&s, id));
|
||||
s.execute("effect.setParams", &json!({"index":0,"ids":[id.0],"item":null,"params":{"cap":true}})).unwrap();
|
||||
let capped = node(&s, id);
|
||||
let art = effects_for_test(&s, id);
|
||||
assert_eq!(count(&art), open_count + 2, "each endpoint adds one continuous vector cap");
|
||||
assert_eq!(capped.path_data(), Some(&source));
|
||||
assert!(s.execute("effect.setParams", &json!({"index":0,"ids":[id.0],"item":null,"params":{"cap":"yes"}})).is_err());
|
||||
assert_eq!(node(&s, id), capped, "a bad boolean does not mutate the document");
|
||||
let restored = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||||
assert_eq!(restored.node(id).unwrap().appearance.effects[0].params["cap"], json!(true));
|
||||
assert_eq!(effects::revolve_art(restored.node(id).unwrap()), Some(art.clone()));
|
||||
let pdf = s.execute("document.serialize", &json!({"format":"pdf"})).unwrap();
|
||||
assert!(vectorcraft_format::base64_decode(pdf["dataBase64"].as_str().unwrap()).unwrap().starts_with(b"%PDF"));
|
||||
s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap();
|
||||
assert_eq!(count(&node(&s, id)), count(&art));
|
||||
assert!(!effects::has_revolve(&node(&s, id)));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(node(&s, id), capped);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(node(&s, id), uncapped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revolve_visibility_parameter_saves_expands_and_undoes_without_other_appearance_changes() {
|
||||
let mut s = Session::new();
|
||||
@@ -695,6 +828,56 @@ mod tests {
|
||||
assert!(s.execute("effect.apply", &json!({"effect": "nope"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warp_options_style_change_preserves_slot_and_visibility() {
|
||||
let (mut s, id) = session_with_rect();
|
||||
for effect in ["path.offsetPath", "warp.arc", "stylize.roundCorners"] {
|
||||
s.execute("effect.apply", &json!({"effect": effect, "item": 1})).unwrap();
|
||||
}
|
||||
s.execute("effect.setParams", &json!({"index": 1, "item": 1, "visible": false})).unwrap();
|
||||
s.doc_mut().unwrap().mark_saved();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let result = s.execute("effect.setParams", &json!({"index": 1, "item": 1, "warpStyle": "flag", "params": {"bend": 25.0}}));
|
||||
assert!(result.is_ok(), "an applied warp can change style in place: {result:?}");
|
||||
let old = before.node(id).unwrap().appearance.effects_at(Some(1)).unwrap();
|
||||
let n = node(&s, id);
|
||||
let new = n.appearance.effects_at(Some(1)).unwrap();
|
||||
assert_eq!(new.len(), 3);
|
||||
assert_eq!(new[0], old[0]);
|
||||
assert_eq!(new[2], old[2]);
|
||||
assert_eq!(new[1].id, "warp.flag");
|
||||
assert_eq!(new[1].params["bend"], 25.0);
|
||||
assert!(!new[1].visible);
|
||||
assert!(n.appearance.effects.is_empty());
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||||
let after = s.doc().unwrap().doc.clone();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc, before);
|
||||
assert!(!s.doc().unwrap().is_dirty());
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc, after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warp_options_non_warp_target_rejection_is_atomic() {
|
||||
let (mut s, first) = session_with_rect();
|
||||
s.execute("effect.apply", &json!({"effect": "warp.arc", "item": null})).unwrap();
|
||||
let second = s.execute("shape.rectangle", &json!({"x": 250, "y": 100, "width": 100, "height": 100})).unwrap()["id"].clone();
|
||||
s.execute("effect.apply", &json!({"effect": "distort.twist", "item": null})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [first.0, second]})).unwrap();
|
||||
s.doc_mut().unwrap().mark_saved();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let selection = s.doc().unwrap().selection.clone();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
assert!(s.execute("effect.setParams", &json!({"index": 0, "item": null, "warpStyle": "flag"})).is_err());
|
||||
let state = s.doc().unwrap();
|
||||
assert_eq!(state.doc, before);
|
||||
assert_eq!(state.selection, selection);
|
||||
assert_eq!(state.history.undo.len(), undo);
|
||||
assert!(!state.is_dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_remove_and_set_params() {
|
||||
let (mut s, id) = session_with_rect();
|
||||
@@ -770,6 +953,29 @@ mod tests {
|
||||
assert_eq!(node(&s, a).appearance.effects.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pathfinder_effect_on_a_group_with_type_cuts_the_glyphs_out() {
|
||||
let (mut s, a) = session_with_rect();
|
||||
let t = NodeId(s.execute("text.create", &json!({"x": 110, "y": 180, "text": "H", "size": 100})).unwrap()["id"].as_u64().unwrap());
|
||||
let glyphs = effects::outline_text(&node(&s, t)).unwrap();
|
||||
let b = glyphs.geometric_bounds().unwrap();
|
||||
assert!(b.x0 > 105.0 && b.y0 > 105.0 && b.x1 < 195.0 && b.y1 < 195.0, "the type lies inside the square: {b:?}");
|
||||
let area = |n: &vectorcraft_doc::Node| effects::member_shapes(n).iter().map(|(p, r)| vectorcraft_pathops::area(p, *r)).sum::<f64>();
|
||||
let glyph_area = area(&glyphs);
|
||||
s.execute("select.set", &json!({"ids": [a.0, t.0]})).unwrap();
|
||||
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("effect.apply", &json!({"effect": "pathfinder.subtract"})).unwrap();
|
||||
// Live: the renderer leaves the glyph's place in the square empty.
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let img = vectorcraft_render::Renderer::new().render(&doc, 400, 400, vectorcraft_geom::Affine::IDENTITY, &Default::default());
|
||||
let empty = (105..195).flat_map(|y| (105..195).map(move |x| (x, y))).filter(|&(x, y)| img.pixel(x, y)[3] == 0).count();
|
||||
assert!(empty as f64 > glyph_area / 2.0, "{empty} empty pixels for a glyph area of {glyph_area}");
|
||||
// Expanded: the group holds the square minus the glyph.
|
||||
s.execute("effect.expandAppearance", &json!({})).unwrap();
|
||||
let left = area(&node(&s, g));
|
||||
assert!((left - (10_000.0 - glyph_area)).abs() < 0.5, "{left} left of the square for a glyph area of {glyph_area}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pathfinder_effect_on_a_group_renders_live_and_expands() {
|
||||
let (mut s, a) = session_with_rect();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! File → File Info (`file.info`): the document title and its descriptive metadata (author,
|
||||
//! description, keywords, rating, copyright), with the read-only created and modified dates. The
|
||||
//! SVG (with `metadata`), PDF and PNG writers carry it.
|
||||
//! SVG (with `metadata`), PDF and PNG writers carry it, and a PDF or `.ai` opens with the File Info
|
||||
//! it gives ([`read_pdf`]).
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_doc::metadata::{MAX_RATING, iso8601};
|
||||
@@ -62,6 +63,30 @@ pub(crate) fn file_info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(info_json(&s.doc()?.doc))
|
||||
}
|
||||
|
||||
/// The File Info a PDF or PDF-compatible `.ai` gives ([`vectorcraft_pdf::file_info`]) into `m`: each
|
||||
/// field that `file.info` would take (a value it refuses is left out), and the created and
|
||||
/// modified dates.
|
||||
pub(crate) fn read_pdf(m: &mut DocMetadata, f: vectorcraft_pdf::FileInfo) {
|
||||
let status = f.copyrighted.map(|c| if c { CopyrightStatus::Copyrighted } else { CopyrightStatus::PublicDomain });
|
||||
let fields = [
|
||||
("author", f.author.map(Value::from)),
|
||||
("description", f.description.map(Value::from)),
|
||||
("keywords", f.keywords.map(Value::from)),
|
||||
("copyrightStatus", status.map(|s| Value::from(s.id()))),
|
||||
("copyrightNotice", f.copyright_notice.map(Value::from)),
|
||||
("copyrightUrl", f.copyright_url.map(Value::from)),
|
||||
];
|
||||
// `apply` changes the field only when it takes the value.
|
||||
let mut title = String::new();
|
||||
for (k, v) in fields {
|
||||
if let Some(v) = v {
|
||||
let _ = apply(&mut title, m, k, &v);
|
||||
}
|
||||
}
|
||||
m.created = f.created.or(m.created);
|
||||
m.modified = f.modified.or(m.modified);
|
||||
}
|
||||
|
||||
fn apply(title: &mut String, m: &mut DocMetadata, k: &str, v: &Value) -> Result<()> {
|
||||
match k {
|
||||
"title" => {
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
//! `action.batch`: play an action's commands on every file in a folder, or on every data set
|
||||
//! of the open document (the Actions panel menu's Batch…).
|
||||
//!
|
||||
//! Public options follow the Batch dialog: source Folder or Data Sets; destination None,
|
||||
//! Save and Close, or Folder; Override Open / Save / Export. Cited:
|
||||
//! <https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/batch-options.html>
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::*;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(super) fn action_batch(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
Err(bad("action.batch", "no file system here"))
|
||||
}
|
||||
|
||||
/// Caps on a batch (folders and MCP payloads are untrusted).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_BATCH_FILES: usize = 500;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_BATCH_DEPTH: usize = 8;
|
||||
|
||||
/// Shared Batch options for one run.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
struct Run<'a> {
|
||||
label: &'a str,
|
||||
steps: &'a [Value],
|
||||
stop_on_error: bool,
|
||||
override_save: bool,
|
||||
override_export: bool,
|
||||
destination: &'a str,
|
||||
dest_folder: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// Play `commands` on each source file or data set.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn action_batch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use super::{absolute_path, may_write};
|
||||
|
||||
const C: &str = "action.batch";
|
||||
let cmds = p.get("commands").and_then(Value::as_array).ok_or_else(|| bad(C, "missing `commands`"))?;
|
||||
if cmds.is_empty() {
|
||||
return Err(bad(C, "`commands` must not be empty"));
|
||||
}
|
||||
if cmds.len() > 256 {
|
||||
return Err(bad(C, "more than 256 commands"));
|
||||
}
|
||||
for (i, c) in cmds.iter().enumerate() {
|
||||
let id = c.get("command").and_then(Value::as_str).unwrap_or("");
|
||||
if id.is_empty() {
|
||||
return Err(bad(C, format!("commands[{i}]: missing `command`")));
|
||||
}
|
||||
if id == "command.batch" || id == "action.batch" {
|
||||
return Err(bad(C, "batches cannot nest"));
|
||||
}
|
||||
if find_command(id).is_none() {
|
||||
return Err(bad(C, format!("commands[{i}]: unknown command `{id}`")));
|
||||
}
|
||||
}
|
||||
let source = str_param(p, "source").unwrap_or("folder");
|
||||
let destination = str_param(p, "destination").unwrap_or("none");
|
||||
if !matches!(destination, "none" | "saveAndClose" | "folder") {
|
||||
return Err(bad(C, "`destination` must be `none`, `saveAndClose` or `folder`"));
|
||||
}
|
||||
let dest_folder = str_param(p, "destinationFolder").map(absolute_path);
|
||||
if destination == "folder" {
|
||||
let Some(ref folder) = dest_folder else {
|
||||
return Err(bad(C, "`destinationFolder` is required when destination is `folder`"));
|
||||
};
|
||||
may_write(folder)?;
|
||||
std::fs::create_dir_all(folder).map_err(|e| bad(C, format!("{folder}: {e}")))?;
|
||||
}
|
||||
let label = str_param(p, "label").unwrap_or("Batch").to_string();
|
||||
let override_open = bool_or(p, "overrideOpen", true);
|
||||
let run = Run {
|
||||
label: &label,
|
||||
steps: cmds,
|
||||
stop_on_error: bool_or(p, "stopOnError", true),
|
||||
override_save: bool_or(p, "overrideSave", true),
|
||||
override_export: bool_or(p, "overrideExport", true),
|
||||
destination,
|
||||
dest_folder: dest_folder.as_deref(),
|
||||
};
|
||||
match source {
|
||||
"folder" => batch_folder(s, C, p, override_open, &run),
|
||||
"datasets" => batch_datasets(s, C, &run),
|
||||
_ => Err(bad(C, "`source` must be `folder` or `datasets`")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run: &Run<'_>) -> Result<Value> {
|
||||
use super::{absolute_path, may_read};
|
||||
|
||||
let folder = str_param(p, "folder").map(absolute_path).ok_or_else(|| bad(cmd, "missing `folder`"))?;
|
||||
may_read(&folder)?;
|
||||
let include_subdirs = bool_or(p, "includeSubdirs", false);
|
||||
let files = collect_files(&folder, include_subdirs, 0)?;
|
||||
if files.len() > MAX_BATCH_FILES {
|
||||
return Err(bad(cmd, format!("more than {MAX_BATCH_FILES} files")));
|
||||
}
|
||||
if files.is_empty() {
|
||||
return Err(bad(cmd, "no readable files in the folder"));
|
||||
}
|
||||
let steps = filter_steps(run.steps, override_open, false);
|
||||
let run = Run { steps: &steps, ..*run };
|
||||
let mut processed = Vec::new();
|
||||
let mut failed = 0usize;
|
||||
for path in &files {
|
||||
let stem = std::path::Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or("file");
|
||||
let ext = super::extension(path);
|
||||
match process_one_file(s, &run, path, stem, &ext) {
|
||||
Ok(row) => processed.push(row),
|
||||
Err(e) => {
|
||||
failed += 1;
|
||||
processed.push(json!({"path": path, "error": e.to_string()}));
|
||||
if run.stop_on_error {
|
||||
return Ok(json!({"processed": processed, "count": processed.len(), "failed": failed, "stopped": true}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({"processed": processed, "count": processed.len(), "failed": failed, "stopped": false}))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn batch_datasets(s: &mut Session, cmd: &str, run: &Run<'_>) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let names: Vec<String> = st.doc.variables.datasets.iter().map(|d| d.name.clone()).collect();
|
||||
if names.is_empty() {
|
||||
return Err(bad(cmd, "no data sets"));
|
||||
}
|
||||
if names.len() > MAX_BATCH_FILES {
|
||||
return Err(bad(cmd, format!("more than {MAX_BATCH_FILES} data sets")));
|
||||
}
|
||||
let doc_stem = st.path.as_deref().and_then(|p| std::path::Path::new(p).file_stem().and_then(|s| s.to_str())).unwrap_or("Untitled").to_string();
|
||||
let steps = filter_steps(run.steps, true, false);
|
||||
let run = Run { steps: &steps, ..*run };
|
||||
let mut processed = Vec::new();
|
||||
let mut failed = 0usize;
|
||||
for name in &names {
|
||||
match process_one_dataset(s, &run, name, &doc_stem) {
|
||||
Ok(row) => processed.push(row),
|
||||
Err(e) => {
|
||||
failed += 1;
|
||||
processed.push(json!({"dataset": name, "error": e.to_string()}));
|
||||
if run.stop_on_error {
|
||||
return Ok(json!({"processed": processed, "count": processed.len(), "failed": failed, "stopped": true}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({"processed": processed, "count": processed.len(), "failed": failed, "stopped": false}))
|
||||
}
|
||||
|
||||
/// Drop Open (and optionally Close) steps when the batch supplies the file itself.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn filter_steps(cmds: &[Value], drop_open: bool, drop_close: bool) -> Vec<Value> {
|
||||
cmds.iter()
|
||||
.filter(|c| {
|
||||
let id = c.get("command").and_then(Value::as_str).unwrap_or("");
|
||||
if drop_open && matches!(id, "document.open" | "file.open") {
|
||||
return false;
|
||||
}
|
||||
if drop_close && matches!(id, "file.close" | "document.close") {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn process_one_file(s: &mut Session, run: &Run<'_>, path: &str, stem: &str, ext: &str) -> Result<Value> {
|
||||
s.execute("document.open", &json!({"path": path}))?;
|
||||
let rewritten = rewrite_paths(run.steps, run.override_save, run.override_export, run.dest_folder, stem, ext);
|
||||
let batch = s.execute("command.batch", &json!({"label": run.label, "commands": rewritten}))?;
|
||||
// Folder sources open a file per pass: close after Save and Close / Folder.
|
||||
finish_destination(s, run, stem, ext, path, true)?;
|
||||
Ok(json!({"path": path, "result": batch}))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn process_one_dataset(s: &mut Session, run: &Run<'_>, name: &str, doc_stem: &str) -> Result<Value> {
|
||||
s.execute("dataset.select", &json!({"name": name}))?;
|
||||
// File + Data Set Name naming when overriding Save/Export (helpx).
|
||||
let stem = format!("{doc_stem}_{name}");
|
||||
let rewritten = rewrite_paths(run.steps, run.override_save, run.override_export, run.dest_folder, &stem, "vectorcraft");
|
||||
let batch = s.execute("command.batch", &json!({"label": run.label, "commands": rewritten}))?;
|
||||
// Data sets share one open document: write each row out but leave it open for the next.
|
||||
finish_destination(s, run, &stem, "vectorcraft", "", false)?;
|
||||
Ok(json!({"dataset": name, "result": batch}))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn finish_destination(s: &mut Session, run: &Run<'_>, stem: &str, ext: &str, source_path: &str, close: bool) -> Result<()> {
|
||||
match run.destination {
|
||||
"none" => Ok(()),
|
||||
"saveAndClose" => {
|
||||
if !source_path.is_empty() {
|
||||
s.execute("document.save", &json!({"path": source_path}))?;
|
||||
} else {
|
||||
s.execute("document.save", &json!({}))?;
|
||||
}
|
||||
if close {
|
||||
s.execute("file.close", &json!({}))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
"folder" => {
|
||||
let folder = run.dest_folder.ok_or_else(|| bad("action.batch", "missing destination folder"))?;
|
||||
let out_ext = if ext.is_empty() { "vectorcraft" } else { ext };
|
||||
let out = std::path::Path::new(folder).join(format!("{stem}.{out_ext}"));
|
||||
let out = out.to_string_lossy().into_owned();
|
||||
// When `close` is false (data sets), write a copy so the template keeps its path.
|
||||
write_result(s, &out, out_ext, !close)?;
|
||||
if close {
|
||||
s.execute("file.close", &json!({}))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Save when the extension is a Save format; otherwise export (PNG, JPEG…).
|
||||
/// `as_copy`: `file.saveCopy` so the open document keeps its path (data-set batches).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn write_result(s: &mut Session, path: &str, ext: &str, as_copy: bool) -> Result<()> {
|
||||
use super::SAVE_FORMATS;
|
||||
let id = if ext == "drawcraft" { "vectorcraft" } else { ext };
|
||||
if SAVE_FORMATS.contains(&id) {
|
||||
let cmd = if as_copy { "file.saveCopy" } else { "document.save" };
|
||||
s.execute(cmd, &json!({"path": path}))?;
|
||||
} else {
|
||||
s.execute("document.export", &json!({"path": path, "format": id}))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Point Save / Export steps at `dest_folder/{stem}.{ext}` when overrides are on.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn rewrite_paths(
|
||||
steps: &[Value],
|
||||
override_save: bool,
|
||||
override_export: bool,
|
||||
dest_folder: Option<&str>,
|
||||
stem: &str,
|
||||
default_ext: &str,
|
||||
) -> Vec<Value> {
|
||||
let Some(folder) = dest_folder else {
|
||||
return steps.to_vec();
|
||||
};
|
||||
steps
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let id = c.get("command").and_then(Value::as_str).unwrap_or("");
|
||||
let mut c = c.clone();
|
||||
let rewrite = (override_save && matches!(id, "document.save" | "file.save" | "file.saveAs"))
|
||||
|| (override_export && matches!(id, "document.export" | "document.exportForScreens" | "document.exportSelection"));
|
||||
if !rewrite {
|
||||
return c;
|
||||
}
|
||||
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 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() {
|
||||
o.insert("params".into(), Value::Object(params));
|
||||
}
|
||||
c
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 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>> {
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::list_dir;
|
||||
|
||||
if depth > MAX_BATCH_DEPTH {
|
||||
return Err(bad("action.batch", format!("folders deeper than {MAX_BATCH_DEPTH} levels")));
|
||||
}
|
||||
let (entries, _) = list_dir(folder, MAX_BATCH_FILES.saturating_mul(2))?;
|
||||
let mut files = Vec::new();
|
||||
let mut subdirs = Vec::new();
|
||||
for e in entries {
|
||||
let name = e.get("name").and_then(Value::as_str).unwrap_or("");
|
||||
if name.is_empty() || name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
let kind = e.get("kind").and_then(Value::as_str).unwrap_or("");
|
||||
let path = PathBuf::from(folder).join(name);
|
||||
let path = path.to_string_lossy().into_owned();
|
||||
match kind {
|
||||
"file" if is_openable(&path) => files.push(path),
|
||||
"folder" if include_subdirs => subdirs.push(path),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
files.sort();
|
||||
for sub in subdirs {
|
||||
let mut nested = collect_files(&sub, true, depth + 1)?;
|
||||
files.append(&mut nested);
|
||||
if files.len() > MAX_BATCH_FILES {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn is_openable(path: &str) -> bool {
|
||||
use super::{FORMATS, OPEN_EXTS, extension};
|
||||
let ext = extension(path);
|
||||
if ext.is_empty() {
|
||||
return false;
|
||||
}
|
||||
OPEN_EXTS.iter().any(|e| *e == ext) || FORMATS.iter().any(|f| f.read && f.extensions.iter().any(|e| *e == ext))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn tmp(tag: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("vc-action-batch-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn write_svg(dir: &std::path::Path, name: &str, fill: &str) -> std::path::PathBuf {
|
||||
let p = dir.join(name);
|
||||
std::fs::write(
|
||||
&p,
|
||||
format!(
|
||||
r#"<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg" width="100" height="80">
|
||||
<rect x="10" y="10" width="40" height="30" fill="{fill}"/></svg>"#
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_folder_runs_action_and_saves_to_destination() {
|
||||
let src = tmp("src");
|
||||
let dest = tmp("dest");
|
||||
write_svg(&src, "a.svg", "#f00");
|
||||
write_svg(&src, "b.svg", "#0f0");
|
||||
let mut s = Session::new();
|
||||
// Need a document so command.batch's has_doc passes after the first open leaves one.
|
||||
s.execute("file.new", &json!({"width": 100, "height": 80})).unwrap();
|
||||
let r = s
|
||||
.execute(
|
||||
"action.batch",
|
||||
&json!({
|
||||
"folder": src.to_string_lossy(),
|
||||
"destination": "folder",
|
||||
"destinationFolder": dest.to_string_lossy(),
|
||||
"commands": [{"command": "object.unlockAll", "params": {}}],
|
||||
"label": "Unlock"
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["count"], 2);
|
||||
assert_eq!(r["failed"], 0);
|
||||
assert!(dest.join("a.svg").is_file(), "a.svg written");
|
||||
assert!(dest.join("b.svg").is_file(), "b.svg written");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_skips_open_steps_when_override_open() {
|
||||
let src = tmp("open");
|
||||
let dest = tmp("open-out");
|
||||
write_svg(&src, "only.svg", "#00f");
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 80})).unwrap();
|
||||
let r = s
|
||||
.execute(
|
||||
"action.batch",
|
||||
&json!({
|
||||
"folder": src.to_string_lossy(),
|
||||
"destination": "folder",
|
||||
"destinationFolder": dest.to_string_lossy(),
|
||||
"overrideOpen": true,
|
||||
"commands": [
|
||||
{"command": "document.open", "params": {"path": "/no/such/file.svg"}},
|
||||
{"command": "object.unlockAll", "params": {}}
|
||||
]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["failed"], 0, "{r}");
|
||||
assert!(dest.join("only.svg").is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_datasets_applies_each_row() {
|
||||
let dest = tmp("sets");
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
|
||||
let id = s.execute("text.create", &json!({"x": 10, "y": 40, "text": "A"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Name", "ids": [id]})).unwrap();
|
||||
s.execute("dataset.capture", &json!({})).unwrap();
|
||||
s.execute("text.setText", &json!({"ids": [id], "text": "B"})).unwrap();
|
||||
s.execute("dataset.capture", &json!({})).unwrap();
|
||||
let r = s
|
||||
.execute(
|
||||
"action.batch",
|
||||
&json!({
|
||||
"source": "datasets",
|
||||
"destination": "folder",
|
||||
"destinationFolder": dest.to_string_lossy(),
|
||||
"commands": [{"command": "object.unlockAll", "params": {}}],
|
||||
"label": "Per set"
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["count"], 2, "{r}");
|
||||
assert_eq!(r["failed"], 0, "{r}");
|
||||
let written: Vec<_> = std::fs::read_dir(&dest).unwrap().filter_map(|e| e.ok()).map(|e| e.file_name()).collect();
|
||||
assert_eq!(written.len(), 2, "{written:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_refuses_nesting_and_empty_commands() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 80})).unwrap();
|
||||
assert!(s.execute("action.batch", &json!({"commands": []})).is_err());
|
||||
assert!(
|
||||
s.execute(
|
||||
"action.batch",
|
||||
&json!({
|
||||
"folder": ".",
|
||||
"commands": [{"command": "command.batch", "params": {"commands": []}}]
|
||||
})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,8 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// starts (one for all the steps in that document), so a step may open, switch or close documents.
|
||||
/// A step that acts on the active artboard ([`crate::ON_ACTIVE_ARTBOARD`]) and names none gets
|
||||
/// `artboard`, as the Artboards panel shows it in the document the step runs in: past that
|
||||
/// document's last artboard, the last one.
|
||||
/// document's last artboard, the last one. A step that resizes the active artboard
|
||||
/// ([`crate::FIT_ACTIVE_ARTBOARD`]) and names no `index` gets that artboard as its `index`.
|
||||
fn run_steps(s: &mut Session, label: &str, artboard: Option<u64>, cmds: &mut [Value]) -> Result<Vec<Value>> {
|
||||
let mut results = Vec::with_capacity(cmds.len());
|
||||
for c in cmds {
|
||||
@@ -67,13 +68,18 @@ fn run_steps(s: &mut Session, label: &str, artboard: Option<u64>, cmds: &mut [Va
|
||||
s.begin_interaction(label)?;
|
||||
}
|
||||
let mut params = c.get("params").cloned().unwrap_or(json!({}));
|
||||
let key = if crate::ON_ACTIVE_ARTBOARD.contains(&id.as_str()) {
|
||||
Some("artboard")
|
||||
} else {
|
||||
crate::FIT_ACTIVE_ARTBOARD.contains(&id.as_str()).then_some("index")
|
||||
};
|
||||
if let Some(a) = artboard
|
||||
&& crate::ON_ACTIVE_ARTBOARD.contains(&id.as_str())
|
||||
&& params.get("artboard").is_none()
|
||||
&& let Some(key) = key
|
||||
&& params.get(key).is_none()
|
||||
&& let Some(last) = s.active().and_then(|d| d.doc.artboards.len().checked_sub(1))
|
||||
&& let Some(o) = params.as_object_mut()
|
||||
{
|
||||
o.insert("artboard".into(), json!(usize::try_from(a).map_or(last, |a| a.min(last))));
|
||||
o.insert(key.into(), json!(usize::try_from(a).map_or(last, |a| a.min(last))));
|
||||
}
|
||||
let (v, noted) =
|
||||
s.execute_step(&id, ¶ms).map_err(|e| EngineError::Other(format!("batch step {} (`{id}`) failed: {e}", results.len())))?;
|
||||
|
||||
@@ -94,13 +94,15 @@ fn selected_part(n: &Node, keep: &HashSet<NodeId>) -> Option<Node> {
|
||||
}
|
||||
|
||||
/// `doc` with only the art over `rect` (an artboard): objects whose visual bounds miss it are left
|
||||
/// out, at any depth of layers and sublayers, and objects that reach it stay whole (the file's
|
||||
/// bounds clip them), as DXF's crop leaves them. Symbol instances are measured by their art, and
|
||||
/// the box of one whose art holds brushed strokes grows by as far as their brush art can paint
|
||||
/// ([`SymbolBoxes::with_brush_art`]). An object that holds a symbol instance also stays when what
|
||||
/// the canvas paints for it reaches `rect` ([`vectorcraft_render::painted_bounds`]: the effects in
|
||||
/// the symbols' art and its own effects). A clipping layer keeps every object, its clipping path
|
||||
/// among them. An artboard's file then holds its own art, not a whole document of artboards.
|
||||
/// out, at any depth of layers, sublayers and groups that don't clip, unless what the renderer
|
||||
/// draws for them, their effects included ([`vectorcraft_render::effect_bounds`]), reaches it.
|
||||
/// Objects that reach it stay whole (the file's bounds clip them), as DXF's crop leaves them.
|
||||
/// Symbol instances are measured by their art, and the box of one whose art holds brushed strokes
|
||||
/// grows by as far as their brush art can paint ([`SymbolBoxes::with_brush_art`]). An object that
|
||||
/// holds a symbol instance also stays when what the canvas paints for it reaches `rect`
|
||||
/// ([`vectorcraft_render::painted_bounds`]: the effects in the symbols' art and its own effects). A
|
||||
/// clipping layer keeps every object, its clipping path among them. An artboard's file then holds
|
||||
/// its own art, not a whole document of artboards.
|
||||
pub(super) fn art_over(doc: &Document, rect: vectorcraft_geom::Rect) -> Document {
|
||||
fn kept(n: &Arc<Node>, doc: &Document, symbols: &SymbolBoxes, rect: vectorcraft_geom::Rect) -> Option<Arc<Node>> {
|
||||
if matches!(n.kind, NodeKind::Layer { clip: false, .. }) {
|
||||
@@ -109,11 +111,21 @@ pub(super) fn art_over(doc: &Document, rect: vectorcraft_geom::Rect) -> Document
|
||||
*children = children.iter().filter_map(|c| kept(c, doc, symbols, rect)).collect();
|
||||
return Some(Arc::new(layer));
|
||||
}
|
||||
// A group that doesn't clip keeps only its members over the artboard, so art gathered in one
|
||||
// group across several artboards splits by artboard. Not with effects of its own: a shadow
|
||||
// can reach in from a member outside.
|
||||
if matches!(n.kind, NodeKind::Group { clip: false, .. }) && n.appearance.effects.is_empty() {
|
||||
let mut group = Node::clone(n);
|
||||
let children = group.children_mut()?;
|
||||
*children = children.iter().filter_map(|c| kept(c, doc, symbols, rect)).collect();
|
||||
return (!children.is_empty()).then(|| Arc::new(group));
|
||||
}
|
||||
let over = |b: vectorcraft_geom::Rect| b.x0 <= rect.x1 && rect.x0 <= b.x1 && b.y0 <= rect.y1 && rect.y0 <= b.y1;
|
||||
let mut holds_instance = false;
|
||||
n.walk(&mut |c| holds_instance |= matches!(c.kind, NodeKind::SymbolInstance { .. }));
|
||||
let drawn = || vectorcraft_render::effect_bounds(symbols, n).is_some_and(over);
|
||||
let painted = || holds_instance && vectorcraft_render::painted_bounds(doc, n).is_some_and(over);
|
||||
(n.visual_bounds_with(symbols).is_none_or(over) || painted()).then(|| n.clone())
|
||||
(n.visual_bounds_with(symbols).is_none_or(over) || drawn() || painted()).then(|| n.clone())
|
||||
}
|
||||
let symbols = doc.symbol_boxes().with_brush_art();
|
||||
let mut d = doc.clone();
|
||||
@@ -147,28 +159,42 @@ fn source<'a>(s: &'a mut Session, f: &Format, p: &Value, cmd: &str) -> Result<Co
|
||||
if f.id == "txt" && bool_or(p, "selectionOnly", false) {
|
||||
return Ok(Cow::Owned(selection(s, cmd)?.0));
|
||||
}
|
||||
export_source(s.doc()?, p)
|
||||
let st = s.doc()?;
|
||||
let doc = export_source(st, p)?;
|
||||
Ok(super::titled_for_export(&doc, st.path.as_deref(), f.id).map_or(doc, Cow::Owned))
|
||||
}
|
||||
|
||||
/// The selected objects (not on template layers) alone on one layer, with one artboard: their
|
||||
/// visual bounds.
|
||||
fn selection(s: &mut Session, cmd: &str) -> Result<(Document, vectorcraft_geom::Rect)> {
|
||||
let ids = selected_artwork(s)?;
|
||||
isolated(&s.doc()?.doc, &ids, "Selection").ok_or_else(|| bad(cmd, "select something to export"))
|
||||
}
|
||||
|
||||
/// The selected objects (their top-most selected ancestors) that are artwork: those on template
|
||||
/// layers are guides and are left out.
|
||||
pub(crate) fn selected_artwork(s: &mut Session) -> Result<Vec<NodeId>> {
|
||||
let ids = edit::selected_roots(s)?;
|
||||
let st = s.doc()?;
|
||||
let is_template = |id| st.doc.node(id).is_some_and(|l| matches!(l.kind, NodeKind::Layer { template: true, .. }));
|
||||
// On a template layer or sublayer, at any depth.
|
||||
let on_template = |id| st.doc.ancestry(id).is_some_and(|a| a.into_iter().any(is_template));
|
||||
let ids: Vec<NodeId> = ids.into_iter().filter(|id| !on_template(*id)).collect();
|
||||
isolated(&st.doc, &ids, "Selection").ok_or_else(|| bad(cmd, "select something to export"))
|
||||
Ok(ids.into_iter().filter(|id| !on_template(*id)).collect())
|
||||
}
|
||||
|
||||
/// The crop of an export of objects `ids` of `doc` alone: the bounds the renderer measures for what
|
||||
/// it draws for them, their effects included ([`vectorcraft_render::effect_bounds`]; symbol
|
||||
/// instances are measured by their art), `None` when they have none.
|
||||
pub(crate) fn export_crop(doc: &Document, ids: &[NodeId]) -> Option<vectorcraft_geom::Rect> {
|
||||
let symbols = doc.symbol_boxes();
|
||||
ids.iter().filter_map(|id| vectorcraft_render::effect_bounds(&symbols, doc.node(*id)?)).reduce(|a, b| a.union(b))
|
||||
}
|
||||
|
||||
/// Objects `ids` of `doc` alone on one layer (in that order, back to front), with one artboard
|
||||
/// named `name`: their visual bounds, symbol instances measured by their art (`None` when they
|
||||
/// have none).
|
||||
/// named `name`: their [`export_crop`] (`None` when they have none).
|
||||
pub(crate) fn isolated(doc: &Document, ids: &[NodeId], name: &str) -> Option<(Document, vectorcraft_geom::Rect)> {
|
||||
let bounds = export_crop(doc, ids)?;
|
||||
let nodes: Vec<Arc<Node>> = ids.iter().filter_map(|id| doc.node(*id).cloned().map(Arc::new)).collect();
|
||||
let symbols = doc.symbol_boxes();
|
||||
let bounds = nodes.iter().filter_map(|n| n.visual_bounds_with(&symbols)).reduce(|a, b| a.union(b))?;
|
||||
let mut d = single_artboard(doc, bounds, name);
|
||||
let mut layer = Node::layer(d.alloc_id(), name, vectorcraft_doc::LayerColor::Preset(0));
|
||||
if let Some(ch) = layer.children_mut() {
|
||||
@@ -178,8 +204,9 @@ pub(crate) fn isolated(doc: &Document, ids: &[NodeId], name: &str) -> Option<(Do
|
||||
Some((d, bounds))
|
||||
}
|
||||
|
||||
/// File → Export Selection: the selected objects alone, cropped to their visual bounds. Objects on
|
||||
/// template layers are guides, not artwork, and are left out.
|
||||
/// File → Export Selection: the selected objects alone, cropped to the bounds the renderer
|
||||
/// measures for what they draw, their effects included ([`isolated`]). Objects on template layers
|
||||
/// are guides, not artwork, and are left out.
|
||||
pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "document.exportSelection";
|
||||
if let Some(path) = str_param(p, "path") {
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
//! Save As › `.ai` with `aiVersion: "7"`: legacy `.ai` files of version 7 (the PostScript-form `.ai`
|
||||
//! that `vectorcraft_eps::export_legacy_ai` writes), which keep the layers, groups, compound
|
||||
//! paths and clipping groups that a PDF-compatible `.ai` loses in apps reading only its PDF part
|
||||
//! (#868).
|
||||
//!
|
||||
//! The format has one artboard: each chosen artboard (default all) is a file of its own, holding
|
||||
//! the art that touches it, named `{stem}-{artboard}.ai` beside the path when there are several.
|
||||
//! Appearances the format can't hold (several fills or strokes, effects, brushes) are expanded into
|
||||
//! objects. Transparency isn't flattened: flattening merges the art that overlaps transparent
|
||||
//! objects, across layers, into one flattened result, which empties the layers under them, and
|
||||
//! editable layers are what this format is saved for. Transparent objects are written opaque (a
|
||||
//! warning says so).
|
||||
//!
|
||||
//! Images are the exception (`transparentImages`): an image with see-through pixels, opacity or a
|
||||
//! 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 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
|
||||
//! left out, with `visible` they show where they are. Type is written as text the format's reader
|
||||
//! lays out again, in the fonts it names; `outlineText` writes its outlines instead (as type that
|
||||
//! 8-bit text can't hold always is).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_color::BlendMode;
|
||||
use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
|
||||
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)"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// What a legacy `.ai` does with the images it can't hold (`transparentImages`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum SeeThrough {
|
||||
Flatten,
|
||||
Omit,
|
||||
}
|
||||
|
||||
/// Where a legacy `.ai`'s hidden objects go (`hiddenObjects`): the format hides only layers.
|
||||
fn hidden_objects(cmd: &str, p: &Value) -> Result<vectorcraft_eps::HiddenObjects> {
|
||||
use vectorcraft_eps::HiddenObjects;
|
||||
match p.get("hiddenObjects") {
|
||||
None | Some(Value::Null) => Ok(HiddenObjects::Layer),
|
||||
Some(Value::String(v)) if v.trim().is_empty() || v.trim().eq_ignore_ascii_case("layer") => Ok(HiddenObjects::Layer),
|
||||
Some(Value::String(v)) if v.trim().eq_ignore_ascii_case("omit") => Ok(HiddenObjects::Omit),
|
||||
Some(Value::String(v)) if v.trim().eq_ignore_ascii_case("visible") => Ok(HiddenObjects::Visible),
|
||||
Some(v) => Err(bad(cmd, format!("hiddenObjects {v}: \"layer\" (the default), \"omit\" or \"visible\""))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Type as outlines (`outlineText`), not text objects.
|
||||
fn outline_text(cmd: &str, p: &Value) -> Result<bool> {
|
||||
match p.get("outlineText") {
|
||||
None | Some(Value::Null) => Ok(false),
|
||||
Some(Value::Bool(b)) => Ok(*b),
|
||||
Some(v) => Err(bad(cmd, format!("outlineText {v}: true or false"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn see_through(cmd: &str, p: &Value) -> Result<SeeThrough> {
|
||||
match p.get("transparentImages") {
|
||||
None | Some(Value::Null) => Ok(SeeThrough::Flatten),
|
||||
Some(Value::String(v)) if v.trim().is_empty() || v.trim().eq_ignore_ascii_case("flatten") => Ok(SeeThrough::Flatten),
|
||||
Some(Value::String(v)) if v.trim().eq_ignore_ascii_case("omit") => Ok(SeeThrough::Omit),
|
||||
Some(v) => Err(bad(cmd, format!("transparentImages {v}: \"flatten\" (the default) or \"omit\""))),
|
||||
}
|
||||
}
|
||||
|
||||
/// What the format loses against a VectorCraft document, reported with every save.
|
||||
const FIDELITY: &str = "a legacy .ai (version 7) keeps layers, groups, compound paths, clipping groups, type, colours, gradients and images, not the rest: save as VectorCraft to keep everything editable";
|
||||
const FLATTENED: &str =
|
||||
"transparent images (see-through pixels, opacity or a blending mode) are flattened with the art under them into opaque images";
|
||||
const OMITTED: &str = "transparent images (see-through pixels, opacity or a blending mode) are left out";
|
||||
const SEPARATE: &str = "legacy .ai files have one artboard: each artboard is saved to a file of its own";
|
||||
|
||||
/// Encode `doc` as legacy `.ai` files of version 7: one per chosen artboard (`artboard`, `artboards` or
|
||||
/// `range`; default all).
|
||||
pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<Encoded> {
|
||||
let n = doc.artboards.len();
|
||||
if n == 0 {
|
||||
return Err(bad(cmd, "the document has no artboard"));
|
||||
}
|
||||
let mode = see_through(cmd, p)?;
|
||||
let (hidden, outline_text) = (hidden_objects(cmd, p)?, outline_text(cmd, p)?);
|
||||
let picked: ArtboardPick = super::encode::options(f, p)?;
|
||||
let boards = picked.resolve(n).map_err(|e| bad(cmd, e))?.unwrap_or_else(|| (0..n).collect());
|
||||
let mut enc = Encoded { warnings: vec![FIDELITY.to_string()], ..Encoded::default() };
|
||||
if boards.len() > 1 {
|
||||
enc.warnings.push(SEPARATE.to_string());
|
||||
}
|
||||
let created = vectorcraft_doc::metadata::now_unix();
|
||||
for b in boards {
|
||||
let one = super::save::artboard_doc(doc, b);
|
||||
let (full, placed) = crate::cmd::place::document::full_documents(&one);
|
||||
let mut d = full.into_owned();
|
||||
expand_appearances(&mut d);
|
||||
let images = see_through_images(&d, mode);
|
||||
if !images.is_empty() {
|
||||
note(&mut enc.warnings, if mode == SeeThrough::Flatten { FLATTENED } else { OMITTED });
|
||||
if mode == SeeThrough::Flatten {
|
||||
flatten_images(&mut d, &images)?;
|
||||
} else {
|
||||
for (_, id) in images {
|
||||
let _ = d.remove(id); // Inside an image left out before: gone already.
|
||||
}
|
||||
}
|
||||
}
|
||||
let o = vectorcraft_eps::LegacyAiOptions { title: doc.title.clone(), created, hidden, outline_text };
|
||||
let out = vectorcraft_eps::export_legacy_ai(&d, &o).map_err(|e| bad(cmd, e))?;
|
||||
for w in placed.iter().chain(&out.warnings) {
|
||||
note(&mut enc.warnings, w);
|
||||
}
|
||||
enc.files.push((Some(b), out.bytes));
|
||||
}
|
||||
Ok(enc)
|
||||
}
|
||||
|
||||
/// Expand Appearance of every layer's art whose appearance the format can't hold.
|
||||
fn expand_appearances(d: &mut Document) {
|
||||
let brushes = vectorcraft_brush::library(d);
|
||||
let layers: Vec<NodeId> = d.layers.iter().map(|l| l.id).collect();
|
||||
let mut out = vec![];
|
||||
for id in layers {
|
||||
super::super::effectcmd::expand_node(d, id, &brushes, &mut out);
|
||||
}
|
||||
}
|
||||
|
||||
/// The images of `d` a legacy `.ai` can't hold, in paint order with their places in it (as
|
||||
/// [`Document::walk`] counts): those with see-through pixels, opacity or a blending mode. Only
|
||||
/// visible ones flatten (a hidden one, which the hidden objects option may write, is written over
|
||||
/// white); all are left out with `omit`.
|
||||
fn see_through_images(d: &Document, mode: SeeThrough) -> Vec<(usize, NodeId)> {
|
||||
let keys = super::super::flatten::see_through_images(d);
|
||||
let mut out = vec![];
|
||||
let mut at = 0;
|
||||
d.walk(|n| {
|
||||
if let NodeKind::Image(im) = &n.kind
|
||||
&& (keys.contains(&im.key) || n.opacity < 1.0 || n.blend != BlendMode::Normal)
|
||||
{
|
||||
out.push((at, n.id));
|
||||
}
|
||||
at += 1;
|
||||
});
|
||||
if mode == SeeThrough::Flatten {
|
||||
out.retain(|(_, id)| d.is_visible(*id));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Replace each of `images` (from [`see_through_images`]) with an opaque image of what shows
|
||||
/// where it is, clipped to where it changes that; one that changes nothing is left out. Each is
|
||||
/// rendered from `d` as it was, so images over one another flatten as they look.
|
||||
fn flatten_images(d: &mut Document, images: &[(usize, NodeId)]) -> Result<()> {
|
||||
let src = d.clone();
|
||||
for &(at, id) in images {
|
||||
let Some((parent, idx, _)) = d.position(id) else { continue };
|
||||
let flat = flattened(&src, at, id)?;
|
||||
d.remove(id)?;
|
||||
if let Some(flat) = flat {
|
||||
let png = flat.png;
|
||||
let key = super::super::menucmds::unique_key(d, "flattened");
|
||||
d.images.insert(key.clone(), ImageBlob::new("image/png", png));
|
||||
let image = ImageObject { key, width: flat.width, height: flat.height, xf: flat.xf, link: None, placement: Default::default() };
|
||||
let image = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image)));
|
||||
let mut group = super::super::rasterfx::clip_group(d, flat.clip, FillRule::NonZero, image);
|
||||
group.name = src.node(id).and_then(|n| n.name.clone());
|
||||
d.insert(parent, idx, group)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// An image made by [`flattened`]: PNG pixels, their place, and the outline they are clipped to.
|
||||
struct Flat {
|
||||
png: Vec<u8>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
xf: Affine,
|
||||
clip: PathData,
|
||||
}
|
||||
|
||||
/// Highest and lowest resolution of a flattened image (pixels per inch); it takes the image's own
|
||||
/// in between.
|
||||
const MAX_PPI: f64 = 600.0;
|
||||
const MIN_PPI: f64 = 72.0;
|
||||
/// A rendered pixel channel changes when it changes by more than this.
|
||||
const CHANGED: u8 = 2;
|
||||
/// The clip outline follows cells of about this many points.
|
||||
const CELL: f64 = 2.0;
|
||||
|
||||
/// The image `id` (the `at`th node in paint order of `src`) flattened: `src` drawn up to and
|
||||
/// including it, over white, cropped and clipped to the cells where drawing it changes the
|
||||
/// result. `None` when it changes nothing.
|
||||
fn flattened(src: &Document, at: usize, id: NodeId) -> Result<Option<Flat>> {
|
||||
let (Some(n), Some(bounds)) = (src.node(id), src.bounds_of(&[id], true)) else { return Ok(None) };
|
||||
let NodeKind::Image(im) = &n.kind else { return Ok(None) };
|
||||
// The image's own resolution (points per pixel from its placement).
|
||||
let pt = im.xf.determinant().abs().sqrt();
|
||||
let ppi = if pt.is_finite() && pt > 0.0 { (72.0 / pt).clamp(MIN_PPI, MAX_PPI) } else { MIN_PPI };
|
||||
let area = (bounds.width() * bounds.height()).max(1.0);
|
||||
let scale = (ppi / 72.0).min((super::super::menucmds::MAX_PIXELS / area).sqrt());
|
||||
if !(scale.is_finite() && scale > 0.0) {
|
||||
return Ok(None);
|
||||
}
|
||||
let region = Rect::new(
|
||||
bounds.x0,
|
||||
bounds.y0,
|
||||
bounds.x0 + (bounds.width() * scale).ceil().max(1.0) / scale,
|
||||
bounds.y0 + (bounds.height() * scale).ceil().max(1.0) / scale,
|
||||
);
|
||||
let mut with = src.clone();
|
||||
let mut i = 0;
|
||||
with.layers = src.layers.iter().filter_map(|l| up_to(l, at, &mut i)).map(Arc::new).collect();
|
||||
let mut without = with.clone();
|
||||
without.remove(id)?;
|
||||
let mut r = vectorcraft_render::Renderer::new();
|
||||
let shown = r.render_region(&with, region, scale, true);
|
||||
let under = r.render_region(&without, region, scale, true);
|
||||
let (w, h) = (shown.width as usize, shown.height as usize);
|
||||
if w == 0 || h == 0 || shown.pixels.len() != w * h * 4 || under.pixels.len() != shown.pixels.len() {
|
||||
return Ok(None);
|
||||
}
|
||||
// The cells where it changes anything.
|
||||
let c = (CELL * scale).round().max(1.0) as usize;
|
||||
let (cw, ch) = (w.div_ceil(c), h.div_ceil(c));
|
||||
let mut cells = vec![false; cw * ch];
|
||||
for (k, (a, b)) in shown.pixels.as_chunks::<4>().0.iter().zip(under.pixels.as_chunks::<4>().0).enumerate() {
|
||||
if a.iter().zip(b).any(|(a, b)| a.abs_diff(*b) > CHANGED) {
|
||||
let (x, y) = (k % w, k / w);
|
||||
if let Some(cell) = cells.get_mut(y / c * cw + x / c) {
|
||||
*cell = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
let marked = || cells.iter().enumerate().filter(|(_, on)| **on).map(|(k, _)| (k % cw, k / cw));
|
||||
let (Some(x0), Some(x1), Some(y0), Some(y1)) =
|
||||
(marked().map(|p| p.0).min(), marked().map(|p| p.0).max(), marked().map(|p| p.1).min(), marked().map(|p| p.1).max())
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
// Crop to them.
|
||||
let (px0, py0) = (x0 * c, y0 * c);
|
||||
let (px1, py1) = (((x1 + 1) * c).min(w), ((y1 + 1) * c).min(h));
|
||||
let mut pixels = Vec::with_capacity((px1 - px0) * (py1 - py0) * 4);
|
||||
for y in py0..py1 {
|
||||
pixels.extend_from_slice(shown.pixels.get((y * w + px0) * 4..(y * w + px1) * 4).unwrap_or_default());
|
||||
}
|
||||
let crop = vectorcraft_render::Rendered { width: (px1 - px0) as u32, height: (py1 - py0) as u32, pixels };
|
||||
let png = crop.to_png().map_err(EngineError::Other)?;
|
||||
let at = |x: usize, y: usize| Point::new(region.x0 + (x.min(w)) as f64 / scale, region.y0 + (y.min(h)) as f64 / scale);
|
||||
let mut clip = PathData::new(vec![]);
|
||||
for comp in vectorcraft_trace::trace_mask(&cells, cw, ch) {
|
||||
for lp in std::iter::once(&comp.outer).chain(&comp.holes) {
|
||||
let pts: Vec<(i32, i32)> = corners(&lp.pts);
|
||||
let anchors = pts.iter().map(|&(x, y)| Anchor::corner(at(x.max(0) as usize * c, y.max(0) as usize * c))).collect();
|
||||
clip.subpaths.push(SubPath::new(anchors, true));
|
||||
}
|
||||
}
|
||||
let xf = Affine::translate(at(px0, py0).to_vec2()) * Affine::scale(1.0 / scale);
|
||||
Ok(Some(Flat { png, width: crop.width, height: crop.height, xf, clip }))
|
||||
}
|
||||
|
||||
/// `n` without the nodes after the `last`th in paint order (`i` counts them, as
|
||||
/// [`Document::walk`] does); `None` when `n` itself comes after it.
|
||||
fn up_to(n: &Node, last: usize, i: &mut usize) -> Option<Node> {
|
||||
if *i > last {
|
||||
return None;
|
||||
}
|
||||
*i += 1;
|
||||
let mut n = n.clone();
|
||||
if let Some(ch) = n.children_mut() {
|
||||
let kept: Vec<Arc<Node>> = ch.iter().filter_map(|c| up_to(c, last, i)).map(Arc::new).collect();
|
||||
*ch = kept;
|
||||
}
|
||||
Some(n)
|
||||
}
|
||||
|
||||
/// A loop's corners: its points without those in the middle of a straight run.
|
||||
fn corners(pts: &[(i32, i32)]) -> Vec<(i32, i32)> {
|
||||
let n = pts.len();
|
||||
let get = |k: usize| pts.get(k % n.max(1)).copied();
|
||||
(0..n)
|
||||
.filter_map(|k| {
|
||||
let (a, b, c) = (get(k + n - 1)?, get(k)?, get(k + 1)?);
|
||||
let straight = (a.0 == b.0 && b.0 == c.0) || (a.1 == b.1 && b.1 == c.1);
|
||||
(!straight).then_some(b)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn note(warnings: &mut Vec<String>, w: &str) {
|
||||
if !warnings.iter().any(|x| x == w) {
|
||||
warnings.push(w.to_string());
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ use std::io::Cursor;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
|
||||
use vectorcraft_geom::Affine;
|
||||
use vectorcraft_render::effects::pixel::LoadedTexture;
|
||||
|
||||
use super::super::*;
|
||||
use super::pdfimport::LoadOptions;
|
||||
@@ -153,14 +154,15 @@ pub fn load(name: &str, bytes: &[u8]) -> Result<Loaded> {
|
||||
/// A PDF-compatible `.ai`, or a PDF saved with the editing data, read through the editor's own copy
|
||||
/// of its art, which it carries: its layers and hidden objects as they were, and the art outside
|
||||
/// its artboard (its PDF part has only what is on the artboards), as an EPS of the editor is read.
|
||||
/// Anything else as it was.
|
||||
fn through_editing_data(bytes: &[u8], opts: &LoadOptions, doc: Document, warnings: Vec<String>) -> (Document, Vec<String>) {
|
||||
/// Anything else as it was. `unit`: the size in points of a unit of the PDF part's user space (its
|
||||
/// first page's `UserUnit`).
|
||||
fn through_editing_data(bytes: &[u8], opts: &LoadOptions, doc: Document, warnings: Vec<String>, unit: f64) -> (Document, Vec<String>) {
|
||||
if opts.pages.is_some() || !opts.layers || !opts.editing_data {
|
||||
return (doc, warnings);
|
||||
}
|
||||
let Some(private) = vectorcraft_pdf::illustrator_data(bytes, opts.password.as_deref()) else { return (doc, warnings) };
|
||||
let outlined = opts.text_as == vectorcraft_pdf::TextAs::Outlines;
|
||||
let (doc, mut warnings, read) = vectorcraft_eps::layered_ai(&private, doc, warnings, outlined, opts.trust_editing_data);
|
||||
let (doc, mut warnings, read) = vectorcraft_eps::layered_ai(&private, doc, warnings, outlined, opts.trust_editing_data, unit);
|
||||
// The layers weren't read (or couldn't be): the notes of reading the PDF part still hold.
|
||||
if !read {
|
||||
return (doc, warnings);
|
||||
@@ -228,7 +230,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
// Pages of text alone: the editing copy alone where it reads, as for a file
|
||||
// saved without its PDF part (#1387: else the page, which is the art).
|
||||
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)))
|
||||
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.
|
||||
Err(e) if e.to_string() == vectorcraft_pdf::PdfError::PlaceholderOnly.to_string() => {
|
||||
@@ -253,6 +255,14 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
_ if format.raster => (raster_doc(&title, bytes)?, vec![], false),
|
||||
_ => return Err(err(format!("{} files can't be opened yet", format.label))),
|
||||
};
|
||||
// A PDF, `.ai` or `.ait` read from its pages or its editing copy, not restored from the document
|
||||
// it carries, opens with the File Info it gives (a PostScript `.ai` gives none).
|
||||
if matches!(format.id, "pdf" | "ai" | "ait")
|
||||
&& !restored
|
||||
&& let Some(info) = vectorcraft_pdf::file_info(bytes, opts.password.as_deref())
|
||||
{
|
||||
super::super::fileinfo::read_pdf(&mut doc.metadata, info);
|
||||
}
|
||||
// Threaded type read from a file flows through its frames.
|
||||
if !doc.text_threads.is_empty() && !restored {
|
||||
crate::cmd::threads::reflow(&Document::new(1.0, 1.0), &mut doc);
|
||||
@@ -482,6 +492,43 @@ fn as_png(img: image::RgbaImage, ppi: Option<(f64, f64)>) -> Result<RasterImage>
|
||||
stored(png, "image/png", size, ppi)
|
||||
}
|
||||
|
||||
/// Effect › Distort › Glass › Load Texture…: encoded image `bytes`, in any format [`raster_image`]
|
||||
/// reads, as the `textureImage` Glass stores ([`LoadedTexture::to_stored`]). The pixels are decoded
|
||||
/// within [`MAX_RASTER_ALLOC`], counted as RGBA, as the renderer decodes them (#1717); a CMYK image
|
||||
/// is converted with the active color settings.
|
||||
pub fn texture_image(bytes: &[u8]) -> Result<String> {
|
||||
if let Some(note) = too_large_note(bytes) {
|
||||
return Err(err(note));
|
||||
}
|
||||
let RasterImage { blob, width, height, .. } = raster_image(bytes)?;
|
||||
// The allowance counts the pixels as RGBA: a gray image takes four times its file's samples (#1717).
|
||||
if u64::from(width) * u64::from(height) * 4 > MAX_RASTER_ALLOC {
|
||||
return Err(err(format!("an image of {width} × {height} pixels is too large to draw")));
|
||||
}
|
||||
let texture = match blob.cmyk() {
|
||||
Some(inks) => {
|
||||
let cms = vectorcraft_color::cms::active();
|
||||
let rgba: Vec<u8> = (inks.data.as_chunks::<4>().0.iter())
|
||||
.flat_map(|c| {
|
||||
let rgb = cms.cmyk_to_srgb(c.map(|v| f32::from(v) / 255.0), false);
|
||||
let [r, g, b] = rgb.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8);
|
||||
[r, g, b, 255]
|
||||
})
|
||||
.collect();
|
||||
LoadedTexture::from_rgba(inks.width, inks.height, &rgba)
|
||||
}
|
||||
None => {
|
||||
let mut reader = image::ImageReader::new(Cursor::new(blob.bytes.as_slice())).with_guessed_format().map_err(err)?;
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_alloc = Some(MAX_RASTER_ALLOC);
|
||||
reader.limits(limits);
|
||||
let img = reader.decode().map_err(err)?.to_rgba8();
|
||||
LoadedTexture::from_rgba(img.width(), img.height(), img.as_raw())
|
||||
}
|
||||
};
|
||||
texture.and_then(|t| t.to_stored()).ok_or_else(|| err("the image can't be read as a texture"))
|
||||
}
|
||||
|
||||
/// An image's encoded `bytes`, `width` × `height` pixels, ready to embed.
|
||||
fn stored(bytes: Vec<u8>, mime: &'static str, (width, height): (u32, u32), ppi: Option<(f64, f64)>) -> Result<RasterImage> {
|
||||
if width == 0 || height == 0 {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
//! Document file I/O for every frontend (desktop, web, CLI, control channel, headless MCP): open,
|
||||
//! save, export and serialize by path or bytes (base64), the format table, and `command.batch`.
|
||||
//! save, export and serialize by path or bytes (base64), the format table, `command.batch` and
|
||||
//! `action.batch` (the Actions panel menu's Batch…).
|
||||
//!
|
||||
//! - `load`: `document.open` (native, legacy, SVG/SVGZ, PDF/.ai/.ait, DXF, raster images).
|
||||
//! - `action_batch`: play an action on every file in a folder or every data set.
|
||||
//! - `encode`: one encoder per writable format, with typed options parsed from the params.
|
||||
//! - `svg`: the SVG Options (styling, fonts, images, object ids, artboards…).
|
||||
//! - `export`: `document.export` / `serialize` / `exportSelection` / `exportForOffice`.
|
||||
@@ -19,6 +21,7 @@
|
||||
//! [`FORMATS`] is the single list of formats (append-only); open dialogs use [`open_filters`],
|
||||
//! agents query `document.formats`.
|
||||
|
||||
mod action_batch;
|
||||
mod affinity;
|
||||
mod batch;
|
||||
pub mod dxf;
|
||||
@@ -27,9 +30,11 @@ mod encode;
|
||||
pub mod eps;
|
||||
mod export;
|
||||
pub(crate) mod imagemap;
|
||||
mod legacy_ai;
|
||||
mod load;
|
||||
pub(crate) use load::{MAX_RASTER_ALLOC, too_large_note};
|
||||
mod metafile;
|
||||
mod naming;
|
||||
mod native;
|
||||
pub mod pdf;
|
||||
mod pdfimport;
|
||||
@@ -48,7 +53,7 @@ pub use dxf::{UNSUPPORTED, Unsupported, unsupported};
|
||||
pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, encode, encode_all, encode_with_warnings};
|
||||
pub(crate) use encode::{anti_alias, background, with_single_artboard};
|
||||
pub use export::export_source;
|
||||
pub(crate) use export::isolated;
|
||||
pub(crate) use export::{export_crop, isolated};
|
||||
pub(crate) use load::check_not_lossy_overwrite;
|
||||
use load::err;
|
||||
pub(crate) use load::import_svg;
|
||||
@@ -56,18 +61,22 @@ pub(crate) use load::native_file;
|
||||
pub(crate) use load::source;
|
||||
pub use load::{
|
||||
Loaded, RasterImage, ReadFile, detect, file_name, load, load_with, open_bytes, open_bytes_with, open_read, open_template, raster_image,
|
||||
read_for_open,
|
||||
read_for_open, texture_image,
|
||||
};
|
||||
pub use load::{losses_summary, overwrite_losses};
|
||||
pub use naming::{NameParts as FileNameParts, parse_date as parse_file_date, render as render_file_name};
|
||||
pub(crate) use native::preview_png;
|
||||
pub use native::with_compression_pref;
|
||||
pub use pdfimport::{LoadOptions, page_document};
|
||||
pub(crate) use save::job_for;
|
||||
pub use save::{
|
||||
SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, export_folder, save_filters, save_format, save_job, save_plan, save_with,
|
||||
stamp_save_dates, templates_dialog_folder, templates_folder,
|
||||
SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, export_folder, save_filters, save_format, save_job, save_plan,
|
||||
save_with, stamp_save_dates, template_filters, templates_dialog_folder, templates_folder,
|
||||
};
|
||||
pub use screens::{
|
||||
PRESET_KEYS as SCREEN_EXPORT_PRESET_KEYS, PRESETS as SCREEN_PRESETS, ScreenExportPreset, ScreenSize,
|
||||
builtin_presets as screen_export_builtin_presets, preset_rows as screen_preset_rows,
|
||||
};
|
||||
pub use screens::{PRESETS as SCREEN_PRESETS, ScreenSize, 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,
|
||||
};
|
||||
@@ -105,7 +114,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Serialize Document",
|
||||
[],
|
||||
None,
|
||||
"{format?: vectorcraft (default)|template|svg|svgz|pdf|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, …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
|
||||
),
|
||||
@@ -114,7 +123,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export Document",
|
||||
[],
|
||||
None,
|
||||
"{path?, format?: svg|svgz|pdf|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF writes 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: every page) | false (pdf/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: 72 (raster formats: pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
|
||||
"{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: 72 (raster formats: pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
|
||||
has_doc,
|
||||
export::export
|
||||
),
|
||||
@@ -123,7 +132,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export Selection…",
|
||||
["File"],
|
||||
None,
|
||||
"{path?, format?: png|jpg|webp|svg|svgz|pdf (default: from the extension, else png), scale?: 1, …the format's options} the selected objects cropped to their bounds (template layers left out) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export",
|
||||
"{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|vectorcraft|template (default: from the extension, else png), scale?: 1, …the format's options} the selected objects cropped to their bounds (template layers left out) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export",
|
||||
has_selection,
|
||||
export::export_selection
|
||||
),
|
||||
@@ -132,19 +141,55 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export for Screens",
|
||||
["File", "Export"],
|
||||
None,
|
||||
"{folder?, zip?: false (one store-only .zip of every file: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|svg|svgz|pdf, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|svg|pdf: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/[email protected]). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step)",
|
||||
"{folder?, zip?: false (one store-only .zip of every file: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with <filename> for the document, <name> for the artboard, asset, layer or object, <index> from 1, #### for the counter zero-padded to the run of #, <format>, <scale>, <suffix> (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, <date>, <time>, <YYYY>, <MM>, <DD>, <hh>, <mm> and <ss>; a trailing .ext is dropped, as in `COMPANY-Logo-<filename>-####-YYYYMMDD`; different files it names alike get -2, -3…), counterStart?: 1, counterPer?: item (every file of an item has its number)|file, date?: YYYY-MM-DD (the date the pattern uses; default today, UTC), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/[email protected]). eps, ai and vectorcraft rows write a document of the item alone (its one artboard). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step)",
|
||||
has_doc,
|
||||
screens::export_for_screens
|
||||
),
|
||||
cmd!(
|
||||
query "document.exportPresets.list",
|
||||
"Export Presets",
|
||||
[],
|
||||
None,
|
||||
"{} → {presets: [{name, builtIn, settings}]} the built-in batch export presets (Logo Package, Print-Ready PDF), then the saved ones; any name works as document.exportForScreens' exportPreset",
|
||||
always,
|
||||
screens::presets_list
|
||||
),
|
||||
cmd!(
|
||||
query "document.exportPresets.save",
|
||||
"Save Export Preset",
|
||||
[],
|
||||
None,
|
||||
"{name, settings: {formats, settings, preset, name, prefix, counterStart, counterPer, folders, subfolders, includeBleed, antiAlias, folder, fullDocument} (as document.exportForScreens; other keys are left out)} save (or replace) a batch export preset, kept with the preferences; built-in names are refused → {name, created, settings}",
|
||||
always,
|
||||
screens::presets_save
|
||||
),
|
||||
cmd!(
|
||||
query "document.exportPresets.delete",
|
||||
"Delete Export Preset",
|
||||
[],
|
||||
None,
|
||||
"{name} delete a saved batch export preset (built-in ones stay) → {deleted}",
|
||||
always,
|
||||
screens::presets_delete
|
||||
),
|
||||
cmd!(
|
||||
query "command.batch",
|
||||
"Batch",
|
||||
[],
|
||||
None,
|
||||
"{label?, commands: [{command, params}], artboard?: index (the active artboard; the app passes it: each edit.pasteInPlace, edit.pasteInFront, edit.pasteInBack, object.align or select.allOnArtboard step that names none gets it, or the last artboard of a document with fewer)} run several commands as ONE undo step (one in each document they edit: steps may open, switch, close or revert documents); stops at the first error and rolls everything back, documents included",
|
||||
"{label?, commands: [{command, params}], artboard?: index (the active artboard; the app passes it: each edit.pasteInPlace, edit.pasteInFront, edit.pasteInBack, object.align, object.distribute, object.distributeSpacing or select.allOnArtboard step that names none gets it, and each artboard.fitToArt or artboard.fitToSelection step that names no index gets it as index, or the last artboard of a document with fewer)} run several commands as ONE undo step (one in each document they edit: steps may open, switch, close or revert documents); stops at the first error and rolls everything back, documents included",
|
||||
has_doc,
|
||||
batch::batch
|
||||
),
|
||||
cmd!(
|
||||
"action.batch",
|
||||
"Batch…",
|
||||
[],
|
||||
None,
|
||||
"{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportForScreens / document.exportSelection the same way), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs), or on every data set of the open document (source: datasets; File + Data Set Name for Override Save/Export). Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped}",
|
||||
always,
|
||||
action_batch::action_batch
|
||||
),
|
||||
cmd!(
|
||||
query "document.formats",
|
||||
"File Formats",
|
||||
@@ -159,7 +204,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}; 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}",
|
||||
always,
|
||||
pdfimport::pdf_info
|
||||
),
|
||||
@@ -496,7 +541,17 @@ pub const FORMATS: &[Format] = &[
|
||||
raster: false,
|
||||
options: native::AI_OPTIONS,
|
||||
},
|
||||
reader("ait", "PDF-compatible .ait template", &["ait"], "application/pdf", false),
|
||||
Format {
|
||||
id: "ait",
|
||||
label: "PDF-compatible .ait template",
|
||||
extensions: &["ait"],
|
||||
mime: "application/pdf",
|
||||
read: true,
|
||||
// Save as Template writes it, with the .ai file's options; exports don't.
|
||||
write: false,
|
||||
raster: false,
|
||||
options: native::AI_OPTIONS,
|
||||
},
|
||||
Format {
|
||||
id: "png",
|
||||
label: "PNG",
|
||||
@@ -683,6 +738,7 @@ pub fn open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static
|
||||
.chain(std::iter::once(("Flattener presets", super::flatten::PRESET_EXTS)))
|
||||
.chain(std::iter::once(("PDF presets", super::pdfcmds::PRESET_EXTS)))
|
||||
.chain(std::iter::once(("Print presets", super::printpresets::PRESET_EXTS)))
|
||||
.chain(std::iter::once(("Shortcut sets", super::shortcutset::EXTS)))
|
||||
.chain(std::iter::once(("Scripts", super::scripts::EXTS)))
|
||||
.chain(std::iter::once(("Plug-ins", super::plugin::EXTS)))
|
||||
}
|
||||
@@ -710,6 +766,18 @@ pub fn file_stem(name: &str) -> String {
|
||||
std::path::Path::new(name).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| name.to_string())
|
||||
}
|
||||
|
||||
/// What a `format` export of `doc`, whose file is `path`, writes when it isn't `doc` itself: a
|
||||
/// document that only has the Untitled-N name New gave it is titled after its file, as its tab is
|
||||
/// (the title PDF, PNG and SVG metadata carry). A title of its own stays, and native files keep
|
||||
/// the stored title.
|
||||
pub fn titled_for_export(doc: &vectorcraft_doc::Document, path: Option<&str>, format: &str) -> Option<vectorcraft_doc::Document> {
|
||||
let untitled = doc.title.strip_prefix("Untitled-").is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()));
|
||||
let path = path.filter(|_| untitled && !matches!(format, "vectorcraft" | "template" | "ai"))?;
|
||||
let mut d = doc.clone();
|
||||
d.title = file_stem(path);
|
||||
Some(d)
|
||||
}
|
||||
|
||||
/// The format a file name's extension names.
|
||||
pub fn format_for_name(name: &str) -> Option<&'static Format> {
|
||||
Some(extension(name)).filter(|e| !e.is_empty()).and_then(|e| format(&e))
|
||||
@@ -1107,6 +1175,8 @@ mod tests_jpeg;
|
||||
#[cfg(test)]
|
||||
mod tests_palette;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_batch;
|
||||
#[cfg(test)]
|
||||
mod tests_screens;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
//! File-name templates for batch exports (`document.exportForScreens {name}`): the same pattern
|
||||
//! gives every exported file a consistent name, such as `COMPANY-Logo-<filename>-####-YYYYMMDD`.
|
||||
//!
|
||||
//! Tokens:
|
||||
//! - `<filename>` (or `<document>`): the document's name without its extension;
|
||||
//! - `<name>` (or `<artboard>`, `<item>`): the artboard's, asset's, layer's or selection's name;
|
||||
//! - `<index>`: the item's place in the export, from 1;
|
||||
//! - a run of `#`: the counter, zero-padded to the run's length (`####` → `0001`);
|
||||
//! - `<format>` (or `<ext>`): the file's extension; `<scale>`: the row's size (`2x`, `100w`);
|
||||
//! `<suffix>`: the row's suffix (otherwise added at the end);
|
||||
//! - the date and time: `YYYYMMDD`, `YYYY-MM-DD` and `YYYY` as written, and `<date>`
|
||||
//! (YYYYMMDD), `<time>` (hhmmss), `<YYYY>`, `<MM>`, `<DD>`, `<hh>`, `<mm>`, `<ss>`.
|
||||
//!
|
||||
//! A trailing `.ext` (or `.<ext>`, `.<format>`) is dropped: the extension comes from the format.
|
||||
//! Anything that could start a folder (`/`, `\`, `:`) becomes `-`.
|
||||
|
||||
/// What a template's tokens stand for, for one file.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct NameParts<'a> {
|
||||
pub filename: &'a str,
|
||||
pub name: &'a str,
|
||||
pub index: usize,
|
||||
pub counter: u64,
|
||||
pub format: &'a str,
|
||||
pub scale: &'a str,
|
||||
pub suffix: &'a str,
|
||||
/// `[year, month, day, hour, minute, second]`.
|
||||
pub when: [i64; 6],
|
||||
}
|
||||
|
||||
/// The bracketed tokens and what each stands for.
|
||||
fn token(name: &str, p: &NameParts<'_>) -> Option<String> {
|
||||
let [y, mo, d, h, mi, s] = p.when;
|
||||
Some(match name {
|
||||
"filename" | "document" => p.filename.to_string(),
|
||||
"name" | "artboard" | "item" => p.name.to_string(),
|
||||
"index" => p.index.to_string(),
|
||||
"format" | "ext" => p.format.to_string(),
|
||||
"scale" => p.scale.to_string(),
|
||||
"suffix" => p.suffix.to_string(),
|
||||
"date" => format!("{y:04}{mo:02}{d:02}"),
|
||||
"time" => format!("{h:02}{mi:02}{s:02}"),
|
||||
"YYYY" => format!("{y:04}"),
|
||||
"MM" => format!("{mo:02}"),
|
||||
"DD" => format!("{d:02}"),
|
||||
"hh" => format!("{h:02}"),
|
||||
"mm" => format!("{mi:02}"),
|
||||
"ss" => format!("{s:02}"),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Text with `/`, `\` and `:` turned into `-`, so it can't start a folder.
|
||||
fn clean(s: &str) -> String {
|
||||
s.chars().map(|c| if matches!(c, '/' | '\\' | ':') { '-' } else { c }).collect()
|
||||
}
|
||||
|
||||
/// Does `template` place the row's suffix itself (so it isn't added at the end)?
|
||||
pub fn has_suffix_token(template: &str) -> bool {
|
||||
template.contains("<suffix>")
|
||||
}
|
||||
|
||||
/// The file name (without extension) `template` gives for `p`.
|
||||
pub fn render(template: &str, p: &NameParts<'_>) -> String {
|
||||
let mut t = template.trim();
|
||||
for ext in [".ext", ".<ext>", ".<format>"] {
|
||||
if let Some(stripped) = t.strip_suffix(ext) {
|
||||
t = stripped;
|
||||
}
|
||||
}
|
||||
let [y, mo, d, ..] = p.when;
|
||||
let mut out = String::with_capacity(t.len() + 16);
|
||||
let mut rest = t;
|
||||
while let Some(c) = rest.chars().next() {
|
||||
// Bracketed tokens; an unknown one stays as written.
|
||||
if c == '<'
|
||||
&& let Some(end) = rest.find('>')
|
||||
&& let Some(value) = rest.get(1..end).and_then(|name| token(name, p))
|
||||
{
|
||||
out.push_str(&clean(&value));
|
||||
rest = rest.get(end + 1..).unwrap_or("");
|
||||
continue;
|
||||
}
|
||||
if c == '#' {
|
||||
let run = rest.chars().take_while(|c| *c == '#').count();
|
||||
out.push_str(&format!("{:0run$}", p.counter));
|
||||
rest = rest.get(run..).unwrap_or("");
|
||||
continue;
|
||||
}
|
||||
// The date spelled out, longest first.
|
||||
let date = [("YYYYMMDD", format!("{y:04}{mo:02}{d:02}")), ("YYYY-MM-DD", format!("{y:04}-{mo:02}-{d:02}")), ("YYYY", format!("{y:04}"))];
|
||||
if let Some((pat, value)) = date.iter().find(|(pat, _)| rest.starts_with(pat)) {
|
||||
out.push_str(value);
|
||||
rest = rest.get(pat.len()..).unwrap_or("");
|
||||
continue;
|
||||
}
|
||||
out.push(if matches!(c, '/' | '\\' | ':') { '-' } else { c });
|
||||
rest = rest.get(c.len_utf8()..).unwrap_or("");
|
||||
}
|
||||
out.trim().to_string()
|
||||
}
|
||||
|
||||
/// `YYYY-MM-DD` (or `YYYYMMDD`) as `[year, month, day, 0, 0, 0]`; `None` when it isn't a date.
|
||||
pub fn parse_date(s: &str) -> Option<[i64; 6]> {
|
||||
let digits: String = s.trim().chars().filter(|c| *c != '-').collect();
|
||||
if digits.len() != 8 || !digits.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
let num = |a: usize, b: usize| digits.get(a..b).and_then(|t| t.parse::<i64>().ok());
|
||||
let (y, m, d) = (num(0, 4)?, num(4, 6)?, num(6, 8)?);
|
||||
((1..=12).contains(&m) && (1..=31).contains(&d)).then_some([y, m, d, 0, 0, 0])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parts() -> NameParts<'static> {
|
||||
NameParts { filename: "Brand", name: "Dark", index: 3, counter: 7, format: "png", scale: "2x", suffix: "@2x", when: [2026, 10, 16, 9, 5, 0] }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_example_from_the_request() {
|
||||
assert_eq!(render("COMPANY-Logo-<filename>-####-YYYYMMDD.ext", &parts()), "COMPANY-Logo-Brand-0007-20261016");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_token() {
|
||||
let p = parts();
|
||||
assert_eq!(render("<document>_<name>_<artboard>_<item>", &p), "Brand_Dark_Dark_Dark");
|
||||
assert_eq!(render("<index>-#-##-<format>-<ext>-<scale><suffix>", &p), "3-7-07-png-png-2x@2x");
|
||||
assert_eq!(render("<date>T<time> <YYYY>/<MM>/<DD> <hh>:<mm>:<ss>", &p), "20261016T090500 2026-10-16 09-05-00");
|
||||
assert_eq!(render("YYYY-MM-DD YYYY", &p), "2026-10-16 2026");
|
||||
// Words are left alone: only the date patterns and bracketed tokens are replaced.
|
||||
assert_eq!(render("SUMMER-DD-MM", &p), "SUMMER-DD-MM");
|
||||
assert_eq!(render("<unknown>-x", &p), "<unknown>-x");
|
||||
assert_eq!(render("logo.<format>", &p), "logo", "the extension comes from the format");
|
||||
assert!(has_suffix_token("a<suffix>") && !has_suffix_token("a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_name_climbs_out_of_its_folder() {
|
||||
let p = NameParts { name: "../../etc", ..parts() };
|
||||
let n = render("a/b\\c:<name>", &p);
|
||||
assert!(!n.contains('/') && !n.contains('\\') && !n.contains(':'), "{n}");
|
||||
// A long counter is padded, a short run is not cut.
|
||||
assert_eq!(render("########", &NameParts { counter: 123, ..parts() }), "00000123");
|
||||
assert_eq!(render("#", &NameParts { counter: 12345, ..parts() }), "12345");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dates_parse() {
|
||||
assert_eq!(parse_date("2026-10-16"), Some([2026, 10, 16, 0, 0, 0]));
|
||||
assert_eq!(parse_date("20261016"), Some([2026, 10, 16, 0, 0, 0]));
|
||||
for bad in ["", "2026-13-01", "2026-1-1", "today", "2026-10-1x"] {
|
||||
assert_eq!(parse_date(bad), None, "{bad}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,30 @@ pub const AI_OPTIONS: &[FormatOption] = &[
|
||||
default: "true",
|
||||
description: "compress the PDF's content and the native document it carries (the PDF option compression.compressText)",
|
||||
},
|
||||
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)",
|
||||
},
|
||||
FormatOption {
|
||||
name: "transparentImages",
|
||||
ty: "string",
|
||||
default: "\"flatten\"",
|
||||
description: "with aiVersion 7, the images version 7 can't hold (see-through pixels, opacity or a blending mode): flatten (the default: each becomes an opaque image of itself and the art under it, clipped to where it changes what shows; that art stays as it is) or omit (left out)",
|
||||
},
|
||||
FormatOption {
|
||||
name: "hiddenObjects",
|
||||
ty: "string",
|
||||
default: "\"layer\"",
|
||||
description: "with aiVersion 7, hidden objects, which version 7 can't hide (it hides only layers): layer (the default: each layer's hidden objects in a hidden layer of their own just above it, out of their groups), omit (left out) or visible (shown where they are)",
|
||||
},
|
||||
FormatOption {
|
||||
name: "outlineText",
|
||||
ty: "boolean",
|
||||
default: "false",
|
||||
description: "with aiVersion 7, type as its outlines instead of text (which apps reading the file set in the fonts it names: they need them installed); type 8-bit text can't hold (characters outside Windows Latin-1, vertical type, inline graphics, gradient or pattern fills) is always outlines",
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
|
||||
@@ -109,7 +109,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export PDF",
|
||||
[],
|
||||
None,
|
||||
"{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A always), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), form
Line truncated
|
||||
"{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A, pdfX3 and pdfX4 always, whatever profiles says), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from a
Line truncated
|
||||
has_doc,
|
||||
export_pdf
|
||||
),
|
||||
@@ -365,7 +365,9 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let expanded = expand_preset(s, C, p)?;
|
||||
let p = &*expanded;
|
||||
let (bytes, warnings) = encode(C, &s.doc()?.doc, p)?;
|
||||
let st = s.doc()?;
|
||||
let titled = super::titled_for_export(&st.doc, st.path.as_deref(), "pdf");
|
||||
let (bytes, warnings) = encode(C, titled.as_ref().unwrap_or(&st.doc), p)?;
|
||||
write_or_return(str_param(p, "path"), &bytes, json!({ "warnings": warnings }))
|
||||
}
|
||||
|
||||
|
||||
@@ -22,13 +22,27 @@ use crate::{DocState, Prefs};
|
||||
/// they never become the document's own file.
|
||||
pub const SAVE_FORMATS: &[&str] = &["vectorcraft", "template", "pdf", "svg", "svgz", "ai"];
|
||||
|
||||
/// The formats Save as Template writes, the default first: a native template, or a
|
||||
/// PDF-compatible `.ait` (written as a `.ai` file is).
|
||||
pub const TEMPLATE_FORMATS: &[&str] = &["template", "ait"];
|
||||
|
||||
/// Save-panel filters `(label, [extension])` for `first` (a format id, or a file name whose
|
||||
/// extension names one): when Save writes that format, one per [`SAVE_FORMATS`] entry with it
|
||||
/// leading (the panel's default type), else none (an export picks its own). Only the extension a
|
||||
/// save writes (never the former native name).
|
||||
pub fn save_filters(first: &str) -> Vec<(&'static str, &'static [&'static str])> {
|
||||
let Some(first) = format_for_name(first).or_else(|| format(first)).filter(|f| SAVE_FORMATS.contains(&f.id)) else { return vec![] };
|
||||
let mut v: Vec<&'static Format> = SAVE_FORMATS.iter().filter_map(|id| format(id)).collect();
|
||||
filters(SAVE_FORMATS, first)
|
||||
}
|
||||
|
||||
/// [`save_filters`] for Save as Template: one per [`TEMPLATE_FORMATS`] entry.
|
||||
pub fn template_filters(first: &str) -> Vec<(&'static str, &'static [&'static str])> {
|
||||
filters(TEMPLATE_FORMATS, first)
|
||||
}
|
||||
|
||||
/// One filter per format of `ids`, `first` leading; none when `first` names none of them.
|
||||
fn filters(ids: &[&str], first: &str) -> Vec<(&'static str, &'static [&'static str])> {
|
||||
let Some(first) = format_for_name(first).or_else(|| format(first)).filter(|f| ids.contains(&f.id)) else { return vec![] };
|
||||
let mut v: Vec<&'static Format> = ids.iter().filter_map(|id| format(id)).collect();
|
||||
v.sort_by_key(|f| f.id != first.id);
|
||||
v.into_iter().map(|f| (f.label, &f.extensions[..1])).collect()
|
||||
}
|
||||
@@ -73,7 +87,7 @@ pub struct SavePlan {
|
||||
pub format: &'static Format,
|
||||
/// The format's options (only those its encoder reads).
|
||||
pub options: Map<String, Value>,
|
||||
/// Suggested file name: `<name>.<ext>`, `<name> copy.<ext>` or `<name> template.vctemplate`.
|
||||
/// Suggested file name: `<name>.<ext>`, `<name> copy.<ext>` or `<name> template.<ext>`.
|
||||
pub name: String,
|
||||
/// Suggested folder: the document's own, or the Templates folder for a template.
|
||||
pub folder: Option<String>,
|
||||
@@ -194,7 +208,11 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result<SavePlan> {
|
||||
let st = s.doc()?;
|
||||
let path = str_param(p, "path").map(str::to_string);
|
||||
let format = match (mode, str_param(p, "format"), path.as_deref().and_then(format_for_name)) {
|
||||
(SaveMode::Template, ..) => checked(cmd, "template")?,
|
||||
// `.ait` when the format or else the path's extension names it; else a native template.
|
||||
(SaveMode::Template, f, by_name) => match f.map_or(by_name, format).filter(|f| TEMPLATE_FORMATS.contains(&f.id)) {
|
||||
Some(f) => f,
|
||||
None => checked(cmd, "template")?,
|
||||
},
|
||||
(_, Some(f), _) => checked(cmd, f)?,
|
||||
(_, None, Some(f)) => checked(cmd, f.id)?,
|
||||
(_, None, None) => checked(cmd, st.format)?,
|
||||
@@ -248,13 +266,13 @@ fn reads_option(f: &Format, key: &str) -> bool {
|
||||
}
|
||||
|
||||
/// Formats that carry the whole native document: they lose nothing, and record links relative to
|
||||
/// where they are written. A `.ai` file is a PDF carrying it.
|
||||
/// where they are written. A `.ai` or `.ait` file is a PDF carrying it.
|
||||
fn is_native(f: &Format) -> bool {
|
||||
matches!(f.id, "vectorcraft" | "template" | "ai")
|
||||
matches!(f.id, "vectorcraft" | "template" | "ai" | "ait")
|
||||
}
|
||||
|
||||
fn is_pdf(f: &Format) -> bool {
|
||||
matches!(f.id, "pdf" | "ai")
|
||||
matches!(f.id, "pdf" | "ai" | "ait")
|
||||
}
|
||||
|
||||
fn is_svg(f: &Format) -> bool {
|
||||
@@ -335,7 +353,8 @@ impl<W: std::io::Write> std::io::Write for Counted<W> {
|
||||
/// The artboards a native or `.ai` save also writes to files of their own: with
|
||||
/// `separateArtboards`, those `range` names (default all).
|
||||
fn separate_boards(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<Vec<usize>> {
|
||||
if !is_native(f) || !bool_or(p, "separateArtboards", false) {
|
||||
// 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)? {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let range = str_param(p, "range").filter(|r| !r.trim().is_empty()).map(str::to_string);
|
||||
@@ -346,7 +365,7 @@ fn separate_boards(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<V
|
||||
|
||||
/// `doc` reduced to artboard `i` and the art on it (Save each artboard to a separate file):
|
||||
/// objects whose bounds touch the artboard, in their layers; resources are kept.
|
||||
fn artboard_doc(doc: &Document, i: usize) -> Document {
|
||||
pub(super) fn artboard_doc(doc: &Document, i: usize) -> Document {
|
||||
let mut d = doc.clone();
|
||||
let Some(a) = doc.artboards.get(i) else { return d };
|
||||
let r = a.rect;
|
||||
@@ -385,8 +404,12 @@ fn blank_pages(doc: &Document) -> Document {
|
||||
/// Why a `.ai` file saved without PDF content looks empty elsewhere.
|
||||
const NOT_PDF_COMPATIBLE: &str = "saved without PDF content: VectorCraft opens it as before, other apps show empty pages";
|
||||
|
||||
/// The shared Save and Export encoder for PDF-compatible `.ai` files.
|
||||
/// 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)? {
|
||||
return super::legacy_ai::encode(cmd, f, doc, p);
|
||||
}
|
||||
let mut params = if p.is_object() { p.clone() } else { json!({}) };
|
||||
if let Some(o) = params.as_object_mut() {
|
||||
o.insert("preserveEditing".into(), json!(true));
|
||||
@@ -442,6 +465,11 @@ pub(crate) fn job_for(s: &Session, st: &DocState, plan: SavePlan) -> Result<Save
|
||||
fn job_for_stamped(s: &Session, st: &DocState, plan: SavePlan, stamped: Option<i64>) -> Result<SaveJob> {
|
||||
let cmd = plan.mode.command();
|
||||
let mut own = doc_to_save(st, plan.format);
|
||||
// The file the document is saved as (else its own) titles it if New's name is all it has.
|
||||
let file = plan.path.as_deref().filter(|_| plan.retargets()).or(st.path.as_deref());
|
||||
if let Some(d) = super::titled_for_export(&own, file, plan.format.id) {
|
||||
own = Arc::new(d);
|
||||
}
|
||||
let mut snapshot = st.doc.clone();
|
||||
if let Some(at) = stamped {
|
||||
stamp_doc_dates(Arc::make_mut(&mut own), at);
|
||||
@@ -463,9 +491,14 @@ 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();
|
||||
if let (Some(o), "ai") = (params.as_object_mut(), plan.format.id) {
|
||||
// Save keeps every artboard; exports may choose a subset instead.
|
||||
o.insert("range".into(), json!("all"));
|
||||
let legacy = super::legacy_ai::wanted(cmd, ¶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.
|
||||
let chosen = legacy && plan.format.id == "ai" && o.get("range").and_then(Value::as_str).is_some_and(|r| !r.trim().is_empty());
|
||||
if !chosen {
|
||||
o.insert("range".into(), json!("all"));
|
||||
}
|
||||
}
|
||||
Ok(SaveJob { plan, doc, params, snapshot, source: st.doc.clone(), stamped, boards })
|
||||
}
|
||||
@@ -506,8 +539,10 @@ impl SaveJob {
|
||||
/// Encode one file of `doc` in the job's format.
|
||||
fn encode_one(&self, doc: &Document) -> Result<Encoded> {
|
||||
let (cmd, f) = (self.plan.mode.command(), self.plan.format);
|
||||
let enc = if f.id == "ai" { encode_ai(cmd, f, doc, &self.params)? } else { encode_all(doc, f.id, &self.params)? };
|
||||
if enc.files.len() != 1 {
|
||||
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)?;
|
||||
if enc.files.len() != 1 && !per_artboard {
|
||||
return Err(bad(cmd, "Save writes one artboard: name one, or export several with document.export"));
|
||||
}
|
||||
Ok(enc)
|
||||
@@ -687,7 +722,7 @@ fn format_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.active();
|
||||
let id = str_param(p, "format").or(st.map(|d| d.format)).unwrap_or("vectorcraft");
|
||||
let f = format(id)
|
||||
.filter(|f| f.write || SAVE_FORMATS.contains(&f.id))
|
||||
.filter(|f| f.write || SAVE_FORMATS.contains(&f.id) || TEMPLATE_FORMATS.contains(&f.id))
|
||||
.ok_or_else(|| bad(C, format!("`{id}` is no writable format (see document.formats)")))?;
|
||||
let saved = st.filter(|d| d.format == f.id).map(|d| &d.save_options);
|
||||
// A native save compresses as Use Compression says, unless told otherwise.
|
||||
@@ -738,7 +773,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
|
||||
"Save as Template…",
|
||||
["File"],
|
||||
None,
|
||||
"{…as document.save} {path?, compress?, version?, preview?} a native template (.vctemplate) that opens as a new untitled document; the document is unchanged → {path, format, bytes, warnings}; no path → {dataBase64, format, name: \"<name> template.vctemplate\", folder: the Templates folder (preference templatesFolder), warnings}",
|
||||
"{…as document.save} {path?, format?: template (default) | ait (also when format is not given and the path ends in .ait), compress?, version?, preview?} a template that opens as a new untitled document: a native template (.vctemplate), or a PDF-compatible .ait written as a .ai file is (the .ai options and the PDF options, as document.save takes them for .ai); the document is unchanged → {path, format, bytes, warnings}; no path → {dataBase64, format, name: \"<name> template.<ext>\", folder: the Templates folder (preference templatesFolder), warnings}",
|
||||
has_doc,
|
||||
|s, p| save(s, SaveMode::Template, p)
|
||||
),
|
||||
@@ -765,7 +800,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
|
||||
"Format Options",
|
||||
[],
|
||||
None,
|
||||
"{format?: a writable format id (default: the document's own)} → {id, label, extensions, mime, read, write, raster, options: {name: {type, default, description, value}}, saveFormats: [{id, label, extensions}]}; value = the document's option as last saved in that format, else the default",
|
||||
"{format?: a writable format id or ait (default: the document's own)} → {id, label, extensions, mime, read, write, raster, options: {name: {type, default, description, value}}, saveFormats: [{id, label, extensions}]}; value = the document's option as last saved in that format, else the default",
|
||||
always,
|
||||
format_options
|
||||
),
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
//! File → Export → Export for Screens (`document.exportForScreens`): the chosen artboards (or the
|
||||
//! whole document, or the chosen assets of the Asset Export panel) in every format row at each
|
||||
//! row's size, into a folder and its sub-folders, or returned for download (each file, or one ZIP);
|
||||
//! the presets the dialog offers; and the settings the document remembers
|
||||
//! whole document, the chosen assets of the Asset Export panel, layers, or the selection) in every
|
||||
//! format row at each row's size, into a folder and its sub-folders, or returned for download (each
|
||||
//! file, or one ZIP); the presets the dialog offers; and the settings the document remembers
|
||||
//! (`document.exportSettings`), which the Asset Export panel shares ([`SHARED_KEYS`]).
|
||||
//!
|
||||
//! A batch export names every file from one pattern (`name`, see [`super::naming`]: the document's
|
||||
//! and the item's names, a counter, the date), and sorts the files into folders by row, by format,
|
||||
//! by item, or by item and format (`folders`).
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_doc::Document;
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
use super::super::*;
|
||||
use super::encode::single_artboard;
|
||||
use super::export::{isolated, without_artboards};
|
||||
use super::export::{isolated, selected_artwork, without_artboards};
|
||||
use super::naming::{self, NameParts};
|
||||
use super::{
|
||||
ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names, create_dir, encode, unique_file_names, with_black_output, writable, write_file,
|
||||
};
|
||||
@@ -161,6 +166,8 @@ struct Row {
|
||||
folder: String,
|
||||
/// The encoder options: the format's settings, then the row's own.
|
||||
options: Value,
|
||||
/// What makes two rows the same file: format, size, suffix and options.
|
||||
key: String,
|
||||
}
|
||||
|
||||
impl Row {
|
||||
@@ -169,8 +176,8 @@ impl Row {
|
||||
return Err(bad(C, "each format is an object {format, scale?, suffix?}"));
|
||||
}
|
||||
let format = writable(C, Some(str_param(row, "format").unwrap_or("png")), None)?;
|
||||
if matches!(format.id, "vectorcraft" | "template" | "ai" | "txt" | "dxf" | "eps" | "emf" | "wmf") {
|
||||
return Err(bad(C, "Export for Screens writes png, png8, jpg, webp, gif, svg, svgz or pdf"));
|
||||
if matches!(format.id, "template" | "txt" | "dxf" | "emf" | "wmf") {
|
||||
return Err(bad(C, "Export for Screens writes png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz, pdf, eps, ai or vectorcraft"));
|
||||
}
|
||||
// Vector formats have no pixel size: it doesn't apply and adds no @2x suffix (not even one
|
||||
// left over from a raster row switched to SVG or PDF).
|
||||
@@ -203,13 +210,21 @@ impl Row {
|
||||
let options = Value::Object(options);
|
||||
// A PDF preset (built-in or saved) becomes its settings.
|
||||
let options = if format.id == "pdf" { super::pdf::expand_preset(s, C, &options)?.into_owned() } else { options };
|
||||
Ok(Self { format, size, suffix, folder, options })
|
||||
// Two rows that would write the same bytes under the same name are one file.
|
||||
let key = format!("{}|{}|{suffix}|{options}", format.id, size.label());
|
||||
Ok(Self { format, size, suffix, folder, options, key })
|
||||
}
|
||||
|
||||
/// Formats that hold a whole document to edit (native, Illustrator, EPS): written from a
|
||||
/// document of the item alone, one artboard, rather than with an `artboard` option.
|
||||
fn whole_document(&self) -> bool {
|
||||
matches!(self.format.id, "vectorcraft" | "ai" | "eps")
|
||||
}
|
||||
|
||||
/// The encoder options for `region`, `artboard` of the document written.
|
||||
fn options_for(&self, region: Rect, artboard: Option<usize>) -> Result<Value> {
|
||||
let mut o = self.options.clone();
|
||||
if let Some(b) = artboard {
|
||||
if let Some(b) = artboard.filter(|_| !self.whole_document()) {
|
||||
o["artboard"] = json!(b);
|
||||
}
|
||||
if self.format.raster {
|
||||
@@ -247,6 +262,60 @@ struct ScreenParams {
|
||||
prefix: Option<String>,
|
||||
/// Asset ids (Asset Export) to write instead of artboards.
|
||||
assets: Option<Vec<u64>>,
|
||||
/// Layer ids to write instead of artboards: each layer's art alone.
|
||||
layers: Option<Vec<u64>>,
|
||||
/// The selection instead of artboards: `one` file of it all, or one for `each` object.
|
||||
selection: Option<String>,
|
||||
/// The file name pattern ([`naming`]); without it, `prefix` + name + suffix.
|
||||
name: Option<String>,
|
||||
/// The counter's first value (`####` in `name`).
|
||||
counter_start: Option<u64>,
|
||||
/// The counter counts `item`s (every file of an artboard has its number) or `file`s.
|
||||
counter_per: Option<String>,
|
||||
/// The date the pattern's date tokens use (`YYYY-MM-DD`); default today (UTC).
|
||||
date: Option<String>,
|
||||
/// Where files go: `none` (all in the folder), `rows` (the rows' sub-folders, as
|
||||
/// `subfolders`), `format` (a folder per extension), `item`, or `itemFormat`.
|
||||
folders: Option<String>,
|
||||
}
|
||||
|
||||
/// How exported files are sorted into folders.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Folders {
|
||||
None,
|
||||
Rows,
|
||||
Format,
|
||||
Item,
|
||||
ItemFormat,
|
||||
}
|
||||
|
||||
impl Folders {
|
||||
fn of(o: &ScreenParams) -> Result<Self> {
|
||||
Ok(match o.folders.as_deref() {
|
||||
None if o.subfolders || o.preset.is_some() => Self::Rows,
|
||||
None | Some("none" | "") => Self::None,
|
||||
Some("rows") => Self::Rows,
|
||||
Some("format") => Self::Format,
|
||||
Some("item") => Self::Item,
|
||||
Some("itemFormat") => Self::ItemFormat,
|
||||
Some(f) => return Err(bad(C, format!("folders `{f}`: none, rows, format, item or itemFormat"))),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// One thing a batch export writes a file of per row: an artboard, an asset, a layer, the
|
||||
/// selection or one selected object, or the whole document.
|
||||
struct Item {
|
||||
/// Its name as the user gave it (`<name>`).
|
||||
name: String,
|
||||
/// Its name made safe and unique among the items (the default file name, the item folder).
|
||||
stem: String,
|
||||
/// The document written: the whole document with `artboard`, or the item alone.
|
||||
doc: Arc<Document>,
|
||||
artboard: Option<usize>,
|
||||
region: Rect,
|
||||
/// Full Document: what a PDF row writes (every artboard of the document).
|
||||
every_artboard: Option<Arc<Document>>,
|
||||
}
|
||||
|
||||
impl ScreenParams {
|
||||
@@ -289,8 +358,9 @@ pub(crate) fn check_settings(s: &Session, p: &Value) -> Result<()> {
|
||||
/// The file names (and bytes) an export wrote, or the files it returns.
|
||||
#[derive(Default)]
|
||||
struct Output {
|
||||
/// Lower-case relative names already used (two identical rows write one file).
|
||||
taken: HashSet<String>,
|
||||
/// Lower-case relative names already used, with what wrote each (two identical rows write
|
||||
/// one file; different files that would share a name get `-2`, `-3`…).
|
||||
taken: std::collections::HashMap<String, String>,
|
||||
/// Sub-folders already made.
|
||||
made: HashSet<String>,
|
||||
/// `(relative name, bytes)` kept to return or zip (empty when written to a folder).
|
||||
@@ -300,9 +370,30 @@ struct Output {
|
||||
}
|
||||
|
||||
impl Output {
|
||||
/// Is `name` new (not written by an identical row already)?
|
||||
fn claim(&mut self, name: &str) -> bool {
|
||||
self.taken.insert(name.to_lowercase())
|
||||
/// The name the file `key` writes as `name` takes: `name`, else (with `number`) `name-2`,
|
||||
/// `name-3`… when another file has it; `None` when the same file (`key`) was written as `name`
|
||||
/// already, or, without `number`, when any file was (the first row wins, as Export for Screens
|
||||
/// always did when no name pattern is given).
|
||||
fn claim(&mut self, name: &str, key: &str, number: bool) -> Option<String> {
|
||||
let (base, ext) = name.rsplit_once('.').map_or((name, ""), |(b, e)| (b, e));
|
||||
for n in 1.. {
|
||||
let candidate = if n == 1 {
|
||||
name.to_string()
|
||||
} else if ext.is_empty() {
|
||||
format!("{base}-{n}")
|
||||
} else {
|
||||
format!("{base}-{n}.{ext}")
|
||||
};
|
||||
match self.taken.get(&candidate.to_lowercase()) {
|
||||
Some(k) if k == key || !number => return None,
|
||||
Some(_) => continue,
|
||||
None => {
|
||||
self.taken.insert(candidate.to_lowercase(), key.to_string());
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Write `bytes` as `name` (relative) under `folder`, or keep them.
|
||||
@@ -324,32 +415,166 @@ impl Output {
|
||||
}
|
||||
}
|
||||
|
||||
/// File → Export for Screens: [`export`], with the params remembered in the document
|
||||
/// (`document.exportSettings`).
|
||||
/// File → Export for Screens: [`export`] (over the settings of `exportPreset`, when given), with
|
||||
/// the params remembered in the document (`document.exportSettings`).
|
||||
pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let r = export(s, p)?;
|
||||
remember(s, p);
|
||||
let p = with_preset(s, p)?;
|
||||
let r = export(s, &p)?;
|
||||
remember(s, &p);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Every chosen artboard in every format row, one file each (a PDF holds its artboard alone), or
|
||||
/// with `fullDocument` one file per row (a PDF of every artboard, else the bounds of all art), or
|
||||
/// with `assets` each asset's art alone, cropped to it. Artboards (assets) that share a name get
|
||||
/// `-2`, `-3`… instead of overwriting.
|
||||
// ---------- export presets ----------
|
||||
|
||||
/// A saved batch export: what to write and how to name and sort it, to use again
|
||||
/// (`document.exportPresets.*`, `exportPreset`). Kept with the preferences.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ScreenExportPreset {
|
||||
pub name: String,
|
||||
/// The [`PRESET_KEYS`] of `document.exportForScreens` params.
|
||||
pub settings: Map<String, Value>,
|
||||
}
|
||||
|
||||
/// What a preset keeps: the formats, their settings, the naming and the folders; not which art
|
||||
/// (it changes with the document) nor where to (unless given).
|
||||
pub const PRESET_KEYS: [&str; 13] = [
|
||||
"formats",
|
||||
"settings",
|
||||
"preset",
|
||||
"name",
|
||||
"prefix",
|
||||
"counterStart",
|
||||
"counterPer",
|
||||
"folders",
|
||||
"subfolders",
|
||||
"includeBleed",
|
||||
"antiAlias",
|
||||
"folder",
|
||||
"fullDocument",
|
||||
];
|
||||
|
||||
/// The built-in export presets: (name, settings).
|
||||
pub fn builtin_presets() -> Vec<ScreenExportPreset> {
|
||||
let marks = json!({"trim": true, "registration": true, "colorBars": true, "pageInfo": true});
|
||||
let preset = |name: &str, settings: Value| ScreenExportPreset { name: name.into(), settings: settings.as_object().cloned().unwrap_or_default() };
|
||||
vec![
|
||||
preset(
|
||||
"Logo Package",
|
||||
json!({
|
||||
"name": "<filename>-<name>",
|
||||
"folders": "format",
|
||||
"formats": [
|
||||
{"format": "svg"},
|
||||
{"format": "png", "scale": "1x", "suffix": ""},
|
||||
{"format": "png", "scale": "2x", "suffix": "@2x"},
|
||||
{"format": "pdf"},
|
||||
{"format": "pdf", "suffix": "-print", "marks": marks},
|
||||
],
|
||||
}),
|
||||
),
|
||||
preset("Print-Ready PDF", json!({"formats": [{"format": "pdf", "suffix": "-print", "marks": marks, "bleed": {"useDocument": true}}]})),
|
||||
]
|
||||
}
|
||||
|
||||
/// The preset called `name`: a saved one, else a built-in one.
|
||||
fn find_preset(s: &Session, name: &str) -> Option<ScreenExportPreset> {
|
||||
s.prefs.screen_export_presets.iter().find(|p| p.name == name).cloned().or_else(|| builtin_presets().into_iter().find(|p| p.name == name))
|
||||
}
|
||||
|
||||
/// `p` over the settings of its `exportPreset`, when it names one (its own keys win).
|
||||
fn with_preset(s: &Session, p: &Value) -> Result<Value> {
|
||||
let Some(name) = str_param(p, "exportPreset") else { return Ok(p.clone()) };
|
||||
let preset = find_preset(s, name).ok_or_else(|| bad(C, format!("no export preset `{name}` (see document.exportPresets.list)")))?;
|
||||
let mut merged = preset.settings;
|
||||
if let Some(o) = p.as_object() {
|
||||
merged.extend(o.iter().filter(|(k, _)| k.as_str() != "exportPreset").map(|(k, v)| (k.clone(), v.clone())));
|
||||
}
|
||||
Ok(Value::Object(merged))
|
||||
}
|
||||
|
||||
pub(super) fn presets_list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let row = |p: &ScreenExportPreset, built_in: bool| json!({"name": p.name, "builtIn": built_in, "settings": p.settings});
|
||||
let rows: Vec<Value> =
|
||||
builtin_presets().iter().map(|p| row(p, true)).chain(s.prefs.screen_export_presets.iter().map(|p| row(p, false))).collect();
|
||||
Ok(json!({ "presets": rows }))
|
||||
}
|
||||
|
||||
pub(super) fn presets_save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const SAVE: &str = "document.exportPresets.save";
|
||||
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(SAVE, "give the preset a name"))?.to_string();
|
||||
if builtin_presets().iter().any(|b| b.name == name) {
|
||||
return Err(bad(SAVE, format!("`{name}` is a built-in preset: save under another name")));
|
||||
}
|
||||
let given = p.get("settings").and_then(Value::as_object).ok_or_else(|| bad(SAVE, "settings is an object of document.exportForScreens params"))?;
|
||||
let settings: Map<String, Value> = given.iter().filter(|(k, _)| PRESET_KEYS.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone())).collect();
|
||||
// Refuse what an export would refuse, now rather than when the preset is used.
|
||||
check_settings(s, &Value::Object(settings.clone())).map_err(|e| bad(SAVE, e.to_string()))?;
|
||||
Folders::of(&ScreenParams::of(&Value::Object(settings.clone()))?).map_err(|e| bad(SAVE, e.to_string()))?;
|
||||
let preset = ScreenExportPreset { name: name.clone(), settings };
|
||||
let created = match s.prefs.screen_export_presets.iter_mut().find(|q| q.name == name) {
|
||||
Some(slot) => {
|
||||
*slot = preset.clone();
|
||||
false
|
||||
}
|
||||
None => {
|
||||
s.prefs.screen_export_presets.push(preset.clone());
|
||||
true
|
||||
}
|
||||
};
|
||||
Ok(json!({ "name": name, "created": created, "settings": preset.settings }))
|
||||
}
|
||||
|
||||
pub(super) fn presets_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const DELETE: &str = "document.exportPresets.delete";
|
||||
let name = str_param(p, "name").ok_or_else(|| bad(DELETE, "missing `name`"))?;
|
||||
if builtin_presets().iter().any(|b| b.name == name) {
|
||||
return Err(bad(DELETE, format!("`{name}` is built in and stays")));
|
||||
}
|
||||
let before = s.prefs.screen_export_presets.len();
|
||||
s.prefs.screen_export_presets.retain(|q| q.name != name);
|
||||
if s.prefs.screen_export_presets.len() == before {
|
||||
return Err(bad(DELETE, format!("no saved export preset `{name}`")));
|
||||
}
|
||||
Ok(json!({ "deleted": name }))
|
||||
}
|
||||
|
||||
/// Every chosen artboard (asset, layer, selected object) in every format row, one file each (a PDF
|
||||
/// holds its artboard alone), or with `fullDocument` one file per row (a PDF of every artboard,
|
||||
/// else the bounds of all art). Items that share a name get `-2`, `-3`… instead of overwriting, and
|
||||
/// so do different files the `name` pattern gives one name.
|
||||
pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let o = ScreenParams::of(p)?;
|
||||
let rows = rows_of(s, p, o.preset.as_deref())?;
|
||||
if o.full_document && o.assets.is_some() {
|
||||
return Err(bad(C, "give assets or fullDocument, not both"));
|
||||
let sources = [o.full_document, o.assets.is_some(), o.layers.is_some(), o.selection.is_some()].iter().filter(|b| **b).count();
|
||||
if sources > 1 {
|
||||
return Err(bad(C, "give one of assets, layers, selection or fullDocument"));
|
||||
}
|
||||
let subfolders = o.subfolders || o.preset.is_some();
|
||||
let folders = Folders::of(&o)?;
|
||||
// A prefix names files, not folders.
|
||||
let prefix = file_part(o.prefix.as_deref().unwrap_or(""));
|
||||
let folder = o.folder.as_deref().filter(|f| !f.is_empty());
|
||||
let when = match o.date.as_deref() {
|
||||
Some(d) => naming::parse_date(d).ok_or_else(|| bad(C, format!("date `{d}`: YYYY-MM-DD")))?,
|
||||
None => vectorcraft_doc::metadata::now_unix().map_or([1970, 1, 1, 0, 0, 0], vectorcraft_doc::metadata::civil),
|
||||
};
|
||||
let per_file = match o.counter_per.as_deref() {
|
||||
None | Some("item") => false,
|
||||
Some("file") => true,
|
||||
Some(c) => return Err(bad(C, format!("counterPer `{c}`: item or file"))),
|
||||
};
|
||||
let counter_start = o.counter_start.unwrap_or(1);
|
||||
let selection = match o.selection.as_deref() {
|
||||
None => None,
|
||||
Some("one") => Some(false),
|
||||
Some("each") => Some(true),
|
||||
Some(m) => return Err(bad(C, format!("selection `{m}`: one or each"))),
|
||||
};
|
||||
let selected = if selection.is_some() { selected_artwork(s)? } else { vec![] };
|
||||
if let Some(dir) = folder {
|
||||
create_dir(dir)?;
|
||||
}
|
||||
let doc = s.doc()?.doc.clone();
|
||||
let filename = safe_name(&super::file_stem(&doc.title)).unwrap_or_else(|| "Untitled".into());
|
||||
// Include Bleed: each artboard grown by the document's bleed.
|
||||
let doc = if o.include_bleed && doc.setup.has_bleed() {
|
||||
let mut d = (*doc).clone();
|
||||
@@ -360,79 +585,66 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} else {
|
||||
doc
|
||||
};
|
||||
let name_of = |r: &Row, stem: &str| {
|
||||
let file = format!("{prefix}{stem}{}.{}", r.suffix, r.format.extensions[0]);
|
||||
if subfolders { format!("{}/{file}", r.folder) } else { file }
|
||||
};
|
||||
let crop = rows.iter().any(|r| r.format.id != "pdf");
|
||||
let items = items(&doc, &o, selection, &selected, &filename, crop)?;
|
||||
let mut out = Output::default();
|
||||
// Appearance of Black › Printing / Exporting for RGB raster rows.
|
||||
let write = |doc: &Document, format: &str, options: &Value| encode(doc, format, &with_black_output(s, options));
|
||||
if o.full_document {
|
||||
let stem = safe_name(&super::file_stem(&doc.title)).unwrap_or_else(|| "Untitled".into());
|
||||
// The bounds of all visible art, as the one artboard of the files that aren't PDFs.
|
||||
let mut whole: Option<Document> = None;
|
||||
// A whole-document copy of an artboard, for the formats that write documents to edit.
|
||||
let mut board_docs: std::collections::HashMap<usize, Arc<Document>> = std::collections::HashMap::new();
|
||||
let mut files = 0u64;
|
||||
for (index, item) in items.iter().enumerate() {
|
||||
for r in &rows {
|
||||
let name = name_of(r, &stem);
|
||||
if !out.claim(&name) {
|
||||
continue;
|
||||
}
|
||||
let bytes = if r.format.id == "pdf" {
|
||||
let counter = counter_start.saturating_add(if per_file { files } else { index as u64 });
|
||||
let ext = r.format.extensions.first().copied().unwrap_or(r.format.id);
|
||||
let stem = match o.name.as_deref().filter(|n| !n.trim().is_empty()) {
|
||||
Some(pattern) => {
|
||||
let parts = NameParts {
|
||||
filename: &filename,
|
||||
name: &item.name,
|
||||
index: index + 1,
|
||||
counter,
|
||||
format: ext,
|
||||
scale: &if r.format.raster { r.size.label() } else { String::new() },
|
||||
suffix: &r.suffix,
|
||||
when,
|
||||
};
|
||||
let named = naming::render(pattern, &parts);
|
||||
let named = if named.is_empty() { item.stem.clone() } else { named };
|
||||
if naming::has_suffix_token(pattern) { format!("{prefix}{named}") } else { format!("{prefix}{named}{}", r.suffix) }
|
||||
}
|
||||
None => format!("{prefix}{}{}", item.stem, r.suffix),
|
||||
};
|
||||
let file = format!("{stem}.{ext}");
|
||||
let sub = match folders {
|
||||
Folders::None => None,
|
||||
Folders::Rows => Some(r.folder.clone()),
|
||||
Folders::Format => Some(ext.to_string()),
|
||||
Folders::Item => Some(item.stem.clone()),
|
||||
Folders::ItemFormat => Some(format!("{}/{ext}", item.stem)),
|
||||
};
|
||||
let name = sub.map_or_else(|| file.clone(), |d| format!("{d}/{file}"));
|
||||
let Some(name) = out.claim(&name, &format!("{}|{}", item.stem, r.key), o.name.is_some()) else { continue };
|
||||
let bytes = if let (Some(all), "pdf") = (&item.every_artboard, r.format.id) {
|
||||
// One page per artboard.
|
||||
let mut options = r.options.clone();
|
||||
options["range"] = json!("all");
|
||||
write(&doc, "pdf", &options)?
|
||||
write(all, "pdf", &options)?
|
||||
} else if r.whole_document() && item.artboard.is_some() {
|
||||
let b = item.artboard.unwrap_or(0);
|
||||
let d = board_docs.entry(b).or_insert_with(|| Arc::new(single_artboard(&item.doc, item.region, &item.name))).clone();
|
||||
write(&d, r.format.id, &r.options_for(item.region, None)?)?
|
||||
} else {
|
||||
if whole.is_none() {
|
||||
let bounds =
|
||||
vectorcraft_render::encode::art_bounds(&doc).ok_or_else(|| bad(C, "nothing to export: the document has no visible art"))?;
|
||||
whole = Some(single_artboard(&doc, bounds, "Document"));
|
||||
}
|
||||
let Some(d) = whole.as_ref() else { continue };
|
||||
let region = d.artboards.first().map_or(Rect::ZERO, |a| a.rect);
|
||||
write(d, r.format.id, &r.options_for(region, Some(0))?)?
|
||||
write(&item.doc, r.format.id, &r.options_for(item.region, item.artboard)?)?
|
||||
};
|
||||
out.put(folder, o.zip, name, bytes)?;
|
||||
}
|
||||
} else if let Some(ids) = &o.assets {
|
||||
let assets =
|
||||
ids.iter().map(|id| doc.asset(*id).ok_or_else(|| bad(C, format!("no asset {id} (see assets.list)")))).collect::<Result<Vec<_>>>()?;
|
||||
if assets.is_empty() {
|
||||
return Err(bad(C, "no assets to export"));
|
||||
}
|
||||
for (a, stem) in assets.iter().zip(unique_file_names(assets.iter().map(|a| (a.name.as_str(), "Asset".to_string())))) {
|
||||
let (d, bounds) = isolated(&doc, &doc.paint_order(a.nodes.iter().copied()), &a.name)
|
||||
.ok_or_else(|| bad(C, format!("asset `{}` has no art to export", a.name)))?;
|
||||
for r in &rows {
|
||||
let name = name_of(r, &stem);
|
||||
if !out.claim(&name) {
|
||||
continue;
|
||||
}
|
||||
let bytes = write(&d, r.format.id, &r.options_for(bounds, Some(0))?)?;
|
||||
out.put(folder, o.zip, name, bytes)?;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let n = doc.artboards.len();
|
||||
let boards = o.boards.resolve(n).map_err(|e| bad(C, e))?.unwrap_or_else(|| (0..n).collect());
|
||||
if boards.is_empty() {
|
||||
return Err(bad(C, "the document has no artboard"));
|
||||
}
|
||||
for (b, stem) in boards.iter().copied().zip(artboard_file_names(&doc, &boards)) {
|
||||
let region = doc.artboards.get(b).map_or(Rect::ZERO, |a| a.rect);
|
||||
for r in &rows {
|
||||
let name = name_of(r, &stem);
|
||||
if !out.claim(&name) {
|
||||
continue; // the same file from two identical rows
|
||||
}
|
||||
let bytes = write(&doc, r.format.id, &r.options_for(region, Some(b))?)?;
|
||||
out.put(folder, o.zip, name, bytes)?;
|
||||
}
|
||||
files += 1;
|
||||
}
|
||||
}
|
||||
let names: Vec<&str> = out.kept.iter().map(|(n, _)| n.as_str()).collect();
|
||||
if o.zip {
|
||||
let zip = super::zip::store(&out.kept).map_err(|e| bad(C, e))?;
|
||||
let zip_name = format!("{prefix}{}.zip", safe_name(&super::file_stem(&doc.title)).unwrap_or_else(|| "Untitled".into()));
|
||||
let zip_name = format!("{prefix}{filename}.zip");
|
||||
return match folder {
|
||||
Some(dir) => {
|
||||
let path = format!("{dir}/{zip_name}");
|
||||
@@ -450,6 +662,98 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// What `o` exports a file of per row ([`Item`]): the whole document, the assets, the layers, the
|
||||
/// selection (one item, or one per object), else the chosen artboards.
|
||||
/// `crop`: some row needs the Full Document's art bounds (a format other than PDF).
|
||||
fn items(doc: &Arc<Document>, o: &ScreenParams, selection: Option<bool>, selected: &[NodeId], filename: &str, crop: bool) -> Result<Vec<Item>> {
|
||||
// The art of `ids` alone, as an item named `name`.
|
||||
let alone = |ids: &[NodeId], name: &str| -> Option<(Arc<Document>, Rect)> {
|
||||
isolated(doc, &doc.paint_order(ids.iter().copied()), name).map(|(d, r)| (Arc::new(d), r))
|
||||
};
|
||||
let named = |pieces: Vec<(String, Arc<Document>, Option<usize>, Rect)>, fallback: &str| -> Vec<Item> {
|
||||
let stems = unique_file_names(pieces.iter().map(|(n, ..)| (n.as_str(), fallback.to_string())));
|
||||
pieces
|
||||
.into_iter()
|
||||
.zip(stems)
|
||||
.map(|((name, doc, artboard, region), stem)| Item { name, stem, doc, artboard, region, every_artboard: None })
|
||||
.collect()
|
||||
};
|
||||
if o.full_document {
|
||||
// The bounds of all visible art, as the one artboard of the files that aren't PDFs (a PDF
|
||||
// row writes every artboard of the document itself).
|
||||
let (whole, region) = if crop {
|
||||
let bounds = vectorcraft_render::encode::art_bounds(doc).ok_or_else(|| bad(C, "nothing to export: the document has no visible art"))?;
|
||||
(Arc::new(single_artboard(doc, bounds, "Document")), bounds)
|
||||
} else {
|
||||
(doc.clone(), Rect::ZERO)
|
||||
};
|
||||
let name = filename.to_string();
|
||||
return Ok(vec![Item { name: name.clone(), stem: name, doc: whole, artboard: Some(0), region, every_artboard: Some(doc.clone()) }]);
|
||||
}
|
||||
if let Some(ids) = &o.assets {
|
||||
let mut pieces = vec![];
|
||||
for id in ids {
|
||||
let a = doc.asset(*id).ok_or_else(|| bad(C, format!("no asset {id} (see assets.list)")))?;
|
||||
let (d, r) = alone(&a.nodes, &a.name).ok_or_else(|| bad(C, format!("asset `{}` has no art to export", a.name)))?;
|
||||
pieces.push((a.name.clone(), d, Some(0), r));
|
||||
}
|
||||
if pieces.is_empty() {
|
||||
return Err(bad(C, "no assets to export"));
|
||||
}
|
||||
return Ok(named(pieces, "Asset"));
|
||||
}
|
||||
if let Some(ids) = &o.layers {
|
||||
let mut pieces = vec![];
|
||||
for id in ids {
|
||||
let layer = doc.node(NodeId(*id)).filter(|n| n.is_layer()).ok_or_else(|| bad(C, format!("no layer {id} (see layer.list)")))?;
|
||||
let mut art = vec![];
|
||||
crate::cmd::assets::art_under(doc, layer.id, &mut art);
|
||||
let name = layer.display_name();
|
||||
let (d, r) = alone(&art, &name).ok_or_else(|| bad(C, format!("layer `{name}` has no art to export")))?;
|
||||
pieces.push((name, d, Some(0), r));
|
||||
}
|
||||
if pieces.is_empty() {
|
||||
return Err(bad(C, "no layers to export"));
|
||||
}
|
||||
return Ok(named(pieces, "Layer"));
|
||||
}
|
||||
if let Some(each) = selection {
|
||||
if selected.is_empty() {
|
||||
return Err(bad(C, "select something to export"));
|
||||
}
|
||||
let mut pieces = vec![];
|
||||
if each {
|
||||
for id in selected {
|
||||
let name = doc.node(*id).map(|n| n.display_name()).filter(|n| !n.is_empty()).unwrap_or_else(|| "Selection".into());
|
||||
if let Some((d, r)) = alone(&[*id], &name) {
|
||||
pieces.push((name, d, Some(0), r));
|
||||
}
|
||||
}
|
||||
} else if let Some((d, r)) = alone(selected, "Selection") {
|
||||
pieces.push(("Selection".to_string(), d, Some(0), r));
|
||||
}
|
||||
if pieces.is_empty() {
|
||||
return Err(bad(C, "the selection has no art to export"));
|
||||
}
|
||||
return Ok(named(pieces, "Selection"));
|
||||
}
|
||||
let n = doc.artboards.len();
|
||||
let boards = o.boards.resolve(n).map_err(|e| bad(C, e))?.unwrap_or_else(|| (0..n).collect());
|
||||
if boards.is_empty() {
|
||||
return Err(bad(C, "the document has no artboard"));
|
||||
}
|
||||
let stems = artboard_file_names(doc, &boards);
|
||||
Ok(boards
|
||||
.into_iter()
|
||||
.zip(stems)
|
||||
.map(|(b, stem)| {
|
||||
let a = doc.artboards.get(b);
|
||||
let name = a.map(|a| a.name.clone()).filter(|n| !n.is_empty()).unwrap_or_else(|| stem.clone());
|
||||
Item { name, stem, doc: doc.clone(), artboard: Some(b), region: a.map_or(Rect::ZERO, |a| a.rect), every_artboard: None }
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Keep the params of a successful export in the document (without `zip`, a delivery detail, and
|
||||
/// `assets`, a choice of art), so the dialog reopens on them.
|
||||
fn remember(s: &mut Session, p: &Value) {
|
||||
|
||||
@@ -159,8 +159,8 @@ fn open_exts_cover_every_readable_format() {
|
||||
assert_eq!(filters[0], ("All readable files", OPEN_EXTS));
|
||||
assert_eq!(
|
||||
filters.len(),
|
||||
8 + FORMATS.iter().filter(|f| f.read).count(),
|
||||
"and swatch libraries, libraries, flattener, PDF and print presets, scripts, plug-ins"
|
||||
9 + FORMATS.iter().filter(|f| f.read).count(),
|
||||
"and swatch libraries, libraries, flattener, PDF and print presets, shortcut sets, scripts, plug-ins"
|
||||
);
|
||||
assert!(filters.contains(&("Scripts", crate::cmd::scripts::EXTS)), "File › Open checks scripts");
|
||||
assert_eq!(filters.last(), Some(&("Plug-ins", crate::cmd::plugin::EXTS)), "File › Open installs plug-ins");
|
||||
@@ -236,6 +236,36 @@ fn formats_query_lists_readers_writers_and_options() {
|
||||
assert_eq!(r["openExtensions"].as_array().unwrap().len(), OPEN_EXTS.len());
|
||||
}
|
||||
|
||||
/// The format ids `command`'s params text lists for `format` (`a|b (default)|c (…)`, up to the
|
||||
/// first comma), sorted.
|
||||
fn listed_formats(command: &str) -> Vec<String> {
|
||||
let params = crate::cmd::find_command(command).unwrap().params;
|
||||
let (_, rest) = params.split_once("format?: ").unwrap_or_else(|| panic!("{command} lists no formats"));
|
||||
let list = rest.split(',').next().unwrap_or_default();
|
||||
let mut ids: Vec<String> = list.split('|').filter_map(|f| f.split_whitespace().next()).map(str::to_string).collect();
|
||||
ids.sort();
|
||||
ids
|
||||
}
|
||||
|
||||
/// The params texts of the commands that take any writable format list exactly the formats each
|
||||
/// command writes (.ai among them).
|
||||
#[test]
|
||||
fn export_params_list_every_format_the_command_writes() {
|
||||
let mut s = session(100.0, 80.0, 1);
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
for command in ["document.serialize", "document.export", "document.exportSelection"] {
|
||||
let mut refused = vec![];
|
||||
let mut written: Vec<String> = FORMATS
|
||||
.iter()
|
||||
.filter(|f| s.execute(command, &json!({"format": f.id})).map_err(|e| refused.push(format!("{}: {e}", f.id))).is_ok())
|
||||
.map(|f| f.id.to_string())
|
||||
.collect();
|
||||
written.sort();
|
||||
assert_eq!(listed_formats(command), written, "{command}; refused: {refused:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ranges_parse_one_based() {
|
||||
assert_eq!(parse_range("1-3, 5", 5).unwrap(), [0, 1, 2, 4]);
|
||||
@@ -348,7 +378,9 @@ fn export_for_screens_one_page_per_pdf_unique_names() {
|
||||
let r = s.execute("document.exportForScreens", &json!({"range": "3", "formats": [{"format": "jpg", "scale": 0.5}]})).unwrap();
|
||||
assert_eq!(r["files"][0]["name"], "[email protected]");
|
||||
assert_eq!(image::load_from_memory(&b64(&r["files"][0])).unwrap().width(), 50);
|
||||
assert!(s.execute("document.exportForScreens", &json!({"formats": [{"format": "vectorcraft"}]})).is_err());
|
||||
// A native row: each artboard as a document of its own to edit.
|
||||
let r = s.execute("document.exportForScreens", &json!({"range": "1", "formats": [{"format": "vectorcraft"}]})).unwrap();
|
||||
assert!(r["files"][0]["name"].as_str().unwrap().ends_with(".vectorcraft"), "{r}");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +82,27 @@ fn an_illustrator_ai_file_opens_from_its_editing_data() {
|
||||
assert_ne!(names(&s.doc().unwrap().doc.layers), ["Art", "Hidden"]);
|
||||
}
|
||||
|
||||
/// A 1-bit RGB image opens from the editing data in the colors its PDF part shows.
|
||||
#[test]
|
||||
fn a_one_bit_rgb_image_opens_from_the_editing_data_in_its_colors() {
|
||||
// 3 × 1: red, green, blue (100 010 001), as hex samples in both parts.
|
||||
let raster = "%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n/DeviceRGB XN\n[ 60 0 0 60 10 90 ] 3 1 0 Xh\n\
|
||||
[ 60 0 0 60 10 90 ] 0 0 3 1 3 1 1 3 0 0 0 0 4 4 0 0\n%%BeginData: 6\rXI\n8880\n%%EndData\r\nXH\r\n%AI5_EndRaster\r\nN\r\n";
|
||||
let data = ai::editing_data(200.0, 100.0, &ai::layer("Art", raster));
|
||||
let page = "q 180 0 0 60 10 30 cm BI /W 3 /H 1 /CS /RGB /BPC 1 /F /AHx ID 8880> EI Q";
|
||||
let file = ai::ai(&compressed(data.as_bytes()), page);
|
||||
let mut s = Session::new();
|
||||
let r = open(&mut s, "bits.ai", &file, json!({})).unwrap();
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert_eq!(names(&doc.layers), ["Art"], "{r}");
|
||||
let mut found = vec![];
|
||||
doc.visit_images(|_, im| {
|
||||
let img = image::load_from_memory(&doc.images[&im.key].bytes).unwrap().to_rgb8();
|
||||
found.push(img.pixels().map(|p| p.0).collect::<Vec<_>>());
|
||||
});
|
||||
assert_eq!(found, [[[255, 0, 0], [0, 255, 0], [0, 0, 255]]], "{r}");
|
||||
}
|
||||
|
||||
/// A PDF saved with the editing data opens from it, as a `.ai` does (#868).
|
||||
#[test]
|
||||
fn a_pdf_saved_with_the_editing_data_opens_from_it() {
|
||||
@@ -220,6 +241,36 @@ fn editing_data_false_opens_only_what_the_page_prints() {
|
||||
assert!(e.contains("placeholder"), "{e}");
|
||||
}
|
||||
|
||||
/// A CMYK image opens with the same ink amounts from a `.ai` file's editing data as from its PDF
|
||||
/// part.
|
||||
#[test]
|
||||
fn a_cmyk_image_keeps_its_inks_from_the_editing_data_as_from_the_pdf_part() {
|
||||
// 3 × 1: cyan, 100% black, then a mix, as hex samples in both parts.
|
||||
let inks = [255u8, 0, 0, 0, 0, 0, 0, 255, 12, 34, 56, 78];
|
||||
let hex: String = inks.iter().map(|b| format!("{b:02x}")).collect();
|
||||
let raster = format!(
|
||||
"%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n/DeviceCMYK XN\n[ 10 0 0 10 10 60 ] 3 1 0 Xh\n\
|
||||
[ 10 0 0 10 10 60 ] 0 0 3 1 3 1 8 4 0 0 0 0 4 4 0 0\n%%BeginData: {}\rXI\n{hex}\n%%EndData\r\nXH\r\n%AI5_EndRaster\r\nN\r\n",
|
||||
hex.len() + 2
|
||||
);
|
||||
let data = ai::editing_data(200.0, 100.0, &ai::layer("Art", &raster));
|
||||
let page = format!("q 30 0 0 10 10 50 cm BI /W 3 /H 1 /CS /CMYK /BPC 8 /F /AHx ID {hex}> EI Q");
|
||||
let file = ai::ai(&compressed(data.as_bytes()), &page);
|
||||
let opened = |s: &Session| {
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let mut found = vec![];
|
||||
doc.visit_images(|_, im| found.push(doc.images[&im.key].cmyk().map(|i| i.data)));
|
||||
found
|
||||
};
|
||||
let mut s = Session::new();
|
||||
let r = open(&mut s, "cmyk.ai", &file, json!({})).unwrap();
|
||||
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "{r}");
|
||||
assert_eq!(opened(&s), [Some(inks.to_vec())], "from the editing data");
|
||||
let r = open(&mut s, "cmyk.ai", &file, json!({"editingData": false})).unwrap();
|
||||
assert_ne!(names(&s.doc().unwrap().doc.layers), ["Art"], "{r}");
|
||||
assert_eq!(opened(&s), [Some(inks.to_vec())], "from the PDF part");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_to_artwork_bounds_leaves_out_the_hidden_art_and_guides_the_editing_data_has() {
|
||||
// A shown square; a hidden one and a long guide far from it, which the page doesn't draw.
|
||||
@@ -252,6 +303,30 @@ fn type_asked_for_as_outlines_opens_a_file_whose_type_shows_from_its_pdf_part()
|
||||
assert_ne!(names(&s.doc().unwrap().doc.layers), ["Art"], "{r}");
|
||||
}
|
||||
|
||||
/// A `.ai` saved in units of 10 points: its PDF part's page is 200 × 100 with `/UserUnit 10`, and
|
||||
/// its editing data has the artboard and a red rectangle in the same units. It opens at 2000 × 1000
|
||||
/// pt, from its editing data and from its PDF part alone, with the rectangle ten times as large,
|
||||
/// and `document.pdfInfo` gives the page in points (#1482).
|
||||
#[test]
|
||||
fn an_ai_file_with_a_user_unit_opens_at_its_size_in_points() {
|
||||
let data = ai::editing_data(200.0, 100.0, &ai::layer("Art", &format!("0 1 1 0 1 0 0 Xa\n{}f\n", ai::rect(20.0, 20.0, 60.0, 50.0))));
|
||||
let file = ai::ai_with(&compressed(data.as_bytes()), "1 0 0 rg 20 20 40 30 re f", "/UserUnit 10");
|
||||
let near = |r: vectorcraft_geom::Rect, want: [f64; 4]| [r.x0, r.y0, r.x1, r.y1].iter().zip(want).all(|(a, b)| (a - b).abs() < 0.01);
|
||||
let mut s = Session::new();
|
||||
for (params, layers) in [(json!({}), ["Art"]), (json!({"editingData": false}), ["Page 1"])] {
|
||||
let r = open(&mut s, "large.ai", &file, params.clone()).unwrap();
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert_eq!(names(&doc.layers), layers, "{params}: {r}");
|
||||
assert!(near(doc.artboards[0].rect, [0.0, 0.0, 2000.0, 1000.0]), "{params}: {:?}", doc.artboards[0].rect);
|
||||
let art = doc.art_bounds().unwrap();
|
||||
assert!(near(art, [200.0, 500.0, 600.0, 800.0]), "{params}: {art:?}");
|
||||
}
|
||||
let v = s.execute("document.pdfInfo", &json!({"dataBase64": vectorcraft_format::base64_encode(&file)})).unwrap();
|
||||
let page = &v["pageInfo"][0];
|
||||
assert_eq!((page["width"].as_f64(), page["height"].as_f64()), (Some(2000.0), Some(1000.0)), "{v}");
|
||||
assert_eq!(page["boxes"]["media"], json!([0.0, 0.0, 2000.0, 1000.0]), "{v}");
|
||||
}
|
||||
|
||||
/// A `.ai` of a 200 × 100 pt artboard with a 10 mm bleed: its PDF part's page is the artboard
|
||||
/// (its trim box) grown by the bleed, and a red square reaches 20 pt into the bleed, on the page
|
||||
/// as in the editing data. `key`: `AIPrivateData`, or `AIPDFPrivateData` for a PDF saved with its
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
//! Batch exports through Export for Screens (#1935): a file name pattern (the document's and the
|
||||
//! item's names, a counter, the date), folders by format, by item or both, layers and the selection
|
||||
//! as what is exported, and document formats (VectorCraft, Illustrator, EPS) as rows.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn b64(v: &Value) -> Vec<u8> {
|
||||
vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap()
|
||||
}
|
||||
|
||||
/// A document of `artboards` 40×30 pt artboards side by side with a 20 pt square on the first.
|
||||
fn session(artboards: usize) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 40, "height": 30, "artboards": artboards})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 5, "y": 5, "width": 20, "height": 20})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn export(s: &mut Session, p: Value) -> Value {
|
||||
s.execute("document.exportForScreens", &p).unwrap()
|
||||
}
|
||||
|
||||
fn files(r: &Value) -> Vec<(String, Vec<u8>)> {
|
||||
r["files"].as_array().unwrap().iter().map(|f| (f["name"].as_str().unwrap().to_string(), b64(f))).collect()
|
||||
}
|
||||
|
||||
fn names(r: &Value) -> Vec<String> {
|
||||
files(r).into_iter().map(|(n, _)| n).collect()
|
||||
}
|
||||
|
||||
/// The request in #1935: SVG, PNG, PDF and PDF with printer's marks for artboards 1–3, named
|
||||
/// `COMPANY-Logo-<filename>-####-YYYYMMDD`, sorted into a folder per extension.
|
||||
#[test]
|
||||
fn the_logo_package_of_the_request() {
|
||||
let mut s = session(4);
|
||||
let r = export(
|
||||
&mut s,
|
||||
json!({
|
||||
"range": "1-3",
|
||||
"name": "COMPANY-Logo-<filename>-####-YYYYMMDD.ext",
|
||||
"date": "2026-10-16",
|
||||
"folders": "format",
|
||||
"formats": [
|
||||
{"format": "svg"},
|
||||
{"format": "png", "scale": "2x", "suffix": ""},
|
||||
{"format": "pdf"},
|
||||
{"format": "pdf", "suffix": "-print", "marks": {"trim": true, "registration": true, "colorBars": true, "pageInfo": true}}
|
||||
]
|
||||
}),
|
||||
);
|
||||
let n = names(&r);
|
||||
assert_eq!(n.len(), 12, "{n:?}");
|
||||
for (i, number) in ["0001", "0002", "0003"].iter().enumerate() {
|
||||
let stem = format!("COMPANY-Logo-Untitled-1-{number}-20261016");
|
||||
for want in [format!("svg/{stem}.svg"), format!("png/{stem}.png"), format!("pdf/{stem}.pdf"), format!("pdf/{stem}-print.pdf")] {
|
||||
assert!(n.contains(&want), "artboard {}: {want} in {n:?}", i + 1);
|
||||
}
|
||||
}
|
||||
// The printed PDF has room for its marks: a bigger page than the plain one.
|
||||
let f = files(&r);
|
||||
let page = |name: &str| {
|
||||
let bytes = &f.iter().find(|(n, _)| n == name).unwrap().1;
|
||||
let d = vectorcraft_pdf::import(bytes).unwrap();
|
||||
let a = d.artboards.first().unwrap().rect;
|
||||
(a.width(), a.height())
|
||||
};
|
||||
let plain = page("pdf/COMPANY-Logo-Untitled-1-0001-20261016.pdf");
|
||||
let print = page("pdf/COMPANY-Logo-Untitled-1-0001-20261016-print.pdf");
|
||||
assert!(print.0 > plain.0 && print.1 > plain.1, "{plain:?} vs {print:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folders_by_item_and_by_item_and_format() {
|
||||
let mut s = session(2);
|
||||
let rows = json!([{"format": "png"}, {"format": "svg"}]);
|
||||
let by_item = names(&export(&mut s, json!({"folders": "item", "formats": rows})));
|
||||
assert_eq!(by_item, ["Artboard-1/Artboard-1.png", "Artboard-1/Artboard-1.svg", "Artboard-2/Artboard-2.png", "Artboard-2/Artboard-2.svg"]);
|
||||
let both = names(&export(&mut s, json!({"folders": "itemFormat", "formats": rows})));
|
||||
assert_eq!(both[0], "Artboard-1/png/Artboard-1.png");
|
||||
let flat = names(&export(&mut s, json!({"folders": "none", "subfolders": true, "formats": rows})));
|
||||
assert_eq!(flat[0], "Artboard-1.png", "folders wins over subfolders");
|
||||
// Without folders, subfolders keeps its meaning (a folder per row).
|
||||
let rows_mode = names(&export(&mut s, json!({"subfolders": true, "formats": rows})));
|
||||
assert_eq!(rows_mode[0], "1x/Artboard-1.png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_counter_counts_items_or_files_from_any_start() {
|
||||
let mut s = session(2);
|
||||
let rows = json!([{"format": "png"}, {"format": "svg"}]);
|
||||
let items = names(&export(&mut s, json!({"name": "logo-###", "counterStart": 9, "formats": rows})));
|
||||
assert_eq!(items, ["logo-009.png", "logo-009.svg", "logo-010.png", "logo-010.svg"]);
|
||||
let per_file = names(&export(&mut s, json!({"name": "logo-#", "counterPer": "file", "formats": rows})));
|
||||
assert_eq!(per_file, ["logo-1.png", "logo-2.svg", "logo-3.png", "logo-4.svg"]);
|
||||
// A pattern that names different files alike numbers them instead of dropping one.
|
||||
let same = names(&export(&mut s, json!({"name": "logo", "formats": [{"format": "png"}]})));
|
||||
assert_eq!(same, ["logo.png", "logo-2.png"]);
|
||||
// The item's name, its place and the row's size and suffix.
|
||||
let parts = names(&export(
|
||||
&mut s,
|
||||
json!({"name": "<index>_<name>_<scale><suffix>_<format>", "range": "2", "formats": [{"format": "png", "scale": "2x"}]}),
|
||||
));
|
||||
assert_eq!(parts, ["1_Artboard 2_2x@2x_png.png"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_and_the_selection_are_exported_by_name() {
|
||||
let mut s = session(1);
|
||||
let first_layer = s.doc().unwrap().doc.layers[0].id.0;
|
||||
let top = s.execute("layer.new", &json!({"name": "Dark Mode"})).unwrap()["id"].as_u64().unwrap();
|
||||
let circle = s.execute("shape.ellipse", &json!({"x": 2, "y": 2, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
|
||||
let r = export(&mut s, json!({"layers": [first_layer, top], "formats": [{"format": "svg"}]}));
|
||||
assert_eq!(names(&r), ["Layer-1.svg", "Dark-Mode.svg"]);
|
||||
// The selection: one file of it all, or a file per object.
|
||||
let square = s.doc().unwrap().doc.layers[0].children().unwrap()[0].id.0;
|
||||
s.execute("select.set", &json!({"ids": [square, circle]})).unwrap();
|
||||
assert_eq!(names(&export(&mut s, json!({"selection": "one", "formats": [{"format": "png"}]}))), ["Selection.png"]);
|
||||
let each = names(&export(&mut s, json!({"selection": "each", "formats": [{"format": "png"}]})));
|
||||
assert_eq!(each.len(), 2, "{each:?}");
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
assert!(s.execute("document.exportForScreens", &json!({"selection": "one"})).is_err(), "nothing selected");
|
||||
}
|
||||
|
||||
/// Document formats write each artboard as a document of its own: one artboard, its art.
|
||||
#[test]
|
||||
fn document_formats_write_one_artboard_each() {
|
||||
let mut s = session(2);
|
||||
let r = export(&mut s, json!({"formats": [{"format": "vectorcraft"}, {"format": "ai"}, {"format": "eps"}]}));
|
||||
let n = names(&r);
|
||||
assert_eq!(n, ["Artboard-1.vectorcraft", "Artboard-1.ai", "Artboard-1.eps", "Artboard-2.vectorcraft", "Artboard-2.ai", "Artboard-2.eps"]);
|
||||
let native = &files(&r)[0].1;
|
||||
let mut t = Session::new();
|
||||
t.execute("document.open", &json!({"name": "a.vectorcraft", "dataBase64": vectorcraft_format::base64_encode(native)})).unwrap();
|
||||
assert_eq!(t.doc().unwrap().doc.artboards.len(), 1, "that artboard alone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_batch_params_are_refused() {
|
||||
let mut s = session(1);
|
||||
for bad in [
|
||||
json!({"folders": "byColour"}),
|
||||
json!({"counterPer": "page"}),
|
||||
json!({"date": "16/10/2026"}),
|
||||
json!({"selection": "some"}),
|
||||
json!({"selection": "one", "fullDocument": true}),
|
||||
json!({"layers": [99999]}),
|
||||
json!({"layers": [1], "assets": [1]}),
|
||||
] {
|
||||
assert!(s.execute("document.exportForScreens", &bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Export presets: the built-in ones, saving (only what a preset keeps), using one under other
|
||||
/// params, replacing, deleting, and keeping them with the preferences.
|
||||
#[test]
|
||||
fn export_presets_save_use_and_delete() {
|
||||
let mut s = session(2);
|
||||
let list = s.execute("document.exportPresets.list", &json!({})).unwrap();
|
||||
let built_in: Vec<&str> =
|
||||
list["presets"].as_array().unwrap().iter().filter(|p| p["builtIn"] == true).map(|p| p["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(built_in, ["Logo Package", "Print-Ready PDF"]);
|
||||
// The Logo Package: SVG, PNG 1x and 2x, PDF and PDF with marks, a folder per format.
|
||||
let r = export(&mut s, json!({"exportPreset": "Logo Package", "range": "1"}));
|
||||
assert_eq!(
|
||||
names(&r),
|
||||
[
|
||||
"svg/Untitled-1-Artboard 1.svg",
|
||||
"png/Untitled-1-Artboard 1.png",
|
||||
"png/Untitled-1-Artboard [email protected]",
|
||||
"pdf/Untitled-1-Artboard 1.pdf",
|
||||
"pdf/Untitled-1-Artboard 1-print.pdf"
|
||||
]
|
||||
);
|
||||
|
||||
let settings = json!({"name": "ACME-<name>-##", "folders": "item", "formats": [{"format": "jpg", "quality": 80}], "range": "2", "assets": [1]});
|
||||
let saved = s.execute("document.exportPresets.save", &json!({"name": "Web JPG", "settings": settings})).unwrap();
|
||||
assert_eq!(saved["created"], true);
|
||||
assert!(saved["settings"].get("range").is_none() && saved["settings"].get("assets").is_none(), "which art is not kept: {saved}");
|
||||
let r = export(&mut s, json!({"exportPreset": "Web JPG", "range": "2"}));
|
||||
assert_eq!(names(&r), ["Artboard-2/ACME-Artboard 2-01.jpg"]);
|
||||
// The call's own params win over the preset's.
|
||||
let r = export(&mut s, json!({"exportPreset": "Web JPG", "range": "2", "folders": "none"}));
|
||||
assert_eq!(names(&r), ["ACME-Artboard 2-01.jpg"]);
|
||||
// Saving again replaces it; the preferences keep it.
|
||||
let again = s.execute("document.exportPresets.save", &json!({"name": "Web JPG", "settings": {"formats": [{"format": "png"}]}})).unwrap();
|
||||
assert_eq!(again["created"], false);
|
||||
let prefs = serde_json::to_value(&s.prefs).unwrap();
|
||||
assert_eq!(prefs["screenExportPresets"][0]["name"], "Web JPG");
|
||||
s.execute("document.exportPresets.delete", &json!({"name": "Web JPG"})).unwrap();
|
||||
assert!(s.execute("document.exportForScreens", &json!({"exportPreset": "Web JPG"})).is_err());
|
||||
// Built-in names stay; bad settings are refused when saved.
|
||||
for bad in [
|
||||
("document.exportPresets.save", json!({"name": "Logo Package", "settings": {}})),
|
||||
("document.exportPresets.save", json!({"name": "", "settings": {}})),
|
||||
("document.exportPresets.save", json!({"name": "X", "settings": {"formats": [{"format": "txt"}]}})),
|
||||
("document.exportPresets.save", json!({"name": "X", "settings": {"folders": "sideways"}})),
|
||||
("document.exportPresets.save", json!({"name": "X"})),
|
||||
("document.exportPresets.delete", json!({"name": "Logo Package"})),
|
||||
("document.exportPresets.delete", json!({"name": "Never saved"})),
|
||||
] {
|
||||
assert!(s.execute(bad.0, &bad.1).is_err(), "{bad:?}");
|
||||
}
|
||||
}
|
||||
@@ -171,7 +171,9 @@ fn bad_eps_options_are_refused_and_formats_list_eps() {
|
||||
let e = s.execute("document.exportEps", &p).unwrap_err().to_string();
|
||||
assert!(e.contains("EPS"), "{p}: {e}");
|
||||
}
|
||||
assert!(s.execute("document.exportForScreens", &json!({"formats": [{"format": "eps"}]})).is_err());
|
||||
// Export for Screens writes an EPS per artboard (a document of that artboard alone).
|
||||
let r = s.execute("document.exportForScreens", &json!({"formats": [{"format": "eps"}]})).unwrap();
|
||||
assert!(r["files"][0]["name"].as_str().unwrap().ends_with(".eps"), "{r}");
|
||||
let r = s.execute("document.formats", &json!({})).unwrap();
|
||||
let eps = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "eps").unwrap().clone();
|
||||
// Readable too since EPS import (tests_epsimport.rs).
|
||||
|
||||
@@ -65,6 +65,34 @@ fn other_eps_files_are_read_by_the_postscript_interpreter() {
|
||||
assert_eq!(s.doc().unwrap().title(), "plain.eps");
|
||||
}
|
||||
|
||||
/// A CMYK image the PostScript interpreter reads keeps its ink amounts when the EPS opens and when
|
||||
/// it is placed, with its transparent pixel as its opacity mask.
|
||||
#[test]
|
||||
fn cmyk_images_keep_their_inks_when_an_eps_opens_or_is_placed() {
|
||||
let eps = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 60 20\n%%EndComments\n/DeviceCMYK setcolorspace 60 20 scale \
|
||||
<< /ImageType 4 /Width 2 /Height 1 /BitsPerComponent 8 /Decode [0 1 0 1 0 1 0 1] /ImageMatrix [2 0 0 -1 0 1] \
|
||||
/MaskColor [10 20 30 40] /DataSource <0a141e28 00667f08> >> image\nshowpage\n%%EOF\n";
|
||||
// Each image object's ink amounts (`None` when it isn't CMYK) and whether it has a mask.
|
||||
let images = |s: &Session| {
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let mut found = vec![];
|
||||
d.walk(|n| {
|
||||
if let vectorcraft_doc::NodeKind::Image(im) = &n.kind {
|
||||
found.push((d.images[&im.key].cmyk().map(|i| i.data), n.extras().mask.is_some()));
|
||||
}
|
||||
});
|
||||
found
|
||||
};
|
||||
let want = (Some(vec![10, 20, 30, 40, 0, 102, 127, 8]), true);
|
||||
let mut s = Session::new();
|
||||
let r = open(&mut s, "cmyk.eps", eps).unwrap();
|
||||
assert_eq!(r["format"], "eps", "{r}");
|
||||
assert_eq!(images(&s), std::slice::from_ref(&want));
|
||||
let r = s.execute("file.place", &json!({"name": "cmyk.eps", "dataBase64": vectorcraft_format::base64_encode(eps)})).unwrap();
|
||||
assert_eq!(r["ids"].as_array().unwrap().len(), 1, "{r}");
|
||||
assert_eq!(images(&s), [want.clone(), want]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unreadable_eps_file_comes_in_as_its_preview() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -139,6 +139,27 @@ fn exports_write_ai_as_save_as_does() {
|
||||
assert!(b64(&r).starts_with(b"%PDF"));
|
||||
}
|
||||
|
||||
/// A `.ai` with an artboard larger than 14,400 pt has its PDF page written with a `UserUnit`,
|
||||
/// and reopens at its size from its editing data and from its PDF part.
|
||||
#[test]
|
||||
fn an_ai_with_an_artboard_past_14400_pt_reopens_at_its_size() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 20000, "height": 10000})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 1000, "y": 1000, "width": 5000, "height": 3000})).unwrap();
|
||||
let ai = b64(&s.execute("document.export", &json!({"format": "ai", "compress": false})).unwrap());
|
||||
assert!(String::from_utf8_lossy(&ai).contains("/UserUnit 1.389"), "20,000 / 14,400 rounded up to the next thousandth");
|
||||
let size = |s: &Session| {
|
||||
let r = s.doc().unwrap().doc.artboards[0].rect;
|
||||
(r.width().round(), r.height().round())
|
||||
};
|
||||
assert_eq!(open(&mut s, "big.ai", &ai)["restored"], true);
|
||||
assert_eq!(size(&s), (20_000.0, 10_000.0));
|
||||
// Picking its page opens the PDF part.
|
||||
let r = s.execute("document.open", &json!({"name": "big.ai", "dataBase64": vectorcraft_format::base64_encode(&ai), "pages": "1"})).unwrap();
|
||||
assert_eq!(r["restored"], false, "{r}");
|
||||
assert_eq!(size(&s), (20_000.0, 10_000.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ait_templates_open_their_document_untitled() {
|
||||
let mut s = rich();
|
||||
@@ -153,6 +174,33 @@ fn ait_templates_open_their_document_untitled() {
|
||||
assert_eq!(comparable(&s.doc().unwrap().doc), before);
|
||||
}
|
||||
|
||||
/// Save as Template writes a PDF-compatible `.ait` for a `.ait` path or `format: ait`, and New
|
||||
/// from Template opens it as an untitled copy of the document, layers and art included.
|
||||
#[test]
|
||||
fn save_as_template_writes_an_ait_that_new_from_template_opens_untitled() {
|
||||
let mut s = rich();
|
||||
s.execute("layer.new", &json!({"name": "Notes"})).unwrap();
|
||||
s.execute("shape.ellipse", &json!({"x": 20, "y": 120, "width": 40, "height": 30})).unwrap();
|
||||
let before = comparable(&s.doc().unwrap().doc);
|
||||
assert_eq!(before.layers.len(), 2);
|
||||
let path = tmp("brochure template.ait");
|
||||
let r = s.execute("file.saveAsTemplate", &json!({"path": path})).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");
|
||||
assert_eq!((s.doc().unwrap().path.as_deref(), s.doc().unwrap().is_dirty()), (None, true), "the document is unchanged");
|
||||
let r = s.execute("file.newFromTemplate", &json!({"path": path})).unwrap();
|
||||
assert_eq!(r["format"], "ait", "{r}");
|
||||
assert!(r["title"].as_str().unwrap().starts_with("Untitled-"), "{r}");
|
||||
assert_eq!(s.doc().unwrap().path, None);
|
||||
assert_eq!(comparable(&s.doc().unwrap().doc), before);
|
||||
// Without a path: the bytes and a name for the Templates folder.
|
||||
let r = s.execute("file.saveAsTemplate", &json!({"format": "ait"})).unwrap();
|
||||
assert_eq!(r["format"], "ait", "{r}");
|
||||
assert!(r["name"].as_str().is_some_and(|n| n.ends_with(" template.ait")), "{r}");
|
||||
assert!(vectorcraft_pdf::editing(&b64(&r)).is_some_and(|e| e.intact));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standards_and_screens_leave_the_editing_data_out() {
|
||||
let mut s = rich();
|
||||
|
||||
Loaded 100 of 525 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user