mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Compare commits
1173
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38d2ade2b7 | ||
|
|
7be391a1be | ||
|
|
528af4e503 | ||
|
|
202255cbbb | ||
|
|
44b33a2f57 | ||
|
|
56b15df0ab | ||
|
|
a453292729 | ||
|
|
910b03f4a1 | ||
|
|
e679fa500a | ||
|
|
027075d352 | ||
|
|
f86928d0d5 | ||
|
|
2bbfe46d3e | ||
|
|
9dcb7bab03 | ||
|
|
75bec53e08 | ||
|
|
c55a9f771e | ||
|
|
c955407373 | ||
|
|
54962c6389 | ||
|
|
6189fa8bf6 | ||
|
|
713f605566 | ||
|
|
9641817a15 | ||
|
|
1e097e3edd | ||
|
|
d94790d97a | ||
|
|
9c31b567da | ||
|
|
30755b3ff7 | ||
|
|
f639ef986e | ||
|
|
ded9094f59 | ||
|
|
7c1ae73ead | ||
|
|
36cd258bd2 | ||
|
|
8957e193e9 | ||
|
|
820b163067 | ||
|
|
80e5506456 | ||
|
|
3a37fb6b7e | ||
|
|
abd8768b65 | ||
|
|
882f1cbdbc | ||
|
|
bb5da2036d | ||
|
|
5b5fd5eabe | ||
|
|
2693336155 | ||
|
|
f52c911959 | ||
|
|
0313e0128a | ||
|
|
5880086522 | ||
|
|
b7c505a389 | ||
|
|
987f49ac03 | ||
|
|
dd9111ba20 | ||
|
|
92724e02bb | ||
|
|
0b6ea6a287 | ||
|
|
29cc5f83bd | ||
|
|
c480b74a3f | ||
|
|
2fd32751cc | ||
|
|
78afd2fc45 | ||
|
|
b280f7755d | ||
|
|
84702f3810 | ||
|
|
be5413826c | ||
|
|
2109dfa03c | ||
|
|
e6f7648219 | ||
|
|
3aa8f4b8b1 | ||
|
|
4f56789f45 | ||
|
|
f2e2fcaba4 | ||
|
|
6971d55345 | ||
|
|
aed303e504 | ||
|
|
c7388bd1ed | ||
|
|
cac379b5ee | ||
|
|
7fd34fb359 | ||
|
|
5827d3b4c1 | ||
|
|
f6597e0259 | ||
|
|
f8a70063f7 | ||
|
|
601a20ecf6 | ||
|
|
ca0e1a300a | ||
|
|
ee721e26dd | ||
|
|
a1c92dc6c5 | ||
|
|
a774f01199 | ||
|
|
e56c62c260 | ||
|
|
5fadda5ecf | ||
|
|
b913c4f9d0 | ||
|
|
6d7b32351f | ||
|
|
fc914a059c | ||
|
|
18e8bb0ded | ||
|
|
52bf97d983 | ||
|
|
aaba9febc2 | ||
|
|
0c510426af | ||
|
|
3ac39f9bb6 | ||
|
|
30b7eff487 | ||
|
|
be9bf48eb2 | ||
|
|
1b03ae95de | ||
|
|
a48a2d706a | ||
|
|
9aef21e627 | ||
|
|
7626f74b99 | ||
|
|
8789394de1 | ||
|
|
9b2ab3d2b8 | ||
|
|
8117d5b710 | ||
|
|
50667e925e | ||
|
|
284db01d5e | ||
|
|
8212c1a805 | ||
|
|
a58bdbc756 | ||
|
|
10d911cdd6 | ||
|
|
7871ff3d62 | ||
|
|
8b912c106e | ||
|
|
37ac38447d | ||
|
|
a7876aab54 | ||
|
|
e282b90185 | ||
|
|
4ada0efac2 | ||
|
|
8c55529df9 | ||
|
|
137685b098 | ||
|
|
07cbe5c908 | ||
|
|
a5ca7d04b0 | ||
|
|
b6bd10e0a8 | ||
|
|
c7a1423105 | ||
|
|
e294a09664 | ||
|
|
3545a0a995 | ||
|
|
9861de3c29 | ||
|
|
48066c4674 | ||
|
|
964d470122 | ||
|
|
8f81cdc380 | ||
|
|
a64487ca2c | ||
|
|
5018f53391 | ||
|
|
bfb1362cef | ||
|
|
7ada390639 | ||
|
|
e452a751c1 | ||
|
|
c6c86e7a3f | ||
|
|
e27d549be7 | ||
|
|
a0aca7f36b | ||
|
|
b2017dcb47 | ||
|
|
4030f3c008 | ||
|
|
a7c4894471 | ||
|
|
1d6205d576 | ||
|
|
274a55e812 | ||
|
|
7e5b21b794 | ||
|
|
fe9674297a | ||
|
|
836313560e | ||
|
|
b339ba490d | ||
|
|
1763368843 | ||
|
|
2736193d31 | ||
|
|
e6527e5c2c | ||
|
|
2d7b69d7b6 | ||
|
|
8485b10356 | ||
|
|
e8e36be177 | ||
|
|
0f00a94c23 | ||
|
|
5bebcd9075 | ||
|
|
998429fb9e | ||
|
|
f5e9cf5b0a | ||
|
|
9057bb7ef0 | ||
|
|
6498809e29 | ||
|
|
eefb1ac380 | ||
|
|
ddeef2d9a6 | ||
|
|
1cb436bcbb | ||
|
|
e128a09caa | ||
|
|
3084f5b5f4 | ||
|
|
532d88cda1 | ||
|
|
e676b823f9 | ||
|
|
7d80f052fb | ||
|
|
86f6b365f3 | ||
|
|
8a1a1ee698 | ||
|
|
4b6ff7d324 | ||
|
|
8aa76142c9 | ||
|
|
070cee617e | ||
|
|
077682ed4c | ||
|
|
c787f4f080 | ||
|
|
ebf67f8a9a | ||
|
|
b7ea777be7 | ||
|
|
8a75773988 | ||
|
|
8a61586585 | ||
|
|
9a3db3840a | ||
|
|
5bdea79f5c | ||
|
|
d86f7aed75 | ||
|
|
f001f37906 | ||
|
|
49f296cca1 | ||
|
|
f43d42d535 | ||
|
|
e126fad9a7 | ||
|
|
ac0130d2d7 | ||
|
|
0f4fffcf6c | ||
|
|
bdcb532608 | ||
|
|
de18659978 | ||
|
|
58d746bc17 | ||
|
|
9eacd994bb | ||
|
|
40f6f1756b | ||
|
|
3a917bb4e0 | ||
|
|
90d40e2ecc | ||
|
|
da9bbac5a0 | ||
|
|
da3e89695f | ||
|
|
6e201094c6 | ||
|
|
c0b405c42a | ||
|
|
ced5dc04ed | ||
|
|
6d312e0722 | ||
|
|
f330a02970 | ||
|
|
b61898bba5 | ||
|
|
adcfa70b4a | ||
|
|
7858d589cd | ||
|
|
53972cf37c | ||
|
|
19ebd9c2dc | ||
|
|
b47195feec | ||
|
|
bde35fab11 | ||
|
|
c4a459d1ca | ||
|
|
07c8e2d77f | ||
|
|
df0fab857d | ||
|
|
42e89b347d | ||
|
|
a2435383b1 | ||
|
|
d01254cf2d | ||
|
|
3f399dd450 | ||
|
|
8f04a999b9 | ||
|
|
f011313a3a | ||
|
|
792829ec41 | ||
|
|
4a1d27a227 | ||
|
|
0b8a4cc23e | ||
|
|
31a828b027 | ||
|
|
1dd96d3ddc | ||
|
|
dffef33960 | ||
|
|
7f2955be1c | ||
|
|
08b7b5eb15 | ||
|
|
aa6f45ba2c | ||
|
|
e11c7e4d7e | ||
|
|
128cb0b883 | ||
|
|
8ddb977733 | ||
|
|
7dce06dca4 | ||
|
|
e149f32208 | ||
|
|
354087ecd5 | ||
|
|
4113361660 | ||
|
|
30d00468e2 | ||
|
|
7919429652 | ||
|
|
48dbdffe07 | ||
|
|
5a76518ddd | ||
|
|
9ad2cf3be8 | ||
|
|
f71ce3fa9d | ||
|
|
3cd6a815ca | ||
|
|
fdfdb9e058 | ||
|
|
63831cdfa9 | ||
|
|
e03debfd4b | ||
|
|
906898f662 | ||
|
|
2741d3236b | ||
|
|
96697f7612 | ||
|
|
7b0252f291 | ||
|
|
2303efe7d5 | ||
|
|
2d6582163f | ||
|
|
e89a893fe1 | ||
|
|
72a9e4456e | ||
|
|
309d9f5b2c | ||
|
|
6b91132ce7 | ||
|
|
5e0a60b15e | ||
|
|
02b3940efd | ||
|
|
98b6d04413 | ||
|
|
dc69e57600 | ||
|
|
e5a407d6e2 | ||
|
|
502fb2ba73 | ||
|
|
6f83ed464d | ||
|
|
e37267f548 | ||
|
|
a31b990d32 | ||
|
|
7571e01210 | ||
|
|
18f5e17b36 | ||
|
|
65564cfc1b | ||
|
|
2071ff599d | ||
|
|
1df1d2e724 | ||
|
|
e843309280 | ||
|
|
0405fb73bf | ||
|
|
0b476e823d | ||
|
|
52a9a45f6a | ||
|
|
bc12031d70 | ||
|
|
fe510bdac6 | ||
|
|
a9031d63e1 | ||
|
|
0dc39b9efc | ||
|
|
57d65faf4f | ||
|
|
7040bb0619 | ||
|
|
900f62f5a3 | ||
|
|
4b474d77a9 | ||
|
|
756ca23d31 | ||
|
|
f8c8223ff0 | ||
|
|
883cc6e391 | ||
|
|
37d69f0449 | ||
|
|
8e83e8bb81 | ||
|
|
3610cd6b6f | ||
|
|
f34f4121c8 | ||
|
|
ba194e35d8 | ||
|
|
1090ca2d70 | ||
|
|
9412d3ccaf | ||
|
|
c90ea7d9ae | ||
|
|
5beca8c1b6 | ||
|
|
5ac14f2015 | ||
|
|
9b56f83929 | ||
|
|
935724850b | ||
|
|
a49570e45b | ||
|
|
b75ea712e9 | ||
|
|
fc8e811c2a | ||
|
|
010941ec60 | ||
|
|
8bc3d3f110 | ||
|
|
e34169ff75 | ||
|
|
86e7879f5e | ||
|
|
a7833e094d | ||
|
|
0a0ad04f47 | ||
|
|
74bc27f27e | ||
|
|
ea98ebb615 | ||
|
|
85b994e7cd | ||
|
|
445e7c6b55 | ||
|
|
0298201b2c | ||
|
|
03993057a4 | ||
|
|
172952f6e0 | ||
|
|
e54bd2ccb7 | ||
|
|
8e9c198c7c | ||
|
|
cb78440a16 | ||
|
|
601823d98c | ||
|
|
868016ee2f | ||
|
|
0981e6e675 | ||
|
|
006bbe1136 | ||
|
|
dfe40a17bf | ||
|
|
0b6705c12c | ||
|
|
ef6e8cdc2f | ||
|
|
9430673350 | ||
|
|
efc279d6f2 | ||
|
|
c1184d64fd | ||
|
|
8b01ed3e88 | ||
|
|
9e1430e296 | ||
|
|
fab77f45e5 | ||
|
|
11bc157477 | ||
|
|
389c2c2921 | ||
|
|
4fcf593bac | ||
|
|
26371b0629 | ||
|
|
dfa91441e6 | ||
|
|
9eadf35c97 | ||
|
|
f5a895ef75 | ||
|
|
0883d89e77 | ||
|
|
eb417f0470 | ||
|
|
483a351c37 | ||
|
|
19fadde1cc | ||
|
|
46dec4a660 | ||
|
|
45eeb7c819 | ||
|
|
6dca3e860b | ||
|
|
3e729bbf08 | ||
|
|
3f973b0d87 | ||
|
|
e5a3c9ca65 | ||
|
|
8ee5094c60 | ||
|
|
368e07493c | ||
|
|
051d3b4fd3 | ||
|
|
daae74b57e | ||
|
|
5e8c1bcc51 | ||
|
|
f4afe6d2df | ||
|
|
a773fcfce9 | ||
|
|
121882e177 | ||
|
|
22db9261f9 | ||
|
|
19724866a6 | ||
|
|
83f21e7cb9 | ||
|
|
bdcbee8d0b | ||
|
|
23144e36fe | ||
|
|
a178b9955b | ||
|
|
2cf4535c5d | ||
|
|
887e868a46 | ||
|
|
afd6d3a3eb | ||
|
|
2a4adf3024 | ||
|
|
9632162029 | ||
|
|
12085b1153 | ||
|
|
b0006d6af8 | ||
|
|
a55b6cfa6f | ||
|
|
70244c899d | ||
|
|
8f4b0d4c3f | ||
|
|
271c6c4037 | ||
|
|
432016fc96 | ||
|
|
d3fb412167 | ||
|
|
beb883d036 | ||
|
|
7f1979a1cc | ||
|
|
392734a135 | ||
|
|
39e7fb1d4f | ||
|
|
5f0a088870 | ||
|
|
c7423aa986 | ||
|
|
013941e8e8 | ||
|
|
8c41140abe | ||
|
|
b6af19ae35 | ||
|
|
1dc69720db | ||
|
|
1b64de3499 | ||
|
|
ad6be08603 | ||
|
|
e5620b538a | ||
|
|
1bb408462c | ||
|
|
6d98534a27 | ||
|
|
e755d3eeb1 | ||
|
|
f4731822c9 | ||
|
|
f9af68a06f | ||
|
|
be2f10d8cb | ||
|
|
acea4e3f14 | ||
|
|
367c3488c5 | ||
|
|
d28b4e0f54 | ||
|
|
c8dc372519 | ||
|
|
d7509e7ad6 | ||
|
|
13e06cace2 | ||
|
|
595d8f17c1 | ||
|
|
59d5893793 | ||
|
|
0954289c62 | ||
|
|
b27bd0519c | ||
|
|
062adfe405 | ||
|
|
aa681eafad | ||
|
|
c39d71a51a | ||
|
|
b919e33217 | ||
|
|
79088b7060 | ||
|
|
0fea876101 | ||
|
|
9859da3925 | ||
|
|
09c7f60567 | ||
|
|
155c1cfce2 | ||
|
|
6b03a3f29c | ||
|
|
a15d93acdd | ||
|
|
561902d0b3 | ||
|
|
a74191dba4 | ||
|
|
593fd215ff | ||
|
|
1cba8f0f60 | ||
|
|
404d303c2e | ||
|
|
e4fd7a29cc | ||
|
|
0b870df463 | ||
|
|
816f256c5f | ||
|
|
60fa10f99f | ||
|
|
82ef9642a2 | ||
|
|
672d86c0e4 | ||
|
|
54d3f19b5b | ||
|
|
2c57163549 | ||
|
|
0cbe475f13 | ||
|
|
0222a90e5d | ||
|
|
51de1ad2cf | ||
|
|
8d3e4a630f | ||
|
|
8b08718b9a | ||
|
|
7cda7eda1d | ||
|
|
57c03d36fc | ||
|
|
40e05abd45 | ||
|
|
b69613bf88 | ||
|
|
a82f691993 | ||
|
|
82ee5dec25 | ||
|
|
2568c55afa | ||
|
|
ea904591a5 | ||
|
|
afe4a408b9 | ||
|
|
c79416be4c | ||
|
|
67e1ab6373 | ||
|
|
f4ce764d1c | ||
|
|
b1ca1230e6 | ||
|
|
16bef64ea1 | ||
|
|
4517dd4e88 | ||
|
|
1370e78e09 | ||
|
|
0997d1e4cd | ||
|
|
5006f4cc84 | ||
|
|
10eb4866d9 | ||
|
|
bb15c0a033 | ||
|
|
76978b1634 | ||
|
|
a7d47dbc1b | ||
|
|
62bb8d62e6 | ||
|
|
b54ec601d8 | ||
|
|
198659a697 | ||
|
|
b4be808ba3 | ||
|
|
6050c04ecd | ||
|
|
70a9a5b1cd | ||
|
|
10d4921d41 | ||
|
|
5ef510e57b | ||
|
|
081c9fdc5e | ||
|
|
a5a3137e80 | ||
|
|
398d34c737 | ||
|
|
f403c65c94 | ||
|
|
30107bdb31 | ||
|
|
bdd9898d6f | ||
|
|
0dfc9ccf6f | ||
|
|
9fc910886c | ||
|
|
34f4c1b1df | ||
|
|
6b62aff539 | ||
|
|
d66833b7c2 | ||
|
|
b6a4343ec5 | ||
|
|
708a22c51f | ||
|
|
49d215f07f | ||
|
|
4b6f307ebb | ||
|
|
b9851d9329 | ||
|
|
91eb9d8884 | ||
|
|
bd005c4626 | ||
|
|
621efd0290 | ||
|
|
3d0c19af45 | ||
|
|
ac7fa9047b | ||
|
|
b9f112a6e4 | ||
|
|
a0facd60af | ||
|
|
6d23d5b5b5 | ||
|
|
bad8e9d1dd | ||
|
|
b56d5792b0 | ||
|
|
c448a3dba9 | ||
|
|
e80d3f4aed | ||
|
|
2e727a3ccf | ||
|
|
c40b2d53ee | ||
|
|
fc118f1710 | ||
|
|
320e3a971a | ||
|
|
1d7ba53cf9 | ||
|
|
330fc16c8c | ||
|
|
1481c2d3f0 | ||
|
|
a4ea5b66db | ||
|
|
f800acee47 | ||
|
|
95cb3be9e6 | ||
|
|
8700a2da92 | ||
|
|
afc387cdc5 | ||
|
|
0e51b7b1fc | ||
|
|
adf273bf4d | ||
|
|
f8949f28cd | ||
|
|
ed13dd6158 | ||
|
|
3af36720fd | ||
|
|
b2b0e82359 | ||
|
|
c354f02279 | ||
|
|
1e6d9a2888 | ||
|
|
143a842d1c | ||
|
|
69e212e3af | ||
|
|
e6862f18ae | ||
|
|
86c5559f24 | ||
|
|
b267a921cd | ||
|
|
560928af94 | ||
|
|
748b83a284 | ||
|
|
e9923c6c42 | ||
|
|
3723b36ff2 | ||
|
|
e90e265453 | ||
|
|
822c8f44b3 | ||
|
|
b815568fd7 | ||
|
|
526e31d1f7 | ||
|
|
eff4c02efa | ||
|
|
8d68b2fe9d | ||
|
|
5fbdb6c79e | ||
|
|
740a57ddba | ||
|
|
4416bc170a | ||
|
|
bd607f333a | ||
|
|
367b079d64 | ||
|
|
6f38c958b6 | ||
|
|
b3ec4013e6 | ||
|
|
5ed2f808e3 | ||
|
|
ec44671a81 | ||
|
|
79b3e3693f | ||
|
|
e1a92e37c6 | ||
|
|
2173cf9569 | ||
|
|
7dcfe0f989 | ||
|
|
3b28496c36 | ||
|
|
38e95d8c15 | ||
|
|
1659e06565 | ||
|
|
2d4c4e75d8 | ||
|
|
7acb9faad1 | ||
|
|
23eb82c02a | ||
|
|
c3489d6a99 | ||
|
|
2a410e54fa | ||
|
|
e225e4eec1 | ||
|
|
8587be6af9 | ||
|
|
b590cb0b0b | ||
|
|
8c3404fc87 | ||
|
|
89da88e374 | ||
|
|
e406e11db2 | ||
|
|
1c150c3c31 | ||
|
|
2dc22843e4 | ||
|
|
8ee597216d | ||
|
|
12df77ed19 | ||
|
|
be445fb083 | ||
|
|
f6cf99bf87 | ||
|
|
0609ca01cb | ||
|
|
f0dbc08c09 | ||
|
|
80d3ad5f24 | ||
|
|
aec4043ccd | ||
|
|
8fde5f31e7 | ||
|
|
a0d97fa931 | ||
|
|
d498633faf | ||
|
|
71c3848eff | ||
|
|
42b6e8e871 | ||
|
|
fca521ac8c | ||
|
|
3335ee0feb | ||
|
|
31390e5e21 | ||
|
|
0c2c433ade | ||
|
|
a85024558d | ||
|
|
f6661ed15e | ||
|
|
b2b378a3f0 | ||
|
|
ea12297822 | ||
|
|
4eae520beb | ||
|
|
16355053e1 | ||
|
|
8ea2cb0871 | ||
|
|
3a8927e3f3 | ||
|
|
0c3f4eee63 | ||
|
|
a4fa978903 | ||
|
|
31e22977f7 | ||
|
|
7266b30018 | ||
|
|
2009242055 | ||
|
|
1e80cd5547 | ||
|
|
749656605c | ||
|
|
1360b283f3 | ||
|
|
389c4dd5d0 | ||
|
|
b3c9cc099c | ||
|
|
4d7406be10 | ||
|
|
126029db40 | ||
|
|
3450180f19 | ||
|
|
eab5686393 | ||
|
|
912598fa46 | ||
|
|
ad7eb45abe | ||
|
|
5c7e9fc6c3 | ||
|
|
bb3627f954 | ||
|
|
f9c1000e35 | ||
|
|
0ac222a543 | ||
|
|
6d1b01b73d | ||
|
|
808087e6a5 | ||
|
|
5089d615bf | ||
|
|
6902c4bf92 | ||
|
|
d2bf428362 | ||
|
|
256ad7057c | ||
|
|
bc62c58b17 | ||
|
|
b61cf33836 | ||
|
|
0f28ac96ed | ||
|
|
e74015490b | ||
|
|
3e89fbbaa6 | ||
|
|
266da52dd9 | ||
|
|
6c605be582 | ||
|
|
593f55dfa1 | ||
|
|
32a49e8bf6 | ||
|
|
8f0640a7b2 | ||
|
|
d6d7e91c47 | ||
|
|
d3ebe57a02 | ||
|
|
ee991563c9 | ||
|
|
0a470c3b1f | ||
|
|
23ac41f191 | ||
|
|
7d87b6e668 | ||
|
|
0ed74b684a | ||
|
|
8d429a23e9 | ||
|
|
b0345cfaf2 | ||
|
|
82ea49bcf1 | ||
|
|
7c46b5b76a | ||
|
|
6057715c2a | ||
|
|
b5499d7157 | ||
|
|
0376c0b0e1 | ||
|
|
9479e3b17c | ||
|
|
2f6629c4cb | ||
|
|
5b017f4590 | ||
|
|
f52bcfa1c8 | ||
|
|
d00b01b5a1 | ||
|
|
9ed995d8d8 | ||
|
|
0f3495e55d | ||
|
|
62fe8571f6 | ||
|
|
9b8871c86b | ||
|
|
7f9b508296 | ||
|
|
76bfc19dfa | ||
|
|
4cd6412a3c | ||
|
|
18be662a2a | ||
|
|
6f634a9809 | ||
|
|
2d8014c706 | ||
|
|
4653cb4e18 | ||
|
|
e0ec02d62c | ||
|
|
63c32b89b8 | ||
|
|
4c1357b2e6 | ||
|
|
f685deef57 | ||
|
|
875df7674c | ||
|
|
47cf76f32c | ||
|
|
085ba98d47 | ||
|
|
5fd7984088 | ||
|
|
fd1b62bded | ||
|
|
510fb4b0b7 | ||
|
|
aa56910de8 | ||
|
|
981b09da83 | ||
|
|
25eb575146 | ||
|
|
a19b199efc | ||
|
|
b4ea3b1df8 | ||
|
|
175142f1d3 | ||
|
|
eabcf47d09 | ||
|
|
d534343d6e | ||
|
|
a205d665b0 | ||
|
|
2834166904 | ||
|
|
55993247b4 | ||
|
|
20392c955b | ||
|
|
84d60009be | ||
|
|
8da7862348 | ||
|
|
5a3bc1e294 | ||
|
|
534925bdf1 | ||
|
|
d0fb793954 | ||
|
|
6af2b0ee21 | ||
|
|
3bfaeffa6c | ||
|
|
058890ed87 | ||
|
|
d4d352622a | ||
|
|
1ac61262eb | ||
|
|
72238c1b6e | ||
|
|
5c0630cba3 | ||
|
|
0f7dc3d336 | ||
|
|
32a2d9e6bd | ||
|
|
ab3b1defbf | ||
|
|
e80316eb76 | ||
|
|
edaef792e0 | ||
|
|
8a56ce8340 | ||
|
|
6508be38f6 | ||
|
|
62f7075b42 | ||
|
|
30a1234c33 | ||
|
|
9ae4d726d4 | ||
|
|
a567c328cd | ||
|
|
a5ce62fe28 | ||
|
|
337bd35a31 | ||
|
|
6a8922ac49 | ||
|
|
f0d0e2a93b | ||
|
|
facca31f75 | ||
|
|
2c57da32ad | ||
|
|
bd8e227596 | ||
|
|
9eed1b4532 | ||
|
|
e7a106fcc3 | ||
|
|
a87f84526f | ||
|
|
8080e5a285 | ||
|
|
2dcfbdaff4 | ||
|
|
d48d66b7f8 | ||
|
|
5d4e08733b | ||
|
|
0bab72aff2 | ||
|
|
1080d02dd0 | ||
|
|
15b9ae0267 | ||
|
|
437153c8b5 | ||
|
|
99e9774408 | ||
|
|
3a5153a1d0 | ||
|
|
f4c182795f | ||
|
|
2bd01910dc | ||
|
|
4dea355f02 | ||
|
|
6e294a0eaa | ||
|
|
e171e47a81 | ||
|
|
a67b005ae5 | ||
|
|
5b26eeb7f7 | ||
|
|
c201a62e72 | ||
|
|
c9e071c355 | ||
|
|
11e36ed3ac | ||
|
|
aaf1042d64 | ||
|
|
02b502b69d | ||
|
|
e12f40a05a | ||
|
|
5334442eb5 | ||
|
|
cc0a2f3dc5 | ||
|
|
f4892fc9e7 | ||
|
|
16138fc03d | ||
|
|
6f2b41f236 | ||
|
|
ca7140c1ff | ||
|
|
06a6b10d83 | ||
|
|
20cf13a801 | ||
|
|
e1af96d3a6 | ||
|
|
bce94027ad | ||
|
|
0950423f08 | ||
|
|
163a259db4 | ||
|
|
215fed001e | ||
|
|
8195e3b6d8 | ||
|
|
6484091328 | ||
|
|
50e30dc6ed | ||
|
|
21c5f8e745 | ||
|
|
994d9a4b66 | ||
|
|
78b6c2982f | ||
|
|
271822164f | ||
|
|
c15b1deaf9 | ||
|
|
50d9ad00e6 | ||
|
|
a837c26eda | ||
|
|
849f543d38 | ||
|
|
5b3f4cb521 | ||
|
|
97e7164026 | ||
|
|
44f4b037e2 | ||
|
|
402c1f4611 | ||
|
|
4dcce01ecc | ||
|
|
a5eae5e9cf | ||
|
|
4dc6e06c41 | ||
|
|
8ffb218f78 | ||
|
|
973abff543 | ||
|
|
30d282d510 | ||
|
|
f8d52f6fbe | ||
|
|
fc74794394 | ||
|
|
af1c6696ca | ||
|
|
b6721fe32f | ||
|
|
b7cb5331d7 | ||
|
|
1256ee1022 | ||
|
|
888b5d7287 | ||
|
|
2ae8fe1839 | ||
|
|
e13db7f564 | ||
|
|
8d8cbeb123 | ||
|
|
40231d8114 | ||
|
|
6ec369aede | ||
|
|
9fab337cd2 | ||
|
|
5992cbeb15 | ||
|
|
d281ba7707 | ||
|
|
a535040d6d | ||
|
|
c57d25e8d3 | ||
|
|
6d57610489 | ||
|
|
7862974cad | ||
|
|
24644f3e3b | ||
|
|
8409c337c1 | ||
|
|
2578a92e96 | ||
|
|
194e7fcf1b | ||
|
|
1171e92308 | ||
|
|
b9b0fdbab0 | ||
|
|
903cf0930b | ||
|
|
5d406be3b0 | ||
|
|
3635bdc8ba | ||
|
|
a9b8cc4f4a | ||
|
|
9ab6a3a35c | ||
|
|
e3a654d446 | ||
|
|
f4ae239725 | ||
|
|
1d8f460fc5 | ||
|
|
838cef2fec | ||
|
|
d184eb7927 | ||
|
|
3046d49e56 | ||
|
|
2b20fc3a45 | ||
|
|
ac88cad50b | ||
|
|
f1b30485b2 | ||
|
|
9b94481481 | ||
|
|
ab2befffa0 | ||
|
|
ea480ad141 | ||
|
|
a7ed1767b5 | ||
|
|
44df1ebcb0 | ||
|
|
5d31b78736 | ||
|
|
d78e657e4c | ||
|
|
cf640c4b08 | ||
|
|
887ea1e706 | ||
|
|
42fb6728fa | ||
|
|
ba290f171e | ||
|
|
f036a8e805 | ||
|
|
084e076c1d | ||
|
|
fa4b80ed65 | ||
|
|
5d9f47c81a | ||
|
|
ce3725a5c8 | ||
|
|
8bbea22f74 | ||
|
|
26d8bfdd3a | ||
|
|
5115f77f08 | ||
|
|
2f3a525568 | ||
|
|
800c2008df | ||
|
|
d6b9cc9b5e | ||
|
|
1e6292306a | ||
|
|
c4a9f25754 | ||
|
|
256612b232 | ||
|
|
0fcaabc370 | ||
|
|
6b0c1db6ed | ||
|
|
8c0147cfa7 | ||
|
|
d04df05249 | ||
|
|
65e465a611 | ||
|
|
f22ff9eb62 | ||
|
|
5398c2397c | ||
|
|
3f82866aaf | ||
|
|
29c24aa07c | ||
|
|
0c84b0434d | ||
|
|
ef858b710f | ||
|
|
c2e770ab53 | ||
|
|
a9e7747ea5 | ||
|
|
9f5de924ad | ||
|
|
3ea696dc1a | ||
|
|
679ce03da4 | ||
|
|
8fde746211 | ||
|
|
4a7ad03eb0 | ||
|
|
b6aa6ab4e4 | ||
|
|
aed386e269 | ||
|
|
f0647e1bd8 | ||
|
|
22074c4c69 | ||
|
|
2702b48530 | ||
|
|
8a5eec369f | ||
|
|
ed20cebed8 | ||
|
|
fcb5eae1a5 | ||
|
|
dfee72e742 | ||
|
|
3960ea47cd | ||
|
|
dbffbab59d | ||
|
|
fd1b0a1965 | ||
|
|
f5fd324892 | ||
|
|
d31ed52bbe | ||
|
|
0b66323765 | ||
|
|
05eb4c611b | ||
|
|
77de43266e | ||
|
|
dac0d054fe | ||
|
|
ba71a40a39 | ||
|
|
4575d19ac7 | ||
|
|
3fd1083468 | ||
|
|
c583480010 | ||
|
|
111f029112 | ||
|
|
bd017b861d | ||
|
|
71b02327cf | ||
|
|
ca2af8fe30 | ||
|
|
8e124d32a8 | ||
|
|
cbc6cd0e34 | ||
|
|
e4022e6702 | ||
|
|
fea8c1498f | ||
|
|
5fc8aaee7c | ||
|
|
b1ce243806 | ||
|
|
a4ed08b687 | ||
|
|
68ab3a4839 | ||
|
|
33252f28db | ||
|
|
8bc7e55561 | ||
|
|
304bf06290 | ||
|
|
7764c27cc4 | ||
|
|
b4c03f0a75 | ||
|
|
06c275d136 | ||
|
|
eb7e346ea1 | ||
|
|
00dd069678 | ||
|
|
a29e200a4e | ||
|
|
c5a63fc8b7 | ||
|
|
6f19e0bd07 | ||
|
|
1a0ffc6413 | ||
|
|
ec1f25e617 | ||
|
|
f894bb1d17 | ||
|
|
1866de9895 | ||
|
|
7e4e628b13 | ||
|
|
c176d42bb4 | ||
|
|
768641615a | ||
|
|
b03b19bca7 | ||
|
|
ad7af60510 | ||
|
|
210df9a3d1 | ||
|
|
ab6051894d | ||
|
|
6e40335424 | ||
|
|
27988aaa24 | ||
|
|
b3420a0b04 | ||
|
|
0cbee85ea8 | ||
|
|
ec725a4e6b | ||
|
|
15daed6ca4 | ||
|
|
8fd04ebd92 | ||
|
|
9f8c860f90 | ||
|
|
8f935a8697 | ||
|
|
afd058a8ff | ||
|
|
11e63e31e1 | ||
|
|
07d12ed8d9 | ||
|
|
32fcaec293 | ||
|
|
6fcf392370 | ||
|
|
c94b5c8d1e | ||
|
|
2c15fcc033 | ||
|
|
443fe300d0 | ||
|
|
bba398f60f | ||
|
|
641fa36a8e | ||
|
|
4b2548c99b | ||
|
|
b89c9cafd7 | ||
|
|
8a36a6990e | ||
|
|
c52b3142c4 | ||
|
|
5bdfc3228d | ||
|
|
88bea399e5 | ||
|
|
184b76e2a7 | ||
|
|
b763579d94 | ||
|
|
f0be1aa767 | ||
|
|
4dbea3256f | ||
|
|
d500d045b5 | ||
|
|
4ba7deb5ff | ||
|
|
f9d5a43174 | ||
|
|
0e763ab78c | ||
|
|
d4175d2902 | ||
|
|
bc42b62cc4 | ||
|
|
1b326e2e87 | ||
|
|
36acf26f1d | ||
|
|
c4c56a6a8b | ||
|
|
427a955c06 | ||
|
|
5a060c8b48 | ||
|
|
c22747b104 | ||
|
|
f3ca5a63b9 | ||
|
|
4658042de8 | ||
|
|
0d47da07e5 | ||
|
|
37559f793c | ||
|
|
5c1432b362 | ||
|
|
3f31f9951c | ||
|
|
034b312a64 | ||
|
|
e8a383effa | ||
|
|
30c2fe4b6d | ||
|
|
24622bf94d | ||
|
|
3489c3b22d | ||
|
|
5acf9eeebf | ||
|
|
10ad8d7ba7 | ||
|
|
e615188f8b | ||
|
|
5007c6919e | ||
|
|
aeaa3684a8 | ||
|
|
c536c6e905 | ||
|
|
7d5dd85209 | ||
|
|
a7bc932cc8 | ||
|
|
9af5a7d17b | ||
|
|
6a85afa236 | ||
|
|
69a4424e75 | ||
|
|
4ea79d4c5e | ||
|
|
733522d528 | ||
|
|
5eef9fb55a | ||
|
|
37bd53c62e | ||
|
|
7432c61665 | ||
|
|
36ec1dc0ca | ||
|
|
e9315bc141 | ||
|
|
ad24f545e2 | ||
|
|
460a2712e4 | ||
|
|
283b30f12d | ||
|
|
607ffc5591 | ||
|
|
14bc6a7ef9 | ||
|
|
85833cf5f5 | ||
|
|
16c5d6a6a3 | ||
|
|
e6920f8ebe | ||
|
|
1b9b830f32 | ||
|
|
2a79cb7671 | ||
|
|
eec67560df | ||
|
|
07dd513c54 | ||
|
|
ede2ca69b6 | ||
|
|
c7a4930c4c | ||
|
|
7d23e4670b | ||
|
|
6b071aa053 | ||
|
|
5d4bfc0846 | ||
|
|
241ecd59d7 | ||
|
|
1fe638bcf8 | ||
|
|
d2066a1f07 | ||
|
|
8bab5c2053 | ||
|
|
201ab63c48 | ||
|
|
cb26e7a04f | ||
|
|
ce878ef74e | ||
|
|
272ab5f96f | ||
|
|
fb2a83edb6 | ||
|
|
de098cfa52 | ||
|
|
8dc068e0ba | ||
|
|
952ebad3cf | ||
|
|
6873a25635 | ||
|
|
1be57fee4b | ||
|
|
1baf1f5569 | ||
|
|
8ba97e61be | ||
|
|
007741718e | ||
|
|
97855c70a0 | ||
|
|
09b72e4b70 | ||
|
|
902edb3c85 | ||
|
|
d4cf50dd3d | ||
|
|
33ade63687 | ||
|
|
45b8dce220 | ||
|
|
27d73db0d9 | ||
|
|
863f7cbd87 | ||
|
|
a4a7ebe658 | ||
|
|
9aebc8ba51 | ||
|
|
efb2127e4e | ||
|
|
c623b4f555 | ||
|
|
0b35b74c4d | ||
|
|
eff88eaf59 | ||
|
|
34372569e7 | ||
|
|
3ed12de918 | ||
|
|
525b7ed3f9 | ||
|
|
82dcd86c88 | ||
|
|
e70fce996b | ||
|
|
4fee02636a | ||
|
|
12dd392705 | ||
|
|
7887a13868 | ||
|
|
35312529e1 | ||
|
|
78a99ad1cf | ||
|
|
69d4893689 | ||
|
|
457406a7a5 | ||
|
|
e614f0d6d6 | ||
|
|
8ee082f257 | ||
|
|
238157d98e | ||
|
|
4e2cbaed79 | ||
|
|
ee71879f80 | ||
|
|
7cb62d5197 | ||
|
|
c0dcfc1db5 | ||
|
|
9238a915a2 | ||
|
|
2e68e54eec | ||
|
|
239e41bdf6 | ||
|
|
cef6e1a8c8 | ||
|
|
9d4541b8b4 | ||
|
|
46dfb23dc5 | ||
|
|
675d20417e | ||
|
|
754841aaa4 | ||
|
|
bf877411fb | ||
|
|
3b04dd14be | ||
|
|
772abb2154 | ||
|
|
84e6c12543 | ||
|
|
aafce0d2ef | ||
|
|
4773e4ee0f | ||
|
|
4012cea0bf | ||
|
|
2965e69e2f | ||
|
|
ee4712f829 | ||
|
|
c2b622a797 | ||
|
|
0cbcbebf68 | ||
|
|
0c5ddb5dea | ||
|
|
9a7e7e133e | ||
|
|
47dfaaca61 | ||
|
|
7d642e12c9 | ||
|
|
862e2175a7 | ||
|
|
b3a980dcb5 | ||
|
|
0796325a1f | ||
|
|
90a9933d46 | ||
|
|
2fca333cac | ||
|
|
39adfcd62a | ||
|
|
525ec7b1a9 | ||
|
|
5ca1dad281 | ||
|
|
6912b5df76 | ||
|
|
f5fef50364 | ||
|
|
bab3854484 | ||
|
|
3d22ca642d | ||
|
|
30b2daadb6 | ||
|
|
85a7f0ce3e | ||
|
|
232501a9f3 | ||
|
|
b8a665f0ca | ||
|
|
41d839d931 | ||
|
|
bc2fc816d4 | ||
|
|
257e79de27 | ||
|
|
d30e4edb45 | ||
|
|
fa3db9a777 | ||
|
|
08bc46c0ea | ||
|
|
ed562d7305 | ||
|
|
ef313b91b0 | ||
|
|
9e2f7b96dc | ||
|
|
f3f959cabf | ||
|
|
cfd4f25545 | ||
|
|
ad8fea51d2 | ||
|
|
591c06ba33 | ||
|
|
8b1836bc53 | ||
|
|
c238d933df | ||
|
|
503ee5d67e | ||
|
|
d5e30557eb | ||
|
|
44a032d3de | ||
|
|
8392081976 | ||
|
|
d63d250d58 | ||
|
|
456c5c5373 | ||
|
|
70314fa828 | ||
|
|
b5a8642417 | ||
|
|
71ddbc59dc | ||
|
|
7812c77cf3 | ||
|
|
09d6a6c715 | ||
|
|
ab81f6bc73 | ||
|
|
8c5f101e68 | ||
|
|
9d185aeb68 | ||
|
|
daf612b8fd | ||
|
|
8a4342757b | ||
|
|
18e47d1af7 | ||
|
|
0f069ce351 | ||
|
|
735e16d93c | ||
|
|
7d8bb05374 | ||
|
|
ce1c7b7759 | ||
|
|
5c82c08847 | ||
|
|
67b7f82cff | ||
|
|
b585027de2 | ||
|
|
0d6301cb7c | ||
|
|
5207844798 | ||
|
|
71c5b1712a | ||
|
|
8788f994ef | ||
|
|
720b66fbd3 | ||
|
|
922445300b | ||
|
|
75c280d253 | ||
|
|
85345aaa13 | ||
|
|
180cb4f36f | ||
|
|
e6a41d1ada | ||
|
|
1660982e58 | ||
|
|
f6c143ac85 | ||
|
|
a68da9f51d | ||
|
|
cdfaaf4a38 | ||
|
|
10d3333344 | ||
|
|
bb6f0dc88a | ||
|
|
c4f355773d | ||
|
|
d4bc8812a3 | ||
|
|
dc8a884389 | ||
|
|
e28851d2f1 | ||
|
|
d6ab3d10da | ||
|
|
6334d689d2 | ||
|
|
9d571ef644 | ||
|
|
cc8e2eeb4b | ||
|
|
b97af97a58 | ||
|
|
283be17f93 | ||
|
|
426fd71bd2 | ||
|
|
66f7e6e1ed | ||
|
|
e3d9803069 | ||
|
|
717e855cec | ||
|
|
28f51bf8ad | ||
|
|
837e60ce65 | ||
|
|
30a5ee202d | ||
|
|
bb53f8c741 | ||
|
|
2a8c5b2b27 | ||
|
|
1eb7ec83ae | ||
|
|
fa2de347c4 | ||
|
|
31f5446641 | ||
|
|
f3ae32d920 | ||
|
|
a541dad33d | ||
|
|
dc6ea04a8f | ||
|
|
aa65bb8af3 | ||
|
|
f2c27cbba1 | ||
|
|
8ac6dc2f47 | ||
|
|
b22d88c845 | ||
|
|
0167c0c31f | ||
|
|
bd61308cf9 | ||
|
|
9c64754f20 | ||
|
|
aaec22c681 | ||
|
|
21249df5da | ||
|
|
f58f4f425f | ||
|
|
c462859f6f | ||
|
|
99c1e062aa | ||
|
|
2ff4285be6 | ||
|
|
1601fd250a | ||
|
|
be019fd615 | ||
|
|
96abeba71b | ||
|
|
d5c62b640a | ||
|
|
dd404af8b5 | ||
|
|
c1ab5d3c43 | ||
|
|
c871c5f634 | ||
|
|
b14cf1da70 | ||
|
|
f033c76913 | ||
|
|
1ce5f686e8 | ||
|
|
a3fae2c031 | ||
|
|
6a91164a5b | ||
|
|
e999424c79 | ||
|
|
1c8992fdbe | ||
|
|
44ddb579da | ||
|
|
5ac5223946 | ||
|
|
208c163647 | ||
|
|
3bb284cfdd | ||
|
|
7db770d91d | ||
|
|
04069e1968 | ||
|
|
35d265891c | ||
|
|
736dd52b51 | ||
|
|
d34b5df775 | ||
|
|
20366a8ea9 | ||
|
|
cebf667579 | ||
|
|
7afa9ea133 | ||
|
|
d2acdf07db | ||
|
|
6ff5b274ea | ||
|
|
6be9817693 | ||
|
|
074e090f05 | ||
|
|
a6000f18fd |
@@ -0,0 +1,6 @@
|
||||
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
|
||||
reviews:
|
||||
auto_review:
|
||||
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
|
||||
ignore_usernames:
|
||||
- "dependabot[bot]"
|
||||
@@ -0,0 +1,30 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Maintain Rust / Cargo dependencies
|
||||
- package-ecosystem: "cargo"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
# Consolidate updates into a single PR rather than one PR per crate
|
||||
groups:
|
||||
cargo-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
|
||||
# Maintain GitHub Actions workflows
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "ci(deps)"
|
||||
+18
-44
@@ -1,54 +1,28 @@
|
||||
name: ci
|
||||
name: CI
|
||||
|
||||
# Quality gates for every push to main and every pull request: rustfmt, clippy (-D warnings),
|
||||
# tests, asset attribution, crate layering and the wasm32 check — `cargo xtask ci`.
|
||||
# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the
|
||||
# suite's shared gate workflow in craft-common-actions.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
# Same craft-fonts commit as release.yml (Latin presentation fonts).
|
||||
CRAFT_FONTS_REF: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
|
||||
# A newer push to a PR branch cancels its older run; every push to main runs to the end.
|
||||
concurrency:
|
||||
group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
gates:
|
||||
name: gates (${{ matrix.os }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# No macOS on PRs or main: the org's 5 macOS slots are kept for release builds, and
|
||||
# release.yml builds macOS (a failing release is fixed and rebuilt).
|
||||
os: [ubuntu-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- name: Linux build dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libasound2-dev libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: ci-${{ matrix.os }}
|
||||
- name: cargo xtask ci
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
run: cargo xtask ci
|
||||
gate:
|
||||
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
|
||||
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
|
||||
with:
|
||||
# Same craft-fonts commit as release.yml.
|
||||
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
|
||||
packages: >-
|
||||
libasound2-dev libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev
|
||||
libgl1-mesa-dev
|
||||
|
||||
@@ -2,16 +2,17 @@
|
||||
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
|
||||
name: FreeBSD
|
||||
|
||||
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull
|
||||
# request: a drifted dependency still shows up within a day, and always before a release ships.
|
||||
# PRs that change the FreeBSD workflow itself still run it.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [release]
|
||||
pull_request:
|
||||
paths:
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
- "**/Cargo.toml"
|
||||
- "crates/fonts/**"
|
||||
- .github/workflows/freebsd.yml
|
||||
schedule:
|
||||
- cron: "27 2 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,21 @@
|
||||
name: SonarCloud
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: sonarcloud-${{ github.ref }}
|
||||
# A push to main is the run that publishes coverage; superseded runs there would never finish.
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
sonarcloud:
|
||||
# Tracked at @main so org-wide CI improvements propagate without PR churn.
|
||||
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
|
||||
secrets: inherit
|
||||
@@ -0,0 +1,31 @@
|
||||
# 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.
|
||||
name: Windows ARM64
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [release]
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/windows-arm64.yml
|
||||
- packaging/windows/**
|
||||
# Nightly, so dependency and runner-image drift shows up within a day.
|
||||
schedule:
|
||||
- cron: "3 5 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: windows-arm64-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
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: deckcraft
|
||||
app-dir: DeckCraft
|
||||
@@ -131,11 +131,12 @@ Standard: craftrules `standards/never-crash.md`.
|
||||
CLI, control channel and MCP do exactly the same thing.
|
||||
- **The UI is thin**: panels read engine state and act through commands. Colours come from theme
|
||||
tokens. **Never use Tauri**; egui only.
|
||||
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
|
||||
- **Never break wasm** (`cargo xtask wasm`).
|
||||
|
||||
## 5. Quality gates (before every commit)
|
||||
|
||||
`cargo xtask ci` = fmt check, clippy `-D warnings`, tests, layering check, asset check, wasm
|
||||
`cargo xtask ci` = fmt check, clippy `-D warnings`, tests (nextest when installed) and doctests, layering check, asset check, wasm
|
||||
check. Commit after each landed arc with a task id in the message (`M1.2: smart guides`) and push
|
||||
to `main`.
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ fails if a file under `assets/`, `docs/images/` or `docs/brand/` is missing from
|
||||
DeckCraft contains **no Microsoft, Adobe, Avid or Autodesk iconography, images, fonts, themes,
|
||||
templates or other assets**. Its themes, colour schemes, layouts, preset-shape geometry, sample
|
||||
deck and UI icons are original work of the DeckCraft contributors (drawn or generated in code).
|
||||
Icons placed from Insert › Icons are Lucide paths (ISC; Feather-derived icons MIT), not Microsoft
|
||||
artwork. The ribbon and menu icons stay original drawings.
|
||||
|
||||
## In this repository
|
||||
|
||||
@@ -26,11 +28,14 @@ deck and UI icons are original work of the DeckCraft contributors (drawn or gene
|
||||
| `docs/images/editor.png`, `docs/images/shape-format.png`, `docs/images/format-pane.png`, `docs/images/slide-sorter.png`, `docs/images/dark-mode.png`, `docs/images/sample-deck.png` | DeckCraft contributors | Screenshots of DeckCraft and slides rendered by `deckcraft-cli` from the built-in sample deck (original content; Inter font, OFL-1.1) | MIT OR Apache-2.0 | README screenshots |
|
||||
| `crates/media/tests/fixtures/tone*` (`tone.mp3`, `.m4a`, `.aac`, `.ogg`, `.opus`, `.flac`, `.wma`, `tone-alac.m4a`) | DeckCraft contributors | synthesised 0.5 s 440 Hz sine, encoded by `crates/media/tests/fixtures/make.sh` (ffmpeg as the encoder only) | CC0-1.0 | Audio decoding tests |
|
||||
| `crates/media/tests/fixtures/clip-*` (`clip-h264.mp4`, `clip-h264.mov`, `clip-hevc.mp4`, `clip-vp9.webm`, `clip-av1.mkv`) | DeckCraft contributors | 6 frames of a colour gradient generated by `make.sh` (python3), encoded by ffmpeg (x264 / x265 / libvpx / SVT-AV1 as encoders only) | CC0-1.0 | Video decoding tests |
|
||||
| `crates/media/tests/fixtures/damaged/*` (`ebml-header.mkv`, `ebml-position.webm`, `demux-loop.mp4`, `demux-loop.m4a`) | DeckCraft contributors | the `clip-*` and `tone*` fixtures above with bytes changed by a mutation fuzzer | CC0-1.0 | Damaged media must not panic or hang |
|
||||
| `crates/media/tests/fixtures/damaged/*` (`stsc-count.m4a`, `stsz-size.m4a`) | DeckCraft contributors | `tone.m4a` above with bytes changed by a mutation fuzzer | CC0-1.0 | Damaged MP4 sample tables must not abort |
|
||||
| `crates/media/tests/fixtures/make.sh` | DeckCraft contributors | this repository | MIT OR Apache-2.0 | Regenerates the media fixtures |
|
||||
| `crates/render/tests/fixtures/colr-squares.ttf` | DeckCraft contributors | two squares as a COLRv0/CPAL test font, built by `make_colr_font.py` (fontTools as the builder only) | CC0-1.0 | Colour glyph rendering tests |
|
||||
| `crates/fonts/tests/fixtures/legacy-semibold.ttf` | DeckCraft contributors | a one-glyph Semibold face named like Segoe UI Semibold (its Windows family differs from the typographic one), built by `make_legacy_font.py` (fontTools as the builder only) | CC0-1.0 | Font lookup by Windows family name tests |
|
||||
| `crates/fonts/tests/fixtures/legacy-regular.ttf` | DeckCraft contributors | the same family's one-glyph Regular face, built by `make_legacy_font.py` (fontTools as the builder only) | CC0-1.0 | Font lookup by Windows family name tests |
|
||||
| `crates/render/tests/fixtures/make_colr_font.py` | DeckCraft contributors | this repository | MIT OR Apache-2.0 | Regenerates the COLR test font |
|
||||
| Lucide icon paths in `crates/engine/src/icon_data.rs` (Insert › Icons) | Lucide Contributors; Feather icons by Cole Bemis | https://github.com/lucide-icons/lucide/tree/440a1e1dfb1e712505308da46bead3c28b1f1398/icons (release 1.56.0, 2026-10-13) | ISC; MIT for the Feather-derived icons named in that commit's LICENSE | Stroke icons inserted as shapes |
|
||||
|
||||
## Bundled through dependencies
|
||||
|
||||
@@ -56,3 +61,17 @@ families excluded by `crates/fonts/build.rs`:
|
||||
| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic | OFL-1.1 | Japanese and Arabic text |
|
||||
|
||||
Web (wasm32) builds embed only Inter Regular/Bold and BIZ UDPGothic Regular.
|
||||
|
||||
## PDF writer colour profiles
|
||||
|
||||
`craft-pdf` is used with only its `write` feature and default features disabled. Krilla
|
||||
0.8.2 embeds the following profiles; these were already reachable through the direct Krilla
|
||||
exporter. No interpreter fallback fonts or CMaps are enabled, and no profile or font binary
|
||||
is copied into this repository. The licence text is in `licenses/pdf-writer/LICENSE-ICC.txt`.
|
||||
|
||||
| Asset | Author | Source | Licence | SHA-256 | Used for |
|
||||
|---|---|---|---|---|---|
|
||||
| `krilla 0.8.2 / icc/sGrey-v2-magic.icc` | Clinton Ingram (Compact ICC Profiles) | https://github.com/LaurenzV/krilla/blob/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc/sGrey-v2-magic.icc | CC0-1.0 | `4110169287e5f1b5149ca9f1da1814d32943905bba8030e849d1a3b729a19481` | PDF export colour profiles |
|
||||
| `krilla 0.8.2 / icc/sGrey-v4.icc` | Clinton Ingram (Compact ICC Profiles) | https://github.com/LaurenzV/krilla/blob/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc/sGrey-v4.icc | CC0-1.0 | `00c0f94e09127520a17dc0e1d9264b5702081d96dbdb1549ee88e3631ce42a9d` | PDF export colour profiles |
|
||||
| `krilla 0.8.2 / icc/sRGB-v2-magic.icc` | Clinton Ingram (Compact ICC Profiles) | https://github.com/LaurenzV/krilla/blob/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc/sRGB-v2-magic.icc | CC0-1.0 | `af4efe28f6d311799865f325ba39184a2e978b113ca74124d60af7de22b105f4` | PDF export colour profiles |
|
||||
| `krilla 0.8.2 / icc/sRGB-v4.icc` | Clinton Ingram (Compact ICC Profiles) | https://github.com/LaurenzV/krilla/blob/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc/sRGB-v4.icc | CC0-1.0 | `c56e1685d888f5edb92fe07f2750f387f8fe8e91b32ff8fb0b56bfbbb9458353` | PDF export colour profiles |
|
||||
|
||||
Generated
+154
-200
@@ -444,30 +444,15 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
|
||||
dependencies = [
|
||||
"bit-vec 0.8.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d"
|
||||
dependencies = [
|
||||
"bit-vec 0.9.1",
|
||||
"bit-vec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-vec"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
||||
|
||||
[[package]]
|
||||
name = "bit-vec"
|
||||
version = "0.9.1"
|
||||
@@ -605,6 +590,12 @@ dependencies = [
|
||||
"wayland-client",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cast"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.6.0"
|
||||
@@ -814,6 +805,23 @@ dependencies = [
|
||||
"windows 0.54.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-media"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4#f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
|
||||
dependencies = [
|
||||
"krilla",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
@@ -877,15 +885,17 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft"
|
||||
version = "0.6.1"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
"cpal",
|
||||
"deckcraft-engine",
|
||||
"deckcraft-media",
|
||||
"deckcraft-ui-egui",
|
||||
"eframe",
|
||||
"egui",
|
||||
"fmv-macos-events",
|
||||
"image",
|
||||
"log",
|
||||
"rfd",
|
||||
@@ -896,7 +906,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-anim"
|
||||
version = "0.6.1"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-geom",
|
||||
"deckcraft-model",
|
||||
@@ -905,7 +915,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-av1"
|
||||
version = "0.6.1"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -914,31 +924,34 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-bitstream"
|
||||
version = "0.6.1"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"craft-media",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-cli"
|
||||
version = "0.6.1"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-mcp",
|
||||
"serde_json",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-color"
|
||||
version = "0.6.1"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-engine"
|
||||
version = "0.6.1"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"deckcraft-anim",
|
||||
"deckcraft-color",
|
||||
"deckcraft-fonts",
|
||||
"deckcraft-format",
|
||||
@@ -951,15 +964,17 @@ dependencies = [
|
||||
"deckcraft-text",
|
||||
"i_overlay",
|
||||
"image",
|
||||
"js-sys",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-fonts"
|
||||
version = "0.6.1"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"epaint_default_fonts",
|
||||
"harfrust 0.13.3",
|
||||
@@ -971,7 +986,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-format"
|
||||
version = "0.6.1"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-model",
|
||||
"serde",
|
||||
@@ -982,7 +997,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-geom"
|
||||
version = "0.6.1"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -990,7 +1005,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-h264"
|
||||
version = "0.6.1"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -999,7 +1014,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-hevc"
|
||||
version = "0.6.1"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1008,19 +1023,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-isobmff"
|
||||
version = "0.6.1"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"thiserror 2.0.21",
|
||||
"craft-media",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-matroska"
|
||||
version = "0.6.1"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-mcp"
|
||||
version = "0.6.1"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-render",
|
||||
@@ -1030,7 +1047,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-media"
|
||||
version = "0.6.1"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-av1",
|
||||
"deckcraft-h264",
|
||||
@@ -1048,49 +1065,54 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-metafile"
|
||||
version = "0.6.1"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-model"
|
||||
version = "0.6.1"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-geom",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-ogg"
|
||||
version = "0.6.1"
|
||||
version = "0.10.0"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-opus"
|
||||
version = "0.6.1"
|
||||
version = "0.10.0"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-pdf"
|
||||
version = "0.6.1"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"craft-pdf",
|
||||
"deckcraft-fonts",
|
||||
"deckcraft-model",
|
||||
"deckcraft-render",
|
||||
"deckcraft-text",
|
||||
"hayro-syntax",
|
||||
"krilla",
|
||||
"kurbo",
|
||||
"log",
|
||||
"serde",
|
||||
"thiserror 2.0.21",
|
||||
"wasm-bindgen-test",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-pptx"
|
||||
version = "0.6.1"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-engine",
|
||||
@@ -1098,7 +1120,7 @@ dependencies = [
|
||||
"deckcraft-model",
|
||||
"image",
|
||||
"log",
|
||||
"quick-xml 0.38.4",
|
||||
"quick-xml",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"zip",
|
||||
@@ -1106,8 +1128,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-render"
|
||||
version = "0.6.1"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-anim",
|
||||
"deckcraft-color",
|
||||
"deckcraft-fonts",
|
||||
"deckcraft-geom",
|
||||
@@ -1124,7 +1147,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-text"
|
||||
version = "0.6.1"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"deckcraft-color",
|
||||
@@ -1138,7 +1161,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-ui-egui"
|
||||
version = "0.6.1"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-anim",
|
||||
"deckcraft-color",
|
||||
@@ -1160,7 +1183,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-vp9"
|
||||
version = "0.6.1"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1169,7 +1192,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-web"
|
||||
version = "0.6.1"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-ui-egui",
|
||||
@@ -1580,12 +1603,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"
|
||||
@@ -1613,10 +1630,15 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
name = "fmv-macos-events"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
checksum = "90642cf66dde7f205dc8b42dfe57b47d0f3a29f571b832742883ef512e23fe55"
|
||||
dependencies = [
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
@@ -1999,7 +2021,6 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "000e025850f4592ddcaee85640db70704151f2bafd43b6f431aa5b6a2c6a35a9"
|
||||
dependencies = [
|
||||
"fearless_simd 1.1.0",
|
||||
"hayro-ccitt",
|
||||
]
|
||||
|
||||
@@ -2008,9 +2029,6 @@ name = "hayro-jpeg2000"
|
||||
version = "0.3.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1"
|
||||
dependencies = [
|
||||
"fearless_simd 0.4.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hayro-syntax"
|
||||
@@ -2018,26 +2036,13 @@ version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0edeafd70aa2db743de8ede8637d07ec87db05efe69cde371d03f1b185fcef27"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hayro-jbig2",
|
||||
"hayro-jpeg2000",
|
||||
"memchr",
|
||||
"rustc-hash 2.1.3",
|
||||
"smallvec",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hayro-write"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a0b3db92c4917aad24ff30d61413a9a7509ae88b27aee1ae34eff881e1d2723a"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hayro-syntax",
|
||||
"pdf-writer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.5.3"
|
||||
@@ -2246,7 +2251,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
|
||||
dependencies = [
|
||||
"byteorder-lite",
|
||||
"quick-error 2.0.1",
|
||||
"quick-error",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2424,20 +2429,19 @@ 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=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bumpalo",
|
||||
"flate2",
|
||||
"float-cmp",
|
||||
"gif",
|
||||
"hayro-write",
|
||||
"image-webp",
|
||||
"imagesize",
|
||||
"indexmap",
|
||||
"pdf-writer",
|
||||
"png",
|
||||
"rayon",
|
||||
"rustc-hash 2.1.3",
|
||||
"rustybuzz",
|
||||
"siphasher",
|
||||
@@ -2581,6 +2585,16 @@ dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minicov"
|
||||
version = "0.3.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -2624,7 +2638,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"bit-set 0.10.0",
|
||||
"bit-set",
|
||||
"bitflags 2.13.2",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
@@ -2691,6 +2705,15 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-derive"
|
||||
version = "0.4.2"
|
||||
@@ -3088,6 +3111,12 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "oorandom"
|
||||
version = "11.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.4.4"
|
||||
@@ -3360,15 +3389,6 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "presser"
|
||||
version = "0.3.1"
|
||||
@@ -3399,52 +3419,18 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
|
||||
|
||||
[[package]]
|
||||
name = "proptest"
|
||||
version = "1.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
|
||||
dependencies = [
|
||||
"bit-set 0.8.0",
|
||||
"bit-vec 0.8.0",
|
||||
"bitflags 2.13.2",
|
||||
"num-traits",
|
||||
"rand",
|
||||
"rand_chacha",
|
||||
"rand_xorshift",
|
||||
"regex-syntax",
|
||||
"rusty-fork",
|
||||
"tempfile",
|
||||
"unarray",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "1.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.38.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.41.0"
|
||||
@@ -3475,44 +3461,6 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_xorshift"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
|
||||
dependencies = [
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "range-alloc"
|
||||
version = "0.1.5"
|
||||
@@ -3627,12 +3575,6 @@ dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "renderdoc-sys"
|
||||
version = "1.1.0"
|
||||
@@ -3719,18 +3661,6 @@ version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "rusty-fork"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"quick-error 1.2.3",
|
||||
"tempfile",
|
||||
"wait-timeout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustybuzz"
|
||||
version = "0.20.1"
|
||||
@@ -4368,7 +4298,7 @@ dependencies = [
|
||||
"fax",
|
||||
"flate2",
|
||||
"half",
|
||||
"quick-error 2.0.1",
|
||||
"quick-error",
|
||||
"weezl",
|
||||
"zune-jpeg",
|
||||
]
|
||||
@@ -4515,12 +4445,6 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unarray"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-bidi"
|
||||
version = "0.3.18"
|
||||
@@ -4617,7 +4541,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",
|
||||
@@ -4632,7 +4556,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",
|
||||
@@ -4676,15 +4600,6 @@ version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wait-timeout"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
@@ -4760,6 +4675,45 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-test"
|
||||
version = "0.3.79"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae7499dfd45780a0a91d7ee6bb9ac51970a4479a41a89da443fdda5a39547d42"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"cast",
|
||||
"js-sys",
|
||||
"libm",
|
||||
"minicov",
|
||||
"nu-ansi-term",
|
||||
"num-traits",
|
||||
"oorandom",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-bindgen-test-macro",
|
||||
"wasm-bindgen-test-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-test-macro"
|
||||
version = "0.3.79"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b84b5ac638bfb168196a1a461fcc8f46a294a18b1b6be52133b4e0db122cc9f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-test-shared"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f692aa943ccd88363733b77063f32cfed5bc6cbea8e6e8b251b302f881606fe"
|
||||
|
||||
[[package]]
|
||||
name = "wayland-backend"
|
||||
version = "0.3.17"
|
||||
@@ -4879,7 +4833,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quick-xml 0.41.0",
|
||||
"quick-xml",
|
||||
"quote",
|
||||
]
|
||||
|
||||
@@ -4974,8 +4928,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"bit-set 0.10.0",
|
||||
"bit-vec 0.9.1",
|
||||
"bit-set",
|
||||
"bit-vec",
|
||||
"bitflags 2.13.2",
|
||||
"bytemuck",
|
||||
"cfg_aliases",
|
||||
@@ -5047,7 +5001,7 @@ dependencies = [
|
||||
"android_system_properties",
|
||||
"arrayvec",
|
||||
"ash",
|
||||
"bit-set 0.10.0",
|
||||
"bit-set",
|
||||
"bitflags 2.13.2",
|
||||
"block2 0.6.2",
|
||||
"bytemuck",
|
||||
@@ -5665,7 +5619,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.6.1"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+16
-5
@@ -4,10 +4,10 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.6.1"
|
||||
version = "0.10.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
rust-version = "1.95"
|
||||
repository = "https://github.com/storytold/deckcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
@@ -49,7 +49,9 @@ rayon = "1"
|
||||
unicode-bidi = "0.3"
|
||||
unicode-segmentation = "1"
|
||||
unicode-linebreak = "0.1"
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
# Base features are wasm-safe (`std` only). The non-wasm `clock` feature is requested
|
||||
# per-crate for targets that read the local clock (see crates/text).
|
||||
chrono = { version = "0.4", default-features = false, features = ["std"] }
|
||||
|
||||
# text / rendering / formats
|
||||
skrifa = "0.47"
|
||||
@@ -57,9 +59,10 @@ harfrust = "0.13"
|
||||
vello_cpu = "0.2"
|
||||
vello_common = { version = "0.2", default-features = false }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] }
|
||||
krilla = { version = "0.8", features = ["pdf"] }
|
||||
craft-media = { git = "https://github.com/storytold/craft-libs", rev = "f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4", version = "=0.1.0", default-features = false }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.1.0", default-features = false, features = ["write", "write-simple-text"] }
|
||||
i_overlay = "9"
|
||||
quick-xml = "0.38"
|
||||
quick-xml = "0.41"
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
|
||||
# ui
|
||||
@@ -82,14 +85,22 @@ self_named_constructors = "allow"
|
||||
large_enum_variant = "allow"
|
||||
result_large_err = "allow"
|
||||
|
||||
# Line tables keep file and line in panics and backtraces; full debug info
|
||||
# for every test binary and dependency would multiply the target dir size.
|
||||
# The test profile inherits both settings.
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
debug = false
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 1
|
||||
debug = "line-tables-only"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = "thin"
|
||||
codegen-units = 16
|
||||
debug = 0
|
||||
|
||||
[patch.crates-io]
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.8.2" }
|
||||
|
||||
@@ -19,6 +19,51 @@ list (author, source, license) is in ATTRIBUTION.md.
|
||||
ATTRIBUTION.md (Inter, Carlito, Caladea, Liberation, Montserrat, Lato, Open Sans, Roboto,
|
||||
Merriweather, Playfair Display, Poppins, Nunito Sans, BIZ UD, Shippori Mincho, Noto Sans
|
||||
Arabic) under the SIL Open Font License 1.1; their licence texts ship with release packages.
|
||||
- Lucide icon paths embedded in crates/engine/src/icon_data.rs (Insert › Icons), release 1.56.0,
|
||||
commit 440a1e1dfb1e712505308da46bead3c28b1f1398, https://github.com/lucide-icons/lucide.
|
||||
ISC License, Copyright (c) 2026 Lucide Icons and Contributors. Icons Lucide derives from Feather
|
||||
are MIT, Copyright (c) 2013-present Cole Bemis; which names those are is the list in Lucide's
|
||||
LICENSE at that commit. Among the paths embedded here, the Feather-derived names are arrow-down,
|
||||
arrow-left, arrow-right, arrow-up, at-sign, calendar, check, chevron-right, clipboard, clock,
|
||||
compass, download, info, key, link, lock, minus, moon, move, music, plus, search, target and x.
|
||||
|
||||
ISC License
|
||||
|
||||
Copyright (c) 2026 Lucide Icons and Contributors
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted, provided that the above
|
||||
copyright notice and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
The MIT License (MIT) (for the Feather-derived icons)
|
||||
|
||||
Copyright (c) 2013-present Cole Bemis
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
Code dependencies
|
||||
|
||||
@@ -27,3 +72,11 @@ Code dependencies
|
||||
https://github.com/pdeljanov/Symphonia.
|
||||
- The H.264, HEVC, VP9, AV1, Opus, MP4, Matroska and Ogg crates under crates/ are ported from
|
||||
FilmCraft (same owner, MIT OR Apache-2.0).
|
||||
|
||||
- Krilla 0.8.2 (https://github.com/LaurenzV/krilla) is used through craft-pdf under
|
||||
MIT OR Apache-2.0. craft-libs vendors that version with page/surface unwind recovery
|
||||
and rejection of serialization after failed cleanup. Its upstream licence files are
|
||||
retained in craft-libs/licenses/pdf-writer. Its bundled Compact ICC Profiles by Clinton Ingram
|
||||
(https://github.com/saucecontrol/Compact-ICC-Profiles) are CC0-1.0; the profile
|
||||
licence is retained in licenses/pdf-writer/LICENSE-ICC.txt and exact files, sources
|
||||
and hashes are in ATTRIBUTION.md.
|
||||
|
||||
@@ -7,15 +7,19 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.deckcraft.png" width="128" height="128" alt="DeckCraft icon: a presentation screen with a bar chart on an orange tile">
|
||||
</p>
|
||||
|
||||
<h1 align="center">DeckCraft</h1>
|
||||
|
||||
<p align="center">
|
||||
<b>Presentations and slide shows; an open-source, clean-room reimplementation of Microsoft PowerPoint, rebuilt in pure Rust.</b>
|
||||
<b>Presentations and slide shows; an open-source, modern presentation and slide designer, built in pure Rust.</b>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
Build decks with themes, layouts, shapes, pictures, tables, charts, transitions and animations,
|
||||
open and save PowerPoint files, and present them full screen. Native on macOS, Windows, Linux
|
||||
open and save .pptx presentation files, and present them full screen. Native on macOS, Windows, Linux
|
||||
and FreeBSD, and in the browser. Every button is also a command that the CLI and AI agents
|
||||
(through MCP) can drive.
|
||||
</p>
|
||||
@@ -24,7 +28,10 @@
|
||||
<img alt="Status: early development" src="https://img.shields.io/badge/status-early%20development-f26b1d?style=flat-square">
|
||||
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue?style=flat-square">
|
||||
<img alt="Written in Rust" src="https://img.shields.io/badge/written%20in-Rust-b7410e?style=flat-square">
|
||||
<img alt="Platforms" src="https://img.shields.io/badge/platforms-macOS%20·%20Windows%20·%20Linux%20·%20BSD%20·%20Web-555?style=flat-square">
|
||||
<img alt="Platforms" src="https://img.shields.io/badge/platforms-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20BSD%20%C2%B7%20Web-555?style=flat-square">
|
||||
<br>
|
||||
<a href="https://sonarcloud.io/project/overview?id=storytold_deckcraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
|
||||
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/deckcraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -74,8 +81,8 @@ Today it can:
|
||||
audio and video (MP3, AAC, FLAC, ALAC, Ogg, Opus, WAV, AIFF; H.264, HEVC, VP9, AV1).
|
||||
- **Present**: transitions (including Morph), entrance/emphasis/exit and motion-path animations,
|
||||
by-paragraph builds, full-screen slide show, presenter view, pen, rehearse timings, media playback.
|
||||
- **Exchange files**: open and save PowerPoint `.pptx`, export PDF (slides, notes pages, handouts
|
||||
with selectable text), PNG/JPEG and outlines. Unsaved work is kept by AutoRecover.
|
||||
- **Exchange files**: open and save .pptx presentations, export PDF (slides, notes pages, handouts
|
||||
with selectable text), PNG/JPEG, an animated GIF of the deck and outlines. Unsaved work is kept by AutoRecover.
|
||||
- **Automate**: 200+ commands with undo, all scriptable from the CLI, a JSON control channel and an
|
||||
MCP server for AI agents.
|
||||
|
||||
@@ -97,6 +104,9 @@ Today it can:
|
||||
|
||||
## Getting started
|
||||
|
||||
PDF export uses the shared `craft-pdf` writer with import features disabled. See
|
||||
[`crates/pdf/README.md`](crates/pdf/README.md) for the dependency and validation requirements.
|
||||
|
||||
```sh
|
||||
git clone https://github.com/storytold/deckcraft
|
||||
cd deckcraft
|
||||
@@ -129,7 +139,7 @@ settings directory, beside `ui.json` and `prefs.json` (Linux `~/.config/deckcraf
|
||||
`$XDG_CONFIG_HOME/deckcraft/logs/`; macOS `~/Library/Application Support/DeckCraft/logs/`; Windows
|
||||
`%APPDATA%\DeckCraft\logs\`). A start from a desktop menu or the Dock has no terminal, so this file
|
||||
is what to attach to a bug report: the crash guard's panic report, a failed crash-recovery save,
|
||||
PowerPoint parts skipped on import and audio output problems land there. Each launch moves the
|
||||
Parts skipped on import and audio output problems land there. Each launch moves the
|
||||
previous log to `deckcraft.1.log` (and that one to `deckcraft.2.log`), so the log of a run that
|
||||
crashed survives the next start. The file stops growing at 16 MiB. `--version` writes no file.
|
||||
|
||||
@@ -150,12 +160,13 @@ deckcraft-cli commands format. # list commands
|
||||
deckcraft-cli run --sample --cmd 'slide.new={"layout":"titleOnly","title":"Hello"}' --save hello.deckcraft
|
||||
deckcraft-cli mcp # MCP server over stdio (headless)
|
||||
deckcraft-cli mcp --connect 7990 # drive the running app (deckcraft --control 7990)
|
||||
deckcraft-cli mcp --automation-read-root DIR --automation-write-root DIR # confine the headless agent to folders
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
|
||||
DeckCraft is in **alpha**. It covers **79% of PowerPoint's features by breadth** (92% of the core
|
||||
ones, measured) and is about **55% of the way to replacing PowerPoint for real work** (estimated).
|
||||
DeckCraft is in **alpha**. It covers **84% of the features tracked in [`docs/target-app-parity.md`](docs/target-app-parity.md) by breadth** (92% of the core
|
||||
ones, measured) and is about **55% ready for real work across all tracked capabilities** (estimated).
|
||||
See [ROADMAP.md](ROADMAP.md) for the stage, numbers by dimension and what's next,
|
||||
[docs/target-app-parity.md](docs/target-app-parity.md) for the full assessment,
|
||||
[docs/gaps.md](docs/gaps.md) for every known shortfall and
|
||||
@@ -190,6 +201,10 @@ Remove-Item Env:WGPU_BACKEND # restore the default for later
|
||||
An explicit `gl` override can bring the driver crash back on affected systems. The macOS, Linux
|
||||
and web backend defaults are unchanged.
|
||||
|
||||
DirectX 12 shaders are compiled with FXC, which is part of Windows, so no `dxcompiler.dll` or
|
||||
`dxil.dll` is needed ("Parent device is lost" at start, #153). `$env:WGPU_DX12_COMPILER = "dxc"`
|
||||
(or `"auto"`) selects wgpu's DXC handling again for troubleshooting.
|
||||
|
||||
### macOS
|
||||
|
||||
| Build | File | Notes |
|
||||
@@ -209,6 +224,41 @@ and web backend defaults are unchanged.
|
||||
|
||||
RISC-V 64 (riscv64): `deckcraft-<ver>-linux-riscv64.tar.gz`, a tarball only (cross-compiled; needs glibc 2.39+, e.g. Ubuntu 24.04+).
|
||||
|
||||
**Runtime libraries.** The window-system, GPU and file-dialog libraries DeckCraft needs are opened
|
||||
when the app starts (`dlopen` through `x11-dl`, `xkbcommon-dl`, `ash`, `glutin` and `rfd`) instead
|
||||
of being linked in, so `ldd` shows none of them: an install that satisfies the linked dependencies
|
||||
can still fail at start-up with a loader error and no hint of what to install. Only
|
||||
`libasound.so.2` (slide-show sound) is a hard link-time dependency, and only of the `deckcraft`
|
||||
GUI — `deckcraft-cli` uses neither sound nor a display. The `.deb` and `.rpm` declare the set in
|
||||
`packaging/linux/nfpm.yaml`; a tarball install or a musl system has to provide it itself:
|
||||
|
||||
```sh
|
||||
apk add libx11 libxcursor libxrandr libxi libxkbcommon xkeyboard-config \
|
||||
wayland-libs-client alsa-lib \
|
||||
vulkan-loader mesa-vulkan-swrast \
|
||||
mesa-gl mesa-egl mesa-dri-gallium \
|
||||
xdg-desktop-portal
|
||||
```
|
||||
|
||||
Alpine Linux `v3.22` names above; Void's musl variant uses the same `/lib/ld-musl-x86_64.so.1`
|
||||
loader and the same libraries as `libX11 libXcursor libXrandr libXi libxkbcommon xkeyboard-config
|
||||
wayland alsa-lib vulkan-loader mesa xdg-desktop-portal`. There is no musl build among the release
|
||||
artifacts yet — this is what a build from source on musl needs at run time, and what a future musl
|
||||
artifact will need (#954).
|
||||
|
||||
- `mesa-vulkan-swrast` is the software Vulkan driver: it runs anywhere, VMs and containers
|
||||
included. On a real GPU install your vendor's driver instead (`mesa-vulkan-intel`,
|
||||
`mesa-vulkan-ati`, `mesa-vulkan-nouveau`, …).
|
||||
- Without Vulkan the app falls back to CPU rendering through Mesa's GL drivers. It works, but
|
||||
slowly — a driver limit, not a DeckCraft bug.
|
||||
- `xdg-desktop-portal` (with `xdg-desktop-portal-gtk` or `-kde`; `-wlr` has no file chooser, so
|
||||
on wlroots compositors install `zenity`) is what the Open and Save dialogs talk to; `xkeyboard-config` holds the XKB keyboard data `libxkbcommon` reads.
|
||||
- fontconfig and freetype are not needed: DeckCraft reads `/usr/share/fonts` itself and embeds its
|
||||
presentation fonts.
|
||||
|
||||
Building from source on musl also needs `build-base pkgconf alsa-lib-dev` (`alsa-lib-dev` is the
|
||||
ALSA headers).
|
||||
|
||||
### FreeBSD
|
||||
|
||||
| Build | File |
|
||||
@@ -271,15 +321,13 @@ Bundled fonts, icons, images and other assets keep their own open licenses; each
|
||||
with its author, source and license in [ATTRIBUTION.md](ATTRIBUTION.md).
|
||||
|
||||
DeckCraft's themes, layouts, shape geometry, icons and sample decks are original work, drawn or
|
||||
generated in code; no Microsoft artwork, fonts, themes or templates are used.
|
||||
generated in code; no proprietary artwork, fonts, themes or templates are used.
|
||||
|
||||
The ArtCraft name, wordmark and logos in [`docs/brand/`](docs/brand/) are trademarks of the
|
||||
ArtCraft Team and are not covered by this license. They may be used only unmodified, and only as
|
||||
part of this repository and DeckCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
|
||||
Forks and modified versions must remove them.
|
||||
|
||||
<sub>Microsoft and PowerPoint are trademarks of the Microsoft group of companies. Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. DeckCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Microsoft Corporation or Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
|
||||
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
|
||||
|
||||
+238
-24
@@ -2,7 +2,10 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~20 points and ~120–210 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-23 · **Change:** trivial (progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** trivial (fill and outline edits reach group members, #1079; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-20 · **Change:** trivial (empty chart number-format sections hide values; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (Symbol-font characters (U+F0xx) remap to the installed Symbol face's real Unicode on macOS, so they draw from Symbol instead of a look-alike; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON
|
||||
format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere
|
||||
@@ -14,8 +17,8 @@ format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and av
|
||||
|
||||
| | Value | Kind |
|
||||
|---|---|---|
|
||||
| **Feature breadth** | **79%** weighted over 191 PowerPoint features (P0 92%, P1 75%, P2 36%) | measured: `cargo xtask parity` over [docs/parity-checklist.md](docs/parity-checklist.md) |
|
||||
| Menu-bar coverage | 59% of 217 app-specific PowerPoint menu items matched by name (lower bound) | measured: script over the PowerPoint menu dump and the 226 commands |
|
||||
| **Feature breadth** | **84%** weighted over 191 PowerPoint features (P0 92%, P1 82%, P2 55%) | measured: `cargo xtask parity` over [docs/parity-checklist.md](docs/parity-checklist.md) |
|
||||
| Menu-bar coverage | 59% of 217 app-specific PowerPoint menu items matched by name (lower bound) | measured: script over the PowerPoint menu dump and the 226 commands at d556ff9 (the registry has grown since; not re-measured) |
|
||||
| **Ready for real work** | **~55%** | estimated: weighted dimensions below |
|
||||
| Mainstream practitioner | ~53% | estimated: weekly-use areas × discounts for interaction (×0.92), stability (×0.92) and `.pptx` exchange (×0.88), [method](docs/target-app-parity.md#mainstream-practitioner-and-essentials-user) |
|
||||
| Essentials user | ~62% | estimated: core features × discounts for launch (×0.90), clarity (×0.95) and opening received files (×0.90), [method](docs/target-app-parity.md#mainstream-practitioner-and-essentials-user) |
|
||||
@@ -45,7 +48,7 @@ EMF/SVG pictures, the data-loss and startup-crash bugs (#60, #40, #59) and the b
|
||||
|
||||
| Dimension | Ready | Hours to full | Doc |
|
||||
|---|---:|---|---|
|
||||
| Features (breadth 79% measured, with depth) | ~62% | 140–240 | [docs/parity-checklist.md](docs/parity-checklist.md) |
|
||||
| Features (breadth 84% measured, with depth) | ~62% | 140–240 | [docs/parity-checklist.md](docs/parity-checklist.md) |
|
||||
| UI/UX fidelity | ~60% | 30–50 | [docs/ui-parity.md](docs/ui-parity.md) |
|
||||
| File formats (PPTX, PDF, legacy, export) | ~58% | 60–110 | [docs/file-format-parity.md](docs/file-format-parity.md) |
|
||||
| Hardware (GPU raster, video decode, displays, pens) | ~40% | 25–45 | [docs/hardware-parity.md](docs/hardware-parity.md) |
|
||||
@@ -61,26 +64,26 @@ EMF/SVG pictures, the data-loss and startup-crash bugs (#60, #40, #59) and the b
|
||||
|
||||
| Area | Breadth (measured) | Ready (est.) | Hours | Doc |
|
||||
|---|---:|---:|---|---|
|
||||
| Application shell | 71% | ~55% | 8–14 | [ui-parity](docs/ui-parity.md) |
|
||||
| Slides and sections | 90% | ~85% | 3–5 | [checklist](docs/parity-checklist.md) |
|
||||
| Views | 90% | ~80% | 3–6 | [ui-parity](docs/ui-parity.md) |
|
||||
| Masters and themes | 85% | ~70% | 8–14 | [checklist](docs/parity-checklist.md) |
|
||||
| Shapes | 86% | ~75% | 8–14 | [ui-parity](docs/ui-parity.md) |
|
||||
| Format (fill, line, effects) | 81% | ~65% | 8–14 | [ui-parity](docs/ui-parity.md) |
|
||||
| Application shell | 81% | ~55% | 8–14 | [ui-parity](docs/ui-parity.md) |
|
||||
| Slides and sections | 92% | ~85% | 3–5 | [checklist](docs/parity-checklist.md) |
|
||||
| Views | 98% | ~80% | 3–6 | [ui-parity](docs/ui-parity.md) |
|
||||
| Masters and themes | 92% | ~70% | 8–14 | [checklist](docs/parity-checklist.md) |
|
||||
| Shapes | 91% | ~75% | 8–14 | [ui-parity](docs/ui-parity.md) |
|
||||
| Format (fill, line, effects) | 83% | ~65% | 8–14 | [ui-parity](docs/ui-parity.md) |
|
||||
| Arrange | 100% | ~90% | 1–2 | [ui-parity](docs/ui-parity.md) |
|
||||
| Text | 80% | ~62% | 18–30 | [gaps](docs/gaps.md#features) |
|
||||
| Tables | 89% | ~75% | 3–6 | [checklist](docs/parity-checklist.md) |
|
||||
| Text | 83% | ~62% | 18–30 | [gaps](docs/gaps.md#features) |
|
||||
| Tables | 97% | ~75% | 3–6 | [checklist](docs/parity-checklist.md) |
|
||||
| Charts | 65% | ~45% | 12–20 | [gaps](docs/gaps.md#charts-data-grid-elements-and-the-newer-chart-types) |
|
||||
| SmartArt | 33% | ~25% | 10–16 | [gaps](docs/gaps.md#smartart-is-pre-drawn-shapes-only) |
|
||||
| Pictures | 48% | ~45% | 12–20 | [file formats](docs/file-format-parity.md) |
|
||||
| SmartArt | 50% | ~35% | 8–14 | [gaps](docs/gaps.md#smartart-editing-is-shallow) |
|
||||
| Pictures | 60% | ~45% | 12–20 | [file formats](docs/file-format-parity.md) |
|
||||
| Media | 67% | ~60% | 8–14 | [file formats](docs/file-format-parity.md) |
|
||||
| Transitions | 92% | ~80% | 4–8 | [animation-parity](docs/animation-parity.md) |
|
||||
| Animations | 79% | ~65% | 10–16 | [animation-parity](docs/animation-parity.md) |
|
||||
| Slide show and presenter view | 66% | ~60% | 10–16 | [slideshow-parity](docs/slideshow-parity.md) |
|
||||
| Review | 60% | ~45% | 8–14 | [gaps](docs/gaps.md) |
|
||||
| Draw (ink) | 33% | ~30% | 5–9 | [hardware-parity](docs/hardware-parity.md) |
|
||||
| Undo and clipboard | 88% | ~75% | 3–5 | [ui-parity](docs/ui-parity.md) |
|
||||
| Files (commands; formats above) | 69% | ~50% | in file formats | [file formats](docs/file-format-parity.md) |
|
||||
| Transitions | 100% | ~80% | 4–8 | [animation-parity](docs/animation-parity.md) |
|
||||
| Animations | 85% | ~65% | 10–16 | [animation-parity](docs/animation-parity.md) |
|
||||
| Slide show and presenter view | 68% | ~60% | 10–16 | [slideshow-parity](docs/slideshow-parity.md) |
|
||||
| Review | 70% | ~45% | 8–14 | [gaps](docs/gaps.md) |
|
||||
| Draw (ink) | 50% | ~30% | 5–9 | [hardware-parity](docs/hardware-parity.md) |
|
||||
| Undo and clipboard | 100% | ~75% | 3–5 | [ui-parity](docs/ui-parity.md) |
|
||||
| Files (commands; formats above) | 75% | ~50% | in file formats | [file formats](docs/file-format-parity.md) |
|
||||
|
||||
## Languages
|
||||
|
||||
@@ -111,8 +114,11 @@ Ranked; detail and the full beta plan in [docs/roadmap.md](docs/roadmap.md).
|
||||
|
||||
1. **No data loss, no startup crashes:** Close's save prompt (#60), Windows GPU crashes (#40, #59),
|
||||
image paste (#53), editor soak tests. 10–20 h.
|
||||
2. **PPTX on real decks:** owned corpus, round-trip checks, fixes; #70, #74, #77; EMF/WMF and SVG.
|
||||
36–64 h.
|
||||
2. **PPTX on real decks:** the owned corpus now round-trips comments, links and actions, custom
|
||||
shows, rotated groups, inherited placeholders, text effects and body layout, theme effects,
|
||||
fills, bevels, connectors, tables, charts, animations, media, sections, masters, 3-D models
|
||||
and animation bounce end. Still open: a corpus of real decks, #70, #74, #77, EMF+ records,
|
||||
MicroType Express font subsets and SVG. 36–64 h.
|
||||
3. **Localization:** catalog, system language (#15), zh-Hans, es, fr, de, ja with IME. 27–46 h.
|
||||
4. **Output:** vector PDF, printing. 10–16 h.
|
||||
5. **Depth that users hit:** font weights (#69), chart data and elements, Animation Pane timeline,
|
||||
@@ -128,10 +134,139 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-23 | Chart text colour: `chart.options {textColor}` and Font Color on a selected chart colour its title, axes, legend and data labels; the colour is written to the chart part (`c:txPr` on the axes, legend, labels and chart, the title's run defaults) so an imported chart keeps its `txPr` colours through an edit and a save; text without a colour of its own takes PowerPoint's default, the theme's text colour at 65 % plus 35 % (595959 on a light theme, light grey on a dark one) |
|
||||
| 2026-10-23 | Format Background ▸ Apply to All also sets the background of the slide masters the slides use (and clears their layouts' overrides), so new slides keep it (#891) |
|
||||
| 2026-10-22 | Table text takes the master's other-text style (`otherStyle`), shapes and text boxes the presentation's default text style, as PowerPoint for Mac draws them (synthetic probe: 14 pt cells under a 14 pt `otherStyle` and a 26 pt `defaultTextStyle`); a private deck's table had been drawn 20 pt instead of 18 |
|
||||
| 2026-10-18 | Find (Ctrl+F): "Find what:" has the keyboard as soon as the dialog opens, and keeps it after Enter finds a match, so the search can be typed and changed without clicking the field (#951) |
|
||||
| 2026-10-18 | Duplicate (Ctrl+D) repeats a move: duplicate a shape, move the copy, and the next Duplicate places its copy the same distance further on, as in PowerPoint (#966) |
|
||||
| 2026-10-22 | Slide Show: while animations play, each frame draws only the slide's shapes over the slide's backdrop (background, master and layout graphics), rendered once and ahead of time. A full-slide picture with a glow in the layout re-rendered at every frame held animations to 2–3 frames a second (~390 ms a frame at 1080p); now ~28 ms |
|
||||
| 2026-10-22 | Animation Pane ▸ Play From: with an effect selected, the pane's play button starts the canvas preview at that effect, with the effects before it already played. With nothing selected it is still Play All |
|
||||
| 2026-10-21 | Opening a .pptx puts the text of shapes without an anchor at the top even when the theme's shape defaults (`spDef`) centre new shapes, as PowerPoint for Mac draws them; checked with a synthetic probe and on a private 166-deck corpus (51 slides closer to PowerPoint's PDF, none further) |
|
||||
| 2026-10-23 | A shape with no fill and no line (a text box) no longer draws its shape-level glow as a halo round its box; the text's own glow is all PowerPoint shows |
|
||||
| 2026-10-23 | Normal view: a slide zoomed past the window gets a horizontal and a vertical scroll bar on the axes that overflow; drag the thumb or click the track to reach every edge (#1157) |
|
||||
| 2026-10-22 | Shape Fill and Shape Outline format the members of a selected group, including nested groups, in one undo step. Fill transparency retains each member's effective colour (#1079) |
|
||||
| 2026-10-21 | 3-D contours, extrusion depths and bevels keep their size in points inside a group whose child coordinates scale its shapes: a 1 pt contour on a small extruded cube in a legacy group drew hundreds of points wide, a band across the slide |
|
||||
| 2026-10-21 | Groups nest at most 32 deep (`arrange.group` refuses more), as deep as PPTX import reads and slides draw: a deck grouped 39 or more levels deep saved without complaint and then could not be opened again |
|
||||
| 2026-10-20 | Chart number formats with explicit empty positive, negative or zero sections hide the corresponding labels, including all values with three semicolons; a blank whole format still uses General. Synthetic formatter regressions |
|
||||
| 2026-10-22 | Chart bars and their legend keys draw the series' own pattern fill (`a:pattFill`, now read and written) and draw nothing for `a:noFill`, with the series outline where it has one, as PowerPoint for Mac does (synthetic probe); they took the palette's colour before. Lines, areas and markers use a pattern's foreground colour |
|
||||
| 2026-10-21 | Reuse Slides: pick which slides of the other presentation to insert from a list (`slide.reuseList`, #1103) |
|
||||
| 2026-10-18 | Native chart sidecar bytes move from JSON arrays into referenced ZIP entries, with shared media byte/item limits and bounded reads; older inline sidecars still load. Synthetic format regressions cover large workbooks and malformed members |
|
||||
| 2026-10-18 | Slide thumbnails and the Slide Sorter render only the slides in view: on a deck of more than 600 slides they used to evict their own thumbnails, so the pane flashed white and never finished (#969) |
|
||||
| 2026-10-18 | Resizing with the mouse snaps the edge being dragged to the slide, guides and other shapes: a fixed edge that already lined up used to take the snap and leave the dragged edge free, and a fixed edge near a line shifted the dragged one (#961) |
|
||||
| 2026-10-18 | Slide show: a video or audio clip is looked up on the slide it belongs to, so slides whose shape ids repeat (the PPTX import numbers each slide afresh) no longer play each other's clips (#994) |
|
||||
| 2026-10-18 | About: Contributors and Models get their own fixed-size block below the tab row, so the tab row is no longer squeezed into the credits and the tabs keep answering (#843) |
|
||||
| 2026-10-19 | README: the Linux runtime libraries a tarball or musl install has to provide — the `dlopen`ed X11/Vulkan/GL/dialog libraries with their Alpine and Void package names, the software-Vulkan fallback, and why fontconfig and freetype are not among them (#955) |
|
||||
| 2026-10-17 | Opening a file caps absurd effect sizes (glow, soft edges, shadows, reflections, on shapes, text and table text) at what Format Shape allows: a glow of 10^18 pt in a .pptx made rendering ask for 34 GB and abort |
|
||||
| 2026-10-17 | Opening a .deckcraft file whose shapes repeat an id gives the repeats fresh ids (groups, layouts and masters too), so the deck validates and edits by id find one shape |
|
||||
| 2026-10-17 | Edit Points: turning a shape into a freeform is one undo step, so Undo brings the original shape back (it used to stay a freeform) |
|
||||
| 2026-10-17 | While a placeholder's text is being edited, a click on a shape in front of it selects that shape instead of moving the caret, as in PowerPoint (#931) |
|
||||
| 2026-10-16 | Symbol-font characters: a run's U+F0xx symbol code (from `<a:sym typeface="Symbol"/>`) now draws from the installed Symbol face even when it has no private-use mapping — Apple's Symbol on macOS maps the glyphs to real Unicode, so the code is remapped to that character (the Adobe Symbol encoding) instead of falling to a look-alike. Bullets and metafile text take the same remap |
|
||||
| 2026-10-15 | Native saves keep an imported chart's embedded workbook, drawings and relationships; headless PPTX → edit → native save/reopen → PPTX tests verify the payloads and stale-workbook rejection |
|
||||
| 2026-10-18 | Charts: a series' own data-label setting is honoured — numbers PowerPoint shows on a graph no longer disappear, and a bare `c:showVal` means on (#868) |
|
||||
| 2026-10-15 | Render: quick styles render on empty placeholders — the fill gate now checks for style and line overrides too (#832) |
|
||||
| 2026-10-15 | Canvas: the resize cursor follows the handle's rotated diagonal — rotated shapes showed the wrong diagonal cursor (#745) |
|
||||
| 2026-10-20 | Tests: the Options dialog's Cancel, Esc and close button put the settings back the way they were (#878) |
|
||||
| 2026-10-20 | Tests: Ctrl+A in the slide sorter selects every slide, as in PowerPoint (#936) |
|
||||
| 2026-10-18 | Thumbnails: navigating slides with the arrow keys scrolls the thumbnail pane to keep the current slide visible (#917) |
|
||||
| 2026-10-15 | PPTX trigger sequences keep separate On Click steps, including paragraph builds; synthetic import/edit/save/reopen and headless show regressions |
|
||||
| 2026-10-16 | Export: PNG with a transparent background — the slide background is left out, only the slide's content is drawn (Export… dialog, File › Export, web download, `file.export`/`file.render` `transparentBackground`, `deckcraft-cli render --transparent`) (#784) |
|
||||
| 2026-10-16 | Arrange ▸ Align: Align to Slide and Align Selected Objects, as in PowerPoint. One object aligns to the slide and several to each other until the other choice is ticked; the choice holds for that selection and also drives Distribute |
|
||||
| 2026-10-16 | Fitted canvas: discrete mouse-wheel steps navigate immediately, including rapid and batched input, while trackpad gestures retain their cooldown and zoomed slides still pan. |
|
||||
| 2026-10-16 | PPTX import: a paragraph build edited in PowerPoint (paragraphs with their own start, other shapes' effects in between, builds by 2nd-level paragraphs) takes PowerPoint's clicks in the show; it used to be rebuilt as one build, 34 clicks instead of 10 on the reporter's slide. Paragraph builds with sub-points save as PowerPoint writes them (#844) |
|
||||
| 2026-10-15 | CLI: .txt and .md export includes table cell text, which it used to skip silently (#814) |
|
||||
| 2026-10-15 | A `.pptx` slide that can't be read is reported, not dropped in silence: `lostSlides` from `file.open`, a message and a repaired copy in the app, a warning from `deckcraft-cli` (#203) |
|
||||
| 2026-10-16 | AutoFormat as you type: ordinals (1st, 22nd, 103rd) get superscript suffixes after a space or punctuation; Tools ▸ AutoCorrect and `text.autocorrect {ordinals}` turn it off |
|
||||
| 2026-10-16 | A PowerPoint Show (`.ppsx`) opened from the command line or the file association starts its slide show, as in PowerPoint; File › Open still opens it for editing (#862) |
|
||||
| 2026-10-15 | Canvas: the Format and Animation Painter override the cursor to show the tool is armed (#830) |
|
||||
| 2026-10-15 | Edit Points: right-click a segment for Straight/Curved Segment, a point for Open/Close Path (`shape.segmentType`, `shape.openPath`, `shape.closePath`); Delete Segment and Add Point in the menu still to come |
|
||||
| 2026-10-15 | Design › Browse for Themes… with a `.pptx` or `.potx` brings its slide master and layouts, not just the theme: the master the current slide uses is replaced (a preserved one stays beside it), the template stretches to the deck's slide size, and each slide moves to the layout with the same name or kind and keeps its content. One undo step (#807) |
|
||||
| 2026-10-15 | Clicking the frame of a text box or placeholder selects the box whole, so it can be moved, duplicated or deleted; only a click inside puts the caret in the text (#787) |
|
||||
| 2026-10-15 | Review › Compare: Reject and Reject All drop revisions without applying them (`review.reject`); Accept All skips what was rejected |
|
||||
| 2026-10-15 | PDF export and rendering: `anchorCtr` centres the drawn text block — right-aligned text no longer ran past the box edge (#736) |
|
||||
| 2026-10-14 | connected MCP rejects unrelated, malformed, incomplete and oversized replies without replay; buffered unsolicited bytes force a fresh connection before sending (#718) |
|
||||
| 2026-10-14 | Empty bulleted and numbered paragraphs show gray markers while editing, including on first click and after Enter. Markers use the list style and stay out of slide shows and exports. |
|
||||
| 2026-10-13 | Interface font fallback continues to eligible installed faces when the first covering face is colour or exceeds the 48 MiB limit; document fallback order and the CJK/IME rendering assertions are retained (#101) |
|
||||
| 2026-10-13 | PDFW2: adopt the shared writer with native unwind recovery and explicit font-embedding failures; preserve app-owned export behavior and keep reader dependencies out of production |
|
||||
| 2026-10-13 | Eyedropper: Shape Fill, Shape Outline, Font Color and every other colour menu end with an Eyedropper that takes the colour drawn at a point of the slide (hover shows it, a click applies it, Esc cancels); `slide.colorAt` gives the same from scripts and MCP (#367) |
|
||||
| 2026-10-13 | Slide show: Honeycomb's hexagons cover the whole slide again — the bottom quarter stayed black until the endpoint (#420) |
|
||||
| 2026-10-13 | Copy and Cut from the ribbon, the slide's right-click menu and the menus put the text on the system clipboard, as Ctrl+C and Ctrl+X do (#431) |
|
||||
| 2026-10-13 | Video seeks and loops keep newly decoded frames and end-of-stream notifications; stale generations are filtered when consumed (upstream #416). Deterministic queue coverage and repeated seeks across H.264, HEVC, VP9 and AV1 |
|
||||
| 2026-10-14 | Presenter View handles the same keyboard navigation as the audience window, consumes handled keys before focused buttons, and remains available at the end screen. Regression tests cover presenter-only input, single-step navigation and Escape |
|
||||
| 2026-10-13 | EMF and WMF rectangle clip regions (`INTERSECTCLIPRECT`, `EXCLUDECLIPRECT`, `EXTSELECTCLIPRGN`) limit later drawing. EMF+ records are still skipped |
|
||||
| 2026-10-13 | Embedded fonts (`p:embeddedFontLst`) are read, written and used to draw. TrueType, OpenType and Embedded OpenType faces load; MicroType Express subsets are kept for the round trip and still draw in a stand-in |
|
||||
| 2026-10-13 | Codec parsers (#426): H.264 and HEVC quantiser offsets, Matroska block times, Opus granule timing and QuickTime PCM frame sizes are range-checked before the arithmetic, so a hostile number is a format error instead of an overflow |
|
||||
| 2026-10-13 | WordArt Transform: Shape Format ▸ Text Effects shows the 40 text warps and No Transform; `format.textWarp` applies one to the selected text. Adjust handles are still open |
|
||||
| 2026-10-13 | PPTX corpus round-trip for decks we author (open → save → reopen, package parts and text). A notes master's header and footer are kept instead of being replaced by an empty notes master |
|
||||
| 2026-10-13 | PPTX round trip of comments, hyperlinks and actions, custom shows, rotated groups, and placeholders that inherit from the layout. Paragraph default formatting (`defRPr`, including a link) and action sounds (the clip, stop-previous, and a custom show's "Show and return") are kept. Embedded fonts and comment threads stay open |
|
||||
| 2026-10-13 | PPTX round trip of text effects, autofit, columns, vertical and RTL text, tab stops, line spacing, theme effects, gradient and pattern fills, 3-D bevels and connectors. A run keeps its inner shadow, reflection and soft edge, and a bevel preset with no kept 3-D XML is written back. Embedded fonts and #70, #74, #77 stay open |
|
||||
| 2026-10-14 | PPTX: an owned corpus round-trips SmartArt and OLE kept as XML, audio and video bookmarks and trims, a section with a hidden slide, slide-number and date fields, ink (as freeform lines), 3-D models (`.glb`, including the fallback picture) and animation repeat, bounce end and triggers. Relationship-bearing SmartArt and OLE (#203), playback options (#74) and rewind (#77) stay open |
|
||||
| 2026-10-14 | Chart trendlines are drawn: linear, exponential, logarithmic, polynomial, power and moving average, with forecast and the equation and R² label. The value axis grows to include the fit |
|
||||
| 2026-10-14 | Chart trendlines round-trip (type, polynomial order, moving-average period, forecast, intercept, equation and R²). An owned corpus slice also checks combo charts with data labels, merged tables with diagonal borders, and text columns, autofit, vertical text and a WordArt warp |
|
||||
| 2026-10-14 | Insert › Icons: a gallery of Lucide stroke icons (release 1.56.0) inserted as custom shapes, no fill, round stroke scaled from the 24-unit design |
|
||||
| 2026-10-14 | Size and Position dialog: height, width, rotation, scale and lock aspect ratio in one undo step, from Shape Format › Size and the shape menu |
|
||||
| 2026-10-14 | Review: Previous and Next comment walk every comment in the deck; the Comments pane lists every slide and highlights the one you landed on |
|
||||
| 2026-10-13 | View › New Window opens a second window on the same presentation. The window has its own slide and shows edits made in the first window. Arrange All is not included |
|
||||
| 2026-10-14 | File › New starter decks: Briefing, Lesson, Pitch, Field Notes, Quarterly and Evening Talk, original text on our themes (`file.new {template}`, `file.templates`). The large template gallery is still open |
|
||||
| 2026-10-14 | Slide Sorter sections: Add Section, rename in place, collapse a section, and drag the bar to move that section and its slides |
|
||||
| 2026-10-14 | Text: an input method's composition in a table cell is underlined in place, with the caret and candidate window in that cell |
|
||||
| 2026-10-14 | Edit Photo Album (`insert.photoAlbumEdit`): reopen the album's slides and re-flow them in one undo step, with black and white, rotation, brightness, contrast and an album theme |
|
||||
| 2026-10-14 | Photo Album dialog (`insert.photoAlbum`): pictures and text boxes become slides in the open presentation, one undo step |
|
||||
| 2026-10-14 | Design › Colors › Customize Colors… opens Create New Theme Colors: the open master's scheme name and twelve theme colours, applied in one undo |
|
||||
| 2026-10-14 | Design › Fonts › Customize Fonts… opens Create New Theme Fonts: the open master's scheme name and its heading and body latin faces, applied in one undo |
|
||||
| 2026-10-14 | Alt Text: Mark as decorative opens checked when the selection already is, so OK no longer clears it |
|
||||
| 2026-10-14 | Insert Hyperlink: address, email, slide and ScreenTip open from the selection, so OK no longer replaces them with `https://` and an empty tip |
|
||||
| 2026-10-14 | Insert › Date and Time: the ribbon and Insert menu open a format list (datetime1–datetime13) with a language and Update automatically. OK inserts one field, or the shown text when Update is off. English has every format; en-GB, French, Japanese and Swedish list the numeric date and 24-hour time |
|
||||
| 2026-10-14 | View: the Zoom dialog takes a percent or Fit and applies it on OK. View ▸ Zoom opens it; the arrow still lists the presets |
|
||||
| 2026-10-13 | PPTX corpus round-trip for tables, charts, animations, media, sections and masters/layouts (open → save → reopen, package parts and text). A handout master's header, footer, date and slide number are kept |
|
||||
| 2026-10-13 | Playback stays with its document and its clip (#415): switching decks or closing the last one stops the previous sound, replacing a clip plays the new one, and the duration and decode caches keep the bytes they are keyed by |
|
||||
| 2026-10-13 | Slide show: a laser pointer (Cmd/Ctrl+L, the show's laser colour, no ink). Checklist row Partial. The presenter window does not move the dot |
|
||||
| 2026-10-14 | Slide Show: Show Media Controls writes the show setting and hides the playback bar and GIF button during the show |
|
||||
| 2026-10-14 | During a show, a typed slide number is shown and Enter goes to that slide |
|
||||
| 2026-10-14 | A custom show plays only its slides: Show in the Custom Shows dialog, Set Up Slide Show, or a click action |
|
||||
| 2026-10-14 | Presenter view pauses and resets its timer (T, R) and changes notes size (A-/A+, brackets). The clock shows local time. Second-display placement stays with #433 |
|
||||
| 2026-10-14 | See All Slides and zoom during a show: a thumbnail grid (G, or All), click to jump, and click-to-zoom at 2× (Z). Presenter view has Black, White, All slides and Zoom |
|
||||
| 2026-10-13 | The command palette skips commands that need a value, so searching for Export runs the export dialog (#79) |
|
||||
| 2026-10-14 | PNG and JPEG slide export record their resolution. The default 2 pixels per point is 144 dpi, so Preview and print show the slide's real size instead of twice that |
|
||||
| 2026-10-14 | Design ▸ Effects: named effect schemes (DeckCraft, Soft, Crisp, Lift, Glow, Plain) replace the theme's fills, lines and effects and leave colours and fonts. `design.effects` |
|
||||
| 2026-10-14 | Slide Master: Preserve on the open master or layout, and Title and Footers placeholder checkboxes. Two commands (`master.preserve`, `master.placeholders`) |
|
||||
| 2026-10-14 | Trim Media: start, end and fades open from the selected clip, so OK no longer resets them to zero |
|
||||
| 2026-10-14 | Format Background: a solid fill's transparency, and for a picture transparency, tile, offset, scale, alignment and mirror. Apply to All copies a picture background onto every slide. The same `design.background` command; dialogs stay 27 |
|
||||
| 2026-10-13 | Editor slide rasters sit on the physical pixel grid, so small text stays sharp on a Retina display instead of being blurred by a linear filter between pixels (#184) |
|
||||
| 2026-10-14 | Copy and paste with other apps: bold, italic and underline travel as HTML (Pages keeps them); a shape with no text still copies as DeckCraft objects so Ctrl+V pastes it (#182) |
|
||||
| 2026-10-13 | Animations ▸ Preview plays the current slide's animations on the editor canvas (Play All does the same); Escape or a click stops it. Checklist row Preview in editor re-scored done |
|
||||
| 2026-10-14 | A two-finger twist on the trackpad rotates the selected shapes, as one undo step. Pinch-zoom is unchanged |
|
||||
| 2026-10-14 | A trackpad swipe turns the page: sideways when the slide fits the window, and any flick during the slide show. One gesture turns one page. Pinch-zoom is unchanged |
|
||||
| 2026-10-14 | Format Shape: the Fill transparency slider shows the selected solid fill, and leaving it no longer clears that transparency |
|
||||
| 2026-10-13 | Web file picker: a picture, sound, video or outline finishes on the document and slide that asked for it, and is dropped if that presentation was closed or the picker dismissed (#414) |
|
||||
| 2026-10-14 | Paragraph dialog (Home › Paragraph launcher): alignment, first-line and hanging indents, spacing before and after, line spacing, and a Tabs dialog, each applied in one undo step |
|
||||
| 2026-10-14 | Font dialog (Home › Font, the shape menu, Cmd+T): family, size, bold, italic, underline, colour, strikethrough, baseline, caps and character spacing, applied in one undo step |
|
||||
| 2026-10-13 | Grid Options: spacing, snap to grid, snap to other objects, and whether gridlines and guides are drawn; the on-screen grid uses that spacing |
|
||||
| 2026-10-13 | WordArt adjust handles: a selected text warp shows one yellow handle per adjust, and dragging it sets that guide value (one undo step) |
|
||||
| 2026-10-14 | Draw: pen pressure scales ink width when the pointer reports it (a mouse or a missing reading stays at the pen width; a stroke saved as a flat 0.5 still draws at the pen width). Tilt is not read |
|
||||
| 2026-10-13 | Saving a presentation keeps the embedded workbook behind an unchanged chart, and the chart part's relationships (#335). Editing the chart drops that workbook instead of writing numbers that no longer match it |
|
||||
| 2026-10-13 | A presentation whose object ids are used up refuses to grow instead of handing out duplicate ids (#407) |
|
||||
| 2026-10-13 | macOS: a Finder double-click, Open With, a drop on the Dock icon and `open -a DeckCraft deck.pptx` open the file (#138, #347). `Info.plist` now declares the documents DeckCraft opens (`.deckcraft`, `.pptx`, `.potx`, `.ppsx`, the same set as the Windows installer and the Linux desktop file, kept equal by a test), and the `kAEOpenDocuments` Apple event is handled through the pinned `fmv-macos-events` crate as in GridCraft; the `-psn_` argument older macOS adds on a Finder launch is ignored. PowerPoint stays the default until the user picks DeckCraft |
|
||||
| 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless |
|
||||
| 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline |
|
||||
| 2026-10-13 | Render matches PowerPoint more closely on wild shape, picture and table cases: even-odd custom paths, dashes and caps, arrowheads, radial and path gradients, tile rects, rotWithShape, gradient lines, shadows cast by what is drawn (group and perspective too), group fill, picture borders, tiles, reflection, recolour (biLevel, alphaInv, brightness/contrast), table borders and grid default, 20+ preset shapes by their ECMA formulas, default miter joins, and approximate 3-D bevels, cameras and face lighting |
|
||||
| 2026-10-13 | Slide show for interactive decks: click-triggered animations play (one step per click, repeating, applied in click order), a slide the show returns to keeps its state and its triggered animations go on where they stopped, shapes an animation has hidden cannot be clicked, a shape without an action blocks the one beneath it, hand cursor over clickable shapes, "advance on click" off respected |
|
||||
| 2026-10-12 | SmartArt import: text laid out on its `dsp:txXfrm` text rectangle, not squeezed into the shape (list layouts no longer show text in narrow columns) |
|
||||
| 2026-10-12 | Title bar: Export button next to Save, opening the Export dialog on every platform (a shortcut beside File › Export) |
|
||||
| 2026-10-13 | PPTX export writes modern threaded comments (`p188`) next to the flattened legacy part; replies and resolved state survive a round trip, and real PowerPoint decks stop importing their comments twice (#70) |
|
||||
| 2026-10-12 | Canvas: a right click selects the shape under the pointer before the context menu opens (`contextclick` pointer kind for the control channel and MCP); in edited text the menu offers text items |
|
||||
| 2026-10-12 | Canvas: dragging the frame of a text box being edited moves it (move cursor on the frame, one undo step); the caret zone follows rotated boxes |
|
||||
| 2026-10-13 | Start screen: a missing Recent file is marked and its click shows the error there; entries can be removed from the list (#172) |
|
||||
| 2026-10-12 | Tables: the caret and text selection show in the cell being edited; clicks and double-clicks place the caret in the clicked cell (and select the word); dragging inner column borders, the right edge and row borders resizes them in one undo step; Tab skips cells hidden under a merge |
|
||||
| 2026-10-12 | Objects off the slide show and can be selected and moved around it in Normal view, as in PowerPoint; the show, exports and thumbnails still clip to the slide (#270) |
|
||||
|
||||
| 2026-10-12 | Korean, Japanese and Chinese input: IME composition shown in place and underlined, one undo step per composition, candidate window at the caret (slide text, notes, fields); interface text (title, notes, outline, sections, comments) borrows installed fonts for scripts the interface fonts lack (#101) |
|
||||
| 2026-10-12 | Selection Pane like PowerPoint (#133): front-most first, groups fold, drag and drop restacks among siblings, Bring Forward / Send Backward, Show All / Hide All, eye, rename in place, keyboard; Lock (pane padlock, canvas menu) keeps objects from moving, sizing or rotating and round-trips PowerPoint's locking attributes |
|
||||
| 2026-10-12 | Text keys like PowerPoint for Windows (#268): Ctrl+arrows and Ctrl+Shift+arrows by word (punctuation, CJK, emoji, RTL), Ctrl+Backspace/Delete with smart spacing, Ctrl+Up/Down by paragraph, Ctrl+Home/End, End on wrapped lines, Ctrl+]/[ and Ctrl+Shift+>/< font size, Shift+F3 case cycle, word and paragraph selection |
|
||||
| 2026-10-12 | EMF/WMF pictures draw their text (fonts, alignment, dx advances, escapement, symbol fonts), pattern and hatch brushes, PATBLT and the XOR/AND see-through shadow; legacy OLE objects show their VML preview and ActiveX controls their pictures |
|
||||
| 2026-10-12 | File tab (Backstage) like PowerPoint's (#76): File or Alt+F opens Home, New, Open, Info, Save, Save As, Print, Export, Close, Account and Options over the window, Esc or Back returns, arrows walk the side menu; the start screen is File › Home. Pages run the existing file commands; new `file.info`, print slide ranges (`"1-3, 5"`), Print through a PDF, Ctrl+P, and Close/Ctrl+W ask about unsaved changes |
|
||||
| 2026-10-12 | WordArt text warps (`prstTxWarp`): all 40 presets drawn, curves fitted to PowerPoint by observation; warp adjusts round-trip |
|
||||
| 2026-10-12 | Render: line widths, dashes and arrowheads inside a group no longer scale with the group's child-coordinate mapping, so a group in a small legacy child space (scale about 1600) draws thin lines instead of covering the slide (#233) |
|
||||
| 2026-10-12 | `deckcraft-cli help` and each subcommand's `--help` explain the CLI to an agent; `describe` spells out a command's parameters and what it acts on; `commands` lists one command per line |
|
||||
| 2026-10-11 | Animated GIF export of the deck: the Export… dialog (Animated GIF), `file.export` format `gif`, CLI and MCP (#171) |
|
||||
| 2026-10-10 | Progress docs restructured to the craftrules standard; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) |
|
||||
| 2026-10-09 | v0.4.0 released. UAX #9 bidi and Arabic shaping (#4), Morph with Words and Characters (#5), tables grow to fit and PDF cell text (#14), rotating log file (#7), DX12 default on Windows (#16), H.264/HEVC overflow hardening (#2), PPTX sniffing anywhere in the zip (#19) |
|
||||
| 2026-10-08 | v0.2.0 and v0.3.0 released: Windows arm64, Flatpak aarch64, AppImage zsync updates, branded DMG |
|
||||
@@ -143,6 +278,85 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-22 | trivial | Progress log: Animation Pane ▸ Play From. Scores unchanged |
|
||||
| 2026-10-23 | trivial | Progress log: scroll bars on the zoomed slide pane (#1157). Scores and milestone names unchanged |
|
||||
| 2026-10-20 | trivial | Explicit empty chart number-format sections hide their values; blank whole-code General fallback is retained. Synthetic formatter regressions; chart-element scores and readiness estimates unchanged |
|
||||
| 2026-10-21 | trivial | Progress log: Reuse Slides picker (#1103). Scores and milestone names unchanged |
|
||||
| 2026-10-18 | minor | Native chart payloads use referenced ZIP members and share media byte/item limits; old inline sidecars remain readable. Synthetic tests cover multi-megabyte layout, all shape owners, shared budgets, missing/corrupt members and invalid references. Scores and milestone scope unchanged |
|
||||
| 2026-10-16 | minor | Symbol-font U+F0xx codes remap to the installed Symbol face's real Unicode (Apple Symbol on macOS) |
|
||||
| 2026-10-15 | trivial | Progress log: chart package parts survive a native save. Scores and milestone names unchanged |
|
||||
| 2026-10-18 | trivial | Progress log: thumbnails of huge decks render only the slides in view (#969) |
|
||||
| 2026-10-15 | trivial | Progress log: PPTX trigger click-step round trip. Feature scores and milestone names unchanged |
|
||||
| 2026-10-16 | trivial | Progress log: PNG export with a transparent background (#784) |
|
||||
| 2026-10-16 | trivial | Progress log: a `.ppsx` from the system starts the slide show (#862) |
|
||||
| 2026-10-22 | trivial | Progress log: table text takes the master's other-text style |
|
||||
| 2026-10-16 | trivial | Progress log: responsive mouse-wheel navigation on the fitted canvas |
|
||||
| 2026-10-21 | trivial | Progress log: theme `spDef` anchor no longer applied to a file's own shapes |
|
||||
| 2026-10-21 | trivial | Progress log: 3-D sizes in scaled groups stay in points |
|
||||
| 2026-10-22 | trivial | Progress log: chart series pattern and no fills |
|
||||
| 2026-10-15 | trivial | Progress log: Browse for Themes applies a template's master and layouts (#807) |
|
||||
| 2026-10-15 | trivial | Progress log: Compare Reject and Reject All |
|
||||
| 2026-10-16 | trivial | Progress log: PPTX paragraph builds edited in PowerPoint keep their clicks (#844) |
|
||||
| 2026-10-14 | trivial | remote MCP checks reply IDs, envelopes and bounded newline framing (#718) |
|
||||
| 2026-10-14 | minor | Gray list markers on empty paragraphs while editing; caret keeps the list indent before typing |
|
||||
| 2026-10-14 | minor | Community PR wave folded: headline breadth 84% over 191 features (P0 92%, P1 82%, P2 55%) and the feature-area breadths from `cargo xtask parity`; one row per area; Upcoming item 2 merged; per-PR count notes dropped from the progress log. Ready stays ~55%, stage alpha |
|
||||
| 2026-10-13 | trivial | Progress log: Eyedropper in the colour menus, slide.colorAt (#367) |
|
||||
| 2026-10-13 | trivial | Progress log: video seek frame-loss race and regression coverage |
|
||||
| 2026-10-14 | minor | Presenter keyboard navigation and end-screen controls |
|
||||
| 2026-10-13 | trivial | Progress log: EMF/WMF rectangle clip regions |
|
||||
| 2026-10-13 | trivial | Progress log: embedded fonts |
|
||||
| 2026-10-13 | trivial | Progress log: codec parsers reject overflowing numeric fields (#426) |
|
||||
| 2026-10-13 | trivial | Progress log: owned PPTX corpus round-trip; notes-master header and footer |
|
||||
| 2026-10-13 | trivial | Progress log: PPTX corpus for comments, links, custom shows, groups and inherited placeholders |
|
||||
| 2026-10-13 | trivial | Progress log: PPTX corpus for text effects, body layout, theme effects, fills, bevels and connectors |
|
||||
| 2026-10-14 | trivial | Progress log: PPTX corpus for 3-D models, media bookmarks and animation bounce end |
|
||||
| 2026-10-14 | trivial | Progress log: chart trendlines are drawn |
|
||||
| 2026-10-14 | trivial | Progress log: chart trendlines |
|
||||
| 2026-10-14 | trivial | Progress log: Lucide icon gallery |
|
||||
| 2026-10-14 | trivial | Progress log: Size and Position dialog. Breadth stays 79%, ready stays ~55% |
|
||||
| 2026-10-14 | trivial | Progress log: previous and next comment |
|
||||
| 2026-10-13 | trivial | Progress log: View › New Window |
|
||||
| 2026-10-14 | trivial | Progress log: six original starter decks |
|
||||
| 2026-10-14 | trivial | Progress log: Slide Sorter sections |
|
||||
| 2026-10-14 | trivial | Progress log: IME underlines in table cells |
|
||||
| 2026-10-14 | trivial | Progress log: Photo Album dialog. Breadth stays 79%, ready ~55% |
|
||||
| 2026-10-14 | trivial | Progress log: Edit Photo Album. Breadth stays 79%, ready ~55% |
|
||||
| 2026-10-14 | trivial | Progress log: Create New Theme Colors |
|
||||
| 2026-10-14 | trivial | Progress log: Create New Theme Fonts |
|
||||
| 2026-10-14 | trivial | Progress log: Alt Text decorative checkbox |
|
||||
| 2026-10-14 | trivial | Progress log: Hyperlink dialog follows the selection |
|
||||
| 2026-10-14 | trivial | Progress log: Insert › Date and Time dialog (28 dialogs, 226 commands; breadth and readiness unchanged) |
|
||||
| 2026-10-14 | trivial | Progress log: Zoom dialog |
|
||||
| 2026-10-13 | trivial | Progress log: owned PPTX corpus for more deck kinds; handout-master text |
|
||||
| 2026-10-13 | trivial | Progress log: media playback follows the open document and clip (#415) |
|
||||
| 2026-10-13 | trivial | Progress log: laser pointer. Headline P2 36% → 39% to match the checklist (the row moves P2 unweighted 38% → 39%; breadth stays 79%) |
|
||||
| 2026-10-14 | trivial | Progress log: Show Media Controls |
|
||||
| 2026-10-14 | trivial | Progress log: go to slide shows the typed number. Breadth stays 79% |
|
||||
| 2026-10-14 | trivial | Progress log: custom show playback. Breadth stays 79% |
|
||||
| 2026-10-14 | trivial | Progress log: presenter timer pause/reset, clock and notes size. Breadth stays 79% |
|
||||
| 2026-10-14 | trivial | Progress log: See All Slides and zoom during a show. Breadth stays 79% |
|
||||
| 2026-10-13 | trivial | Progress log: command palette Export (#79) |
|
||||
| 2026-10-14 | trivial | Progress log: Design ▸ Effects |
|
||||
| 2026-10-14 | trivial | Progress log: Slide Master Preserve and Title/Footers placeholders. Percentages unchanged |
|
||||
| 2026-10-14 | trivial | Progress log: Trim Media dialog follows the selection |
|
||||
| 2026-10-14 | trivial | Progress log: Format Background picture and solid options |
|
||||
| 2026-10-13 | trivial | Progress log: editor rasters on the Retina pixel grid (#184) |
|
||||
| 2026-10-14 | trivial | Progress log: copy and paste rich text and pictures with other apps |
|
||||
| 2026-10-13 | trivial | Progress log: animation preview on the editor canvas; breadth 80%, Animations 85% |
|
||||
| 2026-10-14 | trivial | Progress log: Format Shape fill transparency follows the selection |
|
||||
| 2026-10-13 | trivial | Progress log: web file picker keeps its document and slide (#414) |
|
||||
| 2026-10-14 | trivial | Progress log: Paragraph dialog |
|
||||
| 2026-10-14 | trivial | Progress log: Font dialog |
|
||||
| 2026-10-13 | trivial | Progress log: Grid Options dialog |
|
||||
| 2026-10-14 | trivial | Progress log: pen pressure scales ink width |
|
||||
| 2026-10-13 | trivial | Progress log: chart workbook round-trip (#335) |
|
||||
| 2026-10-13 | trivial | Progress log: Finder opens presentations on macOS (#138) |
|
||||
| 2026-10-13 | trivial | Progress log: wild text cases |
|
||||
| 2026-10-13 | trivial | Progress log: slide show for interactive decks |
|
||||
| 2026-10-12 | minor | Progress log: table cell editing and border resizing |
|
||||
| 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) |
|
||||
| 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) |
|
||||
| 2026-10-11 | minor | Progress log: animated GIF export (#171); P2 breadth 36% → 37%, overall breadth stays 79% |
|
||||
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% for full, mainstream and essentials |
|
||||
| 2026-10-10 | minor | Added mainstream practitioner (~53%) and essentials user (~62%); features recomputed from written area weights, ready ~56% → ~55% |
|
||||
| 2026-10-10 | minor | Stage checked against the new core-workflow gate: passes, stays alpha |
|
||||
|
||||
@@ -17,3 +17,6 @@ path = "src/main.rs"
|
||||
deckcraft-engine = { workspace = true }
|
||||
deckcraft-mcp = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
zip = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Parse `--automation-read-root` / `--automation-write-root` before the server starts. A malformed
|
||||
//! flag must not silently remove a root and leave the server unconfined.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct AutomationArgs {
|
||||
pub read_root: Option<PathBuf>,
|
||||
pub write_root: Option<PathBuf>,
|
||||
read_given: bool,
|
||||
write_given: bool,
|
||||
}
|
||||
|
||||
impl AutomationArgs {
|
||||
pub fn new(read_root: Option<PathBuf>, write_root: Option<PathBuf>) -> Self {
|
||||
Self { read_root, write_root, read_given: false, write_given: false }
|
||||
}
|
||||
|
||||
/// Returns whether this argument is a root flag. A second occurrence of a flag is an error,
|
||||
/// including when the two spellings (`--flag DIR`, `--flag=DIR`) are mixed.
|
||||
pub fn consume(&mut self, arg: &str, args: &mut impl Iterator<Item = String>) -> Result<bool, String> {
|
||||
let (flag, inline) = arg.split_once('=').map_or((arg, None), |(flag, value)| (flag, Some(value)));
|
||||
let (root, given) = match flag {
|
||||
"--automation-read-root" => (&mut self.read_root, &mut self.read_given),
|
||||
"--automation-write-root" => (&mut self.write_root, &mut self.write_given),
|
||||
_ => return Ok(false),
|
||||
};
|
||||
if *given {
|
||||
return Err(format!("{flag} given twice"));
|
||||
}
|
||||
let value = match inline {
|
||||
Some(value) => value.to_owned(),
|
||||
None => {
|
||||
let value = args.next().ok_or_else(|| format!("{flag} needs a folder"))?;
|
||||
if value.starts_with('-') {
|
||||
return Err(format!("{flag} needs a folder; use {flag}=<dir> for a folder starting with '-'"));
|
||||
}
|
||||
value
|
||||
}
|
||||
};
|
||||
if value.is_empty() {
|
||||
return Err(format!("{flag} needs a non-empty folder"));
|
||||
}
|
||||
*root = Some(value.into());
|
||||
*given = true;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roots_are_taken_once_and_other_arguments_are_left() {
|
||||
let mut roots = AutomationArgs::new(None, None);
|
||||
let mut args = ["a folder".to_owned(), "deck.pptx".to_owned()].into_iter();
|
||||
assert!(roots.consume("--automation-read-root", &mut args).unwrap());
|
||||
assert!(roots.consume("--automation-write-root=out", &mut args).unwrap());
|
||||
assert!(!roots.consume("--sample", &mut args).unwrap());
|
||||
assert_eq!(roots.read_root, Some("a folder".into()));
|
||||
assert_eq!(roots.write_root, Some("out".into()));
|
||||
assert_eq!(args.next().as_deref(), Some("deck.pptx"));
|
||||
assert!(roots.consume("--automation-read-root=other", &mut args).is_err());
|
||||
assert_eq!(roots.read_root, Some("a folder".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_operands_leave_roots_unchanged() {
|
||||
for flag in ["--automation-read-root", "--automation-write-root"] {
|
||||
for operand in [None, Some(""), Some("--version"), Some("--connect"), Some("-folder")] {
|
||||
let mut roots = AutomationArgs::new(Some("read".into()), Some("write".into()));
|
||||
let mut args = operand.map(str::to_owned).into_iter();
|
||||
assert!(roots.consume(flag, &mut args).is_err(), "{flag} {operand:?}");
|
||||
assert_eq!(roots.read_root, Some("read".into()));
|
||||
assert_eq!(roots.write_root, Some("write".into()));
|
||||
}
|
||||
let mut roots = AutomationArgs::new(None, None);
|
||||
assert!(roots.consume(&format!("{flag}="), &mut std::iter::empty()).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_dash_folder_and_relative_dash_folder_are_valid() {
|
||||
let mut roots = AutomationArgs::new(None, None);
|
||||
assert!(roots.consume("--automation-read-root=-folder", &mut std::iter::empty()).unwrap());
|
||||
assert!(roots.consume("--automation-write-root", &mut ["./-folder".to_owned()].into_iter()).unwrap());
|
||||
assert_eq!(roots.read_root, Some("-folder".into()));
|
||||
assert_eq!(roots.write_root, Some("./-folder".into()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
pub struct Subcommand {
|
||||
pub name: &'static str,
|
||||
pub usage: &'static str,
|
||||
pub summary: &'static str,
|
||||
pub details: &'static str,
|
||||
}
|
||||
|
||||
pub const SUBCOMMANDS: &[Subcommand] = &[
|
||||
Subcommand {
|
||||
name: "info",
|
||||
usage: "info FILE",
|
||||
summary: "print a presentation's slides, layouts and theme as JSON",
|
||||
details: "Prints FILE (.deckcraft or .pptx) as JSON: its slides (index, id, title, layout, how many shapes,
|
||||
notes, transition), its layouts, theme fonts, sections and slide size.
|
||||
|
||||
The shapes of one slide, with their ids, boxes and text, come from the slide.inspect command:
|
||||
|
||||
deckcraft-cli run --in deck.deckcraft --print --cmd 'slide.inspect={\"index\":0}'",
|
||||
},
|
||||
Subcommand {
|
||||
name: "render",
|
||||
usage: "render (FILE | --sample) [--slide N | --all] [--scale S] [--edit] [--transparent] OUT",
|
||||
summary: "draw slides as PNG images",
|
||||
details: "Draws slides of FILE, or of the sample presentation with --sample, as PNG images. FILE comes first;
|
||||
OUT is the image to write. With --all, OUT is a folder that gets one slide-01.png, slide-02.png… per
|
||||
slide. Prints each image's path and size.
|
||||
|
||||
--slide N the slide at index N; indices start at 0, and 0 is the default
|
||||
--scale S pixels per point, 2 by default: a 960×540 slide becomes 1920×1080
|
||||
--edit draw what the editor shows: placeholder prompts and their dashed outlines
|
||||
--transparent leave the slide background out, so only what is on the slide is drawn
|
||||
|
||||
deckcraft-cli render deck.deckcraft --slide 2 slide-3.png",
|
||||
},
|
||||
Subcommand {
|
||||
name: "render-transition",
|
||||
usage: "render-transition FILE --slide N --t T[,T...] [--width PX] OUT.png",
|
||||
summary: "draw frames of the transition into a slide as PNG images",
|
||||
details: "Draws the transition into slide N of FILE at each time T, from 0 (the slide before) to 1 (slide N),
|
||||
as PNG images. With one T, OUT.png is the image; with several, each frame is written next to it as
|
||||
OUT-t0.250.png and so on. Prints each image's path, size, transition and time.
|
||||
|
||||
--slide N the slide the transition leads into; indices start at 0
|
||||
--t T,T... the times to draw, comma-separated
|
||||
--width PX the image width in pixels, twice the slide width in points by default
|
||||
|
||||
deckcraft-cli render-transition deck.deckcraft --slide 1 --t 0.25,0.5,0.75 fade.png",
|
||||
},
|
||||
Subcommand {
|
||||
name: "convert",
|
||||
usage: "convert IN OUT",
|
||||
summary: "save a presentation in the format OUT's extension names",
|
||||
details: "Opens IN (.deckcraft or .pptx) and writes it to OUT, in the format OUT's extension names:
|
||||
.deckcraft, .pptx, .pdf, .txt or .md for the outline, .png or .jpg for the first slide, .gif for an
|
||||
animated GIF with one frame per slide. Other extensions are refused. Prints OUT.
|
||||
|
||||
deckcraft-cli convert deck.deckcraft deck.pptx",
|
||||
},
|
||||
Subcommand {
|
||||
name: "run",
|
||||
usage: "run [--in FILE | --sample] [--cmd COMMAND[=PARAMS]]... [--save OUT] [--export OUT [--export-params JSON]] [--print]",
|
||||
summary: "run commands on a presentation, then save or export it",
|
||||
details: "Runs commands, in order, on one presentation: FILE with --in, the sample presentation with --sample,
|
||||
else a new one with an empty title slide. Then --save writes the presentation: .deckcraft keeps
|
||||
everything; .pptx, .pdf and .md (the outline) are written as convert writes them. --export writes the
|
||||
current slide as .png or .jpg, or the presentation as .pdf, .pptx or an animated .gif (one frame per
|
||||
slide). --export-params passes file.export options as one JSON object, such as
|
||||
'{\"width\": \"large\", \"secondsPerSlide\": 3, \"loop\": false, \"slides\": [0, 1, 2]}' for a GIF
|
||||
(`deckcraft-cli describe file.export` lists them).
|
||||
|
||||
Each --cmd is one command: COMMAND is a command id, then `=`, then the command's parameters as one
|
||||
JSON object. Quote it for the shell. A command that needs no parameters is just its id.
|
||||
|
||||
deckcraft-cli run \\
|
||||
--cmd 'slide.new={\"layout\":\"titleOnly\",\"title\":\"Q3 results\"}' \\
|
||||
--cmd 'shape.insert={\"preset\":\"roundRect\",\"rect\":[80,160,320,120],\"text\":\"Revenue\"}' \\
|
||||
--cmd 'format.size={\"size\":28}' \\
|
||||
--save q3.deckcraft
|
||||
|
||||
`deckcraft-cli commands` lists the command ids; `deckcraft-cli describe COMMAND` explains one and its
|
||||
parameters.
|
||||
|
||||
What the ids don't say:
|
||||
- Positions and sizes are in points on a 960×540 slide, x to the right and y down; a rect is
|
||||
[x, y, width, height].
|
||||
- Slide indices start at 0. slide.new inserts after the current slide and makes the new one current;
|
||||
slide.go {\"index\": N} makes slide N current.
|
||||
- Shape ids are looked up on the current slide only.
|
||||
- A command that adds a shape (shape.insert, insert.textBox, insert.table…) selects it. format.* acts
|
||||
on the selected text or shapes; edit.select {\"ids\": [...]} selects others. Many shape commands
|
||||
take the ids directly; `deckcraft-cli describe COMMAND` says what a command acts on.
|
||||
|
||||
If a command fails, run stops there and saves nothing. --print shows each command's answer, such as
|
||||
the id of an inserted shape.",
|
||||
},
|
||||
Subcommand {
|
||||
name: "commands",
|
||||
usage: "commands [FILTER]",
|
||||
summary: "list the commands run can use, one per line",
|
||||
details: "Lists the commands that run --cmd, app and the MCP server take, one per line: the id, its name in
|
||||
the app and its parameters. FILTER keeps the commands whose id or name contains it, ignoring case.
|
||||
|
||||
deckcraft-cli commands format.
|
||||
|
||||
In the parameters, `?` marks an optional one, a spaced ` | ` separates alternatives and `→` shows what
|
||||
the command answers. `deckcraft-cli describe COMMAND` spells one command out.",
|
||||
},
|
||||
Subcommand {
|
||||
name: "describe",
|
||||
usage: "describe COMMAND",
|
||||
summary: "explain one command and its parameters",
|
||||
details: "Explains one command: what it is in the app, each of its parameters, what it answers, and how run
|
||||
calls it.
|
||||
|
||||
deckcraft-cli describe shape.insert",
|
||||
},
|
||||
Subcommand {
|
||||
name: "app",
|
||||
usage: "app [--port PORT] COMMAND [PARAMS] | --method METHOD [PARAMS]",
|
||||
summary: "run a command in the DeckCraft app started with --control",
|
||||
details: "Sends one request to the DeckCraft app started with `deckcraft --control PORT` (port 7979 by default)
|
||||
and prints its answer as JSON. COMMAND [PARAMS] runs a command, as run --cmd does; --method METHOD
|
||||
[PARAMS] calls a control-channel method such as ui.screenshot. PARAMS is a JSON object.
|
||||
|
||||
deckcraft-cli app shape.insert '{\"preset\":\"star5\",\"rect\":[100,100,200,200]}'
|
||||
deckcraft-cli app --method ui.screenshot '{\"path\":\"/tmp/shot.png\"}'",
|
||||
},
|
||||
Subcommand {
|
||||
name: "mcp",
|
||||
usage: "mcp [--connect PORT] [--sample] [--automation-read-root DIR] [--automation-write-root DIR]",
|
||||
summary: "serve the commands to an agent over MCP on stdio",
|
||||
details: "Runs a Model Context Protocol server on stdin and stdout. It works on its own presentation without a
|
||||
window, or with --connect PORT (or host:port) on the DeckCraft app started with --control PORT.
|
||||
--sample opens the sample presentation.
|
||||
|
||||
--automation-read-root DIR and --automation-write-root DIR confine every file the agent's commands read
|
||||
(open, pictures, audio, video) and write (save, export, PDF) to those folders, links followed; a root left
|
||||
out grants none of its access. They run the in-process server, so they conflict with --connect (a running
|
||||
app is not confined by them).",
|
||||
},
|
||||
Subcommand {
|
||||
name: "bench",
|
||||
usage: "bench (FILE | --sample) [--runs N] [--json]",
|
||||
summary: "time opening, drawing, saving and exporting a presentation",
|
||||
details: "Times how long FILE, or the sample presentation with --sample, takes to open, to draw each slide, to
|
||||
save as .deckcraft and .pptx, to reopen as .pptx and to export as .pdf, and the peak memory. Prints the
|
||||
median and slowest of N runs (5 by default, 1 to 1000) as a table, or as JSON with --json.
|
||||
|
||||
deckcraft-cli bench deck.pptx --runs 10",
|
||||
},
|
||||
Subcommand {
|
||||
name: "links",
|
||||
usage: "links",
|
||||
summary: "print the DeckCraft community and project links",
|
||||
details: "Prints the DeckCraft community and project links.",
|
||||
},
|
||||
Subcommand { name: "version", usage: "--version", summary: "print the version", details: "Prints the version of deckcraft-cli." },
|
||||
];
|
||||
|
||||
pub fn page(name: &str) -> Option<String> {
|
||||
let name = match name {
|
||||
"help" | "--help" | "-h" => return Some(overview()),
|
||||
"--version" | "-V" => "version",
|
||||
name => name,
|
||||
};
|
||||
SUBCOMMANDS.iter().find(|s| s.name == name).map(|s| format!("usage: deckcraft-cli {}\n\n{}", s.usage, s.details))
|
||||
}
|
||||
|
||||
pub fn overview() -> String {
|
||||
let width = SUBCOMMANDS.iter().map(|s| s.usage.split(' ').next().unwrap_or_default().len()).max().unwrap_or_default();
|
||||
let list: Vec<String> =
|
||||
SUBCOMMANDS.iter().map(|s| format!(" {:<width$} {}", s.usage.split(' ').next().unwrap_or_default(), s.summary)).collect();
|
||||
format!(
|
||||
"deckcraft-cli works on DeckCraft presentations (.deckcraft, .pptx) without a window.
|
||||
|
||||
{}
|
||||
|
||||
A presentation is edited by running commands on it:
|
||||
|
||||
deckcraft-cli run --cmd 'slide.new={{\"title\":\"Q3 results\"}}' --save q3.deckcraft
|
||||
|
||||
`deckcraft-cli help SUBCOMMAND`, or `deckcraft-cli SUBCOMMAND --help`, explains one subcommand;
|
||||
`deckcraft-cli help run` explains how to write a command.",
|
||||
list.join("\n")
|
||||
)
|
||||
}
|
||||
+553
-48
@@ -1,19 +1,11 @@
|
||||
//! Headless DeckCraft.
|
||||
//!
|
||||
//! ```text
|
||||
//! deckcraft-cli info FILE # slides, titles, layouts (JSON)
|
||||
//! deckcraft-cli render (FILE | --sample) [--slide N | --all] [--scale S] OUT.png|OUT_DIR
|
||||
//! deckcraft-cli convert IN OUT # .deckcraft ⇄ .pptx, outline .txt, .png (first slide)
|
||||
//! deckcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--save OUT] [--export OUT] [--print]
|
||||
//! deckcraft-cli commands [FILTER] # list commands (JSON)
|
||||
//! deckcraft-cli describe ID # one command
|
||||
//! deckcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON] # drive the running app
|
||||
//! deckcraft-cli mcp [--connect PORT] [--sample] # MCP server over stdio
|
||||
//! deckcraft-cli links | --version
|
||||
//! ```
|
||||
//! Headless DeckCraft. `deckcraft-cli help` explains each subcommand (`help.rs`).
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod automation_args;
|
||||
|
||||
mod help;
|
||||
|
||||
use std::process::ExitCode;
|
||||
|
||||
use deckcraft_engine::Session;
|
||||
@@ -32,38 +24,49 @@ macro_rules! outln {
|
||||
}};
|
||||
}
|
||||
|
||||
const USAGE: &str = "usage: deckcraft-cli info FILE
|
||||
deckcraft-cli render (FILE | --sample) [--slide N | --all] [--scale S] [--edit] OUT
|
||||
deckcraft-cli convert IN OUT
|
||||
deckcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--save OUT] [--export OUT] [--print]
|
||||
deckcraft-cli commands [FILTER]
|
||||
deckcraft-cli describe COMMAND
|
||||
deckcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]
|
||||
deckcraft-cli mcp [--connect PORT] [--sample]
|
||||
deckcraft-cli links | --version";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let rest = args.get(1..).unwrap_or(&[]);
|
||||
let asks_help = rest.iter().any(|a| a == "--help" || a == "-h");
|
||||
if let Some(page) = args.first().filter(|_| asks_help).and_then(|n| help::page(n)) {
|
||||
outln!("{page}");
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
match args.first().map(String::as_str) {
|
||||
Some("help" | "--help" | "-h") => match rest.first() {
|
||||
None => {
|
||||
outln!("{}", help::overview());
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Some(name) => match help::page(name) {
|
||||
Some(page) => {
|
||||
outln!("{page}");
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
None => unknown(name),
|
||||
},
|
||||
},
|
||||
Some("--version" | "-V" | "version") => {
|
||||
outln!("deckcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Some("info") => report(info(rest)),
|
||||
Some("bench") => report(bench(rest)),
|
||||
Some("render") => report(render(rest)),
|
||||
Some("render-transition") => report(render_transition(rest)),
|
||||
Some("convert") => report(convert(rest)),
|
||||
Some("run") => report(run(rest)),
|
||||
Some("commands") => {
|
||||
let s = Session::new();
|
||||
let f = rest.first().map(|x| x.to_lowercase());
|
||||
let list: Vec<Value> = s
|
||||
.commands()
|
||||
.into_iter()
|
||||
for c in deckcraft_engine::command_specs()
|
||||
.iter()
|
||||
.filter(|c| f.as_ref().is_none_or(|f| c.id.to_lowercase().contains(f.as_str()) || c.label.to_lowercase().contains(f.as_str())))
|
||||
.map(|c| serde_json::to_value(c).unwrap_or_default())
|
||||
.collect();
|
||||
outln!("{}", serde_json::to_string_pretty(&list).unwrap_or_default());
|
||||
{
|
||||
match c.params.strip_prefix("{}") {
|
||||
Some(answer) => outln!("{} — {}{answer}", c.id, c.label),
|
||||
None => outln!("{} — {}: {}", c.id, c.label, c.params),
|
||||
}
|
||||
}
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Some("describe") => report(describe(rest.first().map(String::as_str))),
|
||||
@@ -74,14 +77,31 @@ fn main() -> ExitCode {
|
||||
outln!("Discord {DISCORD}\nWebsite {WEBSITE}\nApp page {APP_PAGE}\nGitHub {GITHUB}");
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
_ => {
|
||||
eprintln!("{USAGE}");
|
||||
eprintln!("\nCommunity: {} · {}", deckcraft_engine::links::DISCORD, deckcraft_engine::links::APP_PAGE);
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
Some(other) => unknown(other),
|
||||
None => usage(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_help(arg: &str) -> bool {
|
||||
matches!(arg, "--help" | "-h")
|
||||
}
|
||||
|
||||
/// The help page of subcommand `name`, else the overview.
|
||||
fn help_page(name: &str) -> String {
|
||||
help::page(name).unwrap_or_else(help::overview)
|
||||
}
|
||||
|
||||
fn unknown(sub: &str) -> ExitCode {
|
||||
eprintln!("deckcraft-cli: unknown subcommand `{sub}`\n");
|
||||
usage()
|
||||
}
|
||||
|
||||
fn usage() -> ExitCode {
|
||||
eprintln!("{}", help::overview());
|
||||
eprintln!("\nCommunity: {} · {}", deckcraft_engine::links::DISCORD, deckcraft_engine::links::APP_PAGE);
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
|
||||
fn report(r: Result<(), String>) -> ExitCode {
|
||||
match r {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
@@ -97,12 +117,134 @@ fn open(s: &mut Session, input: Option<&str>, sample: bool) -> Result<(), String
|
||||
return deckcraft_engine::sample::open_sample(s).map_err(|e| e.to_string());
|
||||
}
|
||||
match input {
|
||||
Some(p) => s.execute("file.open", &json!({"path": p})).map(|_| ()).map_err(|e| e.to_string()),
|
||||
Some(p) => {
|
||||
let opened = s.execute("file.open", &json!({"path": p})).map_err(|e| e.to_string())?;
|
||||
warn_if_damaged(p, &opened);
|
||||
Ok(())
|
||||
}
|
||||
None => s.execute("file.new", &json!({})).map(|_| ()).map_err(|e| e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Say on stderr what a file lost on opening, so a saved copy doesn't silently miss it (#203).
|
||||
fn warn_if_damaged(path: &str, opened: &serde_json::Value) {
|
||||
let count = |key: &str| opened.get(key).and_then(|v| v.as_array()).map_or(0, |a| a.len());
|
||||
let (slides, media) = (count("lostSlides"), count("damagedMedia"));
|
||||
if slides > 0 {
|
||||
eprintln!("deckcraft-cli: warning: {path}: {slides} slide{} couldn't be read and left out", if slides == 1 { "" } else { "s" });
|
||||
}
|
||||
if media > 0 {
|
||||
eprintln!("deckcraft-cli: warning: {path}: {media} picture or media file{} couldn't be read", if media == 1 { "" } else { "s" });
|
||||
}
|
||||
}
|
||||
|
||||
/// Median and slowest of `runs` timings of `f`, in milliseconds.
|
||||
fn time_ms(runs: usize, mut f: impl FnMut() -> Result<(), String>) -> Result<(f64, f64), String> {
|
||||
let mut ms = Vec::with_capacity(runs);
|
||||
for _ in 0..runs.max(1) {
|
||||
let t = std::time::Instant::now();
|
||||
f()?;
|
||||
ms.push(t.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
ms.sort_by(f64::total_cmp);
|
||||
let median = ms.get(ms.len() / 2).copied().unwrap_or(0.0);
|
||||
Ok((median, ms.last().copied().unwrap_or(0.0)))
|
||||
}
|
||||
|
||||
/// Peak resident memory of this process in MiB (Linux `VmHWM`); `None` elsewhere.
|
||||
fn peak_rss_mib() -> Option<f64> {
|
||||
let s = std::fs::read_to_string("/proc/self/status").ok()?;
|
||||
let kb: f64 = s.lines().find_map(|l| l.strip_prefix("VmHWM:"))?.trim().trim_end_matches("kB").trim().parse().ok()?;
|
||||
Some(kb / 1024.0)
|
||||
}
|
||||
|
||||
/// The performance baseline (docs/gaps.md "No baseline"): how long a deck takes to open, to
|
||||
/// render each slide, to save and to export, and the peak memory, as JSON or a table.
|
||||
fn bench(args: &[String]) -> Result<(), String> {
|
||||
use deckcraft_engine::cmd::file::{open_presentation, save_bytes};
|
||||
let mut input = None;
|
||||
let mut sample = false;
|
||||
let mut runs = 5usize;
|
||||
let mut as_json = false;
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
match a.as_str() {
|
||||
"--sample" => sample = true,
|
||||
"--json" => as_json = true,
|
||||
"--runs" => {
|
||||
runs = it.next().and_then(|v| v.parse().ok()).filter(|n| (1..=1000).contains(n)).ok_or("--runs needs a number from 1 to 1000")?
|
||||
}
|
||||
f if !f.starts_with("--") && input.is_none() => input = Some(f.to_string()),
|
||||
o => return Err(format!("bench: unknown argument `{o}`")),
|
||||
}
|
||||
}
|
||||
if input.is_none() && !sample {
|
||||
return Err("bench needs a FILE or --sample".into());
|
||||
}
|
||||
let mut s = Session::new();
|
||||
let (name, bytes) = match &input {
|
||||
Some(p) => (p.clone(), std::fs::read(p).map_err(|e| format!("{p}: {e}"))?),
|
||||
None => {
|
||||
open(&mut s, None, true)?;
|
||||
let doc = &s.doc().map_err(|e| e.to_string())?.doc;
|
||||
("sample.deckcraft".to_string(), save_bytes(doc, "deckcraft").map_err(|e| e.to_string())?)
|
||||
}
|
||||
};
|
||||
let e = |x: deckcraft_engine::EngineError| x.to_string();
|
||||
let doc = open_presentation(&name, &bytes).map_err(e)?;
|
||||
let slides = doc.slides.len();
|
||||
let open = time_ms(runs, || open_presentation(&name, &bytes).map(|_| ()).map_err(e))?;
|
||||
let opts = deckcraft_engine::render::RenderOpts { scale: 1.0, ..Default::default() };
|
||||
let mut per_slide = vec![];
|
||||
for i in 0..slides {
|
||||
per_slide.push(time_ms(runs, || {
|
||||
deckcraft_engine::render::render_slide(&doc, i, &opts);
|
||||
Ok(())
|
||||
})?);
|
||||
}
|
||||
let pptx = save_bytes(&doc, "pptx").map_err(e)?;
|
||||
let mut steps = vec![
|
||||
("open", open),
|
||||
("save .deckcraft", time_ms(runs, || save_bytes(&doc, "deckcraft").map(|_| ()).map_err(e))?),
|
||||
("save .pptx", time_ms(runs, || save_bytes(&doc, "pptx").map(|_| ()).map_err(e))?),
|
||||
("open .pptx", time_ms(runs, || open_presentation("bench.pptx", &pptx).map(|_| ()).map_err(e))?),
|
||||
("export .pdf", time_ms(runs.min(3), || save_bytes(&doc, "pdf").map(|_| ()).map_err(e))?),
|
||||
];
|
||||
let render_total: f64 = per_slide.iter().map(|m| m.0).sum();
|
||||
let render_max = per_slide.iter().map(|m| m.1).fold(0.0, f64::max);
|
||||
steps.insert(1, ("render all slides (1×)", (render_total, render_max)));
|
||||
let peak = peak_rss_mib();
|
||||
if as_json {
|
||||
let v = json!({
|
||||
"file": name,
|
||||
"bytes": bytes.len(),
|
||||
"slides": slides,
|
||||
"runs": runs,
|
||||
"steps": steps.iter().map(|(k, (m, x))| json!({"step": k, "medianMs": m, "maxMs": x})).collect::<Vec<_>>(),
|
||||
"renderMsPerSlide": per_slide.iter().map(|m| m.0).collect::<Vec<_>>(),
|
||||
"peakRssMiB": peak,
|
||||
});
|
||||
outln!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
|
||||
} else {
|
||||
outln!("{name}: {slides} slides, {} KiB, median of {runs} runs", bytes.len() / 1024);
|
||||
for (k, (m, x)) in &steps {
|
||||
outln!(" {k:<24} {m:>9.1} ms (slowest {x:.1} ms)");
|
||||
}
|
||||
if let Some((i, m)) = per_slide.iter().enumerate().max_by(|a, b| a.1.0.total_cmp(&b.1.0)) {
|
||||
outln!(" slowest slide {:>9.1} ms (slide {})", m.0, i + 1);
|
||||
}
|
||||
if let Some(p) = peak {
|
||||
outln!(" peak memory {p:>9.1} MiB");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn info(args: &[String]) -> Result<(), String> {
|
||||
if args.first().is_some_and(|a| is_help(a)) {
|
||||
outln!("{}", help_page("info"));
|
||||
return Ok(());
|
||||
}
|
||||
let path = args.first().ok_or("info needs a FILE")?;
|
||||
let mut s = Session::new();
|
||||
open(&mut s, Some(path), false)?;
|
||||
@@ -118,15 +260,22 @@ fn render(args: &[String]) -> Result<(), String> {
|
||||
let mut all = false;
|
||||
let mut scale = 2.0;
|
||||
let mut edit = false;
|
||||
let mut transparent = false;
|
||||
let mut out = None;
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
match a.as_str() {
|
||||
"--sample" => sample = true,
|
||||
"--transparent" => transparent = true,
|
||||
"--slide" => slide = Some(it.next().and_then(|v| v.parse().ok()).ok_or("--slide needs a number")?),
|
||||
"--all" => all = true,
|
||||
"--edit" => edit = true,
|
||||
"--scale" => scale = it.next().and_then(|v| v.parse().ok()).ok_or("--scale needs a number")?,
|
||||
"--help" | "-h" => {
|
||||
outln!("{}", help_page("render"));
|
||||
return Ok(());
|
||||
}
|
||||
other if other.starts_with('-') => return Err(format!("unknown option `{other}`")),
|
||||
other
|
||||
if input.is_none()
|
||||
&& !sample
|
||||
@@ -145,15 +294,12 @@ fn render(args: &[String]) -> Result<(), String> {
|
||||
open(&mut s, input.as_deref(), sample)?;
|
||||
let st = s.doc().map_err(|e| e.to_string())?;
|
||||
let n = st.doc.slides.len();
|
||||
let list: Vec<usize> = if all { (0..n).collect() } else { vec![slide.unwrap_or(0)] };
|
||||
let list = slide_indices(n, all, slide)?;
|
||||
if all {
|
||||
std::fs::create_dir_all(&out).map_err(|e| format!("{out}: {e}"))?;
|
||||
}
|
||||
for i in list {
|
||||
if i >= n {
|
||||
return Err(format!("no slide {i} (the deck has {n})"));
|
||||
}
|
||||
let (png, w, h) = deckcraft_engine::cmd::file::render_png(&st.doc, i, scale, edit);
|
||||
let (png, w, h) = deckcraft_engine::cmd::file::render_png_with(&st.doc, i, scale, edit, transparent);
|
||||
let path = if all { format!("{out}/slide-{:02}.png", i + 1) } else { out.clone() };
|
||||
std::fs::write(&path, png).map_err(|e| format!("{path}: {e}"))?;
|
||||
outln!("{path} ({w}×{h})");
|
||||
@@ -161,13 +307,131 @@ fn render(args: &[String]) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Slides `--slide` asks for. `N` is a 0-based index, the same one `slide.go` uses.
|
||||
/// `--all` still names files from 1 (`slide-01.png`), which is PowerPoint's slide number.
|
||||
fn slide_indices(n: usize, all: bool, slide: Option<usize>) -> Result<Vec<usize>, String> {
|
||||
if all {
|
||||
return Ok((0..n).collect());
|
||||
}
|
||||
let i = slide.unwrap_or(0);
|
||||
if i >= n {
|
||||
return Err(missing_slide(i, n));
|
||||
}
|
||||
Ok(vec![i])
|
||||
}
|
||||
|
||||
fn missing_slide(i: usize, n: usize) -> String {
|
||||
match n {
|
||||
0 => format!("no slide index {i} (this deck has no slides)"),
|
||||
1 => format!("no slide index {i} (this deck has 1 slide, index 0, which --all names slide-01.png)"),
|
||||
n => {
|
||||
let last = n - 1;
|
||||
format!(
|
||||
"no slide index {i} (this deck has {n} slides, numbered 0 through {last}; --all names them slide-01.png through slide-{n:02}.png)"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Frames of slide N's transition (0-based N; from slide N-1, or black for the first slide) at
|
||||
/// the given progress values. With several values, OUT gets `-t<value>` before its extension.
|
||||
fn render_transition(args: &[String]) -> Result<(), String> {
|
||||
use deckcraft_engine::render;
|
||||
let mut input = None;
|
||||
let mut slide: Option<usize> = None;
|
||||
let mut ts: Vec<f64> = Vec::new();
|
||||
let mut width: Option<f64> = None;
|
||||
let mut out = None;
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
match a.as_str() {
|
||||
"--slide" => slide = Some(it.next().and_then(|v| v.parse().ok()).ok_or("--slide needs a number")?),
|
||||
"--t" => {
|
||||
let v = it.next().ok_or("--t needs a value")?;
|
||||
for x in v.split(',') {
|
||||
let t: f64 = x.trim().parse().map_err(|_| format!("bad --t value `{x}`"))?;
|
||||
if !t.is_finite() {
|
||||
return Err(format!("bad --t value `{x}`"));
|
||||
}
|
||||
ts.push(t);
|
||||
}
|
||||
}
|
||||
"--width" => {
|
||||
width = Some(it.next().and_then(|v| v.parse().ok()).filter(|w: &f64| w.is_finite() && *w >= 1.0).ok_or("--width needs pixels")?)
|
||||
}
|
||||
other if input.is_none() => input = Some(other.to_string()),
|
||||
other => out = Some(other.to_string()),
|
||||
}
|
||||
}
|
||||
let input = input.ok_or("render-transition needs a FILE")?;
|
||||
let out = out.ok_or("render-transition needs an output path")?;
|
||||
let i = slide.ok_or("render-transition needs --slide N")?;
|
||||
if ts.is_empty() {
|
||||
return Err("render-transition needs --t".into());
|
||||
}
|
||||
let paths = frame_paths(&out, &ts)?;
|
||||
let mut s = Session::new();
|
||||
open(&mut s, Some(&input), false)?;
|
||||
let st = s.doc().map_err(|e| e.to_string())?;
|
||||
let doc = &st.doc;
|
||||
let n = doc.slides.len();
|
||||
let Some(sl) = doc.slides.get(i) else { return Err(format!("no slide {i} (the deck has {n})")) };
|
||||
let sw = doc.slide_size.width.max(1.0);
|
||||
let w = width.unwrap_or(sw * 2.0).min(render::MAX_SIDE as f64);
|
||||
let opts = render::RenderOpts { scale: w / sw, ..Default::default() };
|
||||
let tr = sl.transition.clone().unwrap_or_default();
|
||||
// Render both slides once and composite every frame from them (morph re-renders per frame).
|
||||
let prev = i.checked_sub(1);
|
||||
let cached = (tr.kind != "morph").then(|| {
|
||||
let parts = render::uses_slide_parts(&tr.kind, &tr.option).then(|| render::SlideParts::render(doc, prev, i, &opts));
|
||||
(prev.map(|p| render::render_slide(doc, p, &opts)), render::render_slide(doc, i, &opts), parts)
|
||||
});
|
||||
let aspect = sw / doc.slide_size.height.max(1.0);
|
||||
for (t, path) in ts.iter().zip(&paths) {
|
||||
let img = match &cached {
|
||||
Some((old, new, parts)) => {
|
||||
render::composite_transition(old.as_ref(), new, parts.as_ref(), &render::transition_frame(&tr.kind, &tr.option, *t, aspect))
|
||||
}
|
||||
None => render::render_transition(doc, i, *t, &opts),
|
||||
};
|
||||
std::fs::write(path, img.to_png_at(render::dpi_for_scale(opts.scale))).map_err(|e| format!("{path}: {e}"))?;
|
||||
outln!("{path} ({}×{}) {} {} t={t}", img.width, img.height, tr.kind, tr.option);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Output path of each frame. A single frame goes to OUT; several get `-t<value>` (three decimals)
|
||||
/// before the extension. Values that round to the same name would overwrite each other, so that is an error.
|
||||
fn frame_paths(out: &str, ts: &[f64]) -> Result<Vec<String>, String> {
|
||||
if ts.len() == 1 {
|
||||
return Ok(vec![out.to_string()]);
|
||||
}
|
||||
let p = std::path::Path::new(out);
|
||||
let stem = p.file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("frame");
|
||||
let mut paths: Vec<String> = Vec::with_capacity(ts.len());
|
||||
for t in ts {
|
||||
let path = p.with_file_name(format!("{stem}-t{t:.3}.png")).to_string_lossy().into_owned();
|
||||
if paths.contains(&path) {
|
||||
return Err(format!("--t values round to the same three decimals: more than one frame would be written to {path}"));
|
||||
}
|
||||
paths.push(path);
|
||||
}
|
||||
Ok(paths)
|
||||
}
|
||||
|
||||
fn convert(args: &[String]) -> Result<(), String> {
|
||||
if args.first().is_some_and(|a| is_help(a)) {
|
||||
outln!("{}", help_page("convert"));
|
||||
return Ok(());
|
||||
}
|
||||
let (Some(input), Some(out)) = (args.first(), args.get(1)) else { return Err("convert needs IN and OUT".into()) };
|
||||
let fmt = output_format(out)?;
|
||||
let mut s = Session::new();
|
||||
open(&mut s, Some(input), false)?;
|
||||
let fmt = deckcraft_engine::cmd::file::format_for_path(out);
|
||||
let r = if matches!(fmt, "png" | "jpeg") {
|
||||
s.execute("file.export", &json!({"path": out, "slide": 0}))
|
||||
} else if fmt == "gif" {
|
||||
s.execute("file.export", &json!({"path": out}))
|
||||
} else {
|
||||
s.execute("file.saveAs", &json!({"path": out}))
|
||||
};
|
||||
@@ -176,25 +440,50 @@ fn convert(args: &[String]) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The format `convert`, `run --save` and `run --export` write to `path`, or an error naming the
|
||||
/// supported extensions when `path`'s extension isn't one (rather than native bytes under that name).
|
||||
fn output_format(path: &str) -> Result<&'static str, String> {
|
||||
use deckcraft_engine::cmd::file::{WRITABLE_EXTENSIONS, writable_format_for_path};
|
||||
writable_format_for_path(path).ok_or_else(|| {
|
||||
let ext = std::path::Path::new(path).extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_else(|| "(none)".into());
|
||||
format!("{path}: unsupported output format {ext} (supported: .{})", WRITABLE_EXTENSIONS.join(", ."))
|
||||
})
|
||||
}
|
||||
|
||||
fn run(args: &[String]) -> Result<(), String> {
|
||||
let mut input = None;
|
||||
let mut sample = false;
|
||||
let mut cmds = vec![];
|
||||
let mut save = None;
|
||||
let mut export = None;
|
||||
let mut export_params = json!({});
|
||||
let mut print = false;
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
match a.as_str() {
|
||||
"--in" => input = Some(it.next().cloned().ok_or("--in needs a file")?),
|
||||
"--sample" => sample = true,
|
||||
"--cmd" => cmds.push(it.next().cloned().ok_or("--cmd needs ID[=JSON]")?),
|
||||
"--cmd" => cmds.push(it.next().cloned().ok_or("--cmd needs COMMAND[=PARAMS]")?),
|
||||
"--save" => save = Some(it.next().cloned().ok_or("--save needs a path")?),
|
||||
"--export" => export = Some(it.next().cloned().ok_or("--export needs a path")?),
|
||||
"--export-params" => {
|
||||
let j = it.next().ok_or("--export-params needs a JSON object")?;
|
||||
export_params = serde_json::from_str::<Value>(j).map_err(|e| format!("--export-params: bad JSON: {e}"))?;
|
||||
if !export_params.is_object() {
|
||||
return Err("--export-params must be a JSON object".into());
|
||||
}
|
||||
}
|
||||
"--print" => print = true,
|
||||
"--help" | "-h" => {
|
||||
outln!("{}", help_page("run"));
|
||||
return Ok(());
|
||||
}
|
||||
other => return Err(format!("unknown option `{other}`")),
|
||||
}
|
||||
}
|
||||
for p in save.iter().chain(&export) {
|
||||
output_format(p)?;
|
||||
}
|
||||
let mut s = Session::new();
|
||||
open(&mut s, input.as_deref(), sample)?;
|
||||
for c in cmds {
|
||||
@@ -211,18 +500,76 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
s.execute("file.saveAs", &json!({"path": p})).map_err(|e| e.to_string())?;
|
||||
}
|
||||
if let Some(p) = export {
|
||||
s.execute("file.export", &json!({"path": p})).map_err(|e| e.to_string())?;
|
||||
export_params["path"] = json!(p);
|
||||
s.execute("file.export", &export_params).map_err(|e| e.to_string())?;
|
||||
} else if export_params != json!({}) {
|
||||
return Err("--export-params needs --export OUT".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn describe(id: Option<&str>) -> Result<(), String> {
|
||||
if id.is_some_and(is_help) {
|
||||
outln!("{}", help_page("describe"));
|
||||
return Ok(());
|
||||
}
|
||||
let id = id.ok_or("describe needs a command id")?;
|
||||
let c = deckcraft_engine::find_command(id).ok_or_else(|| format!("unknown command `{id}`"))?;
|
||||
outln!("{} — {}\n where: {}\n shortcut: {}\n params: {}", c.id, c.label, c.menu.join(" ▸ "), c.shortcut.unwrap_or("—"), c.params);
|
||||
let c = deckcraft_engine::find_command(id).ok_or_else(|| deckcraft_engine::EngineError::UnknownCommand(id.into()).to_string())?;
|
||||
let mut head = format!("{}: {}", c.id, c.label);
|
||||
if !c.menu.is_empty() {
|
||||
head.push_str(&format!(", in {}", c.menu.join(" ▸ ")));
|
||||
}
|
||||
if let Some(k) = c.shortcut {
|
||||
head.push_str(&format!(", shortcut {k}"));
|
||||
}
|
||||
outln!("{head}.\n");
|
||||
let (args, answer) = c.params.split_once('→').unwrap_or((c.params, ""));
|
||||
let parsed = deckcraft_engine::cmd::parse_params(c.params);
|
||||
match &parsed {
|
||||
Err(_) => outln!("Parameters: {}", args.trim()),
|
||||
Ok(params) if params.is_empty() => outln!("Takes no parameters."),
|
||||
Ok(params) => print_params(params),
|
||||
}
|
||||
let params = parsed.unwrap_or_default();
|
||||
if let Some(acts) = c.acts_on() {
|
||||
outln!("{acts}");
|
||||
}
|
||||
let remark = remark(args);
|
||||
if !remark.is_empty() {
|
||||
outln!("Note: {remark}");
|
||||
}
|
||||
if !answer.trim().is_empty() {
|
||||
outln!("Answers with {}.", answer.trim());
|
||||
}
|
||||
let bare = params.is_empty() || params.iter().any(|p| params.iter().filter(|q| q.object == p.object).all(|q| q.optional));
|
||||
let call = if bare { c.id.to_string() } else { format!("'{}={{…}}'", c.id) };
|
||||
outln!("\nRun it with: deckcraft-cli run --cmd {call}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_params(params: &[deckcraft_engine::cmd::Param]) {
|
||||
let name = |p: &deckcraft_engine::cmd::Param| {
|
||||
let or = if p.or { "or " } else { "" };
|
||||
let optional = if p.optional { ", optional" } else { "" };
|
||||
format!("{or}{}{optional}", p.name)
|
||||
};
|
||||
let width = params.iter().map(|p| name(p).chars().count()).max().unwrap_or_default();
|
||||
let sets = params.iter().map(|p| p.object).max().unwrap_or_default() > 0;
|
||||
outln!("Parameters:");
|
||||
for (k, p) in params.iter().enumerate() {
|
||||
if sets && params.get(k.wrapping_sub(1)).is_none_or(|before| before.object != p.object) {
|
||||
outln!(" {}", if p.object == 0 { "either:" } else { "or:" });
|
||||
}
|
||||
let indent = if sets { " " } else { " " };
|
||||
outln!("{}", format!("{indent}{:<width$} {}", name(p), p.about).trim_end());
|
||||
}
|
||||
}
|
||||
|
||||
fn remark(args: &str) -> &str {
|
||||
let after = args.rfind('}').and_then(|i| args.get(i + 1..)).unwrap_or_default().trim().trim_start_matches('—').trim();
|
||||
after.strip_prefix('(').and_then(|n| n.strip_suffix(')')).unwrap_or(after)
|
||||
}
|
||||
|
||||
fn app(args: &[String]) -> Result<(), String> {
|
||||
use deckcraft_mcp::{Backend, Remote, control_addr};
|
||||
let mut port = deckcraft_mcp::DEFAULT_PORT.to_string();
|
||||
@@ -233,6 +580,10 @@ fn app(args: &[String]) -> Result<(), String> {
|
||||
match a.as_str() {
|
||||
"--port" => port = it.next().cloned().ok_or("--port needs a value")?,
|
||||
"--method" => method = Some(it.next().cloned().ok_or("--method needs a name")?),
|
||||
"--help" | "-h" => {
|
||||
outln!("{}", help_page("app"));
|
||||
return Ok(());
|
||||
}
|
||||
other => rest.push(other.to_string()),
|
||||
}
|
||||
}
|
||||
@@ -253,24 +604,47 @@ fn app(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
|
||||
fn mcp(args: &[String]) -> Result<(), String> {
|
||||
use deckcraft_engine::file_access::{self, AutomationRoots};
|
||||
use deckcraft_mcp::{Backend, Headless, Remote, Server, control_addr};
|
||||
let mut connect: Option<String> = None;
|
||||
let mut sample = false;
|
||||
let mut it = args.iter();
|
||||
let mut roots_args = automation_args::AutomationArgs::new(None, None);
|
||||
let mut it = args.iter().cloned();
|
||||
while let Some(a) = it.next() {
|
||||
if roots_args.consume(&a, &mut it)? {
|
||||
continue;
|
||||
}
|
||||
match a.as_str() {
|
||||
"--connect" => connect = Some(it.next().cloned().ok_or("--connect needs a port or host:port")?),
|
||||
"--connect" => connect = Some(it.next().ok_or("--connect needs a port or host:port")?),
|
||||
"--sample" => sample = true,
|
||||
"--help" | "-h" => {
|
||||
outln!("{}", help_page("mcp"));
|
||||
return Ok(());
|
||||
}
|
||||
other => return Err(format!("unknown mcp option `{other}`")),
|
||||
}
|
||||
}
|
||||
let roots = AutomationRoots::new(roots_args.read_root.as_deref(), roots_args.write_root.as_deref())?;
|
||||
if roots.is_some() && connect.is_some() {
|
||||
return Err("--automation-read-root and --automation-write-root confine the in-process server; a running app is not confined by them, \
|
||||
so drop --connect (or use --connect without the roots)"
|
||||
.into());
|
||||
}
|
||||
let backend: Box<dyn Backend> = match connect {
|
||||
Some(c) => Box::new(Remote::connect(&control_addr(&c)).map_err(|e| format!("can't reach the app at {c}: {e}"))?),
|
||||
None => {
|
||||
let mut h = Headless::new();
|
||||
// `--sample` is the operator's own choice, made before the roots come into force.
|
||||
if sample {
|
||||
deckcraft_engine::sample::open_sample(&mut h.session).map_err(|e| e.to_string())?;
|
||||
}
|
||||
if let Some(roots) = roots {
|
||||
// Every thread of this process, not only the server's: nothing reaches past the roots.
|
||||
file_access::confine_process(roots.clone());
|
||||
let show = |root: Option<&std::path::Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string());
|
||||
eprintln!("deckcraft-cli: files confined: read root {}, write root {}", show(roots.read_root()), show(roots.write_root()));
|
||||
h = h.with_automation_roots(Some(roots));
|
||||
}
|
||||
Box::new(h)
|
||||
}
|
||||
};
|
||||
@@ -278,3 +652,134 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
||||
let stdin = std::io::stdin();
|
||||
server.serve(stdin.lock(), std::io::stdout()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use super::*;
|
||||
|
||||
fn args(a: &[&str]) -> Vec<String> {
|
||||
a.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_help_prints_usage_and_writes_nothing() {
|
||||
assert!(render(&args(&["--help"])).is_ok());
|
||||
assert!(render(&args(&["-h"])).is_ok());
|
||||
assert!(!std::path::Path::new("--help").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_rejects_unknown_flag() {
|
||||
let err = render(&args(&["--sample", "--scael", "2", "out.png"])).unwrap_err();
|
||||
assert_eq!(err, "unknown option `--scael`");
|
||||
assert!(!std::path::Path::new("--scael").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_subcommands_accept_help() {
|
||||
assert!(info(&args(&["--help"])).is_ok());
|
||||
assert!(convert(&args(&["-h"])).is_ok());
|
||||
assert!(run(&args(&["--help"])).is_ok());
|
||||
assert!(describe(Some("--help")).is_ok());
|
||||
assert!(mcp(&args(&["-h"])).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_refuses_an_unknown_option_instead_of_writing_it_as_a_file() {
|
||||
let out = std::env::temp_dir().join("deckcraft-cli-unknown-option.png");
|
||||
let _ = std::fs::remove_file(&out);
|
||||
for flag in ["--bogus", "-x"] {
|
||||
let args: Vec<String> = ["--sample", flag, &out.to_string_lossy()].iter().map(|a| a.to_string()).collect();
|
||||
let e = render(&args).unwrap_err();
|
||||
assert!(e.contains(&format!("unknown option `{flag}`")), "{flag}: {e}");
|
||||
assert!(!out.exists(), "{flag}: rendered anyway");
|
||||
}
|
||||
}
|
||||
|
||||
/// `render --transparent` leaves the slide background out (#784): the corner is transparent.
|
||||
#[test]
|
||||
fn render_transparent_leaves_the_background_out() {
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-cli-transparent-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let alpha = |path: &std::path::Path| deckcraft_engine::render::decode_image(&std::fs::read(path).unwrap()).unwrap().get_pixel(1, 1).0[3];
|
||||
let opaque = dir.join("opaque.png");
|
||||
render(&args(&["--sample", "--scale", "0.25", &opaque.to_string_lossy()])).unwrap();
|
||||
assert_eq!(alpha(&opaque), 255);
|
||||
let clear = dir.join("clear.png");
|
||||
render(&args(&["--sample", "--transparent", "--scale", "0.25", &clear.to_string_lossy()])).unwrap();
|
||||
assert_eq!(alpha(&clear), 0);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// `run --export OUT.gif --export-params JSON` and `convert IN OUT.gif` write animated GIFs.
|
||||
#[test]
|
||||
fn exports_an_animated_gif() {
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-cli-gif-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let gif = dir.join("sample.gif").to_string_lossy().to_string();
|
||||
let deck = dir.join("sample.deckcraft").to_string_lossy().to_string();
|
||||
let params = r#"{"width": "small", "secondsPerSlide": 2, "loop": false, "slides": [0, 1]}"#;
|
||||
run(&args(&["--sample", "--save", &deck, "--export", &gif, "--export-params", params])).unwrap();
|
||||
let bytes = std::fs::read(&gif).unwrap();
|
||||
assert!(bytes.starts_with(b"GIF89a"));
|
||||
assert_eq!(u16::from_le_bytes([bytes[6], bytes[7]]), 640, "small = 640 px wide");
|
||||
assert!(!bytes.windows(11).any(|w| w == b"NETSCAPE2.0"), "play once: no loop block");
|
||||
|
||||
let gif2 = dir.join("converted.gif").to_string_lossy().to_string();
|
||||
convert(&args(&[&deck, &gif2])).unwrap();
|
||||
let bytes = std::fs::read(&gif2).unwrap();
|
||||
assert!(bytes.starts_with(b"GIF89a") && bytes.windows(11).any(|w| w == b"NETSCAPE2.0"), "loops forever by default");
|
||||
|
||||
// Bad options are errors, not panics.
|
||||
let bad = dir.join("bad.gif").to_string_lossy().to_string();
|
||||
assert!(run(&args(&["--sample", "--export", &bad, "--export-params", r#"{"width": 1e9}"#])).is_err());
|
||||
assert!(run(&args(&["--sample", "--export", &bad, "--export-params", "[1]"])).is_err());
|
||||
assert!(run(&args(&["--sample", "--export-params", "{}"])).is_ok());
|
||||
assert!(run(&args(&["--sample", "--export-params", r#"{"width": 64}"#])).is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod slide_index_tests {
|
||||
use super::{missing_slide, slide_indices};
|
||||
|
||||
#[test]
|
||||
fn slide_numbers_start_at_zero_and_files_at_one() {
|
||||
assert_eq!(slide_indices(8, false, None).ok().as_deref(), Some(&[0][..]));
|
||||
assert_eq!(slide_indices(8, false, Some(0)).ok().as_deref(), Some(&[0][..]));
|
||||
assert_eq!(slide_indices(8, false, Some(7)).ok().as_deref(), Some(&[7][..]));
|
||||
assert_eq!(slide_indices(8, true, None).ok().as_deref(), Some(&[0, 1, 2, 3, 4, 5, 6, 7][..]));
|
||||
let err = slide_indices(8, false, Some(8)).err().unwrap_or_default();
|
||||
assert!(!err.is_empty(), "index 8 was accepted");
|
||||
assert!(err.contains("numbered 0 through 7"), "{err}");
|
||||
assert!(err.contains("slide-01.png"), "{err}");
|
||||
assert!(err.contains("slide-08.png"), "{err}");
|
||||
assert!(missing_slide(0, 0).contains("no slides"));
|
||||
assert!(missing_slide(1, 1).contains("index 0"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod frame_path_tests {
|
||||
use super::frame_paths;
|
||||
|
||||
#[test]
|
||||
fn single_frame_uses_out_as_is() {
|
||||
assert_eq!(frame_paths("a/b.png", &[0.5]), Ok(vec!["a/b.png".to_string()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_get_distinct_names() {
|
||||
let paths = frame_paths("f.png", &[0.0, 0.25, 1.0]).unwrap_or_default();
|
||||
assert_eq!(paths, ["f-t0.000.png", "f-t0.250.png", "f-t1.000.png"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_rounding_to_one_name_are_rejected() {
|
||||
assert!(frame_paths("f.png", &[0.1001, 0.1002]).is_err());
|
||||
assert!(frame_paths("f.png", &[0.5, 0.5]).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
use std::process::{Command, Output};
|
||||
|
||||
fn cli(args: &[&str]) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).args(args).output().unwrap()
|
||||
}
|
||||
|
||||
fn stdout(o: &Output) -> String {
|
||||
assert!(o.status.success(), "exit {:?}\nstderr: {}", o.status, String::from_utf8_lossy(&o.stderr));
|
||||
String::from_utf8_lossy(&o.stdout).into_owned()
|
||||
}
|
||||
|
||||
fn stderr(o: &Output) -> String {
|
||||
assert!(!o.status.success(), "exit {:?}\nstdout: {}", o.status, String::from_utf8_lossy(&o.stdout));
|
||||
String::from_utf8_lossy(&o.stderr).into_owned()
|
||||
}
|
||||
|
||||
const SUBCOMMANDS: [&str; 11] = ["info", "render", "render-transition", "convert", "run", "commands", "describe", "app", "mcp", "bench", "links"];
|
||||
|
||||
fn words(s: &str) -> String {
|
||||
s.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_subcommand_is_named_before_the_overview() {
|
||||
for args in [vec!["frobnicate"], vec!["help", "frobnicate"], vec!["--info", "--help"]] {
|
||||
let err = stderr(&cli(&args));
|
||||
assert!(err.contains("unknown subcommand `") && err.contains("\n\ndeckcraft-cli works on"), "{args:?}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_subcommand_shows_the_overview_and_fails() {
|
||||
assert!(stderr(&cli(&[])).starts_with("deckcraft-cli works on"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_lists_every_subcommand_with_a_run_example() {
|
||||
for flag in ["help", "--help", "-h"] {
|
||||
let out = stdout(&cli(&[flag]));
|
||||
for sub in SUBCOMMANDS {
|
||||
assert!(out.contains(&format!("\n {sub} ")), "{flag} lacks {sub}: {out}");
|
||||
}
|
||||
assert!(out.contains("deckcraft-cli run --cmd 'slide.new={\"title\":\"Q3 results\"}' --save q3.deckcraft"), "{out}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_subcommand_answers_help_and_does_nothing_else() {
|
||||
let dir = std::env::temp_dir().join(format!("dc-cli-help-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
for sub in SUBCOMMANDS {
|
||||
for args in [vec![sub, "--help"], vec![sub, "-h", "out.png"], vec!["help", sub]] {
|
||||
let o = Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).current_dir(&dir).args(&args).output().unwrap();
|
||||
assert!(stdout(&o).starts_with(&format!("usage: deckcraft-cli {sub}")), "{args:?}");
|
||||
}
|
||||
}
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "help wrote nothing");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
for args in [["help", "version"], ["--version", "--help"]] {
|
||||
assert!(stdout(&cli(&args)).starts_with("usage: deckcraft-cli --version"), "{args:?}");
|
||||
}
|
||||
for args in [["help", "help"], ["help", "--help"]] {
|
||||
assert!(stdout(&cli(&args)).starts_with("deckcraft-cli works on"), "{args:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_help_says_what_the_ids_do_not() {
|
||||
let out = words(&stdout(&cli(&["run", "--help"])));
|
||||
for fact in [
|
||||
"COMMAND is a command id, then `=`, then the command's parameters as one JSON object.",
|
||||
"points on a 960×540 slide",
|
||||
"Slide indices start at 0.",
|
||||
"Shape ids are looked up on the current slide only.",
|
||||
"A command that adds a shape (shape.insert, insert.textBox, insert.table…) selects it.",
|
||||
"format.* acts on the selected text or shapes",
|
||||
] {
|
||||
assert!(out.contains(fact), "{fact}: {out}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn describe_spells_out_a_command() {
|
||||
assert_eq!(
|
||||
stdout(&cli(&["describe", "shape.insert"])),
|
||||
"shape.insert: Shapes, in Insert ▸ Illustrations.
|
||||
|
||||
Parameters:
|
||||
preset rect|roundRect|ellipse|triangle|rightArrow|star5|… (see shape.presets)
|
||||
rect [x,y,w,h] pt
|
||||
text, optional string
|
||||
|
||||
Run it with: deckcraft-cli run --cmd 'shape.insert={…}'
|
||||
"
|
||||
);
|
||||
assert_eq!(
|
||||
stdout(&cli(&["describe", "format.size"])),
|
||||
"format.size: Font Size, in Home ▸ Font.
|
||||
|
||||
Parameters:
|
||||
size pt
|
||||
Acts on the selected text or shapes.
|
||||
|
||||
Run it with: deckcraft-cli run --cmd 'format.size={…}'
|
||||
"
|
||||
);
|
||||
assert!(stdout(&cli(&["describe", "edit.copy"])).contains("Answers with {text, html?}.\n\nRun it with: deckcraft-cli run --cmd edit.copy\n"));
|
||||
assert!(stdout(&cli(&["describe", "text.set"])).contains("Note: replaces the whole text, keeping the first run's formatting\n"));
|
||||
assert_eq!(
|
||||
stdout(&cli(&["describe", "shape.setDefault"])),
|
||||
"shape.setDefault: Set as Default Shape.\n\nTakes no parameters.\nActs on the selected shapes.\n\nRun it with: deckcraft-cli run --cmd shape.setDefault\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn describe_shows_alternative_sets_of_parameters() {
|
||||
assert!(stdout(&cli(&["describe", "slide.go"])).contains("Parameters:\n either:\n index\n or:\n id\n"));
|
||||
let remove = stdout(&cli(&["describe", "animation.remove"]));
|
||||
assert!(remove.contains(" or:\n ids, optional (all of the shapes' animations)\n"), "{remove}");
|
||||
assert!(remove.ends_with("Run it with: deckcraft-cli run --cmd animation.remove\n") && !remove.contains("Note:"), "{remove}");
|
||||
assert!(stdout(&cli(&["describe", "shape.move"])).contains(
|
||||
" either:
|
||||
dx, optional pt (relative)
|
||||
dy, optional pt (relative)
|
||||
ids, optional
|
||||
or:
|
||||
x, optional pt (absolute)
|
||||
y, optional pt (absolute)
|
||||
ids, optional
|
||||
"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_lists_one_line_per_command() {
|
||||
assert_eq!(stdout(&cli(&["commands", "format.size"])), "format.size — Font Size: {size: pt}\n");
|
||||
assert_eq!(stdout(&cli(&["commands", "slide.next"])), "slide.next — Next Slide\n");
|
||||
assert_eq!(stdout(&cli(&["commands", "shape.presets"])), "shape.presets — Shape Gallery → [{name, label, category}]\n");
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//! `deckcraft-cli convert` writes the format OUT's extension names, or fails without writing.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output};
|
||||
|
||||
fn cli(args: &[&Path]) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).args(args).output().unwrap()
|
||||
}
|
||||
|
||||
fn temp_dir(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-cli-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_rejects_output_formats_it_cannot_write() {
|
||||
let dir = temp_dir("convert-unsupported");
|
||||
let input = dir.join("in.pptx");
|
||||
let out = Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).args(["run", "--sample", "--export"]).arg(&input).output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
for name in ["out.odp", "out.bmp", "out.svg", "out.outline", "out"] {
|
||||
let target = dir.join(name);
|
||||
let out = cli(&[Path::new("convert"), &input, &target]);
|
||||
assert!(!out.status.success(), "{name}: convert should fail");
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(err.contains("unsupported output format") && err.contains(".pptx"), "{name}: {err}");
|
||||
assert!(!target.exists(), "{name}: nothing is written");
|
||||
}
|
||||
|
||||
let target = dir.join("out.deckcraft");
|
||||
let out = cli(&[Path::new("convert"), &input, &target]);
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
assert!(std::fs::read(&target).unwrap().starts_with(b"PK\x03\x04"));
|
||||
|
||||
let target = dir.join("out.txt");
|
||||
let out = cli(&[Path::new("convert"), &input, &target]);
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
assert!(!std::fs::read(&target).unwrap().starts_with(b"PK"), "an outline is text, not a zip");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_rejects_unsupported_save_and_export_paths_before_running() {
|
||||
let dir = temp_dir("run-unsupported");
|
||||
for flag in ["--save", "--export"] {
|
||||
let target = dir.join("deck.odp");
|
||||
let out = Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).args(["run", "--sample", flag]).arg(&target).output().unwrap();
|
||||
assert!(!out.status.success(), "{flag}: run should fail");
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("unsupported output format .odp"), "{flag}");
|
||||
assert!(!target.exists(), "{flag}: nothing is written");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//! Opening a `.pptx` that lost slides says so on stderr instead of saving a shorter deck in silence (#203).
|
||||
|
||||
use std::io::{Cursor, Read, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Output};
|
||||
|
||||
fn cli(args: &[&str]) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).args(args).output().unwrap()
|
||||
}
|
||||
|
||||
fn temp_dir(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-cli-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// `deck` with `ppt/slides/slide2.xml` cut off.
|
||||
fn without_slide2(deck: &[u8]) -> Vec<u8> {
|
||||
let mut zin = zip::ZipArchive::new(Cursor::new(deck)).unwrap();
|
||||
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let options = zip::write::SimpleFileOptions::default();
|
||||
for i in 0..zin.len() {
|
||||
let mut f = zin.by_index(i).unwrap();
|
||||
let mut data = vec![];
|
||||
f.read_to_end(&mut data).unwrap();
|
||||
if f.name() == "ppt/slides/slide2.xml" {
|
||||
data = b"<p:sld".to_vec();
|
||||
}
|
||||
out.start_file(f.name(), options).unwrap();
|
||||
out.write_all(&data).unwrap();
|
||||
}
|
||||
out.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pptx_with_an_unreadable_slide_is_reported_on_stderr() {
|
||||
let dir = temp_dir("lost-slide");
|
||||
let good = dir.join("good.pptx");
|
||||
let made = cli(&["run", "--sample", "--save", good.to_str().unwrap()]);
|
||||
assert!(made.status.success(), "{}", String::from_utf8_lossy(&made.stderr));
|
||||
let broken = dir.join("broken.pptx");
|
||||
std::fs::write(&broken, without_slide2(&std::fs::read(&good).unwrap())).unwrap();
|
||||
|
||||
let ok = cli(&["info", good.to_str().unwrap()]);
|
||||
assert!(ok.status.success() && !String::from_utf8_lossy(&ok.stderr).contains("warning"), "{}", String::from_utf8_lossy(&ok.stderr));
|
||||
|
||||
let bad = cli(&["info", broken.to_str().unwrap()]);
|
||||
assert!(bad.status.success(), "{}", String::from_utf8_lossy(&bad.stderr));
|
||||
let err = String::from_utf8_lossy(&bad.stderr);
|
||||
assert!(err.contains("warning") && err.contains("1 slide couldn't be read and left out"), "{err}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
mod cli;
|
||||
mod convert;
|
||||
mod damaged;
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod mcp_roots;
|
||||
@@ -0,0 +1,116 @@
|
||||
//! `deckcraft-cli mcp` with the automation root flags, run as the real binary.
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Output, Stdio};
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
fn bin() -> Command {
|
||||
Command::new(env!("CARGO_BIN_EXE_deckcraft-cli"))
|
||||
}
|
||||
|
||||
fn run(args: &[&str]) -> Output {
|
||||
bin().args(args).output().unwrap()
|
||||
}
|
||||
|
||||
fn tmpdir(tag: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("dc-cli-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// Send `calls` (tool name, arguments) to `deckcraft-cli mcp <args>`; returns whether it exited
|
||||
/// cleanly, its stderr, and the stdout lines after `initialize`, one per call.
|
||||
fn mcp_session(args: &[&str], calls: &[(&str, Value)]) -> (bool, String, Vec<Value>) {
|
||||
let mut child = bin().arg("mcp").args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
{
|
||||
let stdin = child.stdin.as_mut().unwrap();
|
||||
writeln!(stdin, r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2025-06-18"}}}}"#).unwrap();
|
||||
for (i, (name, arguments)) in calls.iter().enumerate() {
|
||||
let call = serde_json::json!({"jsonrpc": "2.0", "id": i + 2, "method": "tools/call", "params": {"name": name, "arguments": arguments}});
|
||||
writeln!(stdin, "{call}").unwrap();
|
||||
}
|
||||
}
|
||||
let out = child.wait_with_output().unwrap();
|
||||
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||
let replies = stdout.lines().skip(1).map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
(out.status.success(), String::from_utf8_lossy(&out.stderr).into_owned(), replies)
|
||||
}
|
||||
|
||||
fn text(reply: &Value) -> &str {
|
||||
reply["result"]["content"][0]["text"].as_str().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_automation_roots_confine_the_server() {
|
||||
let base = tmpdir("roots");
|
||||
let (work, elsewhere) = (base.join("work"), base.join("elsewhere"));
|
||||
std::fs::create_dir_all(&work).unwrap();
|
||||
std::fs::create_dir_all(&elsewhere).unwrap();
|
||||
std::fs::write(work.join("in.txt"), "Inside text").unwrap();
|
||||
std::fs::write(elsewhere.join("secret.txt"), "TOP SECRET").unwrap();
|
||||
let work_s = work.to_str().unwrap();
|
||||
let secret = elsewhere.join("secret.txt").to_str().unwrap().to_string();
|
||||
|
||||
// Both spellings of the flags; inside works, outside is refused with a readable message.
|
||||
let read_flag = format!("--automation-read-root={work_s}");
|
||||
let calls = [
|
||||
("open_presentation", serde_json::json!({"path": work.join("in.txt").to_str().unwrap()})),
|
||||
("open_presentation", serde_json::json!({"path": secret})),
|
||||
("save_presentation", serde_json::json!({"path": work.join("saved.deckcraft").to_str().unwrap()})),
|
||||
("save_presentation", serde_json::json!({"path": elsewhere.join("saved.deckcraft").to_str().unwrap()})),
|
||||
];
|
||||
let (success, stderr, replies) = mcp_session(&[&read_flag, "--automation-write-root", work_s], &calls);
|
||||
assert!(success, "{stderr}");
|
||||
assert!(stderr.contains("files confined"), "{stderr}");
|
||||
assert_eq!(replies[0]["result"]["isError"], false, "{}", replies[0]);
|
||||
assert_eq!(replies[1]["result"]["isError"], true);
|
||||
assert!(text(&replies[1]).contains("outside the read root"), "{}", text(&replies[1]));
|
||||
assert_eq!(replies[2]["result"]["isError"], false, "{}", replies[2]);
|
||||
assert!(work.join("saved.deckcraft").exists());
|
||||
assert_eq!(replies[3]["result"]["isError"], true);
|
||||
assert!(text(&replies[3]).contains("outside the write root"), "{}", text(&replies[3]));
|
||||
assert!(!elsewhere.join("saved.deckcraft").exists());
|
||||
|
||||
// A root left out grants none of its access.
|
||||
let save = [("new_presentation", serde_json::json!({})), calls[2].clone()];
|
||||
let (_, _, replies) = mcp_session(&[&read_flag], &save);
|
||||
assert_eq!(replies[1]["result"]["isError"], true);
|
||||
assert!(text(&replies[1]).contains("write authority is absent"), "{}", text(&replies[1]));
|
||||
|
||||
// No flags: nothing is confined.
|
||||
let (success, _, replies) = mcp_session(&[], &calls[1..2]);
|
||||
assert!(success);
|
||||
assert_eq!(replies[0]["result"]["isError"], false, "{}", replies[0]);
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_automation_root_flags_are_checked_up_front() {
|
||||
let base = tmpdir("roots-args");
|
||||
let dir = base.to_str().unwrap();
|
||||
let missing = base.join("missing");
|
||||
let fails = |args: &[&str], needle: &str| {
|
||||
let mut full = vec!["mcp"];
|
||||
full.extend_from_slice(args);
|
||||
let o = run(&full);
|
||||
assert!(!o.status.success(), "{args:?}");
|
||||
let err = String::from_utf8_lossy(&o.stderr).into_owned();
|
||||
assert!(err.contains(needle), "{args:?}: {err}");
|
||||
};
|
||||
// The flags confine the in-process server, so they cannot be combined with --connect.
|
||||
fails(&["--connect", "7979", "--automation-read-root", dir], "--connect");
|
||||
fails(&["--automation-write-root", dir, "--connect", "7979"], "--connect");
|
||||
fails(&["--automation-read-root", dir, "--automation-read-root", dir], "given twice");
|
||||
fails(&["--automation-write-root", dir, "--automation-write-root", dir], "given twice");
|
||||
fails(&["--automation-read-root"], "needs a folder");
|
||||
fails(&["--automation-write-root", "--sample"], "needs a folder");
|
||||
fails(&["--automation-read-root", missing.to_str().unwrap()], "cannot open the automation read root");
|
||||
fails(&["--automation-write-root="], "needs a non-empty folder");
|
||||
// A file is not a folder.
|
||||
std::fs::write(base.join("f.txt"), "x").unwrap();
|
||||
fails(&["--automation-write-root", base.join("f.txt").to_str().unwrap()], "cannot open the automation write root");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<title>DeckCraft</title>
|
||||
<link data-trunk rel="rust" data-bin="deckcraft-web" data-wasm-opt="z" data-weak-refs />
|
||||
<link data-trunk rel="icon" href="../../assets/app-icon/hicolor/32x32/apps/ai.storyteller.deckcraft.png" />
|
||||
<link data-trunk rel="icon" href="../../assets/app-icon/deckcraft.ico" />
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262626; }
|
||||
#deckcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
//! Differences from the desktop app:
|
||||
//! - no TCP control channel (browsers can't listen on sockets);
|
||||
//! - File → Open and Insert → Pictures/Audio/Video use the browser file picker; bytes arrive
|
||||
//! asynchronously through `Services::inbox` (presentations open, media is inserted);
|
||||
//! asynchronously through `Services::inbox`, applied to the document, slide and operation
|
||||
//! that started the pick (a dismissed picker or a closed document drops the file);
|
||||
//! - Save and Export trigger a browser download;
|
||||
//! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox;
|
||||
//! - preferences are not persisted.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! The browser shell: web `Services`, drag-and-drop, and the eframe web runner.
|
||||
|
||||
use deckcraft_engine::Session;
|
||||
use deckcraft_ui_egui::{Inbox, Services, SlideApp};
|
||||
use deckcraft_ui_egui::{Inbox, InboxEvent, InboxFile, Services, SlideApp};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
|
||||
const PRESENTATION_EXTS: &[&str] = &["deckcraft", "pptx", "potx", "ppsx", "txt", "md"];
|
||||
@@ -77,16 +77,23 @@ impl eframe::App for WebShell {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
|
||||
for f in dropped {
|
||||
// Capture the document and slide now. The bytes arrive later, after the user may
|
||||
// have switched slides.
|
||||
let id = self.app.begin_external_file("drop");
|
||||
let inbox = self.inbox.clone();
|
||||
let ctx = ctx.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
|
||||
match f.bytes_async().await {
|
||||
Ok(bytes) => {
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
|
||||
push_inbox(&inbox, InboxEvent::File(InboxFile { id, name, bytes }));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("couldn't read dropped file {name}: {e}");
|
||||
push_inbox(&inbox, InboxEvent::Cancelled { id });
|
||||
ctx.request_repaint();
|
||||
}
|
||||
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -107,7 +114,7 @@ impl eframe::App for WebShell {
|
||||
fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
||||
let open_inbox = inbox.clone();
|
||||
Services {
|
||||
open_async: Some(Box::new(move |purpose: &str| {
|
||||
open_async: Some(Box::new(move |purpose: &str, request: u64| {
|
||||
let inbox = open_inbox.clone();
|
||||
let ctx = ctx.clone();
|
||||
let dialog = match purpose {
|
||||
@@ -118,10 +125,12 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
||||
};
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let Some(file) = dialog.pick_file().await else {
|
||||
push_inbox(&inbox, InboxEvent::Cancelled { id: request });
|
||||
ctx.request_repaint();
|
||||
return;
|
||||
};
|
||||
let bytes = file.read().await;
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((file.file_name(), bytes));
|
||||
push_inbox(&inbox, InboxEvent::File(InboxFile { id: request, name: file.file_name(), bytes }));
|
||||
ctx.request_repaint();
|
||||
});
|
||||
})),
|
||||
@@ -136,6 +145,10 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
||||
}
|
||||
}
|
||||
|
||||
fn push_inbox(inbox: &Inbox, event: InboxEvent) {
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push(event);
|
||||
}
|
||||
|
||||
/// Trigger a browser download of `bytes` named after the last component of `path`.
|
||||
fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
|
||||
@@ -168,6 +181,7 @@ fn mime_for(name: &str) -> &'static str {
|
||||
Some("png") => "image/png",
|
||||
Some("jpg" | "jpeg") => "image/jpeg",
|
||||
Some("pdf") => "application/pdf",
|
||||
Some("gif") => "image/gif",
|
||||
Some("txt") => "text/plain",
|
||||
Some("pptx") => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
Some("deckcraft") => "application/zip",
|
||||
|
||||
@@ -18,6 +18,7 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
# Icon + VERSIONINFO for deckcraft.exe; build.rs only uses it when the target is Windows.
|
||||
@@ -29,5 +30,12 @@ arboard = { workspace = true, features = ["image-data"] }
|
||||
image = { workspace = true }
|
||||
cpal = "0.16"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# Finder double-click, Open With, Dock drops and `open -a DeckCraft deck.pptx` arrive as a
|
||||
# kAEOpenDocuments Apple event, which winit 0.30 doesn't handle. This audited crate (also used by
|
||||
# GridCraft, PdfCraft and PhotoCraft) registers the handler (Objective-C interop needs unsafe, which
|
||||
# our workspace forbids) behind a safe API. Pinned exactly: review the diff before bumping.
|
||||
fmv-macos-events = "=0.1.0"
|
||||
|
||||
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
|
||||
winit = { version = "0.30", default-features = false, features = ["x11"] }
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
//! macOS open-documents and quit Apple events.
|
||||
//!
|
||||
//! Finder double-clicks, Open With, drops on the Dock icon and `open -a DeckCraft deck.pptx` don't
|
||||
//! pass paths on the command line: LaunchServices sends the running (or just-launched) app a
|
||||
//! `kAEOpenDocuments` ('odoc') Apple event. winit 0.30 doesn't handle it and owns the
|
||||
//! `NSApplicationDelegate`. Handling it ourselves needs Objective-C class declarations, i.e.
|
||||
//! `unsafe`, which this workspace forbids. The audited `fmv-macos-events` crate (also used by
|
||||
//! GridCraft, PdfCraft and PhotoCraft) wraps exactly that, an `NSAppleEventManager` handler
|
||||
//! registered before Finder's launch event that leaves winit's delegate alone, behind a safe
|
||||
//! main-thread API.
|
||||
|
||||
use deckcraft_ui_egui::SlideApp;
|
||||
use fmv_macos_events::{Event, Inbox, Registration};
|
||||
|
||||
/// Keeps the Apple-event handlers registered; hold it until the event loop returns.
|
||||
pub struct AppleEvents {
|
||||
_registration: Registration,
|
||||
inbox: Inbox,
|
||||
}
|
||||
|
||||
impl AppleEvents {
|
||||
/// Register the handlers. Call on the main thread before the event loop starts, so the event
|
||||
/// that launched the app (a Finder double-click) is caught too.
|
||||
pub fn install() -> Self {
|
||||
let (registration, inbox) = Registration::install();
|
||||
Self { _registration: registration, inbox }
|
||||
}
|
||||
|
||||
/// The queue the app drains every frame ([`poll`]); events arriving later wake `ctx`.
|
||||
pub fn connect(&self, ctx: &egui::Context) -> Inbox {
|
||||
let ctx = ctx.clone();
|
||||
self.inbox.set_wake(move || ctx.request_repaint());
|
||||
self.inbox.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the documents and act on the quit requests that arrived since the last frame.
|
||||
pub fn poll(inbox: &Inbox, app: &mut SlideApp, ctx: &egui::Context) {
|
||||
for e in inbox.drain() {
|
||||
match e {
|
||||
Event::Open(paths) => {
|
||||
for p in paths.iter().filter_map(|p| p.to_str()) {
|
||||
if let Err(e) = app.open_path(p) {
|
||||
let safe_p = p.replace(['\r', '\n'], " ");
|
||||
log::error!("{safe_p}: {e}");
|
||||
app.set_status(format!("Couldn't open {safe_p}: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! cpal audio output for media playback: the device pulls mixed samples from the
|
||||
//! `deckcraft_media::Player`, which makes it the playback clock video frames follow.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
@@ -29,6 +30,8 @@ fn take_stream_error(slot: &ErrorSlot) -> Option<String> {
|
||||
pub struct CpalOut {
|
||||
stream: Option<cpal::Stream>,
|
||||
error: ErrorSlot,
|
||||
/// Set by the realtime error callback when the device went away: the stream is dead.
|
||||
dead: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl CpalOut {
|
||||
@@ -56,7 +59,14 @@ impl AudioOut for CpalOut {
|
||||
let rate = cfg.sample_rate().0;
|
||||
let config: cpal::StreamConfig = cfg.clone().into();
|
||||
let slot = self.error.clone();
|
||||
let err = move |e: cpal::StreamError| keep_stream_error(&slot, &e);
|
||||
let dead = Arc::new(AtomicBool::new(false));
|
||||
self.dead = dead.clone();
|
||||
let err = move |e: cpal::StreamError| {
|
||||
if matches!(e, cpal::StreamError::DeviceNotAvailable) {
|
||||
dead.store(true, Ordering::Relaxed);
|
||||
}
|
||||
keep_stream_error(&slot, &e);
|
||||
};
|
||||
let stream = match cfg.sample_format() {
|
||||
cpal::SampleFormat::F32 => dev.build_output_stream(&config, move |buf: &mut [f32], _| fill(buf, channels, rate), err, None),
|
||||
cpal::SampleFormat::I16 => {
|
||||
@@ -86,6 +96,13 @@ impl AudioOut for CpalOut {
|
||||
self.stream = None;
|
||||
self.report();
|
||||
}
|
||||
|
||||
fn failure(&mut self) -> Option<String> {
|
||||
if !self.dead.swap(false, Ordering::Relaxed) {
|
||||
return None;
|
||||
}
|
||||
Some(take_stream_error(&self.error).unwrap_or_else(|| "the audio device is no longer available".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,62 +1,509 @@
|
||||
//! Loopback JSON-lines control server: one request per line, one reply per line.
|
||||
//! This is the transport the MCP server (`deckcraft-cli mcp --connect`) wraps.
|
||||
//!
|
||||
//! The server only reads requests: a line that is not a JSON object with a string `method` (an
|
||||
//! HTTP request line, a stray `nc`, binary data) gets one error reply and the connection is
|
||||
//! closed, so nothing sent after it runs. Every resource a client can hold is bounded
|
||||
//! ([`Limits`]): simultaneous connections, request line length, queued requests, and how long a
|
||||
//! socket may sit idle or blocked on a write.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::Duration;
|
||||
use std::io::{BufRead, BufReader, ErrorKind, Read, Write};
|
||||
use std::net::{Shutdown, TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{Receiver, SyncSender, TrySendError, sync_channel};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use deckcraft_ui_egui::ControlRequest;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Resource bounds for the control server.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Limits {
|
||||
/// Connections served at once; more are told `busy` and closed.
|
||||
pub max_clients: usize,
|
||||
/// Longest request line in bytes (newline excluded); a longer one closes the connection.
|
||||
pub max_line: usize,
|
||||
/// Requests waiting for the UI at once; more are answered `busy`.
|
||||
pub queue: usize,
|
||||
/// How long a connection may go without sending a byte before it is closed.
|
||||
pub idle_timeout: Duration,
|
||||
/// How long a reply write may block before the connection is closed.
|
||||
pub write_timeout: Duration,
|
||||
/// How long a request waits for the UI's answer.
|
||||
pub reply_timeout: Duration,
|
||||
}
|
||||
|
||||
impl Default for Limits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_clients: 16,
|
||||
// Picture and media inserts carry base64 payloads: room for files of about 190 MB.
|
||||
max_line: 256 * 1024 * 1024,
|
||||
queue: 64,
|
||||
idle_timeout: Duration::from_secs(600),
|
||||
write_timeout: Duration::from_secs(30),
|
||||
reply_timeout: Duration::from_secs(60),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
||||
Ok(l) => l,
|
||||
match TcpListener::bind(("127.0.0.1", port)) {
|
||||
Ok(l) => {
|
||||
log::info!("control server listening on 127.0.0.1:{port}");
|
||||
serve_listener(l, Limits::default(), ctx)
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("control server failed to bind 127.0.0.1:{port}: {e}");
|
||||
return rx;
|
||||
sync_channel::<ControlRequest>(0).1
|
||||
}
|
||||
};
|
||||
log::info!("control server listening on 127.0.0.1:{port}");
|
||||
}
|
||||
}
|
||||
|
||||
/// How long the accepting thread waits after a failed accept. Accepting can keep failing, for
|
||||
/// example while the process has no file descriptors left, and retrying at once would keep a
|
||||
/// core busy until it recovers.
|
||||
const ACCEPT_RETRY: Duration = Duration::from_millis(100);
|
||||
|
||||
/// The connections `incoming` accepts, calling `wait` after each failed accept.
|
||||
fn accepted<T>(incoming: impl Iterator<Item = std::io::Result<T>>, mut wait: impl FnMut()) -> impl Iterator<Item = T> {
|
||||
incoming.filter_map(move |stream| stream.map_err(|_| wait()).ok())
|
||||
}
|
||||
|
||||
/// Accept connections on `listener` under `limits`; requests arrive on the returned receiver.
|
||||
fn serve_listener(listener: TcpListener, limits: Limits, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let (tx, rx) = sync_channel::<ControlRequest>(limits.queue.max(1));
|
||||
let active = Arc::new(AtomicUsize::new(0));
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
for mut stream in accepted(listener.incoming(), || std::thread::sleep(ACCEPT_RETRY)) {
|
||||
let _ = stream.set_write_timeout(Some(limits.write_timeout));
|
||||
if active.fetch_add(1, Ordering::SeqCst) >= limits.max_clients {
|
||||
active.fetch_sub(1, Ordering::SeqCst);
|
||||
let _ = writeln!(stream, "{}", json!({"ok": false, "error": "busy: too many control connections"}));
|
||||
continue;
|
||||
}
|
||||
let slot = Slot(Arc::clone(&active));
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
std::thread::spawn(move || serve(stream, tx, ctx));
|
||||
let spawned = std::thread::Builder::new().name("control-client".into()).spawn(move || {
|
||||
let _slot = slot;
|
||||
serve(stream, &tx, &ctx, limits);
|
||||
});
|
||||
if spawned.is_err() {
|
||||
log::warn!("control server could not start a client thread");
|
||||
}
|
||||
}
|
||||
});
|
||||
rx
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
/// Holds one connection slot; gives it back when the client thread ends, however it ends.
|
||||
struct Slot(Arc<AtomicUsize>);
|
||||
|
||||
impl Drop for Slot {
|
||||
fn drop(&mut self) {
|
||||
self.0.fetch_sub(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
enum Line {
|
||||
Text(Vec<u8>),
|
||||
TooLong,
|
||||
Closed,
|
||||
}
|
||||
|
||||
/// Read one `\n`-terminated line of at most `max` bytes without buffering more than `max + 1`.
|
||||
fn read_line(r: &mut impl BufRead, max: usize) -> Line {
|
||||
let mut buf = Vec::new();
|
||||
let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1);
|
||||
match r.by_ref().take(limit).read_until(b'\n', &mut buf) {
|
||||
Ok(0) | Err(_) => Line::Closed,
|
||||
Ok(_) => {
|
||||
if buf.last() == Some(&b'\n') {
|
||||
buf.pop();
|
||||
Line::Text(buf)
|
||||
} else if buf.len() > max {
|
||||
Line::TooLong
|
||||
} else {
|
||||
// EOF inside a line: serve what arrived, the next read reports Closed.
|
||||
Line::Text(buf)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One request line, parsed.
|
||||
enum Parsed {
|
||||
Request {
|
||||
id: Value,
|
||||
method: String,
|
||||
params: Value,
|
||||
},
|
||||
Blank,
|
||||
/// Not a request: reply with this error and close the connection.
|
||||
Reject(String),
|
||||
}
|
||||
|
||||
fn parse(bytes: &[u8]) -> Parsed {
|
||||
let Ok(text) = std::str::from_utf8(bytes) else {
|
||||
return Parsed::Reject("not a control request (invalid UTF-8); closing the connection".into());
|
||||
};
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return Parsed::Blank;
|
||||
}
|
||||
if is_http_request_line(text) {
|
||||
return Parsed::Reject("HTTP is not served here: this port takes JSON lines; closing the connection".into());
|
||||
}
|
||||
let msg = match serde_json::from_str::<Value>(text) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return Parsed::Reject(format!("bad JSON ({e}); expected one JSON request object per line; closing the connection")),
|
||||
};
|
||||
let Some(obj) = msg.as_object() else {
|
||||
return Parsed::Reject("not a control request (expected a JSON object); closing the connection".into());
|
||||
};
|
||||
let Some(method) = obj.get("method").and_then(Value::as_str) else {
|
||||
return Parsed::Reject("not a control request (missing string `method`); closing the connection".into());
|
||||
};
|
||||
Parsed::Request {
|
||||
id: obj.get("id").cloned().unwrap_or(Value::Null),
|
||||
method: method.to_string(),
|
||||
params: obj.get("params").cloned().unwrap_or(json!({})),
|
||||
}
|
||||
}
|
||||
|
||||
/// An HTTP/1 request line (`POST / HTTP/1.1`) or the HTTP/2 preface (`PRI * HTTP/2.0`).
|
||||
fn is_http_request_line(text: &str) -> bool {
|
||||
const METHODS: [&str; 10] = ["GET", "HEAD", "POST", "PUT", "DELETE", "CONNECT", "OPTIONS", "TRACE", "PATCH", "PRI"];
|
||||
text.split_once(' ').is_some_and(|(method, _)| METHODS.contains(&method))
|
||||
|| text.rsplit_once(' ').is_some_and(|(_, version)| version.starts_with("HTTP/"))
|
||||
}
|
||||
|
||||
/// Close after the last reply so the peer can still read it. A peer that had sent another line
|
||||
/// before the reply arrived -- two lines in one write, which any script does -- leaves input
|
||||
/// unread in the socket, and closing a socket with unread input resets the connection: the reply
|
||||
/// already queued for the peer is discarded and it sees only the reset. Sending the FIN first and
|
||||
/// then draining, briefly and boundedly, makes the close orderly and the reason survive.
|
||||
fn close_politely(out: &TcpStream, reader: &mut BufReader<TcpStream>, grace: Duration) {
|
||||
if out.shutdown(Shutdown::Write).is_err() {
|
||||
return;
|
||||
}
|
||||
// Enough for a pipelined line or two; a peer that keeps talking is dropped at the deadline.
|
||||
// The timeout is what is left of the grace before each read, not the whole of it: a peer that
|
||||
// sends a byte just before each one expires would otherwise hold this thread, and its slot,
|
||||
// for close to twice as long as the deadline says.
|
||||
const MAX_DRAIN: usize = 64 << 10;
|
||||
let until = Instant::now() + grace;
|
||||
let mut sink = [0u8; 4 << 10];
|
||||
let mut drained = 0;
|
||||
while drained < MAX_DRAIN {
|
||||
let left = until.saturating_duration_since(Instant::now());
|
||||
if left.is_zero() || out.set_read_timeout(Some(left)).is_err() {
|
||||
break;
|
||||
}
|
||||
match reader.read(&mut sink) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => drained += n,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context, limits: Limits) {
|
||||
if stream.set_read_timeout(Some(limits.idle_timeout)).is_err() || stream.set_write_timeout(Some(limits.write_timeout)).is_err() {
|
||||
return;
|
||||
}
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
for line in BufReader::new(read).lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let reply = match serde_json::from_str::<Value>(&line) {
|
||||
Ok(msg) => {
|
||||
let id = msg.get("id").cloned().unwrap_or(Value::Null);
|
||||
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let params = msg.get("params").cloned().unwrap_or(json!({}));
|
||||
let mut reader = BufReader::new(read);
|
||||
loop {
|
||||
let line = match read_line(&mut reader, limits.max_line) {
|
||||
Line::Text(t) => t,
|
||||
Line::TooLong => {
|
||||
let _ = writeln!(out, "{}", json!({"ok": false, "error": format!("request line longer than {} bytes", limits.max_line)}));
|
||||
close_politely(&out, &mut reader, limits.write_timeout);
|
||||
break;
|
||||
}
|
||||
Line::Closed => break,
|
||||
};
|
||||
let reply = match parse(&line) {
|
||||
Parsed::Blank => continue,
|
||||
Parsed::Reject(e) => {
|
||||
// The connection closes either way, but the peer is told why first: see
|
||||
// `close_politely`, without which a peer that had pipelined another line had this
|
||||
// reply reset away and saw nothing.
|
||||
let _ = writeln!(out, "{}", json!({"ok": false, "error": e}));
|
||||
close_politely(&out, &mut reader, limits.write_timeout);
|
||||
break;
|
||||
}
|
||||
Parsed::Request { id, method, params } => {
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
if tx.send(req).is_err() {
|
||||
break;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
let mut r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
|
||||
let state = Arc::clone(&req.state);
|
||||
let mut r = match tx.try_send(req) {
|
||||
Ok(()) => {
|
||||
ctx.request_repaint();
|
||||
match rrx.recv_timeout(limits.reply_timeout) {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
// Give up on the request. While it was still queued this makes
|
||||
// sure it never runs late (#719); if the app had already taken
|
||||
// it, its outcome is unknown rather than a definite failure.
|
||||
if ControlRequest::abandon(&state) {
|
||||
json!({"ok": false, "error": "timeout"})
|
||||
} else {
|
||||
json!({"ok": false, "error": "timeout: the app had already started the request, so it may still run; its outcome is unknown"})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(TrySendError::Full(_)) => json!({"ok": false, "error": "busy: too many queued requests"}),
|
||||
Err(TrySendError::Disconnected(_)) => break,
|
||||
};
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
}
|
||||
r
|
||||
}
|
||||
Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}),
|
||||
};
|
||||
if writeln!(out, "{reply}").is_err() {
|
||||
if let Err(e) = writeln!(out, "{reply}") {
|
||||
if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) {
|
||||
log::debug!("control client stopped reading replies; closing");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A failed accept is followed by a wait before the next one, so a listener that keeps
|
||||
/// failing does not spin, and the connections around the failures still come through.
|
||||
#[test]
|
||||
fn a_failed_accept_waits_before_the_next() {
|
||||
let failed = || Err(std::io::Error::other("no file descriptors left"));
|
||||
let incoming = vec![failed(), Ok(1), failed(), failed(), Ok(2)];
|
||||
let mut waits = 0;
|
||||
let connections: Vec<i32> = super::accepted(incoming.into_iter(), || waits += 1).collect();
|
||||
assert_eq!(connections, [1, 2]);
|
||||
assert_eq!(waits, 3, "one wait per failed accept");
|
||||
}
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Instant;
|
||||
|
||||
fn small() -> Limits {
|
||||
Limits {
|
||||
max_clients: 2,
|
||||
max_line: 64,
|
||||
queue: 1,
|
||||
idle_timeout: Duration::from_millis(300),
|
||||
write_timeout: Duration::from_millis(300),
|
||||
reply_timeout: Duration::from_millis(300),
|
||||
}
|
||||
}
|
||||
|
||||
fn server(limits: Limits) -> (SocketAddr, Receiver<ControlRequest>) {
|
||||
let l = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = l.local_addr().unwrap();
|
||||
(addr, serve_listener(l, limits, egui::Context::default()))
|
||||
}
|
||||
|
||||
fn client(addr: SocketAddr) -> (BufReader<TcpStream>, TcpStream) {
|
||||
let s = TcpStream::connect(addr).unwrap();
|
||||
s.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
(BufReader::new(s.try_clone().unwrap()), s)
|
||||
}
|
||||
|
||||
fn reply(r: &mut BufReader<TcpStream>) -> Option<Value> {
|
||||
let mut s = String::new();
|
||||
match r.read_line(&mut s) {
|
||||
Ok(0) | Err(_) => None,
|
||||
Ok(_) => serde_json::from_str(&s).ok(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Answer every request with its method name until the server goes away.
|
||||
fn ui(rx: Receiver<ControlRequest>) {
|
||||
std::thread::spawn(move || {
|
||||
for req in rx {
|
||||
let _ = req.reply.send(json!({"ok": true, "result": req.method}));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_round_trip() {
|
||||
let (addr, rx) = server(small());
|
||||
ui(rx);
|
||||
let (mut r, mut w) = client(addr);
|
||||
writeln!(w, r#"{{"id":7,"method":"ui.inspect"}}"#).unwrap();
|
||||
let v = reply(&mut r).unwrap();
|
||||
assert_eq!(v["id"], 7);
|
||||
assert_eq!(v["result"], "ui.inspect");
|
||||
}
|
||||
|
||||
/// Every reply line until the server closes the connection.
|
||||
fn replies_until_closed(r: &mut BufReader<TcpStream>) -> Vec<Value> {
|
||||
std::iter::from_fn(|| reply(r)).collect()
|
||||
}
|
||||
|
||||
/// A browser can POST to the port: its request line closes the connection before the JSON
|
||||
/// line in its body is read, so nothing is dispatched.
|
||||
#[test]
|
||||
fn an_http_request_closes_the_connection_before_its_body_is_dispatched() {
|
||||
let (addr, rx) = server(Limits { max_line: 512, ..small() });
|
||||
let (mut r, mut w) = client(addr);
|
||||
w.write_all(
|
||||
b"POST / HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: text/plain\r\n\r\n\
|
||||
{\"id\":1,\"method\":\"engine.execute\",\"params\":{\"command\":\"slide.new\"}}\n",
|
||||
)
|
||||
.unwrap();
|
||||
let replies = replies_until_closed(&mut r);
|
||||
assert_eq!(replies.len(), 1, "{replies:?}");
|
||||
assert!(replies[0]["error"].as_str().unwrap().contains("closing the connection"), "{replies:?}");
|
||||
assert!(rx.try_recv().is_err(), "the body was dispatched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_that_is_not_a_request_closes_the_connection() {
|
||||
let (addr, rx) = server(small());
|
||||
ui(rx);
|
||||
for junk in [&b"hello\n"[..], b"[1,2]\n", b"{\"id\":1}\n", b"{\"method\":5}\n", b"\xff\xfe\n", b"GET /x HTTP/1.1\n"] {
|
||||
let (mut r, mut w) = client(addr);
|
||||
w.write_all(junk).unwrap();
|
||||
w.write_all(b"{\"id\":2,\"method\":\"m\"}\n").unwrap();
|
||||
let replies = replies_until_closed(&mut r);
|
||||
assert_eq!(replies.len(), 1, "{replies:?}");
|
||||
assert_eq!(replies[0]["ok"], false);
|
||||
}
|
||||
assert!(is_http_request_line("OPTIONS * HTTP/1.1"));
|
||||
assert!(is_http_request_line("PRI * HTTP/2.0"));
|
||||
assert!(!is_http_request_line("{\"method\":\"GET /\"}"));
|
||||
}
|
||||
|
||||
/// A peer that pipelines -- two lines in one write, as a script does -- still reads why its
|
||||
/// line was rejected. Closing with its second line unread resets the connection and discards
|
||||
/// the reply already on its way, so the peer saw nothing at all; repeated here because it
|
||||
/// depended on the timing of the two.
|
||||
#[test]
|
||||
fn a_pipelined_peer_still_reads_why_its_line_was_rejected() {
|
||||
let (addr, rx) = server(small());
|
||||
for _ in 0..12 {
|
||||
let (mut r, mut w) = client(addr);
|
||||
w.write_all(b"hello\n").unwrap();
|
||||
// A second line, written after the first as a script writes it: it races the close.
|
||||
let _ = w.write_all(b"{\"id\":2,\"method\":\"m\"}\n");
|
||||
let replies = replies_until_closed(&mut r);
|
||||
assert_eq!(replies.len(), 1, "{replies:?}");
|
||||
assert_eq!(replies[0]["ok"], false);
|
||||
assert!(replies[0]["error"].as_str().unwrap_or_default().contains("closing the connection"), "{replies:?}");
|
||||
}
|
||||
// The line after the rejected one is never dispatched, pipelined or not.
|
||||
assert!(rx.try_recv().is_err(), "a line after the rejection was dispatched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_line_closes_connection() {
|
||||
let (addr, rx) = server(small());
|
||||
ui(rx);
|
||||
let (mut r, mut w) = client(addr);
|
||||
w.write_all(&[b'x'; 200]).unwrap();
|
||||
let v = reply(&mut r).unwrap();
|
||||
assert!(v["error"].as_str().unwrap().contains("longer than 64"));
|
||||
assert!(reply(&mut r).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_connection_is_closed() {
|
||||
let (addr, rx) = server(small());
|
||||
ui(rx);
|
||||
let (mut r, mut w) = client(addr);
|
||||
w.write_all(b"{\"id\":1,").unwrap(); // unterminated, then silence
|
||||
let t = Instant::now();
|
||||
assert!(reply(&mut r).is_none());
|
||||
assert!(t.elapsed() < Duration::from_secs(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_limit_refuses_then_frees_slot() {
|
||||
let (addr, rx) = server(small());
|
||||
ui(rx);
|
||||
let a = client(addr);
|
||||
let (mut br, mut bw) = client(addr);
|
||||
// Make sure both are being served before the third arrives.
|
||||
writeln!(bw, r#"{{"id":1,"method":"m"}}"#).unwrap();
|
||||
assert!(reply(&mut br).is_some());
|
||||
let (mut cr, _cw) = client(addr);
|
||||
let v = reply(&mut cr).unwrap();
|
||||
assert!(v["error"].as_str().unwrap().starts_with("busy"));
|
||||
drop(a);
|
||||
// The freed slot is reusable once the server notices the disconnect.
|
||||
let deadline = Instant::now() + Duration::from_secs(4);
|
||||
loop {
|
||||
let (mut dr, mut dw) = client(addr);
|
||||
writeln!(dw, r#"{{"id":2,"method":"m"}}"#).unwrap();
|
||||
if reply(&mut dr).is_some_and(|v| v["ok"] == true) {
|
||||
break;
|
||||
}
|
||||
assert!(Instant::now() < deadline, "slot never freed");
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocked_ui_times_out_and_full_queue_is_busy() {
|
||||
let (addr, rx) = server(small());
|
||||
// No UI: the first request fills the one-slot queue and times out; the second finds it full.
|
||||
let (mut ar, mut aw) = client(addr);
|
||||
let (mut br, mut bw) = client(addr);
|
||||
writeln!(aw, r#"{{"id":1,"method":"m"}}"#).unwrap();
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
writeln!(bw, r#"{{"id":2,"method":"m"}}"#).unwrap();
|
||||
let b = reply(&mut br).unwrap();
|
||||
assert!(b["error"].as_str().unwrap().starts_with("busy"));
|
||||
assert_eq!(b["id"], 2);
|
||||
let a = reply(&mut ar).unwrap();
|
||||
assert_eq!(a["error"], "timeout");
|
||||
drop(rx);
|
||||
}
|
||||
|
||||
/// A request that timed out while it was still queued must not run when the window resumes (#719).
|
||||
#[test]
|
||||
fn timed_out_request_does_not_run_when_the_window_resumes() {
|
||||
let (addr, rx) = server(Limits { max_line: 512, ..small() });
|
||||
let (mut r, mut w) = client(addr);
|
||||
writeln!(w, r#"{{"id":1,"method":"engine.execute","params":{{"command":"slide.new"}}}}"#).unwrap();
|
||||
let t = reply(&mut r).unwrap();
|
||||
assert_eq!(t["error"], "timeout");
|
||||
// The window resumes and drains the queue, exactly as `SlideApp::drain_control` does.
|
||||
let mut app = deckcraft_ui_egui::SlideApp::new(deckcraft_engine::Session::with_new(), deckcraft_ui_egui::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
let before = app.session.active().unwrap().doc.slides.len();
|
||||
let req = rx.try_recv().expect("the timed-out request is still queued");
|
||||
if req.take() {
|
||||
let _ = deckcraft_ui_egui::control::handle(&mut app, &ctx, &req);
|
||||
}
|
||||
assert_eq!(app.session.active().unwrap().doc.slides.len(), before, "a request that timed out must not run when the window resumes");
|
||||
drop(rx);
|
||||
}
|
||||
|
||||
/// A request the app had already started when the reply timed out reports an unknown outcome,
|
||||
/// not a definite failure (#719).
|
||||
#[test]
|
||||
fn a_request_already_started_when_the_reply_times_out_reports_an_unknown_outcome() {
|
||||
let (addr, rx) = server(small());
|
||||
let ui = std::thread::spawn(move || {
|
||||
let req = rx.recv().expect("the request is queued");
|
||||
assert!(req.take(), "the app claims the request");
|
||||
// Still running it when the sender's wait expires.
|
||||
std::thread::sleep(Duration::from_millis(450));
|
||||
let _ = req.reply.send(json!({"ok": true, "result": "ran"}));
|
||||
});
|
||||
let (mut r, mut w) = client(addr);
|
||||
writeln!(w, r#"{{"id":1,"method":"m"}}"#).unwrap();
|
||||
let v = reply(&mut r).unwrap();
|
||||
assert!(v["error"].as_str().unwrap().contains("outcome is unknown"), "{v}");
|
||||
ui.join().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
//! Which windowing system to start on, on Linux and the BSDs.
|
||||
//!
|
||||
//! DeckCraft prefers X11 when `DISPLAY` is set (see `main`). A stale `DISPLAY` (left over from
|
||||
//! an ended `ssh -X` session or a desktop with Xwayland turned off) used to make start-up fail
|
||||
//! with winit's "failed to open display" even though a Wayland compositor was running. When a
|
||||
//! local `DISPLAY` names an X server whose socket isn't there and Wayland is available, start on
|
||||
//! Wayland instead. With no display at all, say so plainly.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// The windowing system to ask winit for.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum Choice {
|
||||
X11,
|
||||
/// Let winit decide (Wayland when `WAYLAND_DISPLAY` is set).
|
||||
Default,
|
||||
/// Neither `DISPLAY` nor `WAYLAND_DISPLAY`: there is nothing to open a window on.
|
||||
None,
|
||||
}
|
||||
|
||||
/// The X server number of a local `DISPLAY` (`:1`, `:1.0`, `unix:1`); `None` for a remote one
|
||||
/// (`host:10.0`, as `ssh -X` sets) or one we can't read, which are left to X11 itself.
|
||||
fn local_display_number(display: &str) -> Option<u32> {
|
||||
let rest = display.strip_prefix(':').or_else(|| display.strip_prefix("unix:"))?;
|
||||
rest.split('.').next()?.parse().ok()
|
||||
}
|
||||
|
||||
/// Choose from the environment. `socket_exists` checks an X socket path (the filesystem in
|
||||
/// [`choose_from_env`], a stub in tests).
|
||||
pub fn choose(display: Option<&str>, wayland: Option<&str>, force_wayland: bool, socket_exists: impl Fn(&Path) -> bool) -> Choice {
|
||||
let display = display.filter(|d| !d.is_empty());
|
||||
let wayland = wayland.is_some_and(|w| !w.is_empty());
|
||||
match display {
|
||||
None if wayland => Choice::Default,
|
||||
None => Choice::None,
|
||||
Some(_) if force_wayland && wayland => Choice::Default,
|
||||
Some(d) => match local_display_number(d) {
|
||||
Some(n) if wayland && !socket_exists(&Path::new("/tmp/.X11-unix").join(format!("X{n}"))) => {
|
||||
log::warn!("DISPLAY={d} has no X server; starting on Wayland");
|
||||
Choice::Default
|
||||
}
|
||||
_ => Choice::X11,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// [`choose`] for this process.
|
||||
pub fn choose_from_env() -> Choice {
|
||||
let display = std::env::var("DISPLAY").ok();
|
||||
let wayland = std::env::var("WAYLAND_DISPLAY").ok();
|
||||
choose(display.as_deref(), wayland.as_deref(), std::env::var_os("DECKCRAFT_WAYLAND").is_some(), |p| p.exists())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_stale_local_display_falls_back_to_wayland() {
|
||||
let none = |_: &Path| false;
|
||||
let all = |_: &Path| true;
|
||||
assert_eq!(choose(Some(":1"), Some("wayland-0"), false, none), Choice::Default);
|
||||
assert_eq!(choose(Some(":1.0"), Some("wayland-0"), false, none), Choice::Default);
|
||||
assert_eq!(choose(Some("unix:0"), Some("wayland-0"), false, none), Choice::Default);
|
||||
// A live X server, no Wayland, or a remote DISPLAY: X11 as before.
|
||||
assert_eq!(choose(Some(":1"), Some("wayland-0"), false, all), Choice::X11);
|
||||
assert_eq!(choose(Some(":1"), None, false, none), Choice::X11);
|
||||
assert_eq!(choose(Some("host:10.0"), Some("wayland-0"), false, none), Choice::X11);
|
||||
// The socket checked is the display's own.
|
||||
assert_eq!(choose(Some(":3"), Some("w"), false, |p: &Path| p.ends_with("X3")), Choice::X11);
|
||||
assert_eq!(choose(Some(":3"), Some("w"), false, |p: &Path| p.ends_with("X0")), Choice::Default);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wayland_only_forced_and_no_display_at_all() {
|
||||
let all = |_: &Path| true;
|
||||
assert_eq!(choose(None, Some("wayland-0"), false, all), Choice::Default);
|
||||
assert_eq!(choose(Some(""), Some("wayland-0"), false, all), Choice::Default);
|
||||
assert_eq!(choose(Some(":0"), Some("wayland-0"), true, all), Choice::Default);
|
||||
// Forcing Wayland without a compositor keeps X11 rather than failing.
|
||||
assert_eq!(choose(Some(":0"), None, true, all), Choice::X11);
|
||||
assert_eq!(choose(None, None, false, all), Choice::None);
|
||||
assert_eq!(choose(Some(""), Some(""), false, all), Choice::None);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
//! Windows graphics instance configuration, applied before wgpu can initialize any driver.
|
||||
|
||||
use eframe::{NativeOptions, egui_wgpu::WgpuSetup, wgpu::Backends};
|
||||
use eframe::{
|
||||
NativeOptions,
|
||||
egui_wgpu::WgpuSetup,
|
||||
wgpu::{Backends, Dx12Compiler},
|
||||
};
|
||||
|
||||
/// Restrict the wgpu instance to DirectX 12 unless `backend_override` (normally
|
||||
/// `Backends::from_env()`, i.e. `WGPU_BACKEND`) names other backends.
|
||||
pub fn configure(options: &mut NativeOptions, backend_override: Option<Backends>) {
|
||||
/// Default to DirectX 12 with FXC before creating the wgpu instance. Explicit
|
||||
/// `WGPU_BACKEND` and `WGPU_DX12_COMPILER` choices, read by the caller, take precedence.
|
||||
pub fn configure(options: &mut NativeOptions, backend_override: Option<Backends>, compiler_override: Option<Dx12Compiler>) {
|
||||
if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
|
||||
// eframe's default includes GL. Creating that backend can crash inside AMD's
|
||||
// atio6axx.dll before adapter selection or any Rust error handling runs, so the
|
||||
@@ -12,6 +16,16 @@ pub fn configure(options: &mut NativeOptions, backend_override: Option<Backends>
|
||||
// out of the instance avoids it. This matches the verified WGPU_BACKEND=dx12
|
||||
// workaround without mutating process environment variables.
|
||||
create.instance_descriptor.backends = backend_override.unwrap_or(Backends::DX12);
|
||||
// Auto loads whichever dxcompiler.dll the DLL search path finds; DeckCraft ships
|
||||
// none, and without a matching dxil.dll the unsigned-DXIL warning fails wgpu's
|
||||
// indirect-validation pipeline and loses the device before the first frame
|
||||
// (issue #153). FXC uses the compiler that is part of Windows.
|
||||
create.instance_descriptor.backend_options.dx12.shader_compiler = compiler_override.unwrap_or(Dx12Compiler::Fxc);
|
||||
log::info!(
|
||||
"Windows graphics: backends={:?}, DX12 compiler={:?}",
|
||||
create.instance_descriptor.backends,
|
||||
create.instance_descriptor.backend_options.dx12.shader_compiler
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +35,7 @@ mod tests {
|
||||
|
||||
fn configured_backends(backend_override: Option<Backends>) -> Backends {
|
||||
let mut options = NativeOptions::default();
|
||||
configure(&mut options, backend_override);
|
||||
configure(&mut options, backend_override, None);
|
||||
let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
|
||||
panic!("default native options must create a wgpu instance");
|
||||
};
|
||||
@@ -38,6 +52,38 @@ mod tests {
|
||||
assert_eq!(configured_backends(None), Backends::DX12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_default_uses_fxc_instead_of_searching_for_dxc() {
|
||||
let mut options = NativeOptions::default();
|
||||
if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
|
||||
// Auto can find an unrelated DXC without its matching DXIL runtime (issue #153).
|
||||
create.instance_descriptor.backend_options.dx12.shader_compiler = Dx12Compiler::Auto;
|
||||
}
|
||||
configure(&mut options, None, None);
|
||||
let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
|
||||
panic!("default native options must create a wgpu instance");
|
||||
};
|
||||
assert!(matches!(create.instance_descriptor.backend_options.dx12.shader_compiler, Dx12Compiler::Fxc));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_compiler_override_is_preserved() {
|
||||
for compiler in [Dx12Compiler::Fxc, Dx12Compiler::Auto, Dx12Compiler::DynamicDxc { dxc_path: "custom/dxcompiler.dll".into() }] {
|
||||
let mut options = NativeOptions::default();
|
||||
configure(&mut options, None, Some(compiler.clone()));
|
||||
let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
|
||||
panic!("default native options must create a wgpu instance");
|
||||
};
|
||||
match (compiler, create.instance_descriptor.backend_options.dx12.shader_compiler) {
|
||||
(Dx12Compiler::Fxc, Dx12Compiler::Fxc) | (Dx12Compiler::Auto, Dx12Compiler::Auto) => {}
|
||||
(Dx12Compiler::DynamicDxc { dxc_path: expected }, Dx12Compiler::DynamicDxc { dxc_path: actual }) => {
|
||||
assert_eq!(actual, expected);
|
||||
}
|
||||
(expected, actual) => panic!("compiler override changed from {expected:?} to {actual:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_backend_override_is_preserved() {
|
||||
for backends in [Backends::VULKAN, Backends::GL, Backends::DX12, Backends::VULKAN | Backends::DX12, Backends::empty()] {
|
||||
|
||||
@@ -95,29 +95,35 @@ fn matches(name: &str, target: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch; a clock
|
||||
/// set absurdly far ahead keeps its real year (years far outside chrono's own range are preserved)
|
||||
/// instead of panicking.
|
||||
pub fn timestamp(t: SystemTime) -> String {
|
||||
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
|
||||
let secs = since.as_secs();
|
||||
let (days, rem) = (secs / 86_400, secs % 86_400);
|
||||
let (y, m, d) = civil_from_days(days);
|
||||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
|
||||
let (y, dt) = utc_parts(since.as_secs(), since.subsec_nanos());
|
||||
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
|
||||
format!("{y:04}-{}", dt.format("%m-%dT%H:%M:%S%.3fZ"))
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
|
||||
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch; saturating so an absurd
|
||||
/// clock can't overflow).
|
||||
fn civil_from_days(days: u64) -> (u64, u64, u64) {
|
||||
let z = days.saturating_add(719_468);
|
||||
let era = z / 146_097;
|
||||
let doe = z % 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
|
||||
(y, m, d)
|
||||
/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into
|
||||
/// chrono's range by whole 400-year cycles). The Gregorian calendar repeats every 400 years
|
||||
/// (146_097 days), so month, day and time of day come from chrono and only the year is carried,
|
||||
/// which keeps every input exact instead of collapsing out-of-range years to the epoch. `secs` is
|
||||
/// `u64` (the clock is clamped at the epoch before 1970), so `days` always fits an `i64`.
|
||||
fn utc_parts(secs: u64, nanos: u32) -> (i64, chrono::NaiveDateTime) {
|
||||
use chrono::Datelike;
|
||||
let (days, sod) = ((secs / 86_400) as i64, (secs % 86_400) as i64);
|
||||
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
|
||||
// `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
|
||||
let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
|
||||
let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999)));
|
||||
match (date, time) {
|
||||
(Some(d), Some(t)) => {
|
||||
let y = i128::from(d.year()) + i128::from(cycles) * 400;
|
||||
(i64::try_from(y).unwrap_or(i64::MAX), d.and_time(t))
|
||||
}
|
||||
_ => (1970, chrono::NaiveDateTime::default()), // unreachable
|
||||
}
|
||||
}
|
||||
|
||||
fn numbered(dir: &Path, n: usize) -> PathBuf {
|
||||
@@ -389,6 +395,17 @@ mod tests {
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
|
||||
// Before the epoch (a clock set wrong) clamps instead of panicking.
|
||||
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
|
||||
// A clock set absurdly far ahead (past chrono's own range) keeps its real year rather than
|
||||
// collapsing to the epoch. Windows' SystemTime can't hold such an instant, so it is built
|
||||
// with a checked add and only asserted where it exists.
|
||||
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
|
||||
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
|
||||
}
|
||||
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
|
||||
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
|
||||
}
|
||||
// Years past 9999 keep the plain zero-padded year (no `+`).
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+244
-53
@@ -1,6 +1,8 @@
|
||||
//! DeckCraft desktop app.
|
||||
//!
|
||||
//! Usage: `deckcraft [--control <port>] [--sample] [--show] [files…]`
|
||||
//! A PowerPoint Show (`.ppsx`) given last starts its slide show, as a double-click does in PowerPoint.
|
||||
//! `deckcraft --help` prints the flags and exits. `deckcraft --version` prints the version and exits.
|
||||
//!
|
||||
//! `--control <port>` (or `DECKCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
|
||||
//! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
|
||||
@@ -12,19 +14,32 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod apple_events;
|
||||
mod audio;
|
||||
mod control_server;
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
mod display;
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
mod graphics;
|
||||
mod logging;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod pasteboard;
|
||||
|
||||
use deckcraft_engine::Session;
|
||||
use deckcraft_ui_egui::{Services, SlideApp};
|
||||
|
||||
struct App(SlideApp);
|
||||
struct App(
|
||||
SlideApp,
|
||||
/// Files opened from Finder and quit requests (see `apple_events`).
|
||||
#[cfg(target_os = "macos")]
|
||||
fmv_macos_events::Inbox,
|
||||
);
|
||||
|
||||
impl eframe::App for App {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
#[cfg(target_os = "macos")]
|
||||
apple_events::poll(&self.1, &mut self.0, ctx);
|
||||
self.0.logic(ctx);
|
||||
}
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
@@ -35,6 +50,16 @@ impl eframe::App for App {
|
||||
}
|
||||
fn on_exit(&mut self) {
|
||||
save_prefs(&self.0);
|
||||
// macOS (#279): end the process here, before eframe tears the window down. AccessKit's
|
||||
// macOS adapter subclasses the window's content view and restores its class on teardown;
|
||||
// AppKit's Touch Bar support observes that view (KVO), so in the next display cycle
|
||||
// `-[_NSTouchBarFinderObservation invalidate]` throws "not registered" and the quit ends
|
||||
// in a crash report. eframe calls `on_exit` on every way of quitting (⌘Q, the close
|
||||
// button, `app.quit`), once the unsaved-changes prompt has let the window close, and
|
||||
// nothing after it is needed: the preferences are saved and the log file is unbuffered.
|
||||
// `on_exit` only runs once the app exists, never on a failed start. Same as GridCraft #117.
|
||||
#[cfg(target_os = "macos")]
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,24 +77,16 @@ fn config_dir() -> Option<std::path::PathBuf> {
|
||||
}
|
||||
}
|
||||
|
||||
fn prefs_path() -> Option<std::path::PathBuf> {
|
||||
config_dir().map(|b| b.join("ui.json"))
|
||||
}
|
||||
|
||||
fn load_prefs(app: &mut SlideApp) {
|
||||
if std::env::var_os("DECKCRAFT_NO_PREFS").is_some() {
|
||||
return;
|
||||
}
|
||||
if let Some(p) = prefs_path()
|
||||
&& let Ok(bytes) = std::fs::read(&p)
|
||||
&& let Ok(ui) = serde_json::from_slice::<deckcraft_ui_egui::UiState>(&bytes)
|
||||
{
|
||||
let Some(dir) = config_dir() else { return };
|
||||
let (ui, prefs) = deckcraft_ui_egui::settings::load(&dir);
|
||||
if let Some(ui) = ui {
|
||||
app.ui = ui;
|
||||
}
|
||||
if let Some(p) = prefs_path().map(|p| p.with_file_name("prefs.json"))
|
||||
&& let Ok(bytes) = std::fs::read(&p)
|
||||
&& let Ok(prefs) = serde_json::from_slice::<deckcraft_engine::Prefs>(&bytes)
|
||||
{
|
||||
if let Some(prefs) = prefs {
|
||||
app.session.prefs = prefs;
|
||||
}
|
||||
}
|
||||
@@ -78,14 +95,8 @@ fn save_prefs(app: &SlideApp) {
|
||||
if std::env::var_os("DECKCRAFT_NO_PREFS").is_some() {
|
||||
return;
|
||||
}
|
||||
if let Some(p) = prefs_path() {
|
||||
let _ = std::fs::create_dir_all(p.parent().unwrap_or(std::path::Path::new(".")));
|
||||
if let Ok(bytes) = serde_json::to_vec_pretty(&app.ui) {
|
||||
let _ = std::fs::write(&p, bytes);
|
||||
}
|
||||
if let Ok(bytes) = serde_json::to_vec_pretty(&app.session.prefs) {
|
||||
let _ = std::fs::write(p.with_file_name("prefs.json"), bytes);
|
||||
}
|
||||
if let Some(dir) = config_dir() {
|
||||
deckcraft_ui_egui::settings::save(&dir, &app.ui, &app.session.prefs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +111,7 @@ fn services() -> Services {
|
||||
&["wav", "mp3", "m4a", "m4b", "aac", "flac", "ogg", "oga", "opus", "aif", "aiff", "aifc", "caf", "wma", "weba", "mka"],
|
||||
),
|
||||
"video" => d.add_filter("Video", &["mp4", "m4v", "mov", "webm", "mkv", "wmv"]),
|
||||
"theme" => d.add_filter("Themes and presentations", &["thmx", "pptx", "potx", "ppsx"]).add_filter("Office Theme", &["thmx"]),
|
||||
_ => d
|
||||
.add_filter("Presentations", &["deckcraft", "pptx", "potx", "ppsx"])
|
||||
.add_filter("DeckCraft", &["deckcraft"])
|
||||
@@ -111,14 +123,9 @@ fn services() -> Services {
|
||||
pick_save: Some(Box::new(|name: &str| rfd::FileDialog::new().set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()))),
|
||||
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
|
||||
write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))),
|
||||
clipboard_image: Some(Box::new(|| {
|
||||
let mut cb = arboard::Clipboard::new().ok()?;
|
||||
let img = cb.get_image().ok()?;
|
||||
let rgba = image::RgbaImage::from_raw(img.width as u32, img.height as u32, img.bytes.into_owned())?;
|
||||
let mut out = Vec::new();
|
||||
rgba.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).ok()?;
|
||||
Some(out)
|
||||
})),
|
||||
clipboard_image: Some(Box::new(pasteboard::image_png)),
|
||||
clipboard_html: Some(Box::new(pasteboard::html)),
|
||||
clipboard_set_html: Some(Box::new(pasteboard::set_html)),
|
||||
// Media is mixed and clocked by the output device; `DECKCRAFT_NO_AUDIO` plays silently.
|
||||
audio_out: std::env::var_os("DECKCRAFT_NO_AUDIO")
|
||||
.is_none()
|
||||
@@ -129,6 +136,45 @@ fn services() -> Services {
|
||||
|
||||
const APP_ID: &str = "ai.storyteller.deckcraft";
|
||||
|
||||
const USAGE: &str = "\
|
||||
Usage: deckcraft [--control PORT] [--sample] [--show] [FILE...]
|
||||
|
||||
--control PORT JSON control server on 127.0.0.1:PORT
|
||||
--sample open the sample presentation
|
||||
--show start the slide show
|
||||
--help, -h print this help and exit
|
||||
--version, -V print the version and exit
|
||||
|
||||
A PowerPoint Show (.ppsx) given last starts its slide show.
|
||||
";
|
||||
|
||||
enum Startup {
|
||||
Help,
|
||||
Version,
|
||||
Run { control_port: Option<u16>, files: Vec<String>, sample: bool, show: bool },
|
||||
}
|
||||
|
||||
/// `--help` and `--version` leave before the window and the log file.
|
||||
fn startup(mut control_port: Option<u16>, args: impl IntoIterator<Item = String>) -> Startup {
|
||||
let mut files = Vec::new();
|
||||
let mut sample = false;
|
||||
let mut show = false;
|
||||
let mut args = args.into_iter();
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
|
||||
"--sample" => sample = true,
|
||||
"--show" => show = true,
|
||||
"--version" | "-V" => return Startup::Version,
|
||||
"--help" | "-h" => return Startup::Help,
|
||||
// The process serial number older macOS adds when Finder launches the app.
|
||||
a if a.starts_with("-psn_") => {}
|
||||
_ => files.push(a),
|
||||
}
|
||||
}
|
||||
Startup::Run { control_port, files, sample, show }
|
||||
}
|
||||
|
||||
fn app_icon() -> Option<egui::IconData> {
|
||||
let png: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.deckcraft.png");
|
||||
eframe::icon_data::from_png_bytes(png).map_err(|e| log::warn!("app icon: {e}")).ok()
|
||||
@@ -138,26 +184,21 @@ fn main() -> eframe::Result {
|
||||
// First, so every start-up record (and the engine's panic hook, installed with the first
|
||||
// Session) is captured; see `logging`.
|
||||
let logger = logging::install();
|
||||
let mut control_port: Option<u16> = std::env::var("DECKCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
||||
let mut files = Vec::new();
|
||||
let mut sample = false;
|
||||
let mut show = false;
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
|
||||
"--sample" => sample = true,
|
||||
"--show" => show = true,
|
||||
"--version" => {
|
||||
let (control_port, files, sample, show) =
|
||||
match startup(std::env::var("DECKCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()), std::env::args().skip(1)) {
|
||||
Startup::Help => {
|
||||
print!("{USAGE}");
|
||||
return Ok(());
|
||||
}
|
||||
Startup::Version => {
|
||||
println!("deckcraft {}", env!("CARGO_PKG_VERSION"));
|
||||
return Ok(());
|
||||
}
|
||||
_ => files.push(a),
|
||||
}
|
||||
}
|
||||
Startup::Run { control_port, files, sample, show } => (control_port, files, sample, show),
|
||||
};
|
||||
log::info!("DeckCraft {} ({} {})", env!("CARGO_PKG_VERSION"), std::env::consts::OS, std::env::consts::ARCH);
|
||||
// The log file lives under the settings directory; opened after the arguments, so `--version`
|
||||
// leaves no file behind. Records logged until now are written to it first.
|
||||
// The log file lives under the settings directory; opened after the arguments, so `--help` and
|
||||
// `--version` leave no file behind. Records logged until now are written to it first.
|
||||
if let Some(logger) = logger {
|
||||
match config_dir().map(|d| logger.attach_dir(&d.join(logging::LOG_DIR))) {
|
||||
Some(Ok(path)) => log::info!("log file {}", path.display()),
|
||||
@@ -184,15 +225,32 @@ fn main() -> eframe::Result {
|
||||
options.viewport = options.viewport.with_icon(icon);
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
if std::env::var_os("DISPLAY").is_some() && std::env::var_os("DECKCRAFT_WAYLAND").is_none() {
|
||||
let x11 = match display::choose_from_env() {
|
||||
display::Choice::X11 => true,
|
||||
display::Choice::Default => false,
|
||||
display::Choice::None => {
|
||||
// winit's own error says little to someone who started DeckCraft from a console.
|
||||
log::error!("no display: DISPLAY and WAYLAND_DISPLAY are not set");
|
||||
eprintln!("DeckCraft needs a graphical session: neither DISPLAY nor WAYLAND_DISPLAY is set.");
|
||||
false
|
||||
}
|
||||
};
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
if x11 {
|
||||
options.event_loop_builder = Some(Box::new(|b| {
|
||||
use winit::platform::x11::EventLoopBuilderExtX11;
|
||||
b.with_x11();
|
||||
}));
|
||||
}
|
||||
// Before eframe creates the wgpu instance: default Windows to DirectX 12 only (see graphics.rs).
|
||||
// Before eframe creates the wgpu instance: default Windows to DirectX 12 with FXC (see graphics.rs).
|
||||
#[cfg(target_os = "windows")]
|
||||
graphics::configure(&mut options, eframe::wgpu::Backends::from_env());
|
||||
graphics::configure(&mut options, eframe::wgpu::Backends::from_env(), eframe::wgpu::Dx12Compiler::from_env());
|
||||
// Registered before the event loop starts, so it catches the Finder event that launched us as
|
||||
// well as later ones. Lives until the event loop returns; the app creator only borrows it.
|
||||
#[cfg(target_os = "macos")]
|
||||
let apple_events = apple_events::AppleEvents::install();
|
||||
#[cfg(target_os = "macos")]
|
||||
let apple_events = &apple_events;
|
||||
eframe::run_native(
|
||||
"DeckCraft",
|
||||
options,
|
||||
@@ -216,15 +274,148 @@ fn main() -> eframe::Result {
|
||||
if sample && let Err(e) = deckcraft_engine::sample::open_sample(&mut app.session) {
|
||||
log::error!("sample: {e}");
|
||||
}
|
||||
for f in files {
|
||||
if let Err(e) = app.open_path(&f) {
|
||||
log::error!("{f}: {e}");
|
||||
}
|
||||
// A .ppsx opened last starts its slide show (#862).
|
||||
for (f, e) in app.open_from_system(&files) {
|
||||
log::error!("{f}: {e}");
|
||||
}
|
||||
if show {
|
||||
app.start_show(0, false);
|
||||
}
|
||||
Ok(Box::new(App(app)))
|
||||
Ok(Box::new(App(
|
||||
app,
|
||||
#[cfg(target_os = "macos")]
|
||||
apple_events.connect(&cc.egui_ctx),
|
||||
)))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// #279: on macOS the process ends inside `on_exit`, before eframe would tear the window down.
|
||||
/// The check runs in a child (this test binary again), since on macOS it ends the process.
|
||||
#[test]
|
||||
fn on_exit_ends_the_process_on_macos() {
|
||||
if std::env::var_os("DECKCRAFT_TEST_ON_EXIT").is_some() {
|
||||
let mut app = App(
|
||||
SlideApp::new(Session::new(), Services::default()),
|
||||
#[cfg(target_os = "macos")]
|
||||
fmv_macos_events::Inbox::default(),
|
||||
);
|
||||
println!("calling on_exit");
|
||||
eframe::App::on_exit(&mut app);
|
||||
println!("on_exit returned");
|
||||
return;
|
||||
}
|
||||
let out = std::process::Command::new(std::env::current_exe().unwrap())
|
||||
.args(["--exact", "tests::on_exit_ends_the_process_on_macos", "--nocapture"])
|
||||
.env("DECKCRAFT_TEST_ON_EXIT", "1")
|
||||
.env("DECKCRAFT_NO_PREFS", "1")
|
||||
.output()
|
||||
.unwrap();
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(out.status.success(), "{stdout}");
|
||||
assert!(stdout.contains("calling on_exit"), "the child didn't run the test: {stdout}");
|
||||
assert_eq!(stdout.contains("on_exit returned"), !cfg!(target_os = "macos"), "{stdout}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_exits_instead_of_opening_a_file() {
|
||||
assert!(matches!(startup(None, ["--help".to_string()]), Startup::Help));
|
||||
assert!(matches!(startup(None, ["-h".to_string()]), Startup::Help));
|
||||
assert!(matches!(startup(None, ["talk.pptx".to_string(), "--help".to_string()]), Startup::Help));
|
||||
assert!(matches!(startup(None, ["--version".to_string()]), Startup::Version));
|
||||
let launch = startup(Some(9), ["--sample".to_string(), "--control".to_string(), "4242".to_string(), "talk.pptx".to_string()]);
|
||||
assert!(matches!(launch, Startup::Run { .. }));
|
||||
if let Startup::Run { control_port, files, sample, show } = launch {
|
||||
assert_eq!(control_port, Some(4242));
|
||||
assert_eq!(files, ["talk.pptx".to_string()]);
|
||||
assert!(sample);
|
||||
assert!(!show);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finder_process_serial_number_is_not_a_file() {
|
||||
let launch = startup(None, ["-psn_0_1234567".to_string(), "talk.pptx".to_string()]);
|
||||
assert!(matches!(launch, Startup::Run { .. }));
|
||||
if let Startup::Run { files, .. } = launch {
|
||||
assert_eq!(files, ["talk.pptx".to_string()]);
|
||||
}
|
||||
}
|
||||
|
||||
/// The `<string>` values of every `<array>` that follows a `<key>{key}</key>` line.
|
||||
fn plist_strings(plist: &str, key: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut in_array = false;
|
||||
for line in plist.lines() {
|
||||
let line = line.trim();
|
||||
if line == format!("<key>{key}</key>") {
|
||||
in_array = true;
|
||||
continue;
|
||||
}
|
||||
if in_array {
|
||||
for part in line.split("<string>").skip(1) {
|
||||
if let Some((s, _)) = part.split_once("</string>") {
|
||||
out.push(s.to_string());
|
||||
}
|
||||
}
|
||||
if line.ends_with("</array>") {
|
||||
in_array = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Every desktop offers the same file types: macOS (`CFBundleTypeExtensions` and the native
|
||||
/// type's extension in `Info.plist.in`), Windows (`SupportedTypes` in `deckcraft.wxs`) and
|
||||
/// Linux (`MimeType` in the desktop file). A format added to one installer must reach the
|
||||
/// others, and each must be one Finder, Explorer or the file manager can hand to DeckCraft.
|
||||
#[test]
|
||||
fn every_desktop_offers_the_same_file_types() {
|
||||
let plist = include_str!("../../../packaging/macos/Info.plist.in");
|
||||
let wxs = include_str!("../../../packaging/windows/deckcraft.wxs");
|
||||
let desktop = include_str!("../../../packaging/linux/ai.storyteller.deckcraft.desktop");
|
||||
|
||||
let mut mac = plist_strings(plist, "CFBundleTypeExtensions");
|
||||
// The native type is declared by its UTI; its extension is in the exported type.
|
||||
mac.extend(plist_strings(plist, "public.filename-extension").into_iter().filter(|e| e == "deckcraft"));
|
||||
mac.sort();
|
||||
mac.dedup();
|
||||
assert!(mac.iter().any(|e| e == "pptx") && mac.iter().any(|e| e == "deckcraft"), "Info.plist.in: {mac:?}");
|
||||
// Every document type names a content type too, so "Change All" in Finder's Get Info works.
|
||||
let names = plist.matches("<key>CFBundleTypeName</key>").count();
|
||||
let content_types = plist.matches("<key>LSItemContentTypes</key>").count();
|
||||
assert!(names > 0 && names == content_types, "{names} document types, {content_types} with LSItemContentTypes");
|
||||
|
||||
let mut win: Vec<String> = wxs
|
||||
.lines()
|
||||
.filter(|l| l.contains("SupportedTypes\" Name=\"."))
|
||||
.filter_map(|l| l.split("Name=\".").nth(1)?.split('"').next().map(str::to_string))
|
||||
.collect();
|
||||
win.sort();
|
||||
|
||||
let mime_ext = |m: &str| match m {
|
||||
"application/x-deckcraft" => Some("deckcraft"),
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation" => Some("pptx"),
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.template" => Some("potx"),
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.slideshow" => Some("ppsx"),
|
||||
_ => None,
|
||||
};
|
||||
let mut linux: Vec<String> = desktop
|
||||
.lines()
|
||||
.find_map(|l| l.strip_prefix("MimeType="))
|
||||
.unwrap_or_default()
|
||||
.split(';')
|
||||
.filter(|m| !m.is_empty())
|
||||
.map(|m| mime_ext(m).unwrap_or(m).to_string()) // an unknown MIME type shows up in the diff
|
||||
.collect();
|
||||
linux.sort();
|
||||
|
||||
assert_eq!(mac, win, "macOS and Windows offer different file types");
|
||||
assert_eq!(mac, linux, "macOS and Linux offer different file types");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
//! System pasteboard: HTML rich text, and a picture (TIFF on macOS, via arboard).
|
||||
//!
|
||||
//! Pages keeps bold, italic and underline when the pasteboard carries HTML plus the same plain
|
||||
//! words. A shape with no text is copied as an image by the UI; this module only reads a picture
|
||||
//! back (Keynote's TIFF) as PNG bytes.
|
||||
|
||||
/// HTML currently on the pasteboard, when there is some.
|
||||
pub fn html() -> Option<String> {
|
||||
let mut cb = arboard::Clipboard::new().ok()?;
|
||||
let html = cb.get().html().ok()?;
|
||||
let html = html.trim();
|
||||
if html.is_empty() { None } else { Some(html.to_string()) }
|
||||
}
|
||||
|
||||
/// Write `html` and the plain alternative `text`. `false` when the pasteboard refuses it.
|
||||
pub fn set_html(html: &str, text: &str) -> bool {
|
||||
let Ok(mut cb) = arboard::Clipboard::new() else { return false };
|
||||
cb.set_html(html, Some(text)).is_ok()
|
||||
}
|
||||
|
||||
/// The pasteboard picture as PNG bytes (TIFF on macOS is decoded by arboard).
|
||||
pub fn image_png() -> Option<Vec<u8>> {
|
||||
let mut cb = arboard::Clipboard::new().ok()?;
|
||||
let img = cb.get_image().ok()?;
|
||||
let width = u32::try_from(img.width).ok()?;
|
||||
let height = u32::try_from(img.height).ok()?;
|
||||
let rgba = image::RgbaImage::from_raw(width, height, img.bytes.into_owned())?;
|
||||
let mut out = Vec::new();
|
||||
rgba.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).ok()?;
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::borrow::Cow;
|
||||
|
||||
/// Puts HTML and a 1×1 picture on the macOS pasteboard and reads them back.
|
||||
/// Restores whatever plain text was there. Other platforms return immediately.
|
||||
#[test]
|
||||
fn html_and_a_picture_round_trip_on_macos() {
|
||||
if !cfg!(target_os = "macos") {
|
||||
return;
|
||||
}
|
||||
let previous = arboard::Clipboard::new().ok().and_then(|mut cb| cb.get_text().ok());
|
||||
let _restore = Restore(previous);
|
||||
assert!(super::set_html("<b>DeckCraft</b>", "DeckCraft"), "set html");
|
||||
let html = super::html().unwrap_or_default();
|
||||
assert!(html.contains("<b>") || html.contains("<B>"), "{html}");
|
||||
let text = arboard::Clipboard::new().ok().and_then(|mut cb| cb.get_text().ok()).unwrap_or_default();
|
||||
assert_eq!(text, "DeckCraft");
|
||||
let mut cb = arboard::Clipboard::new().expect("pasteboard");
|
||||
let img = arboard::ImageData { width: 1, height: 1, bytes: Cow::Owned(vec![255, 0, 0, 255]) };
|
||||
cb.set_image(img).expect("set image");
|
||||
drop(cb);
|
||||
let png = super::image_png().expect("png");
|
||||
let decoded = image::load_from_memory(&png).expect("decode").to_rgba8();
|
||||
let p = decoded.get_pixel(0, 0);
|
||||
assert!(p[0] > 200 && p[1] < 40 && p[3] > 200, "{p:?}");
|
||||
}
|
||||
|
||||
/// Puts the previous plain text back, including when the test fails halfway.
|
||||
struct Restore(Option<String>);
|
||||
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
let Ok(mut cb) = arboard::Clipboard::new() else { return };
|
||||
match self.0.take() {
|
||||
Some(t) => {
|
||||
let _ = cb.set_text(t);
|
||||
}
|
||||
None => {
|
||||
let _ = cb.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,3 +37,28 @@ sync_github_name = false
|
||||
|
||||
[people.Johannesgauss]
|
||||
sync_github_name = true
|
||||
|
||||
[people.Silvertree2010]
|
||||
display_name = "Netter"
|
||||
sync_github_name = false
|
||||
|
||||
[people.dotCooCoo]
|
||||
real_name = "Robert Lee"
|
||||
sync_github_name = true
|
||||
|
||||
[people.Momentum96]
|
||||
sync_github_name = true
|
||||
|
||||
[people.NikitkaRa]
|
||||
real_name = "Nikita Rakitin"
|
||||
|
||||
[people.RCShoemaker]
|
||||
real_name = "Ryan C. Shoemaker"
|
||||
|
||||
[people.marcoarnulfo]
|
||||
real_name = "Marco Arnulfo"
|
||||
display_name = "Marco Arnulfo"
|
||||
sync_github_name = false
|
||||
|
||||
[people.GhostMitra]
|
||||
sync_github_name = true
|
||||
|
||||
@@ -44,6 +44,15 @@ pub(crate) fn exit_base(effect: &str) -> &str {
|
||||
"swivelOut" => "swivel",
|
||||
"bounceOut" => "bounce",
|
||||
"dissolveOut" => "dissolve",
|
||||
"blindsOut" => "blinds",
|
||||
"boxOut" => "box",
|
||||
"checkerboardOut" => "checkerboard",
|
||||
"diamondOut" => "diamond",
|
||||
"peekOut" => "peek",
|
||||
"stripsOut" => "strips",
|
||||
"sinkDown" => "rise",
|
||||
"spinnerOut" => "spinner",
|
||||
"contract" => "expand",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
@@ -69,6 +78,7 @@ pub(crate) fn emphasis_persists(effect: &str) -> bool {
|
||||
|
||||
fn uses_default_smoothing(effect: &str) -> bool {
|
||||
matches!(effect, "fly" | "float" | "rise" | "peek" | "lines" | "arcs" | "turns" | "shapes" | "loops" | "customPath")
|
||||
|| deckcraft_model::anim::preset_path(effect).is_some()
|
||||
}
|
||||
|
||||
impl EffectSpec {
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
//! Clicks in a slide show: which shape a click lands on, what it starts (a triggered animation, a
|
||||
//! link, the next step) and what the show remembers about each slide it has visited.
|
||||
//!
|
||||
//! The rules follow PowerPoint, as observed on its slide show:
|
||||
//! - the topmost visible shape under the pointer decides; a shape without an action on top of one
|
||||
//! with an action blocks it (the click then counts as a click on the background);
|
||||
//! - a shape with a triggered animation plays the next step of it per click, and starts over once all
|
||||
//! its steps have played; clicking it while a step plays completes the step at once;
|
||||
//! - a shape with a link and a triggered animation follows the link at once. The animation's clock
|
||||
//! runs only while its slide is shown, so it goes on where it stopped when the show returns;
|
||||
//! - with "advance on click" off a click never leaves the slide, but it still plays animation steps;
|
||||
//! - a slide the show returns to (link or arrow keys) looks as it was left.
|
||||
|
||||
use deckcraft_model::text::Action;
|
||||
use deckcraft_model::{Presentation, ShapeId};
|
||||
|
||||
use crate::{AnimState, LinkJump, ShowAction, ShowState, StepStamp, Timeline, TriggerPlay};
|
||||
|
||||
/// What was played on one slide.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub(crate) struct SlideRun {
|
||||
/// The show has been on this slide.
|
||||
pub visited: bool,
|
||||
/// Main-sequence steps played when the show left the slide.
|
||||
pub step: usize,
|
||||
/// Triggered sequences started by clicks.
|
||||
pub triggers: Vec<TriggerRun>,
|
||||
/// Steps of triggered sequences started so far (the stamps of [`StepStamp`]).
|
||||
pub clock: u64,
|
||||
}
|
||||
|
||||
/// Progress of one triggered sequence.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct TriggerRun {
|
||||
pub shape: ShapeId,
|
||||
/// Clicks whose step has played to its end.
|
||||
pub done: usize,
|
||||
/// Seconds the current step has played; `None` when no step is playing.
|
||||
pub elapsed: Option<f64>,
|
||||
/// When each step of this run was started, by step.
|
||||
pub stamps: Vec<u64>,
|
||||
}
|
||||
|
||||
/// One shape of the slide for hit testing, in drawing order (back to front; a group before its
|
||||
/// children), with whether the pointer is inside its box.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct HitShape {
|
||||
pub id: ShapeId,
|
||||
/// Index of the enclosing group in the same list.
|
||||
pub parent: Option<usize>,
|
||||
pub is_group: bool,
|
||||
/// The shape's own action.
|
||||
pub link: Option<Action>,
|
||||
pub under_pointer: bool,
|
||||
}
|
||||
|
||||
/// What the pointer is over: a shape that does something when clicked.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Target {
|
||||
pub shape: ShapeId,
|
||||
pub link: Option<Action>,
|
||||
}
|
||||
|
||||
/// What a click did.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Click {
|
||||
/// Nothing happened.
|
||||
None,
|
||||
/// A triggered animation started or was completed: redraw.
|
||||
Animated,
|
||||
/// Navigation inside the show (already applied to the state).
|
||||
Show(ShowAction),
|
||||
/// Open this URL outside the show.
|
||||
Open(String),
|
||||
/// End the show.
|
||||
Exit,
|
||||
}
|
||||
|
||||
impl From<LinkJump> for Click {
|
||||
fn from(j: LinkJump) -> Click {
|
||||
match j {
|
||||
LinkJump::Show(a) => Click::Show(a),
|
||||
LinkJump::Open(u) => Click::Open(u),
|
||||
LinkJump::Exit => Click::Exit,
|
||||
LinkJump::None => Click::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn finite_or_zero(v: f64) -> f64 {
|
||||
if v.is_finite() { v } else { 0.0 }
|
||||
}
|
||||
|
||||
impl ShowState {
|
||||
fn animated(&self, pres: &Presentation) -> bool {
|
||||
!pres.show.without_animation
|
||||
}
|
||||
|
||||
/// Does clicking `shape` start a triggered sequence? A sequence can be started again once it has
|
||||
/// played to its end.
|
||||
fn trigger_live(&self, pres: &Presentation, tl: &Timeline, shape: ShapeId) -> bool {
|
||||
self.animated(pres) && tl.trigger_steps(shape) > 0
|
||||
}
|
||||
|
||||
/// The playheads of the current slide's triggered sequences at the last tick, for
|
||||
/// [`Timeline::state_with`].
|
||||
pub fn trigger_plays(&self) -> Vec<TriggerPlay> {
|
||||
match self.runs.get(&self.slide) {
|
||||
Some(r) => r.triggers.iter().map(|t| TriggerPlay { trigger: t.shape, step: t.done, t: t.elapsed.unwrap_or(0.0) }).collect(),
|
||||
None => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// When the steps of the current slide's triggered sequences were started.
|
||||
pub fn trigger_stamps(&self) -> Vec<StepStamp> {
|
||||
let Some(r) = self.runs.get(&self.slide) else { return vec![] };
|
||||
r.triggers.iter().flat_map(|t| t.stamps.iter().enumerate().map(|(step, &at)| StepStamp { trigger: t.shape, step, at })).collect()
|
||||
}
|
||||
|
||||
/// Is a triggered animation of the current slide playing (so the picture changes every frame)?
|
||||
pub fn trigger_playing(&self) -> bool {
|
||||
self.runs.get(&self.slide).is_some_and(|r| r.triggers.iter().any(|t| t.elapsed.is_some()))
|
||||
}
|
||||
|
||||
/// Animation state of `shape` on the current slide: main step `step` with `t_main` seconds
|
||||
/// played, plus the triggered sequences.
|
||||
pub fn anim_state(&self, tl: &Timeline, shape: ShapeId, t_main: f64) -> AnimState {
|
||||
tl.state_ordered(shape, None, self.step, t_main, &self.trigger_plays(), &self.trigger_stamps())
|
||||
}
|
||||
|
||||
/// Advance the clock of the playing triggered animations to `now` (seconds, any steady clock)
|
||||
/// and complete the steps that reached their end. [`Click::Animated`] when the picture changed.
|
||||
pub fn tick(&mut self, tl: &Timeline, now: f64) -> Click {
|
||||
let dt = match self.last_now {
|
||||
Some(last) if now.is_finite() => (now - last).clamp(0.0, 3600.0),
|
||||
_ => 0.0,
|
||||
};
|
||||
if now.is_finite() {
|
||||
self.last_now = Some(now);
|
||||
}
|
||||
let Some(run) = self.runs.get_mut(&self.slide) else { return Click::None };
|
||||
let mut changed = false;
|
||||
for t in run.triggers.iter_mut() {
|
||||
let Some(e) = t.elapsed else { continue };
|
||||
let e = finite_or_zero(e) + dt;
|
||||
if e >= tl.trigger_step_duration(t.shape, t.done) {
|
||||
t.done += 1;
|
||||
t.elapsed = None;
|
||||
changed = true;
|
||||
} else {
|
||||
t.elapsed = Some(e);
|
||||
}
|
||||
}
|
||||
if changed { Click::Animated } else { Click::None }
|
||||
}
|
||||
|
||||
/// Index of the topmost shape a click lands on: the front-most one under the pointer that is
|
||||
/// shown. A group without an action does not count; its children and the space inside its box
|
||||
/// are not it.
|
||||
pub fn top(&self, pres: &Presentation, tl: &Timeline, shapes: &[HitShape], visible: &dyn Fn(ShapeId) -> bool) -> Option<usize> {
|
||||
let shown = |mut i: usize| -> bool {
|
||||
// A shape is shown when it and the groups around it are.
|
||||
for _ in 0..=shapes.len() {
|
||||
let Some(h) = shapes.get(i) else { return false };
|
||||
if !visible(h.id) {
|
||||
return false;
|
||||
}
|
||||
match h.parent {
|
||||
Some(p) => i = p,
|
||||
None => return true,
|
||||
}
|
||||
}
|
||||
false
|
||||
};
|
||||
(0..shapes.len())
|
||||
.rev()
|
||||
.find(|&i| shapes.get(i).is_some_and(|h| h.under_pointer && !(h.is_group && !self.interactive(pres, tl, h)) && shown(i)))
|
||||
}
|
||||
|
||||
fn interactive(&self, pres: &Presentation, tl: &Timeline, h: &HitShape) -> bool {
|
||||
h.link.is_some() || self.trigger_live(pres, tl, h.id)
|
||||
}
|
||||
|
||||
/// The shape under the pointer that a click would act on, `None` when a click would be a click
|
||||
/// on the background. `visible` tells whether a shape is currently shown (not yet entered or
|
||||
/// already exited shapes cannot be clicked).
|
||||
pub fn target(&self, pres: &Presentation, tl: &Timeline, shapes: &[HitShape], visible: &dyn Fn(ShapeId) -> bool) -> Option<Target> {
|
||||
// The topmost shape, or the group it is in, that has an action. A shape without one blocks
|
||||
// what lies beneath it.
|
||||
let mut at = self.top(pres, tl, shapes, visible);
|
||||
for _ in 0..=shapes.len() {
|
||||
let h = shapes.get(at?)?;
|
||||
if self.interactive(pres, tl, h) {
|
||||
return Some(Target { shape: h.id, link: h.link.clone() });
|
||||
}
|
||||
at = h.parent;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// A mouse click. `shapes` and `visible` describe what is under the pointer (see
|
||||
/// [`ShowState::target`]); `last_viewed` is the slide shown before this one.
|
||||
pub fn click(
|
||||
&mut self,
|
||||
pres: &Presentation,
|
||||
tl: &Timeline,
|
||||
shapes: &[HitShape],
|
||||
visible: &dyn Fn(ShapeId) -> bool,
|
||||
now: f64,
|
||||
last_viewed: Option<usize>,
|
||||
) -> Click {
|
||||
self.tick(tl, now);
|
||||
if self.ended {
|
||||
return Click::None;
|
||||
}
|
||||
let Some(t) = self.target(pres, tl, shapes, visible) else { return self.background_click(pres) };
|
||||
let mut animated = false;
|
||||
if self.trigger_live(pres, tl, t.shape) {
|
||||
let slide = self.slide;
|
||||
let run = self.runs.entry(slide).or_default();
|
||||
let at = match run.triggers.iter().position(|r| r.shape == t.shape) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
run.triggers.push(TriggerRun { shape: t.shape, done: 0, elapsed: None, stamps: vec![] });
|
||||
run.triggers.len() - 1
|
||||
}
|
||||
};
|
||||
let stamp = run.clock.saturating_add(1);
|
||||
if let Some(r) = run.triggers.get_mut(at) {
|
||||
if r.elapsed.is_some() {
|
||||
// A click on a shape whose step is playing completes the step.
|
||||
r.done += 1;
|
||||
r.elapsed = None;
|
||||
} else {
|
||||
if r.done >= tl.trigger_steps(t.shape) {
|
||||
// All steps have played: the sequence starts over.
|
||||
r.done = 0;
|
||||
r.stamps.clear();
|
||||
}
|
||||
r.stamps.push(stamp);
|
||||
run.clock = stamp;
|
||||
if tl.trigger_step_duration(t.shape, r.done) > 0.0 {
|
||||
r.elapsed = Some(0.0);
|
||||
} else {
|
||||
r.done += 1;
|
||||
}
|
||||
}
|
||||
animated = true;
|
||||
}
|
||||
}
|
||||
match t.link {
|
||||
// The link is followed at once, whatever the animation still has to play.
|
||||
Some(a) => self.follow(pres, &a, last_viewed).into(),
|
||||
None if animated => Click::Animated,
|
||||
None => Click::None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A click that is on no shape with an action: next animation step, then next slide, unless the
|
||||
/// slide has "advance on click" off, which leaves only the animation steps.
|
||||
fn background_click(&mut self, pres: &Presentation) -> Click {
|
||||
let advance = pres.slides.get(self.slide).and_then(|s| s.transition.as_ref()).is_none_or(|t| t.advance_on_click);
|
||||
let steps_left = self.playing || self.step < ShowState::steps(pres, self.slide);
|
||||
if advance || steps_left { Click::Show(self.next(pres)) } else { Click::None }
|
||||
}
|
||||
}
|
||||
+15
-4
@@ -5,7 +5,8 @@
|
||||
//! - [`transition_layers`]: a slide transition frame as textured quads of the old/new slide images.
|
||||
//! - [`morph_frame`]: a Morph transition frame (shapes matched by [`morph_pairs`], moved by
|
||||
//! [`morph_xfrm`]).
|
||||
//! - [`ShowState`]: slide show navigation (hidden slides, custom shows, loop, timings).
|
||||
//! - [`ShowState`]: slide show navigation (hidden slides, custom shows, loop, timings), what was
|
||||
//! played on each visited slide, and what a click does ([`ShowState::click`]).
|
||||
//!
|
||||
//! Pure logic: no rendering, no UI toolkit. Every function accepts hostile numbers (NaN, huge or
|
||||
//! negative times) and never panics.
|
||||
@@ -14,24 +15,34 @@
|
||||
|
||||
mod easing;
|
||||
mod effects;
|
||||
mod interact;
|
||||
mod mesh;
|
||||
mod morph;
|
||||
mod path;
|
||||
mod show;
|
||||
mod timeline;
|
||||
mod transition;
|
||||
mod transition_fx;
|
||||
mod transition_geom;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_interact;
|
||||
#[cfg(test)]
|
||||
mod transition_tests;
|
||||
|
||||
pub use deckcraft_model::Rgba;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use easing::ease;
|
||||
pub use morph::{MorphFrame, MorphPath, MorphText, morph_frame, morph_pairs, morph_xfrm};
|
||||
pub use interact::{Click, HitShape, Target};
|
||||
pub use mesh::{MeshVertex, layer_mesh};
|
||||
pub use morph::{MorphFrame, MorphPath, MorphText, morph_crop, morph_frame, morph_pairs, morph_xfrm};
|
||||
pub use path::{MotionPath, default_path};
|
||||
pub use show::{LinkJump, ShowAction, ShowState, safe_url, show_order};
|
||||
pub use timeline::{EffectTiming, Sequence, Timeline, TriggerPlay};
|
||||
pub use transition::{Layer, Source, transition_layers};
|
||||
pub use timeline::{EffectTiming, Sequence, StepStamp, Timeline, TriggerPlay};
|
||||
pub use transition::{DEFAULT_ASPECT, Layer, Source, transition_frame, transition_layers, uses_slide_parts};
|
||||
|
||||
/// Animation state of one shape (or one paragraph of a shape's text) at one moment of the show.
|
||||
///
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
//! Triangle meshes for transition layers.
|
||||
//!
|
||||
//! A [`Layer`] is a textured quad. Drawn as two triangles, a trapezoid (a slide seen in
|
||||
//! perspective) bends its texture along the diagonal, because GPUs and simple rasterisers
|
||||
//! interpolate texture coordinates linearly in screen space. [`layer_mesh`] splits such quads into a
|
||||
//! grid whose vertices follow the projective map from the source rectangle to the quad, so the
|
||||
//! picture looks like a flat card turned in 3-D. Rectangles and parallelograms stay two triangles,
|
||||
//! wedges one: the per-frame cost stays a handful of vertices per layer.
|
||||
|
||||
use crate::transition::Layer;
|
||||
|
||||
/// One mesh vertex: position in slide-unit space (0..1, y down), texture coordinate (0..1) and
|
||||
/// opacity (the layer's alpha times its corner fade, interpolated).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct MeshVertex {
|
||||
pub pos: [f64; 2],
|
||||
pub uv: [f64; 2],
|
||||
pub a: f64,
|
||||
}
|
||||
|
||||
/// Grid cells per side for quads that need a projective subdivision.
|
||||
const GRID: usize = 8;
|
||||
|
||||
/// The triangles of a layer: vertices and index triples into them.
|
||||
pub fn layer_mesh(l: &Layer) -> (Vec<MeshVertex>, Vec<[u32; 3]>) {
|
||||
let q = l.quad;
|
||||
let uv = l.uv;
|
||||
let fa = l.fade.map(|f| f * l.alpha);
|
||||
// A wedge (triangle): the 4th corner repeats the 3rd.
|
||||
if q[3] == q[2] && uv[3] == uv[2] {
|
||||
let v = (0..3).map(|k| MeshVertex { pos: q[k], uv: uv[k], a: fa[k] }).collect();
|
||||
return (v, vec![[0, 1, 2]]);
|
||||
}
|
||||
let affine = {
|
||||
// Parallelogram: opposite edges equal.
|
||||
let e1 = [q[1][0] - q[0][0], q[1][1] - q[0][1]];
|
||||
let e2 = [q[2][0] - q[3][0], q[2][1] - q[3][1]];
|
||||
(e1[0] - e2[0]).abs() < 1e-9 && (e1[1] - e2[1]).abs() < 1e-9
|
||||
};
|
||||
let h = if affine { None } else { square_to_quad(q) };
|
||||
let Some(h) = h else {
|
||||
let v = (0..4).map(|k| MeshVertex { pos: q[k], uv: uv[k], a: fa[k] }).collect();
|
||||
return (v, vec![[0, 1, 2], [0, 2, 3]]);
|
||||
};
|
||||
// Small quads (tiles, shards) bend their texture too little to matter: fewer cells.
|
||||
let span = q.iter().flat_map(|p| q.iter().map(move |r| (p[0] - r[0]).abs().max((p[1] - r[1]).abs()))).fold(0.0, f64::max);
|
||||
let n = ((span * 2.0 * GRID as f64).ceil() as usize).clamp(1, GRID);
|
||||
let mut v = Vec::with_capacity((n + 1) * (n + 1));
|
||||
for j in 0..=n {
|
||||
let b = j as f64 / n as f64;
|
||||
for i in 0..=n {
|
||||
let a = i as f64 / n as f64;
|
||||
// Texture: bilinear in (a, b) over the four uv corners (exact for rectangles).
|
||||
let top = lerp2(uv[0], uv[1], a);
|
||||
let bot = lerp2(uv[3], uv[2], a);
|
||||
let t = lerp2(top, bot, b);
|
||||
let at = fa[0] + (fa[1] - fa[0]) * a;
|
||||
let ab = fa[3] + (fa[2] - fa[3]) * a;
|
||||
v.push(MeshVertex { pos: apply(&h, a, b), uv: t, a: at + (ab - at) * b });
|
||||
}
|
||||
}
|
||||
let mut tris = Vec::with_capacity(n * n * 2);
|
||||
let w = (n + 1) as u32;
|
||||
for j in 0..n as u32 {
|
||||
for i in 0..n as u32 {
|
||||
let p = j * w + i;
|
||||
tris.push([p, p + 1, p + w + 1]);
|
||||
tris.push([p, p + w + 1, p + w]);
|
||||
}
|
||||
}
|
||||
(v, tris)
|
||||
}
|
||||
|
||||
fn lerp2(a: [f64; 2], b: [f64; 2], t: f64) -> [f64; 2] {
|
||||
[a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]
|
||||
}
|
||||
|
||||
/// Projective map taking the unit square's corners (0,0), (1,0), (1,1), (0,1) to `q`'s tl, tr, br,
|
||||
/// bl (Heckbert's square-to-quad). `None` when the quad is degenerate.
|
||||
fn square_to_quad(q: [[f64; 2]; 4]) -> Option<[f64; 8]> {
|
||||
let [[x0, y0], [x1, y1], [x2, y2], [x3, y3]] = q;
|
||||
let sx = x0 - x1 + x2 - x3;
|
||||
let sy = y0 - y1 + y2 - y3;
|
||||
let (dx1, dx2, dy1, dy2) = (x1 - x2, x3 - x2, y1 - y2, y3 - y2);
|
||||
let den = dx1 * dy2 - dx2 * dy1;
|
||||
if !den.is_finite() || den.abs() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
let g = (sx * dy2 - dx2 * sy) / den;
|
||||
let h = (dx1 * sy - sx * dy1) / den;
|
||||
let m = [x1 - x0 + g * x1, x3 - x0 + h * x3, x0, y1 - y0 + g * y1, y3 - y0 + h * y3, y0, g, h];
|
||||
m.iter().all(|v| v.is_finite()).then_some(m)
|
||||
}
|
||||
|
||||
fn apply(m: &[f64; 8], u: f64, v: f64) -> [f64; 2] {
|
||||
let w = m[6] * u + m[7] * v + 1.0;
|
||||
let w = if w.abs() < 1e-9 { 1e-9_f64.copysign(w) } else { w };
|
||||
[(m[0] * u + m[1] * v + m[2]) / w, (m[3] * u + m[4] * v + m[5]) / w]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::transition::{Layer, Source};
|
||||
|
||||
#[test]
|
||||
fn rect_is_two_triangles() {
|
||||
let (v, t) = layer_mesh(&Layer::full(Source::New, 1.0));
|
||||
assert_eq!((v.len(), t.len()), (4, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trapezoid_is_subdivided_projectively() {
|
||||
// A card turned about its left edge: the right edge is shorter.
|
||||
let q = [[0.0, 0.0], [0.6, 0.2], [0.6, 0.8], [0.0, 1.0]];
|
||||
let l = Layer::quad(Source::Old, [0.0, 0.0, 1.0, 1.0], q, 1.0);
|
||||
let (v, t) = layer_mesh(&l);
|
||||
assert_eq!(v.len(), (GRID + 1) * (GRID + 1));
|
||||
assert_eq!(t.len(), GRID * GRID * 2);
|
||||
// A small tile in perspective needs no more than a couple of triangles.
|
||||
let small = Layer::quad(Source::Old, [0.0, 0.0, 1.0, 1.0], [[0.5, 0.5], [0.52, 0.505], [0.52, 0.515], [0.5, 0.52]], 1.0);
|
||||
assert!(layer_mesh(&small).1.len() <= 2);
|
||||
// Corners land on the quad's corners.
|
||||
assert_eq!(v[0].pos, q[0]);
|
||||
let last = v[v.len() - 1].pos;
|
||||
assert!((last[0] - 0.6).abs() < 1e-9 && (last[1] - 0.8).abs() < 1e-9);
|
||||
// The texture's middle column sits nearer the far (short) edge than halfway: foreshortening.
|
||||
let mid = v[GRID / 2].pos[0];
|
||||
assert!(mid > 0.3, "{mid}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degenerate_quads_do_not_blow_up() {
|
||||
let q = [[0.5, 0.5]; 4];
|
||||
let l = Layer::quad(Source::Old, [0.0, 0.0, 1.0, 1.0], q, 1.0);
|
||||
let (v, _) = layer_mesh(&l);
|
||||
assert!(v.iter().all(|p| p.pos[0].is_finite() && p.pos[1].is_finite()));
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,9 @@ fn text_of(s: &Shape) -> String {
|
||||
|
||||
/// Pairs of (shape on `a`, shape on `b`) that Morph animates from one to the other: first by
|
||||
/// `!!name` (forced match), then by exact name, then by the same kind and text, then by the same
|
||||
/// kind and preset geometry. Each shape is used once; top-level shapes only (groups move whole).
|
||||
/// kind and preset geometry if neither carries text. A text box with neither a name nor its text on
|
||||
/// the other slide stays unpaired and fades in place, as in PowerPoint (#119). Each shape is used
|
||||
/// once; top-level shapes only (groups move whole).
|
||||
pub fn morph_pairs(a: &Slide, b: &Slide) -> Vec<(ShapeId, ShapeId)> {
|
||||
let mut pairs: Vec<(ShapeId, ShapeId)> = Vec::new();
|
||||
let mut used_a = vec![false; a.shapes.len()];
|
||||
@@ -31,7 +33,8 @@ pub fn morph_pairs(a: &Slide, b: &Slide) -> Vec<(ShapeId, ShapeId)> {
|
||||
x.kind_name() == y.kind_name()
|
||||
&& x.geom.preset_name().is_some()
|
||||
&& x.geom.preset_name() == y.geom.preset_name()
|
||||
&& text_of(x).trim().is_empty() == text_of(y).trim().is_empty()
|
||||
&& text_of(x).trim().is_empty()
|
||||
&& text_of(y).trim().is_empty()
|
||||
},
|
||||
];
|
||||
for pass in passes {
|
||||
@@ -173,9 +176,58 @@ fn reshaped(a: &Shape, b: &Shape) -> bool {
|
||||
a.geom != b.geom && outlined(a) && outlined(b) && Shape { geom: Geom::default(), ..content(a) } == Shape { geom: Geom::default(), ..content(b) }
|
||||
}
|
||||
|
||||
/// The content of a shape apart from what Morph interpolates, with a picture's crop left out.
|
||||
fn uncropped(s: &Shape) -> Shape {
|
||||
let mut c = content(s);
|
||||
if let ShapeKind::Picture { fill } = &mut c.kind {
|
||||
fill.crop = [0.0; 4];
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
fn crop_of(s: &Shape) -> [f64; 4] {
|
||||
match &s.kind {
|
||||
ShapeKind::Picture { fill } => fill.crop,
|
||||
_ => [0.0; 4],
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether two pictures differ in their crop only (apart from box and fill), so the crop can morph.
|
||||
fn recropped(a: &Shape, b: &Shape) -> bool {
|
||||
matches!(a.kind, ShapeKind::Picture { .. }) && crop_of(a) != crop_of(b) && uncropped(a) == uncropped(b)
|
||||
}
|
||||
|
||||
/// The crop (left, top, right, bottom) of a picture morphing from box `xa` cropped by `ca` to box
|
||||
/// `xb` cropped by `cb`, when its box is `x` at progress `t`: the whole image moves and scales
|
||||
/// linearly between where it lies on either slide, and the crop is what of it `x` shows. Falls back
|
||||
/// to blending the crops when a crop leaves nothing visible.
|
||||
pub fn morph_crop(ca: [f64; 4], xa: Xfrm, cb: [f64; 4], xb: Xfrm, x: Xfrm, t: f64) -> [f64; 4] {
|
||||
// Along one axis: (box start, box size, crop before, crop after) → the crops at `t`.
|
||||
let axis = |(pa, sa, la, ha): (f64, f64, f64, f64), (pb, sb, lb, hb): (f64, f64, f64, f64), (p, s): (f64, f64)| {
|
||||
let (va, vb) = (1.0 - la - ha, 1.0 - lb - hb);
|
||||
if va <= 1e-6 || vb <= 1e-6 {
|
||||
return (lerp(la, lb, t), lerp(ha, hb, t));
|
||||
}
|
||||
// The whole image: its extent and start on either slide.
|
||||
let (ea, eb) = (sa / va, sb / vb);
|
||||
let e = lerp(ea, eb, t);
|
||||
if !e.is_finite() || e.abs() <= 1e-9 {
|
||||
return (lerp(la, lb, t), lerp(ha, hb, t));
|
||||
}
|
||||
let o = lerp(pa - la * ea, pb - lb * eb, t);
|
||||
let lo = (p - o) / e;
|
||||
(lo, 1.0 - lo - s / e)
|
||||
};
|
||||
let (l, r) = axis((xa.x, xa.w, ca[0], ca[2]), (xb.x, xb.w, cb[0], cb[2]), (x.x, x.w));
|
||||
let (tp, bt) = axis((xa.y, xa.h, ca[1], ca[3]), (xb.y, xb.h, cb[1], cb[3]), (x.y, x.h));
|
||||
let c = [l, tp, r, bt];
|
||||
if c.iter().all(|v| v.is_finite()) { c } else { cb }
|
||||
}
|
||||
|
||||
/// Morph frame from slide `a` to slide `b` at progress `t` (0..1, eased here). Paired shapes
|
||||
/// ([`morph_pairs`]) move along [`morph_xfrm`]; when they differ only in solid fill colour the
|
||||
/// colour blends, when only in geometry (and fill colour) the outline morphs ([`MorphFrame::paths`]),
|
||||
/// colour blends, when pictures differ only in crop the crop morphs ([`morph_crop`]), when only
|
||||
/// in geometry (and fill colour) the outline morphs ([`MorphFrame::paths`]),
|
||||
/// otherwise the new shape fades in under the fading old one on the same moving
|
||||
/// box; with the transition option `words` or `characters` their texts are listed in
|
||||
/// [`MorphFrame::text`] to morph word by word or character by character. Unpaired shapes of `a` fade out, unpaired shapes of `b` fade in, and shapes of `b` for
|
||||
@@ -223,8 +275,18 @@ pub fn morph_frame(pres: &Presentation, a: &Slide, b: &Slide, t: f64, hidden_b:
|
||||
(_, xb) => xb,
|
||||
};
|
||||
let reshape = reshaped(sa, sb);
|
||||
if reshape || content(sa) == content(sb) {
|
||||
let recrop = recropped(sa, sb);
|
||||
if reshape || recrop || content(sa) == content(sb) {
|
||||
let mut m = Shape { xfrm: x, ..sb.clone() };
|
||||
if recrop && let ShapeKind::Picture { fill } = &mut m.kind {
|
||||
fill.crop = match (xa, xb, x) {
|
||||
(Some(xa), Some(xb), Some(x)) => morph_crop(crop_of(sa), xa, crop_of(sb), xb, x, s),
|
||||
_ => {
|
||||
let (ca, cb) = (crop_of(sa), crop_of(sb));
|
||||
[lerp(ca[0], cb[0], s), lerp(ca[1], cb[1], s), lerp(ca[2], cb[2], s), lerp(ca[3], cb[3], s)]
|
||||
}
|
||||
};
|
||||
}
|
||||
if let (Some(fa), Some(fb)) = (solid_color(ca.as_ref(), sa), solid_color(cb.as_ref(), sb))
|
||||
&& fa != fb
|
||||
{
|
||||
@@ -237,7 +299,11 @@ pub fn morph_frame(pres: &Presentation, a: &Slide, b: &Slide, t: f64, hidden_b:
|
||||
} else {
|
||||
let new = push(Shape { xfrm: x, ..sb.clone() }, false, s);
|
||||
let old = push(Shape { xfrm: x, ..sa.clone() }, true, 1.0 - s);
|
||||
if by_text
|
||||
// The same text in shapes that differ otherwise (a title slide's centred title and a
|
||||
// content slide's title: other placeholder, size, alignment) moves and resizes word
|
||||
// by word even with the Objects option, as PowerPoint does, instead of cross-fading.
|
||||
let same_text = text_of(sa).trim() == text_of(sb).trim();
|
||||
if (by_text || same_text)
|
||||
&& has_text(sa)
|
||||
&& has_text(sb)
|
||||
&& let (Some(from), Some(to)) = (box_of(ca.as_ref(), sa), box_of(cb.as_ref(), sb))
|
||||
|
||||
+23
-2
@@ -49,7 +49,7 @@ fn tokenize(s: &str) -> Vec<Tok> {
|
||||
n1.is_ascii_digit() || ((n1 == '-' || n1 == '+') && n2.is_ascii_digit())
|
||||
} {
|
||||
i += 2;
|
||||
while cs.get(i).is_some_and(|d| d.is_ascii_digit()) {
|
||||
while cs.get(i).is_some_and(char::is_ascii_digit) {
|
||||
i += 1;
|
||||
}
|
||||
break;
|
||||
@@ -288,6 +288,27 @@ pub fn default_path(effect: &str, option: &str) -> String {
|
||||
}
|
||||
},
|
||||
"loops" => "M 0 0 C 0.08 -0.1 0.16 0 0.08 0.05 C 0 0.1 -0.08 0.1 -0.08 0 C -0.08 -0.1 0 -0.05 0 0 E".into(),
|
||||
_ => String::new(),
|
||||
other => deckcraft_model::anim::preset_path(other).unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod preset_path_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_extra_motion_path_preset_starts_at_its_shape_and_moves() {
|
||||
use deckcraft_model::anim::{ANIMATIONS, AnimClass, preset_path};
|
||||
let extra: Vec<_> = ANIMATIONS.iter().filter(|a| a.2 == AnimClass::Path && preset_path(a.0).is_some()).collect();
|
||||
assert!(extra.len() >= 30);
|
||||
for a in extra {
|
||||
assert_eq!(default_path(a.0, ""), preset_path(a.0).unwrap_or_default());
|
||||
let mp = MotionPath::parse(&default_path(a.0, ""));
|
||||
let (x, y) = mp.at(0.0);
|
||||
assert!(x.abs() < 1e-9 && y.abs() < 1e-9, "{} starts at its shape", a.0);
|
||||
let far = mp.points.iter().map(|p| p.0.abs() + p.1.abs()).fold(0.0, f64::max);
|
||||
assert!(far > 0.05, "{} moves", a.0);
|
||||
assert!(mp.points.iter().all(|p| p.0.abs() < 0.5 && p.1.abs() < 0.5), "{} stays near its shape", a.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+157
-23
@@ -1,10 +1,13 @@
|
||||
//! Slide show navigation: click steps, slide order (hidden slides, custom shows, ranges), loop
|
||||
//! and automatic advance.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use deckcraft_model::Presentation;
|
||||
use deckcraft_model::text::Action;
|
||||
use deckcraft_model::text::{Action, Hyperlink};
|
||||
|
||||
use crate::Timeline;
|
||||
use crate::interact::SlideRun;
|
||||
|
||||
/// What the UI should do after a navigation call.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -52,8 +55,10 @@ pub enum LinkJump {
|
||||
None,
|
||||
}
|
||||
|
||||
/// Where a slide show is.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
/// Where a slide show is. Besides the current position it remembers what was played on every slide
|
||||
/// the show has visited (main steps, triggered sequences), so a slide reached again by a link or
|
||||
/// the arrow keys looks the way it was left, as in PowerPoint.
|
||||
#[derive(Clone, Debug, PartialEq, Default)]
|
||||
pub struct ShowState {
|
||||
/// Index into `presentation.slides`.
|
||||
pub slide: usize,
|
||||
@@ -63,6 +68,19 @@ pub struct ShowState {
|
||||
pub playing: bool,
|
||||
/// On the end screen.
|
||||
pub ended: bool,
|
||||
/// The show has entered a slide (so `slide` and `step` are meaningful and can be saved).
|
||||
active: bool,
|
||||
/// The time of the last [`ShowState::tick`] (seconds), for the triggered animations' clocks.
|
||||
pub(crate) last_now: Option<f64>,
|
||||
/// What was played on each visited slide (index into `presentation.slides`).
|
||||
pub(crate) runs: BTreeMap<usize, SlideRun>,
|
||||
/// One-shot visit order (Custom Show from the dialog or a click). `None` uses [`show_order`].
|
||||
order: Option<Vec<usize>>,
|
||||
/// Name of the custom show being played, when there is one.
|
||||
custom: Option<String>,
|
||||
/// Return to Zoom: (zoom slide, last zoomed slide). Advancing past the last zoomed slide goes
|
||||
/// back to the zoom slide, fully built.
|
||||
pub zoom: Option<(usize, usize)>,
|
||||
}
|
||||
|
||||
/// Slide indices the show visits in order: the custom show or range from Set Up Slide Show, minus
|
||||
@@ -83,6 +101,18 @@ pub fn show_order(pres: &Presentation) -> Vec<usize> {
|
||||
base.into_iter().filter(|i| pres.slides.get(*i).is_some_and(|s| !s.hidden)).collect()
|
||||
}
|
||||
|
||||
/// Visible slides of custom show `name`, in that show's order.
|
||||
fn custom_indices(pres: &Presentation, name: &str) -> Option<Vec<usize>> {
|
||||
let cs = pres.custom_shows.iter().find(|c| c.name == name)?;
|
||||
let order: Vec<usize> = cs
|
||||
.slides
|
||||
.iter()
|
||||
.filter_map(|id| pres.slides.iter().position(|s| s.id == *id))
|
||||
.filter(|&i| pres.slides.get(i).is_some_and(|s| !s.hidden))
|
||||
.collect();
|
||||
if order.is_empty() { None } else { Some(order) }
|
||||
}
|
||||
|
||||
fn looping(pres: &Presentation) -> bool {
|
||||
pres.show.loop_until_esc || pres.show.show_type == "kiosk"
|
||||
}
|
||||
@@ -104,9 +134,48 @@ impl ShowState {
|
||||
} else {
|
||||
s.ended = true;
|
||||
}
|
||||
if let Some(name) = pres.show.custom_show.clone() {
|
||||
s.custom = Some(name);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Play custom show `name` from its first slide, without changing Set Up Slide Show.
|
||||
/// `None` when that show is missing or has no visible slides.
|
||||
pub fn play_custom(pres: &Presentation, name: &str) -> Option<ShowState> {
|
||||
let order = custom_indices(pres, name)?;
|
||||
let &first = order.first()?;
|
||||
let mut s = ShowState::start_at(pres, first);
|
||||
s.order = Some(order);
|
||||
s.custom = Some(name.to_string());
|
||||
Some(s)
|
||||
}
|
||||
|
||||
/// Name of the custom show this playback is limited to.
|
||||
pub fn custom_name(&self) -> Option<&str> {
|
||||
self.custom.as_deref().filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// The slide that follows the current one in this show.
|
||||
pub fn peek_next(&self, pres: &Presentation) -> Option<usize> {
|
||||
self.next_index(pres)
|
||||
}
|
||||
|
||||
pub fn first_index(&self, pres: &Presentation) -> Option<usize> {
|
||||
self.visit(pres).first().copied()
|
||||
}
|
||||
|
||||
pub fn last_index(&self, pres: &Presentation) -> Option<usize> {
|
||||
self.visit(pres).last().copied()
|
||||
}
|
||||
|
||||
fn visit(&self, pres: &Presentation) -> Vec<usize> {
|
||||
match &self.order {
|
||||
Some(order) => order.iter().copied().filter(|&i| pres.slides.get(i).is_some_and(|s| !s.hidden)).collect(),
|
||||
None => show_order(pres),
|
||||
}
|
||||
}
|
||||
|
||||
/// The timeline of slide `i`.
|
||||
pub fn timeline(pres: &Presentation, i: usize) -> Timeline {
|
||||
match pres.slides.get(i) {
|
||||
@@ -120,15 +189,41 @@ impl ShowState {
|
||||
if pres.show.without_animation { 0 } else { ShowState::timeline(pres, i).steps() }
|
||||
}
|
||||
|
||||
/// Save what was played on the current slide before the show moves away from it. A main step
|
||||
/// that is playing is taken to its end. Triggered animations keep their place: their clock only
|
||||
/// runs while the slide is shown, and they go on from there when the show comes back.
|
||||
fn leave(&mut self, pres: &Presentation) {
|
||||
if !self.active {
|
||||
return;
|
||||
}
|
||||
let done = if self.playing { self.step + 1 } else { self.step };
|
||||
let step = done.min(ShowState::steps(pres, self.slide));
|
||||
self.runs.entry(self.slide).or_default().step = step;
|
||||
self.playing = false;
|
||||
}
|
||||
|
||||
/// Show slide `i`: as it was left when the show has been there before, otherwise from the start.
|
||||
fn enter(&mut self, pres: &Presentation, i: usize) {
|
||||
self.leave(pres);
|
||||
self.active = true;
|
||||
self.slide = i;
|
||||
self.step = 0;
|
||||
self.ended = false;
|
||||
self.playing = !pres.show.without_animation && ShowState::timeline(pres, i).step_is_auto(0);
|
||||
// Time spent elsewhere never counts for this slide's triggered animations.
|
||||
self.last_now = None;
|
||||
let steps = ShowState::steps(pres, i);
|
||||
let run = self.runs.entry(i).or_default();
|
||||
if run.visited {
|
||||
self.step = run.step.min(steps);
|
||||
self.playing = false;
|
||||
} else {
|
||||
run.visited = true;
|
||||
self.step = 0;
|
||||
self.playing = !pres.show.without_animation && ShowState::timeline(pres, i).step_is_auto(0);
|
||||
}
|
||||
}
|
||||
|
||||
fn next_index(&self, pres: &Presentation) -> Option<usize> {
|
||||
let order = show_order(pres);
|
||||
let order = self.visit(pres);
|
||||
match order.iter().position(|&i| i == self.slide) {
|
||||
Some(p) => order.get(p + 1).copied(),
|
||||
None => order.iter().copied().find(|&i| i > self.slide),
|
||||
@@ -136,7 +231,7 @@ impl ShowState {
|
||||
}
|
||||
|
||||
fn prev_index(&self, pres: &Presentation) -> Option<usize> {
|
||||
let order = show_order(pres);
|
||||
let order = self.visit(pres);
|
||||
match order.iter().position(|&i| i == self.slide) {
|
||||
Some(p) => p.checked_sub(1).and_then(|q| order.get(q).copied()),
|
||||
None => order.iter().copied().rev().find(|&i| i < self.slide),
|
||||
@@ -160,12 +255,22 @@ impl ShowState {
|
||||
self.playing = true;
|
||||
return ShowAction::PlayStep(self.step);
|
||||
}
|
||||
if let Some((back, until)) = self.zoom
|
||||
&& until == self.slide
|
||||
&& back < pres.slides.len()
|
||||
{
|
||||
self.zoom = None;
|
||||
self.enter(pres, back);
|
||||
self.step = ShowState::steps(pres, back);
|
||||
self.playing = false;
|
||||
return ShowAction::Slide(back);
|
||||
}
|
||||
match self.next_index(pres) {
|
||||
Some(i) => {
|
||||
self.enter(pres, i);
|
||||
ShowAction::Slide(i)
|
||||
}
|
||||
None if looping(pres) => match show_order(pres).first() {
|
||||
None if looping(pres) => match self.visit(pres).first() {
|
||||
Some(&i) => {
|
||||
self.enter(pres, i);
|
||||
ShowAction::Slide(i)
|
||||
@@ -194,7 +299,8 @@ impl ShowState {
|
||||
ShowAction::FinishStep(n)
|
||||
}
|
||||
|
||||
/// ← / Backspace: undo the last step, else go to the previous slide fully built.
|
||||
/// ← / Backspace: undo the last step, else go to the previous slide fully built. An automatic
|
||||
/// first step (With/After Previous) is part of the slide appearing, so it is never undone.
|
||||
pub fn prev(&mut self, pres: &Presentation) -> ShowAction {
|
||||
if self.ended {
|
||||
self.ended = false;
|
||||
@@ -205,24 +311,32 @@ impl ShowState {
|
||||
self.playing = false;
|
||||
return ShowAction::Slide(self.slide);
|
||||
}
|
||||
if self.playing {
|
||||
// #844: undoing the automatic first step left the slide as before it appeared, and the show
|
||||
// played the step again at once, so Back never got past the slide.
|
||||
let undo = if self.playing { Some(self.step) } else { self.step.checked_sub(1) };
|
||||
let auto_first = undo == Some(0) && !pres.show.without_animation && ShowState::timeline(pres, self.slide).step_is_auto(0);
|
||||
if let Some(k) = undo
|
||||
&& !auto_first
|
||||
{
|
||||
self.playing = false;
|
||||
return ShowAction::StepBack(self.step);
|
||||
}
|
||||
if self.step > 0 {
|
||||
self.step -= 1;
|
||||
return ShowAction::StepBack(self.step);
|
||||
self.step = k;
|
||||
return ShowAction::StepBack(k);
|
||||
}
|
||||
let target = match self.prev_index(pres) {
|
||||
Some(i) => Some(i),
|
||||
None if looping(pres) => show_order(pres).last().copied().filter(|&i| i != self.slide),
|
||||
None if looping(pres) => self.visit(pres).last().copied().filter(|&i| i != self.slide),
|
||||
None => None,
|
||||
};
|
||||
match target {
|
||||
Some(i) => {
|
||||
self.slide = i;
|
||||
self.step = ShowState::steps(pres, i);
|
||||
self.playing = false;
|
||||
// A slide the show has been to looks as it was left; one it has not been to is
|
||||
// shown fully built.
|
||||
let seen = self.runs.get(&i).is_some_and(|r| r.visited);
|
||||
self.enter(pres, i);
|
||||
if !seen {
|
||||
self.step = ShowState::steps(pres, i);
|
||||
self.playing = false;
|
||||
}
|
||||
ShowAction::Slide(i)
|
||||
}
|
||||
None => ShowAction::None,
|
||||
@@ -234,6 +348,7 @@ impl ShowState {
|
||||
if i >= pres.slides.len() {
|
||||
return ShowAction::None;
|
||||
}
|
||||
self.zoom = None;
|
||||
self.enter(pres, i);
|
||||
ShowAction::Slide(i)
|
||||
}
|
||||
@@ -247,11 +362,18 @@ impl ShowState {
|
||||
Action::Slide { slide } => pres.slides.iter().position(|s| s.id == *slide),
|
||||
Action::NextSlide => self.next_index(pres),
|
||||
Action::PreviousSlide => self.prev_index(pres),
|
||||
Action::FirstSlide => show_order(pres).first().copied(),
|
||||
Action::LastSlide => show_order(pres).last().copied(),
|
||||
Action::FirstSlide => self.visit(pres).first().copied(),
|
||||
Action::LastSlide => self.visit(pres).last().copied(),
|
||||
Action::LastViewed => last_viewed,
|
||||
// Custom shows, programs and media actions aren't followed (yet).
|
||||
Action::CustomShow { .. } | Action::Program { .. } | Action::PlayMedia => None,
|
||||
Action::CustomShow { name, .. } => {
|
||||
let Some(order) = custom_indices(pres, name) else { return LinkJump::None };
|
||||
let Some(&i) = order.first() else { return LinkJump::None };
|
||||
self.order = Some(order);
|
||||
self.custom = Some(name.clone());
|
||||
return LinkJump::Show(self.goto(pres, i));
|
||||
}
|
||||
// Programs and media actions aren't followed.
|
||||
Action::Program { .. } | Action::PlayMedia => None,
|
||||
};
|
||||
match target {
|
||||
Some(i) => LinkJump::Show(self.goto(pres, i)),
|
||||
@@ -259,6 +381,18 @@ impl ShowState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Follow a clicked link, honouring a zoom's Return to Zoom (see [`Hyperlink::zoom_return`]).
|
||||
pub fn follow_link(&mut self, pres: &Presentation, link: &Hyperlink, last_viewed: Option<usize>) -> LinkJump {
|
||||
let from = self.slide;
|
||||
let jump = self.follow(pres, &link.action, last_viewed);
|
||||
if let Some(until) = link.zoom_return.and_then(|id| pres.slides.iter().position(|s| s.id == id))
|
||||
&& matches!(jump, LinkJump::Show(ShowAction::Slide(_)))
|
||||
{
|
||||
self.zoom = Some((from, until));
|
||||
}
|
||||
jump
|
||||
}
|
||||
|
||||
/// Seconds after which the current slide advances by itself (Advance Slide ▸ After), when the
|
||||
/// show uses timings.
|
||||
pub fn advance_after(&self, pres: &Presentation) -> Option<f64> {
|
||||
|
||||
@@ -344,6 +344,36 @@ fn empty_slide() {
|
||||
assert_eq!(tl.locate(5.0), (0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_of_seeks_the_play_all_clock_to_an_effect() {
|
||||
let mut late = anim(4, AnimClass::Entrance, "fade", AnimStart::WithPrevious);
|
||||
late.delay_ms = 250;
|
||||
let mut triggered = anim(4, AnimClass::Emphasis, "pulse", AnimStart::OnClick);
|
||||
triggered.trigger = Some(ShapeId(1));
|
||||
let tl = Timeline::new(
|
||||
&slide_with(vec![
|
||||
anim(1, AnimClass::Entrance, "fade", AnimStart::OnClick),
|
||||
anim(2, AnimClass::Exit, "fadeOut", AnimStart::OnClick),
|
||||
anim(3, AnimClass::Entrance, "fade", AnimStart::OnClick),
|
||||
late,
|
||||
triggered,
|
||||
]),
|
||||
W,
|
||||
H,
|
||||
);
|
||||
assert_eq!(tl.start_of(0), Some(0.0));
|
||||
assert_eq!(tl.start_of(2), Some(2.0));
|
||||
assert_eq!(tl.start_of(3), Some(2.25));
|
||||
assert_eq!(tl.start_of(4), None, "a triggered effect is not on the play-all clock");
|
||||
assert_eq!(tl.start_of(9), None);
|
||||
// Playing from the third effect: what came before has played, the rest has not.
|
||||
let at = tl.start_of(2).unwrap();
|
||||
assert!(tl.state_at(ShapeId(1), None, at).visible, "entered before");
|
||||
assert!(!tl.state_at(ShapeId(2), None, at).visible, "exited before");
|
||||
assert!(!tl.state_at(ShapeId(3), None, at).visible, "not entered yet");
|
||||
assert!(!tl.state_at(ShapeId(4), None, at).visible, "not entered yet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locate_play_all_clock() {
|
||||
let tl = Timeline::new(
|
||||
@@ -529,6 +559,61 @@ fn morph_pairs_by_name_text_and_geometry() {
|
||||
assert!(morph_pairs(&Slide::default(), &b).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn morph_pairs_leaves_unrelated_text_boxes_alone() {
|
||||
// A text box that leaves and an unrelated one that arrives fade in place (#119).
|
||||
let p = deckcraft_model::defaults::new_presentation(None);
|
||||
let mut a = Slide::default();
|
||||
let mut b = Slide::default();
|
||||
let tb = |id, name, x, text| Shape { xfrm: Some(Xfrm::new(x, 100.0, 200.0, 100.0)), text: Some(TextBody::from_text(text)), ..shape(id, name) };
|
||||
a.shapes = vec![tb(1, "TextBox 1", 0.0, "Gone")];
|
||||
b.shapes = vec![tb(2, "TextBox 2", 400.0, "New")];
|
||||
assert!(morph_pairs(&a, &b).is_empty());
|
||||
let f = morph_frame(&p, &a, &b, 0.5, &|_| false);
|
||||
let xs: Vec<(&str, f64)> = f.shapes.iter().map(|(s, _)| (s.name.as_str(), s.xfrm.map_or(f64::NAN, |x| x.x))).collect();
|
||||
assert_eq!(xs, [("TextBox 1", 0.0), ("TextBox 2", 400.0)]);
|
||||
// Empty shapes of one kind and geometry still pair, and a named text box keeps its partner.
|
||||
a.shapes.push(shape(3, "Rect A"));
|
||||
b.shapes.push(shape(4, "Rect B"));
|
||||
b.shapes[0].name = "TextBox 1".into();
|
||||
assert_eq!(morph_pairs(&a, &b), [(ShapeId(1), ShapeId(2)), (ShapeId(3), ShapeId(4))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn morph_crop_moves_the_image_linearly() {
|
||||
let bx = |x, w| Xfrm::new(x, 0.0, w, 100.0);
|
||||
// Same box, the crop pans from the left half to the right half of a 200-wide image.
|
||||
let c = morph_crop([0.0, 0.0, 0.5, 0.0], bx(0.0, 100.0), [0.5, 0.0, 0.0, 0.0], bx(0.0, 100.0), bx(0.0, 100.0), 0.5);
|
||||
assert!(c.iter().zip([0.25, 0.0, 0.25, 0.0]).all(|(a, b)| (a - b).abs() < 1e-9), "{c:?}");
|
||||
// The box shrinks over an image that stays put: only the right crop grows.
|
||||
let c = morph_crop([0.0; 4], bx(0.0, 100.0), [0.0, 0.0, 0.5, 0.0], bx(0.0, 50.0), bx(0.0, 75.0), 0.5);
|
||||
assert!(c.iter().zip([0.0, 0.0, 0.25, 0.0]).all(|(a, b)| (a - b).abs() < 1e-9), "{c:?}");
|
||||
// Nothing visible on one side: the crops blend.
|
||||
let c = morph_crop([0.5, 0.0, 0.5, 0.0], bx(0.0, 100.0), [0.0; 4], bx(0.0, 100.0), bx(0.0, 100.0), 0.5);
|
||||
assert!(c.iter().all(|v| v.is_finite()) && (c[0] - 0.25).abs() < 1e-9, "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn morph_frame_morphs_a_picture_crop() {
|
||||
// A picture recropped on the next slide stays one picture whose crop morphs (#118).
|
||||
let p = deckcraft_model::defaults::new_presentation(None);
|
||||
let pic = |id, crop| Shape {
|
||||
kind: deckcraft_model::ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { crop, ..Default::default() } },
|
||||
..shape(id, "Picture 1")
|
||||
};
|
||||
let mut a = Slide::default();
|
||||
let mut b = Slide::default();
|
||||
a.shapes = vec![pic(1, [0.0, 0.0, 0.5, 0.0])];
|
||||
b.shapes = vec![pic(2, [0.5, 0.0, 0.0, 0.0])];
|
||||
let f = morph_frame(&p, &a, &b, 0.5, &|_| false);
|
||||
assert_eq!(f.shapes.len(), 1);
|
||||
assert!(f.opacity.is_empty());
|
||||
let deckcraft_model::ShapeKind::Picture { fill } = &f.shapes[0].0.kind else { panic!("not a picture") };
|
||||
let want = 0.5 * f.mix;
|
||||
assert!((fill.crop[0] - want).abs() < 1e-9 && (fill.crop[2] - (0.5 - want)).abs() < 1e-9, "{:?}", fill.crop);
|
||||
assert_eq!(morph_frame(&p, &a, &b, 1.0, &|_| false).shapes[0].0.kind, b.shapes[0].kind);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn morph_xfrm_interpolates() {
|
||||
let a = Xfrm { rot: 350.0, ..Xfrm::new(0.0, 0.0, 100.0, 100.0) };
|
||||
@@ -702,6 +787,37 @@ fn show_custom_show_and_range() {
|
||||
assert_eq!(show_order(&p), vec![0, 1, 2, 3, 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playing_a_custom_show_stays_on_its_slides() {
|
||||
use deckcraft_model::text::Action;
|
||||
let mut p = pres(5);
|
||||
p.custom_shows = vec![CustomShow { name: "Ends".into(), slides: vec![SlideId(1000), SlideId(1004)] }];
|
||||
assert!(p.show.custom_show.is_none());
|
||||
let mut s = ShowState::play_custom(&p, "Ends").expect("the show exists");
|
||||
assert_eq!(s.custom_name(), Some("Ends"));
|
||||
assert_eq!(s.slide, 0);
|
||||
assert_eq!(s.peek_next(&p), Some(4));
|
||||
assert_eq!(s.next(&p), ShowAction::Slide(4));
|
||||
assert_eq!(s.next(&p), ShowAction::End);
|
||||
assert_eq!(s.prev(&p), ShowAction::Slide(4));
|
||||
assert_eq!(s.prev(&p), ShowAction::Slide(0));
|
||||
assert!(ShowState::play_custom(&p, "Missing").is_none());
|
||||
assert!(p.show.custom_show.is_none(), "one-shot playback does not change Set Up");
|
||||
|
||||
Arc::make_mut(&mut p.slides[4]).hidden = true;
|
||||
let only = ShowState::play_custom(&p, "Ends").expect("the visible slide remains");
|
||||
assert_eq!(only.first_index(&p), Some(0));
|
||||
assert_eq!(only.last_index(&p), Some(0));
|
||||
|
||||
let mut s = ShowState::new(&p);
|
||||
p.slides[4] = Arc::new(Slide { id: SlideId(1004), ..Default::default() });
|
||||
assert_eq!(s.follow(&p, &Action::CustomShow { name: "Ends".into(), returns: true }, None), LinkJump::Show(ShowAction::Slide(0)));
|
||||
assert_eq!(s.custom_name(), Some("Ends"));
|
||||
assert_eq!(s.next(&p), ShowAction::Slide(4));
|
||||
assert_eq!(s.follow(&p, &Action::CustomShow { name: "Missing".into(), returns: true }, None), LinkJump::None);
|
||||
assert_eq!(s.slide, 4, "an unknown custom show leaves the current slide");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_steps_through_builds() {
|
||||
let mut p = pres(2);
|
||||
@@ -722,6 +838,30 @@ fn show_steps_through_builds() {
|
||||
assert_eq!(s.step, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_back_leaves_a_slide_whose_first_step_is_automatic() {
|
||||
// #844: undoing the automatic first step left the slide as before it appeared, and the show
|
||||
// played that step again at once, so Back never reached the slide before.
|
||||
for start in [AnimStart::WithPrevious, AnimStart::AfterPrevious] {
|
||||
let mut p = pres(2);
|
||||
Arc::make_mut(&mut p.slides[1]).shapes = vec![shape(1, "A"), shape(2, "B")];
|
||||
Arc::make_mut(&mut p.slides[1]).animations =
|
||||
vec![anim(1, AnimClass::Entrance, "fade", start), anim(2, AnimClass::Entrance, "fade", AnimStart::OnClick)];
|
||||
let mut s = ShowState::new(&p);
|
||||
assert_eq!(s.next(&p), ShowAction::Slide(1));
|
||||
assert!(s.playing, "{start:?}");
|
||||
// While the automatic step plays.
|
||||
assert_eq!(s.prev(&p), ShowAction::Slide(0), "{start:?}");
|
||||
assert_eq!(s.next(&p), ShowAction::Slide(1));
|
||||
// Once it has played, the click step is undone first, then Back leaves the slide.
|
||||
assert_eq!(s.next(&p), ShowAction::PlayStep(1));
|
||||
assert_eq!(s.step_done(&p), ShowAction::FinishStep(1));
|
||||
assert_eq!(s.prev(&p), ShowAction::StepBack(1), "{start:?}");
|
||||
assert_eq!(s.prev(&p), ShowAction::Slide(0), "{start:?}");
|
||||
assert_eq!((s.step, s.playing), (0, false));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_auto_advance() {
|
||||
let mut p = pres(2);
|
||||
@@ -789,3 +929,156 @@ fn show_follows_links() {
|
||||
assert_eq!(s.follow(&p, &Action::LastViewed, None), LinkJump::None);
|
||||
assert_eq!(s.slide, 3);
|
||||
}
|
||||
|
||||
/// #420: late in the Honeycomb every cell has settled, so the incoming hexagons must cover the
|
||||
/// whole slide — the old row count stopped at ~75% of a 16:9 slide and left the bottom black.
|
||||
#[test]
|
||||
fn honeycomb_cells_reach_the_bottom_before_the_slide_ends() {
|
||||
let sign = |a: [f64; 2], b: [f64; 2], c: [f64; 2]| (a[0] - c[0]) * (b[1] - c[1]) - (b[0] - c[0]) * (a[1] - c[1]);
|
||||
let inside = |p: [f64; 2], t: [[f64; 2]; 3]| {
|
||||
// The dark seams between cells are the effect: at t=0.99 they are ~0.012% of the width
|
||||
// open, so a 1% pad forgives them and still fails genuinely uncovered ground.
|
||||
let c = [(t[0][0] + t[1][0] + t[2][0]) / 3.0, (t[0][1] + t[1][1] + t[2][1]) / 3.0];
|
||||
let t = t.map(|v| [c[0] + (v[0] - c[0]) * 1.01, c[1] + (v[1] - c[1]) * 1.01]);
|
||||
let (d1, d2, d3) = (sign(p, t[0], t[1]), sign(p, t[1], t[2]), sign(p, t[2], t[0]));
|
||||
!((d1 < 0.0 || d2 < 0.0 || d3 < 0.0) && (d1 > 0.0 || d2 > 0.0 || d3 > 0.0))
|
||||
};
|
||||
for aspect in [16.0 / 9.0, 3.0 / 2.0, 4.0 / 3.0, 11.0 / 8.5, 1.0] {
|
||||
// t = 0.99: every cell is settled and the seams are nearly closed.
|
||||
let ls = transition_frame("honeycomb", "", 0.99, aspect);
|
||||
let tris: Vec<[[f64; 2]; 3]> =
|
||||
ls.iter().filter(|l| l.source == Source::New && l.alpha >= 1.0).map(|l| [l.quad[0], l.quad[1], l.quad[2]]).collect();
|
||||
assert!(!tris.is_empty());
|
||||
let mut sy = 0.0;
|
||||
while sy <= 1.0 {
|
||||
for sx in [0.0, 0.2, 0.4, 0.6, 0.8, 0.99, 1.0] {
|
||||
let p = [sx, sy];
|
||||
assert!(tris.iter().any(|t| inside(p, *t)), "honeycomb aspect {aspect} t=0.99: {sx},{sy} is uncovered (no settled cell reaches it)");
|
||||
}
|
||||
sy += 0.02;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #147: exits PowerPoint plays downward leave downward here too.
|
||||
#[test]
|
||||
fn sink_down_and_peek_out_to_bottom_move_down() {
|
||||
for (effect, option) in [("sinkDown", ""), ("peekOut", "b"), ("floatOut", "d")] {
|
||||
let mut a = anim(1, AnimClass::Exit, effect, AnimStart::OnClick);
|
||||
a.option = option.into();
|
||||
let tl = Timeline::new(&slide_with(vec![a]), W, H);
|
||||
let s = tl.state(ShapeId(1), None, 0, 0.5);
|
||||
assert!(s.offset_y > 0.0, "{effect} {option}: {s:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trigger_sequences_keep_first_appearance_order_when_interleaved() {
|
||||
let mut anims = vec![];
|
||||
for (i, tr) in [7u32, 5, 7, 0, 9, 5].iter().enumerate() {
|
||||
let mut a = anim(1 + (i as u32 % 4), AnimClass::Entrance, "fade", AnimStart::OnClick);
|
||||
a.trigger = (*tr != 0).then_some(ShapeId(*tr));
|
||||
anims.push(a);
|
||||
}
|
||||
let tl = Timeline::new(&slide_with(anims), W, H);
|
||||
assert_eq!(tl.trigger_shapes(), vec![ShapeId(7), ShapeId(5), ShapeId(9)]);
|
||||
assert_eq!(tl.trigger_steps(ShapeId(7)), 2);
|
||||
assert_eq!(tl.trigger_steps(ShapeId(5)), 2);
|
||||
assert_eq!(tl.trigger_steps(ShapeId(9)), 1);
|
||||
assert_eq!(tl.steps(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_distinct_triggers_past_the_effect_cap_build_in_order() {
|
||||
// One sequence per animation: grouping used to rescan every animation per trigger
|
||||
// (~1.2e10 steps here), now it is one pass, and nothing past the cap is looked at.
|
||||
let n = 110_000u32;
|
||||
let anims: Vec<Animation> = (0..n)
|
||||
.map(|i| {
|
||||
let mut a = anim(1, AnimClass::Entrance, "appear", AnimStart::OnClick);
|
||||
a.trigger = Some(ShapeId(1000 + i));
|
||||
a
|
||||
})
|
||||
.collect();
|
||||
let tl = Timeline::new(&slide_with(anims), W, H);
|
||||
assert_eq!(tl.effects().len(), 100_000);
|
||||
let shapes = tl.trigger_shapes();
|
||||
assert_eq!(shapes.len(), 100_000);
|
||||
assert!(shapes.iter().enumerate().all(|(i, s)| *s == ShapeId(1000 + i as u32)));
|
||||
}
|
||||
|
||||
/// #316: the same text in two shapes that differ otherwise (a title slide's big centred title,
|
||||
/// a content slide's title) morphs word by word with the default Objects option, instead of
|
||||
/// the old box cross-fading into the new one.
|
||||
#[test]
|
||||
fn same_text_morphs_even_by_objects() {
|
||||
let p = deckcraft_model::defaults::new_presentation(None);
|
||||
let titled = |id, x, size: f64, text: &str| {
|
||||
let mut body = TextBody::from_text(text);
|
||||
for para in &mut body.paragraphs {
|
||||
for r in &mut para.runs {
|
||||
r.props.size = Some(size);
|
||||
}
|
||||
}
|
||||
Shape { xfrm: Some(Xfrm::new(x, 100.0, 600.0, 120.0)), text: Some(body), ..shape(id, "Title 1") }
|
||||
};
|
||||
let mut a = Slide::default();
|
||||
let mut b = Slide::default();
|
||||
a.shapes = vec![titled(1, 100.0, 60.0, "TEST")];
|
||||
b.shapes = vec![titled(2, 40.0, 44.0, "TEST")];
|
||||
let f = morph_frame(&p, &a, &b, 0.5, &|_| false);
|
||||
assert_eq!(f.text.len(), 1, "the shared text morphs");
|
||||
assert!(!f.text[0].chars);
|
||||
// Different texts still cross-fade with the Objects option.
|
||||
b.shapes = vec![titled(2, 40.0, 44.0, "Other")];
|
||||
assert!(morph_frame(&p, &a, &b, 0.5, &|_| false).text.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_in_the_click_sequence_cues_and_does_not_hold_the_click() {
|
||||
// #116: a video set to play in the click sequence starts with its step, and the step doesn't
|
||||
// last as long as the video (the next click goes on while it plays).
|
||||
let play = Animation { duration_ms: 5000, ..anim(1, AnimClass::Media, "play", AnimStart::OnClick) };
|
||||
let tl = Timeline::new(&slide_with(vec![play.clone(), anim(2, AnimClass::Exit, "fadeOut", AnimStart::OnClick)]), W, H);
|
||||
assert_eq!(tl.steps(), 2);
|
||||
assert_eq!(tl.step_duration(0), 0.0);
|
||||
let cues = tl.media_cues(0, f64::NEG_INFINITY, 0.0);
|
||||
assert_eq!(cues.iter().map(|e| (e.shape, e.anim)).collect::<Vec<_>>(), [(ShapeId(1), 0)]);
|
||||
assert!(tl.media_cues(0, 0.0, f64::INFINITY).is_empty(), "set off once");
|
||||
assert!(tl.media_cues(1, f64::NEG_INFINITY, f64::INFINITY).is_empty(), "only media effects");
|
||||
// An After Previous effect still waits for the video.
|
||||
let tl = Timeline::new(&slide_with(vec![play, anim(2, AnimClass::Entrance, "fade", AnimStart::AfterPrevious)]), W, H);
|
||||
assert!((tl.step_duration(0) - 6.0).abs() < 1e-9, "{}", tl.step_duration(0));
|
||||
}
|
||||
|
||||
/// Zoom with Return to Zoom: after the zoomed slides the show goes back to the zoom slide, fully
|
||||
/// built; without it, or after jumping elsewhere, the show carries on in order.
|
||||
#[test]
|
||||
fn show_returns_to_the_zoom_slide() {
|
||||
use deckcraft_model::text::{Action, Hyperlink};
|
||||
let p = pres(6);
|
||||
let link = |to: u32, last: Option<u32>| Hyperlink {
|
||||
action: Action::Slide { slide: SlideId(1000 + to) },
|
||||
tooltip: String::new(),
|
||||
highlight_click: false,
|
||||
zoom_return: last.map(|l| SlideId(1000 + l)),
|
||||
sound: None,
|
||||
stop_sound: false,
|
||||
};
|
||||
let mut s = ShowState::new(&p);
|
||||
// A section zoom on slide 0 for slides 2–3.
|
||||
assert_eq!(s.follow_link(&p, &link(2, Some(3)), None), LinkJump::Show(ShowAction::Slide(2)));
|
||||
assert_eq!(s.next(&p), ShowAction::Slide(3));
|
||||
assert_eq!(s.next(&p), ShowAction::Slide(0), "back to the zoom");
|
||||
assert_eq!(s.step, ShowState::steps(&p, 0));
|
||||
assert_eq!(s.next(&p), ShowAction::Slide(1), "then on in order");
|
||||
// Without Return to Zoom the show carries on.
|
||||
let mut s = ShowState::new(&p);
|
||||
s.follow_link(&p, &link(4, None), None);
|
||||
assert_eq!(s.next(&p), ShowAction::Slide(5));
|
||||
// Jumping elsewhere (See All Slides, a number) forgets the zoom.
|
||||
let mut s = ShowState::new(&p);
|
||||
s.follow_link(&p, &link(2, Some(2)), None);
|
||||
s.goto(&p, 4);
|
||||
assert_eq!(s.next(&p), ShowAction::Slide(5));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
//! Clicks, triggered animations and what the show remembers about visited slides.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::text::Action;
|
||||
use deckcraft_model::{AnimClass, AnimStart, Animation, Presentation, Shape, ShapeId, Slide, SlideId, Transition, Xfrm};
|
||||
|
||||
use crate::*;
|
||||
|
||||
fn shape(id: u32) -> Shape {
|
||||
Shape { id: ShapeId(id), name: format!("S{id}"), xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)), ..Default::default() }
|
||||
}
|
||||
|
||||
/// A triggered exit (`Disappear` when `ms` is 0, else a fade out) of `target`, started by clicking `trigger`.
|
||||
fn exit_on_click(trigger: u32, target: u32, ms: u32) -> Animation {
|
||||
let effect = if ms == 0 { "disappear" } else { "fadeOut" };
|
||||
Animation {
|
||||
shape: ShapeId(target),
|
||||
class: AnimClass::Exit,
|
||||
effect: effect.into(),
|
||||
start: AnimStart::OnClick,
|
||||
duration_ms: ms,
|
||||
trigger: Some(ShapeId(trigger)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn slide(id: u32, shapes: Vec<Shape>, animations: Vec<Animation>, advance_on_click: bool) -> Arc<Slide> {
|
||||
let transition = (!advance_on_click).then(|| Transition { advance_on_click: false, ..Default::default() });
|
||||
Arc::new(Slide { id: SlideId(id), shapes, animations, transition, ..Default::default() })
|
||||
}
|
||||
|
||||
fn to_slide(n: u32) -> Option<Action> {
|
||||
Some(Action::Slide { slide: SlideId(n) })
|
||||
}
|
||||
|
||||
fn hit(id: u32, link: Option<Action>) -> HitShape {
|
||||
HitShape { id: ShapeId(id), parent: None, is_group: false, link, under_pointer: true }
|
||||
}
|
||||
|
||||
/// Test driver: a show plus the click helpers the slide show window uses.
|
||||
struct Run {
|
||||
pres: Presentation,
|
||||
show: ShowState,
|
||||
/// Hit shapes of each slide, back to front.
|
||||
hits: Vec<Vec<HitShape>>,
|
||||
now: f64,
|
||||
}
|
||||
|
||||
impl Run {
|
||||
fn new(pres: Presentation, hits: Vec<Vec<HitShape>>) -> Run {
|
||||
let show = ShowState::new(&pres);
|
||||
Run { pres, show, hits, now: 0.0 }
|
||||
}
|
||||
|
||||
fn tl(&self) -> Timeline {
|
||||
ShowState::timeline(&self.pres, self.show.slide)
|
||||
}
|
||||
|
||||
fn visible(&self, id: ShapeId) -> bool {
|
||||
let st = self.show.anim_state(&self.tl(), id, 0.0);
|
||||
st.visible && st.opacity > 0.0
|
||||
}
|
||||
|
||||
fn opacity(&self, id: u32) -> f64 {
|
||||
self.show.anim_state(&self.tl(), ShapeId(id), 0.0).opacity
|
||||
}
|
||||
|
||||
fn click(&mut self) -> Click {
|
||||
let (tl, hits) = (self.tl(), self.hits[self.show.slide].clone());
|
||||
let vis: Vec<(ShapeId, bool)> = hits.iter().map(|h| (h.id, self.visible(h.id))).collect();
|
||||
let visible = move |id: ShapeId| vis.iter().find(|v| v.0 == id).is_none_or(|v| v.1);
|
||||
self.show.click(&self.pres, &tl, &hits, &visible, self.now, None)
|
||||
}
|
||||
|
||||
fn target(&self) -> Option<Target> {
|
||||
let (tl, hits) = (self.tl(), &self.hits[self.show.slide]);
|
||||
self.show.target(&self.pres, &tl, hits, &|id| self.visible(id))
|
||||
}
|
||||
|
||||
/// Let `secs` pass on the shown slide, in small frames.
|
||||
fn wait(&mut self, secs: f64) {
|
||||
let end = self.now + secs;
|
||||
while self.now < end - 1e-9 {
|
||||
self.now = (self.now + 0.05).min(end);
|
||||
let tl = self.tl();
|
||||
self.show.tick(&tl, self.now);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The test deck: slide 1 has a text box (3), a linked button that also disappears (2) and a
|
||||
/// picture on top of both that disappears (4); slide 2 has a button back to slide 1.
|
||||
fn two_slides(advance_on_click: bool) -> Run {
|
||||
let s1 = slide(1000, vec![shape(3), shape(2), shape(4)], vec![exit_on_click(4, 4, 0), exit_on_click(2, 2, 0)], advance_on_click);
|
||||
let s2 = slide(1001, vec![shape(1)], vec![], advance_on_click);
|
||||
let pres = Presentation { slides: vec![s1, s2], ..Default::default() };
|
||||
Run::new(pres, vec![vec![hit(3, None), hit(2, to_slide(1001)), hit(4, None)], vec![hit(1, to_slide(1000))]])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_shapes_stay_hidden_when_the_show_comes_back() {
|
||||
let mut r = two_slides(false);
|
||||
assert_eq!(r.target().map(|t| t.shape), Some(ShapeId(4)), "the picture is on top");
|
||||
// Click the picture: it disappears; the button beneath it is now the target.
|
||||
assert_eq!(r.click(), Click::Animated);
|
||||
assert!(!r.visible(ShapeId(4)));
|
||||
assert_eq!(r.target().map(|t| t.shape), Some(ShapeId(2)));
|
||||
// Click the button: its animation starts and the link is followed.
|
||||
assert_eq!(r.click(), Click::Show(ShowAction::Slide(1)));
|
||||
assert_eq!(r.show.slide, 1);
|
||||
// Slide 2: the button goes back.
|
||||
assert_eq!(r.click(), Click::Show(ShowAction::Slide(0)));
|
||||
assert_eq!(r.show.slide, 0);
|
||||
// Both are still gone, and nothing on the slide can be clicked any more.
|
||||
assert!(!r.visible(ShapeId(4)) && !r.visible(ShapeId(2)) && r.visible(ShapeId(3)));
|
||||
assert_eq!(r.target(), None);
|
||||
// "Advance on click" is off: a click anywhere does nothing, however often.
|
||||
for _ in 0..3 {
|
||||
assert_eq!(r.click(), Click::None);
|
||||
}
|
||||
assert_eq!(r.show.slide, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_click_advances_when_the_slide_allows_it() {
|
||||
let mut r = two_slides(true);
|
||||
r.click(); // the picture disappears
|
||||
r.click(); // the button disappears and links to slide 2
|
||||
r.click(); // the button there links back
|
||||
assert_eq!(r.show.slide, 0);
|
||||
assert_eq!(r.target(), None);
|
||||
assert_eq!(r.click(), Click::Show(ShowAction::Slide(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advance_on_click_off_still_plays_animation_steps() {
|
||||
let main = Animation { shape: ShapeId(1), class: AnimClass::Entrance, effect: "fade".into(), duration_ms: 500, ..Default::default() };
|
||||
let s1 = slide(1000, vec![shape(1)], vec![main], false);
|
||||
let s2 = slide(1001, vec![], vec![], false);
|
||||
let pres = Presentation { slides: vec![s1, s2], ..Default::default() };
|
||||
let mut r = Run::new(pres, vec![vec![hit(1, None)], vec![]]);
|
||||
// The first click plays the step, the next finishes it; then the slide stays.
|
||||
assert!(matches!(r.click(), Click::Show(ShowAction::PlayStep(0))));
|
||||
assert!(matches!(r.click(), Click::Show(ShowAction::FinishStep(0))));
|
||||
assert_eq!(r.click(), Click::None);
|
||||
assert_eq!(r.show.slide, 0);
|
||||
// The arrow keys still move on.
|
||||
assert_eq!(r.show.next(&r.pres), ShowAction::Slide(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shape_without_an_action_blocks_the_one_beneath_it() {
|
||||
let s = slide(1000, vec![shape(1), shape(2)], vec![], true);
|
||||
let pres = Presentation { slides: vec![s, slide(1001, vec![], vec![], true)], ..Default::default() };
|
||||
let mut r = Run::new(pres, vec![vec![hit(1, to_slide(1001)), hit(2, None)], vec![]]);
|
||||
assert_eq!(r.target(), None, "the plain shape on top blocks the button");
|
||||
// So the click is a background click, not the button's link.
|
||||
assert_eq!(r.click(), Click::Show(ShowAction::Slide(1)));
|
||||
// Reversed, the button is on top and is the target.
|
||||
let s = slide(1000, vec![shape(2), shape(1)], vec![], true);
|
||||
let pres = Presentation { slides: vec![s, slide(1001, vec![], vec![], true)], ..Default::default() };
|
||||
let r = Run::new(pres, vec![vec![hit(2, None), hit(1, to_slide(1001))], vec![]]);
|
||||
assert_eq!(r.target().map(|t| t.shape), Some(ShapeId(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groups_answer_for_their_children() {
|
||||
let s = slide(1000, vec![shape(1)], vec![], true);
|
||||
let pres = Presentation { slides: vec![s, slide(1001, vec![], vec![], true)], ..Default::default() };
|
||||
let group = |link| HitShape { id: ShapeId(10), parent: None, is_group: true, link, under_pointer: true };
|
||||
let child = HitShape { id: ShapeId(11), parent: Some(1), is_group: false, link: None, under_pointer: true };
|
||||
// A child of a group with an action acts as the group.
|
||||
let r = Run::new(pres.clone(), vec![vec![hit(5, None), group(to_slide(1001)), child.clone()], vec![]]);
|
||||
assert_eq!(r.target().map(|t| t.shape), Some(ShapeId(10)));
|
||||
// Empty space inside the box of a group without an action blocks nothing.
|
||||
let behind = hit(5, to_slide(1001));
|
||||
let r = Run::new(pres, vec![vec![behind, group(None)], vec![]]);
|
||||
assert_eq!(r.target().map(|t| t.shape), Some(ShapeId(5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pointer_outside_a_shape_is_not_on_it() {
|
||||
let s = slide(1000, vec![shape(1)], vec![], true);
|
||||
let pres = Presentation { slides: vec![s], ..Default::default() };
|
||||
let mut h = hit(1, to_slide(1000));
|
||||
h.under_pointer = false;
|
||||
let r = Run::new(pres, vec![vec![h]]);
|
||||
assert_eq!(r.target(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_click_plays_one_step_of_a_trigger_and_a_click_completes_a_playing_step() {
|
||||
// Clicking shape 1 fades shape 2 out over 5 s, then shape 3 over 5 s.
|
||||
let s = slide(1000, vec![shape(1), shape(2), shape(3)], vec![exit_on_click(1, 2, 5000), exit_on_click(1, 3, 5000)], true);
|
||||
let pres = Presentation { slides: vec![s], ..Default::default() };
|
||||
let mut r = Run::new(pres, vec![vec![hit(2, None), hit(3, None), hit(1, None)]]);
|
||||
assert_eq!(r.target().map(|t| t.shape), Some(ShapeId(1)));
|
||||
assert_eq!(r.click(), Click::Animated);
|
||||
r.wait(2.0);
|
||||
assert!(r.show.trigger_playing());
|
||||
assert!(r.opacity(2) < 1.0 && r.opacity(2) > 0.0, "part way through the fade");
|
||||
assert!(r.visible(ShapeId(3)));
|
||||
// A click on the shape while its step plays completes the step at once...
|
||||
assert_eq!(r.click(), Click::Animated);
|
||||
assert!(!r.visible(ShapeId(2)) && !r.show.trigger_playing());
|
||||
// ...and the next click plays the next step.
|
||||
assert_eq!(r.click(), Click::Animated);
|
||||
r.wait(5.2);
|
||||
assert!(!r.visible(ShapeId(3)));
|
||||
// All steps have played, and the shape still answers: its sequence starts over.
|
||||
assert_eq!(r.target().map(|t| t.shape), Some(ShapeId(1)));
|
||||
assert_eq!(r.click(), Click::Animated);
|
||||
assert!(r.visible(ShapeId(2)) && r.visible(ShapeId(3)), "the first lap's effects are undone");
|
||||
r.wait(5.2);
|
||||
assert!(!r.visible(ShapeId(2)) && r.visible(ShapeId(3)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ring_of_triggers_goes_round_and_round() {
|
||||
// Soccer's score: clicking shape k hides it and shows shape k+1; the last shows the first again.
|
||||
let ring = 4u32;
|
||||
let anims: Vec<Animation> = (1..=ring)
|
||||
.flat_map(|k| {
|
||||
let next = k % ring + 1;
|
||||
let show = Animation {
|
||||
shape: ShapeId(next),
|
||||
class: AnimClass::Entrance,
|
||||
effect: "appear".into(),
|
||||
start: AnimStart::WithPrevious,
|
||||
duration_ms: 0,
|
||||
trigger: Some(ShapeId(k)),
|
||||
..Default::default()
|
||||
};
|
||||
[exit_on_click(k, k, 0), show]
|
||||
})
|
||||
.collect();
|
||||
let shapes: Vec<Shape> = (1..=ring).map(shape).collect();
|
||||
let s = slide(1000, shapes, anims, true);
|
||||
let pres = Presentation { slides: vec![s], ..Default::default() };
|
||||
let hits: Vec<HitShape> = (1..=ring).map(|k| hit(k, None)).collect();
|
||||
let mut r = Run::new(pres, vec![hits]);
|
||||
let showing = |r: &Run| -> Vec<u32> { (1..=ring).filter(|k| r.visible(ShapeId(*k))).collect() };
|
||||
assert_eq!(showing(&r), vec![1]);
|
||||
for lap in 0..3 {
|
||||
for k in 1..=ring {
|
||||
assert_eq!(showing(&r), vec![k], "lap {lap}, before click {k}");
|
||||
assert_eq!(r.target().map(|t| t.shape), Some(ShapeId(k)));
|
||||
assert_eq!(r.click(), Click::Animated);
|
||||
}
|
||||
}
|
||||
assert_eq!(showing(&r), vec![1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_is_followed_at_once_and_the_animation_goes_on_where_it_stopped() {
|
||||
// Shape 1 fades shape 4 over 5 s; shape 2 links to slide 2.
|
||||
let s1 = slide(1000, vec![shape(1), shape(2), shape(4)], vec![exit_on_click(1, 4, 5000)], false);
|
||||
let s2 = slide(1001, vec![shape(5)], vec![], false);
|
||||
let pres = Presentation { slides: vec![s1, s2], ..Default::default() };
|
||||
let mut r = Run::new(pres, vec![vec![hit(4, None), hit(2, to_slide(1001)), hit(1, None)], vec![hit(5, to_slide(1000))]]);
|
||||
// Click the animated shape, wait 2 s, click the link.
|
||||
r.click();
|
||||
r.wait(2.0);
|
||||
assert_eq!(r.show.trigger_plays()[0].t.round(), 2.0);
|
||||
// The second shape is not over the first one, so click the link directly.
|
||||
let tl = r.tl();
|
||||
let hits = vec![hit(2, to_slide(1001))];
|
||||
assert_eq!(r.show.click(&r.pres, &tl, &hits, &|_| true, r.now, None), Click::Show(ShowAction::Slide(1)));
|
||||
// However long the show stays away, the animation does not run.
|
||||
r.wait(60.0);
|
||||
assert_eq!(r.click(), Click::Show(ShowAction::Slide(0)));
|
||||
// Back on the slide it has 3 s left.
|
||||
assert!((r.show.trigger_plays()[0].t - 2.0).abs() < 0.1);
|
||||
assert!(r.show.trigger_playing());
|
||||
r.wait(2.5);
|
||||
assert!(r.show.trigger_playing() && r.opacity(4) > 0.0);
|
||||
r.wait(1.0);
|
||||
assert!(!r.show.trigger_playing() && !r.visible(ShapeId(4)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shape_with_a_link_and_an_animation_follows_the_link_without_waiting() {
|
||||
let s1 = slide(1000, vec![shape(2)], vec![exit_on_click(2, 2, 5000)], false);
|
||||
let s2 = slide(1001, vec![shape(5)], vec![], false);
|
||||
let pres = Presentation { slides: vec![s1, s2], ..Default::default() };
|
||||
let mut r = Run::new(pres, vec![vec![hit(2, to_slide(1001))], vec![hit(5, to_slide(1000))]]);
|
||||
assert_eq!(r.click(), Click::Show(ShowAction::Slide(1)));
|
||||
r.wait(30.0);
|
||||
assert_eq!(r.click(), Click::Show(ShowAction::Slide(0)));
|
||||
// The whole animation is still ahead.
|
||||
assert!(r.visible(ShapeId(2)) && r.opacity(2) > 0.95);
|
||||
assert!(r.show.trigger_playing());
|
||||
r.wait(5.5);
|
||||
assert!(!r.visible(ShapeId(2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_slide_comes_back_the_way_it_was_left() {
|
||||
let steps: Vec<Animation> = (1..=3)
|
||||
.map(|i| Animation { shape: ShapeId(i), class: AnimClass::Entrance, effect: "appear".into(), duration_ms: 0, ..Default::default() })
|
||||
.collect();
|
||||
let s1 = slide(1000, vec![shape(1), shape(2), shape(3)], steps, true);
|
||||
let s2 = slide(1001, vec![], vec![], true);
|
||||
let s3 = slide(1002, vec![], vec![], true);
|
||||
let pres = Presentation { slides: vec![s1, s2, s3], ..Default::default() };
|
||||
let mut s = ShowState::new(&pres);
|
||||
s.next(&pres);
|
||||
s.step_done(&pres);
|
||||
assert_eq!(s.step, 1);
|
||||
// A link away and back: still after step 1.
|
||||
assert_eq!(s.follow(&pres, &Action::Slide { slide: SlideId(1002) }, None), LinkJump::Show(ShowAction::Slide(2)));
|
||||
assert_eq!(s.follow(&pres, &Action::Slide { slide: SlideId(1000) }, None), LinkJump::Show(ShowAction::Slide(0)));
|
||||
assert_eq!((s.step, s.playing), (1, false));
|
||||
// So does the back arrow.
|
||||
s.goto(&pres, 1);
|
||||
assert_eq!(s.prev(&pres), ShowAction::Slide(0));
|
||||
assert_eq!(s.step, 1);
|
||||
// A slide the show has not been to is entered at its start; going back to one shows it fully
|
||||
// built.
|
||||
let mut s = ShowState::new(&pres);
|
||||
s.goto(&pres, 2);
|
||||
assert_eq!(s.prev(&pres), ShowAction::Slide(1));
|
||||
assert_eq!(s.step, ShowState::steps(&pres, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_playing_main_step_is_finished_when_the_show_leaves() {
|
||||
let a = Animation { shape: ShapeId(1), class: AnimClass::Entrance, effect: "fade".into(), duration_ms: 500, ..Default::default() };
|
||||
let s1 = slide(1000, vec![shape(1)], vec![a], true);
|
||||
let s2 = slide(1001, vec![], vec![], true);
|
||||
let pres = Presentation { slides: vec![s1, s2], ..Default::default() };
|
||||
let mut s = ShowState::new(&pres);
|
||||
assert_eq!(s.next(&pres), ShowAction::PlayStep(0));
|
||||
s.goto(&pres, 1);
|
||||
s.goto(&pres, 0);
|
||||
assert_eq!((s.step, s.playing), (1, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_without_animation_ignores_triggers() {
|
||||
let s = slide(1000, vec![shape(1), shape(2)], vec![exit_on_click(1, 2, 0)], true);
|
||||
let mut pres = Presentation { slides: vec![s], ..Default::default() };
|
||||
pres.show.without_animation = true;
|
||||
let r = Run::new(pres, vec![vec![hit(2, None), hit(1, None)]]);
|
||||
assert_eq!(r.target(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clocks_and_pointers_with_hostile_numbers_are_harmless() {
|
||||
let mut r = two_slides(true);
|
||||
let tl = r.tl();
|
||||
for now in [f64::NAN, f64::INFINITY, -1.0e300, 1.0e300, 0.0] {
|
||||
r.show.tick(&tl, now);
|
||||
let _ = r.show.click(&r.pres, &tl, &[], &|_| true, now, None);
|
||||
}
|
||||
// A parent index that points nowhere, or in a circle, is not followed forever.
|
||||
let bad = vec![
|
||||
HitShape { id: ShapeId(1), parent: Some(7), is_group: true, link: None, under_pointer: true },
|
||||
HitShape { id: ShapeId(2), parent: Some(2), is_group: false, link: None, under_pointer: true },
|
||||
];
|
||||
assert_eq!(r.show.target(&r.pres, &tl, &bad, &|_| true), None);
|
||||
}
|
||||
+99
-30
@@ -1,6 +1,8 @@
|
||||
//! A slide's animation sequence: click steps, effect start times and per-shape state over time.
|
||||
|
||||
use deckcraft_model::anim::TextBuild;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use deckcraft_model::anim::{BuildRole, TextBuild, paragraph_build_roles};
|
||||
use deckcraft_model::style::Fill;
|
||||
use deckcraft_model::{AnimClass, AnimStart, Animation, ColorScheme, Rgba, SchemeSlot, ShapeId, Slide, Xfrm};
|
||||
|
||||
@@ -10,7 +12,6 @@ use crate::effects::{EffectSpec, exit_base, motion_path};
|
||||
/// Delay between paragraphs of a by-paragraph build that isn't on click (seconds).
|
||||
pub const PARAGRAPH_STAGGER: f64 = 0.5;
|
||||
const MAX_EFFECTS: usize = 100_000;
|
||||
const MAX_PARAGRAPHS: usize = 1_000;
|
||||
const MAX_ITERATIONS: f64 = 1.0e6;
|
||||
|
||||
/// Which sequence an effect belongs to.
|
||||
@@ -57,6 +58,16 @@ pub struct TriggerPlay {
|
||||
pub t: f64,
|
||||
}
|
||||
|
||||
/// When a step of a triggered sequence was started, counted in started steps on the slide. Effects
|
||||
/// of different triggers apply in this order, so that a shape hidden by one trigger and shown again
|
||||
/// by another ends up as the later of the two left it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct StepStamp {
|
||||
pub trigger: ShapeId,
|
||||
pub step: usize,
|
||||
pub at: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct StepInfo {
|
||||
auto: bool,
|
||||
@@ -151,25 +162,31 @@ impl Timeline {
|
||||
let accent = scheme.map(|s| s.get(SchemeSlot::Accent1)).unwrap_or(Rgba::rgb(0x15, 0x60, 0x82));
|
||||
|
||||
let mut tl = Timeline::default();
|
||||
// Sequences in order of first appearance: main first.
|
||||
let mut seqs: Vec<Sequence> = vec![Sequence::Main];
|
||||
for a in &slide.animations {
|
||||
if let Some(tr) = a.trigger {
|
||||
let s = Sequence::Trigger(tr);
|
||||
if !seqs.contains(&s) {
|
||||
seqs.push(s);
|
||||
}
|
||||
// Group the animations by sequence in one pass, sequences in order of first appearance
|
||||
// (main first), so many distinct triggers cost linear work, not a scan per trigger.
|
||||
let mut seqs: Vec<(Sequence, Vec<usize>)> = vec![(Sequence::Main, Vec::new())];
|
||||
let mut seq_index: HashMap<Sequence, usize> = HashMap::from([(Sequence::Main, 0)]);
|
||||
for (ai, a) in slide.animations.iter().enumerate() {
|
||||
let s = a.trigger.map(Sequence::Trigger).unwrap_or(Sequence::Main);
|
||||
let i = *seq_index.entry(s).or_insert_with(|| {
|
||||
seqs.push((s, Vec::new()));
|
||||
seqs.len() - 1
|
||||
});
|
||||
if let Some((_, members)) = seqs.get_mut(i) {
|
||||
members.push(ai);
|
||||
}
|
||||
}
|
||||
for seq in seqs {
|
||||
// Paragraph groups already recorded (the list itself keeps their order).
|
||||
let mut grouped: HashSet<(ShapeId, usize, usize)> = HashSet::new();
|
||||
for (seq, members) in seqs {
|
||||
if tl.effects.len() >= MAX_EFFECTS {
|
||||
break;
|
||||
}
|
||||
let mut steps: Vec<StepInfo> = Vec::new();
|
||||
let mut group_begin = 0.0f64;
|
||||
let mut max_end = 0.0f64;
|
||||
for (ai, a) in slide.animations.iter().enumerate() {
|
||||
let aseq = a.trigger.map(Sequence::Trigger).unwrap_or(Sequence::Main);
|
||||
if aseq != seq {
|
||||
continue;
|
||||
}
|
||||
for ai in members {
|
||||
let Some(a) = slide.animations.get(ai) else { continue };
|
||||
if tl.effects.len() >= MAX_EFFECTS {
|
||||
break;
|
||||
}
|
||||
@@ -180,17 +197,15 @@ impl Timeline {
|
||||
(None, TextBuild::ByParagraph) => {
|
||||
let mut v: Vec<Option<usize>> = vec![];
|
||||
if let Some(t) = shape.and_then(|s| s.text.as_ref()) {
|
||||
let filled = |p: &deckcraft_model::text::Paragraph| p.runs.iter().any(|r| !r.text.trim().is_empty());
|
||||
let top = t.paragraphs.iter().filter(|p| filled(p)).map(|p| p.level).min().unwrap_or(0);
|
||||
let mut owner: Option<usize> = None;
|
||||
for (i, p) in t.paragraphs.iter().enumerate() {
|
||||
if p.level <= top && filled(p) && v.len() < MAX_PARAGRAPHS {
|
||||
owner = Some(i);
|
||||
v.push(Some(i));
|
||||
} else if let Some(o) = owner
|
||||
&& !tl.groups.contains(&(a.shape, i, o))
|
||||
{
|
||||
tl.groups.push((a.shape, i, o));
|
||||
for (i, role) in paragraph_build_roles(t).into_iter().enumerate() {
|
||||
match role {
|
||||
BuildRole::Top => v.push(Some(i)),
|
||||
BuildRole::Member(o) => {
|
||||
if grouped.insert((a.shape, i, o)) {
|
||||
tl.groups.push((a.shape, i, o));
|
||||
}
|
||||
}
|
||||
BuildRole::Unowned => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -249,7 +264,9 @@ impl Timeline {
|
||||
max_end = max_end.max(end);
|
||||
let step = steps.len().saturating_sub(1);
|
||||
if let Some(s) = steps.last_mut() {
|
||||
s.duration = s.duration.max(end);
|
||||
// Media plays on by itself once started: it holds back an After Previous
|
||||
// effect (`max_end`), but not the next click (#116).
|
||||
s.duration = s.duration.max(if a.class == AnimClass::Media { start } else { end });
|
||||
}
|
||||
tl.effects.push(Effect {
|
||||
timing: EffectTiming {
|
||||
@@ -308,6 +325,20 @@ impl Timeline {
|
||||
self.main.get(i).map(|s| s.duration).unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// Media effects (Play, Pause, Stop) of main step `step` that start after `from` and by `to`
|
||||
/// seconds into it, in order: what a show running the step from `from` to `to` sets off.
|
||||
pub fn media_cues(&self, step: usize, from: f64, to: f64) -> Vec<EffectTiming> {
|
||||
let mut v: Vec<EffectTiming> = self
|
||||
.effects
|
||||
.iter()
|
||||
.map(|e| &e.timing)
|
||||
.filter(|e| e.class == AnimClass::Media && e.sequence == Sequence::Main && e.step == step && e.start > from && e.start <= to)
|
||||
.cloned()
|
||||
.collect();
|
||||
v.sort_by(|a, b| a.start.total_cmp(&b.start));
|
||||
v
|
||||
}
|
||||
|
||||
/// Total duration of the main sequence played without pauses.
|
||||
pub fn total_duration(&self) -> f64 {
|
||||
self.main.iter().map(|s| s.duration).sum()
|
||||
@@ -325,6 +356,19 @@ impl Timeline {
|
||||
(self.main.len(), 0.0)
|
||||
}
|
||||
|
||||
/// Where effect `anim` (index into `slide.animations`) starts on the play-everything clock:
|
||||
/// Animation Pane ▸ Play From. `None` for a triggered effect or one that isn't on the slide.
|
||||
pub fn start_of(&self, anim: usize) -> Option<f64> {
|
||||
let e = self
|
||||
.effects
|
||||
.iter()
|
||||
.map(|e| &e.timing)
|
||||
.filter(|e| e.anim == anim && e.sequence == Sequence::Main)
|
||||
.min_by(|a, b| a.step.cmp(&b.step).then(a.start.total_cmp(&b.start)))?;
|
||||
let before: f64 = self.main.iter().take(e.step).map(|s| s.duration).sum();
|
||||
Some(before + e.start)
|
||||
}
|
||||
|
||||
/// Shapes that start an interactive sequence when clicked.
|
||||
pub fn trigger_shapes(&self) -> Vec<ShapeId> {
|
||||
self.triggers.iter().map(|(s, _)| *s).collect()
|
||||
@@ -379,6 +423,21 @@ impl Timeline {
|
||||
|
||||
/// [`Timeline::state`] with the playheads of triggered sequences.
|
||||
pub fn state_with(&self, shape: ShapeId, para: Option<usize>, step: usize, t: f64, triggers: &[TriggerPlay]) -> AnimState {
|
||||
self.state_ordered(shape, para, step, t, triggers, &[])
|
||||
}
|
||||
|
||||
/// [`Timeline::state_with`] applying the effects of triggered sequences in the order their steps
|
||||
/// were started (`stamps`), after those of the main sequence. Without stamps they apply in
|
||||
/// Animation Pane order.
|
||||
pub fn state_ordered(
|
||||
&self,
|
||||
shape: ShapeId,
|
||||
para: Option<usize>,
|
||||
step: usize,
|
||||
t: f64,
|
||||
triggers: &[TriggerPlay],
|
||||
stamps: &[StepStamp],
|
||||
) -> AnimState {
|
||||
let para = para.map(|p| self.groups.iter().find(|g| g.0 == shape && g.1 == p).map(|g| g.2).unwrap_or(p));
|
||||
let mine = self.effects.iter().filter(|e| e.timing.shape == shape && e.timing.paragraph == para);
|
||||
let base_color = self.colors.iter().find(|(s, _)| *s == shape).map(|(_, c)| *c).unwrap_or(Rgba::rgb(0x15, 0x60, 0x82));
|
||||
@@ -391,7 +450,16 @@ impl Timeline {
|
||||
{
|
||||
st.visible = false;
|
||||
}
|
||||
// Then play in time order: main sequence first, then triggered sequences.
|
||||
// Then play in time order: main sequence first, then triggered sequences as they were clicked.
|
||||
if !stamps.is_empty() {
|
||||
let when = |e: &Effect| match e.timing.sequence {
|
||||
Sequence::Main => 0,
|
||||
Sequence::Trigger(tr) => {
|
||||
stamps.iter().find(|s| s.trigger == tr && s.step == e.timing.step).map_or(u64::MAX, |s| s.at.saturating_add(1))
|
||||
}
|
||||
};
|
||||
ordered.sort_by_key(|e| (when(e), e.timing.anim));
|
||||
}
|
||||
for e in &ordered {
|
||||
let (pstep, pt) = match e.timing.sequence {
|
||||
Sequence::Main => (step, t),
|
||||
@@ -471,7 +539,8 @@ fn build_spec(a: &Animation, xf: Xfrm, sw: f64, sh: f64, resolve: &dyn Fn(&deckc
|
||||
EffectSpec {
|
||||
class: a.class,
|
||||
effect,
|
||||
option: a.option.clone(),
|
||||
// Sink Down is Rise Up played backwards, leaving downward.
|
||||
option: if a.effect == "sinkDown" { "d".into() } else { a.option.clone() },
|
||||
accel: a.smooth_start,
|
||||
decel: a.smooth_end,
|
||||
bounce: a.bounce_end,
|
||||
|
||||
+96
-821
@@ -1,15 +1,18 @@
|
||||
//! Slide transitions as layers of textured quads.
|
||||
//!
|
||||
//! A frame is drawn over black: each [`Layer`] maps part of a source image (old slide, new slide,
|
||||
//! or a solid colour) onto a quad in slide-unit space (0..1, y down). Fake 3-D comes from
|
||||
//! trapezoids; wedges are triangles (4th corner repeats the 3rd).
|
||||
|
||||
use std::f64::consts::PI;
|
||||
//! their backgrounds or contents alone, or a solid colour) onto a quad in slide-unit space (0..1, y
|
||||
//! down), with an opacity per corner. Perspective comes from projecting 3-D cards
|
||||
//! ([`crate::layer_mesh`] keeps their textures straight); soft edges come from per-corner opacity
|
||||
//! ramps; wedges are triangles (4th corner repeats the 3rd).
|
||||
//!
|
||||
//! The motions, curves, directions and option meanings were measured frame by frame on
|
||||
//! PowerPoint's video export of synthetic decks (`plan/powerpoint/tools/synth_transitions.ps1`).
|
||||
//! The effects themselves live in [`crate::transition_fx`].
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::clampf;
|
||||
use crate::easing::smooth;
|
||||
|
||||
/// What a layer shows.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
@@ -20,6 +23,35 @@ pub enum Source {
|
||||
New,
|
||||
Black,
|
||||
White,
|
||||
/// The outgoing slide's background (background fill plus master and layout graphics) alone.
|
||||
OldBack,
|
||||
/// The incoming slide's background alone.
|
||||
NewBack,
|
||||
/// The outgoing slide's own shapes alone, over transparency.
|
||||
OldContent,
|
||||
/// The incoming slide's own shapes alone, over transparency.
|
||||
NewContent,
|
||||
}
|
||||
|
||||
impl Source {
|
||||
/// The whole-slide source to draw instead when the parts aren't available: a background
|
||||
/// becomes its slide, content becomes nothing.
|
||||
pub fn whole(self) -> Option<Source> {
|
||||
match self {
|
||||
Source::OldBack => Some(Source::Old),
|
||||
Source::NewBack => Some(Source::New),
|
||||
Source::OldContent | Source::NewContent => None,
|
||||
s => Some(s),
|
||||
}
|
||||
}
|
||||
/// Does this source need the slide rendered in parts (background / content)?
|
||||
pub fn is_part(self) -> bool {
|
||||
matches!(self, Source::OldBack | Source::NewBack | Source::OldContent | Source::NewContent)
|
||||
}
|
||||
}
|
||||
|
||||
fn ones() -> [f64; 4] {
|
||||
[1.0; 4]
|
||||
}
|
||||
|
||||
/// One textured quad of a transition frame.
|
||||
@@ -34,12 +66,18 @@ pub struct Layer {
|
||||
/// are the corners of `src`; triangles and mirrored flaps carry their own. Draw with these.
|
||||
pub uv: [[f64; 2]; 4],
|
||||
pub alpha: f64,
|
||||
/// Opacity of each corner (tl, tr, br, bl), multiplied by `alpha` and interpolated across the
|
||||
/// quad: soft edges and feathered masks.
|
||||
#[serde(default = "ones")]
|
||||
pub fade: [f64; 4],
|
||||
}
|
||||
|
||||
type R = [f64; 4];
|
||||
const FULL: R = [0.0, 0.0, 1.0, 1.0];
|
||||
pub(crate) type R = [f64; 4];
|
||||
pub(crate) type P = [f64; 2];
|
||||
pub(crate) type Q = [[f64; 2]; 4];
|
||||
pub(crate) const FULL: R = [0.0, 0.0, 1.0, 1.0];
|
||||
|
||||
fn corners(r: R) -> [[f64; 2]; 4] {
|
||||
pub(crate) fn corners(r: R) -> Q {
|
||||
[[r[0], r[1]], [r[2], r[1]], [r[2], r[3]], [r[0], r[3]]]
|
||||
}
|
||||
|
||||
@@ -50,23 +88,39 @@ impl Layer {
|
||||
}
|
||||
/// `src` rectangle of the source drawn into the `dst` rectangle.
|
||||
pub fn rect(source: Source, src: R, dst: R, alpha: f64) -> Layer {
|
||||
Layer { source, src, quad: corners(dst), uv: corners(src), alpha }
|
||||
Layer { source, src, quad: corners(dst), uv: corners(src), alpha, fade: ones() }
|
||||
}
|
||||
/// `src` rectangle of the source drawn onto a quad.
|
||||
pub fn quad(source: Source, src: R, quad: [[f64; 2]; 4], alpha: f64) -> Layer {
|
||||
Layer { source, src, quad, uv: corners(src), alpha }
|
||||
pub fn quad(source: Source, src: R, quad: Q, alpha: f64) -> Layer {
|
||||
Layer { source, src, quad, uv: corners(src), alpha, fade: ones() }
|
||||
}
|
||||
/// The part of the source under `dst` drawn in place.
|
||||
fn inplace(source: Source, dst: R, alpha: f64) -> Layer {
|
||||
pub(crate) fn inplace(source: Source, dst: R, alpha: f64) -> Layer {
|
||||
Layer::rect(source, dst, dst, alpha)
|
||||
}
|
||||
/// A triangle of the source drawn in place.
|
||||
fn tri(source: Source, a: [f64; 2], b: [f64; 2], c: [f64; 2], alpha: f64) -> Layer {
|
||||
let xs = [a[0], b[0], c[0]];
|
||||
let ys = [a[1], b[1], c[1]];
|
||||
/// A triangle with its own texture coordinates and corner opacities.
|
||||
pub(crate) fn tri_uv(source: Source, p: [P; 3], uv: [P; 3], fade: [f64; 3], alpha: f64) -> Layer {
|
||||
let xs = [uv[0][0], uv[1][0], uv[2][0]];
|
||||
let ys = [uv[0][1], uv[1][1], uv[2][1]];
|
||||
let mn = |v: [f64; 3]| v.iter().copied().fold(f64::INFINITY, f64::min);
|
||||
let mx = |v: [f64; 3]| v.iter().copied().fold(f64::NEG_INFINITY, f64::max);
|
||||
Layer { source, src: [mn(xs), mn(ys), mx(xs), mx(ys)], quad: [a, b, c, c], uv: [a, b, c, c], alpha }
|
||||
Layer {
|
||||
source,
|
||||
src: [mn(xs), mn(ys), mx(xs), mx(ys)],
|
||||
quad: [p[0], p[1], p[2], p[2]],
|
||||
uv: [uv[0], uv[1], uv[2], uv[2]],
|
||||
alpha,
|
||||
fade: [fade[0], fade[1], fade[2], fade[2]],
|
||||
}
|
||||
}
|
||||
/// With corner opacities.
|
||||
pub(crate) fn faded(mut self, fade: [f64; 4]) -> Layer {
|
||||
self.fade = fade;
|
||||
self
|
||||
}
|
||||
/// A black copy of this layer's shape at `k` of its opacity: shading a card.
|
||||
pub(crate) fn shade(&self, k: f64) -> Layer {
|
||||
Layer { source: Source::Black, alpha: self.alpha * k.clamp(0.0, 1.0), ..*self }
|
||||
}
|
||||
fn sanitized(mut self) -> Layer {
|
||||
let f = |v: f64| if v.is_finite() { v.clamp(-1.0e6, 1.0e6) } else { 0.0 };
|
||||
@@ -81,827 +135,48 @@ impl Layer {
|
||||
c[0] = c[0].clamp(0.0, 1.0);
|
||||
c[1] = c[1].clamp(0.0, 1.0);
|
||||
}
|
||||
for a in self.fade.iter_mut() {
|
||||
*a = clampf(*a, 0.0, 1.0, 0.0);
|
||||
}
|
||||
self.alpha = clampf(self.alpha, 0.0, 1.0, 0.0);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- geometry helpers ----------
|
||||
|
||||
fn offset(r: R, dx: f64, dy: f64) -> R {
|
||||
[r[0] + dx, r[1] + dy, r[2] + dx, r[3] + dy]
|
||||
}
|
||||
|
||||
fn moved(source: Source, dx: f64, dy: f64, alpha: f64) -> Layer {
|
||||
Layer::rect(source, FULL, offset(FULL, dx, dy), alpha)
|
||||
}
|
||||
|
||||
/// The whole source scaled about (cx, cy) and moved.
|
||||
fn scaled(source: Source, s: f64, cx: f64, cy: f64, dx: f64, dy: f64, alpha: f64) -> Layer {
|
||||
let r = [cx + (0.0 - cx) * s + dx, cy + (0.0 - cy) * s + dy, cx + (1.0 - cx) * s + dx, cy + (1.0 - cy) * s + dy];
|
||||
Layer::rect(source, FULL, r, alpha)
|
||||
}
|
||||
|
||||
/// Rotate a point about a pivot (degrees).
|
||||
fn rot(p: [f64; 2], c: [f64; 2], deg: f64) -> [f64; 2] {
|
||||
let (s, co) = deg.to_radians().sin_cos();
|
||||
let (x, y) = (p[0] - c[0], p[1] - c[1]);
|
||||
[c[0] + x * co - y * s, c[1] + x * s + y * co]
|
||||
}
|
||||
|
||||
fn rot_quad(q: [[f64; 2]; 4], c: [f64; 2], deg: f64) -> [[f64; 2]; 4] {
|
||||
[rot(q[0], c, deg), rot(q[1], c, deg), rot(q[2], c, deg), rot(q[3], c, deg)]
|
||||
}
|
||||
|
||||
/// Horizontal perspective: left/right edges shortened by `li`/`ri` (fraction of height, each end).
|
||||
fn persp_h(r: R, li: f64, ri: f64) -> [[f64; 2]; 4] {
|
||||
let h = r[3] - r[1];
|
||||
[[r[0], r[1] + li * h], [r[2], r[1] + ri * h], [r[2], r[3] - ri * h], [r[0], r[3] - li * h]]
|
||||
}
|
||||
|
||||
/// Vertical perspective: top/bottom edges shortened by `ti`/`bi` (fraction of width, each end).
|
||||
fn persp_v(r: R, ti: f64, bi: f64) -> [[f64; 2]; 4] {
|
||||
let w = r[2] - r[0];
|
||||
[[r[0] + ti * w, r[1]], [r[2] - ti * w, r[1]], [r[2] - bi * w, r[3]], [r[0] + bi * w, r[3]]]
|
||||
}
|
||||
|
||||
/// Mirror a rectangle horizontally and/or vertically, or swap its axes (canonical → direction).
|
||||
fn map_rect(r: R, mx: bool, my: bool, swap: bool) -> R {
|
||||
let mut r = if swap { [r[1], r[0], r[3], r[2]] } else { r };
|
||||
if mx {
|
||||
r = [1.0 - r[2], r[1], 1.0 - r[0], r[3]];
|
||||
}
|
||||
if my {
|
||||
r = [r[0], 1.0 - r[3], r[2], 1.0 - r[1]];
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// Motion direction of an option: l, r, u, d and diagonals.
|
||||
fn dir(opt: &str, default: (f64, f64)) -> (f64, f64) {
|
||||
let mut d = (0.0, 0.0);
|
||||
if opt.contains('l') {
|
||||
d.0 = -1.0;
|
||||
}
|
||||
if opt.contains('r') {
|
||||
d.0 = 1.0;
|
||||
}
|
||||
if opt.contains('u') {
|
||||
d.1 = -1.0;
|
||||
}
|
||||
if opt.contains('d') {
|
||||
d.1 = 1.0;
|
||||
}
|
||||
if d == (0.0, 0.0) { default } else { d }
|
||||
}
|
||||
|
||||
fn hash64(mut x: u64) -> u64 {
|
||||
x = x.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
x = (x ^ (x >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
x = (x ^ (x >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
x ^ (x >> 31)
|
||||
}
|
||||
|
||||
/// Deterministic pseudo-random value in [0, 1).
|
||||
fn rnd(i: u64) -> f64 {
|
||||
(hash64(i) >> 11) as f64 / (1u64 << 53) as f64
|
||||
}
|
||||
|
||||
fn ramp(t: f64, order: f64, window: f64) -> f64 {
|
||||
let w = window.clamp(0.01, 1.0);
|
||||
((t - order * (1.0 - w)) / w).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
fn grid(cols: usize, rows: usize) -> Vec<(usize, usize, R)> {
|
||||
let mut v = Vec::with_capacity(cols * rows);
|
||||
for r in 0..rows {
|
||||
for c in 0..cols {
|
||||
let cell = [c as f64 / cols as f64, r as f64 / rows as f64, (c + 1) as f64 / cols as f64, (r + 1) as f64 / rows as f64];
|
||||
v.push((c, r, cell));
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn center(r: R) -> [f64; 2] {
|
||||
[(r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0]
|
||||
}
|
||||
|
||||
fn scale_rect(r: R, s: f64) -> R {
|
||||
let c = center(r);
|
||||
let hw = (r[2] - r[0]) / 2.0 * s;
|
||||
let hh = (r[3] - r[1]) / 2.0 * s;
|
||||
[c[0] - hw, c[1] - hh, c[0] + hw, c[1] + hh]
|
||||
}
|
||||
|
||||
// ---------- effects ----------
|
||||
/// Slide aspect assumed by [`transition_layers`] (16:9).
|
||||
pub const DEFAULT_ASPECT: f64 = 16.0 / 9.0;
|
||||
|
||||
/// Layers of the transition `kind` (a [`deckcraft_model::anim::TRANSITIONS`] id) with `option` at
|
||||
/// progress `t` (0..1). Draw in order over black. At `t >= 1` the result is the new slide alone;
|
||||
/// at `t <= 0` (or NaN) the old slide alone. Morph returns a plain crossfade (the UI renders morph
|
||||
/// frames with [`crate::morph_frame`]).
|
||||
/// progress `t` (0..1) on a 16:9 slide. See [`transition_frame`].
|
||||
pub fn transition_layers(kind: &str, option: &str, t: f64) -> Vec<Layer> {
|
||||
transition_frame(kind, option, t, DEFAULT_ASPECT)
|
||||
}
|
||||
|
||||
/// Layers of the transition `kind` with `option` at linear progress `t` (0..1; the effect applies
|
||||
/// its own easing) on a slide `aspect` wide per unit of height. Draw in order over black. At
|
||||
/// `t >= 1` the result is the new slide alone; at `t <= 0` (or NaN) the old slide alone. Morph
|
||||
/// returns a plain crossfade (the UI renders morph frames with [`crate::morph_frame`]). Layers
|
||||
/// with [`Source::is_part`] sources need the slides rendered as background and content
|
||||
/// ([`uses_slide_parts`]).
|
||||
pub fn transition_frame(kind: &str, option: &str, t: f64, aspect: f64) -> Vec<Layer> {
|
||||
let t = clampf(t, 0.0, 1.0, 0.0);
|
||||
let aspect = if aspect.is_finite() && aspect > 0.01 { aspect.min(100.0) } else { DEFAULT_ASPECT };
|
||||
let v = if t >= 1.0 {
|
||||
vec![Layer::full(Source::New, 1.0)]
|
||||
} else if t <= 0.0 {
|
||||
vec![Layer::full(Source::Old, 1.0)]
|
||||
} else {
|
||||
layers(kind, option, t, 0)
|
||||
crate::transition_fx::layers(kind, option, t, aspect, 0)
|
||||
};
|
||||
v.into_iter().map(Layer::sanitized).filter(|l| l.alpha > 0.0).collect()
|
||||
v.into_iter().map(Layer::sanitized).filter(|l| l.alpha > 0.0 && l.fade.iter().any(|a| *a > 0.0)).collect()
|
||||
}
|
||||
|
||||
fn crossfade(t: f64) -> Vec<Layer> {
|
||||
vec![Layer::full(Source::Old, 1.0), Layer::full(Source::New, t)]
|
||||
}
|
||||
|
||||
const RANDOM_KINDS: &[&str] = &[
|
||||
"fade",
|
||||
"push",
|
||||
"wipe",
|
||||
"split",
|
||||
"reveal",
|
||||
"randomBar",
|
||||
"shape",
|
||||
"uncover",
|
||||
"cover",
|
||||
"fallOver",
|
||||
"drape",
|
||||
"curtains",
|
||||
"wind",
|
||||
"prestige",
|
||||
"fracture",
|
||||
"crush",
|
||||
"peelOff",
|
||||
"pageCurlDouble",
|
||||
"airplane",
|
||||
"origami",
|
||||
"dissolve",
|
||||
"checker",
|
||||
"blinds",
|
||||
"clock",
|
||||
"ripple",
|
||||
"honeycomb",
|
||||
"glitter",
|
||||
"vortex",
|
||||
"shred",
|
||||
"switch",
|
||||
"flip",
|
||||
"gallery",
|
||||
"cube",
|
||||
"doors",
|
||||
"box",
|
||||
"comb",
|
||||
"zoom",
|
||||
"pan",
|
||||
"ferris",
|
||||
"conveyor",
|
||||
"rotate",
|
||||
"window",
|
||||
"orbit",
|
||||
"flythrough",
|
||||
];
|
||||
|
||||
fn layers(kind: &str, option: &str, t: f64, depth: u32) -> Vec<Layer> {
|
||||
use Source::{Black, New, Old, White};
|
||||
let o = option;
|
||||
let s = smooth(t);
|
||||
/// Does `kind` animate the slides' backgrounds and contents separately (PowerPoint's Dynamic
|
||||
/// Content transitions and Reveal)? Then the frame uses [`Source::OldBack`] & co.
|
||||
pub fn uses_slide_parts(kind: &str, option: &str) -> bool {
|
||||
match kind {
|
||||
"none" => vec![Layer::full(New, 1.0)],
|
||||
"cut" => {
|
||||
if o == "throughBlack" && t < 0.5 {
|
||||
vec![Layer::full(Black, 1.0)]
|
||||
} else {
|
||||
vec![Layer::full(New, 1.0)]
|
||||
}
|
||||
}
|
||||
"fade" if o == "throughBlack" => {
|
||||
if t < 0.5 {
|
||||
vec![Layer::full(Black, 1.0), Layer::full(Old, 1.0 - 2.0 * t)]
|
||||
} else {
|
||||
vec![Layer::full(Black, 1.0), Layer::full(New, 2.0 * t - 1.0)]
|
||||
}
|
||||
}
|
||||
"fade" | "morph" => crossfade(t),
|
||||
"push" | "pan" => {
|
||||
let (dx, dy) = dir(o, (0.0, -1.0));
|
||||
vec![moved(Old, dx * s, dy * s, 1.0), moved(New, dx * (s - 1.0), dy * (s - 1.0), 1.0)]
|
||||
}
|
||||
"uncover" => {
|
||||
let (dx, dy) = dir(o, (-1.0, 0.0));
|
||||
vec![Layer::full(New, 1.0), moved(Old, dx * s, dy * s, 1.0)]
|
||||
}
|
||||
"cover" => {
|
||||
let (dx, dy) = dir(o, (-1.0, 0.0));
|
||||
vec![Layer::full(Old, 1.0), moved(New, dx * (s - 1.0), dy * (s - 1.0), 1.0)]
|
||||
}
|
||||
"wipe" => wipe(o, t),
|
||||
"split" => {
|
||||
let horz = o.starts_with("horz");
|
||||
let mut v = vec![Layer::full(Old, 1.0)];
|
||||
let rects: Vec<R> = if o.ends_with("In") {
|
||||
vec![[0.0, 0.0, s / 2.0, 1.0], [1.0 - s / 2.0, 0.0, 1.0, 1.0]]
|
||||
} else {
|
||||
vec![[0.5 - s / 2.0, 0.0, 0.5 + s / 2.0, 1.0]]
|
||||
};
|
||||
for r in rects {
|
||||
v.push(Layer::inplace(New, map_rect(r, false, false, horz), 1.0));
|
||||
}
|
||||
v
|
||||
}
|
||||
"reveal" => {
|
||||
let dx = if o.ends_with("Right") { 1.0 } else { -1.0 };
|
||||
if o.starts_with("black") {
|
||||
if t < 0.5 {
|
||||
vec![Layer::full(Black, 1.0), moved(Old, dx * t * 0.4, 0.0, 1.0 - 2.0 * t)]
|
||||
} else {
|
||||
vec![Layer::full(Black, 1.0), moved(New, -dx * (1.0 - t) * 0.2, 0.0, 2.0 * t - 1.0)]
|
||||
}
|
||||
} else {
|
||||
vec![Layer::full(New, 1.0), moved(Old, dx * s * 0.6, 0.0, 1.0 - t)]
|
||||
}
|
||||
}
|
||||
"randomBar" => {
|
||||
let horz = o == "horz";
|
||||
let n = 48;
|
||||
let mut v = vec![Layer::full(Old, 1.0)];
|
||||
for i in 0..n {
|
||||
if t > rnd(i as u64 + 17) * 0.98 {
|
||||
let r = [i as f64 / n as f64, 0.0, (i + 1) as f64 / n as f64, 1.0];
|
||||
v.push(Layer::inplace(New, map_rect(r, false, false, horz), 1.0));
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
"shape" => {
|
||||
let form = match o {
|
||||
"circle" | "diamond" | "plus" => o,
|
||||
_ => "box",
|
||||
};
|
||||
let inward = o == "in";
|
||||
if inward {
|
||||
let mut v = vec![Layer::full(New, 1.0)];
|
||||
v.extend(shape_form(Old, form, 1.0 - s));
|
||||
v
|
||||
} else {
|
||||
let mut v = vec![Layer::full(Old, 1.0)];
|
||||
v.extend(shape_form(New, form, s));
|
||||
v
|
||||
}
|
||||
}
|
||||
"flash" => {
|
||||
if t < 0.5 {
|
||||
vec![Layer::full(Old, 1.0), Layer::full(White, (2.0 * t).min(1.0))]
|
||||
} else {
|
||||
vec![Layer::full(New, 1.0), Layer::full(White, 2.0 - 2.0 * t)]
|
||||
}
|
||||
}
|
||||
"fallOver" => {
|
||||
let sg = if o == "r" { 1.0 } else { -1.0 };
|
||||
let a = t * t * PI / 2.0;
|
||||
let top = 1.0 - a.cos();
|
||||
let ins = 0.12 * a.sin();
|
||||
let sh = sg * 0.15 * a.sin();
|
||||
let q = [[ins + sh, top], [1.0 - ins + sh, top], [1.0, 1.0], [0.0, 1.0]];
|
||||
vec![Layer::full(New, 1.0), Layer::quad(Old, FULL, q, 1.0 - t.powi(3))]
|
||||
}
|
||||
"drape" => {
|
||||
let k = t * t;
|
||||
let q = [[0.0, 0.0], [1.0 - 0.3 * k, k], [1.0 - 0.3 * k, 1.0 + k], [0.0, 1.0]];
|
||||
let q = if o == "r" { mirror_q(q) } else { q };
|
||||
vec![Layer::full(New, 1.0), Layer::quad(Old, FULL, q, 1.0 - k)]
|
||||
}
|
||||
"curtains" => {
|
||||
let w = 0.5 * (1.0 - s);
|
||||
let lq = [[0.0, 0.0], [w, 0.12 * s], [w, 1.0 - 0.12 * s], [0.0, 1.0]];
|
||||
let rq = [[1.0 - w, 0.12 * s], [1.0, 0.0], [1.0, 1.0], [1.0 - w, 1.0 - 0.12 * s]];
|
||||
vec![
|
||||
scaled(New, 0.9 + 0.1 * s, 0.5, 0.5, 0.0, 0.0, 1.0),
|
||||
Layer::quad(Old, [0.0, 0.0, 0.5, 1.0], lq, 1.0),
|
||||
Layer::quad(Old, [0.5, 0.0, 1.0, 1.0], rq, 1.0),
|
||||
]
|
||||
}
|
||||
"wind" => {
|
||||
let d = if o == "r" { 1.0 } else { -1.0 };
|
||||
let r = offset(FULL, d * 1.2 * s, -0.1 * s);
|
||||
let q = [[r[0] + d * 0.2 * s, r[1]], [r[2] + d * 0.2 * s, r[1]], [r[2], r[3]], [r[0], r[3]]];
|
||||
vec![moved(New, -d * 0.3 * (1.0 - s), 0.0, smooth((t * 1.5).min(1.0))), Layer::quad(Old, FULL, q, 1.0 - t)]
|
||||
}
|
||||
"prestige" => vec![scaled(Old, 1.0 - 0.3 * s, 0.5, 0.5, 0.0, 0.2 * s, 1.0 - t), scaled(New, 0.7 + 0.3 * s, 0.5, 0.5, 0.0, 0.0, s)],
|
||||
"fracture" => {
|
||||
let mut v = vec![Layer::full(New, 1.0)];
|
||||
for (c, r, cell) in grid(8, 6) {
|
||||
let i = (r * 8 + c) as u64;
|
||||
let q = ramp(t, rnd(i + 101) * 0.5, 0.5);
|
||||
let cc = center(cell);
|
||||
let (dx, dy) = ((cc[0] - 0.5) * 1.5 * q, (cc[1] - 0.5) * 1.5 * q + 0.5 * q * q);
|
||||
let quad = rot_quad(corners(offset(cell, dx, dy)), [cc[0] + dx, cc[1] + dy], (rnd(i + 7) - 0.5) * 180.0 * q);
|
||||
v.push(Layer::quad(Old, cell, quad, 1.0 - q));
|
||||
}
|
||||
v
|
||||
}
|
||||
"crush" => {
|
||||
let mut v = vec![Layer::full(New, 1.0)];
|
||||
for (c, r, cell) in grid(4, 4) {
|
||||
let i = (r * 4 + c) as u64;
|
||||
let cc = center(cell);
|
||||
let k = 1.0 - s;
|
||||
let pos = [0.5 + (cc[0] - 0.5) * k, 0.5 + (cc[1] - 0.5) * k];
|
||||
let half = scale_rect(cell, k * (0.8 + 0.2 * rnd(i + 3)));
|
||||
let half = offset(half, pos[0] - cc[0], pos[1] - cc[1]);
|
||||
let quad = rot_quad(corners(half), pos, (rnd(i + 5) - 0.5) * 60.0 * s);
|
||||
v.push(Layer::quad(Old, cell, quad, 1.0 - t * t));
|
||||
}
|
||||
v
|
||||
}
|
||||
"peelOff" => {
|
||||
let mut v = vec![Layer::full(New, 1.0)];
|
||||
v.extend(peel(Old, [0.0, 0.0, 1.0, 1.0], s, o == "r"));
|
||||
v
|
||||
}
|
||||
"pageCurlDouble" => {
|
||||
let mut v = vec![Layer::full(New, 1.0)];
|
||||
let left_first = o != "r";
|
||||
v.extend(peel(Old, [0.0, 0.0, 0.5, 1.0], s, !left_first));
|
||||
v.extend(peel(Old, [0.5, 0.0, 1.0, 1.0], s, left_first));
|
||||
v
|
||||
}
|
||||
"airplane" => {
|
||||
let d = if o == "r" { 1.0 } else { -1.0 };
|
||||
let k = 1.0 - 0.8 * s;
|
||||
let r = offset(scale_rect(FULL, k), d * 1.2 * s * s, -0.5 * s * s);
|
||||
let q = persp_h(r, if d > 0.0 { 0.3 * s } else { 0.0 }, if d > 0.0 { 0.0 } else { 0.3 * s });
|
||||
vec![Layer::full(New, 1.0), Layer::quad(Old, FULL, q, 1.0 - t * t)]
|
||||
}
|
||||
"origami" => {
|
||||
let d = if o == "r" { 1.0 } else { -1.0 };
|
||||
let mut v = vec![Layer::full(New, 1.0)];
|
||||
if t < 0.5 {
|
||||
let f = t * 2.0;
|
||||
let w = 0.5 * (1.0 - 0.8 * f);
|
||||
v.push(Layer::quad(Old, [0.0, 0.0, 0.5, 1.0], persp_h([0.5 - w, 0.0, 0.5, 1.0], 0.15 * f, 0.0), 1.0));
|
||||
v.push(Layer::quad(Old, [0.5, 0.0, 1.0, 1.0], persp_h([0.5, 0.0, 0.5 + w, 1.0], 0.0, 0.15 * f), 1.0));
|
||||
} else {
|
||||
let f = (t - 0.5) * 2.0;
|
||||
let r = offset(scale_rect([0.4, 0.0, 0.6, 1.0], 1.0 - 0.6 * f), d * f * 0.8, -0.3 * f);
|
||||
v.push(Layer::quad(Old, FULL, persp_v(r, 0.1, 0.0), 1.0 - f));
|
||||
}
|
||||
v
|
||||
}
|
||||
"dissolve" => {
|
||||
let mut v = vec![Layer::full(Old, 1.0)];
|
||||
for (c, r, cell) in grid(32, 18) {
|
||||
if t > rnd((r * 32 + c) as u64 + 991) * 0.98 {
|
||||
v.push(Layer::inplace(New, cell, 1.0));
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
"checker" => {
|
||||
let vert = o == "vert";
|
||||
let (n, m) = (10usize, 8usize);
|
||||
let mut v = vec![Layer::full(Old, 1.0)];
|
||||
for (c, r, cell) in grid(n, m) {
|
||||
let across = if vert { c } else { r };
|
||||
let f = (t * 2.0 - (across % 2) as f64 * 0.5).clamp(0.0, 1.0);
|
||||
if f <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let rc = if vert {
|
||||
[cell[0], cell[1], cell[2], cell[1] + (cell[3] - cell[1]) * f]
|
||||
} else {
|
||||
[cell[0], cell[1], cell[0] + (cell[2] - cell[0]) * f, cell[3]]
|
||||
};
|
||||
v.push(Layer::inplace(New, rc, 1.0));
|
||||
}
|
||||
v
|
||||
}
|
||||
"blinds" => {
|
||||
let horz = o == "horz";
|
||||
let n = 8;
|
||||
let mut v = vec![Layer::full(Old, 1.0)];
|
||||
for i in 0..n {
|
||||
let x0 = i as f64 / n as f64;
|
||||
let r = [x0, 0.0, x0 + s / n as f64, 1.0];
|
||||
v.push(Layer::inplace(New, map_rect(r, false, false, horz), 1.0));
|
||||
}
|
||||
v
|
||||
}
|
||||
"clock" => clock(o, s),
|
||||
"ripple" => {
|
||||
let origin = match o {
|
||||
"lu" => [0.0, 0.0],
|
||||
"ru" => [1.0, 0.0],
|
||||
"ld" => [0.0, 1.0],
|
||||
"rd" => [1.0, 1.0],
|
||||
_ => [0.5, 0.5],
|
||||
};
|
||||
let maxd = if origin == [0.5, 0.5] { 0.71 } else { 1.42 };
|
||||
let mut v = vec![Layer::full(Old, 1.0)];
|
||||
for (_, _, cell) in grid(24, 14) {
|
||||
let cc = center(cell);
|
||||
let d = ((cc[0] - origin[0]).powi(2) + (cc[1] - origin[1]).powi(2)).sqrt() / maxd;
|
||||
let a = ramp(t, d.min(1.0), 0.3);
|
||||
if a > 0.0 {
|
||||
let sc = 1.0 + 0.15 * (a * PI).sin();
|
||||
v.push(Layer::rect(New, cell, scale_rect(cell, sc), a));
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
"honeycomb" => {
|
||||
let (cols, rows) = (12usize, 8usize);
|
||||
let mut v = vec![Layer::full(Old, 1.0)];
|
||||
for c in 0..cols {
|
||||
let odd = c % 2 == 1;
|
||||
let extra = if odd { 1 } else { 0 };
|
||||
for r in 0..rows + extra {
|
||||
let y0 = (r as f64 - if odd { 0.5 } else { 0.0 }) / rows as f64;
|
||||
let cell = [c as f64 / cols as f64, y0.max(0.0), (c + 1) as f64 / cols as f64, (y0 + 1.0 / rows as f64).min(1.0)];
|
||||
let a = ramp(t, rnd((c * 64 + r) as u64 + 4242), 0.35);
|
||||
if a > 0.0 {
|
||||
v.push(Layer::rect(New, cell, scale_rect(cell, 0.3 + 0.7 * a), a));
|
||||
}
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
"glitter" => {
|
||||
let (dx, dy) = dir(o, (-1.0, 0.0));
|
||||
let mut v = vec![Layer::full(Old, 1.0)];
|
||||
for (c, r, cell) in grid(20, 12) {
|
||||
let cc = center(cell);
|
||||
let grad = if dx < 0.0 {
|
||||
1.0 - cc[0]
|
||||
} else if dx > 0.0 {
|
||||
cc[0]
|
||||
} else if dy < 0.0 {
|
||||
1.0 - cc[1]
|
||||
} else {
|
||||
cc[1]
|
||||
};
|
||||
let order = 0.6 * grad + 0.4 * rnd((r * 20 + c) as u64 + 77);
|
||||
let a = ramp(t, order, 0.25);
|
||||
if a > 0.0 {
|
||||
let quad = rot_quad(corners(cell), cc, (1.0 - a) * 90.0);
|
||||
v.push(Layer::quad(New, cell, quad, a));
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
"vortex" => {
|
||||
let (dx, dy) = dir(o, (-1.0, 0.0));
|
||||
let mut v = vec![Layer::full(New, 1.0)];
|
||||
for (c, r, cell) in grid(16, 9) {
|
||||
let i = (r * 16 + c) as u64;
|
||||
let cc = center(cell);
|
||||
let grad = 0.5 + 0.5 * ((cc[0] - 0.5) * -dx + (cc[1] - 0.5) * -dy);
|
||||
let q = ramp(t, 0.5 * grad + 0.5 * rnd(i + 31), 0.4);
|
||||
let ang = q * 2.0 * PI + rnd(i) * PI;
|
||||
let rad = q * 1.2;
|
||||
let (ox, oy) = (dx * q * 0.8 + rad * ang.cos() * 0.3, dy * q * 0.8 + rad * ang.sin() * 0.3);
|
||||
let pos = [cc[0] + ox, cc[1] + oy];
|
||||
let quad = rot_quad(corners(offset(scale_rect(cell, 1.0 - 0.5 * q), ox, oy)), pos, q * 360.0);
|
||||
v.push(Layer::quad(Old, cell, quad, 1.0 - q));
|
||||
}
|
||||
v
|
||||
}
|
||||
"shred" => {
|
||||
let mut v = vec![Layer::full(New, 1.0)];
|
||||
if o == "particles" {
|
||||
for (c, r, cell) in grid(24, 14) {
|
||||
let i = (r * 24 + c) as u64;
|
||||
let q = ramp(t, rnd(i + 555) * 0.6 + 0.4 * (1.0 - center(cell)[1]), 0.4);
|
||||
let dy = q * q * 1.2;
|
||||
let dx = (rnd(i + 9) - 0.5) * 0.3 * q;
|
||||
v.push(Layer::rect(Old, cell, offset(scale_rect(cell, 1.0 - 0.6 * q), dx, dy), 1.0 - q));
|
||||
}
|
||||
} else {
|
||||
let n = 16;
|
||||
for i in 0..n {
|
||||
let x0 = i as f64 / n as f64;
|
||||
let x1 = (i + 1) as f64 / n as f64;
|
||||
let q = ramp(t, i as f64 / n as f64 * 0.3 + 0.1 * rnd(i as u64), 0.7);
|
||||
let dy = if i % 2 == 0 { 1.1 * q * q } else { -1.1 * q * q };
|
||||
v.push(Layer::rect(Old, [x0, 0.0, x1, 1.0], [x0, dy, x1, 1.0 + dy], 1.0));
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
"switch" => {
|
||||
let d = if o == "r" { 1.0 } else { -1.0 };
|
||||
let k = (t * PI).sin();
|
||||
let old = Layer::quad(
|
||||
Old,
|
||||
FULL,
|
||||
persp_h(
|
||||
offset(scale_rect(FULL, 1.0 - 0.25 * t), d * 0.45 * k, 0.0),
|
||||
if d > 0.0 { 0.0 } else { 0.08 * k },
|
||||
if d > 0.0 { 0.08 * k } else { 0.0 },
|
||||
),
|
||||
1.0,
|
||||
);
|
||||
let new = Layer::quad(
|
||||
New,
|
||||
FULL,
|
||||
persp_h(
|
||||
offset(scale_rect(FULL, 0.75 + 0.25 * t), -d * 0.45 * k, 0.0),
|
||||
if d > 0.0 { 0.08 * k } else { 0.0 },
|
||||
if d > 0.0 { 0.0 } else { 0.08 * k },
|
||||
),
|
||||
1.0,
|
||||
);
|
||||
if t < 0.5 { vec![new, old] } else { vec![old, new] }
|
||||
}
|
||||
"flip" => {
|
||||
let a = PI * s;
|
||||
let w = a.cos().abs();
|
||||
let k = 0.1 * a.sin();
|
||||
let r = [0.5 - w / 2.0, 0.0, 0.5 + w / 2.0, 1.0];
|
||||
let right = o != "l";
|
||||
if t < 0.5 {
|
||||
let q = if right { persp_h(r, -k / 2.0, k) } else { persp_h(r, k, -k / 2.0) };
|
||||
vec![Layer::quad(Old, FULL, q, 1.0)]
|
||||
} else {
|
||||
let q = if right { persp_h(r, k, -k / 2.0) } else { persp_h(r, -k / 2.0, k) };
|
||||
vec![Layer::quad(New, FULL, q, 1.0)]
|
||||
}
|
||||
}
|
||||
"gallery" | "conveyor" => {
|
||||
let d = if o == "r" { 1.0 } else { -1.0 };
|
||||
let k = (s * PI).sin();
|
||||
let sc = 1.0 - if kind == "gallery" { 0.25 } else { 0.1 } * k;
|
||||
let gap = if kind == "gallery" { 1.15 } else { 1.05 };
|
||||
let old = offset(scale_rect(FULL, sc), d * s * gap, 0.0);
|
||||
let new = offset(scale_rect(FULL, sc), d * (s - 1.0) * gap, 0.0);
|
||||
let p = 0.08 * k;
|
||||
vec![
|
||||
Layer::quad(Old, FULL, if d > 0.0 { persp_h(old, 0.0, p) } else { persp_h(old, p, 0.0) }, 1.0),
|
||||
Layer::quad(New, FULL, if d > 0.0 { persp_h(new, p, 0.0) } else { persp_h(new, 0.0, p) }, 1.0),
|
||||
]
|
||||
}
|
||||
"cube" | "box" | "rotate" => {
|
||||
let (dx, dy) = dir(o, (-1.0, 0.0));
|
||||
let vertical = dy != 0.0 && dx == 0.0;
|
||||
let neg = if vertical { dy < 0.0 } else { dx < 0.0 };
|
||||
let k = (s * PI).sin();
|
||||
let (far_old, far_new, shared) = match kind {
|
||||
"cube" => (0.18 * s, 0.18 * (1.0 - s), 0.05 * k),
|
||||
"box" => (0.0, 0.0, 0.18 * k),
|
||||
_ => (0.25 * s, 0.25 * (1.0 - s), 0.0),
|
||||
};
|
||||
// Canonical: moving toward -x; old on the left [0, 1-s], new on the right [1-s, 1].
|
||||
let split = 1.0 - s;
|
||||
let (old_q, new_q) = if kind == "rotate" {
|
||||
if t < 0.5 {
|
||||
(Some(canon_q([0.0, 0.0, (1.0 - 2.0 * t).max(0.0), 1.0], far_old, 0.0)), None)
|
||||
} else {
|
||||
(None, Some(canon_q([1.0 - (2.0 * t - 1.0), 0.0, 1.0, 1.0], 0.0, far_new)))
|
||||
}
|
||||
} else {
|
||||
(Some(canon_q([0.0, 0.0, split, 1.0], far_old, shared)), Some(canon_q([split, 0.0, 1.0, 1.0], shared, far_new)))
|
||||
};
|
||||
let fix = |q: [[f64; 2]; 4]| -> [[f64; 2]; 4] {
|
||||
let q = if neg { q } else { mirror_q(q) };
|
||||
if vertical { swap_q(q) } else { q }
|
||||
};
|
||||
let mut v = vec![Layer::full(Black, 1.0)];
|
||||
if let Some(q) = old_q {
|
||||
v.push(Layer::quad(Old, FULL, fix(q), 1.0));
|
||||
}
|
||||
if let Some(q) = new_q {
|
||||
v.push(Layer::quad(New, FULL, fix(q), 1.0));
|
||||
}
|
||||
v
|
||||
}
|
||||
"doors" | "window" => {
|
||||
let horz = o == "horz";
|
||||
let (zoom0, persp) = if kind == "doors" { (0.85, 0.1) } else { (0.6, 0.2) };
|
||||
let w = 0.5 * (1.0 - s);
|
||||
let k = persp * s;
|
||||
let left = [[0.0, 0.0], [w, -k], [w, 1.0 + k], [0.0, 1.0]];
|
||||
let right = [[1.0 - w, -k], [1.0, 0.0], [1.0, 1.0], [1.0 - w, 1.0 + k]];
|
||||
let (l, r) = if horz { (swap_q(left), swap_q(right)) } else { (left, right) };
|
||||
let (sl, sr) = if horz { ([0.0, 0.0, 1.0, 0.5], [0.0, 0.5, 1.0, 1.0]) } else { ([0.0, 0.0, 0.5, 1.0], [0.5, 0.0, 1.0, 1.0]) };
|
||||
vec![scaled(New, zoom0 + (1.0 - zoom0) * s, 0.5, 0.5, 0.0, 0.0, s.max(0.2)), Layer::quad(Old, sl, l, 1.0), Layer::quad(Old, sr, r, 1.0)]
|
||||
}
|
||||
"comb" => {
|
||||
let vert = o == "vert";
|
||||
let n = 8;
|
||||
let mut v = Vec::new();
|
||||
for i in 0..n {
|
||||
let a = i as f64 / n as f64;
|
||||
let b = (i + 1) as f64 / n as f64;
|
||||
let d = if i % 2 == 0 { 1.0 } else { -1.0 };
|
||||
let band = if vert { [a, 0.0, b, 1.0] } else { [0.0, a, 1.0, b] };
|
||||
let (ox, oy) = if vert { (0.0, d * s) } else { (d * s, 0.0) };
|
||||
let (nx, ny) = if vert { (0.0, -d * (1.0 - s)) } else { (-d * (1.0 - s), 0.0) };
|
||||
v.push(Layer::rect(Old, band, offset(band, ox, oy), 1.0));
|
||||
v.push(Layer::rect(New, band, offset(band, nx, ny), 1.0));
|
||||
}
|
||||
v
|
||||
}
|
||||
"zoom" => {
|
||||
if o == "out" {
|
||||
vec![scaled(New, 1.5 - 0.5 * s, 0.5, 0.5, 0.0, 0.0, s), scaled(Old, 1.0 - 0.7 * s, 0.5, 0.5, 0.0, 0.0, 1.0 - t)]
|
||||
} else {
|
||||
vec![scaled(Old, 1.0 + s, 0.5, 0.5, 0.0, 0.0, 1.0 - t), scaled(New, s.max(0.01), 0.5, 0.5, 0.0, 0.0, s)]
|
||||
}
|
||||
}
|
||||
"orbit" => {
|
||||
let (dx, dy) = dir(o, (-1.0, 0.0));
|
||||
let k = (s * PI).sin();
|
||||
let old = scaled(Old, 1.0 - 0.4 * k, 0.5, 0.5, dx * s + dy.abs() * 0.15 * k, dy * s + dx.abs() * 0.15 * k, 1.0 - 0.5 * s);
|
||||
let new = scaled(New, 1.0 - 0.4 * k, 0.5, 0.5, dx * (s - 1.0) - dy.abs() * 0.15 * k, dy * (s - 1.0) - dx.abs() * 0.15 * k, 0.5 + 0.5 * s);
|
||||
if t < 0.5 { vec![new, old] } else { vec![old, new] }
|
||||
}
|
||||
"flythrough" => {
|
||||
let bounce = o.ends_with("Bounce");
|
||||
let ns = |base: f64| if bounce { base + 0.08 * (s * PI).sin() } else { base };
|
||||
if o.starts_with("out") {
|
||||
vec![scaled(Old, 1.0 - 0.5 * s, 0.5, 0.5, 0.0, 0.0, 1.0 - t), scaled(New, ns(1.5 - 0.5 * s), 0.5, 0.5, 0.0, 0.0, s)]
|
||||
} else {
|
||||
vec![scaled(New, ns(0.5 + 0.5 * s), 0.5, 0.5, 0.0, 0.0, s), scaled(Old, 1.0 + 1.5 * s, 0.5, 0.5, 0.0, 0.0, 1.0 - t)]
|
||||
}
|
||||
}
|
||||
"ferris" => {
|
||||
let d = if o == "r" { 1.0 } else { -1.0 };
|
||||
let pivot = [0.5, 2.5];
|
||||
let a = 50.0 * s * d;
|
||||
let oq = rot_quad(corners(FULL), pivot, a);
|
||||
let nq = rot_quad(corners(FULL), pivot, a - 50.0 * d);
|
||||
vec![Layer::quad(Old, FULL, oq, 1.0), Layer::quad(New, FULL, nq, 1.0)]
|
||||
}
|
||||
"random" if depth == 0 => {
|
||||
let h = option.bytes().fold(0x51ED_u64, |h, b| hash64(h ^ b as u64));
|
||||
let k = RANDOM_KINDS.get((h % RANDOM_KINDS.len() as u64) as usize).copied().unwrap_or("fade");
|
||||
let opt = deckcraft_model::anim::TRANSITIONS.iter().find(|x| x.0 == k).and_then(|x| x.4.first().copied()).unwrap_or("");
|
||||
layers(k, opt, t, depth + 1)
|
||||
}
|
||||
_ => crossfade(t),
|
||||
"pan" | "ferris" | "conveyor" | "rotate" | "window" | "orbit" | "flythrough" => true,
|
||||
"reveal" => !option.starts_with("black"),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn mirror_q(q: [[f64; 2]; 4]) -> [[f64; 2]; 4] {
|
||||
// Mirror x and keep the tl, tr, br, bl order (swap left/right corners).
|
||||
let m = |p: [f64; 2]| [1.0 - p[0], p[1]];
|
||||
[m(q[1]), m(q[0]), m(q[3]), m(q[2])]
|
||||
}
|
||||
|
||||
fn swap_q(q: [[f64; 2]; 4]) -> [[f64; 2]; 4] {
|
||||
// Transpose x/y; corners reordered so tl, tr, br, bl still map to the source's corners.
|
||||
let s = |p: [f64; 2]| [p[1], p[0]];
|
||||
[s(q[0]), s(q[3]), s(q[2]), s(q[1])]
|
||||
}
|
||||
|
||||
fn canon_q(r: R, left_inset: f64, right_inset: f64) -> [[f64; 2]; 4] {
|
||||
persp_h(r, left_inset, right_inset)
|
||||
}
|
||||
|
||||
fn wipe(o: &str, t: f64) -> Vec<Layer> {
|
||||
let mut v = vec![Layer::full(Source::Old, 1.0)];
|
||||
let diag = o.len() == 2;
|
||||
if diag {
|
||||
// Diagonal: columns revealed by a slanted edge.
|
||||
let (mx, my) = (o.contains('l'), o.contains('u'));
|
||||
let n = 32;
|
||||
for i in 0..n {
|
||||
let x0 = i as f64 / n as f64;
|
||||
let x1 = (i + 1) as f64 / n as f64;
|
||||
let h = (2.0 * t - (x0 + x1) / 2.0).clamp(0.0, 1.0);
|
||||
if h > 0.0 {
|
||||
v.push(Layer::inplace(Source::New, map_rect([x0, 0.0, x1, h], mx, my, false), 1.0));
|
||||
}
|
||||
}
|
||||
return v;
|
||||
}
|
||||
let (mx, swap) = match o {
|
||||
"l" => (true, false),
|
||||
"d" => (false, true),
|
||||
"u" => (true, true),
|
||||
_ => (false, false),
|
||||
};
|
||||
let soft = 0.15;
|
||||
let e = t * (1.0 + soft);
|
||||
let solid = (e - soft).clamp(0.0, 1.0);
|
||||
if solid > 0.0 {
|
||||
v.push(Layer::inplace(Source::New, map_rect([0.0, 0.0, solid, 1.0], mx, false, swap), 1.0));
|
||||
}
|
||||
let n = 6;
|
||||
for k in 0..n {
|
||||
let a = e - soft + soft * k as f64 / n as f64;
|
||||
let b = e - soft + soft * (k + 1) as f64 / n as f64;
|
||||
let (a, b) = (a.clamp(0.0, 1.0), b.clamp(0.0, 1.0));
|
||||
if b > a {
|
||||
let alpha = 1.0 - (k as f64 + 0.5) / n as f64;
|
||||
v.push(Layer::inplace(Source::New, map_rect([a, 0.0, b, 1.0], mx, false, swap), alpha));
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// A centred shape of the source at size `f` (0 = nothing, 1 = covers the slide), as strips.
|
||||
fn shape_form(src: Source, form: &str, f: f64) -> Vec<Layer> {
|
||||
let f = f.clamp(0.0, 1.0);
|
||||
if f <= 0.0 {
|
||||
return vec![];
|
||||
}
|
||||
match form {
|
||||
"plus" => {
|
||||
let h = f / 2.0;
|
||||
vec![Layer::inplace(src, [0.0, 0.5 - h, 1.0, 0.5 + h], 1.0), Layer::inplace(src, [0.5 - h, 0.0, 0.5 + h, 1.0], 1.0)]
|
||||
}
|
||||
"circle" | "diamond" => {
|
||||
let n = 40;
|
||||
let r = if form == "circle" { f * 0.75 } else { f * 1.02 };
|
||||
let mut v = Vec::new();
|
||||
for i in 0..n {
|
||||
let y0 = i as f64 / n as f64;
|
||||
let y1 = (i + 1) as f64 / n as f64;
|
||||
let dy = ((y0 + y1) / 2.0 - 0.5).abs();
|
||||
let half = if form == "circle" { if r > dy { (r * r - dy * dy).sqrt() } else { 0.0 } } else { r - dy };
|
||||
if half > 0.0 {
|
||||
let half = half.min(0.5);
|
||||
v.push(Layer::inplace(src, [0.5 - half, y0, 0.5 + half, y1], 1.0));
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
_ => vec![Layer::inplace(src, scale_rect(FULL, f), 1.0)],
|
||||
}
|
||||
}
|
||||
|
||||
/// A peeling page: the part of `area` not yet peeled stays, the peeled part shows as a mirrored flap.
|
||||
fn peel(src: Source, area: R, s: f64, toward_right: bool) -> Vec<Layer> {
|
||||
let w = area[2] - area[0];
|
||||
let p = s * w;
|
||||
if toward_right {
|
||||
let edge = area[0] + p;
|
||||
let stay = [edge, area[1], area[2], area[3]];
|
||||
let flap_src = [area[0], area[1], edge, area[3]];
|
||||
let x2 = edge + p;
|
||||
let flap = Layer {
|
||||
source: src,
|
||||
src: flap_src,
|
||||
quad: [[x2, area[1]], [edge, area[1]], [edge, area[3]], [x2, area[3]]],
|
||||
uv: corners(flap_src),
|
||||
alpha: 0.85,
|
||||
};
|
||||
vec![Layer::inplace(src, stay, 1.0), flap]
|
||||
} else {
|
||||
let edge = area[2] - p;
|
||||
let stay = [area[0], area[1], edge, area[3]];
|
||||
let flap_src = [edge, area[1], area[2], area[3]];
|
||||
let x2 = edge - p;
|
||||
let flap = Layer {
|
||||
source: src,
|
||||
src: flap_src,
|
||||
quad: [[edge, area[1]], [x2, area[1]], [x2, area[3]], [edge, area[3]]],
|
||||
uv: corners(flap_src),
|
||||
alpha: 0.85,
|
||||
};
|
||||
vec![Layer::inplace(src, stay, 1.0), flap]
|
||||
}
|
||||
}
|
||||
|
||||
/// Point on the unit square's boundary seen from the centre at angle `a` (radians, clockwise from
|
||||
/// 12 o'clock).
|
||||
fn boundary(a: f64) -> [f64; 2] {
|
||||
let (dx, dy) = (a.sin(), -a.cos());
|
||||
let m = dx.abs().max(dy.abs()).max(1e-9);
|
||||
[0.5 + 0.5 * dx / m, 0.5 + 0.5 * dy / m]
|
||||
}
|
||||
|
||||
fn clock(o: &str, s: f64) -> Vec<Layer> {
|
||||
let mut v = vec![Layer::full(Source::Old, 1.0)];
|
||||
let c = [0.5, 0.5];
|
||||
let sweep = s * 2.0 * PI;
|
||||
let (a0, a1) = match o {
|
||||
"counterClockwise" => (-sweep, 0.0),
|
||||
"wedge" => (-sweep / 2.0, sweep / 2.0),
|
||||
_ => (0.0, sweep),
|
||||
};
|
||||
let span = a1 - a0;
|
||||
if span <= 0.0 {
|
||||
return v;
|
||||
}
|
||||
let n = ((span / (PI / 24.0)).ceil() as usize).clamp(1, 96);
|
||||
let mut angles: Vec<f64> = (0..=n).map(|k| a0 + span * k as f64 / n as f64).collect();
|
||||
// Square corners inside the sweep, so the fan reaches them.
|
||||
for k in -8i32..=8 {
|
||||
let a = PI / 4.0 + k as f64 * PI / 2.0;
|
||||
if a > a0 && a < a1 {
|
||||
angles.push(a);
|
||||
}
|
||||
}
|
||||
angles.sort_by(f64::total_cmp);
|
||||
for w in angles.windows(2) {
|
||||
if let [x, y] = w
|
||||
&& y > x
|
||||
{
|
||||
v.push(Layer::tri(Source::New, c, boundary(*x), boundary(*y), 1.0));
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,382 @@
|
||||
//! Geometry for transition effects: easing, 3-D cards, opacity fields, polygons.
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use crate::easing::{ease, smooth};
|
||||
use crate::transition::{FULL, Layer, P, Q, R, Source, corners};
|
||||
|
||||
// ---------- easing ----------
|
||||
|
||||
/// Smoothstep ease in-out (PowerPoint's fades).
|
||||
pub(crate) fn ss(x: f64) -> f64 {
|
||||
smooth(x)
|
||||
}
|
||||
|
||||
/// Quadratic ease in-out: velocity ramps up for the first half and down for the second
|
||||
/// (PowerPoint's moves: Push, Cover, Uncover, Pan…).
|
||||
pub(crate) fn q2(x: f64) -> f64 {
|
||||
ease(x, 0.5, 0.5, 0.0)
|
||||
}
|
||||
|
||||
/// Power ease in-out of degree `n` (2: quadratic, 3: cubic, 4: quartic).
|
||||
pub(crate) fn pow_inout(x: f64, n: f64) -> f64 {
|
||||
let x = x.clamp(0.0, 1.0);
|
||||
if x < 0.5 { 0.5 * (2.0 * x).powf(n) } else { 1.0 - 0.5 * (2.0 - 2.0 * x).powf(n) }
|
||||
}
|
||||
|
||||
/// Quadratic ease-in.
|
||||
pub(crate) fn ein(x: f64) -> f64 {
|
||||
let x = x.clamp(0.0, 1.0);
|
||||
x * x
|
||||
}
|
||||
|
||||
/// Quadratic ease-out.
|
||||
pub(crate) fn eout(x: f64) -> f64 {
|
||||
let x = 1.0 - x.clamp(0.0, 1.0);
|
||||
1.0 - x * x
|
||||
}
|
||||
|
||||
/// Progress through the window [a, b] of t, clamped to 0..1.
|
||||
pub(crate) fn win(t: f64, a: f64, b: f64) -> f64 {
|
||||
if b <= a {
|
||||
return if t >= b { 1.0 } else { 0.0 };
|
||||
}
|
||||
((t - a) / (b - a)).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
// ---------- randomness ----------
|
||||
|
||||
pub(crate) fn hash64(mut x: u64) -> u64 {
|
||||
x = x.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
x = (x ^ (x >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
x = (x ^ (x >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
x ^ (x >> 31)
|
||||
}
|
||||
|
||||
/// Deterministic pseudo-random value in [0, 1).
|
||||
pub(crate) fn rnd(i: u64) -> f64 {
|
||||
(hash64(i) >> 11) as f64 / (1u64 << 53) as f64
|
||||
}
|
||||
|
||||
// ---------- 2-D ----------
|
||||
|
||||
pub(crate) fn offset(r: R, dx: f64, dy: f64) -> R {
|
||||
[r[0] + dx, r[1] + dy, r[2] + dx, r[3] + dy]
|
||||
}
|
||||
|
||||
pub(crate) fn center(r: R) -> P {
|
||||
[(r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0]
|
||||
}
|
||||
|
||||
pub(crate) fn scale_rect(r: R, s: f64) -> R {
|
||||
scale_rect_about(r, s, center(r))
|
||||
}
|
||||
|
||||
pub(crate) fn scale_rect_about(r: R, s: f64, c: P) -> R {
|
||||
[c[0] + (r[0] - c[0]) * s, c[1] + (r[1] - c[1]) * s, c[0] + (r[2] - c[0]) * s, c[1] + (r[3] - c[1]) * s]
|
||||
}
|
||||
|
||||
/// The whole source moved by (dx, dy).
|
||||
pub(crate) fn moved(source: Source, dx: f64, dy: f64, alpha: f64) -> Layer {
|
||||
Layer::rect(source, FULL, offset(FULL, dx, dy), alpha)
|
||||
}
|
||||
|
||||
/// The whole source scaled by `s` about (cx, cy) and moved.
|
||||
pub(crate) fn scaled(source: Source, s: f64, cx: f64, cy: f64, dx: f64, dy: f64, alpha: f64) -> Layer {
|
||||
Layer::rect(source, FULL, offset(scale_rect_about(FULL, s, [cx, cy]), dx, dy), alpha)
|
||||
}
|
||||
|
||||
/// Rotate a point about a pivot by `deg` (clockwise on screen), in a slide `a` wide per unit of
|
||||
/// height, so angles and shapes stay true.
|
||||
pub(crate) fn rot(p: P, c: P, deg: f64, a: f64) -> P {
|
||||
let (s, co) = deg.to_radians().sin_cos();
|
||||
let (x, y) = ((p[0] - c[0]) * a, p[1] - c[1]);
|
||||
[c[0] + (x * co - y * s) / a, c[1] + x * s + y * co]
|
||||
}
|
||||
|
||||
pub(crate) fn rot_quad(q: Q, c: P, deg: f64, a: f64) -> Q {
|
||||
[rot(q[0], c, deg, a), rot(q[1], c, deg, a), rot(q[2], c, deg, a), rot(q[3], c, deg, a)]
|
||||
}
|
||||
|
||||
/// Motion direction of an option: l, r, u, d and diagonals.
|
||||
pub(crate) fn dir(opt: &str, default: (f64, f64)) -> (f64, f64) {
|
||||
let mut d = (0.0, 0.0);
|
||||
if opt.contains('l') {
|
||||
d.0 = -1.0;
|
||||
}
|
||||
if opt.contains('r') {
|
||||
d.0 = 1.0;
|
||||
}
|
||||
if opt.contains('u') {
|
||||
d.1 = -1.0;
|
||||
}
|
||||
if opt.contains('d') {
|
||||
d.1 = 1.0;
|
||||
}
|
||||
if d == (0.0, 0.0) { default } else { d }
|
||||
}
|
||||
|
||||
/// A rectangle with a soft border `f` wide (slide units) fading out to nothing: the rectangle at
|
||||
/// full opacity, four edge ramps and four corner ramps. `src` maps onto `dst`; the border samples
|
||||
/// the texture's edge.
|
||||
pub(crate) fn soft_rect(source: Source, src: R, dst: R, f: f64, alpha: f64) -> Vec<Layer> {
|
||||
let mut v = vec![Layer::rect(source, src, dst, alpha)];
|
||||
if f <= 1e-6 {
|
||||
return v;
|
||||
}
|
||||
let (l, t, r, b) = (dst[0], dst[1], dst[2], dst[3]);
|
||||
let (fx, fy) = (f, f * 16.0 / 9.0);
|
||||
let uv_at = |x: f64, y: f64| -> P {
|
||||
let u = if r > l { src[0] + (x.clamp(l, r) - l) / (r - l) * (src[2] - src[0]) } else { src[0] };
|
||||
let w = if b > t { src[1] + (y.clamp(t, b) - t) / (b - t) * (src[3] - src[1]) } else { src[1] };
|
||||
[u, w]
|
||||
};
|
||||
let mut q = |pts: Q, fade: [f64; 4]| {
|
||||
let uv = pts.map(|p| uv_at(p[0], p[1]));
|
||||
let xs = uv.map(|p| p[0]);
|
||||
let ys = uv.map(|p| p[1]);
|
||||
let mn = |a: [f64; 4]| a.iter().copied().fold(f64::INFINITY, f64::min);
|
||||
let mx = |a: [f64; 4]| a.iter().copied().fold(f64::NEG_INFINITY, f64::max);
|
||||
v.push(Layer { source, src: [mn(xs), mn(ys), mx(xs), mx(ys)], quad: pts, uv, alpha, fade });
|
||||
};
|
||||
q([[l, t - fy], [r, t - fy], [r, t], [l, t]], [0.0, 0.0, 1.0, 1.0]);
|
||||
q([[l, b], [r, b], [r, b + fy], [l, b + fy]], [1.0, 1.0, 0.0, 0.0]);
|
||||
q([[l - fx, t], [l, t], [l, b], [l - fx, b]], [0.0, 1.0, 1.0, 0.0]);
|
||||
q([[r, t], [r + fx, t], [r + fx, b], [r, b]], [1.0, 0.0, 0.0, 1.0]);
|
||||
q([[l - fx, t - fy], [l, t - fy], [l, t], [l - fx, t]], [0.0, 0.0, 1.0, 0.0]);
|
||||
q([[r, t - fy], [r + fx, t - fy], [r + fx, t], [r, t]], [0.0, 0.0, 0.0, 1.0]);
|
||||
q([[r, b], [r + fx, b], [r + fx, b + fy], [r, b + fy]], [1.0, 0.0, 0.0, 0.0]);
|
||||
q([[l - fx, b], [l, b], [l, b + fy], [l - fx, b + fy]], [0.0, 1.0, 0.0, 0.0]);
|
||||
v
|
||||
}
|
||||
|
||||
// ---------- opacity fields ----------
|
||||
|
||||
/// The source shown in place through an opacity field `f(x, y)` (0..1) sampled on an `n` × `m`
|
||||
/// grid: each cell is a quad with its corners' opacities. Cells with nothing to show are left out
|
||||
/// and runs of fully opaque cells merge, so most frames stay small.
|
||||
pub(crate) fn field(source: Source, n: usize, m: usize, f: &dyn Fn(f64, f64) -> f64) -> Vec<Layer> {
|
||||
let (n, m) = (n.clamp(1, 256), m.clamp(1, 256));
|
||||
let mut vals = vec![0.0; (n + 1) * (m + 1)];
|
||||
for j in 0..=m {
|
||||
for i in 0..=n {
|
||||
let v = f(i as f64 / n as f64, j as f64 / m as f64);
|
||||
if let Some(x) = vals.get_mut(j * (n + 1) + i) {
|
||||
*x = if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
let at = |i: usize, j: usize| vals.get(j * (n + 1) + i).copied().unwrap_or(0.0);
|
||||
let mut out = Vec::new();
|
||||
for j in 0..m {
|
||||
let (y0, y1) = (j as f64 / m as f64, (j + 1) as f64 / m as f64);
|
||||
let mut run: Option<usize> = None;
|
||||
for i in 0..=n {
|
||||
let full = i < n && at(i, j) >= 1.0 && at(i + 1, j) >= 1.0 && at(i, j + 1) >= 1.0 && at(i + 1, j + 1) >= 1.0;
|
||||
if full {
|
||||
run.get_or_insert(i);
|
||||
continue;
|
||||
}
|
||||
if let Some(s) = run.take() {
|
||||
out.push(Layer::inplace(source, [s as f64 / n as f64, y0, i as f64 / n as f64, y1], 1.0));
|
||||
}
|
||||
if i == n {
|
||||
break;
|
||||
}
|
||||
let c = [at(i, j), at(i + 1, j), at(i + 1, j + 1), at(i, j + 1)];
|
||||
if c.iter().all(|v| *v <= 0.0) {
|
||||
continue;
|
||||
}
|
||||
out.push(Layer::inplace(source, [i as f64 / n as f64, y0, (i + 1) as f64 / n as f64, y1], 1.0).faded(c));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Opacity of a soft edge at position `p` when the edge's middle is at `c` and it ramps over `w`.
|
||||
pub(crate) fn edge(c: f64, p: f64, w: f64) -> f64 {
|
||||
if w <= 1e-9 {
|
||||
return if p <= c { 1.0 } else { 0.0 };
|
||||
}
|
||||
((c - p) / w + 0.5).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// Point on the unit square's boundary seen from its centre at angle `ang` (radians, clockwise
|
||||
/// from 12 o'clock, measured on a slide `a` wide per unit of height).
|
||||
pub(crate) fn boundary(ang: f64, a: f64) -> P {
|
||||
let (dx, dy) = (ang.sin() / a, -ang.cos());
|
||||
let m = (dx.abs() / 0.5).max(dy.abs() / 0.5).max(1e-9);
|
||||
[0.5 + dx / m, 0.5 + dy / m]
|
||||
}
|
||||
|
||||
/// The source shown in place through an angular opacity field `f(angle)` (radians clockwise from
|
||||
/// 12 o'clock, 0..2π): a fan of thin wedges from the centre.
|
||||
pub(crate) fn fan(source: Source, a: f64, steps: usize, f: &dyn Fn(f64) -> f64) -> Vec<Layer> {
|
||||
let n = steps.clamp(8, 720);
|
||||
let mut angles: Vec<f64> = (0..=n).map(|k| 2.0 * PI * k as f64 / n as f64).collect();
|
||||
// The square's corners, so wedges reach them.
|
||||
for c in [[1.0, 0.0], [1.0, 1.0], [0.0, 1.0], [0.0, 0.0]] {
|
||||
let ang = ((c[0] - 0.5) * a).atan2(-(c[1] - 0.5));
|
||||
angles.push(ang.rem_euclid(2.0 * PI));
|
||||
}
|
||||
angles.sort_by(f64::total_cmp);
|
||||
angles.dedup_by(|x, y| (*x - *y).abs() < 1e-9);
|
||||
let mut out = Vec::new();
|
||||
let c = [0.5, 0.5];
|
||||
for w in angles.windows(2) {
|
||||
let [x, y] = w else { continue };
|
||||
let (fx, fy) = (f(*x).clamp(0.0, 1.0), f(*y).clamp(0.0, 1.0));
|
||||
if fx <= 0.0 && fy <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let (bx, by) = (boundary(*x, a), boundary(*y, a));
|
||||
out.push(Layer::tri_uv(source, [c, bx, by], [c, bx, by], [(fx + fy) / 2.0, fx, fy], 1.0));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ---------- polygons ----------
|
||||
|
||||
/// Keep the part of a convex polygon where `n · p <= d`.
|
||||
pub(crate) fn clip(poly: &[P], n: P, d: f64) -> Vec<P> {
|
||||
let mut out = Vec::with_capacity(poly.len() + 2);
|
||||
let side = |p: P| n[0] * p[0] + n[1] * p[1] - d;
|
||||
for (i, p) in poly.iter().enumerate() {
|
||||
let q = poly.get((i + 1) % poly.len()).copied().unwrap_or(*p);
|
||||
let (sp, sq) = (side(*p), side(q));
|
||||
if sp <= 0.0 {
|
||||
out.push(*p);
|
||||
}
|
||||
if (sp < 0.0 && sq > 0.0) || (sp > 0.0 && sq < 0.0) {
|
||||
let k = sp / (sp - sq);
|
||||
out.push([p[0] + (q[0] - p[0]) * k, p[1] + (q[1] - p[1]) * k]);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A convex polygon as triangles: vertices drawn at `map(p)` with texture coordinate `p`.
|
||||
pub(crate) fn poly_layers(source: Source, poly: &[P], alpha: f64, map: &dyn Fn(P) -> P) -> Vec<Layer> {
|
||||
let Some(first) = poly.first().copied() else { return vec![] };
|
||||
poly.windows(2)
|
||||
.skip(1)
|
||||
.filter_map(|w| match w {
|
||||
[b, c] => Some(Layer::tri_uv(source, [map(first), map(*b), map(*c)], [first, *b, *c], [1.0; 3], alpha)),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn rect_poly(r: R) -> Vec<P> {
|
||||
corners(r).to_vec()
|
||||
}
|
||||
|
||||
// ---------- 3-D ----------
|
||||
|
||||
/// A point in the 3-D scene: x right and y down in slide heights (the slide spans x ∈ [−a/2, a/2],
|
||||
/// y ∈ [−½, ½] at z = 0), z away from the viewer.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) struct V3 {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub z: f64,
|
||||
}
|
||||
|
||||
pub(crate) fn v3(x: f64, y: f64, z: f64) -> V3 {
|
||||
V3 { x, y, z }
|
||||
}
|
||||
|
||||
impl V3 {
|
||||
pub fn add(self, o: V3) -> V3 {
|
||||
v3(self.x + o.x, self.y + o.y, self.z + o.z)
|
||||
}
|
||||
pub fn sub(self, o: V3) -> V3 {
|
||||
v3(self.x - o.x, self.y - o.y, self.z - o.z)
|
||||
}
|
||||
/// Rotate about the vertical axis through `c` (degrees; positive turns the right side away).
|
||||
pub fn yaw(self, c: V3, deg: f64) -> V3 {
|
||||
let (s, co) = deg.to_radians().sin_cos();
|
||||
let p = self.sub(c);
|
||||
v3(p.x * co - p.z * s, p.y, p.x * s + p.z * co).add(c)
|
||||
}
|
||||
/// Rotate about the horizontal axis through `c` (degrees; positive turns the top away).
|
||||
pub fn pitch(self, c: V3, deg: f64) -> V3 {
|
||||
let (s, co) = deg.to_radians().sin_cos();
|
||||
let p = self.sub(c);
|
||||
v3(p.x, p.y * co + p.z * s, -p.y * s + p.z * co).add(c)
|
||||
}
|
||||
/// Rotate in the screen plane about `c` (degrees, clockwise).
|
||||
pub fn roll(self, c: V3, deg: f64) -> V3 {
|
||||
let (s, co) = deg.to_radians().sin_cos();
|
||||
let p = self.sub(c);
|
||||
v3(p.x * co - p.y * s, p.x * s + p.y * co, p.z).add(c)
|
||||
}
|
||||
}
|
||||
|
||||
/// A perspective camera looking at the slide plane from `d` slide heights away.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct Cam {
|
||||
pub a: f64,
|
||||
pub d: f64,
|
||||
}
|
||||
|
||||
impl Cam {
|
||||
pub fn p(&self, v: V3) -> P {
|
||||
let k = self.d / (self.d + v.z).max(self.d * 0.02);
|
||||
[v.x * k / self.a + 0.5, v.y * k + 0.5]
|
||||
}
|
||||
/// The 3-D corners (tl, tr, br, bl) of the unit-space rectangle `r` lying on the slide plane.
|
||||
pub fn rect(&self, r: R) -> [V3; 4] {
|
||||
corners(r).map(|p| v3((p[0] - 0.5) * self.a, p[1] - 0.5, 0.0))
|
||||
}
|
||||
/// The unit-space point of a 3-D point on the slide plane.
|
||||
pub fn unit(&self, x: f64, y: f64) -> V3 {
|
||||
v3((x - 0.5) * self.a, y - 0.5, 0.0)
|
||||
}
|
||||
pub fn quad(&self, c: [V3; 4]) -> Q {
|
||||
c.map(|v| self.p(v))
|
||||
}
|
||||
}
|
||||
|
||||
/// Signed area of a quad (positive: front side facing the viewer).
|
||||
pub(crate) fn area(q: &Q) -> f64 {
|
||||
let mut s = 0.0;
|
||||
for i in 0..4 {
|
||||
let (a, b) = (q[i], q[(i + 1) % 4]);
|
||||
s += a[0] * b[1] - b[0] * a[1];
|
||||
}
|
||||
s / 2.0
|
||||
}
|
||||
|
||||
/// A card: `src` of the source mapped onto 3-D corners, if it faces the viewer; `shade` (0..1)
|
||||
/// darkens it. Seen from behind it is left out unless `back` is set (then drawn mirrored).
|
||||
pub(crate) fn card(cam: &Cam, source: Source, src: R, c: [V3; 4], alpha: f64, shade: f64, back: bool) -> Vec<Layer> {
|
||||
let q = cam.quad(c);
|
||||
if !q.iter().all(|p| p[0].is_finite() && p[1].is_finite()) {
|
||||
return vec![];
|
||||
}
|
||||
let front = area(&q) > 0.0;
|
||||
if !front && !back {
|
||||
return vec![];
|
||||
}
|
||||
let l = Layer::quad(source, src, q, alpha);
|
||||
let mut v = vec![l];
|
||||
if shade > 0.001 {
|
||||
v.push(l.shade(shade));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// How much a card facing along `n` (unit normal toward the viewer is (0, 0, −1)) is darkened by
|
||||
/// the light behind the viewer: 0 facing straight on.
|
||||
pub(crate) fn facing_shade(c: [V3; 4], k: f64) -> f64 {
|
||||
let u = c[1].sub(c[0]);
|
||||
let w = c[3].sub(c[0]);
|
||||
let n = v3(u.y * w.z - u.z * w.y, u.z * w.x - u.x * w.z, u.x * w.y - u.y * w.x);
|
||||
let len = (n.x * n.x + n.y * n.y + n.z * n.z).sqrt();
|
||||
if len < 1e-12 {
|
||||
return 0.0;
|
||||
}
|
||||
let cos = (n.z / len).abs();
|
||||
(k * (1.0 - cos)).clamp(0.0, 1.0)
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
//! Transition curves and geometry against values measured on PowerPoint's video export
|
||||
//! (`plan/powerpoint/tools/synth_transitions.ps1`, 2 s transitions, 30 fps).
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use deckcraft_model::anim::TRANSITIONS;
|
||||
|
||||
use crate::transition::{Layer, Source, transition_frame, transition_layers, uses_slide_parts};
|
||||
use crate::transition_geom::{edge, pow_inout, q2, ss};
|
||||
|
||||
const A: f64 = 16.0 / 9.0;
|
||||
|
||||
fn near(a: f64, b: f64, tol: f64) -> bool {
|
||||
(a - b).abs() <= tol
|
||||
}
|
||||
|
||||
fn of(ls: &[Layer], src: Source) -> Vec<&Layer> {
|
||||
ls.iter().filter(|l| l.source == src).collect()
|
||||
}
|
||||
|
||||
fn left(l: &Layer) -> f64 {
|
||||
l.quad.iter().map(|p| p[0]).fold(f64::INFINITY, f64::min)
|
||||
}
|
||||
|
||||
fn top(l: &Layer) -> f64 {
|
||||
l.quad.iter().map(|p| p[1]).fold(f64::INFINITY, f64::min)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fade_follows_powerpoints_s_curve() {
|
||||
// Measured share of the new slide in a 2 s Fade at t = 0.25, 0.5, 0.75: 0.155, 0.49, 0.82.
|
||||
for (t, want) in [(0.25, 0.155), (0.5, 0.49), (0.75, 0.82)] {
|
||||
let ls = transition_layers("fade", "smoothly", t);
|
||||
let new = of(&ls, Source::New);
|
||||
assert!(near(new[0].alpha, want, 0.04), "t={t}: {}", new[0].alpha);
|
||||
}
|
||||
// Through black: the old slide is gone to black at the middle, then the new one fades in.
|
||||
let mid = transition_layers("fade", "throughBlack", 0.5);
|
||||
assert!(mid.iter().all(|l| l.source == Source::Black));
|
||||
let q = transition_layers("fade", "throughBlack", 0.25);
|
||||
assert!(near(of(&q, Source::Old)[0].alpha, 0.5, 0.01));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_moves_both_slides_with_an_eased_curve() {
|
||||
for (o, dx, dy) in [("l", -1.0, 0.0), ("r", 1.0, 0.0), ("u", 0.0, -1.0), ("d", 0.0, 1.0)] {
|
||||
for t in [0.25, 0.5, 0.75] {
|
||||
let ls = transition_layers("push", o, t);
|
||||
let (old, new) = (of(&ls, Source::Old)[0], of(&ls, Source::New)[0]);
|
||||
let s = q2(t);
|
||||
assert!(near(old.quad[0][0], dx * s, 1e-9) && near(old.quad[0][1], dy * s, 1e-9), "{o} {t}");
|
||||
assert!(near(new.quad[0][0], dx * (s - 1.0), 1e-9) && near(new.quad[0][1], dy * (s - 1.0), 1e-9), "{o} {t}");
|
||||
}
|
||||
}
|
||||
// Ease in-out: slow at both ends.
|
||||
assert!(q2(0.1) < 0.05 && q2(0.9) > 0.95 && near(q2(0.5), 0.5, 1e-9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cover_and_uncover_match_measured_positions() {
|
||||
// Offset of the new slide in Cover Left (fraction of the width) at measured times.
|
||||
for (t, off) in [(0.445, 0.655), (0.495, 0.512), (0.544, 0.359), (0.643, 0.134), (0.742, 0.046)] {
|
||||
let ls = transition_layers("cover", "l", t);
|
||||
assert!(near(left(of(&ls, Source::New)[0]), off, 0.05), "cover t={t}");
|
||||
}
|
||||
// Offset of the old slide in Uncover Left.
|
||||
for (t, off) in [(0.244, -0.06), (0.343, -0.14), (0.442, -0.32), (0.508, -0.5), (0.558, -0.63)] {
|
||||
let ls = transition_layers("uncover", "l", t);
|
||||
assert!(near(left(of(&ls, Source::Old)[0]), off, 0.05), "uncover t={t}");
|
||||
}
|
||||
// Diagonal Cover shoots in and settles slowly.
|
||||
for (t, off) in [(0.142, 0.728), (0.257, 0.49), (0.356, 0.29), (0.455, 0.146), (0.604, 0.053)] {
|
||||
let ls = transition_layers("cover", "lu", t);
|
||||
let n = of(&ls, Source::New)[0];
|
||||
assert!(near(left(n), off, 0.06) && near(top(n), off, 0.06), "cover lu t={t}");
|
||||
}
|
||||
assert!(near(pow_inout(0.25, 3.0), 0.0625, 1e-9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wipe_has_a_wide_soft_edge() {
|
||||
// Measured along a row of Wipe Right at t = 0.5: about half-way through at x = 0.6, nearly
|
||||
// nothing at the right edge, fully new on the left third.
|
||||
let c = -0.4 + 1.8 * 1.05 * 0.5;
|
||||
assert!(near(edge(c, 0.6, 0.8), 0.49, 0.1));
|
||||
assert!(edge(c, 1.0, 0.8) < 0.1);
|
||||
assert!(edge(c, 0.2, 0.8) > 0.8);
|
||||
// Directions: "r" starts on the left, "l" on the right, "u" at the bottom, "d" at the top,
|
||||
// diagonals at the opposite corner.
|
||||
let first = |o: &str| {
|
||||
let ls = transition_layers("wipe", o, 0.15);
|
||||
let n = of(&ls, Source::New);
|
||||
assert!(!n.is_empty(), "{o}");
|
||||
let cx = n.iter().map(|l| (l.quad[0][0] + l.quad[2][0]) / 2.0).sum::<f64>() / n.len() as f64;
|
||||
let cy = n.iter().map(|l| (l.quad[0][1] + l.quad[2][1]) / 2.0).sum::<f64>() / n.len() as f64;
|
||||
(cx, cy)
|
||||
};
|
||||
assert!(first("r").0 < 0.3 && first("l").0 > 0.7);
|
||||
assert!(first("u").1 > 0.7 && first("d").1 < 0.3);
|
||||
let ru = first("ru");
|
||||
assert!(ru.0 < 0.4 && ru.1 > 0.6, "{ru:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_and_shape_open_from_the_middle() {
|
||||
for o in ["horzOut", "vertOut"] {
|
||||
let ls = transition_layers("split", o, 0.2);
|
||||
let n = of(&ls, Source::New);
|
||||
assert!(n.iter().all(|l| {
|
||||
let c = [(l.quad[0][0] + l.quad[2][0]) / 2.0, (l.quad[0][1] + l.quad[2][1]) / 2.0];
|
||||
if o.starts_with("horz") { (c[1] - 0.5).abs() < 0.4 } else { (c[0] - 0.5).abs() < 0.4 }
|
||||
}));
|
||||
}
|
||||
// Shape In is done with the old slide at half time; Shape Out starts the new one then.
|
||||
assert!(of(&transition_layers("shape", "in", 0.55), Source::Old).is_empty());
|
||||
assert!(of(&transition_layers("shape", "out", 0.45), Source::New).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_content_curves() {
|
||||
// Pan: the new content's offset (fraction of the width) at measured times.
|
||||
for (t, off) in [(0.6, 0.134), (0.7, 0.086), (0.8, 0.039), (0.9, 0.011)] {
|
||||
let ls = transition_layers("pan", "l", t);
|
||||
assert!(near(left(of(&ls, Source::NewContent)[0]), off, 0.07), "pan t={t}");
|
||||
}
|
||||
// The backgrounds cross-fade under the moving contents.
|
||||
let ls = transition_layers("pan", "l", 0.5);
|
||||
assert_eq!(ls.first().map(|l| l.source), Some(Source::OldBack));
|
||||
assert!(of(&ls, Source::NewBack)[0].alpha > 0.5);
|
||||
// Ferris Wheel: the old content drops (top edge, slide heights) as measured.
|
||||
for (t, dy) in [(0.147, 0.06), (0.246, 0.17), (0.345, 0.36), (0.444, 0.70)] {
|
||||
let ls = transition_layers("ferris", "l", t);
|
||||
let q = of(&ls, Source::OldContent)[0].quad;
|
||||
let cy = q.iter().map(|p| p[1]).sum::<f64>() / 4.0 - 0.5;
|
||||
assert!(near(cy, dy, 0.08), "ferris t={t}: {cy}");
|
||||
}
|
||||
for k in ["pan", "ferris", "conveyor", "rotate", "window", "orbit", "flythrough"] {
|
||||
assert!(uses_slide_parts(k, ""), "{k}");
|
||||
}
|
||||
assert!(uses_slide_parts("reveal", "smoothlyLeft") && !uses_slide_parts("reveal", "blackLeft"));
|
||||
assert!(!uses_slide_parts("cube", "l") && !uses_slide_parts("push", "l"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cube_turns_toward_the_option() {
|
||||
// Cube Left at the middle: the shared edge is near the middle, the old face on the left.
|
||||
let ls = transition_frame("cube", "l", 0.5, A);
|
||||
let (o, n) = (of(&ls, Source::Old)[0], of(&ls, Source::New)[0]);
|
||||
let o_right = o.quad.iter().map(|p| p[0]).fold(f64::NEG_INFINITY, f64::max);
|
||||
assert!(near(o_right, left(n), 0.02), "shared edge: {o_right} vs {}", left(n));
|
||||
assert!(o_right > 0.3 && o_right < 0.7);
|
||||
// Up: the new face comes from below.
|
||||
let ls = transition_frame("cube", "u", 0.5, A);
|
||||
assert!(top(of(&ls, Source::New)[0]) > top(of(&ls, Source::Old)[0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switch_swaps_the_cards_past_halfway() {
|
||||
let order = |t: f64| {
|
||||
let ls = transition_frame("switch", "l", t, A);
|
||||
let pos = |s: Source| ls.iter().position(|l| l.source == s);
|
||||
(pos(Source::Old), pos(Source::New))
|
||||
};
|
||||
let (o, n) = order(0.3);
|
||||
assert!(o > n, "old in front early");
|
||||
let (o, n) = order(0.8);
|
||||
assert!(n > o, "new in front late");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clock_hand_sweeps_at_constant_speed() {
|
||||
// The share of the slide covered grows linearly.
|
||||
let area = |t: f64| {
|
||||
let ls = transition_layers("clock", "clockwise", t);
|
||||
of(&ls, Source::New)
|
||||
.iter()
|
||||
.map(|l| {
|
||||
let [a, b, c, _] = l.quad;
|
||||
((b[0] - a[0]) * (c[1] - a[1]) - (c[0] - a[0]) * (b[1] - a[1])).abs() / 2.0 * (l.fade[0] + l.fade[1] + l.fade[2]) / 3.0
|
||||
})
|
||||
.sum::<f64>()
|
||||
};
|
||||
let (a1, a2, a3) = (area(0.25), area(0.5), area(0.75));
|
||||
assert!(near(a2 - a1, a3 - a2, 0.08), "{a1} {a2} {a3}");
|
||||
assert!(near(a2, 0.5, 0.08), "{a2}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_change_the_frame() {
|
||||
// Every option of a transition draws something different from its other options midway.
|
||||
for (id, _, _, _, opts) in TRANSITIONS {
|
||||
if matches!(*id, "morph" | "none" | "random") || opts.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
for (i, a) in opts.iter().enumerate() {
|
||||
for b in opts.iter().skip(i + 1) {
|
||||
let differs = [0.2, 0.35, 0.5, 0.8].iter().any(|t| transition_layers(id, a, *t) != transition_layers(id, b, *t));
|
||||
assert!(differs, "{id}: {a} vs {b}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_stay_cheap() {
|
||||
// Per frame, the show draws these meshes from cached slide textures: keep them small.
|
||||
for (id, _, _, _, opts) in TRANSITIONS {
|
||||
let mut options: Vec<&str> = opts.to_vec();
|
||||
options.push("");
|
||||
for o in options {
|
||||
for t in [0.1, 0.3, 0.5, 0.7, 0.9] {
|
||||
let tris: usize = transition_frame(id, o, t, A).iter().map(|l| crate::layer_mesh(l).1.len()).sum();
|
||||
assert!(tris < 30_000, "{id}/{o} t={t}: {tris} triangles");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_aspects_are_safe() {
|
||||
for a in [f64::NAN, 0.0, -3.0, 1e9, f64::INFINITY] {
|
||||
for (id, ..) in TRANSITIONS {
|
||||
for l in transition_frame(id, "", 0.4, a) {
|
||||
assert!(l.quad.iter().all(|p| p[0].is_finite() && p[1].is_finite()));
|
||||
assert!(l.fade.iter().all(|f| (0.0..=1.0).contains(f)));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(near(ss(0.5), 0.5, 1e-12));
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
//! CDEF (7.15).
|
||||
|
||||
use crate::frame::{FrameBuf, Plane};
|
||||
use crate::spec_tables::*;
|
||||
use crate::spec_tables::{CDEF_DIRECTIONS, CDEF_PRI_TAPS, CDEF_SEC_TAPS, CDEF_UV_DIR, DIV_TABLE};
|
||||
use crate::state::FrameState;
|
||||
|
||||
/// Run CDEF on `fs.cur`; returns the CdefFrame, or None when no block is filtered (CdefFrame
|
||||
@@ -242,14 +242,14 @@ fn filter_rows<const W: usize>(
|
||||
}
|
||||
for i in 0..h {
|
||||
let ci = ((i + 2) * PAD_STRIDE + 2) as isize;
|
||||
let Some(&x) = b16.get(ci as usize..).and_then(|r| r.first_chunk::<W>()) else {
|
||||
let Some(&x) = b16.get(ci as usize..).and_then(<[_]>::first_chunk::<W>) else {
|
||||
return;
|
||||
};
|
||||
let (mut sum, mut max, mut min) = ([0i16; W], x, x);
|
||||
for &(off, weight, thr, adj) in taps {
|
||||
let (weight, thr) = (weight as i16, thr as i16);
|
||||
let start = (ci + off) as usize;
|
||||
let Some(p) = b16.get(start..).and_then(|r| r.first_chunk::<W>()) else {
|
||||
let Some(p) = b16.get(start..).and_then(<[_]>::first_chunk::<W>) else {
|
||||
return;
|
||||
};
|
||||
for j in 0..W {
|
||||
|
||||
+21
-1
@@ -1,6 +1,26 @@
|
||||
//! The adaptive CDF arrays (spec 6.8.2 init_non_coeff_cdfs / init_coeff_cdfs, load_cdfs).
|
||||
|
||||
use crate::spec_tables::*;
|
||||
use crate::spec_tables::{
|
||||
DEFAULT_ANGLE_DELTA_CDF, DEFAULT_CFL_ALPHA_CDF, DEFAULT_CFL_SIGN_CDF, DEFAULT_COEFF_BASE_CDF, DEFAULT_COEFF_BASE_EOB_CDF, DEFAULT_COEFF_BR_CDF,
|
||||
DEFAULT_COMP_BWD_REF_CDF, DEFAULT_COMP_GROUP_IDX_CDF, DEFAULT_COMP_MODE_CDF, DEFAULT_COMP_REF_CDF, DEFAULT_COMP_REF_TYPE_CDF,
|
||||
DEFAULT_COMPOUND_IDX_CDF, DEFAULT_COMPOUND_MODE_CDF, DEFAULT_COMPOUND_TYPE_CDF, DEFAULT_DC_SIGN_CDF, DEFAULT_DELTA_LF_CDF, DEFAULT_DELTA_Q_CDF,
|
||||
DEFAULT_DRL_MODE_CDF, DEFAULT_EOB_EXTRA_CDF, DEFAULT_EOB_PT_16_CDF, DEFAULT_EOB_PT_32_CDF, DEFAULT_EOB_PT_64_CDF, DEFAULT_EOB_PT_128_CDF,
|
||||
DEFAULT_EOB_PT_256_CDF, DEFAULT_EOB_PT_512_CDF, DEFAULT_EOB_PT_1024_CDF, DEFAULT_FILTER_INTRA_CDF, DEFAULT_FILTER_INTRA_MODE_CDF,
|
||||
DEFAULT_INTER_INTRA_CDF, DEFAULT_INTER_INTRA_MODE_CDF, DEFAULT_INTER_TX_TYPE_SET1_CDF, DEFAULT_INTER_TX_TYPE_SET2_CDF,
|
||||
DEFAULT_INTER_TX_TYPE_SET3_CDF, DEFAULT_INTERP_FILTER_CDF, DEFAULT_INTRA_TX_TYPE_SET1_CDF, DEFAULT_INTRA_TX_TYPE_SET2_CDF, DEFAULT_INTRABC_CDF,
|
||||
DEFAULT_IS_INTER_CDF, DEFAULT_MOTION_MODE_CDF, DEFAULT_MV_BIT_CDF, DEFAULT_MV_CLASS_CDF, DEFAULT_MV_CLASS0_BIT_CDF, DEFAULT_MV_CLASS0_FR_CDF,
|
||||
DEFAULT_MV_CLASS0_HP_CDF, DEFAULT_MV_FR_CDF, DEFAULT_MV_HP_CDF, DEFAULT_MV_JOINT_CDF, DEFAULT_MV_SIGN_CDF, DEFAULT_NEW_MV_CDF,
|
||||
DEFAULT_PALETTE_SIZE_2_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_2_Y_COLOR_CDF, DEFAULT_PALETTE_SIZE_3_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_3_Y_COLOR_CDF,
|
||||
DEFAULT_PALETTE_SIZE_4_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_4_Y_COLOR_CDF, DEFAULT_PALETTE_SIZE_5_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_5_Y_COLOR_CDF,
|
||||
DEFAULT_PALETTE_SIZE_6_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_6_Y_COLOR_CDF, DEFAULT_PALETTE_SIZE_7_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_7_Y_COLOR_CDF,
|
||||
DEFAULT_PALETTE_SIZE_8_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_8_Y_COLOR_CDF, DEFAULT_PALETTE_UV_MODE_CDF, DEFAULT_PALETTE_UV_SIZE_CDF,
|
||||
DEFAULT_PALETTE_Y_MODE_CDF, DEFAULT_PALETTE_Y_SIZE_CDF, DEFAULT_PARTITION_W8_CDF, DEFAULT_PARTITION_W16_CDF, DEFAULT_PARTITION_W32_CDF,
|
||||
DEFAULT_PARTITION_W64_CDF, DEFAULT_PARTITION_W128_CDF, DEFAULT_REF_MV_CDF, DEFAULT_RESTORATION_TYPE_CDF, DEFAULT_SEGMENT_ID_CDF,
|
||||
DEFAULT_SEGMENT_ID_PREDICTED_CDF, DEFAULT_SINGLE_REF_CDF, DEFAULT_SKIP_CDF, DEFAULT_SKIP_MODE_CDF, DEFAULT_TX_8X8_CDF, DEFAULT_TX_16X16_CDF,
|
||||
DEFAULT_TX_32X32_CDF, DEFAULT_TX_64X64_CDF, DEFAULT_TXB_SKIP_CDF, DEFAULT_TXFM_SPLIT_CDF, DEFAULT_UNI_COMP_REF_CDF, DEFAULT_USE_OBMC_CDF,
|
||||
DEFAULT_USE_SGRPROJ_CDF, DEFAULT_USE_WIENER_CDF, DEFAULT_UV_MODE_CFL_ALLOWED_CDF, DEFAULT_UV_MODE_CFL_NOT_ALLOWED_CDF, DEFAULT_WEDGE_INDEX_CDF,
|
||||
DEFAULT_WEDGE_INTER_INTRA_CDF, DEFAULT_Y_MODE_CDF, DEFAULT_ZERO_MV_CDF, FRAME_LF_COUNT, MV_CONTEXTS,
|
||||
};
|
||||
|
||||
/// Every CDF array listed by init_non_coeff_cdfs and init_coeff_cdfs. Each innermost array holds
|
||||
/// N cumulative values (the last is 32768) plus the adaptation counter.
|
||||
|
||||
+129
-5
@@ -17,7 +17,11 @@ use crate::cdf::Cdfs;
|
||||
use crate::frame::FrameBuf;
|
||||
use crate::header::{FilmGrainParams, FrameHeader, HeaderState, LoopFilterDeltas, RefInfo, SegmentationFeatures, SequenceHeader, default_gm_params};
|
||||
use crate::par::Pool;
|
||||
use crate::spec_tables::*;
|
||||
use crate::spec_tables::{
|
||||
ALTREF_FRAME, ALTREF2_FRAME, BWDREF_FRAME, DEFAULT_INTRA_FRAME_Y_MODE_CDF, DIV_MULT, GOLDEN_FRAME, INTRA_FRAME, INTRA_ONLY_FRAME, KEY_FRAME,
|
||||
LAST_FRAME, LAST2_FRAME, MAX_FRAME_DISTANCE, MAX_OFFSET_HEIGHT, MAX_OFFSET_WIDTH, MFMV_STACK_SIZE, NUM_REF_FRAMES, PRIMARY_REF_NONE,
|
||||
REFMVS_LIMIT, REFS_PER_FRAME,
|
||||
};
|
||||
use crate::state::{FrameShared, FrameState, TileState};
|
||||
use crate::stats::{DecodeStats, Stage, Timer};
|
||||
use crate::tile::TileDecoder;
|
||||
@@ -61,7 +65,7 @@ impl<T: Clone> Slot<T> {
|
||||
}
|
||||
|
||||
fn set(&self, v: Result<T>) {
|
||||
let mut g = self.value.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut g = self.value.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
*g = Some(v);
|
||||
self.cv.notify_all();
|
||||
}
|
||||
@@ -71,12 +75,12 @@ impl<T: Clone> Slot<T> {
|
||||
}
|
||||
|
||||
fn wait(&self) -> Result<T> {
|
||||
let mut g = self.value.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut g = self.value.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
loop {
|
||||
if let Some(v) = g.as_ref() {
|
||||
return v.clone();
|
||||
}
|
||||
g = self.cv.wait(g).unwrap_or_else(|e| e.into_inner());
|
||||
g = self.cv.wait(g).unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -206,12 +210,35 @@ pub struct Decoder {
|
||||
/// Frames submitted to the workers and not known to be finished (oldest first).
|
||||
in_flight: VecDeque<Arc<FrameSlot>>,
|
||||
max_in_flight: usize,
|
||||
/// Bytes the picture store may take ([`deckcraft_bitstream::budget`]).
|
||||
memory_budget: u64,
|
||||
/// Shown pictures not yet returned, in output order.
|
||||
out: VecDeque<Output>,
|
||||
/// pts of the temporal unit being decoded.
|
||||
pts: i64,
|
||||
}
|
||||
|
||||
/// Whether `pictures` frames of `width` x `height`, padded as `FrameBuf::new` pads them, fit in
|
||||
/// `budget` bytes.
|
||||
fn store_fits(width: u32, height: u32, color: &crate::header::ColorConfig, pictures: usize, budget: u64) -> bool {
|
||||
let shape = deckcraft_bitstream::budget::PictureShape {
|
||||
width: u64::from(width).div_ceil(128) * 128 + 160,
|
||||
height: u64::from(height).div_ceil(128) * 128 + 160,
|
||||
shift_x: u32::from(color.subsampling_x),
|
||||
shift_y: u32::from(color.subsampling_y),
|
||||
chroma_planes: if color.mono_chrome { 0 } else { 2 },
|
||||
bytes_per_sample: 2,
|
||||
};
|
||||
shape.fits(pictures as u64, budget)
|
||||
}
|
||||
|
||||
impl Decoder {
|
||||
/// Cap the bytes of decoded pictures (references, current, in flight) this decoder may hold.
|
||||
pub fn set_memory_budget(&mut self, bytes: u64) {
|
||||
self.memory_budget = bytes;
|
||||
}
|
||||
}
|
||||
|
||||
fn default_threads() -> usize {
|
||||
#[cfg(all(feature = "threads", not(target_arch = "wasm32")))]
|
||||
{
|
||||
@@ -268,6 +295,7 @@ impl Decoder {
|
||||
workers,
|
||||
in_flight: VecDeque::new(),
|
||||
max_in_flight: fthreads + 2,
|
||||
memory_budget: deckcraft_bitstream::budget::DECODE_MEMORY_BUDGET,
|
||||
out: VecDeque::new(),
|
||||
pts: 0,
|
||||
}
|
||||
@@ -508,6 +536,12 @@ impl Decoder {
|
||||
if fh.upscaled_width > 65536 || fh.frame_height > 65536 {
|
||||
return Err(Error::Invalid("frame size"));
|
||||
}
|
||||
// The axis cap alone allows 8 GiB pictures: check the whole store (every reference slot,
|
||||
// the new frame and those in flight, as `FrameBuf::new` pads them) before allocating.
|
||||
let width = fh.upscaled_width.max(fh.frame_width);
|
||||
if !store_fits(width, fh.frame_height, &seq.color, NUM_REF_FRAMES + 1 + self.max_in_flight, self.memory_budget) {
|
||||
return Err(Error::Invalid("frame size exceeds the decoder memory budget"));
|
||||
}
|
||||
if !fh.frame_is_intra {
|
||||
for i in 0..REFS_PER_FRAME {
|
||||
if self.ref_frames[fh.ref_frame_idx[i]].is_none() {
|
||||
@@ -555,6 +589,7 @@ impl Decoder {
|
||||
if tg_end >= num_tiles || tg_start > tg_end {
|
||||
return Err(Error::Invalid("tile group range"));
|
||||
}
|
||||
check_tile_group_start(job.tiles.len(), tg_start)?;
|
||||
let mut pos = r.byte_pos();
|
||||
for tile_num in tg_start..=tg_end {
|
||||
let size = if tile_num == tg_end {
|
||||
@@ -633,7 +668,9 @@ impl Decoder {
|
||||
/// Decode a frame job and publish its results.
|
||||
fn run_job(job: FrameJob, pool: &Pool, stats: &Mutex<DecodeStats>) {
|
||||
let mut st = DecodeStats::default();
|
||||
match decode_frame(&job, pool, &mut st) {
|
||||
// A panic while decoding must still publish an error: the output and every frame that
|
||||
// references this one wait on these slots.
|
||||
match guarded(|| decode_frame(&job, pool, &mut st)) {
|
||||
Ok((rf, pic)) => {
|
||||
if let Some(ps) = &job.picture {
|
||||
ps.set(pic.map(Arc::new).ok_or(Error::Invalid("shown frame without picture")));
|
||||
@@ -652,6 +689,12 @@ fn run_job(job: FrameJob, pool: &Pool, stats: &Mutex<DecodeStats>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f`, turning a panic into an error (the decoder's own code never panics on purpose; this
|
||||
/// keeps a bug from stranding the job's waiters).
|
||||
fn guarded<R>(f: impl FnOnce() -> Result<R>) -> Result<R> {
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).unwrap_or(Err(Error::Invalid("frame decode panicked")))
|
||||
}
|
||||
|
||||
/// The whole decoding process of one frame: setup, tiles, post-filters, motion vector storage
|
||||
/// and the output picture.
|
||||
fn decode_frame(job: &FrameJob, pool: &Pool, stats: &mut DecodeStats) -> Result<(Arc<RefFrame>, Option<Picture>)> {
|
||||
@@ -947,3 +990,84 @@ fn project(fs: &mut FrameState, src: usize, dst_sign: i32) -> bool {
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Tile groups must arrive in order with no gaps and no overlap: each one starts at the tile
|
||||
/// after the last one received (`tg_start` semantics, 6.10.1).
|
||||
fn check_tile_group_start(received: usize, tg_start: usize) -> Result<()> {
|
||||
if tg_start == received { Ok(()) } else { Err(Error::Invalid("tile group out of order")) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::check_tile_group_start;
|
||||
|
||||
#[test]
|
||||
fn tile_groups_must_be_contiguous() {
|
||||
assert!(check_tile_group_start(0, 0).is_ok());
|
||||
assert!(check_tile_group_start(2, 2).is_ok());
|
||||
// duplicate coverage
|
||||
assert!(check_tile_group_start(2, 0).is_err());
|
||||
assert!(check_tile_group_start(2, 1).is_err());
|
||||
// skipped tiles
|
||||
assert!(check_tile_group_start(0, 1).is_err());
|
||||
assert!(check_tile_group_start(2, 3).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod guard_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_panicking_job_still_publishes_an_error_to_its_waiters() {
|
||||
let slot: Arc<Slot<u32>> = Slot::new();
|
||||
let picture: Arc<Slot<u32>> = Slot::new();
|
||||
// Waiters: the output and a dependent frame, on their own threads.
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
for s in [slot.clone(), picture.clone()] {
|
||||
let tx = tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send(s.wait().is_err());
|
||||
});
|
||||
}
|
||||
let (s, p) = (slot.clone(), picture.clone());
|
||||
std::thread::spawn(move || {
|
||||
let r: Result<u32> = guarded(|| panic!("hostile frame"));
|
||||
p.set(r.clone());
|
||||
s.set(r);
|
||||
});
|
||||
for _ in 0..2 {
|
||||
assert_eq!(rx.recv_timeout(std::time::Duration::from_secs(10)), Ok(true));
|
||||
}
|
||||
assert_eq!(guarded(|| Ok(5)).ok(), Some(5));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod budget_tests {
|
||||
use super::*;
|
||||
use deckcraft_bitstream::budget::DECODE_MEMORY_BUDGET;
|
||||
|
||||
fn color(mono: bool) -> crate::header::ColorConfig {
|
||||
crate::header::ColorConfig { mono_chrome: mono, subsampling_x: 1, subsampling_y: 1, ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_axis_maximum_is_over_budget_and_4k_is_not() {
|
||||
let refs = NUM_REF_FRAMES + 1 + 4;
|
||||
assert!(!store_fits(65536, 65536, &color(false), refs, DECODE_MEMORY_BUDGET));
|
||||
assert!(!store_fits(65536, 65536, &color(true), 1, DECODE_MEMORY_BUDGET));
|
||||
assert!(store_fits(3840, 2160, &color(false), refs, DECODE_MEMORY_BUDGET));
|
||||
assert!(store_fits(1920, 1080, &color(false), refs, DECODE_MEMORY_BUDGET));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_budget_boundary_and_reference_count() {
|
||||
// 8x8 pads to 288x288: luma 82944 + chroma 2 * 20736 samples, 2 bytes each.
|
||||
let one = (288 * 288 + 2 * 144 * 144) * 2;
|
||||
assert!(store_fits(8, 8, &color(false), 9, 9 * one));
|
||||
assert!(!store_fits(8, 8, &color(false), 9, 9 * one - 1));
|
||||
assert!(!store_fits(8, 8, &color(false), 10, 9 * one));
|
||||
assert!(store_fits(8, 8, &color(true), 9, 9 * 288 * 288 * 2));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ const POOL_MAX_SAMPLES: usize = 96 << 20;
|
||||
fn zeroed(len: usize) -> Vec<u16> {
|
||||
if len >= POOL_MIN_LEN {
|
||||
let found = {
|
||||
let mut pool = PLANE_POOL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut pool = PLANE_POOL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
pool.iter().position(|v| v.capacity() >= len && v.capacity() <= 2 * len).map(|i| pool.swap_remove(i))
|
||||
};
|
||||
if let Some(mut v) = found {
|
||||
@@ -52,8 +52,8 @@ impl Drop for Plane {
|
||||
fn drop(&mut self) {
|
||||
if self.data.capacity() >= POOL_MIN_LEN {
|
||||
let v = std::mem::take(&mut self.data);
|
||||
let mut pool = PLANE_POOL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let kept: usize = pool.iter().map(|b| b.capacity()).sum();
|
||||
let mut pool = PLANE_POOL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let kept: usize = pool.iter().map(Vec::capacity).sum();
|
||||
if kept + v.capacity() <= POOL_MAX_SAMPLES {
|
||||
pool.push(v);
|
||||
}
|
||||
|
||||
+50
-17
@@ -5,6 +5,39 @@ use crate::header::{FilmGrainParams, SequenceHeader};
|
||||
use crate::spec_tables::{GAUSSIAN_SEQUENCE, MC_IDENTITY};
|
||||
|
||||
#[inline]
|
||||
/// One plane's scaling lookup (7.18.3.4) from its `n` points. The header rejects points whose
|
||||
/// values don't strictly increase; a pair that still doesn't (defence in depth) is skipped
|
||||
/// instead of dividing by zero. No points: all zero.
|
||||
fn scaling_lut(n: usize, xs: &[u8], ys: &[u8]) -> [i32; 256] {
|
||||
let mut l = [0i32; 256];
|
||||
let n = n.min(xs.len()).min(ys.len());
|
||||
let (Some(&x0), Some(&y0)) = (xs.first(), ys.first()) else { return l };
|
||||
if n == 0 {
|
||||
return l;
|
||||
}
|
||||
for v in l.iter_mut().take(x0 as usize) {
|
||||
*v = y0 as i32;
|
||||
}
|
||||
for i in 0..n - 1 {
|
||||
let (xa, xb, ya, yb) = (xs[i] as i32, xs[i + 1] as i32, ys[i] as i32, ys[i + 1] as i32);
|
||||
let dx = xb - xa;
|
||||
if dx <= 0 {
|
||||
continue;
|
||||
}
|
||||
let delta = (yb - ya) * ((65536 + (dx >> 1)) / dx);
|
||||
for x in 0..dx {
|
||||
if let Some(v) = l.get_mut((xa + x) as usize) {
|
||||
*v = ya + ((x * delta + 32768) >> 16);
|
||||
}
|
||||
}
|
||||
}
|
||||
let (xl, yl) = (xs[n - 1], ys[n - 1]);
|
||||
for v in l.iter_mut().skip(xl as usize) {
|
||||
*v = yl as i32;
|
||||
}
|
||||
l
|
||||
}
|
||||
|
||||
fn round2(x: i32, n: u32) -> i32 {
|
||||
if n == 0 { x } else { (x + (1 << (n - 1))) >> n }
|
||||
}
|
||||
@@ -124,23 +157,7 @@ pub(crate) fn apply(pic: &mut Picture, g: &FilmGrainParams, seq: &SequenceHeader
|
||||
} else {
|
||||
(g.num_cr_points, &g.point_cr_value, &g.point_cr_scaling)
|
||||
};
|
||||
if n == 0 {
|
||||
continue;
|
||||
}
|
||||
for v in l.iter_mut().take(xs[0] as usize) {
|
||||
*v = ys[0] as i32;
|
||||
}
|
||||
for i in 0..n - 1 {
|
||||
let dy = ys[i + 1] as i32 - ys[i] as i32;
|
||||
let dx = xs[i + 1] as i32 - xs[i] as i32;
|
||||
let delta = dy * ((65536 + (dx >> 1)) / dx);
|
||||
for x in 0..dx {
|
||||
l[xs[i] as usize + x as usize] = ys[i] as i32 + ((x * delta + 32768) >> 16);
|
||||
}
|
||||
}
|
||||
for v in l.iter_mut().skip(xs[n - 1] as usize) {
|
||||
*v = ys[n - 1] as i32;
|
||||
}
|
||||
*l = scaling_lut(n, xs, ys);
|
||||
}
|
||||
let scale_lut = |plane: usize, index: i32| -> i32 {
|
||||
let sh = bd - 8;
|
||||
@@ -274,3 +291,19 @@ pub(crate) fn apply(pic: &mut Picture, g: &FilmGrainParams, seq: &SequenceHeader
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn scaling_lut_interpolates_and_tolerates_duplicate_points() {
|
||||
let l = scaling_lut(2, &[0, 255], &[0, 255]);
|
||||
assert_eq!((l[0], l[128], l[255]), (0, 128, 255));
|
||||
// Duplicate and descending x values don't divide by zero.
|
||||
let l = scaling_lut(4, &[10, 10, 5, 200], &[20, 40, 60, 80]);
|
||||
assert_eq!(l[0], 20);
|
||||
assert_eq!(l[255], 80);
|
||||
assert_eq!(scaling_lut(0, &[], &[]), [0; 256]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
//! Sequence header (5.5) and uncompressed frame header (5.9) parsing.
|
||||
|
||||
use crate::bits::BitReader;
|
||||
use crate::spec_tables::*;
|
||||
use crate::spec_tables::{
|
||||
AFFINE, ALTREF_FRAME, ALTREF2_FRAME, BWDREF_FRAME, GM_ABS_ALPHA_BITS, GM_ABS_TRANS_BITS, GM_ABS_TRANS_ONLY_BITS, GM_ALPHA_PREC_BITS,
|
||||
GM_TRANS_ONLY_PREC_BITS, GM_TRANS_PREC_BITS, GOLDEN_FRAME, IDENTITY, INTER_FRAME, INTRA_ONLY_FRAME, KEY_FRAME, LAST_FRAME, MAX_SEGMENTS,
|
||||
MAX_TILE_AREA, MAX_TILE_COLS, MAX_TILE_ROWS, MAX_TILE_WIDTH, NUM_REF_FRAMES, ONLY_4X4, PRIMARY_REF_NONE, REF_FRAME_LIST, REFS_PER_FRAME,
|
||||
RESTORATION_TILESIZE_MAX, RESTORE_NONE, RESTORE_SGRPROJ, RESTORE_SWITCHABLE, RESTORE_WIENER, ROTZOOM, SEG_LVL_ALT_Q, SEG_LVL_MAX,
|
||||
SEG_LVL_REF_FRAME, SELECT_INTEGER_MV, SELECT_SCREEN_CONTENT_TOOLS, SUPERRES_DENOM_BITS, SUPERRES_DENOM_MIN, SUPERRES_NUM, SWITCH_FRAME,
|
||||
TOTAL_REFS_PER_FRAME, TRANSLATION, TX_MODE_LARGEST, TX_MODE_SELECT, WARPEDMODEL_PREC_BITS,
|
||||
};
|
||||
use crate::{Error, Result};
|
||||
|
||||
pub const EIGHTTAP: u8 = 0;
|
||||
@@ -1256,6 +1263,7 @@ impl FrameHeader {
|
||||
g.point_y_value[i] = r.f(8)? as u8;
|
||||
g.point_y_scaling[i] = r.f(8)? as u8;
|
||||
}
|
||||
check_increasing(&g.point_y_value[..g.num_y_points], "point_y_value")?;
|
||||
g.chroma_scaling_from_luma = if seq.color.mono_chrome { false } else { r.flag()? };
|
||||
if seq.color.mono_chrome
|
||||
|| g.chroma_scaling_from_luma
|
||||
@@ -1272,6 +1280,7 @@ impl FrameHeader {
|
||||
g.point_cb_value[i] = r.f(8)? as u8;
|
||||
g.point_cb_scaling[i] = r.f(8)? as u8;
|
||||
}
|
||||
check_increasing(&g.point_cb_value[..g.num_cb_points], "point_cb_value")?;
|
||||
g.num_cr_points = r.f(4)? as usize;
|
||||
if g.num_cr_points > 10 {
|
||||
return Err(Error::Invalid("num_cr_points"));
|
||||
@@ -1280,6 +1289,7 @@ impl FrameHeader {
|
||||
g.point_cr_value[i] = r.f(8)? as u8;
|
||||
g.point_cr_scaling[i] = r.f(8)? as u8;
|
||||
}
|
||||
check_increasing(&g.point_cr_value[..g.num_cr_points], "point_cr_value")?;
|
||||
}
|
||||
g.grain_scaling_minus_8 = r.f(2)?;
|
||||
g.ar_coeff_lag = r.f(2)?;
|
||||
@@ -1367,3 +1377,23 @@ fn decode_subexp(r: &mut BitReader, num_syms: i32) -> Result<i32> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Film-grain scaling point values must strictly increase (AV1 spec 6.8.20); a duplicate would
|
||||
/// divide by zero when the scaling lookup is built.
|
||||
fn check_increasing(values: &[u8], what: &'static str) -> Result<()> {
|
||||
if values.windows(2).all(|w| w[0] < w[1]) { Ok(()) } else { Err(Error::Invalid(what)) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod grain_point_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn scaling_points_must_strictly_increase() {
|
||||
assert!(check_increasing(&[], "p").is_ok());
|
||||
assert!(check_increasing(&[7], "p").is_ok());
|
||||
assert!(check_increasing(&[0, 10, 255], "p").is_ok());
|
||||
assert!(matches!(check_increasing(&[0, 10, 10], "point_y_value"), Err(Error::Invalid("point_y_value"))));
|
||||
assert!(check_increasing(&[20, 10], "p").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
+12
-3
@@ -5,7 +5,16 @@ use std::sync::OnceLock;
|
||||
|
||||
use crate::frame::Plane;
|
||||
use crate::mvpred::round2_signed64;
|
||||
use crate::spec_tables::*;
|
||||
use crate::spec_tables::{
|
||||
BLOCK_8X8, BLOCK_SIZES, COMPOUND_AVERAGE, COMPOUND_DIFFWTD, COMPOUND_DISTANCE, COMPOUND_INTRA, COMPOUND_WEDGE, DIV_LUT, DIV_LUT_BITS,
|
||||
DIV_LUT_PREC_BITS, EIGHTTAP, EIGHTTAP_SHARP, EIGHTTAP_SMOOTH, FILTER_BITS, GLOBAL_GLOBALMV, GLOBALMV, II_H_PRED, II_SMOOTH_PRED, II_V_PRED,
|
||||
II_WEIGHTS_1D, INTRA_FRAME, LAST_FRAME, LOCALWARP, LS_MV_MAX, MASK_MASTER_SIZE, MAX_FRAME_DISTANCE, MAX_SB_SIZE, MI_HEIGHT_LOG2, MI_WIDTH_LOG2,
|
||||
NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, OBMC, OBMC_MASK_2, OBMC_MASK_4, OBMC_MASK_8, OBMC_MASK_16, OBMC_MASK_32, QUANT_DIST_LOOKUP,
|
||||
QUANT_DIST_WEIGHT, REF_SCALE_SHIFT, SCALE_SUBPEL_BITS, SUBPEL_BITS, SUBPEL_FILTERS, SUBPEL_MASK, TRANSLATION, WARP_PARAM_REDUCE_BITS,
|
||||
WARPED_FILTERS, WARPEDDIFF_PREC_BITS, WARPEDMODEL_NONDIAGAFFINE_CLAMP, WARPEDMODEL_PREC_BITS, WARPEDMODEL_TRANS_CLAMP, WARPEDPIXEL_PREC_SHIFTS,
|
||||
WEDGE_BITS, WEDGE_CODEBOOK, WEDGE_HORIZONTAL, WEDGE_MASTER_OBLIQUE_EVEN, WEDGE_MASTER_OBLIQUE_ODD, WEDGE_MASTER_VERTICAL, WEDGE_OBLIQUE27,
|
||||
WEDGE_OBLIQUE63, WEDGE_OBLIQUE117, WEDGE_OBLIQUE153, WEDGE_TYPES, WEDGE_VERTICAL,
|
||||
};
|
||||
use crate::tile::TileDecoder;
|
||||
|
||||
const PRED_STRIDE: usize = 128;
|
||||
@@ -678,7 +687,7 @@ fn hpass<const W: usize>(src: &[u16], hf: &[i16; 8], out: &mut [i32], half: i32,
|
||||
let mut acc = [half; W];
|
||||
for t in 0..8 {
|
||||
let f = hf[t] as i32;
|
||||
let Some(p) = src.get(t..).and_then(|s| s.first_chunk::<W>()) else {
|
||||
let Some(p) = src.get(t..).and_then(<[_]>::first_chunk::<W>) else {
|
||||
return;
|
||||
};
|
||||
for j in 0..W {
|
||||
@@ -699,7 +708,7 @@ fn vpass<const W: usize>(rows: &[i32], vf: &[i16; 8], out: &mut [i32], half: i32
|
||||
let mut acc = [half; W];
|
||||
for t in 0..8 {
|
||||
let f = vf[t] as i32;
|
||||
let Some(p) = rows.get(t * W..).and_then(|s| s.first_chunk::<W>()) else {
|
||||
let Some(p) = rows.get(t * W..).and_then(<[_]>::first_chunk::<W>) else {
|
||||
return;
|
||||
};
|
||||
for j in 0..W {
|
||||
|
||||
@@ -3,7 +3,14 @@
|
||||
use crate::Result;
|
||||
use crate::header::{EIGHTTAP, SWITCHABLE};
|
||||
use crate::mvpred::MvP;
|
||||
use crate::spec_tables::*;
|
||||
use crate::spec_tables::{
|
||||
ALTREF_FRAME, ALTREF2_FRAME, BLOCK_8X8, BLOCK_32X32, BLOCK_64X64, BLOCK_128X128, BWDREF_FRAME, CLASS0_SIZE, COMP_NEWMV_CTXS, COMPOUND_AVERAGE,
|
||||
COMPOUND_DIFFWTD, COMPOUND_DISTANCE, COMPOUND_INTRA, COMPOUND_MODE_CTX_MAP, COMPOUND_REFERENCE, COMPOUND_WEDGE, GLOBAL_GLOBALMV, GLOBALMV,
|
||||
GOLDEN_FRAME, INTRA_FRAME, INTRABC_DELAY_PIXELS, LAST_FRAME, LAST2_FRAME, LAST3_FRAME, LOCALWARP, MV_CLASS_0, MV_INTRABC_CONTEXT,
|
||||
MV_JOINT_HNZVNZ, MV_JOINT_HNZVZ, MV_JOINT_HZVNZ, NEAR_NEARMV, NEAR_NEWMV, NEAREST_NEARESTMV, NEAREST_NEWMV, NEARESTMV, NEARMV, NEW_NEARESTMV,
|
||||
NEW_NEARMV, NEW_NEWMV, NEWMV, NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, OBMC, REF_SCALE_SHIFT, SEG_LVL_GLOBALMV, SEG_LVL_REF_FRAME, SEG_LVL_SKIP,
|
||||
SIMPLE, SINGLE_REFERENCE, SIZE_GROUP, TRANSLATION, UNIDIR_COMP_REFERENCE, UV_CFL_PRED, WEDGE_BITS,
|
||||
};
|
||||
use crate::tile::TileDecoder;
|
||||
|
||||
fn block_width(bs: usize) -> usize {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
//! Intra prediction (spec 7.11.2), palette prediction (7.11.4) and chroma from luma (7.11.5).
|
||||
|
||||
use crate::frame::Plane;
|
||||
use crate::spec_tables::*;
|
||||
use crate::spec_tables::{
|
||||
ANGLE_STEP, D67_PRED, DC_PRED, DR_INTRA_DERIVATIVE, INTRA_EDGE_KERNEL, INTRA_EDGE_TAPS, INTRA_FILTER_SCALE_BITS, INTRA_FILTER_TAPS,
|
||||
MODE_TO_ANGLE, SM_WEIGHTS_TX_4X4, SM_WEIGHTS_TX_8X8, SM_WEIGHTS_TX_16X16, SM_WEIGHTS_TX_32X32, SM_WEIGHTS_TX_64X64, SMOOTH_H_PRED, SMOOTH_PRED,
|
||||
SMOOTH_V_PRED, V_PRED,
|
||||
};
|
||||
use crate::transform::round2;
|
||||
|
||||
/// Inputs of the intra prediction process that come from the block.
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
//! Motion vector prediction processes (spec 7.10): find_mv_stack, has_overlappable_candidates
|
||||
//! and find_warp_samples.
|
||||
|
||||
use crate::spec_tables::*;
|
||||
use crate::spec_tables::{
|
||||
GLOBAL_GLOBALMV, GLOBALMV, IDENTITY, INTRA_FRAME, LEAST_SQUARES_SAMPLES_MAX, MAX_REF_MV_STACK_SIZE, MV_BORDER, NEAR_NEWMV, NEAREST_NEWMV,
|
||||
NEW_NEARESTMV, NEW_NEARMV, NEW_NEWMV, NEWMV, NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, REF_CAT_LEVEL, TRANSLATION, WARPEDMODEL_PREC_BITS,
|
||||
};
|
||||
use crate::tile::TileDecoder;
|
||||
|
||||
pub(crate) type MvP = [i32; 2];
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
//! In-loop post filters run by decode_frame_wrapup: loop filter (7.14), CDEF (7.15),
|
||||
//! super-resolution upscaling (7.16) and loop restoration (7.17).
|
||||
|
||||
use crate::spec_tables::*;
|
||||
use crate::spec_tables::{
|
||||
GLOBAL_GLOBALMV, GLOBALMV, INTRA_FRAME, MAX_LOOP_FILTER, NEARESTMV, NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, SEG_LVL_ALT_LF_Y_V, TX_HEIGHT,
|
||||
TX_WIDTH,
|
||||
};
|
||||
use crate::state::FrameState;
|
||||
use crate::stats::{DecodeStats, Stage, Timer};
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
//! Super-resolution upscaling (7.16) and loop restoration (7.17).
|
||||
|
||||
use crate::frame::{FrameBuf, Plane};
|
||||
use crate::spec_tables::*;
|
||||
use crate::spec_tables::{
|
||||
FILTER_BITS, RESTORE_NONE, RESTORE_SGRPROJ, RESTORE_WIENER, SGR_PARAMS, SGRPROJ_MTABLE_BITS, SGRPROJ_PRJ_BITS, SGRPROJ_RECIP_BITS,
|
||||
SGRPROJ_RST_BITS, SGRPROJ_SGR_BITS, SUPERRES_EXTRA_BITS, SUPERRES_FILTER_OFFSET, SUPERRES_FILTER_TAPS, SUPERRES_SCALE_BITS, SUPERRES_SCALE_MASK,
|
||||
UPSCALE_FILTER,
|
||||
};
|
||||
use crate::state::FrameState;
|
||||
|
||||
/// Upscale a frame horizontally from FrameWidth to UpscaledWidth (7.16).
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
use crate::decoder::RefFrame;
|
||||
use crate::frame::{FrameBuf, MiInfo};
|
||||
use crate::header::{FrameHeader, RefInfo, SequenceHeader};
|
||||
use crate::spec_tables::*;
|
||||
use crate::spec_tables::{DEFAULT_INTRA_FRAME_Y_MODE_CDF, NUM_REF_FRAMES, RESTORE_NONE, SUBSAMPLED_SIZE};
|
||||
use std::ops::{Deref, DerefMut};
|
||||
use std::sync::Arc;
|
||||
|
||||
|
||||
+23
-5
@@ -6,7 +6,27 @@ use crate::cdf::Cdfs;
|
||||
use crate::frame::{MiInfo, Mv};
|
||||
use crate::header::{FrameHeader, NONE, SequenceHeader};
|
||||
use crate::intra::{IntraParams, is_directional_mode, predict_intra, round2_signed};
|
||||
use crate::spec_tables::*;
|
||||
use crate::spec_tables::{
|
||||
AC_QLOOKUP, ADJUSTED_TX_SIZE, ADST_FLIPADST, BLOCK_4X4, BLOCK_8X8, BLOCK_64X64, BLOCK_128X128, BR_CDF_SIZE, CFL_SIGN_NEG, CFL_SIGN_ZERO,
|
||||
COEFF_BASE_CTX_OFFSET, COEFF_BASE_POS_CTX_OFFSET, COEFF_BASE_RANGE, COMPOUND_AVERAGE, DC_PRED, DC_QLOOKUP, DCT_DCT, DCT_FLIPADST,
|
||||
DEFAULT_SCAN_4X4, DEFAULT_SCAN_4X8, DEFAULT_SCAN_4X16, DEFAULT_SCAN_8X4, DEFAULT_SCAN_8X8, DEFAULT_SCAN_8X16, DEFAULT_SCAN_8X32,
|
||||
DEFAULT_SCAN_16X4, DEFAULT_SCAN_16X8, DEFAULT_SCAN_16X16, DEFAULT_SCAN_16X32, DEFAULT_SCAN_32X8, DEFAULT_SCAN_32X16, DEFAULT_SCAN_32X32,
|
||||
DELTA_LF_SMALL, DELTA_Q_SMALL, FILTER_INTRA_MODE_TO_INTRA_DIR, FLIPADST_ADST, FLIPADST_DCT, FLIPADST_FLIPADST, FRAME_LF_COUNT, H_ADST, H_DCT,
|
||||
H_FLIPADST, H_PRED, IDTX, II_DC_PRED, II_H_PRED, II_V_PRED, INTRA_FRAME, INTRA_MODE_CONTEXT, MAG_REF_OFFSET_WITH_TX_CLASS, MAX_ANGLE_DELTA,
|
||||
MAX_LOOP_FILTER, MAX_TX_DEPTH_TABLE, MAX_TX_SIZE_RECT, MAX_VARTX_DEPTH, MCOL_SCAN_4X4, MCOL_SCAN_4X8, MCOL_SCAN_4X16, MCOL_SCAN_8X4,
|
||||
MCOL_SCAN_8X8, MCOL_SCAN_8X16, MCOL_SCAN_16X4, MCOL_SCAN_16X8, MCOL_SCAN_16X16, MI_HEIGHT_LOG2, MI_WIDTH_LOG2, MODE_TO_TXFM, MROW_SCAN_4X4,
|
||||
MROW_SCAN_4X8, MROW_SCAN_4X16, MROW_SCAN_8X4, MROW_SCAN_8X8, MROW_SCAN_8X16, MROW_SCAN_16X4, MROW_SCAN_16X8, MROW_SCAN_16X16,
|
||||
NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, NUM_BASE_LEVELS, PALETTE_COLOR_CONTEXT, PALETTE_COLOR_HASH_MULTIPLIERS, PALETTE_COLORS,
|
||||
PALETTE_NUM_NEIGHBORS, PARTITION_HORZ, PARTITION_HORZ_4, PARTITION_HORZ_A, PARTITION_HORZ_B, PARTITION_NONE, PARTITION_SPLIT, PARTITION_SUBSIZE,
|
||||
PARTITION_VERT, PARTITION_VERT_4, PARTITION_VERT_A, PARTITION_VERT_B, QM_OFFSET, QUANTIZER_MATRIX, RESTORE_NONE, RESTORE_SGRPROJ, RESTORE_WIENER,
|
||||
SEG_LVL_SKIP, SGR_PARAMS, SGRPROJ_PARAMS_BITS, SGRPROJ_PRJ_BITS, SGRPROJ_PRJ_SUBEXP_K, SIG_REF_DIFF_OFFSET, SIG_REF_DIFF_OFFSET_NUM, SIMPLE,
|
||||
SMOOTH_H_PRED, SMOOTH_PRED, SMOOTH_V_PRED, SPLIT_TX_SIZE, SUPERRES_NUM, TX_4X4, TX_4X8, TX_4X16, TX_8X4, TX_8X8, TX_8X16, TX_8X32, TX_16X4,
|
||||
TX_16X8, TX_16X16, TX_16X32, TX_16X64, TX_32X8, TX_32X16, TX_32X32, TX_32X64, TX_64X16, TX_64X32, TX_64X64, TX_CLASS_2D, TX_CLASS_HORIZ,
|
||||
TX_CLASS_VERT, TX_HEIGHT, TX_HEIGHT_LOG2, TX_MODE_SELECT, TX_SET_DCTONLY, TX_SET_INTER_1, TX_SET_INTER_2, TX_SET_INTER_3, TX_SET_INTRA_1,
|
||||
TX_SET_INTRA_2, TX_SIZE_SQR, TX_SIZE_SQR_UP, TX_SIZES, TX_SIZES_ALL, TX_TYPE_IN_SET_INTER, TX_TYPE_IN_SET_INTRA, TX_TYPE_INTER_INV_SET1,
|
||||
TX_TYPE_INTER_INV_SET2, TX_TYPE_INTER_INV_SET3, TX_TYPE_INTRA_INV_SET1, TX_TYPE_INTRA_INV_SET2, TX_WIDTH, TX_WIDTH_LOG2, UV_CFL_PRED, V_ADST,
|
||||
V_DCT, V_FLIPADST, V_PRED,
|
||||
};
|
||||
use crate::state::{FrameShared, TileState};
|
||||
use crate::symbol::SymbolDecoder;
|
||||
use crate::transform::inverse_transform_2d;
|
||||
@@ -2222,18 +2242,16 @@ fn neg_deinterleave(diff: i32, r: i32, max: i32) -> i32 {
|
||||
if diff <= 2 * r {
|
||||
if diff & 1 == 1 {
|
||||
return r + ((diff + 1) >> 1);
|
||||
} else {
|
||||
return r - (diff >> 1);
|
||||
}
|
||||
return r - (diff >> 1);
|
||||
}
|
||||
diff
|
||||
} else {
|
||||
if diff <= 2 * (max - r - 1) {
|
||||
if diff & 1 == 1 {
|
||||
return r + ((diff + 1) >> 1);
|
||||
} else {
|
||||
return r - (diff >> 1);
|
||||
}
|
||||
return r - (diff >> 1);
|
||||
}
|
||||
max - (diff + 1)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
//! Inverse transforms (spec 7.13), implemented step by step as specified.
|
||||
|
||||
use crate::spec_tables::*;
|
||||
use crate::spec_tables::{
|
||||
ADST_ADST, ADST_DCT, ADST_FLIPADST, COS128_LOOKUP, DCT_ADST, DCT_DCT, DCT_FLIPADST, FLIPADST_ADST, FLIPADST_DCT, FLIPADST_FLIPADST, H_ADST,
|
||||
H_DCT, H_FLIPADST, TRANSFORM_ROW_SHIFT, TX_HEIGHT_LOG2, TX_WIDTH_LOG2, V_ADST, V_DCT, V_FLIPADST,
|
||||
};
|
||||
|
||||
#[inline(always)]
|
||||
fn round2_64(x: i64, n: u32) -> i32 {
|
||||
|
||||
@@ -9,6 +9,4 @@ rust-version.workspace = true
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
craft-media = { workspace = true, features = ["bitstream"] }
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Memory budget for decoded pictures, shared by the video decoders.
|
||||
//!
|
||||
//! A per-axis size cap does not bound memory: a decoder holds the current picture, every
|
||||
//! reference picture and the pictures still being decoded on worker threads. Each decoder checks
|
||||
//! the whole picture store against [`DECODE_MEMORY_BUDGET`] with checked arithmetic before it
|
||||
//! allocates for a new picture size, so a hostile header is an error, not an out-of-memory abort.
|
||||
|
||||
/// Bytes the decoded pictures of one decoder may take: 4 GiB on 64-bit targets, 1 GiB on 32-bit
|
||||
/// ones (wasm32 has a 4 GiB address space in all).
|
||||
pub const DECODE_MEMORY_BUDGET: u64 = if cfg!(target_pointer_width = "64") { 4 << 30 } else { 1 << 30 };
|
||||
|
||||
/// The shape of one decoded picture as a decoder allocates it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct PictureShape {
|
||||
/// Allocated luma samples per row and rows (padding included).
|
||||
pub width: u64,
|
||||
pub height: u64,
|
||||
/// Chroma subsampling shifts (0 or 1); ignored when `chroma_planes` is 0.
|
||||
pub shift_x: u32,
|
||||
pub shift_y: u32,
|
||||
/// Chroma planes (0 for monochrome, 2 otherwise).
|
||||
pub chroma_planes: u64,
|
||||
/// Bytes per sample (1 for 8-bit buffers, 2 for 16-bit ones).
|
||||
pub bytes_per_sample: u64,
|
||||
}
|
||||
|
||||
impl PictureShape {
|
||||
/// Bytes of one picture, `None` if it does not fit in a `u64`.
|
||||
pub fn bytes(&self) -> Option<u64> {
|
||||
let luma = self.width.checked_mul(self.height)?;
|
||||
let cw = self.width.div_ceil(1 << self.shift_x.min(1));
|
||||
let ch = self.height.div_ceil(1 << self.shift_y.min(1));
|
||||
let chroma = cw.checked_mul(ch)?.checked_mul(self.chroma_planes)?;
|
||||
luma.checked_add(chroma)?.checked_mul(self.bytes_per_sample)
|
||||
}
|
||||
|
||||
/// Bytes of `pictures` such pictures, `None` on overflow.
|
||||
pub fn store_bytes(&self, pictures: u64) -> Option<u64> {
|
||||
self.bytes()?.checked_mul(pictures)
|
||||
}
|
||||
|
||||
/// Whether `pictures` such pictures fit in `budget` bytes.
|
||||
pub fn fits(&self, pictures: u64, budget: u64) -> bool {
|
||||
self.store_bytes(pictures).is_some_and(|b| b <= budget)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn shape(w: u64, h: u64) -> PictureShape {
|
||||
PictureShape { width: w, height: h, shift_x: 1, shift_y: 1, chroma_planes: 2, bytes_per_sample: 2 }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bytes_count_luma_and_subsampled_chroma() {
|
||||
assert_eq!(shape(4, 4).bytes(), Some((16 + 2 * 4) * 2));
|
||||
// Odd sizes round chroma up.
|
||||
assert_eq!(shape(3, 3).bytes(), Some((9 + 2 * 4) * 2));
|
||||
let mono = PictureShape { chroma_planes: 0, bytes_per_sample: 1, ..shape(10, 10) };
|
||||
assert_eq!(mono.bytes(), Some(100));
|
||||
let full = PictureShape { shift_x: 0, shift_y: 0, ..shape(10, 10) };
|
||||
assert_eq!(full.bytes(), Some(600));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_budget_boundary() {
|
||||
let s = shape(4, 4); // 48 bytes
|
||||
assert!(s.fits(10, 480));
|
||||
assert!(!s.fits(10, 479));
|
||||
assert!(!s.fits(11, 480));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overflow_is_never_within_budget() {
|
||||
let huge = shape(u64::MAX / 2, 3);
|
||||
assert_eq!(huge.bytes(), None);
|
||||
assert!(!huge.fits(1, u64::MAX));
|
||||
assert!(!shape(1 << 31, 1 << 31).fits(u64::MAX, u64::MAX));
|
||||
}
|
||||
}
|
||||
+5
-442
@@ -1,451 +1,14 @@
|
||||
//! Bit-level reading and writing shared by DeckCraft's codec crates (ported from FilmCraft).
|
||||
//! Bit-level reading and writing for DeckCraft's codec crates: a re-export of
|
||||
//! [`craft_media::bitstream`] from craft-libs, plus [`budget`].
|
||||
//!
|
||||
//! - [`BitReader`]: MSB-first reader with Exp-Golomb (`ue(v)`, `se(v)`), as used by H.264/HEVC/AV1 headers.
|
||||
//! - [`BitWriter`]: the matching writer (for encoders and header rewriting).
|
||||
//! - [`unescape_rbsp`] / [`escape_rbsp`]: remove/insert H.264/HEVC emulation-prevention bytes.
|
||||
//! - [`annexb_nals`] / [`length_prefixed_nals`]: split Annex-B byte streams and AVCC/HVCC samples into NAL units.
|
||||
//! - [`budget`]: the memory budget video decoders check their picture store against.
|
||||
|
||||
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
||||
|
||||
use std::fmt;
|
||||
pub use craft_media::bitstream::*;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BitError {
|
||||
/// Tried to read past the end of the buffer.
|
||||
Eof,
|
||||
/// An Exp-Golomb code longer than 32 leading zeros.
|
||||
InvalidExpGolomb,
|
||||
}
|
||||
|
||||
impl fmt::Display for BitError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
BitError::Eof => f.write_str("unexpected end of bitstream"),
|
||||
BitError::InvalidExpGolomb => f.write_str("invalid Exp-Golomb code"),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl std::error::Error for BitError {}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, BitError>;
|
||||
|
||||
/// MSB-first bit reader over a byte slice.
|
||||
#[derive(Clone)]
|
||||
pub struct BitReader<'a> {
|
||||
data: &'a [u8],
|
||||
/// Position in bits.
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl<'a> BitReader<'a> {
|
||||
pub fn new(data: &'a [u8]) -> Self {
|
||||
Self { data, pos: 0 }
|
||||
}
|
||||
pub fn data(&self) -> &'a [u8] {
|
||||
self.data
|
||||
}
|
||||
/// Bit position from the start.
|
||||
pub fn position(&self) -> usize {
|
||||
self.pos
|
||||
}
|
||||
pub fn bits_left(&self) -> usize {
|
||||
(self.data.len() * 8).saturating_sub(self.pos)
|
||||
}
|
||||
pub fn is_byte_aligned(&self) -> bool {
|
||||
self.pos.is_multiple_of(8)
|
||||
}
|
||||
pub fn byte_align(&mut self) {
|
||||
self.pos = self.pos.div_ceil(8) * 8;
|
||||
}
|
||||
/// Byte offset of the current position (rounded down).
|
||||
pub fn byte_pos(&self) -> usize {
|
||||
self.pos / 8
|
||||
}
|
||||
pub fn seek_bits(&mut self, pos: usize) {
|
||||
self.pos = pos;
|
||||
}
|
||||
|
||||
/// Peek up to 32 bits without consuming (zero-padded past the end).
|
||||
#[inline]
|
||||
pub fn peek(&self, n: u32) -> u32 {
|
||||
debug_assert!(n <= 32);
|
||||
if n == 0 {
|
||||
return 0;
|
||||
}
|
||||
let byte = self.pos / 8;
|
||||
let shift = self.pos % 8;
|
||||
let mut v: u64 = 0;
|
||||
for i in 0..5 {
|
||||
v = (v << 8) | *self.data.get(byte + i).unwrap_or(&0) as u64;
|
||||
}
|
||||
// v holds 40 bits starting at `byte`
|
||||
((v << (24 + shift)) >> (64 - n)) as u32
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn read_bits(&mut self, n: u32) -> Result<u32> {
|
||||
if n as usize > self.bits_left() {
|
||||
return Err(BitError::Eof);
|
||||
}
|
||||
let v = self.peek(n);
|
||||
self.pos += n as usize;
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
pub fn read_bits_u64(&mut self, n: u32) -> Result<u64> {
|
||||
if n <= 32 {
|
||||
return self.read_bits(n).map(u64::from);
|
||||
}
|
||||
let hi = self.read_bits(n - 32)? as u64;
|
||||
let lo = self.read_bits(32)? as u64;
|
||||
Ok((hi << 32) | lo)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn read_bit(&mut self) -> Result<bool> {
|
||||
self.read_bits(1).map(|b| b == 1)
|
||||
}
|
||||
pub fn read_flag(&mut self) -> Result<bool> {
|
||||
self.read_bit()
|
||||
}
|
||||
|
||||
pub fn skip(&mut self, n: usize) -> Result<()> {
|
||||
if n > self.bits_left() {
|
||||
return Err(BitError::Eof);
|
||||
}
|
||||
self.pos += n;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Unsigned Exp-Golomb `ue(v)`.
|
||||
pub fn read_ue(&mut self) -> Result<u32> {
|
||||
let mut zeros = 0u32;
|
||||
while !self.read_bit()? {
|
||||
zeros += 1;
|
||||
if zeros > 32 {
|
||||
return Err(BitError::InvalidExpGolomb);
|
||||
}
|
||||
}
|
||||
if zeros == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
let v = self.read_bits_u64(zeros)?;
|
||||
let r = (1u64 << zeros) - 1 + v;
|
||||
u32::try_from(r).map_err(|_| BitError::InvalidExpGolomb)
|
||||
}
|
||||
|
||||
/// `ue(v) + add` (the `..._minus1` / `..._minus8` fields), failing instead of overflowing on a
|
||||
/// hostile value near `u32::MAX`.
|
||||
pub fn read_ue_plus(&mut self, add: u32) -> Result<u32> {
|
||||
self.read_ue()?.checked_add(add).ok_or(BitError::InvalidExpGolomb)
|
||||
}
|
||||
|
||||
/// Signed Exp-Golomb `se(v)`.
|
||||
pub fn read_se(&mut self) -> Result<i32> {
|
||||
let k = self.read_ue()? as i64;
|
||||
Ok(if k & 1 == 1 { ((k + 1) / 2) as i32 } else { -((k / 2) as i32) })
|
||||
}
|
||||
|
||||
/// Truncated Exp-Golomb `te(v)` with range `max`.
|
||||
pub fn read_te(&mut self, max: u32) -> Result<u32> {
|
||||
if max > 1 { self.read_ue() } else { Ok(!self.read_bit()? as u32) }
|
||||
}
|
||||
|
||||
/// H.264/HEVC `more_rbsp_data()`: true if there is data before the rbsp trailing bits.
|
||||
pub fn more_rbsp_data(&self) -> bool {
|
||||
let total = self.data.len() * 8;
|
||||
if self.pos >= total {
|
||||
return false;
|
||||
}
|
||||
// find last set bit (the rbsp_stop_one_bit)
|
||||
let mut last = None;
|
||||
for (i, &b) in self.data.iter().enumerate().rev() {
|
||||
if b != 0 {
|
||||
last = Some(i * 8 + 7 - b.trailing_zeros() as usize);
|
||||
break;
|
||||
}
|
||||
}
|
||||
match last {
|
||||
Some(stop) => self.pos < stop,
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// MSB-first bit writer.
|
||||
#[derive(Default, Clone)]
|
||||
pub struct BitWriter {
|
||||
buf: Vec<u8>,
|
||||
acc: u64,
|
||||
nbits: u32,
|
||||
}
|
||||
|
||||
impl BitWriter {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
pub fn write_bits(&mut self, value: u32, n: u32) {
|
||||
debug_assert!(n <= 32);
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
let v = (value as u64) & ((1u64 << n) - 1);
|
||||
self.acc = (self.acc << n) | v;
|
||||
self.nbits += n;
|
||||
while self.nbits >= 8 {
|
||||
self.nbits -= 8;
|
||||
self.buf.push((self.acc >> self.nbits) as u8);
|
||||
}
|
||||
self.acc &= (1u64 << self.nbits) - 1;
|
||||
}
|
||||
pub fn write_bit(&mut self, b: bool) {
|
||||
self.write_bits(b as u32, 1);
|
||||
}
|
||||
pub fn write_ue(&mut self, v: u32) {
|
||||
let x = v as u64 + 1;
|
||||
let len = 64 - x.leading_zeros();
|
||||
let zeros = len - 1;
|
||||
for _ in 0..zeros {
|
||||
self.write_bit(false);
|
||||
}
|
||||
if len > 32 {
|
||||
self.write_bits((x >> 32) as u32, len - 32);
|
||||
self.write_bits(x as u32, 32);
|
||||
} else {
|
||||
self.write_bits(x as u32, len);
|
||||
}
|
||||
}
|
||||
pub fn write_se(&mut self, v: i32) {
|
||||
let k = if v > 0 { (v as u32) * 2 - 1 } else { (-(v as i64) as u32) * 2 };
|
||||
self.write_ue(k);
|
||||
}
|
||||
pub fn bit_len(&self) -> usize {
|
||||
self.buf.len() * 8 + self.nbits as usize
|
||||
}
|
||||
pub fn is_byte_aligned(&self) -> bool {
|
||||
self.nbits == 0
|
||||
}
|
||||
/// Pad with zero bits to a byte boundary.
|
||||
pub fn align_zero(&mut self) {
|
||||
if self.nbits > 0 {
|
||||
self.write_bits(0, 8 - self.nbits);
|
||||
}
|
||||
}
|
||||
/// `rbsp_trailing_bits()`: a one bit then zeros to alignment.
|
||||
pub fn rbsp_trailing(&mut self) {
|
||||
self.write_bit(true);
|
||||
self.align_zero();
|
||||
}
|
||||
pub fn write_bytes(&mut self, bytes: &[u8]) {
|
||||
if self.nbits == 0 {
|
||||
self.buf.extend_from_slice(bytes);
|
||||
} else {
|
||||
for &b in bytes {
|
||||
self.write_bits(b as u32, 8);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn finish(mut self) -> Vec<u8> {
|
||||
self.align_zero();
|
||||
self.buf
|
||||
}
|
||||
pub fn bytes(&self) -> &[u8] {
|
||||
&self.buf
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove emulation-prevention bytes (`00 00 03` → `00 00`) from a NAL payload.
|
||||
pub fn unescape_rbsp(data: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(data.len());
|
||||
let mut zeros = 0;
|
||||
for &b in data {
|
||||
if zeros >= 2 && b == 3 {
|
||||
zeros = 0;
|
||||
continue;
|
||||
}
|
||||
out.push(b);
|
||||
zeros = if b == 0 { zeros + 1 } else { 0 };
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Insert emulation-prevention bytes so no `00 00 0x` (x ≤ 3) sequence appears.
|
||||
pub fn escape_rbsp(data: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(data.len() + data.len() / 64);
|
||||
let mut zeros = 0;
|
||||
for &b in data {
|
||||
if zeros >= 2 && b <= 3 {
|
||||
out.push(3);
|
||||
zeros = 0;
|
||||
}
|
||||
out.push(b);
|
||||
zeros = if b == 0 { zeros + 1 } else { 0 };
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Split an Annex-B byte stream (start codes `00 00 01` / `00 00 00 01`) into NAL units (escaped payloads).
|
||||
pub fn annexb_nals(data: &[u8]) -> Vec<&[u8]> {
|
||||
let mut starts = Vec::new();
|
||||
let mut i = 0;
|
||||
while i + 3 <= data.len() {
|
||||
if data[i] == 0 && data[i + 1] == 0 && data[i + 2] == 1 {
|
||||
starts.push(i + 3);
|
||||
i += 3;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
let mut out = Vec::with_capacity(starts.len());
|
||||
for (k, &s) in starts.iter().enumerate() {
|
||||
let mut e = if k + 1 < starts.len() { starts[k + 1] - 3 } else { data.len() };
|
||||
// trim trailing zero bytes (part of a 4-byte start code or trailing_zero_8bits)
|
||||
while e > s && data[e - 1] == 0 {
|
||||
e -= 1;
|
||||
}
|
||||
if e > s {
|
||||
out.push(&data[s..e]);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Split a length-prefixed sample (AVCC/HVCC, `length_size` 1/2/4 bytes) into NAL units.
|
||||
pub fn length_prefixed_nals(data: &[u8], length_size: usize) -> Result<Vec<&[u8]>> {
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < data.len() {
|
||||
if i + length_size > data.len() {
|
||||
return Err(BitError::Eof);
|
||||
}
|
||||
let mut len = 0usize;
|
||||
for k in 0..length_size {
|
||||
len = (len << 8) | data[i + k] as usize;
|
||||
}
|
||||
i += length_size;
|
||||
if i + len > data.len() {
|
||||
return Err(BitError::Eof);
|
||||
}
|
||||
out.push(&data[i..i + len]);
|
||||
i += len;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// CRC-32 (IEEE, reflected) — used by containers (e.g. Matroska CRC-32 elements, PNG).
|
||||
pub fn crc32(data: &[u8]) -> u32 {
|
||||
let mut crc = 0xFFFF_FFFFu32;
|
||||
for &b in data {
|
||||
crc ^= b as u32;
|
||||
for _ in 0..8 {
|
||||
crc = if crc & 1 != 0 { (crc >> 1) ^ 0xEDB8_8320 } else { crc >> 1 };
|
||||
}
|
||||
}
|
||||
!crc
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use proptest::prelude::*;
|
||||
|
||||
#[test]
|
||||
fn read_ue_plus_rejects_overflow() {
|
||||
let mut w = BitWriter::new();
|
||||
w.write_ue(7);
|
||||
w.write_ue(u32::MAX);
|
||||
w.rbsp_trailing();
|
||||
let b = w.finish();
|
||||
let mut r = BitReader::new(&b);
|
||||
assert_eq!(r.read_ue_plus(1).unwrap(), 8);
|
||||
assert!(r.read_ue_plus(1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_bits() {
|
||||
let d = [0b1010_1100, 0b0101_0011];
|
||||
let mut r = BitReader::new(&d);
|
||||
assert_eq!(r.read_bits(1).unwrap(), 1);
|
||||
assert_eq!(r.read_bits(3).unwrap(), 0b010);
|
||||
assert_eq!(r.read_bits(8).unwrap(), 0b1100_0101);
|
||||
assert_eq!(r.bits_left(), 4);
|
||||
assert_eq!(r.read_bits(5), Err(BitError::Eof));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exp_golomb_known() {
|
||||
// 1 | 010 | 011 | 00100 | 00101 -> 0,1,2,3,4
|
||||
let mut w = BitWriter::new();
|
||||
for v in 0..5 {
|
||||
w.write_ue(v);
|
||||
}
|
||||
let b = w.finish();
|
||||
let mut r = BitReader::new(&b);
|
||||
for v in 0..5 {
|
||||
assert_eq!(r.read_ue().unwrap(), v);
|
||||
}
|
||||
let mut w = BitWriter::new();
|
||||
for v in [0, 1, -1, 2, -2, 1000, -1000] {
|
||||
w.write_se(v);
|
||||
}
|
||||
let b = w.finish();
|
||||
let mut r = BitReader::new(&b);
|
||||
for v in [0, 1, -1, 2, -2, 1000, -1000] {
|
||||
assert_eq!(r.read_se().unwrap(), v);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emulation_prevention() {
|
||||
let raw = [0, 0, 1, 0, 0, 0, 5, 0, 0, 3];
|
||||
let esc = escape_rbsp(&raw);
|
||||
assert_eq!(esc, vec![0, 0, 3, 1, 0, 0, 3, 0, 5, 0, 0, 3, 3]);
|
||||
assert_eq!(unescape_rbsp(&esc), raw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn annexb_split() {
|
||||
let s = [0, 0, 0, 1, 0x67, 1, 2, 0, 0, 1, 0x68, 3, 0, 0, 0, 1, 0x65, 9, 9];
|
||||
let n = annexb_nals(&s);
|
||||
assert_eq!(n, vec![&[0x67, 1, 2][..], &[0x68, 3][..], &[0x65, 9, 9][..]]);
|
||||
let l = [0, 0, 0, 2, 0x67, 1, 0, 0, 0, 1, 0x68];
|
||||
assert_eq!(length_prefixed_nals(&l, 4).unwrap(), vec![&[0x67, 1][..], &[0x68][..]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn more_rbsp() {
|
||||
let d = [0b1100_0000];
|
||||
let mut r = BitReader::new(&d);
|
||||
assert!(r.more_rbsp_data());
|
||||
r.read_bit().unwrap();
|
||||
assert!(!r.more_rbsp_data());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crc() {
|
||||
assert_eq!(crc32(b"123456789"), 0xCBF4_3926);
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
fn roundtrip(vals in proptest::collection::vec((0u32..u32::MAX, 1u32..=32), 0..200), ues in proptest::collection::vec(0u32..u32::MAX - 1, 0..50)) {
|
||||
let mut w = BitWriter::new();
|
||||
for &(v, n) in &vals { w.write_bits(v, n); }
|
||||
for &u in &ues { w.write_ue(u); }
|
||||
let b = w.finish();
|
||||
let mut r = BitReader::new(&b);
|
||||
for &(v, n) in &vals {
|
||||
let mask = if n == 32 { u32::MAX } else { (1 << n) - 1 };
|
||||
prop_assert_eq!(r.read_bits(n).unwrap(), v & mask);
|
||||
}
|
||||
for &u in &ues { prop_assert_eq!(r.read_ue().unwrap(), u); }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_roundtrip(data in proptest::collection::vec(0u8..4, 0..300)) {
|
||||
let e = escape_rbsp(&data);
|
||||
prop_assert_eq!(unescape_rbsp(&e), data);
|
||||
for w in e.windows(3) { prop_assert!(!(w[0] == 0 && w[1] == 0 && w[2] < 3)); }
|
||||
}
|
||||
}
|
||||
}
|
||||
pub mod budget;
|
||||
|
||||
@@ -35,7 +35,7 @@ impl Rgba {
|
||||
/// `RRGGBB` or `#RRGGBB` (also `RRGGBBAA`).
|
||||
pub fn from_hex(s: &str) -> Option<Self> {
|
||||
let s = s.trim().trim_start_matches('#');
|
||||
if !s.is_ascii() {
|
||||
if !s.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
let byte = |i: usize| s.get(i..i + 2).and_then(|h| u8::from_str_radix(h, 16).ok());
|
||||
@@ -519,6 +519,14 @@ pub fn preset(name: &str) -> Option<Rgba> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn from_hex_rejects_signs() {
|
||||
assert_eq!(Rgba::from_hex("+12345"), None);
|
||||
assert_eq!(Rgba::from_hex("#+1+2+3"), None);
|
||||
assert_eq!(Rgba::from_hex("+1+2+3+4"), None);
|
||||
assert_eq!(Rgba::from_hex("+12"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_roundtrip() {
|
||||
assert_eq!(Rgba::from_hex("#156082"), Some(Rgba::rgb(0x15, 0x60, 0x82)));
|
||||
|
||||
@@ -25,3 +25,13 @@ i_overlay = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
image = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
deckcraft-anim = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
|
||||
# The browser clock for comment timestamps (wasm has no std::time::SystemTime).
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
js-sys = "0.3"
|
||||
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
# DeckCraft's built-in thesaurus: headword|synonym, synonym, ...
|
||||
# Written for DeckCraft (no third-party word list). Lowercase; one meaning group per line;
|
||||
# a headword may appear on several lines (one per sense).
|
||||
ability|capability, capacity, skill, competence, talent
|
||||
able|capable, competent, qualified, skilled
|
||||
accept|agree to, approve, adopt, embrace
|
||||
accurate|precise, exact, correct, right
|
||||
achieve|accomplish, attain, reach, realize, deliver
|
||||
achievement|accomplishment, success, attainment, milestone
|
||||
add|include, append, attach, insert
|
||||
advantage|benefit, edge, strength, asset, upside
|
||||
affect|influence, impact, change, alter
|
||||
aim|goal, objective, target, purpose, intent
|
||||
allow|permit, enable, let, authorize
|
||||
analyze|examine, study, assess, evaluate, review
|
||||
answer|reply, response, solution
|
||||
approach|method, strategy, way, technique, plan
|
||||
area|region, field, domain, zone, sector
|
||||
arrange|organize, order, sort, plan
|
||||
ask|request, inquire, query, question
|
||||
assess|evaluate, gauge, judge, review, appraise
|
||||
assist|help, aid, support, back
|
||||
attempt|try, effort, endeavor, bid
|
||||
bad|poor, weak, inferior, unfavorable, negative
|
||||
basic|fundamental, essential, core, elementary, simple
|
||||
begin|start, commence, launch, initiate, open
|
||||
benefit|advantage, gain, value, payoff, upside
|
||||
best|finest, top, leading, optimal, premier
|
||||
big|large, major, substantial, significant, sizable
|
||||
boost|increase, raise, lift, improve, strengthen
|
||||
brief|short, concise, succinct, quick
|
||||
bright|vivid, brilliant, luminous, clever
|
||||
build|construct, create, develop, assemble, establish
|
||||
business|company, firm, enterprise, organization, venture
|
||||
buy|purchase, acquire, obtain
|
||||
careful|cautious, thorough, meticulous, attentive
|
||||
cause|reason, source, origin, driver
|
||||
change|alter, modify, adjust, revise, transform
|
||||
change|shift, transition, adjustment, revision
|
||||
cheap|inexpensive, affordable, low-cost, economical
|
||||
check|verify, confirm, examine, inspect, review
|
||||
choice|option, alternative, selection, pick
|
||||
choose|select, pick, opt for, decide on
|
||||
clear|plain, obvious, evident, transparent, lucid
|
||||
client|customer, buyer, account, patron
|
||||
close|near, nearby, adjacent
|
||||
close|end, finish, conclude, shut
|
||||
common|frequent, usual, typical, widespread, shared
|
||||
company|business, firm, organization, enterprise, corporation
|
||||
complete|finish, conclude, finalize, accomplish
|
||||
complete|whole, full, entire, total
|
||||
complex|complicated, intricate, involved, elaborate
|
||||
concern|worry, issue, problem, risk
|
||||
consider|weigh, contemplate, evaluate, think about
|
||||
continue|proceed, carry on, keep going, persist, maintain
|
||||
cost|price, expense, charge, outlay
|
||||
create|make, build, produce, design, generate
|
||||
critical|crucial, vital, essential, key, decisive
|
||||
current|present, existing, ongoing, latest, up-to-date
|
||||
customer|client, buyer, consumer, user
|
||||
data|information, figures, facts, statistics
|
||||
decide|determine, choose, resolve, settle
|
||||
decrease|reduce, lower, cut, decline, drop
|
||||
define|specify, describe, explain, outline
|
||||
deliver|provide, supply, bring, hand over, ship
|
||||
demonstrate|show, prove, illustrate, display
|
||||
describe|explain, outline, portray, depict
|
||||
design|plan, layout, blueprint, pattern
|
||||
design|create, plan, devise, draft
|
||||
develop|build, create, grow, expand, evolve
|
||||
difference|distinction, contrast, gap, variation
|
||||
different|distinct, various, diverse, unlike
|
||||
difficult|hard, challenging, tough, demanding
|
||||
discuss|talk about, debate, examine, review
|
||||
easy|simple, straightforward, effortless, painless
|
||||
effect|result, impact, outcome, consequence
|
||||
effective|successful, efficient, productive, powerful
|
||||
effort|work, attempt, endeavor, exertion
|
||||
end|finish, conclusion, close, completion
|
||||
enough|sufficient, adequate, ample
|
||||
ensure|guarantee, make sure, secure, confirm
|
||||
error|mistake, fault, flaw, bug, slip
|
||||
essential|vital, crucial, key, necessary, fundamental
|
||||
establish|set up, found, create, institute
|
||||
estimate|forecast, projection, approximation, guess
|
||||
evaluate|assess, appraise, judge, rate, review
|
||||
event|occasion, function, happening, incident
|
||||
example|instance, case, illustration, sample
|
||||
excellent|outstanding, superb, exceptional, first-rate
|
||||
expand|grow, extend, enlarge, broaden, scale
|
||||
expect|anticipate, foresee, predict, await
|
||||
explain|clarify, describe, illustrate, account for
|
||||
fail|fall short, miss, flop, break down
|
||||
fast|quick, rapid, swift, speedy
|
||||
feature|characteristic, attribute, capability, function
|
||||
final|last, closing, concluding, ultimate
|
||||
find|discover, locate, identify, uncover
|
||||
finish|complete, end, conclude, wrap up
|
||||
focus|concentrate, center, target, emphasize
|
||||
focus|emphasis, priority, attention, core
|
||||
future|upcoming, coming, forthcoming, prospective
|
||||
gain|increase, rise, improvement, profit
|
||||
get|obtain, acquire, receive, gain
|
||||
give|provide, offer, supply, grant
|
||||
goal|aim, objective, target, purpose, ambition
|
||||
good|fine, great, strong, solid, positive
|
||||
great|excellent, outstanding, terrific, remarkable
|
||||
grow|increase, expand, rise, develop, scale
|
||||
growth|expansion, increase, development, rise
|
||||
help|assist, aid, support, enable
|
||||
help|assistance, aid, support, guidance
|
||||
high|elevated, tall, top, strong
|
||||
idea|concept, notion, thought, proposal
|
||||
identify|recognize, pinpoint, detect, determine
|
||||
impact|effect, influence, consequence, result
|
||||
important|significant, key, major, crucial, vital
|
||||
improve|enhance, upgrade, refine, strengthen, boost
|
||||
include|contain, cover, incorporate, comprise
|
||||
increase|raise, boost, grow, expand, lift
|
||||
increase|rise, growth, gain, jump
|
||||
information|data, details, facts, knowledge
|
||||
innovative|inventive, creative, original, novel, pioneering
|
||||
issue|problem, concern, matter, question
|
||||
job|task, assignment, role, position
|
||||
keep|retain, maintain, preserve, hold
|
||||
key|main, central, critical, essential, principal
|
||||
large|big, sizable, substantial, major, extensive
|
||||
last|final, latest, previous, closing
|
||||
launch|start, introduce, roll out, release, unveil
|
||||
lead|guide, direct, head, steer, run
|
||||
learn|discover, find out, master, study
|
||||
less|fewer, smaller, reduced, lower
|
||||
little|small, minor, slight, modest
|
||||
look|appear, seem
|
||||
look|view, glance, examine, inspect
|
||||
low|minimal, modest, small, reduced
|
||||
main|primary, chief, principal, central, key
|
||||
maintain|keep, preserve, sustain, uphold
|
||||
major|significant, important, key, principal, big
|
||||
make|create, build, produce, generate
|
||||
manage|run, handle, oversee, direct, lead
|
||||
market|industry, sector, segment, marketplace
|
||||
measure|gauge, assess, quantify, track
|
||||
meet|satisfy, fulfil, reach, achieve
|
||||
meeting|session, conference, discussion, review
|
||||
method|approach, technique, process, procedure, way
|
||||
model|framework, template, pattern, design
|
||||
more|additional, extra, further, added
|
||||
move|shift, transfer, relocate, transition
|
||||
need|requirement, demand, necessity
|
||||
need|require, call for, demand, want
|
||||
new|fresh, recent, novel, modern, latest
|
||||
next|following, upcoming, subsequent
|
||||
number|figure, count, total, quantity, amount
|
||||
objective|goal, aim, target, purpose
|
||||
offer|provide, propose, extend, present
|
||||
old|previous, former, legacy, outdated, existing
|
||||
open|start, launch, begin, unveil
|
||||
opportunity|opening, chance, prospect, possibility
|
||||
option|choice, alternative, possibility, route
|
||||
organize|arrange, structure, coordinate, order
|
||||
outcome|result, consequence, effect, end result
|
||||
overview|summary, outline, synopsis, recap
|
||||
part|portion, piece, segment, section, component
|
||||
partner|ally, collaborator, associate, affiliate
|
||||
people|staff, team, individuals, personnel
|
||||
plan|strategy, scheme, roadmap, proposal, program
|
||||
plan|organize, arrange, schedule, prepare
|
||||
point|aspect, detail, issue, argument
|
||||
popular|well-liked, widespread, in demand, favored
|
||||
possible|feasible, achievable, viable, potential
|
||||
present|show, display, introduce, demonstrate
|
||||
present|current, existing, ongoing
|
||||
price|cost, rate, charge, fee
|
||||
priority|focus, concern, precedence, goal
|
||||
problem|issue, challenge, difficulty, obstacle
|
||||
process|procedure, method, workflow, system
|
||||
produce|make, create, generate, manufacture, yield
|
||||
product|offering, item, solution, good
|
||||
profit|earnings, return, gain, margin
|
||||
progress|advance, headway, development, improvement
|
||||
project|initiative, program, undertaking, venture
|
||||
propose|suggest, recommend, put forward, offer
|
||||
provide|give, supply, deliver, offer
|
||||
purpose|aim, goal, objective, intent, reason
|
||||
quality|standard, caliber, excellence, grade
|
||||
question|query, issue, inquiry
|
||||
quick|fast, rapid, speedy, swift, prompt
|
||||
rapid|fast, quick, swift, speedy
|
||||
reach|achieve, attain, hit, arrive at
|
||||
reason|cause, rationale, motive, basis
|
||||
recent|latest, new, current, fresh
|
||||
recommend|suggest, advise, propose, advocate
|
||||
reduce|cut, lower, decrease, lessen, trim
|
||||
region|area, territory, zone, market
|
||||
relevant|pertinent, applicable, related, appropriate
|
||||
remove|delete, eliminate, take out, drop
|
||||
replace|substitute, swap, supersede, change
|
||||
report|account, summary, review, statement
|
||||
require|need, demand, call for, necessitate
|
||||
research|study, investigation, analysis, inquiry
|
||||
resource|asset, supply, means, capacity
|
||||
result|outcome, effect, consequence, finding
|
||||
review|assessment, evaluation, examination, analysis
|
||||
review|examine, assess, evaluate, check, revisit
|
||||
revenue|income, sales, earnings, turnover
|
||||
risk|danger, threat, exposure, hazard
|
||||
role|part, function, position, responsibility
|
||||
run|operate, manage, conduct, execute
|
||||
safe|secure, protected, reliable
|
||||
same|identical, equal, matching, equivalent
|
||||
save|keep, store, preserve, conserve
|
||||
see|view, observe, notice, spot
|
||||
sell|market, offer, trade, retail
|
||||
service|support, assistance, offering
|
||||
share|portion, part, stake, percentage
|
||||
share|distribute, spread, communicate, pass on
|
||||
show|demonstrate, display, present, reveal, illustrate
|
||||
significant|important, notable, considerable, major, meaningful
|
||||
similar|alike, comparable, related, equivalent
|
||||
simple|easy, straightforward, plain, basic
|
||||
size|scale, extent, magnitude, dimensions
|
||||
small|little, minor, compact, modest
|
||||
solution|answer, fix, remedy, resolution
|
||||
solve|resolve, fix, settle, work out
|
||||
specific|particular, precise, exact, distinct
|
||||
start|begin, launch, commence, kick off, initiate
|
||||
step|stage, phase, measure, action
|
||||
strategy|plan, approach, policy, roadmap, tactic
|
||||
strong|robust, solid, powerful, sturdy
|
||||
structure|framework, organization, layout, arrangement
|
||||
success|achievement, accomplishment, win, triumph
|
||||
successful|effective, thriving, prosperous, winning
|
||||
suggest|propose, recommend, advise, imply
|
||||
summary|overview, recap, synopsis, digest
|
||||
support|help, back, assist, sustain
|
||||
support|help, assistance, backing, aid
|
||||
sure|certain, confident, positive, definite
|
||||
target|goal, objective, aim
|
||||
task|job, assignment, duty, activity
|
||||
team|group, crew, staff, squad, unit
|
||||
test|trial, check, experiment, evaluation
|
||||
test|try, check, evaluate, verify
|
||||
think|believe, consider, reckon, suppose
|
||||
time|period, duration, while, moment
|
||||
tool|instrument, utility, device, aid
|
||||
total|sum, whole, aggregate, overall
|
||||
trend|pattern, tendency, movement, direction
|
||||
understand|grasp, comprehend, follow, realize
|
||||
unique|distinctive, singular, one-of-a-kind, unmatched
|
||||
update|revise, refresh, upgrade, modernize
|
||||
update|revision, change, upgrade, news
|
||||
use|employ, apply, utilize, leverage
|
||||
useful|helpful, practical, handy, valuable
|
||||
value|worth, benefit, merit, importance
|
||||
various|diverse, assorted, different, several
|
||||
view|opinion, perspective, outlook, position
|
||||
vision|outlook, aspiration, goal, dream
|
||||
way|method, approach, manner, means
|
||||
weak|feeble, fragile, poor, limited
|
||||
well|effectively, successfully, nicely, properly
|
||||
work|labor, effort, activity, job
|
||||
work|function, operate, run, succeed
|
||||
year|annum, twelve months
|
||||
@@ -28,7 +28,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection,
|
||||
add
|
||||
),
|
||||
cmd!("animation.remove", "Remove Animation", ["Animation Pane"], None, "{index} | {ids?} (all of the shapes' animations)", has_slide, remove),
|
||||
cmd!("animation.remove", "Remove Animation", ["Animation Pane"], None, "{index} | {ids? (all of the shapes' animations)}", has_slide, remove),
|
||||
cmd!("animation.move", "Reorder Animation", ["Animations", "Timing"], None, "{index, to}", has_slide, move_anim),
|
||||
cmd!(
|
||||
"animation.timing",
|
||||
@@ -82,6 +82,51 @@ fn start_of(s: &str) -> AnimStart {
|
||||
}
|
||||
}
|
||||
|
||||
/// The effect option `given` as one of `allowed`. Besides the stored codes ("b", "lt", "u", …)
|
||||
/// it takes the direction words people type: "fromBottom", "bottom", "fromTopLeft",
|
||||
/// "upRight". `None` when it matches none of them: the renderer reads direction codes letter
|
||||
/// by letter, so an unchecked "fromBottom" would fly in from the right and the bottom.
|
||||
fn resolve_option(allowed: &[&str], given: &str) -> Option<String> {
|
||||
if allowed.is_empty() || allowed.contains(&given) {
|
||||
return Some(given.to_string());
|
||||
}
|
||||
let low = given.to_ascii_lowercase();
|
||||
if let Some(o) = allowed.iter().find(|o| o.to_ascii_lowercase() == low) {
|
||||
return Some(o.to_string());
|
||||
}
|
||||
let mut rest = low.strip_prefix("from").unwrap_or(&low).trim_start_matches(['-', '_', ' ']);
|
||||
// Direction words in order, each as both its edge letter and its arrow letter.
|
||||
let mut dirs: Vec<(char, char)> = Vec::new();
|
||||
while !rest.is_empty() {
|
||||
let words: [(&str, (char, char)); 8] = [
|
||||
("bottom", ('b', 'd')),
|
||||
("down", ('b', 'd')),
|
||||
("top", ('t', 'u')),
|
||||
("up", ('t', 'u')),
|
||||
("left", ('l', 'l')),
|
||||
("right", ('r', 'r')),
|
||||
("-", ('-', '-')),
|
||||
("_", ('-', '-')),
|
||||
];
|
||||
let (w, d) = words.iter().find(|(w, _)| rest.starts_with(w))?;
|
||||
if d.0 != '-' {
|
||||
dirs.push(*d);
|
||||
}
|
||||
rest = &rest[w.len()..];
|
||||
}
|
||||
if dirs.is_empty() || dirs.len() > 2 {
|
||||
return None;
|
||||
}
|
||||
let mut cands: Vec<String> = Vec::new();
|
||||
for pick in [|d: &(char, char)| d.0, |d: &(char, char)| d.1] {
|
||||
let code: String = dirs.iter().map(pick).collect();
|
||||
let rev: String = code.chars().rev().collect();
|
||||
cands.push(code);
|
||||
cands.push(rev);
|
||||
}
|
||||
cands.into_iter().find(|c| allowed.contains(&c.as_str()))
|
||||
}
|
||||
|
||||
fn build(p: &Value) -> Result<Animation> {
|
||||
let effect = str_param(p, "effect").ok_or_else(|| bad("animation", "missing `effect`"))?;
|
||||
let class = class_of(str_param(p, "class"));
|
||||
@@ -93,7 +138,12 @@ fn build(p: &Value) -> Result<Animation> {
|
||||
let mut a = Animation {
|
||||
class: info.2,
|
||||
effect: info.0.to_string(),
|
||||
option: str_param(p, "option").map(String::from).or_else(|| info.5.first().map(|o| o.to_string())).unwrap_or_default(),
|
||||
option: match str_param(p, "option") {
|
||||
Some(o) => {
|
||||
resolve_option(info.5, o).ok_or_else(|| bad("animation", format!("option `{o}` is not one of {:?} for `{}`", info.5, info.0)))?
|
||||
}
|
||||
None => info.5.first().map(|o| o.to_string()).unwrap_or_default(),
|
||||
},
|
||||
duration_ms: p.get("duration").and_then(Value::as_u64).map(|v| v.min(600_000) as u32).unwrap_or(info.4),
|
||||
delay_ms: p.get("delay").and_then(Value::as_u64).map(|v| v.min(600_000) as u32).unwrap_or(0),
|
||||
preset_id: Some(info.3),
|
||||
@@ -127,7 +177,7 @@ pub fn default_path(effect: &str, option: &str) -> String {
|
||||
"turns" => format!("M 0 0 L {} {} C {} {} {dx} {} {dx} {dy} E", dx * 0.5, dy * 0.5, dx, dy * 0.5, dy * 0.75),
|
||||
"shapes" => "M 0 0 C 0.1 0 0.1 0.15 0 0.15 C -0.1 0.15 -0.1 0 0 0 Z E".into(),
|
||||
"loops" => "M 0 0 C 0.1 -0.1 0.2 0.1 0.1 0.1 C 0 0.1 0.1 -0.1 0.25 0 E".into(),
|
||||
_ => format!("M 0 0 L {dx} {dy} E"),
|
||||
other => deckcraft_model::anim::preset_path(other).unwrap_or_else(|| format!("M 0 0 L {dx} {dy} E")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,9 +320,14 @@ fn options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
});
|
||||
each_target(s, p, |a| {
|
||||
if let Some(o) = &option {
|
||||
a.option = o.clone();
|
||||
if a.class == AnimClass::Path {
|
||||
a.path = Some(default_path(&a.effect, o));
|
||||
// Each target takes the option in its own effect's terms; one it has no such
|
||||
// option for keeps its own.
|
||||
let allowed = animation_info(&a.effect, a.class).map(|i| i.5).unwrap_or(&[]);
|
||||
if let Some(o) = resolve_option(allowed, o) {
|
||||
if a.class == AnimClass::Path {
|
||||
a.path = Some(default_path(&a.effect, &o));
|
||||
}
|
||||
a.option = o;
|
||||
}
|
||||
}
|
||||
if let Some(b) = build_mode {
|
||||
@@ -305,7 +360,7 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn painter_pick(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let id = id_param(p, "id")
|
||||
let id = id_param(p, "id", "animation.painter")?
|
||||
.or_else(|| st.selection.shapes.first().copied())
|
||||
.ok_or_else(|| bad("animation.painter", "select an animated shape first"))?;
|
||||
let sl = st.current_slide().ok_or_else(|| bad("animation.painter", "no slide"))?;
|
||||
@@ -355,6 +410,26 @@ fn painter_apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::resolve_option;
|
||||
|
||||
#[test]
|
||||
fn direction_words_resolve_to_codes() {
|
||||
let fly = &["b", "lb", "l", "lt", "t", "rt", "r", "rb"][..];
|
||||
assert_eq!(resolve_option(fly, "fromBottom").as_deref(), Some("b"));
|
||||
assert_eq!(resolve_option(fly, "bottom").as_deref(), Some("b"));
|
||||
assert_eq!(resolve_option(fly, "fromTopLeft").as_deref(), Some("lt"));
|
||||
assert_eq!(resolve_option(fly, "fromBottomRight").as_deref(), Some("rb"));
|
||||
assert_eq!(resolve_option(fly, "lt").as_deref(), Some("lt"));
|
||||
assert_eq!(resolve_option(fly, "sideways"), None);
|
||||
assert_eq!(resolve_option(fly, "fromBottomTopLeft"), None);
|
||||
let wipe = &["r", "l", "u", "d", "ru", "lu", "rd", "ld"][..];
|
||||
assert_eq!(resolve_option(wipe, "fromBottom").as_deref(), Some("d"));
|
||||
assert_eq!(resolve_option(wipe, "upRight").as_deref(), Some("ru"));
|
||||
let split = &["horzIn", "horzOut", "vertIn", "vertOut"][..];
|
||||
assert_eq!(resolve_option(split, "HorzIn").as_deref(), Some("horzIn"));
|
||||
assert_eq!(resolve_option(&[], "anything").as_deref(), Some("anything"));
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
@@ -450,4 +525,46 @@ mod tests {
|
||||
s.execute("animation.painterApply", &json!({"ids": [id3]})).unwrap();
|
||||
assert!(s.anim_painter.is_some());
|
||||
}
|
||||
|
||||
fn setup_triggers() -> (Session, [u64; 4]) {
|
||||
let mut s = Session::with_new();
|
||||
let mut ids = [0u64; 4];
|
||||
for (slot, x) in ids.iter_mut().zip([10.0, 150.0, 300.0, 450.0]) {
|
||||
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, 10.0, 100.0, 100.0]})).unwrap();
|
||||
*slot = r.get("id").unwrap().as_u64().unwrap();
|
||||
}
|
||||
(s, ids)
|
||||
}
|
||||
|
||||
fn triggers(s: &Session) -> Vec<Option<deckcraft_model::ShapeId>> {
|
||||
s.doc().unwrap().current_slide().unwrap().animations.iter().map(|x| x.trigger).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_trigger_shape_clears_the_trigger() {
|
||||
let (mut s, [a, b, c, d]) = setup_triggers();
|
||||
let id = |v: u64| Some(deckcraft_model::ShapeId(v as u32));
|
||||
s.execute("animation.add", &json!({"effect": "fade", "ids": [a]})).unwrap();
|
||||
s.execute("animation.add", &json!({"effect": "fade", "ids": [d]})).unwrap();
|
||||
s.execute("animation.timing", &json!({"index": 0, "trigger": b})).unwrap();
|
||||
s.execute("animation.timing", &json!({"index": 1, "trigger": c})).unwrap();
|
||||
assert_eq!(triggers(&s), vec![id(b), id(c)]);
|
||||
|
||||
s.execute("edit.delete", &json!({"ids": [b]})).unwrap();
|
||||
assert_eq!(triggers(&s), vec![None, id(c)], "only the deleted trigger is cleared");
|
||||
assert_eq!(s.doc().unwrap().current_slide().unwrap().animations.len(), 2, "the animated shapes keep their effects");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_group_clears_triggers_of_its_children() {
|
||||
let (mut s, [a, b, c, _]) = setup_triggers();
|
||||
let g = s.execute("arrange.group", &json!({"ids": [b, c]})).unwrap().get("id").unwrap().as_u64().unwrap();
|
||||
s.execute("animation.add", &json!({"effect": "fade", "ids": [a]})).unwrap();
|
||||
s.execute("animation.timing", &json!({"index": 0, "trigger": b})).unwrap();
|
||||
assert_eq!(triggers(&s), vec![Some(deckcraft_model::ShapeId(b as u32))]);
|
||||
|
||||
s.execute("edit.delete", &json!({"ids": [g]})).unwrap();
|
||||
assert_eq!(triggers(&s), vec![None], "the trigger was inside the deleted group");
|
||||
assert_eq!(s.doc().unwrap().current_slide().unwrap().animations.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,31 @@ use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"guide.add",
|
||||
"Add Guide",
|
||||
["View", "Show", "Guides"],
|
||||
None,
|
||||
"{vertical: bool, pos?: pt (default: the slide's centre)} → {index}. `defaults: true` adds the two centre guides when there are none",
|
||||
has_doc,
|
||||
guide_add
|
||||
),
|
||||
cmd!("guide.move", "Move Guide", [], None, "{index, pos: pt}", has_doc, guide_move),
|
||||
cmd!("guide.delete", "Delete Guide", [], None, "{index}", has_doc, guide_delete),
|
||||
cmd!(query "guide.list", "Guides", [], None, "{} → [{vertical, pos}]", has_doc, guide_list),
|
||||
cmd!("arrange.bringToFront", "Bring to Front", ["Arrange"], Some("Cmd+Shift+]"), "{ids?}", has_selection, |s, p| order(s, p, 2)),
|
||||
cmd!("arrange.bringForward", "Bring Forward", ["Arrange"], Some("Cmd+]"), "{ids?}", has_selection, |s, p| order(s, p, 1)),
|
||||
cmd!("arrange.sendBackward", "Send Backward", ["Arrange"], Some("Cmd+["), "{ids?}", has_selection, |s, p| order(s, p, -1)),
|
||||
cmd!("arrange.sendToBack", "Send to Back", ["Arrange"], Some("Cmd+Shift+["), "{ids?}", has_selection, |s, p| order(s, p, -2)),
|
||||
cmd!("arrange.reorder", "Reorder Objects", ["Arrange"], None, "{id, index: position in the stack (0 = back)}", has_slide, reorder),
|
||||
cmd!(
|
||||
"arrange.reorder",
|
||||
"Reorder Objects",
|
||||
["Arrange"],
|
||||
None,
|
||||
"{id, index: position among its siblings (0 = back; a group's child stays in its group)} → {index}",
|
||||
has_slide,
|
||||
reorder
|
||||
),
|
||||
cmd!("arrange.group", "Group", ["Arrange"], Some("Cmd+Alt+G"), "{ids?}", has_two, group),
|
||||
cmd!("arrange.ungroup", "Ungroup", ["Arrange"], Some("Cmd+Alt+Shift+G"), "{ids?}", has_selection, ungroup),
|
||||
cmd!("arrange.regroup", "Regroup", ["Arrange"], Some("Cmd+Alt+J"), "{}", has_doc, regroup),
|
||||
@@ -50,61 +70,123 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
fn order(s: &mut Session, p: &Value, how: i32) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
// Already in place (front of its group, say): no change and no undo step.
|
||||
let mut trial = s.doc()?.shapes().to_vec();
|
||||
order_tree(&mut trial, &ids, how, 0);
|
||||
if stacking(&trial) == stacking(s.doc()?.shapes()) {
|
||||
return ok();
|
||||
}
|
||||
s.edit(|doc, sel| {
|
||||
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("arrange", "no slide"))?;
|
||||
match how {
|
||||
2 => {
|
||||
let (mut moved, rest): (Vec<Shape>, Vec<Shape>) = list.drain(..).partition(|x| ids.contains(&x.id));
|
||||
list.extend(rest);
|
||||
list.append(&mut moved);
|
||||
}
|
||||
-2 => {
|
||||
let (mut moved, rest): (Vec<Shape>, Vec<Shape>) = list.drain(..).partition(|x| ids.contains(&x.id));
|
||||
moved.extend(rest);
|
||||
*list = moved;
|
||||
}
|
||||
1 => {
|
||||
for i in (0..list.len().saturating_sub(1)).rev() {
|
||||
let (a, b) = (list.get(i).map(|x| ids.contains(&x.id)), list.get(i + 1).map(|x| ids.contains(&x.id)));
|
||||
if a == Some(true) && b == Some(false) {
|
||||
list.swap(i, i + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
for i in 1..list.len() {
|
||||
let (a, b) = (list.get(i - 1).map(|x| ids.contains(&x.id)), list.get(i).map(|x| ids.contains(&x.id)));
|
||||
if a == Some(false) && b == Some(true) {
|
||||
list.swap(i - 1, i);
|
||||
}
|
||||
order_tree(list, &ids, how, 0);
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Every shape id with its depth, in tree order.
|
||||
fn stacking(v: &[Shape]) -> Vec<(ShapeId, usize)> {
|
||||
let mut out = vec![];
|
||||
deckcraft_model::walk(v, &mut |sh, d| out.push((sh.id, d)));
|
||||
out
|
||||
}
|
||||
|
||||
/// Restack `ids` among their siblings, in every list of the tree: a shape inside a group moves
|
||||
/// within its group and never leaves it (PowerPoint's Selection pane arrows work the same way).
|
||||
fn order_tree(list: &mut Vec<Shape>, ids: &[ShapeId], how: i32, depth: usize) {
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
if list.iter().any(|x| ids.contains(&x.id)) {
|
||||
order_list(list, ids, how);
|
||||
}
|
||||
for sh in list.iter_mut() {
|
||||
if let Some(children) = sh.children_mut() {
|
||||
order_tree(children, ids, how, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn order_list(list: &mut Vec<Shape>, ids: &[ShapeId], how: i32) {
|
||||
match how {
|
||||
2 => {
|
||||
let (mut moved, rest): (Vec<Shape>, Vec<Shape>) = list.drain(..).partition(|x| ids.contains(&x.id));
|
||||
list.extend(rest);
|
||||
list.append(&mut moved);
|
||||
}
|
||||
-2 => {
|
||||
let (mut moved, rest): (Vec<Shape>, Vec<Shape>) = list.drain(..).partition(|x| ids.contains(&x.id));
|
||||
moved.extend(rest);
|
||||
*list = moved;
|
||||
}
|
||||
1 => {
|
||||
for i in (0..list.len().saturating_sub(1)).rev() {
|
||||
let (a, b) = (list.get(i).map(|x| ids.contains(&x.id)), list.get(i + 1).map(|x| ids.contains(&x.id)));
|
||||
if a == Some(true) && b == Some(false) {
|
||||
list.swap(i, i + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
_ => {
|
||||
for i in 1..list.len() {
|
||||
let (a, b) = (list.get(i - 1).map(|x| ids.contains(&x.id)), list.get(i).map(|x| ids.contains(&x.id)));
|
||||
if a == Some(false) && b == Some(true) {
|
||||
list.swap(i - 1, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Move one shape to `index` among its siblings (0 = back): the Selection pane's drag and drop.
|
||||
/// A shape stays in the list that holds it, so a group's child stays in its group.
|
||||
fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("arrange.reorder", "missing `id`"))?;
|
||||
let id = id_param(p, "id", "arrange.reorder")?.ok_or_else(|| bad("arrange.reorder", "missing `id`"))?;
|
||||
let to = usize_param(p, "index").ok_or_else(|| bad("arrange.reorder", "missing `index`"))?;
|
||||
let st = s.doc()?;
|
||||
let siblings: &[Shape] = match deckcraft_model::parent_of(st.shapes(), id) {
|
||||
None => return Err(bad("arrange.reorder", "no such shape")),
|
||||
Some(None) => st.shapes(),
|
||||
Some(Some(g)) => st.shape(g).map(Shape::children).unwrap_or(&[]),
|
||||
};
|
||||
let from = siblings.iter().position(|x| x.id == id).ok_or_else(|| bad("arrange.reorder", "no such shape"))?;
|
||||
if from == to.min(siblings.len().saturating_sub(1)) {
|
||||
return Ok(json!({"index": from}));
|
||||
}
|
||||
s.edit(|doc, sel| {
|
||||
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("arrange.reorder", "no slide"))?;
|
||||
let i = list.iter().position(|x| x.id == id).ok_or_else(|| bad("arrange.reorder", "not a top-level shape"))?;
|
||||
let tree = crate::shapes_mut(doc, sel).ok_or_else(|| bad("arrange.reorder", "no slide"))?;
|
||||
let list = deckcraft_model::sibling_list_mut(tree, id).ok_or_else(|| bad("arrange.reorder", "no such shape"))?;
|
||||
let i = list.iter().position(|x| x.id == id).ok_or_else(|| bad("arrange.reorder", "no such shape"))?;
|
||||
let sh = list.remove(i);
|
||||
list.insert(to.min(list.len()), sh);
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
let at = to.min(list.len());
|
||||
list.insert(at, sh);
|
||||
Ok(json!({"index": at}))
|
||||
})
|
||||
}
|
||||
|
||||
/// Bounds of shapes (rotation included).
|
||||
fn union_bounds(xs: &[Xfrm]) -> Option<Rect> {
|
||||
pub(crate) fn union_bounds(xs: &[Xfrm]) -> Option<Rect> {
|
||||
let mut it = xs.iter().map(Xfrm::bounds);
|
||||
let first = it.next()?;
|
||||
Some(it.fold(first, |a, b| a.union(b)))
|
||||
}
|
||||
|
||||
/// How deep groups may nest: as deep as the PPTX reader reads and the renderer draws. A saved
|
||||
/// .deckcraft file stops opening at about 39 (the JSON reader's recursion limit), and walks over
|
||||
/// the shape tree stop at 64.
|
||||
pub const MAX_GROUP_DEPTH: usize = 32;
|
||||
|
||||
/// How many groups deep `shapes` nest (0 without groups).
|
||||
fn group_depth(shapes: &[Shape]) -> usize {
|
||||
let mut d = 0;
|
||||
deckcraft_model::walk(shapes, &mut |sh, depth| {
|
||||
if sh.is_group() {
|
||||
d = d.max(depth + 1);
|
||||
}
|
||||
});
|
||||
d
|
||||
}
|
||||
|
||||
fn group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
s.edit(|doc, sel| {
|
||||
@@ -113,6 +195,9 @@ fn group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if members.len() < 2 {
|
||||
return Err(bad("arrange.group", "select two or more objects on the slide"));
|
||||
}
|
||||
if group_depth(&members) + 1 > MAX_GROUP_DEPTH {
|
||||
return Err(bad("arrange.group", format!("groups can be nested at most {MAX_GROUP_DEPTH} deep")));
|
||||
}
|
||||
for m in &mut members {
|
||||
if m.xfrm.is_none() {
|
||||
m.xfrm = Some(xfrm_of(doc, sel, m));
|
||||
@@ -123,7 +208,7 @@ fn group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let xs: Vec<Xfrm> = members.iter().filter_map(|m| m.xfrm).collect();
|
||||
let b = union_bounds(&xs).ok_or_else(|| bad("arrange.group", "nothing to group"))?;
|
||||
let gx = Xfrm::new(b.x0, b.y0, b.width(), b.height());
|
||||
let id = ShapeId(doc.alloc_id());
|
||||
let id = ShapeId(doc.alloc_id().ok_or_else(|| bad("arrange.group", NO_ID_SPACE))?);
|
||||
let n = super::current_shapes(doc, sel).map(|v| v.len()).unwrap_or(0);
|
||||
let g = Shape {
|
||||
id,
|
||||
@@ -142,7 +227,7 @@ fn group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// A child's xfrm in slide space, given its group.
|
||||
pub(crate) fn child_to_parent(group: &Xfrm, ch: &Xfrm, c: &Xfrm) -> Xfrm {
|
||||
pub fn child_to_parent(group: &Xfrm, ch: &Xfrm, c: &Xfrm) -> Xfrm {
|
||||
let sx = if ch.w.abs() > 1e-9 { group.w / ch.w } else { 1.0 };
|
||||
let sy = if ch.h.abs() > 1e-9 { group.h / ch.h } else { 1.0 };
|
||||
let a = deckcraft_geom::group_child_affine(group, Rect::new(ch.x, ch.y, ch.x + ch.w, ch.y + ch.h));
|
||||
@@ -221,7 +306,7 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} else {
|
||||
union_bounds(&boxes.iter().map(|b| b.1).collect::<Vec<_>>()).unwrap_or_default()
|
||||
};
|
||||
edit_shapes(s, p, "arrange.align", |sh| {
|
||||
edit_geometry(s, p, "arrange.align", |sh| {
|
||||
if let Some(x) = sh.xfrm.as_mut() {
|
||||
let b = x.bounds();
|
||||
let (dx, dy) = match edge.as_str() {
|
||||
@@ -256,9 +341,9 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (start, end) = if to_slide {
|
||||
if horiz { (0.0, size.width) } else { (0.0, size.height) }
|
||||
} else {
|
||||
let f = boxes.first().map(|b| b.1).unwrap_or_default();
|
||||
let l = boxes.last().map(|b| b.1).unwrap_or_default();
|
||||
if horiz { (f.x0, l.x1) } else { (f.y0, l.y1) }
|
||||
let lo = |f: fn(&Rect) -> f64| boxes.iter().map(|b| f(&b.1)).fold(f64::INFINITY, f64::min);
|
||||
let hi = |f: fn(&Rect) -> f64| boxes.iter().map(|b| f(&b.1)).fold(f64::NEG_INFINITY, f64::max);
|
||||
if horiz { (lo(|r| r.x0), hi(|r| r.x1)) } else { (lo(|r| r.y0), hi(|r| r.y1)) }
|
||||
};
|
||||
let n = boxes.len();
|
||||
let gap = if n > 1 { ((end - start) - total) / (n - 1) as f64 } else { 0.0 };
|
||||
@@ -273,7 +358,7 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
targets_pos.push((*id, d));
|
||||
cur += (if horiz { b.width() } else { b.height() }) + gap;
|
||||
}
|
||||
edit_shapes(s, p, "arrange.distribute", |sh| {
|
||||
edit_geometry(s, p, "arrange.distribute", |sh| {
|
||||
if let (Some(x), Some((_, d))) = (sh.xfrm.as_mut(), targets_pos.iter().find(|(i, _)| *i == sh.id)) {
|
||||
if horiz {
|
||||
x.x += d;
|
||||
@@ -284,3 +369,76 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Guides are kept on the slide; a file can hold at most this many.
|
||||
const MAX_GUIDES: usize = 200;
|
||||
|
||||
fn guide_limit(s: &Session, vertical: bool) -> Result<f64> {
|
||||
let size = s.doc()?.doc.slide_size;
|
||||
Ok(if vertical { size.width } else { size.height })
|
||||
}
|
||||
|
||||
fn guide_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if bool_or(p, "defaults", false) {
|
||||
if !s.doc()?.doc.guides.is_empty() {
|
||||
return Ok(json!({"added": 0}));
|
||||
}
|
||||
let size = s.doc()?.doc.slide_size;
|
||||
return s.edit(|doc, _| {
|
||||
doc.guides = vec![
|
||||
deckcraft_model::DrawGuide { vertical: true, pos: size.width / 2.0 },
|
||||
deckcraft_model::DrawGuide { vertical: false, pos: size.height / 2.0 },
|
||||
];
|
||||
Ok(json!({"added": 2}))
|
||||
});
|
||||
}
|
||||
let vertical = bool_or(p, "vertical", true);
|
||||
let limit = guide_limit(s, vertical)?;
|
||||
let pos = p.get("pos").and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(limit / 2.0).clamp(0.0, limit);
|
||||
if s.doc()?.doc.guides.len() >= MAX_GUIDES {
|
||||
return Err(bad("guide.add", "too many guides"));
|
||||
}
|
||||
s.edit(|doc, _| {
|
||||
doc.guides.push(deckcraft_model::DrawGuide { vertical, pos });
|
||||
Ok(json!({"index": doc.guides.len() - 1}))
|
||||
})
|
||||
}
|
||||
|
||||
fn guide_index(s: &Session, p: &Value, cmd: &str) -> Result<usize> {
|
||||
let i = usize_param(p, "index").ok_or_else(|| bad(cmd, "missing `index`"))?;
|
||||
if i >= s.doc()?.doc.guides.len() {
|
||||
return Err(bad(cmd, "no such guide"));
|
||||
}
|
||||
Ok(i)
|
||||
}
|
||||
|
||||
fn guide_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = guide_index(s, p, "guide.move")?;
|
||||
let vertical = s.doc()?.doc.guides.get(i).is_some_and(|g| g.vertical);
|
||||
let limit = guide_limit(s, vertical)?;
|
||||
let pos = p.get("pos").and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad("guide.move", "missing `pos`"))?.clamp(0.0, limit);
|
||||
s.edit(|doc, _| {
|
||||
if let Some(g) = doc.guides.get_mut(i) {
|
||||
g.pos = pos;
|
||||
}
|
||||
Ok(json!({"index": i, "pos": pos}))
|
||||
})
|
||||
}
|
||||
|
||||
fn guide_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = guide_index(s, p, "guide.delete")?;
|
||||
s.edit(|doc, _| {
|
||||
if i < doc.guides.len() {
|
||||
doc.guides.remove(i);
|
||||
}
|
||||
ok()
|
||||
})
|
||||
}
|
||||
|
||||
fn guide_list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
Ok(serde_json::to_value(&s.doc()?.doc.guides).unwrap_or_default())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "guides_tests.rs"]
|
||||
mod guides_tests;
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
//! Picture Format ▸ Artistic Effects: a picture redrawn as a pencil sketch, blurred, grainy…
|
||||
//!
|
||||
//! The effect is applied to the pixels and the result becomes the picture, as PowerPoint does;
|
||||
//! the original is kept (`PictureFill::artistic`) so the effect can be changed or removed, and
|
||||
//! both are saved (`a14:imgLayer`). The effects approximate PowerPoint's look.
|
||||
|
||||
use deckcraft_model::style::Artistic;
|
||||
use deckcraft_model::{MediaId, ShapeId, ShapeKind};
|
||||
use image::{GrayImage, Rgba, RgbaImage};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// The effects offered: `a14` element name and label.
|
||||
pub const EFFECTS: &[(&str, &str)] = &[
|
||||
("artisticBlur", "Blur"),
|
||||
("artisticPencilGrayscale", "Pencil Grayscale"),
|
||||
("artisticLineDrawing", "Line Drawing"),
|
||||
("artisticFilmGrain", "Film Grain"),
|
||||
("artisticCutout", "Cutout"),
|
||||
("artisticGlowEdges", "Glow Edges"),
|
||||
("artisticPhotocopy", "Photocopy"),
|
||||
];
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
"picture.artisticEffect",
|
||||
"Artistic Effects",
|
||||
["Picture Format", "Adjust"],
|
||||
None,
|
||||
"{effect: none|artisticBlur|artisticPencilGrayscale|artisticLineDrawing|artisticFilmGrain|artisticCutout|artisticGlowEdges|artisticPhotocopy, ids?}",
|
||||
has_selection,
|
||||
artistic_effect
|
||||
)]
|
||||
}
|
||||
|
||||
/// Largest side processed; bigger pictures are scaled down first.
|
||||
const MAX_SIDE: u32 = 3000;
|
||||
|
||||
fn luma(p: &Rgba<u8>) -> f32 {
|
||||
0.299 * p[0] as f32 + 0.587 * p[1] as f32 + 0.114 * p[2] as f32
|
||||
}
|
||||
|
||||
fn gray(img: &RgbaImage) -> GrayImage {
|
||||
GrayImage::from_fn(img.width(), img.height(), |x, y| image::Luma([luma(img.get_pixel(x, y)).round().clamp(0.0, 255.0) as u8]))
|
||||
}
|
||||
|
||||
/// Sobel edge strength, 0–255.
|
||||
fn edges(g: &GrayImage) -> GrayImage {
|
||||
let (w, h) = g.dimensions();
|
||||
let at = |x: i64, y: i64| g.get_pixel(x.clamp(0, w as i64 - 1) as u32, y.clamp(0, h as i64 - 1) as u32)[0] as f32;
|
||||
GrayImage::from_fn(w, h, |x, y| {
|
||||
let (x, y) = (x as i64, y as i64);
|
||||
let gx = at(x + 1, y - 1) + 2.0 * at(x + 1, y) + at(x + 1, y + 1) - at(x - 1, y - 1) - 2.0 * at(x - 1, y) - at(x - 1, y + 1);
|
||||
let gy = at(x - 1, y + 1) + 2.0 * at(x, y + 1) + at(x + 1, y + 1) - at(x - 1, y - 1) - 2.0 * at(x, y - 1) - at(x + 1, y - 1);
|
||||
image::Luma([((gx * gx + gy * gy).sqrt() / 4.0).clamp(0.0, 255.0) as u8])
|
||||
})
|
||||
}
|
||||
|
||||
/// Applies `effect` to `img` (alpha kept).
|
||||
pub fn apply(effect: &str, img: &RgbaImage) -> Option<RgbaImage> {
|
||||
let (w, h) = img.dimensions();
|
||||
let scale = ((w * w + h * h) as f32).sqrt() / 1000.0;
|
||||
let alpha = |x: u32, y: u32| img.get_pixel(x, y)[3];
|
||||
let out = match effect {
|
||||
"artisticBlur" => image::imageops::blur(img, (6.0 * scale).max(1.0)),
|
||||
"artisticPencilGrayscale" | "artisticLineDrawing" | "artisticPhotocopy" => {
|
||||
let soft = image::imageops::blur(&gray(img), (0.8 * scale).max(0.5));
|
||||
let e = edges(&soft);
|
||||
RgbaImage::from_fn(w, h, |x, y| {
|
||||
let ev = e.get_pixel(x, y)[0] as f32;
|
||||
let gv = soft.get_pixel(x, y)[0] as f32;
|
||||
let v = match effect {
|
||||
"artisticPencilGrayscale" => 255.0 - ev * 2.0 - (255.0 - gv) * 0.25,
|
||||
"artisticLineDrawing" => {
|
||||
if ev > 28.0 {
|
||||
255.0 - (ev - 28.0) * 3.0
|
||||
} else {
|
||||
255.0
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if ev > 40.0 || gv < 70.0 {
|
||||
0.0
|
||||
} else {
|
||||
255.0
|
||||
}
|
||||
}
|
||||
};
|
||||
let v = v.clamp(0.0, 255.0) as u8;
|
||||
Rgba([v, v, v, alpha(x, y)])
|
||||
})
|
||||
}
|
||||
"artisticFilmGrain" => {
|
||||
// Deterministic noise, so the same picture gives the same grain.
|
||||
let mut seed: u32 = 0x9E37_79B9;
|
||||
RgbaImage::from_fn(w, h, |x, y| {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 17;
|
||||
seed ^= seed << 5;
|
||||
let n = (seed % 61) as f32 - 30.0;
|
||||
let p = img.get_pixel(x, y);
|
||||
let c = |v: u8| ((v as f32 - 128.0) * 1.1 + 128.0 + n).clamp(0.0, 255.0) as u8;
|
||||
Rgba([c(p[0]), c(p[1]), c(p[2]), p[3]])
|
||||
})
|
||||
}
|
||||
"artisticCutout" => {
|
||||
let soft = image::imageops::blur(img, (1.5 * scale).max(0.5));
|
||||
RgbaImage::from_fn(w, h, |x, y| {
|
||||
let p = soft.get_pixel(x, y);
|
||||
let q = |v: u8| ((v as f32 / 255.0 * 3.0).round() / 3.0 * 255.0) as u8;
|
||||
Rgba([q(p[0]), q(p[1]), q(p[2]), alpha(x, y)])
|
||||
})
|
||||
}
|
||||
"artisticGlowEdges" => {
|
||||
let e = edges(&gray(img));
|
||||
RgbaImage::from_fn(w, h, |x, y| {
|
||||
let k = (e.get_pixel(x, y)[0] as f32 / 255.0 * 2.5).min(1.0);
|
||||
let p = img.get_pixel(x, y);
|
||||
let c = |v: u8| (v as f32 * k + 255.0 * k * 0.3).clamp(0.0, 255.0) as u8;
|
||||
Rgba([c(p[0]), c(p[1]), c(p[2]), p[3]])
|
||||
})
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Decodes `data`, applies `effect` and encodes a PNG.
|
||||
fn process(effect: &str, data: &[u8]) -> Result<Vec<u8>> {
|
||||
let img = image::load_from_memory(data).map_err(|_| bad("picture.artisticEffect", "this picture's format can't be processed"))?;
|
||||
let img = if img.width().max(img.height()) > MAX_SIDE { img.thumbnail(MAX_SIDE, MAX_SIDE) } else { img };
|
||||
let out = apply(effect, &img.to_rgba8()).ok_or_else(|| bad("picture.artisticEffect", "unknown effect"))?;
|
||||
let mut png = Vec::new();
|
||||
image::DynamicImage::ImageRgba8(out)
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.map_err(|_| bad("picture.artisticEffect", "the processed picture couldn't be stored"))?;
|
||||
Ok(png)
|
||||
}
|
||||
|
||||
fn artistic_effect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let effect = match str_param(p, "effect") {
|
||||
Some("none") => None,
|
||||
Some(e) if EFFECTS.iter().any(|(n, _)| *n == e) => Some(e.to_string()),
|
||||
_ => return Err(bad("picture.artisticEffect", "`effect` is none or one of artisticBlur, artisticPencilGrayscale, …")),
|
||||
};
|
||||
let ids = targets(s, p)?;
|
||||
let st = s.doc()?;
|
||||
// (shape, original picture, its bytes and name)
|
||||
let mut work: Vec<(ShapeId, MediaId, Option<(Vec<u8>, String)>)> = Vec::new();
|
||||
for id in ids {
|
||||
let Some(ShapeKind::Picture { fill }) = st.shape(id).map(|sh| &sh.kind) else { continue };
|
||||
let original = fill.artistic.as_ref().map_or(fill.media, |a| a.original);
|
||||
let item = st.doc.media(original).map(|m| (m.data.to_vec(), m.name.clone()));
|
||||
work.push((id, original, item));
|
||||
}
|
||||
if work.is_empty() {
|
||||
return Err(bad("picture.artisticEffect", "select a picture"));
|
||||
}
|
||||
let mut results: Vec<(ShapeId, MediaId, Option<(String, Vec<u8>)>)> = Vec::new();
|
||||
for (id, original, item) in work {
|
||||
let processed = match (&effect, item) {
|
||||
(None, _) => None,
|
||||
(Some(e), Some((data, name))) => {
|
||||
Some((format!("{}-{}.png", name.rsplit_once('.').map_or(name.as_str(), |x| x.0), e), process(e, &data)?))
|
||||
}
|
||||
(Some(_), None) => return Err(bad("picture.artisticEffect", "the picture's data is missing")),
|
||||
};
|
||||
results.push((id, original, processed));
|
||||
}
|
||||
let effect2 = effect.clone();
|
||||
s.edit(move |doc, sel| {
|
||||
let mut set: Vec<(ShapeId, MediaId, Option<MediaId>)> = Vec::new();
|
||||
for (id, original, processed) in &results {
|
||||
let m = match processed {
|
||||
Some((name, png)) => Some(doc.add_media(name, "image/png", png.clone()).ok_or_else(|| bad("picture.artisticEffect", NO_ID_SPACE))?),
|
||||
None => None,
|
||||
};
|
||||
set.push((*id, *original, m));
|
||||
}
|
||||
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("picture.artisticEffect", "no slide"))?;
|
||||
for (id, original, m) in set {
|
||||
if let Some(sh) = deckcraft_model::find_shape_mut(list, id)
|
||||
&& let ShapeKind::Picture { fill } = &mut sh.kind
|
||||
{
|
||||
match (m, &effect2) {
|
||||
(Some(m), Some(e)) => {
|
||||
fill.media = m;
|
||||
fill.artistic = Some(Artistic { effect: e.clone(), original });
|
||||
}
|
||||
_ => {
|
||||
fill.media = original;
|
||||
fill.artistic = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"effect": effect}))
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
//! Picture Format ▸ Remove Background: a classical matte, no model. The background is what the
|
||||
//! picture's edges reach through similar colours: a flood fill from every edge pixel, each step
|
||||
//! close to the pixel before it (gradients and soft shadows) and to the edge colour it started
|
||||
//! from (so it can't leak into the subject). It turns transparent, with a one-pixel soft edge.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use deckcraft_model::{ShapeId, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// Larger pictures are refused rather than matted slowly (16 megapixels).
|
||||
const MAX_PIXELS: u64 = 16_000_000;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
"picture.removeBackground",
|
||||
"Remove Background",
|
||||
["Picture Format", "Adjust"],
|
||||
None,
|
||||
"{tolerance?: 0..100 (how different a colour may be from the edge's, default 20), ids?}",
|
||||
has_selection,
|
||||
remove_background
|
||||
)]
|
||||
}
|
||||
|
||||
fn dist(a: [u8; 4], b: [u8; 4]) -> f64 {
|
||||
let d = |i: usize| f64::from(a[i]) - f64::from(b[i]);
|
||||
(d(0) * d(0) + d(1) * d(1) + d(2) * d(2)).sqrt()
|
||||
}
|
||||
|
||||
/// Make the background of `img` transparent. `tolerance` is 0–100. Returns how many pixels
|
||||
/// became transparent.
|
||||
pub fn matte(img: &mut image::RgbaImage, tolerance: f64) -> usize {
|
||||
let (w, h) = (img.width() as usize, img.height() as usize);
|
||||
if w == 0 || h == 0 {
|
||||
return 0;
|
||||
}
|
||||
// Distances run 0–441 (black to white); the step limit follows gradients, the seed limit
|
||||
// keeps the fill on the edge's colour.
|
||||
let seed_tol = tolerance.clamp(0.0, 100.0) * 4.41;
|
||||
let step_tol = (seed_tol * 0.35).max(6.0);
|
||||
let px = |img: &image::RgbaImage, i: usize| img.get_pixel((i % w) as u32, (i / w) as u32).0;
|
||||
// 0: subject, 1: background; the seed colour each background pixel was reached from.
|
||||
let mut bg = vec![false; w * h];
|
||||
let mut seed = vec![[0u8; 4]; w * h];
|
||||
let mut queue = VecDeque::new();
|
||||
let push_edge = |i: usize, bg: &mut [bool], seed: &mut [[u8; 4]], q: &mut VecDeque<usize>| {
|
||||
if let Some(b) = bg.get_mut(i)
|
||||
&& !*b
|
||||
{
|
||||
*b = true;
|
||||
if let Some(s) = seed.get_mut(i) {
|
||||
*s = px(img, i);
|
||||
}
|
||||
q.push_back(i);
|
||||
}
|
||||
};
|
||||
for x in 0..w {
|
||||
push_edge(x, &mut bg, &mut seed, &mut queue);
|
||||
push_edge((h - 1) * w + x, &mut bg, &mut seed, &mut queue);
|
||||
}
|
||||
for y in 0..h {
|
||||
push_edge(y * w, &mut bg, &mut seed, &mut queue);
|
||||
push_edge(y * w + w - 1, &mut bg, &mut seed, &mut queue);
|
||||
}
|
||||
while let Some(i) = queue.pop_front() {
|
||||
let (x, y) = (i % w, i / w);
|
||||
let here = px(img, i);
|
||||
let from = seed.get(i).copied().unwrap_or(here);
|
||||
let mut near = [None; 4];
|
||||
if x > 0 {
|
||||
near[0] = Some(i - 1);
|
||||
}
|
||||
if x + 1 < w {
|
||||
near[1] = Some(i + 1);
|
||||
}
|
||||
if y > 0 {
|
||||
near[2] = Some(i - w);
|
||||
}
|
||||
if y + 1 < h {
|
||||
near[3] = Some(i + w);
|
||||
}
|
||||
for j in near.into_iter().flatten() {
|
||||
if bg.get(j).copied().unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
let c = px(img, j);
|
||||
// Transparent pixels are background already.
|
||||
if c[3] < 16 || (dist(c, here) <= step_tol && dist(c, from) <= seed_tol) {
|
||||
if let Some(b) = bg.get_mut(j) {
|
||||
*b = true;
|
||||
}
|
||||
if let Some(s) = seed.get_mut(j) {
|
||||
*s = from;
|
||||
}
|
||||
queue.push_back(j);
|
||||
}
|
||||
}
|
||||
}
|
||||
// A background edge that is the subject's colour throughout (the subject touches the
|
||||
// edge on every side) is no background: leave such a picture alone.
|
||||
let n = bg.iter().filter(|b| **b).count();
|
||||
if n == w * h {
|
||||
return 0;
|
||||
}
|
||||
let is_bg = |x: usize, y: usize| bg.get(y * w + x).copied().unwrap_or(false);
|
||||
let mut soft = vec![];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
if is_bg(x, y) {
|
||||
continue;
|
||||
}
|
||||
let touching =
|
||||
(x > 0 && is_bg(x - 1, y)) || (x + 1 < w && is_bg(x + 1, y)) || (y > 0 && is_bg(x, y - 1)) || (y + 1 < h && is_bg(x, y + 1));
|
||||
if touching {
|
||||
soft.push((x, y));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (i, b) in bg.iter().enumerate() {
|
||||
if *b {
|
||||
img.get_pixel_mut((i % w) as u32, (i / w) as u32).0[3] = 0;
|
||||
}
|
||||
}
|
||||
for (x, y) in soft {
|
||||
let p = img.get_pixel_mut(x as u32, y as u32);
|
||||
p.0[3] = (u16::from(p.0[3]) * 2 / 3) as u8;
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
fn remove_background(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let tolerance = f64_or(p, "tolerance", 20.0);
|
||||
let ids = targets(s, p)?;
|
||||
let mut done: Vec<(ShapeId, String, Vec<u8>)> = vec![];
|
||||
{
|
||||
let st = s.doc()?;
|
||||
for id in &ids {
|
||||
let Some(sh) = st.shape(*id) else { continue };
|
||||
let ShapeKind::Picture { fill } = &sh.kind else { continue };
|
||||
let Some(item) = st.doc.media(fill.media) else { continue };
|
||||
let Ok(reader) = image::ImageReader::new(std::io::Cursor::new(item.data.as_slice())).with_guessed_format() else { continue };
|
||||
let Ok((iw, ih)) = reader.into_dimensions() else { continue };
|
||||
if u64::from(iw) * u64::from(ih) > MAX_PIXELS {
|
||||
return Err(bad("picture.removeBackground", format!("{iw}×{ih} pixels is too large; compress the picture first")));
|
||||
}
|
||||
let Ok(dynimg) = image::load_from_memory(&item.data) else { continue };
|
||||
let mut img = dynimg.to_rgba8();
|
||||
if matte(&mut img, tolerance) == 0 {
|
||||
continue;
|
||||
}
|
||||
let mut png = vec![];
|
||||
if image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).is_err() {
|
||||
continue;
|
||||
}
|
||||
let stem = item.name.rsplit_once('.').map_or(item.name.as_str(), |(a, _)| a);
|
||||
done.push((*id, format!("{stem}-no-background.png"), png));
|
||||
}
|
||||
}
|
||||
if done.is_empty() {
|
||||
return Err(bad("picture.removeBackground", "no background found: select a picture whose edges show its background"));
|
||||
}
|
||||
let n = done.len();
|
||||
s.edit(|doc, sel| {
|
||||
let added: Vec<(ShapeId, deckcraft_model::MediaId)> = done
|
||||
.into_iter()
|
||||
.map(|(id, name, png)| Ok((id, doc.add_media(&name, "image/png", png).ok_or_else(|| bad("picture.removeBackground", NO_ID_SPACE))?)))
|
||||
.collect::<Result<_>>()?;
|
||||
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad("picture.removeBackground", "no slide"))?;
|
||||
for (id, media) in added {
|
||||
if let Some(sh) = deckcraft_model::find_shape_mut(shapes, id)
|
||||
&& let ShapeKind::Picture { fill } = &mut sh.kind
|
||||
{
|
||||
fill.media = media;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"pictures": n}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A red disc on a white-to-grey gradient.
|
||||
fn disc() -> image::RgbaImage {
|
||||
image::RgbaImage::from_fn(80, 60, |x, y| {
|
||||
let (dx, dy) = (f64::from(x) - 40.0, f64::from(y) - 30.0);
|
||||
if dx * dx + dy * dy < 15.0 * 15.0 {
|
||||
image::Rgba([220, 30, 30, 255])
|
||||
} else {
|
||||
let g = 255 - (x / 2) as u8;
|
||||
image::Rgba([g, g, g, 255])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matte_clears_the_gradient_and_keeps_the_subject() {
|
||||
let mut img = disc();
|
||||
let n = matte(&mut img, 20.0);
|
||||
assert!(n > 80 * 60 / 2, "{n}");
|
||||
assert_eq!(img.get_pixel(0, 0).0[3], 0);
|
||||
assert_eq!(img.get_pixel(79, 59).0[3], 0, "the darker end of the gradient");
|
||||
assert_eq!(img.get_pixel(40, 30).0, [220, 30, 30, 255]);
|
||||
// The disc's rim is softened, not cut.
|
||||
let rim = img.get_pixel(40, 16).0[3];
|
||||
assert!(rim > 0 && rim < 255 || img.get_pixel(40, 15).0[3] < 255, "{rim}");
|
||||
// A picture with nothing but subject at its edges is left alone.
|
||||
let mut flat = image::RgbaImage::from_pixel(10, 10, image::Rgba([10, 200, 10, 255]));
|
||||
assert_eq!(matte(&mut flat, 20.0), 0);
|
||||
assert!(flat.pixels().all(|p| p.0[3] == 255));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_background_replaces_the_picture_and_undoes() {
|
||||
let mut png = vec![];
|
||||
image::DynamicImage::ImageRgba8(disc()).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let mut s = Session::with_new();
|
||||
let r = s.execute("insert.picture", &json!({"data": base64_encode(&png), "name": "disc.png"})).unwrap();
|
||||
let id = r["id"].as_u64().unwrap();
|
||||
let media_of = |s: &Session| match &s.doc().unwrap().shape(ShapeId(id as u32)).unwrap().kind {
|
||||
ShapeKind::Picture { fill } => fill.media,
|
||||
_ => panic!("not a picture"),
|
||||
};
|
||||
let before = media_of(&s);
|
||||
s.execute("picture.removeBackground", &json!({"ids": [id]})).unwrap();
|
||||
let after = media_of(&s);
|
||||
assert_ne!(before, after);
|
||||
let item = s.doc().unwrap().doc.media(after).unwrap().clone();
|
||||
assert_eq!(item.name, "disc-no-background.png");
|
||||
let img = image::load_from_memory(&item.data).unwrap().to_rgba8();
|
||||
assert_eq!(img.get_pixel(0, 0).0[3], 0);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(media_of(&s), before);
|
||||
// Not a picture: refused.
|
||||
s.execute("shape.insert", &json!({"preset": "rect", "rect": [0, 0, 50, 50]})).unwrap();
|
||||
assert!(s.execute("picture.removeBackground", &json!({})).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//! `slide.colorAt`, the colour the slide is drawn with at a point: what the Eyedropper takes (#367).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::*;
|
||||
|
||||
fn deck() -> Session {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap();
|
||||
s.execute("shape.fill", &json!({"color": "#ff0000"})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn color_at(s: &mut Session, x: f64, y: f64) -> Value {
|
||||
s.execute("slide.colorAt", &json!({"x": x, "y": y})).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_point_in_a_shape_gives_its_fill_and_one_beside_it_the_background() {
|
||||
let mut s = deck();
|
||||
let inside = color_at(&mut s, 150.0, 150.0);
|
||||
assert_eq!(inside["color"], "FF0000");
|
||||
assert_eq!(inside["alpha"], 255);
|
||||
let beside = color_at(&mut s, 700.0, 450.0);
|
||||
assert_ne!(beside["color"], "FF0000");
|
||||
// Just inside and just outside the shape's corner.
|
||||
assert_eq!(color_at(&mut s, 101.0, 101.0)["color"], "FF0000");
|
||||
assert_eq!(color_at(&mut s, 99.0, 99.0)["color"], beside["color"]);
|
||||
// A finer or coarser picture gives the same colour.
|
||||
for scale in [0.5, 1.0, 4.0] {
|
||||
let v = s.execute("slide.colorAt", &json!({"x": 150, "y": 150, "scale": scale})).unwrap();
|
||||
assert_eq!(v["color"], "FF0000", "scale {scale}");
|
||||
}
|
||||
// It is a query: the deck is not changed and nothing goes on the undo stack.
|
||||
let revision = s.doc().unwrap().revision;
|
||||
color_at(&mut s, 150.0, 150.0);
|
||||
assert_eq!(s.doc().unwrap().revision, revision);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_see_through_fill_gives_the_colour_it_is_drawn_with() {
|
||||
let mut s = deck();
|
||||
let background = color_at(&mut s, 700.0, 450.0)["color"].as_str().unwrap().to_string();
|
||||
s.execute("shape.fill", &json!({"transparency": 0.5})).unwrap();
|
||||
let v = color_at(&mut s, 150.0, 150.0);
|
||||
let c = v["color"].as_str().unwrap();
|
||||
assert_ne!(c, "FF0000", "half of the background shows through");
|
||||
assert_ne!(c, background);
|
||||
assert_eq!(v["alpha"], 255, "the slide is opaque");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn points_outside_the_slide_and_bad_parameters_are_errors() {
|
||||
let mut s = deck();
|
||||
for p in [
|
||||
json!({"x": -1, "y": 10}),
|
||||
json!({"x": 10, "y": -0.5}),
|
||||
json!({"x": 1e9, "y": 10}),
|
||||
json!({"x": 10, "y": 1e9}),
|
||||
json!({"x": 10}),
|
||||
json!({"y": 10}),
|
||||
json!({"x": "10", "y": 10}),
|
||||
json!({"x": 10, "y": 10, "slide": 99}),
|
||||
] {
|
||||
assert!(s.execute("slide.colorAt", &p).is_err(), "{p}");
|
||||
}
|
||||
// Odd scales are clamped, not refused or crashed on.
|
||||
for scale in [0.0, -3.0, 1e9] {
|
||||
let v = s.execute("slide.colorAt", &json!({"x": 150, "y": 150, "scale": scale}));
|
||||
assert!(v.is_ok(), "{scale}: {v:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
//! Picture Format ▸ Adjust ▸ Compress Pictures: downsample pictures to a target resolution for
|
||||
//! the size they are shown at, and optionally delete their cropped-away areas.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::style::PictureFill;
|
||||
use deckcraft_model::{MediaId, Shape, ShapeId, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
"picture.compress",
|
||||
"Compress Pictures",
|
||||
["Picture Format", "Adjust"],
|
||||
None,
|
||||
"{ppi?: 330|220|150|96 (default 220), deleteCropped?: bool (default true), all?: bool (every picture in the presentation; default the selected pictures), ids?} → {pictures, before, after}",
|
||||
has_doc,
|
||||
compress
|
||||
)]
|
||||
}
|
||||
|
||||
/// A picture compressed for one shape: new bytes, content type and the crop it now needs.
|
||||
struct Packed {
|
||||
bytes: Vec<u8>,
|
||||
content_type: &'static str,
|
||||
crop: [f64; 4],
|
||||
}
|
||||
|
||||
/// Whether `crop` cuts something away (and only cuts: negative values pad, and are kept).
|
||||
fn real_crop(c: &[f64; 4]) -> bool {
|
||||
c.iter().all(|v| (0.0..1.0).contains(v)) && c.iter().any(|v| *v > 0.0) && c[0] + c[2] < 1.0 && c[1] + c[3] < 1.0
|
||||
}
|
||||
|
||||
/// Largest picture decoded for compression, in pixels (a 61 MP camera frame fits); bigger ones
|
||||
/// are left as they are.
|
||||
const MAX_PIXELS: u64 = 64_000_000;
|
||||
/// Most memory the decoder may allocate for one picture.
|
||||
const MAX_DECODE_BYTES: u64 = 512 << 20;
|
||||
|
||||
/// Decode `data` within [`MAX_PIXELS`] and [`MAX_DECODE_BYTES`]: the size is read from the header
|
||||
/// first, so a picture claiming to be huge is never decoded.
|
||||
fn decode(data: &[u8], format: image::ImageFormat) -> Option<image::DynamicImage> {
|
||||
let (w, h) = image::ImageReader::with_format(std::io::Cursor::new(data), format).into_dimensions().ok()?;
|
||||
if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_PIXELS {
|
||||
return None;
|
||||
}
|
||||
let mut reader = image::ImageReader::with_format(std::io::Cursor::new(data), format);
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_image_width = Some(w);
|
||||
limits.max_image_height = Some(h);
|
||||
limits.max_alloc = Some(MAX_DECODE_BYTES);
|
||||
reader.limits(limits);
|
||||
reader.decode().ok()
|
||||
}
|
||||
|
||||
/// Compress `data` for a picture shown `w_pt` × `h_pt` with `crop`, at `ppi`. `None` when the
|
||||
/// format isn't a raster we re-encode (SVG, EMF, GIF…) or nothing would get smaller.
|
||||
fn pack(data: &[u8], crop: [f64; 4], w_pt: f64, h_pt: f64, ppi: f64, delete_cropped: bool) -> Option<Packed> {
|
||||
use image::ImageFormat as F;
|
||||
let format = image::guess_format(data).ok()?;
|
||||
if !matches!(format, F::Png | F::Jpeg | F::Bmp | F::Tiff | F::WebP) {
|
||||
return None;
|
||||
}
|
||||
let mut img = decode(data, format)?;
|
||||
let (iw, ih) = (img.width() as f64, img.height() as f64);
|
||||
if iw < 1.0 || ih < 1.0 {
|
||||
return None;
|
||||
}
|
||||
let cut = delete_cropped && real_crop(&crop);
|
||||
let mut new_crop = crop;
|
||||
// The share of the image that is visible across and down.
|
||||
let (mut fx, mut fy) = ((1.0 - crop[0] - crop[2]).max(0.01), (1.0 - crop[1] - crop[3]).max(0.01));
|
||||
if cut {
|
||||
let x = (crop[0] * iw).floor() as u32;
|
||||
let y = (crop[1] * ih).floor() as u32;
|
||||
let w = ((fx * iw).round() as u32).clamp(1, img.width() - x.min(img.width() - 1));
|
||||
let h = ((fy * ih).round() as u32).clamp(1, img.height() - y.min(img.height() - 1));
|
||||
img = img.crop_imm(x, y, w, h);
|
||||
new_crop = [0.0; 4];
|
||||
(fx, fy) = (1.0, 1.0);
|
||||
} else if !crop.iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
// Pixels needed for the whole (possibly still cropped) image at `ppi`.
|
||||
let need_w = (w_pt.abs() / 72.0 * ppi / fx).ceil().max(1.0);
|
||||
let need_h = (h_pt.abs() / 72.0 * ppi / fy).ceil().max(1.0);
|
||||
let k = (need_w / img.width() as f64).max(need_h / img.height() as f64);
|
||||
let resized = k < 1.0;
|
||||
if resized {
|
||||
let w = ((img.width() as f64 * k).round() as u32).max(1);
|
||||
let h = ((img.height() as f64 * k).round() as u32).max(1);
|
||||
img = img.resize_exact(w, h, image::imageops::FilterType::Lanczos3);
|
||||
}
|
||||
if !resized && !cut {
|
||||
return None;
|
||||
}
|
||||
let alpha = img.color().has_alpha() && img.to_rgba8().pixels().any(|p| p.0[3] < 255);
|
||||
let mut out = std::io::Cursor::new(Vec::new());
|
||||
let content_type = if format == F::Jpeg && !alpha {
|
||||
let rgb = img.to_rgb8();
|
||||
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 85).encode_image(&rgb).ok()?;
|
||||
"image/jpeg"
|
||||
} else {
|
||||
img.write_to(&mut out, F::Png).ok()?;
|
||||
"image/png"
|
||||
};
|
||||
let bytes = out.into_inner();
|
||||
// Never make a picture bigger just to resample it; deleting a crop is worth it regardless.
|
||||
if bytes.len() >= data.len() && !cut {
|
||||
return None;
|
||||
}
|
||||
Some(Packed { bytes, content_type, crop: new_crop })
|
||||
}
|
||||
|
||||
/// Picture shapes in `shapes` (groups included) that `want` accepts.
|
||||
fn pictures_mut<'a>(shapes: &'a mut [Shape], want: &dyn Fn(ShapeId) -> bool, out: &mut Vec<&'a mut Shape>, depth: usize) {
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
for sh in shapes {
|
||||
if matches!(sh.kind, ShapeKind::Picture { .. }) && want(sh.id) {
|
||||
out.push(sh);
|
||||
} else if let Some(ch) = sh.children_mut() {
|
||||
pictures_mut(ch, want, out, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn compress(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ppi = f64_param(p, "ppi").unwrap_or(220.0);
|
||||
if !ppi.is_finite() || !(36.0..=1200.0).contains(&ppi) {
|
||||
return Err(bad("picture.compress", "`ppi` must be between 36 and 1200 (PowerPoint offers 330, 220, 150 and 96)"));
|
||||
}
|
||||
let delete_cropped = bool_or(p, "deleteCropped", true);
|
||||
let all = bool_or(p, "all", false);
|
||||
let ids: HashSet<ShapeId> = if all { HashSet::new() } else { targets(s, p)?.into_iter().collect() };
|
||||
if !all && ids.is_empty() {
|
||||
return Err(bad("picture.compress", "select a picture, or pass all: true"));
|
||||
}
|
||||
let current = s.doc()?.selection.slide;
|
||||
s.edit(|doc, _| {
|
||||
let want = |id: ShapeId| all || ids.contains(&id);
|
||||
let mut before = 0usize;
|
||||
let mut after = 0usize;
|
||||
let mut count = 0usize;
|
||||
let mut changed: Vec<MediaId> = vec![];
|
||||
// The same picture at the same size and crop compresses once.
|
||||
let mut cache: HashMap<(u32, [u64; 4], u64, u64), Option<(MediaId, [f64; 4], usize)>> = HashMap::new();
|
||||
let range: Vec<usize> = if all { (0..doc.slides.len()).collect() } else { vec![current] };
|
||||
for i in range {
|
||||
let Some(slide) = doc.slides.get(i).cloned() else { continue };
|
||||
let mut slide = slide.as_ref().clone();
|
||||
let mut pics = vec![];
|
||||
pictures_mut(&mut slide.shapes, &want, &mut pics, 0);
|
||||
let mut touched = false;
|
||||
for sh in pics {
|
||||
let (w, h) = sh.xfrm.map(|x| (x.w, x.h)).unwrap_or((0.0, 0.0));
|
||||
let ShapeKind::Picture { fill } = &mut sh.kind else { continue };
|
||||
let PictureFill { media, crop, .. } = fill.clone();
|
||||
let key = (media.0, crop.map(f64::to_bits), w.to_bits(), h.to_bits());
|
||||
let hit = match cache.get(&key) {
|
||||
Some(v) => *v,
|
||||
None => {
|
||||
let packed = doc.media(media).filter(|m| !m.data.is_empty()).and_then(|m| {
|
||||
let old = m.data.len();
|
||||
pack(&m.data, crop, w, h, ppi, delete_cropped).map(|pk| (pk, old, m.name.clone()))
|
||||
});
|
||||
let v = match packed {
|
||||
Some((pk, old, name)) => {
|
||||
before += old;
|
||||
after += pk.bytes.len();
|
||||
let stem = std::path::Path::new(&name).file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or(name);
|
||||
let ext = if pk.content_type == "image/jpeg" { "jpg" } else { "png" };
|
||||
let id = doc
|
||||
.add_media(&format!("{stem}.{ext}"), pk.content_type, pk.bytes)
|
||||
.ok_or_else(|| bad("picture.compress", NO_ID_SPACE))?;
|
||||
Some((id, pk.crop, old))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
cache.insert(key, v);
|
||||
v
|
||||
}
|
||||
};
|
||||
if let Some((id, new_crop, _)) = hit {
|
||||
changed.push(media);
|
||||
fill.media = id;
|
||||
fill.crop = new_crop;
|
||||
count += 1;
|
||||
touched = true;
|
||||
}
|
||||
}
|
||||
if touched && let Some(sl) = doc.slides.get_mut(i) {
|
||||
*sl = Arc::new(slide);
|
||||
}
|
||||
}
|
||||
// Drop the originals nothing uses any more, so the saved file actually shrinks.
|
||||
if !changed.is_empty() {
|
||||
// Typed walk of every reference (artistic originals, fills, posters, sounds, fonts…).
|
||||
let used: HashSet<MediaId> = crate::media_refs::presentation_media(doc).into_iter().collect();
|
||||
doc.media.retain(|m| !changed.contains(&m.id) || used.contains(&m.id));
|
||||
}
|
||||
Ok(json!({"pictures": count, "before": before, "after": after, "ppi": ppi}))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn png(w: u32, h: u32) -> Vec<u8> {
|
||||
let img = image::RgbImage::from_fn(w, h, |x, y| image::Rgb([(x * 7 % 256) as u8, (y * 5 % 256) as u8, ((x ^ y) % 256) as u8]));
|
||||
let mut out = std::io::Cursor::new(Vec::new());
|
||||
image::DynamicImage::ImageRgb8(img).write_to(&mut out, image::ImageFormat::Png).unwrap();
|
||||
out.into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pack_downsamples_to_the_shown_size() {
|
||||
// 2000 px shown at 1 inch: 150 ppi needs 150 px.
|
||||
let pk = pack(&png(2000, 1000), [0.0; 4], 72.0, 36.0, 150.0, true).unwrap();
|
||||
let img = image::load_from_memory(&pk.bytes).unwrap();
|
||||
assert_eq!((img.width(), img.height()), (150, 75));
|
||||
assert_eq!(pk.content_type, "image/png");
|
||||
// Already small enough: nothing to do.
|
||||
assert!(pack(&png(100, 50), [0.0; 4], 72.0, 36.0, 150.0, true).is_none());
|
||||
// Not a raster we re-encode.
|
||||
assert!(pack(b"<svg xmlns='http://www.w3.org/2000/svg'/>", [0.0; 4], 72.0, 72.0, 150.0, true).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pack_deletes_cropped_areas_or_keeps_them() {
|
||||
let src = png(400, 400);
|
||||
// Left half cropped away, shown 2 in wide at 100 ppi → 200 px of the visible half.
|
||||
let pk = pack(&src, [0.5, 0.0, 0.0, 0.0], 144.0, 288.0, 100.0, true).unwrap();
|
||||
assert_eq!(pk.crop, [0.0; 4]);
|
||||
let img = image::load_from_memory(&pk.bytes).unwrap();
|
||||
assert_eq!((img.width(), img.height()), (200, 400));
|
||||
// Keeping the crop: the whole image stays, scaled so the visible half has its pixels.
|
||||
let pk = pack(&src, [0.5, 0.0, 0.0, 0.0], 72.0, 72.0, 100.0, false).unwrap();
|
||||
assert_eq!(pk.crop, [0.5, 0.0, 0.0, 0.0]);
|
||||
let img = image::load_from_memory(&pk.bytes).unwrap();
|
||||
assert_eq!(img.width(), 200);
|
||||
}
|
||||
|
||||
/// A PNG whose header claims `w` × `h` (no pixel data): enough for the size check.
|
||||
fn png_header(w: u32, h: u32) -> Vec<u8> {
|
||||
fn crc(bytes: &[u8]) -> u32 {
|
||||
let mut c = 0xFFFF_FFFFu32;
|
||||
for b in bytes {
|
||||
c ^= u32::from(*b);
|
||||
for _ in 0..8 {
|
||||
c = if c & 1 == 1 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 };
|
||||
}
|
||||
}
|
||||
!c
|
||||
}
|
||||
fn chunk(out: &mut Vec<u8>, kind: &[u8], data: &[u8]) {
|
||||
let body: Vec<u8> = kind.iter().chain(data).copied().collect();
|
||||
out.extend((data.len() as u32).to_be_bytes());
|
||||
out.extend(&body);
|
||||
out.extend(crc(&body).to_be_bytes());
|
||||
}
|
||||
let mut ihdr = w.to_be_bytes().to_vec();
|
||||
ihdr.extend(h.to_be_bytes());
|
||||
ihdr.extend([8, 2, 0, 0, 0]);
|
||||
let mut out = b"\x89PNG\r\n\x1a\n".to_vec();
|
||||
chunk(&mut out, b"IHDR", &ihdr);
|
||||
chunk(&mut out, b"IDAT", &[]);
|
||||
chunk(&mut out, b"IEND", &[]);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_pictures_are_not_decoded() {
|
||||
// 72 MP: over the cap, though within what the decoder would allocate by default.
|
||||
let data = png_header(9_000, 8_000);
|
||||
assert_eq!(
|
||||
image::ImageReader::with_format(std::io::Cursor::new(&data), image::ImageFormat::Png).into_dimensions().ok(),
|
||||
Some((9_000, 8_000))
|
||||
);
|
||||
assert!(decode(&data, image::ImageFormat::Png).is_none());
|
||||
assert!(pack(&data, [0.0; 4], 72.0, 72.0, 96.0, true).is_none());
|
||||
}
|
||||
|
||||
/// Compressing a picture whose media is also another picture's artistic-effect original must
|
||||
/// keep that media: the effect needs it to be changed or removed.
|
||||
#[test]
|
||||
fn media_still_used_as_an_artistic_original_is_kept() {
|
||||
use deckcraft_model::style::Artistic;
|
||||
let mut s = Session::with_new();
|
||||
let big = png(1200, 600);
|
||||
let processed = png(1100, 600);
|
||||
s.edit(|doc, _| {
|
||||
let original = doc.add_media("original.png", "image/png", big.clone()).expect("media");
|
||||
let shown = doc.add_media("processed.png", "image/png", processed.clone()).expect("media");
|
||||
let slide = Arc::make_mut(doc.slides.first_mut().ok_or_else(|| bad("test", "no slide"))?);
|
||||
let xfrm = Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 36.0));
|
||||
let with_effect =
|
||||
PictureFill { media: shown, artistic: Some(Artistic { effect: "artisticBlur".into(), original }), ..Default::default() };
|
||||
slide.shapes.push(Shape { id: ShapeId(70_001), xfrm, kind: ShapeKind::Picture { fill: with_effect }, ..Default::default() });
|
||||
slide.shapes.push(Shape {
|
||||
id: ShapeId(70_002),
|
||||
xfrm,
|
||||
kind: ShapeKind::Picture { fill: PictureFill { media: original, ..Default::default() } },
|
||||
..Default::default()
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let r = s.execute("picture.compress", &json!({"all": true, "ppi": 96})).unwrap();
|
||||
assert_eq!(r["pictures"], 2, "{r}");
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
// Every reference left in the deck resolves to media that is still there.
|
||||
for id in crate::media_refs::presentation_media(doc) {
|
||||
assert!(doc.media(id).is_some(), "{id:?} was deleted while still referenced");
|
||||
}
|
||||
assert!(doc.media.iter().any(|m| m.data.as_slice() == big.as_slice()), "the artistic original was deleted");
|
||||
assert!(!doc.media.iter().any(|m| m.data.as_slice() == processed.as_slice()), "the replaced processed picture is dropped");
|
||||
}
|
||||
}
|
||||
+1942
-93
File diff suppressed because it is too large
Load Diff
+380
-36
@@ -1,7 +1,9 @@
|
||||
//! Edit menu: undo/redo, clipboard, duplicate, delete, select all.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::text::TextBody;
|
||||
use deckcraft_model::{MediaId, Shape, ShapeId, Slide, SlideId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -12,13 +14,30 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(noundo "edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, undo),
|
||||
cmd!(noundo "edit.redo", "Redo", ["Edit"], Some("Cmd+Y"), "{}", can_redo, redo),
|
||||
cmd!("edit.cut", "Cut", ["Home", "Clipboard"], Some("Cmd+X"), "{scope?: slides}", has_doc, cut),
|
||||
cmd!(noundo "edit.copy", "Copy", ["Home", "Clipboard"], Some("Cmd+C"), "{scope?: slides} → {text}", has_doc, copy),
|
||||
cmd!("edit.paste", "Paste", ["Home", "Clipboard"], Some("Cmd+V"), "{text?: plain text to paste, scope?: slides}", has_doc, paste),
|
||||
cmd!("edit.cut", "Cut", ["Home", "Clipboard"], Some("Cmd+X"), "{scope?: slides} → {text, html?}", has_doc, cut),
|
||||
cmd!(noundo "edit.copy", "Copy", ["Home", "Clipboard"], Some("Cmd+C"), "{scope?: slides} → {text, html?}", has_doc, copy),
|
||||
cmd!(
|
||||
"edit.paste",
|
||||
"Paste",
|
||||
["Home", "Clipboard"],
|
||||
Some("Cmd+V"),
|
||||
"{text?: plain text to paste, html?: rich text from another app, scope?: slides}",
|
||||
has_doc,
|
||||
paste
|
||||
),
|
||||
cmd!(
|
||||
"edit.pasteSpecial",
|
||||
"Paste Special",
|
||||
["Home", "Clipboard"],
|
||||
None,
|
||||
"{as: destinationTheme|keepSource|picture|text} — Use Destination Theme, Keep Source Formatting, Picture (PNG), Keep Text Only",
|
||||
has_doc,
|
||||
paste_special
|
||||
),
|
||||
cmd!("edit.pasteText", "Paste and Match Formatting", ["Edit"], Some("Cmd+Shift+V"), "{text?}", has_doc, paste_text),
|
||||
cmd!("edit.duplicate", "Duplicate", ["Edit"], Some("Cmd+D"), "{ids?}", has_selection, duplicate),
|
||||
cmd!("edit.delete", "Delete", ["Edit"], Some("Delete"), "{ids?, scope?: slides}", has_doc, delete),
|
||||
cmd!(noundo "edit.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{}", has_slide, select_all),
|
||||
cmd!(noundo "edit.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{scope?: slides}", has_slide, select_all),
|
||||
cmd!(noundo "edit.deselect", "Deselect", [], Some("Escape"), "{}", has_doc, deselect),
|
||||
cmd!(noundo "edit.select", "Select Objects", [], None, "{ids: [id], add?: bool, toggle?: bool}", has_slide, select),
|
||||
]
|
||||
@@ -32,6 +51,8 @@ fn restore(s: &mut Session, redo: bool) -> Result<Value> {
|
||||
st.doc = e.doc;
|
||||
st.selection = e.selection;
|
||||
st.interaction = None;
|
||||
// A later shape can get an undone copy's id: Duplicate starts over with the usual offset.
|
||||
st.last_duplicate = None;
|
||||
st.revision += 1;
|
||||
Ok(json!({"label": e.label}))
|
||||
}
|
||||
@@ -48,7 +69,7 @@ fn slides_scope(s: &Session, p: &Value) -> bool {
|
||||
|| s.active().is_some_and(|d| d.selection.shapes.is_empty() && d.selection.text.is_none() && !d.selection.slides.is_empty())
|
||||
}
|
||||
|
||||
fn media_of_shapes(shapes: &[Shape], out: &mut Vec<MediaId>) {
|
||||
pub(crate) fn media_of_shapes(shapes: &[Shape], out: &mut Vec<MediaId>) {
|
||||
deckcraft_model::walk(shapes, &mut |s, _| {
|
||||
match &s.kind {
|
||||
deckcraft_model::ShapeKind::Picture { fill } => out.push(fill.media),
|
||||
@@ -58,6 +79,12 @@ fn media_of_shapes(shapes: &[Shape], out: &mut Vec<MediaId>) {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
deckcraft_model::ShapeKind::Model3d { media, poster } => {
|
||||
out.push(*media);
|
||||
if let Some(p) = poster {
|
||||
out.push(*p);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some(deckcraft_model::Fill::Picture(pf)) = &s.fill {
|
||||
@@ -87,6 +114,9 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result<String> {
|
||||
let mut mids = vec![];
|
||||
for sl in &slides {
|
||||
media_of_shapes(&sl.shapes, &mut mids);
|
||||
if let Some(deckcraft_model::Background::Fill { fill: deckcraft_model::Fill::Picture(pf) }) = &sl.background {
|
||||
mids.push(pf.media);
|
||||
}
|
||||
}
|
||||
let media = st.doc.media.iter().filter(|m| mids.contains(&m.id)).cloned().collect();
|
||||
let plain = slides.iter().map(|x| x.title()).collect::<Vec<_>>().join("\n");
|
||||
@@ -110,56 +140,173 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result<String> {
|
||||
media_of_shapes(&shapes, &mut mids);
|
||||
let media = st.doc.media.iter().filter(|m| mids.contains(&m.id)).cloned().collect();
|
||||
let plain = shapes.iter().filter_map(|x| x.text.as_ref().map(|t| t.text())).collect::<Vec<_>>().join("\n");
|
||||
s.clipboard = crate::Clipboard { shapes, media, plain: plain.clone(), ..Default::default() };
|
||||
let source_theme = st.current_slide().and_then(|sl| st.doc.master_for(sl)).map(|(m, _)| (m.scheme(), m.theme.fonts.clone()));
|
||||
s.clipboard = crate::Clipboard { shapes, media, plain: plain.clone(), source_theme, ..Default::default() };
|
||||
Ok(plain)
|
||||
}
|
||||
|
||||
fn copying_caret(s: &Session) -> bool {
|
||||
s.active().is_some_and(|d| d.selection.text.as_ref().is_some_and(|t| !t.is_range()))
|
||||
}
|
||||
|
||||
fn copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// A caret copies nothing; the previous clipboard must not leak into this answer.
|
||||
let caret = copying_caret(s);
|
||||
let t = copy_inner(s, p)?;
|
||||
Ok(json!({"text": t}))
|
||||
let html = if caret { String::new() } else { copied_html(s) };
|
||||
Ok(json!({"text": t, "html": html}))
|
||||
}
|
||||
|
||||
/// HTML for what was just copied, when it is text (a selection, or the text on the copied shapes).
|
||||
fn copied_html(s: &Session) -> String {
|
||||
if let Some(body) = &s.clipboard.text {
|
||||
return richclip::body_to_html(body);
|
||||
}
|
||||
let mut paragraphs = Vec::new();
|
||||
for sh in &s.clipboard.shapes {
|
||||
if let Some(t) = &sh.text {
|
||||
paragraphs.extend(t.paragraphs.clone());
|
||||
}
|
||||
}
|
||||
if paragraphs.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
richclip::body_to_html(&TextBody { paragraphs, ..Default::default() })
|
||||
}
|
||||
|
||||
fn cut(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let caret = copying_caret(s);
|
||||
let t = copy_inner(s, p)?;
|
||||
if caret {
|
||||
// Nothing is selected, so there is nothing to cut; `delete` would remove the next character.
|
||||
return Ok(json!({"text": t, "html": ""}));
|
||||
}
|
||||
let html = copied_html(s);
|
||||
delete(s, p)?;
|
||||
Ok(json!({"text": t}))
|
||||
Ok(json!({"text": t, "html": html}))
|
||||
}
|
||||
|
||||
/// Give pasted shapes fresh ids (groups included) and map connector ends.
|
||||
pub(crate) fn reid(doc: &mut deckcraft_model::Presentation, shapes: &mut [Shape]) {
|
||||
fn rec(doc: &mut deckcraft_model::Presentation, shapes: &mut [Shape], depth: usize) {
|
||||
/// Give pasted shapes fresh ids (groups included) and return the old-to-new map. Connector ends
|
||||
/// attached to a shape that was copied too follow its copy; ends attached to a shape outside the
|
||||
/// copied set are detached (the connector keeps its geometry), so a copy never stays glued to
|
||||
/// the original or to an unrelated shape that happens to share an id in another deck.
|
||||
pub(crate) fn reid(cmd: &str, doc: &mut deckcraft_model::Presentation, shapes: &mut [Shape]) -> Result<HashMap<ShapeId, ShapeId>> {
|
||||
fn rec(
|
||||
cmd: &str,
|
||||
doc: &mut deckcraft_model::Presentation,
|
||||
shapes: &mut [Shape],
|
||||
map: &mut HashMap<ShapeId, ShapeId>,
|
||||
depth: usize,
|
||||
) -> Result<()> {
|
||||
if depth > 64 {
|
||||
return Ok(());
|
||||
}
|
||||
for s in shapes {
|
||||
let id = ShapeId(doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE))?);
|
||||
map.insert(s.id, id);
|
||||
s.id = id;
|
||||
if let Some(ch) = s.children_mut() {
|
||||
rec(cmd, doc, ch, map, depth + 1)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
let mut map = HashMap::new();
|
||||
rec(cmd, doc, shapes, &mut map, 0)?;
|
||||
walk_mut(shapes, &mut |s| {
|
||||
if let deckcraft_model::ShapeKind::Connector { start, end } = &mut s.kind {
|
||||
for e in [start, end] {
|
||||
*e = e.and_then(|(id, site)| map.get(&id).map(|n| (*n, site)));
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
/// Point copied animations at the copies: targets and triggers in `map` follow it; an animation
|
||||
/// whose target wasn't copied is dropped, and a trigger that wasn't copied is cleared.
|
||||
pub(crate) fn remap_animations(anims: &mut Vec<deckcraft_model::Animation>, map: &HashMap<ShapeId, ShapeId>) {
|
||||
anims.retain_mut(|a| {
|
||||
let Some(n) = map.get(&a.shape) else { return false };
|
||||
a.shape = *n;
|
||||
a.trigger = a.trigger.and_then(|t| map.get(&t).copied());
|
||||
true
|
||||
});
|
||||
}
|
||||
|
||||
/// Visit every shape mutably, depth-first (groups included, depth-bounded like `walk`).
|
||||
fn walk_mut(shapes: &mut [Shape], f: &mut dyn FnMut(&mut Shape)) {
|
||||
fn rec(shapes: &mut [Shape], f: &mut dyn FnMut(&mut Shape), depth: usize) {
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
for s in shapes {
|
||||
s.id = ShapeId(doc.alloc_id());
|
||||
f(s);
|
||||
if let Some(ch) = s.children_mut() {
|
||||
rec(doc, ch, depth + 1);
|
||||
rec(ch, f, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
rec(doc, shapes, 0);
|
||||
rec(shapes, f, 0);
|
||||
}
|
||||
|
||||
fn bring_media(doc: &mut deckcraft_model::Presentation, media: &[deckcraft_model::MediaItem]) {
|
||||
/// Rewrite the media ids shapes use (`map`: clipboard id to destination id).
|
||||
fn remap_media(shapes: &mut [Shape], map: &HashMap<MediaId, MediaId>) {
|
||||
if map.is_empty() {
|
||||
return;
|
||||
}
|
||||
let m = |id: &mut MediaId| {
|
||||
if let Some(n) = map.get(id) {
|
||||
*id = *n;
|
||||
}
|
||||
};
|
||||
walk_mut(shapes, &mut |s| {
|
||||
match &mut s.kind {
|
||||
deckcraft_model::ShapeKind::Picture { fill } => m(&mut fill.media),
|
||||
deckcraft_model::ShapeKind::Media(c) => {
|
||||
m(&mut c.media);
|
||||
if let Some(p) = c.poster.as_mut() {
|
||||
m(p);
|
||||
}
|
||||
}
|
||||
deckcraft_model::ShapeKind::Opaque { preview: Some(p), .. } => m(p),
|
||||
_ => {}
|
||||
}
|
||||
if let Some(deckcraft_model::Fill::Picture(pf)) = &mut s.fill {
|
||||
m(&mut pf.media);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Add the clipboard's media to `doc` and return the ids that changed: an id already used in
|
||||
/// `doc` by other bytes (another deck) gets a fresh id, and the pasted shapes must use it.
|
||||
fn bring_media(cmd: &str, doc: &mut deckcraft_model::Presentation, media: &[deckcraft_model::MediaItem]) -> Result<HashMap<MediaId, MediaId>> {
|
||||
let mut map = HashMap::new();
|
||||
for m in media {
|
||||
if doc.media(m.id).is_none_or(|x| x.data != m.data) {
|
||||
if doc.media(m.id).is_none() {
|
||||
doc.media.push(m.clone());
|
||||
} else {
|
||||
// Id clash with different bytes: unusual (other deck); keep ours, add theirs as new.
|
||||
let id = doc.add_media(&m.name, &m.content_type, m.data.as_ref().clone());
|
||||
let _ = id;
|
||||
match doc.media(m.id) {
|
||||
None => doc.media.push(m.clone()),
|
||||
Some(x) if x.data == m.data => {}
|
||||
Some(_) => {
|
||||
let id = doc.add_media(&m.name, &m.content_type, m.data.as_ref().clone()).ok_or_else(|| bad(cmd, NO_ID_SPACE))?;
|
||||
map.insert(m.id, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// External plain text (from the system clipboard) wins when given and differs from ours.
|
||||
// HTML from Pages or Keynote keeps bold, italic and underline when it says the same words.
|
||||
if let Some(t) = str_param(p, "text")
|
||||
&& t != s.clipboard.plain
|
||||
{
|
||||
if let Some(html) = str_param(p, "html")
|
||||
&& let Some(body) = richclip::html_to_body(html)
|
||||
&& richclip::same_words(&body.text(), t)
|
||||
{
|
||||
return paste_external_body(s, &body);
|
||||
}
|
||||
return paste_plain(s, t);
|
||||
}
|
||||
let editing = s.doc()?.selection.text.is_some();
|
||||
@@ -174,12 +321,19 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !clip.slides.is_empty() {
|
||||
let at = s.doc()?.selection.slide + 1;
|
||||
return s.edit(|doc, sel| {
|
||||
bring_media(doc, &clip.media);
|
||||
let media = bring_media("edit.paste", doc, &clip.media)?;
|
||||
let mut new_ids = vec![];
|
||||
for (k, sl) in clip.slides.iter().enumerate() {
|
||||
let mut sl = sl.clone();
|
||||
sl.id = SlideId(doc.alloc_id());
|
||||
reid(doc, &mut sl.shapes);
|
||||
sl.id = SlideId(doc.alloc_id().ok_or_else(|| bad("edit.paste", NO_ID_SPACE))?);
|
||||
let map = reid("edit.paste", doc, &mut sl.shapes)?;
|
||||
remap_animations(&mut sl.animations, &map);
|
||||
remap_media(&mut sl.shapes, &media);
|
||||
if let Some(deckcraft_model::Background::Fill { fill: deckcraft_model::Fill::Picture(pf) }) = &mut sl.background
|
||||
&& let Some(n) = media.get(&pf.media)
|
||||
{
|
||||
pf.media = *n;
|
||||
}
|
||||
if doc.layout(sl.layout).is_none() {
|
||||
sl.layout = doc.masters.first().and_then(|m| m.layouts.first()).map(|l| l.id).unwrap_or_default();
|
||||
}
|
||||
@@ -200,9 +354,10 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let same_slide = s.doc().ok().is_some_and(|d| clip.shapes.iter().any(|c| d.shape(c.id).is_some()));
|
||||
let off = if same_slide { off } else { 0.0 };
|
||||
return s.edit(|doc, sel| {
|
||||
bring_media(doc, &clip.media);
|
||||
let media = bring_media("edit.paste", doc, &clip.media)?;
|
||||
let mut shapes = clip.shapes.clone();
|
||||
reid(doc, &mut shapes);
|
||||
reid("edit.paste", doc, &mut shapes)?;
|
||||
remap_media(&mut shapes, &media);
|
||||
for sh in &mut shapes {
|
||||
if let Some(x) = sh.xfrm.as_mut() {
|
||||
x.x += off;
|
||||
@@ -228,6 +383,33 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
fn paste_external_body(s: &mut Session, body: &TextBody) -> Result<Value> {
|
||||
if s.doc()?.selection.text.is_some() {
|
||||
return super::text::paste_body(s, body);
|
||||
}
|
||||
let plain = body.text();
|
||||
let st = s.doc()?;
|
||||
let size = st.doc.slide_size;
|
||||
let rect = [size.width * 0.25, size.height * 0.4, size.width * 0.5, 40.0];
|
||||
let made = s.execute("insert.textBox", &json!({"rect": rect, "text": plain}))?;
|
||||
let raw = made.get("id").and_then(Value::as_u64).ok_or_else(|| bad("edit.paste", "no text box"))?;
|
||||
let id = u32::try_from(raw).map_err(|_| bad("edit.paste", "no text box"))?;
|
||||
let body = body.clone();
|
||||
s.edit(|doc, sel| {
|
||||
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("edit.paste", "no slide"))?;
|
||||
let sh = deckcraft_model::find_shape_mut(list, ShapeId(id)).ok_or_else(|| bad("edit.paste", "no text box"))?;
|
||||
if let Some(t) = sh.text.as_mut() {
|
||||
let (wrap, autofit) = (t.body.wrap, t.body.autofit);
|
||||
*t = body.clone();
|
||||
t.body.wrap = wrap;
|
||||
t.body.autofit = autofit;
|
||||
}
|
||||
sel.shapes = vec![ShapeId(id)];
|
||||
sel.text = None;
|
||||
Ok(json!({"id": id}))
|
||||
})
|
||||
}
|
||||
|
||||
fn paste_plain(s: &mut Session, text: &str) -> Result<Value> {
|
||||
if s.doc()?.selection.text.is_some() {
|
||||
return super::text::insert_text(s, text, None);
|
||||
@@ -239,6 +421,113 @@ fn paste_plain(s: &mut Session, text: &str) -> Result<Value> {
|
||||
s.execute("insert.textBox", &json!({"rect": rect, "text": text}))
|
||||
}
|
||||
|
||||
fn paste_special(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
match str_param(p, "as").unwrap_or("destinationTheme") {
|
||||
"destinationTheme" | "useDestinationTheme" => paste(s, &json!({})),
|
||||
"text" | "keepTextOnly" => paste_text(s, &json!({})),
|
||||
"keepSource" | "keepSourceFormatting" => {
|
||||
let saved = s.clipboard.clone();
|
||||
if let Some((scheme, fonts)) = &saved.source_theme {
|
||||
bake_theme(&mut s.clipboard.shapes, scheme, fonts);
|
||||
}
|
||||
let r = paste(s, &json!({}));
|
||||
let pastes = s.clipboard.pastes;
|
||||
s.clipboard = saved;
|
||||
s.clipboard.pastes = pastes;
|
||||
r
|
||||
}
|
||||
"picture" => paste_picture(s),
|
||||
other => Err(bad("edit.pasteSpecial", format!("unknown `as`: {other} (destinationTheme, keepSource, picture, text)"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace theme colours and theme fonts with the values they have in `scheme` / `fonts`, so the
|
||||
/// shapes keep their look under another theme.
|
||||
pub(crate) fn bake_theme(shapes: &mut [Shape], scheme: &deckcraft_color::ColorScheme, fonts: &deckcraft_model::theme::FontScheme) {
|
||||
fn rec(v: &mut Value, scheme: &deckcraft_color::ColorScheme, fonts: &deckcraft_model::theme::FontScheme, depth: usize) {
|
||||
if depth > 256 {
|
||||
return;
|
||||
}
|
||||
match v {
|
||||
Value::Object(o) => {
|
||||
if o.get("kind").and_then(Value::as_str) == Some("scheme")
|
||||
&& let Some(slot) = o.get("slot").and_then(|x| serde_json::from_value::<deckcraft_color::SchemeSlot>(x.clone()).ok())
|
||||
&& slot != deckcraft_color::SchemeSlot::PhClr
|
||||
&& let Ok(rgb) = serde_json::to_value(scheme.get(slot))
|
||||
{
|
||||
o.clear();
|
||||
o.insert("kind".into(), json!("rgb"));
|
||||
o.insert("rgb".into(), rgb);
|
||||
return;
|
||||
}
|
||||
for x in o.values_mut() {
|
||||
rec(x, scheme, fonts, depth + 1);
|
||||
}
|
||||
}
|
||||
Value::Array(a) => a.iter_mut().for_each(|x| rec(x, scheme, fonts, depth + 1)),
|
||||
Value::String(t) if t.starts_with('+') => {
|
||||
let set = match t.get(1..3) {
|
||||
Some("mj") => &fonts.major,
|
||||
Some("mn") => &fonts.minor,
|
||||
_ => return,
|
||||
};
|
||||
let face = match t.get(3..) {
|
||||
Some("-lt") => &set.latin,
|
||||
Some("-ea") => &set.ea,
|
||||
Some("-cs") => &set.cs,
|
||||
_ => return,
|
||||
};
|
||||
if !face.is_empty() {
|
||||
*t = face.clone();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for sh in shapes.iter_mut() {
|
||||
let Ok(mut v) = serde_json::to_value(&*sh) else { continue };
|
||||
rec(&mut v, scheme, fonts, 0);
|
||||
if let Ok(baked) = serde_json::from_value::<Shape>(v) {
|
||||
*sh = baked;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Paste Special › Picture: the copied shapes drawn into one PNG picture.
|
||||
fn paste_picture(s: &mut Session) -> Result<Value> {
|
||||
let mut clip = s.clipboard.clone();
|
||||
if clip.shapes.is_empty() {
|
||||
return Err(bad("edit.pasteSpecial", "copy shapes first to paste them as a picture"));
|
||||
}
|
||||
if let Some((scheme, fonts)) = &clip.source_theme {
|
||||
bake_theme(&mut clip.shapes, scheme, fonts);
|
||||
}
|
||||
let st = s.doc()?;
|
||||
let mut doc = (*st.doc).clone();
|
||||
bring_media("edit.pasteSpecial", &mut doc, &clip.media)?;
|
||||
let mut slide = st.current_slide().cloned().ok_or_else(|| bad("edit.pasteSpecial", "no slide"))?;
|
||||
let xs: Vec<deckcraft_geom::Xfrm> = clip.shapes.iter().filter_map(|x| x.xfrm).collect();
|
||||
let b = super::arrange::union_bounds(&xs).ok_or_else(|| bad("edit.pasteSpecial", "nothing to draw"))?;
|
||||
let gx = deckcraft_geom::Xfrm::new(b.x0, b.y0, b.width(), b.height());
|
||||
let gid = ShapeId(u32::MAX - 1);
|
||||
let one = if clip.shapes.len() == 1 {
|
||||
let mut sh = clip.shapes[0].clone();
|
||||
sh.id = gid;
|
||||
sh
|
||||
} else {
|
||||
Shape { id: gid, xfrm: Some(gx), kind: deckcraft_model::ShapeKind::Group { children: clip.shapes.clone(), child: gx }, ..Default::default() }
|
||||
};
|
||||
slide.shapes = vec![one];
|
||||
const SCALE: f64 = 2.0;
|
||||
let img = deckcraft_render::render_shape(&doc, &slide, gid, SCALE).ok_or_else(|| bad("edit.pasteSpecial", "the shapes could not be drawn"))?;
|
||||
// render_shape draws the bounds plus an 8 pt margin (room for shadows and outlines).
|
||||
let (w, h) = (img.width as f64 / SCALE, img.height as f64 / SCALE);
|
||||
let same_slide = clip.shapes.iter().any(|c| st.shape(c.id).is_some());
|
||||
let off = if same_slide { s.prefs.duplicate_offset } else { 0.0 };
|
||||
let png = img.to_png();
|
||||
s.execute("insert.picture", &json!({"data": base64_encode(&png), "name": "Picture.png", "rect": [b.x0 - 8.0 + off, b.y0 - 8.0 + off, w, h]}))
|
||||
}
|
||||
|
||||
fn paste_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let t = str_param(p, "text").map(String::from).unwrap_or_else(|| s.clipboard.plain.clone());
|
||||
paste_plain(s, &t)
|
||||
@@ -247,15 +536,31 @@ fn paste_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
let off = s.prefs.duplicate_offset;
|
||||
s.edit(|doc, sel| {
|
||||
let last = s.doc()?.last_duplicate.clone();
|
||||
let (new_ids, made) = s.edit(|doc, sel| {
|
||||
let list = super::current_shapes(doc, sel).cloned().unwrap_or_default();
|
||||
let mut copies: Vec<Shape> = list.iter().filter(|x| ids.contains(&x.id)).cloned().collect();
|
||||
let sources: Vec<ShapeId> = copies.iter().map(|c| c.id).collect();
|
||||
let boxes: Vec<_> = copies.iter().map(|c| xfrm_of(doc, sel, c)).collect();
|
||||
reid(doc, &mut copies);
|
||||
// Duplicating the copies of the last Duplicate again repeats how far they were moved
|
||||
// since (#966); anything else gets the usual offset.
|
||||
let (dx, dy) = last
|
||||
.filter(|(slide, target, pairs)| {
|
||||
*slide == sel.slide && *target == sel.target && pairs.len() == sources.len() && pairs.iter().all(|(_, c)| sources.contains(c))
|
||||
})
|
||||
.and_then(|(_, _, pairs)| {
|
||||
let (src, copy) = pairs.first()?;
|
||||
let a = xfrm_of(doc, sel, list.iter().find(|x| x.id == *src)?);
|
||||
let b = xfrm_of(doc, sel, list.iter().find(|x| x.id == *copy)?);
|
||||
let d = (b.x - a.x, b.y - a.y);
|
||||
(d.0.is_finite() && d.1.is_finite()).then_some(d)
|
||||
})
|
||||
.unwrap_or((off, off));
|
||||
reid("edit.duplicate", doc, &mut copies)?;
|
||||
for (c, b) in copies.iter_mut().zip(boxes) {
|
||||
let mut x = b;
|
||||
x.x += off;
|
||||
x.y += off;
|
||||
x.x += dx;
|
||||
x.y += dy;
|
||||
c.xfrm = Some(x);
|
||||
if sel.target == crate::Target::Slides {
|
||||
c.ph = None;
|
||||
@@ -268,8 +573,11 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad("edit.duplicate", "no slide"))?;
|
||||
shapes.extend(copies);
|
||||
sel.shapes = new_ids.clone();
|
||||
Ok(json!({"ids": new_ids}))
|
||||
})
|
||||
let made = (sel.slide, sel.target, sources.into_iter().zip(new_ids.iter().copied()).collect());
|
||||
Ok((new_ids, made))
|
||||
})?;
|
||||
s.doc_mut()?.last_duplicate = Some(made);
|
||||
Ok(json!({"ids": new_ids}))
|
||||
}
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -285,12 +593,24 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
s.edit(|doc, sel| {
|
||||
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad("edit.delete", "no slide"))?;
|
||||
let mut before = Vec::new();
|
||||
deckcraft_model::walk(shapes, &mut |s, _| before.push(s.id));
|
||||
let n = remove_shapes(shapes, &ids);
|
||||
// Everything that is gone: the given ids plus group descendants and emptied groups.
|
||||
let mut left = Vec::new();
|
||||
deckcraft_model::walk(shapes, &mut |s, _| left.push(s.id));
|
||||
let mut gone = ids.clone();
|
||||
gone.extend(before.into_iter().filter(|i| !left.contains(i)));
|
||||
// Animations of deleted shapes go too.
|
||||
if sel.target == crate::Target::Slides
|
||||
&& let Some(sl) = doc.slides.get_mut(sel.slide)
|
||||
{
|
||||
Arc::make_mut(sl).animations.retain(|a| !ids.contains(&a.shape));
|
||||
let sl = Arc::make_mut(sl);
|
||||
sl.animations.retain(|a| !gone.contains(&a.shape));
|
||||
// Effects triggered by a deleted shape fall back to the main sequence.
|
||||
for a in sl.animations.iter_mut().filter(|a| a.trigger.is_some_and(|t| gone.contains(&t))) {
|
||||
a.trigger = None;
|
||||
}
|
||||
}
|
||||
sel.shapes.retain(|i| !ids.contains(i));
|
||||
sel.text = None;
|
||||
@@ -323,7 +643,12 @@ fn count(v: &[Shape]) -> usize {
|
||||
n
|
||||
}
|
||||
|
||||
fn select_all(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
fn select_all(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
match p.get("scope") {
|
||||
None | Some(Value::Null) => {}
|
||||
Some(Value::String(v)) if v == "slides" => return select_all_slides(s),
|
||||
Some(_) => return Err(bad("edit.selectAll", "scope must be \"slides\"")),
|
||||
}
|
||||
if s.doc()?.selection.text.is_some() {
|
||||
return s.execute("text.selectAll", &json!({}));
|
||||
}
|
||||
@@ -335,6 +660,22 @@ fn select_all(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
Ok(json!({"ids": ids}))
|
||||
}
|
||||
|
||||
/// Thumbnail-pane / sorter select all: every slide, hidden ones included. The current
|
||||
/// slide stays where it is (the canvas must not jump), so this is not `slide.selectSlides`.
|
||||
fn select_all_slides(s: &mut Session) -> Result<Value> {
|
||||
if s.doc()?.selection.target != crate::Target::Slides {
|
||||
return Err(bad("edit.selectAll", "the slide list is not shown while editing a master"));
|
||||
}
|
||||
let ids: Vec<SlideId> = s.doc()?.doc.slides.iter().map(|x| x.id).collect();
|
||||
s.select(|_, sel| {
|
||||
sel.slides = ids.clone();
|
||||
sel.shapes.clear();
|
||||
sel.text = None;
|
||||
sel.cells = None;
|
||||
})?;
|
||||
Ok(json!({"selected": ids}))
|
||||
}
|
||||
|
||||
fn deselect(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
s.painter = None;
|
||||
s.anim_painter = None;
|
||||
@@ -353,7 +694,10 @@ fn deselect(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])).unwrap_or_default();
|
||||
let ids = match ids_param(p, "ids") {
|
||||
Some(v) => v,
|
||||
None => id_param(p, "id", "edit.select")?.map(|i| vec![i]).unwrap_or_default(),
|
||||
};
|
||||
let add = bool_or(p, "add", false);
|
||||
let toggle = bool_or(p, "toggle", false);
|
||||
let valid: Vec<ShapeId> = ids.into_iter().filter(|i| s.active().is_some_and(|d| d.shape(*i).is_some())).collect();
|
||||
|
||||
+491
-60
@@ -1,6 +1,10 @@
|
||||
//! File menu: new, open, save, export, close, properties.
|
||||
|
||||
use deckcraft_model::{Presentation, defaults};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_media::MediaInfo;
|
||||
use deckcraft_model::{MediaId, MediaItem, Presentation, Shape, ShapeKind, defaults};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
@@ -8,15 +12,51 @@ use crate::{DocState, EngineError, Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(noundo "file.new", "New Presentation", ["File"], Some("Cmd+N"), "{theme?: name, size?: [w,h] pt, blank?: bool}", always, new),
|
||||
cmd!(
|
||||
noundo "file.new",
|
||||
"New Presentation",
|
||||
["File"],
|
||||
Some("Cmd+N"),
|
||||
"{theme?: name, template?: starter name, size?: [w,h] pt, blank?: bool}",
|
||||
always,
|
||||
new
|
||||
),
|
||||
cmd!(query "file.templates", "Starter Templates", [], None, "{} → [{name, summary, theme, slides}]", always, templates),
|
||||
cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path}", always, open),
|
||||
cmd!(
|
||||
noundo "file.adopt",
|
||||
"Open",
|
||||
[],
|
||||
None,
|
||||
"{path, token?} (a presentation the host read in the background and staged with Session::stage_loaded; without one, reads path like file.open)",
|
||||
always,
|
||||
adopt
|
||||
),
|
||||
cmd!(noundo "file.openBytes", "Open Data", [], None, "{name, data: base64}", always, open_bytes),
|
||||
cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?, format?: deckcraft|pptx}", has_doc, save),
|
||||
cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path, format?}", has_doc, save_as),
|
||||
cmd!(noundo "file.saveTemplate", "Save as Template…", ["File"], None, "{path}", has_doc, save_as),
|
||||
cmd!(query "file.saveBytes", "Save to Data", [], None, "{format?: deckcraft|pptx} → {data: base64}", has_doc, save_bytes_cmd),
|
||||
cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format: png|jpeg|pptx|deckcraft|outline|pdf, slide?: index, all?: bool, scale?: px per pt; pdf: layout?: slides|notes|handouts, perPage?: 1|2|3|4|6|9, dpi?, slides?: [index], includeHidden?, textLayer?, frame?}", has_doc, export),
|
||||
cmd!(query "file.render", "Render Slide", [], None, "{slide?: index, scale?, edit?: bool} → {png: base64, width, height}", has_doc, render),
|
||||
cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format?: png|jpeg|pptx|deckcraft|outline|rtf|pdf|gif (else by path), slide?: index, all?: bool, scale?: px per pt, layout?: slides|notes|handouts (pdf), perPage?: 1|2|3|4|6|9, paper?: letter|a4|legal|a3 (pdf), orientation?: portrait|landscape (pdf), dpi?, slides?: [index]|range (\"1-3,5\"), includeHidden?, textLayer?, frame?, transparentBackground?: bool (png: leave the slide background out), width?: px 16-3840|small|medium|large|extraLarge (gif), secondsPerSlide?: 0.1-600 (gif; 5), loop?: bool (gif; true: forever)} → {path, bytes} or {files} (png, jpeg); gif adds frames, width, height, secondsPerSlide", has_doc, export),
|
||||
cmd!(
|
||||
query "file.info",
|
||||
"Info",
|
||||
["File"],
|
||||
None,
|
||||
"{} → {title, path, format, dirty, slides, hiddenSlides, sections, bytes?, properties}",
|
||||
has_doc,
|
||||
info
|
||||
),
|
||||
cmd!(query "file.render", "Render Slide", [], None, "{slide?: index, scale?, edit?: bool, transparentBackground?: bool} → {png: base64, width, height}", has_doc, render),
|
||||
cmd!(
|
||||
query "slide.colorAt",
|
||||
"Color At",
|
||||
[],
|
||||
None,
|
||||
"{slide?: index, x, y (slide units), scale?: px per unit (2)} → {color: \"RRGGBB\", alpha: 0..255, x, y}: the colour drawn at a point of the slide (the Eyedropper)",
|
||||
has_doc,
|
||||
color_at
|
||||
),
|
||||
cmd!(noundo "file.close", "Close", ["File"], Some("Cmd+W"), "{}", has_doc, close),
|
||||
cmd!(
|
||||
"file.properties",
|
||||
@@ -30,7 +70,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(noundo "file.recovery.save", "Save AutoRecover Information", [], None, "{} → {written}", has_doc, recovery_save),
|
||||
cmd!(query "file.recovery.list", "Recovered Presentations", [], None, "{} → [{uid, path, title, saved}]", always, recovery_list),
|
||||
cmd!(noundo "file.recovery.open", "Open Recovered Presentations", [], None, "{} → {opened: [document index]}", always, recovery_open),
|
||||
cmd!(noundo "file.recovery.discard", "Discard Recovered Presentations", [], None, "{uid?: one entry, else all}", always, recovery_discard),
|
||||
cmd!(noundo "file.recovery.discard", "Discard Recovered Presentations", [], None, "{uid?: one entry (else all)}", always, recovery_discard),
|
||||
cmd!(noundo "file.revert", "Revert", [], None, "{}", has_doc, revert),
|
||||
cmd!(noundo "window.next", "Next Window", ["Window"], Some("Cmd+`"), "{index?}", has_doc, next_window),
|
||||
]
|
||||
@@ -40,60 +80,218 @@ fn theme_named(name: &str) -> Option<deckcraft_model::Theme> {
|
||||
deckcraft_model::theme::builtin_themes().into_iter().find(|t| t.name.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
fn new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let theme = str_param(p, "theme").and_then(theme_named).unwrap_or_default();
|
||||
let size = p
|
||||
.get("size")
|
||||
fn slide_size(p: &Value) -> deckcraft_geom::Size {
|
||||
p.get("size")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|a| Some(deckcraft_geom::Size::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?)))
|
||||
.filter(|s| s.width >= 1.0 && s.height >= 1.0 && s.width < 5000.0 && s.height < 5000.0)
|
||||
.unwrap_or(defaults::WIDE);
|
||||
let doc = defaults::blank_presentation(size, theme, !bool_or(p, "blank", false));
|
||||
.unwrap_or(defaults::WIDE)
|
||||
}
|
||||
|
||||
fn templates(_s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
Ok(json!({
|
||||
"templates": crate::templates::starters().iter().map(|t| json!({
|
||||
"name": t.name,
|
||||
"summary": t.summary,
|
||||
"theme": t.theme,
|
||||
"slides": t.slides.len(),
|
||||
})).collect::<Vec<_>>(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let template = str_param(p, "template").filter(|t| !t.is_empty());
|
||||
let theme_name = str_param(p, "theme").filter(|t| !t.is_empty());
|
||||
if template.is_some() && theme_name.is_some() {
|
||||
return Err(bad("file.new", "pass `theme` or `template`, not both"));
|
||||
}
|
||||
if template.is_some() && bool_or(p, "blank", false) {
|
||||
return Err(bad("file.new", "a template is not a blank presentation"));
|
||||
}
|
||||
let size = slide_size(p);
|
||||
let (doc, template_name) = if let Some(name) = template {
|
||||
let doc = crate::templates::build(name, size)?;
|
||||
let chosen = crate::templates::find(name).map(|t| t.name);
|
||||
(doc, chosen)
|
||||
} else {
|
||||
let theme = theme_name.and_then(theme_named).unwrap_or_default();
|
||||
(defaults::blank_presentation(size, theme, !bool_or(p, "blank", false)), None)
|
||||
};
|
||||
let slides = doc.slides.len();
|
||||
let title = s.next_untitled();
|
||||
let i = s.add_document(DocState::new(doc, None, title));
|
||||
Ok(json!({"document": i}))
|
||||
let mut out = json!({"document": i, "slides": slides});
|
||||
if let Some(name) = template_name {
|
||||
out["template"] = json!(name);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Recognise and read presentation bytes (native, PPTX).
|
||||
pub fn open_presentation(name: &str, bytes: &[u8]) -> Result<Presentation> {
|
||||
if deckcraft_format::sniff(bytes) {
|
||||
return deckcraft_format::load(bytes).map_err(|e| EngineError::Other(e.to_string()));
|
||||
open_checked(name, bytes).map(|(p, _)| p)
|
||||
}
|
||||
|
||||
/// What a file lost on opening.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct Damage {
|
||||
/// Pictures and media a `.deckcraft` file had lost or damaged.
|
||||
pub media: Vec<String>,
|
||||
/// Slides a `.pptx` lists but that couldn't be read, so they are not in the presentation.
|
||||
pub slides: Vec<String>,
|
||||
}
|
||||
|
||||
impl Damage {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.media.is_empty() && self.slides.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// [`open_presentation`], plus what the file had lost or damaged.
|
||||
pub fn open_checked(name: &str, bytes: &[u8]) -> Result<(Presentation, Damage)> {
|
||||
if deckcraft_format::sniff(bytes) {
|
||||
let (p, media) = deckcraft_format::load_checked(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
return Ok((p, Damage { media, slides: vec![] }));
|
||||
}
|
||||
open_other(name, bytes).map(|(p, slides)| (p, Damage { media: vec![], slides }))
|
||||
}
|
||||
|
||||
fn open_other(name: &str, bytes: &[u8]) -> Result<(Presentation, Vec<String>)> {
|
||||
if deckcraft_pptx::sniff(bytes) {
|
||||
let mut p = deckcraft_pptx::import(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let (mut p, lost_slides) = deckcraft_pptx::import_checked(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
deckcraft_format::repair(&mut p);
|
||||
return Ok(p);
|
||||
probe_clips(&mut p);
|
||||
return Ok((p, lost_slides));
|
||||
}
|
||||
if deckcraft_pptx::is_encrypted(bytes) {
|
||||
return Err(EngineError::Other(format!("{name} is password-protected. DeckCraft can't open password-protected presentations yet.")));
|
||||
}
|
||||
let lower = name.to_ascii_lowercase();
|
||||
if lower.ends_with(".txt") || lower.ends_with(".md") {
|
||||
let rtf = deckcraft_format::rtf::sniff_rtf(bytes);
|
||||
if rtf || lower.ends_with(".txt") || lower.ends_with(".md") {
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
let text = if rtf { std::borrow::Cow::Owned(deckcraft_format::rtf::rtf_to_outline(&text)) } else { text };
|
||||
let mut p = defaults::blank_presentation(defaults::WIDE, Default::default(), false);
|
||||
deckcraft_format::outline_to_slides(&mut p, &text);
|
||||
if lower.ends_with(".md") {
|
||||
deckcraft_format::markdown_to_slides(&mut p, &text);
|
||||
} else {
|
||||
deckcraft_format::outline_to_slides(&mut p, &text);
|
||||
}
|
||||
if p.slides.is_empty() {
|
||||
p = defaults::new_presentation(None);
|
||||
}
|
||||
return Ok(p);
|
||||
return Ok((p, vec![]));
|
||||
}
|
||||
Err(EngineError::Other(format!("{name}: not a presentation DeckCraft can read")))
|
||||
}
|
||||
|
||||
/// Fill in each audio and video clip's length and picture size from its bytes: a `.pptx` doesn't
|
||||
/// store them. Headers only, so opening never decodes a whole audio file.
|
||||
fn probe_clips(p: &mut Presentation) {
|
||||
fn rec(shapes: &mut [Shape], media: &[MediaItem], probed: &mut HashMap<MediaId, Option<MediaInfo>>, depth: usize) {
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
for s in shapes {
|
||||
if let ShapeKind::Media(m) = &mut s.kind
|
||||
&& (m.duration_ms == 0 || m.width == 0 && m.height == 0)
|
||||
&& let Some(info) = probed
|
||||
.entry(m.media)
|
||||
.or_insert_with(|| media.iter().find(|i| i.id == m.media).and_then(|i| deckcraft_media::probe_headers(&i.data).ok()))
|
||||
{
|
||||
if m.duration_ms == 0 {
|
||||
m.duration_ms = info.duration_ms.min(u32::MAX as u64) as u32;
|
||||
}
|
||||
if m.width == 0
|
||||
&& m.height == 0
|
||||
&& let Some(v) = &info.video
|
||||
{
|
||||
(m.width, m.height) = (v.width, v.height);
|
||||
}
|
||||
}
|
||||
if let Some(children) = s.children_mut() {
|
||||
rec(children, media, probed, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut probed = HashMap::new();
|
||||
// Just read, so no slide is shared yet and `make_mut` doesn't copy.
|
||||
for slide in &mut p.slides {
|
||||
rec(&mut Arc::make_mut(slide).shapes, &p.media, &mut probed, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// An outline file's text: RTF is read into the tab-indented outline, anything else as UTF-8.
|
||||
pub fn outline_text(bytes: &[u8]) -> String {
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
if deckcraft_format::rtf::sniff_rtf(bytes) { deckcraft_format::rtf::rtf_to_outline(&text) } else { text.into_owned() }
|
||||
}
|
||||
|
||||
pub fn add_opened(s: &mut Session, name: &str, path: Option<String>, p: Presentation) -> usize {
|
||||
let title = std::path::Path::new(name).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| name.to_string());
|
||||
crate::fonts::install_embedded_fonts(&p);
|
||||
let missing_fonts = crate::fonts::missing_fonts(&p);
|
||||
let mut st = DocState::new(p, path, title);
|
||||
st.missing_fonts = missing_fonts;
|
||||
s.add_document(st)
|
||||
}
|
||||
|
||||
/// A presentation read from disk, not yet added to a session: [`read_presentation_file`] needs no
|
||||
/// session, so a host can run it on a background thread, hand the result to
|
||||
/// [`Session::stage_loaded`] and add it with `file.adopt`.
|
||||
#[derive(Debug)]
|
||||
pub struct LoadedFile {
|
||||
pub path: String,
|
||||
pub doc: Presentation,
|
||||
/// A native or PPTX file (saving goes back to it); an outline import has no file to save to.
|
||||
pub native: bool,
|
||||
/// What the file had lost or damaged.
|
||||
pub damaged: Damage,
|
||||
}
|
||||
|
||||
/// Read and parse the presentation at `path`.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn read_presentation_file(path: &str) -> Result<LoadedFile> {
|
||||
let bytes = crate::io::read_file(path).map_err(EngineError::Other)?;
|
||||
let (doc, damaged) = open_checked(path, &bytes)?;
|
||||
let native = deckcraft_format::sniff(&bytes) || deckcraft_pptx::sniff(&bytes);
|
||||
Ok(LoadedFile { path: path.to_string(), doc, native, damaged })
|
||||
}
|
||||
|
||||
/// Add a loaded presentation as the active document: the shared end of `file.open` and
|
||||
/// `file.adopt`. A damaged file opens as an untitled repaired copy, so Save can't overwrite the
|
||||
/// original with the empty media.
|
||||
fn adopt_loaded(s: &mut Session, f: LoadedFile) -> Result<Value> {
|
||||
let LoadedFile { path, doc, native, damaged } = f;
|
||||
let keep_path = native && damaged.is_empty();
|
||||
let i = add_opened(s, &path, keep_path.then(|| path.clone()), doc);
|
||||
report_damage(s, &damaged)?;
|
||||
let st = s.doc()?;
|
||||
Ok(json!({
|
||||
"document": i, "slides": st.doc.slides.len(), "missingFonts": st.missing_fonts,
|
||||
"damagedMedia": damaged.media, "lostSlides": damaged.slides,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("file.open", "missing `path`"))?;
|
||||
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let doc = open_presentation(path, &bytes)?;
|
||||
let native = deckcraft_format::sniff(&bytes) || deckcraft_pptx::sniff(&bytes);
|
||||
let i = add_opened(s, path, native.then(|| path.to_string()), doc);
|
||||
let st = s.doc()?;
|
||||
Ok(json!({"document": i, "slides": st.doc.slides.len(), "missingFonts": st.missing_fonts}))
|
||||
adopt_loaded(s, read_presentation_file(path)?)
|
||||
}
|
||||
|
||||
/// `file.adopt {path, token?}`: add the presentation a host read in the background and staged
|
||||
/// with [`Session::stage_loaded`]. Without that staged file (a replayed journal, a stale token)
|
||||
/// it reads `path` like `file.open`, so the journal entry stays replayable.
|
||||
fn adopt(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("file.adopt", "missing `path`"))?;
|
||||
let staged = p.get("token").and_then(Value::as_u64).and_then(|t| s.take_loaded(t)).filter(|f| f.path == path);
|
||||
match staged {
|
||||
Some(f) => adopt_loaded(s, f),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
None => adopt_loaded(s, read_presentation_file(path)?),
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
None => Err(bad("file.adopt", format!("{path} was not loaded"))),
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn open(_s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
@@ -103,55 +301,158 @@ fn open(_s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
fn open_bytes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").unwrap_or("Presentation");
|
||||
let data = str_param(p, "data").and_then(base64_decode).ok_or_else(|| bad("file.openBytes", "missing or invalid base64 `data`"))?;
|
||||
let doc = open_presentation(name, &data)?;
|
||||
let (doc, damaged) = open_checked(name, &data)?;
|
||||
let i = add_opened(s, name, None, doc);
|
||||
Ok(json!({"document": i, "missingFonts": s.doc()?.missing_fonts}))
|
||||
report_damage(s, &damaged)?;
|
||||
Ok(json!({"document": i, "missingFonts": s.doc()?.missing_fonts, "damagedMedia": damaged.media, "lostSlides": damaged.slides}))
|
||||
}
|
||||
|
||||
/// Encode the presentation in `format` (`deckcraft`, `pptx`, `outline`).
|
||||
/// Tell the user which pictures, media and slides a damaged file lost, and mark the copy repaired.
|
||||
fn report_damage(s: &mut Session, damaged: &Damage) -> Result<()> {
|
||||
if damaged.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let st = s.doc_mut()?;
|
||||
st.title = format!("{} (Repaired)", st.title);
|
||||
let mut text = String::new();
|
||||
let n = damaged.media.len();
|
||||
if n > 0 {
|
||||
text = format!(
|
||||
"{n} picture{} or media file{} in this presentation couldn't be read and {} empty. ",
|
||||
if n == 1 { "" } else { "s" },
|
||||
if n == 1 { "" } else { "s" },
|
||||
if n == 1 { "is" } else { "are" },
|
||||
);
|
||||
}
|
||||
let n = damaged.slides.len();
|
||||
if n > 0 {
|
||||
text.push_str(&format!(
|
||||
"{n} slide{} in this file couldn't be read and {} left out. ",
|
||||
if n == 1 { "" } else { "s" },
|
||||
if n == 1 { "was" } else { "were" },
|
||||
));
|
||||
}
|
||||
text.push_str("It was opened as a repaired copy; save it under a new name to keep the original file.");
|
||||
s.ui_requests.push(crate::UiRequest::Message { text });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Encode the presentation in `format` (`deckcraft`, `pptx`, `potx`, `ppsx`, `outline`, `pdf`, `gif`).
|
||||
pub fn save_bytes(doc: &Presentation, format: &str) -> Result<Vec<u8>> {
|
||||
match format {
|
||||
"pptx" | "potx" | "ppsx" => deckcraft_pptx::export(doc).map_err(|e| EngineError::Other(e.to_string())),
|
||||
f @ ("pptx" | "potx" | "ppsx") => deckcraft_pptx::export_as(doc, f).map_err(|e| EngineError::Other(e.to_string())),
|
||||
"outline" | "txt" => Ok(deckcraft_format::slides_to_outline(doc).into_bytes()),
|
||||
"rtf" => Ok(deckcraft_format::rtf::slides_to_rtf(doc).into_bytes()),
|
||||
"pdf" => deckcraft_pdf::export(doc, &Default::default()).map_err(|e| EngineError::Other(e.to_string())),
|
||||
"gif" => gif_bytes(doc, &Value::Null),
|
||||
_ => deckcraft_format::save(doc).map_err(|e| EngineError::Other(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// PDF options from command params: `{layout: slides|notes|handouts, perPage, dpi, slides: [i],
|
||||
/// includeHidden, textLayer, frame}`.
|
||||
pub fn pdf_options(p: &Value) -> deckcraft_pdf::PdfOptions {
|
||||
/// Paper sizes for notes and handouts, for the UI's Print page.
|
||||
pub use deckcraft_pdf::{PAPERS, paper_size};
|
||||
|
||||
/// PDF options from command params: `{layout: slides|notes|handouts, perPage, dpi, slides,
|
||||
/// includeHidden, textLayer, frame, paper: letter|a4|legal|a3, orientation: portrait|landscape}` for a presentation of `count` slides. `slides` is a list of
|
||||
/// 0-based indices or a range as typed in PowerPoint's Print page (`"1-3, 5"`, 1-based).
|
||||
pub fn pdf_options(p: &Value, count: usize) -> Result<deckcraft_pdf::PdfOptions> {
|
||||
let mut o = deckcraft_pdf::PdfOptions { layout: deckcraft_pdf::PageLayout::Slides, ..Default::default() };
|
||||
o.layout = match str_param(p, "layout").unwrap_or("slides") {
|
||||
"notes" => deckcraft_pdf::PageLayout::Notes,
|
||||
"handouts" => deckcraft_pdf::PageLayout::Handouts { per_page: usize_param(p, "perPage").unwrap_or(6).min(9) as u8 },
|
||||
"handouts" => deckcraft_pdf::PageLayout::Handouts { per_page: usize_param(p, "perPage").unwrap_or(6).clamp(1, 9) as u8 },
|
||||
_ => deckcraft_pdf::PageLayout::Slides,
|
||||
};
|
||||
o.dpi = f64_or(p, "dpi", o.dpi).clamp(36.0, 600.0);
|
||||
o.slides = p.get("slides").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_u64().map(|x| x as usize)).collect());
|
||||
o.slides = match p.get("slides") {
|
||||
Some(Value::Array(a)) => Some(a.iter().filter_map(|v| v.as_u64().map(|x| x as usize)).filter(|i| *i < count).collect()),
|
||||
Some(Value::String(text)) if !text.trim().is_empty() => Some(parse_slide_range(text, count)?),
|
||||
_ => None,
|
||||
};
|
||||
o.include_hidden = bool_or(p, "includeHidden", false);
|
||||
o.text_layer = bool_or(p, "textLayer", true);
|
||||
o.frame_slides = bool_or(p, "frame", true);
|
||||
o
|
||||
let landscape = match str_param(p, "orientation").unwrap_or("portrait") {
|
||||
"portrait" => false,
|
||||
"landscape" => true,
|
||||
other => return Err(bad("file.export", format!("orientation “{other}” isn't portrait or landscape"))),
|
||||
};
|
||||
let paper = str_param(p, "paper").unwrap_or("letter");
|
||||
o.paper = deckcraft_pdf::paper_size(paper, landscape).ok_or_else(|| {
|
||||
let ids: Vec<&str> = deckcraft_pdf::PAPERS.iter().map(|p| p.0).collect();
|
||||
bad("file.export", format!("paper “{paper}” isn't one of {}", ids.join(", ")))
|
||||
})?;
|
||||
Ok(o)
|
||||
}
|
||||
|
||||
/// A slide range as typed in the Print page's Slides box (`"1-3, 5"`; 1-based, either separator
|
||||
/// `,` or `;`, ranges either way round) → 0-based indices in order, without repeats. Numbers past
|
||||
/// the last slide or a range that isn't numbers is an error, as in PowerPoint.
|
||||
pub fn parse_slide_range(text: &str, count: usize) -> Result<Vec<usize>> {
|
||||
let invalid = || bad("file.export", format!("“{text}” isn't a valid slide range: use numbers and ranges such as 1-3, 5"));
|
||||
let mut out: Vec<usize> = vec![];
|
||||
for part in text.split([',', ';']).map(str::trim).filter(|p| !p.is_empty()) {
|
||||
let (a, b) = match part.split_once('-') {
|
||||
Some((a, b)) => (a.trim(), b.trim()),
|
||||
None => (part, part),
|
||||
};
|
||||
let (a, b) = (a.parse::<usize>().map_err(|_| invalid())?, b.parse::<usize>().map_err(|_| invalid())?);
|
||||
let (lo, hi) = (a.min(b), a.max(b));
|
||||
if lo == 0 || hi > count {
|
||||
return Err(bad("file.export", format!("the presentation has {count} slides; “{part}” is outside them")));
|
||||
}
|
||||
for i in lo - 1..hi {
|
||||
if !out.contains(&i) {
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
return Err(invalid());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// PDF bytes for `doc` with [`pdf_options`] from `p`.
|
||||
pub fn pdf_bytes(doc: &Presentation, p: &Value) -> Result<Vec<u8>> {
|
||||
deckcraft_pdf::export(doc, &pdf_options(p)).map_err(|e| EngineError::Other(e.to_string()))
|
||||
deckcraft_pdf::export(doc, &pdf_options(p, doc.slides.len())?).map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
/// The output extensions [`format_for_path`] maps to a writer of that format.
|
||||
pub const WRITABLE_EXTENSIONS: &[&str] = &["deckcraft", "pptx", "potx", "ppsx", "pdf", "png", "jpg", "jpeg", "gif", "txt", "md", "rtf"];
|
||||
|
||||
/// The format a file named `path` is written in, or `None` when its extension names no format
|
||||
/// DeckCraft writes (`.odp`, `.svg`, no extension …): [`format_for_path`] would save
|
||||
/// those as native `.deckcraft` bytes under the wrong name.
|
||||
pub fn writable_format_for_path(path: &str) -> Option<&'static str> {
|
||||
let ext = std::path::Path::new(path).extension()?.to_str()?.to_ascii_lowercase();
|
||||
WRITABLE_EXTENSIONS.contains(&ext.as_str()).then(|| format_for_path(path))
|
||||
}
|
||||
|
||||
/// Animated GIF bytes for `doc` with the options in `p` (see [`crate::gif_export::GifOptions::from_params`]).
|
||||
pub fn gif_bytes(doc: &Presentation, p: &Value) -> Result<Vec<u8>> {
|
||||
Ok(crate::gif_export::encode(doc, &crate::gif_export::GifOptions::from_params(p)?)?.bytes)
|
||||
}
|
||||
|
||||
pub fn format_for_path(path: &str) -> &'static str {
|
||||
let l = path.to_ascii_lowercase();
|
||||
if l.ends_with(".pptx") || l.ends_with(".potx") || l.ends_with(".ppsx") {
|
||||
if l.ends_with(".pptx") {
|
||||
"pptx"
|
||||
} else if l.ends_with(".potx") {
|
||||
"potx"
|
||||
} else if l.ends_with(".ppsx") {
|
||||
"ppsx"
|
||||
} else if l.ends_with(".png") {
|
||||
"png"
|
||||
} else if l.ends_with(".jpg") || l.ends_with(".jpeg") {
|
||||
"jpeg"
|
||||
} else if l.ends_with(".pdf") {
|
||||
"pdf"
|
||||
} else if l.ends_with(".gif") {
|
||||
"gif"
|
||||
} else if l.ends_with(".txt") || l.ends_with(".md") {
|
||||
"outline"
|
||||
} else if l.ends_with(".rtf") {
|
||||
"rtf"
|
||||
} else {
|
||||
"deckcraft"
|
||||
}
|
||||
@@ -171,16 +472,8 @@ fn mark_saved(s: &mut Session, path: Option<String>) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
// Write to a temporary sibling then rename, so a failed save never truncates the old file.
|
||||
let tmp = format!("{path}.saving");
|
||||
std::fs::write(&tmp, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
std::fs::rename(&tmp, path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn write_file(_path: &str, _bytes: &[u8]) -> Result<()> {
|
||||
Err(EngineError::Other("saving to a path is not available in the browser".into()))
|
||||
crate::io::write_file(path, bytes).map_err(EngineError::Other)
|
||||
}
|
||||
|
||||
fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -210,11 +503,35 @@ fn save_bytes_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"data": base64_encode(&bytes), "bytes": bytes.len()}))
|
||||
}
|
||||
|
||||
/// `scale` for rendering `doc`'s slides to an image: within 0.01–16, and small enough that the
|
||||
/// slide fits the renderer's largest picture ([`deckcraft_render::MAX_SIDE`]). Past that the
|
||||
/// renderer clips, so a 4× export of a 4032 pt slide came out cropped to its first 16,000 pixels.
|
||||
pub fn image_scale(doc: &Presentation, scale: f64) -> f64 {
|
||||
let scale = if scale.is_finite() { scale.clamp(0.01, 16.0) } else { 1.0 };
|
||||
let longest = doc.slide_size.width.max(doc.slide_size.height).max(1.0);
|
||||
scale.min(f64::from(deckcraft_render::MAX_SIDE) / longest)
|
||||
}
|
||||
|
||||
/// Render slide `index` (current when absent) to PNG bytes.
|
||||
pub fn render_png(doc: &Presentation, index: usize, scale: f64, edit: bool) -> (Vec<u8>, u32, u32) {
|
||||
let img =
|
||||
deckcraft_render::render_slide(doc, index, &deckcraft_render::RenderOpts { scale: scale.clamp(0.01, 16.0), edit, ..Default::default() });
|
||||
(img.to_png(), img.width, img.height)
|
||||
render_png_with(doc, index, scale, edit, false)
|
||||
}
|
||||
|
||||
/// [`render_png`]; `transparent` leaves the slide background out (#784).
|
||||
pub fn render_png_with(doc: &Presentation, index: usize, scale: f64, edit: bool, transparent: bool) -> (Vec<u8>, u32, u32) {
|
||||
let scale = image_scale(doc, scale);
|
||||
let opts = deckcraft_render::RenderOpts { scale, edit, transparent_background: transparent, ..Default::default() };
|
||||
let img = deckcraft_render::render_slide(doc, index, &opts);
|
||||
(img.to_png_at(deckcraft_render::dpi_for_scale(scale)), img.width, img.height)
|
||||
}
|
||||
|
||||
fn finite_scale(scale: f64) -> f64 {
|
||||
if scale.is_finite() { scale.clamp(0.01, 16.0) } else { 1.0 }
|
||||
}
|
||||
|
||||
fn picture_bytes(img: &deckcraft_render::Image, format: &str, scale: f64) -> Vec<u8> {
|
||||
let dpi = deckcraft_render::dpi_for_scale(scale);
|
||||
if format == "png" { img.to_png_at(dpi) } else { img.to_jpeg_at(92, Some(dpi)) }
|
||||
}
|
||||
|
||||
fn render(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -223,15 +540,42 @@ fn render(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if i >= st.doc.slides.len() {
|
||||
return Err(bad("file.render", format!("no slide {i}")));
|
||||
}
|
||||
let (png, w, h) = render_png(&st.doc, i, f64_or(p, "scale", 1.0), bool_or(p, "edit", false));
|
||||
let (png, w, h) = render_png_with(&st.doc, i, f64_or(p, "scale", 1.0), bool_or(p, "edit", false), bool_or(p, "transparentBackground", false));
|
||||
Ok(json!({"png": base64_encode(&png), "width": w, "height": h}))
|
||||
}
|
||||
|
||||
/// The colour the slide is drawn with at one point: what the Eyedropper takes.
|
||||
fn color_at(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let i = usize_param(p, "slide").unwrap_or(st.selection.slide);
|
||||
if i >= st.doc.slides.len() {
|
||||
return Err(bad("slide.colorAt", format!("no slide {i}")));
|
||||
}
|
||||
let coord = |key: &str| {
|
||||
p.get(key)
|
||||
.and_then(Value::as_f64)
|
||||
.filter(|v| v.is_finite())
|
||||
.ok_or_else(|| bad("slide.colorAt", format!("`{key}` (a number, in slide units) is required")))
|
||||
};
|
||||
let (x, y) = (coord("x")?, coord("y")?);
|
||||
let scale = f64_or(p, "scale", 2.0).clamp(0.01, 16.0);
|
||||
let img = deckcraft_render::render_slide(&st.doc, i, &deckcraft_render::RenderOpts { scale, ..Default::default() });
|
||||
let (px, py) = (x * scale, y * scale);
|
||||
let inside = px >= 0.0 && py >= 0.0 && px < f64::from(img.width) && py < f64::from(img.height);
|
||||
let [r, g, b, a] =
|
||||
img.straight_at(px as u32, py as u32).filter(|_| inside).ok_or_else(|| bad("slide.colorAt", "the point is outside the slide"))?;
|
||||
Ok(json!({"color": format!("{r:02X}{g:02X}{b:02X}"), "alpha": a, "x": x, "y": y}))
|
||||
}
|
||||
|
||||
fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("file.export", "missing `path`"))?.to_string();
|
||||
let format = str_param(p, "format").unwrap_or(format_for_path(&path)).to_string();
|
||||
let st = s.doc()?;
|
||||
let scale = f64_or(p, "scale", 2.0);
|
||||
let scale = finite_scale(f64_or(p, "scale", 2.0));
|
||||
let transparent = bool_or(p, "transparentBackground", false);
|
||||
if transparent && format != "png" {
|
||||
return Err(bad("file.export", format!("a transparent background is for PNG export, not {format}")));
|
||||
}
|
||||
match format.as_str() {
|
||||
"png" | "jpeg" | "jpg" => {
|
||||
let slides: Vec<usize> = match usize_param(p, "slide") {
|
||||
@@ -239,17 +583,19 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None if bool_or(p, "all", false) => (0..st.doc.slides.len()).collect(),
|
||||
None => vec![st.selection.slide],
|
||||
};
|
||||
// Every slide checked before any file is written, so a bad index leaves nothing behind.
|
||||
if let Some(i) = slides.iter().find(|i| **i >= st.doc.slides.len()) {
|
||||
return Err(bad("file.export", format!("no slide {i}")));
|
||||
}
|
||||
let scale = image_scale(&st.doc, scale);
|
||||
let mut written = vec![];
|
||||
for (k, i) in slides.iter().enumerate() {
|
||||
if *i >= st.doc.slides.len() {
|
||||
return Err(bad("file.export", format!("no slide {i}")));
|
||||
}
|
||||
let img = deckcraft_render::render_slide(
|
||||
&st.doc,
|
||||
*i,
|
||||
&deckcraft_render::RenderOpts { scale: scale.clamp(0.01, 16.0), ..Default::default() },
|
||||
&deckcraft_render::RenderOpts { scale, transparent_background: transparent, ..Default::default() },
|
||||
);
|
||||
let bytes = if format == "png" { img.to_png() } else { img.to_jpeg(92) };
|
||||
let bytes = picture_bytes(&img, &format, scale);
|
||||
let out = if slides.len() == 1 {
|
||||
path.clone()
|
||||
} else {
|
||||
@@ -258,24 +604,65 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ext = pth.extension().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| format.clone());
|
||||
pth.with_file_name(format!("{stem}{}.{ext}", k + 1)).to_string_lossy().to_string()
|
||||
};
|
||||
write_file(&out, &bytes)?;
|
||||
// Say which slides made it when one can't be written (disk full part-way).
|
||||
write_file(&out, &bytes)
|
||||
.map_err(|e| if written.is_empty() { e } else { EngineError::Other(format!("{e} (slides 1–{k} were written)")) })?;
|
||||
written.push(out);
|
||||
}
|
||||
Ok(json!({"files": written}))
|
||||
}
|
||||
"pdf" => {
|
||||
let bytes = deckcraft_pdf::export(&st.doc, &pdf_options(p)).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let bytes = pdf_bytes(&st.doc, p)?;
|
||||
write_file(&path, &bytes)?;
|
||||
Ok(json!({"path": path, "bytes": bytes.len()}))
|
||||
}
|
||||
other => {
|
||||
let bytes = save_bytes(&st.doc, other)?;
|
||||
"gif" => {
|
||||
// `slides` may be a range, as for PDF ("1-3,5").
|
||||
let mut gp = p.clone();
|
||||
if let Some(text) = p.get("slides").and_then(Value::as_str) {
|
||||
gp["slides"] = json!(parse_slide_range(text, st.doc.slides.len())?);
|
||||
}
|
||||
let out = crate::gif_export::encode(&st.doc, &crate::gif_export::GifOptions::from_params(&gp)?)?;
|
||||
write_file(&path, &out.bytes)?;
|
||||
Ok(json!({
|
||||
"path": path,
|
||||
"bytes": out.bytes.len(),
|
||||
"frames": out.frames,
|
||||
"width": out.width,
|
||||
"height": out.height,
|
||||
"secondsPerSlide": f64::from(out.delay_cs) / 100.0,
|
||||
}))
|
||||
}
|
||||
"pptx" | "potx" | "ppsx" | "deckcraft" | "outline" | "txt" | "rtf" => {
|
||||
let bytes = save_bytes(&st.doc, &format)?;
|
||||
write_file(&path, &bytes)?;
|
||||
Ok(json!({"path": path, "bytes": bytes.len()}))
|
||||
}
|
||||
other => Err(bad("file.export", format!("unknown format `{other}`"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// File › Info: what the Info page shows about the active presentation.
|
||||
fn info(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let doc = &st.doc;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let bytes = st.path.as_deref().and_then(crate::io::file_len);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let bytes: Option<u64> = None;
|
||||
Ok(json!({
|
||||
"title": st.title(),
|
||||
"path": st.path,
|
||||
"format": st.path.as_deref().map(format_for_path),
|
||||
"dirty": st.is_dirty(),
|
||||
"slides": doc.slides.len(),
|
||||
"hiddenSlides": doc.slides.iter().filter(|sl| sl.hidden).count(),
|
||||
"sections": doc.sections.len(),
|
||||
"bytes": bytes,
|
||||
"properties": serde_json::to_value(&doc.props).unwrap_or_default(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn close(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
if let Some(i) = s.active_index() {
|
||||
s.close_document(i);
|
||||
@@ -367,3 +754,47 @@ fn recovery_discard(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "color_at_tests.rs"]
|
||||
mod color_at_tests;
|
||||
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use super::write_file;
|
||||
|
||||
fn scratch(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-write-file-{}-{name}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_file_does_not_touch_a_predictable_saving_sibling() {
|
||||
let dir = scratch("sibling");
|
||||
let dest = dir.join("deck.deckcraft");
|
||||
let dest_str = dest.to_str().unwrap();
|
||||
std::fs::write(&dest, b"old").unwrap();
|
||||
std::fs::write(dir.join("deck.deckcraft.saving"), b"precious").unwrap();
|
||||
write_file(dest_str, b"new").unwrap();
|
||||
assert_eq!(std::fs::read(&dest).unwrap(), b"new");
|
||||
assert_eq!(std::fs::read(dir.join("deck.deckcraft.saving")).unwrap(), b"precious");
|
||||
let names: Vec<_> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name()).collect();
|
||||
assert_eq!(names.len(), 2, "no temporary file may be left behind: {names:?}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_file_failed_rename_keeps_destination_and_cleans_up() {
|
||||
let dir = scratch("failed");
|
||||
let dest = dir.join("target");
|
||||
std::fs::create_dir(&dest).unwrap();
|
||||
assert!(write_file(dest.to_str().unwrap(), b"new").is_err());
|
||||
assert!(dest.is_dir());
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
//! Home › Editing: Find and Replace across slide text, table cells and speaker notes.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::text::RunKind;
|
||||
use deckcraft_model::{Paragraph, Shape, ShapeKind, Slide, TextBody};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
query "text.find",
|
||||
"Find",
|
||||
["Home", "Editing"],
|
||||
None,
|
||||
"{text, matchCase?: bool, wholeWords?: bool} → [{slide, shape (null = notes), cell?: [row, col], para, start, end}] (char offsets in the paragraph)",
|
||||
has_doc,
|
||||
find
|
||||
),
|
||||
cmd!(
|
||||
"text.replace",
|
||||
"Replace",
|
||||
["Home", "Editing"],
|
||||
None,
|
||||
"{text, with, matchCase?, wholeWords?, at?: a text.find match (replace just that one; default: replace all)} → {replaced}",
|
||||
has_doc,
|
||||
replace
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Where a text body lives on a slide.
|
||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||
struct Loc {
|
||||
shape: Option<u64>,
|
||||
cell: Option<(usize, usize)>,
|
||||
}
|
||||
|
||||
/// Visit every text body on a slide: shapes depth-first (groups, table cells), then the notes.
|
||||
fn bodies(slide: &mut Slide, f: &mut dyn FnMut(Loc, &mut TextBody)) {
|
||||
fn rec(shapes: &mut [Shape], depth: usize, f: &mut dyn FnMut(Loc, &mut TextBody)) {
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
for sh in shapes {
|
||||
let id = Some(u64::from(sh.id.0));
|
||||
if let Some(t) = &mut sh.text {
|
||||
f(Loc { shape: id, cell: None }, t);
|
||||
}
|
||||
match &mut sh.kind {
|
||||
ShapeKind::Table(t) => {
|
||||
for (r, row) in t.rows.iter_mut().enumerate() {
|
||||
for (c, cell) in row.cells.iter_mut().enumerate() {
|
||||
f(Loc { shape: id, cell: Some((r, c)) }, &mut cell.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
ShapeKind::Group { children, .. } => rec(children, depth + 1, f),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
rec(&mut slide.shapes, 0, f);
|
||||
f(Loc { shape: None, cell: None }, &mut slide.notes);
|
||||
}
|
||||
|
||||
struct Opts {
|
||||
pat: Vec<char>,
|
||||
case: bool,
|
||||
whole: bool,
|
||||
}
|
||||
|
||||
impl Opts {
|
||||
fn from(p: &Value, cmd: &str) -> Result<Self> {
|
||||
let pat: Vec<char> = str_param(p, "text").unwrap_or("").chars().collect();
|
||||
if pat.is_empty() {
|
||||
return Err(bad(cmd, "missing `text`"));
|
||||
}
|
||||
Ok(Opts { pat, case: bool_or(p, "matchCase", false), whole: bool_or(p, "wholeWords", false) })
|
||||
}
|
||||
fn eq(&self, a: char, b: char) -> bool {
|
||||
a == b || (!self.case && a.to_lowercase().eq(b.to_lowercase()))
|
||||
}
|
||||
/// Non-overlapping matches as char ranges.
|
||||
fn matches(&self, text: &[char]) -> Vec<(usize, usize)> {
|
||||
let n = self.pat.len();
|
||||
let word = |c: Option<&char>| c.is_some_and(|c| c.is_alphanumeric() || *c == '_');
|
||||
let mut out = vec![];
|
||||
let mut i = 0;
|
||||
while i + n <= text.len() {
|
||||
let hit = text[i..i + n].iter().zip(&self.pat).all(|(a, b)| self.eq(*a, *b))
|
||||
&& (!self.whole || (!word(i.checked_sub(1).and_then(|j| text.get(j))) && !word(text.get(i + n))));
|
||||
if hit {
|
||||
out.push((i, i + n));
|
||||
i += n;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
fn para_chars(p: &Paragraph) -> Vec<char> {
|
||||
p.runs.iter().flat_map(|r| r.content().chars()).collect()
|
||||
}
|
||||
|
||||
/// Matches that lie wholly in plain text runs (never inside a field, equation or break).
|
||||
fn para_matches(p: &Paragraph, o: &Opts) -> Vec<(usize, usize)> {
|
||||
let mut spans = vec![];
|
||||
let mut off = 0;
|
||||
for r in &p.runs {
|
||||
let n = r.char_len();
|
||||
spans.push((off, off + n, matches!(r.kind, RunKind::Text)));
|
||||
off += n;
|
||||
}
|
||||
o.matches(¶_chars(p)).into_iter().filter(|&(a, b)| spans.iter().all(|&(s, e, text)| text || e <= a || s >= b || s == e)).collect()
|
||||
}
|
||||
|
||||
/// Replace chars `a..b` of the paragraph; the replacement takes the first run's formatting.
|
||||
fn splice(p: &mut Paragraph, a: usize, b: usize, with: &str) {
|
||||
let mut off = 0;
|
||||
let mut placed = false;
|
||||
for r in &mut p.runs {
|
||||
let chars: Vec<char> = r.content().chars().collect();
|
||||
let (s, e) = (off, off + chars.len());
|
||||
off = e;
|
||||
if e <= a || s >= b {
|
||||
continue;
|
||||
}
|
||||
let keep_head: String = chars[..a.saturating_sub(s)].iter().collect();
|
||||
let keep_tail: String = chars[b.saturating_sub(s).min(chars.len())..].iter().collect();
|
||||
r.text = if placed { keep_tail } else { format!("{keep_head}{with}{keep_tail}") };
|
||||
placed = true;
|
||||
}
|
||||
if p.runs.len() > 1 {
|
||||
p.runs.retain(|r| !(r.text.is_empty() && matches!(r.kind, RunKind::Text)));
|
||||
}
|
||||
}
|
||||
|
||||
fn find(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let o = Opts::from(p, "text.find")?;
|
||||
let st = s.doc()?;
|
||||
let mut out = vec![];
|
||||
for (i, sl) in st.doc.slides.iter().enumerate() {
|
||||
let mut sl = (**sl).clone();
|
||||
bodies(&mut sl, &mut |loc, t| {
|
||||
for (pi, para) in t.paragraphs.iter().enumerate() {
|
||||
for (a, b) in para_matches(para, &o) {
|
||||
out.push(json!({"slide": i, "shape": loc.shape, "cell": loc.cell.map(|(r, c)| [r, c]), "para": pi, "start": a, "end": b}));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(Value::Array(out))
|
||||
}
|
||||
|
||||
fn replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let o = Opts::from(p, "text.replace")?;
|
||||
let with = str_param(p, "with").unwrap_or("").to_string();
|
||||
let at = p.get("at").filter(|v| v.is_object()).map(|v| -> Result<(usize, Loc, usize, usize)> {
|
||||
let n = |k: &str| v.get(k).and_then(Value::as_u64).map(|x| x as usize).ok_or_else(|| bad("text.replace", format!("`at` needs `{k}`")));
|
||||
let cell = v.get("cell").and_then(Value::as_array).and_then(|c| Some((c.first()?.as_u64()? as usize, c.get(1)?.as_u64()? as usize)));
|
||||
Ok((n("slide")?, Loc { shape: v.get("shape").and_then(Value::as_u64), cell }, n("para")?, n("start")?))
|
||||
});
|
||||
let at = at.transpose()?;
|
||||
let count = s.edit(|doc, _| {
|
||||
let mut count = 0;
|
||||
for (i, slp) in doc.slides.iter_mut().enumerate() {
|
||||
if at.is_some_and(|a| a.0 != i) {
|
||||
continue;
|
||||
}
|
||||
let mut sl = (**slp).clone();
|
||||
let mut changed = 0;
|
||||
bodies(&mut sl, &mut |loc, t| {
|
||||
if at.is_some_and(|a| a.1 != loc) {
|
||||
return;
|
||||
}
|
||||
for (pi, para) in t.paragraphs.iter_mut().enumerate() {
|
||||
if at.is_some_and(|a| a.2 != pi) {
|
||||
continue;
|
||||
}
|
||||
for (a, b) in para_matches(para, &o).into_iter().rev() {
|
||||
if at.is_some_and(|x| x.3 != a) {
|
||||
continue;
|
||||
}
|
||||
splice(para, a, b, &with);
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
if changed > 0 {
|
||||
*slp = Arc::new(sl);
|
||||
count += changed;
|
||||
}
|
||||
}
|
||||
if count == 0 {
|
||||
return Err(bad("text.replace", "no matches"));
|
||||
}
|
||||
Ok(count)
|
||||
})?;
|
||||
Ok(json!({"replaced": count}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use deckcraft_model::{Run, RunProps};
|
||||
|
||||
fn opts(t: &str, case: bool, whole: bool) -> Opts {
|
||||
Opts { pat: t.chars().collect(), case, whole }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matching_honours_case_and_whole_words() {
|
||||
let t: Vec<char> = "Cat cat category".chars().collect();
|
||||
assert_eq!(opts("cat", false, false).matches(&t).len(), 3);
|
||||
assert_eq!(opts("cat", true, false).matches(&t).len(), 2);
|
||||
assert_eq!(opts("cat", false, true).matches(&t), vec![(0, 3), (4, 7)]);
|
||||
}
|
||||
|
||||
fn text_run(t: &str) -> Run {
|
||||
Run { text: t.into(), props: RunProps::default(), kind: RunKind::Text }
|
||||
}
|
||||
|
||||
fn br() -> Run {
|
||||
Run { text: String::new(), props: RunProps::default(), kind: RunKind::Break }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_match_never_spans_a_line_break() {
|
||||
let p = Paragraph { runs: vec![text_run("a"), br(), text_run("b")], ..Default::default() };
|
||||
assert!(para_matches(&p, &opts("ab", true, false)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offsets_count_a_line_break_as_one_char() {
|
||||
let p = Paragraph { runs: vec![text_run("ab"), br(), text_run("cd")], ..Default::default() };
|
||||
assert_eq!(para_matches(&p, &opts("cd", true, false)), vec![(3, 5)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splice_after_a_line_break_keeps_the_break() {
|
||||
let mut p = Paragraph { runs: vec![text_run("ab"), br(), text_run("cd")], ..Default::default() };
|
||||
splice(&mut p, 3, 5, "X");
|
||||
assert_eq!(p.text(), "ab\u{b}X");
|
||||
assert_eq!(p.runs[1].kind, RunKind::Break);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splice_across_runs_keeps_the_first_runs_format() {
|
||||
let bold = RunProps { bold: Some(true), ..Default::default() };
|
||||
let mut p = Paragraph {
|
||||
runs: vec![
|
||||
Run { text: "Hel".into(), props: bold.clone(), kind: RunKind::Text },
|
||||
Run { text: "lo world".into(), props: RunProps::default(), kind: RunKind::Text },
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
splice(&mut p, 1, 5, "ola");
|
||||
assert_eq!(para_chars(&p).iter().collect::<String>(), "Hola world");
|
||||
assert_eq!(p.runs[0].text, "Hola");
|
||||
assert_eq!(p.runs[0].props, bold);
|
||||
}
|
||||
}
|
||||
+1030
-34
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,76 @@
|
||||
//! Drawing guides: commands, dragging a guide with the pointer, snapping shapes to guides, and
|
||||
//! the PPTX round trip.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::tools::{Mods, PointerEvent, PointerKind};
|
||||
|
||||
fn ev(s: &mut Session, kind: PointerKind, x: f64, y: f64) {
|
||||
s.pointer(PointerEvent { kind, x, y, mods: Mods::default(), tol: 4.0, pressure: None }).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_move_delete_and_undo() {
|
||||
let mut s = Session::with_new();
|
||||
assert_eq!(s.execute("guide.add", &json!({"defaults": true})).unwrap()["added"], 2);
|
||||
assert_eq!(s.execute("guide.add", &json!({"defaults": true})).unwrap()["added"], 0, "only when there are none");
|
||||
let i = s.execute("guide.add", &json!({"vertical": false, "pos": 100})).unwrap()["index"].as_u64().unwrap();
|
||||
assert_eq!(i, 2);
|
||||
s.execute("guide.move", &json!({"index": 2, "pos": 5000})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.guides[2].pos, 540.0, "kept on the slide");
|
||||
assert!(s.execute("guide.move", &json!({"index": 9, "pos": 1})).is_err());
|
||||
assert!(s.execute("guide.move", &json!({"index": 0, "pos": null})).is_err());
|
||||
s.execute("guide.delete", &json!({"index": 0})).unwrap();
|
||||
assert_eq!(s.execute("guide.list", &json!({})).unwrap().as_array().unwrap().len(), 2);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.guides.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drag_a_guide_drop_it_off_the_slide_and_snap_shapes_to_it() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
s.execute("guide.add", &json!({"vertical": true, "pos": 300})).unwrap();
|
||||
// Hidden guides can't be grabbed: the press starts a marquee instead.
|
||||
ev(&mut s, PointerKind::Down, 300.0, 50.0);
|
||||
ev(&mut s, PointerKind::Up, 300.0, 50.0);
|
||||
assert_eq!(s.doc().unwrap().doc.guides[0].pos, 300.0);
|
||||
s.tool.guides_visible = true;
|
||||
ev(&mut s, PointerKind::Down, 301.0, 50.0);
|
||||
ev(&mut s, PointerKind::Drag, 350.4, 60.0);
|
||||
assert_eq!(s.tool.guide_drag, Some((0, 350.0)), "whole points while dragging");
|
||||
ev(&mut s, PointerKind::Up, 350.4, 60.0);
|
||||
assert_eq!(s.doc().unwrap().doc.guides[0].pos, 350.0);
|
||||
assert_eq!(s.tool.guide_drag, None);
|
||||
|
||||
// A shape dragged near the guide snaps its left edge onto it.
|
||||
let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 200, 80, 60]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.prefs.snap_to_grid = false;
|
||||
ev(&mut s, PointerKind::Down, 140.0, 230.0);
|
||||
ev(&mut s, PointerKind::Drag, 200.0, 230.0);
|
||||
ev(&mut s, PointerKind::Drag, 388.0, 231.0);
|
||||
ev(&mut s, PointerKind::Up, 388.0, 231.0);
|
||||
let x = s.doc().unwrap().shapes().iter().find(|sh| sh.id.0 as u64 == id).and_then(|sh| sh.xfrm).unwrap();
|
||||
assert!((x.x - 350.0).abs() < 0.01, "{x:?}");
|
||||
|
||||
// Dropped off the slide: gone, and undo brings it back.
|
||||
ev(&mut s, PointerKind::Down, 350.0, 10.0);
|
||||
ev(&mut s, PointerKind::Drag, -40.0, 10.0);
|
||||
ev(&mut s, PointerKind::Up, -40.0, 10.0);
|
||||
assert!(s.doc().unwrap().doc.guides.is_empty());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.guides.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guides_round_trip_through_pptx() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("guide.add", &json!({"vertical": true, "pos": 123.5})).unwrap();
|
||||
s.execute("guide.add", &json!({"vertical": false, "pos": 400})).unwrap();
|
||||
let bytes = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap();
|
||||
let back = deckcraft_pptx::import(&bytes).unwrap();
|
||||
assert_eq!(back.guides, s.doc().unwrap().doc.guides);
|
||||
let none = deckcraft_pptx::import(&deckcraft_pptx::export(&deckcraft_model::Presentation::default()).unwrap()).unwrap();
|
||||
assert!(none.guides.is_empty());
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
//! Design Ideas: arrangements of what is on the current slide (picture full-bleed with the title
|
||||
//! on a band, picture on one half, a framed picture, text-only layouts). An idea moves and
|
||||
//! resizes the slide's own shapes and crops pictures to their new box; it changes only that
|
||||
//! slide. Everything is computed here, offline.
|
||||
|
||||
use deckcraft_geom::Xfrm;
|
||||
use deckcraft_model::resolve::{self, Ctx};
|
||||
use deckcraft_model::{Fill, Presentation, Rgba, Shape, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::{Result, Selection, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "design.ideas", "Design Ideas", [], None, "{slide?} → [{id, label}] ideas for the slide", has_slide, ideas_cmd),
|
||||
cmd!("design.applyIdea", "Apply Design Idea", ["Home", "Designer"], None, "{id, slide?}", has_slide, apply_cmd),
|
||||
]
|
||||
}
|
||||
|
||||
/// An idea: id and label.
|
||||
pub type Idea = (&'static str, &'static str);
|
||||
|
||||
const PICTURE_IDEAS: [Idea; 4] = [
|
||||
("fullBleed", "Full-bleed picture, title on a band"),
|
||||
("pictureLeft", "Picture left, text right"),
|
||||
("pictureRight", "Picture right, text left"),
|
||||
("framed", "Framed picture beside the title"),
|
||||
];
|
||||
const TEXT_IDEAS: [Idea; 3] =
|
||||
[("titleColumn", "Title column beside the text"), ("centred", "Centred title over the text"), ("titleBottom", "Text above, title at the foot")];
|
||||
|
||||
/// The roles of a slide's top-level shapes, by index.
|
||||
struct Roles {
|
||||
title: Option<usize>,
|
||||
picture: Option<usize>,
|
||||
text: Vec<usize>,
|
||||
}
|
||||
|
||||
fn roles(shapes: &[Shape]) -> Roles {
|
||||
let mut r = Roles { title: None, picture: None, text: vec![] };
|
||||
for (i, s) in shapes.iter().enumerate() {
|
||||
if s.hidden {
|
||||
continue;
|
||||
}
|
||||
if s.ph.as_ref().is_some_and(|p| p.kind.is_title()) && r.title.is_none() {
|
||||
r.title = Some(i);
|
||||
} else if matches!(s.kind, ShapeKind::Picture { .. }) {
|
||||
// The largest picture leads.
|
||||
let area = |j: usize| shapes.get(j).and_then(|s| s.xfrm).map(|x| x.w * x.h).unwrap_or(0.0);
|
||||
if r.picture.is_none_or(|p| area(i) > area(p)) {
|
||||
r.picture = Some(i);
|
||||
}
|
||||
} else if s.text.as_ref().is_some_and(|t| !t.text().trim().is_empty()) {
|
||||
r.text.push(i);
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// Whether `s` paints a fill or an outline once inherited and style-referenced values are
|
||||
/// resolved, as the renderer does (`fill: None` alone means inherited, not invisible).
|
||||
fn shows(ctx: Option<&Ctx>, s: &Shape) -> bool {
|
||||
let visible = |f: &Option<Fill>| f.as_ref().is_some_and(|f| !f.is_none());
|
||||
match ctx {
|
||||
Some(ctx) => visible(&resolve::fill(ctx, s).0) || visible(&resolve::line(ctx, s).0.fill),
|
||||
None => visible(&s.fill) || visible(&s.line.as_ref().and_then(|l| l.fill.clone())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an idea may move this shape: a picture, a placeholder or a plain text box. Diagram
|
||||
/// boxes, connectors, groups, tables, charts and media are laid out by their own geometry, which
|
||||
/// the ideas do not know, so a slide holding one gets no ideas.
|
||||
fn arrangeable(ctx: Option<&Ctx>, s: &Shape) -> bool {
|
||||
match s.kind {
|
||||
ShapeKind::Picture { .. } => true,
|
||||
ShapeKind::Shape => {
|
||||
let is_title = s.ph.as_ref().is_some_and(|p| p.kind.is_title());
|
||||
let has_text = s.text.as_ref().is_some_and(|t| !t.text().trim().is_empty());
|
||||
// Only titles and shapes with text are moved (see `roles`): an empty one that shows
|
||||
// (filled or outlined) would stay where it is, over the rearranged slide.
|
||||
(s.ph.is_some() || s.text_box) && (is_title || has_text || !shows(ctx, s))
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The ideas for slide `index` (none when it has nothing to arrange, or other content in the way).
|
||||
pub fn ideas_for(doc: &Presentation, index: usize) -> Vec<Idea> {
|
||||
let Some(slide) = doc.slides.get(index) else { return vec![] };
|
||||
let ctx = Ctx::for_slide(doc, slide);
|
||||
if slide.shapes.iter().any(|s| !s.hidden && !arrangeable(ctx.as_ref(), s)) {
|
||||
return vec![];
|
||||
}
|
||||
// The ideas move one picture; a second would stay put, over the rearranged text.
|
||||
if slide.shapes.iter().filter(|s| !s.hidden && matches!(s.kind, ShapeKind::Picture { .. })).count() > 1 {
|
||||
return vec![];
|
||||
}
|
||||
let r = roles(&slide.shapes);
|
||||
if r.picture.is_some() {
|
||||
PICTURE_IDEAS.to_vec()
|
||||
} else if r.title.is_some() && !r.text.is_empty() {
|
||||
TEXT_IDEAS.to_vec()
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
/// Crop a picture whose source has aspect `src` (w/h) to fill a box of aspect `dst`, centred.
|
||||
fn cover_crop(src: f64, dst: f64) -> [f64; 4] {
|
||||
if !(src.is_finite() && dst.is_finite() && src > 0.0 && dst > 0.0) {
|
||||
return [0.0; 4];
|
||||
}
|
||||
if src > dst {
|
||||
let cut = (1.0 - dst / src) / 2.0;
|
||||
[cut, 0.0, cut, 0.0]
|
||||
} else {
|
||||
let cut = (1.0 - src / dst) / 2.0;
|
||||
[0.0, cut, 0.0, cut]
|
||||
}
|
||||
}
|
||||
|
||||
/// The light fill behind the title in the full-bleed idea. Its opacity is an `alpha` transform
|
||||
/// (about 220/255), as PPTX stores it, so the fill compares equal after a save and reopen.
|
||||
fn band_fill() -> Fill {
|
||||
Fill::solid(deckcraft_model::ColorRef::rgb(Rgba::rgb(255, 255, 255)).with(deckcraft_model::ColorTransform::Alpha(86274)))
|
||||
}
|
||||
|
||||
/// The band as released versions up to 0.10 stored it (opacity in the colour's own alpha).
|
||||
fn legacy_band_fill() -> Fill {
|
||||
Fill::solid(deckcraft_model::ColorRef::rgb(Rgba::rgba(255, 255, 255, 220)))
|
||||
}
|
||||
|
||||
/// Whether `f` is a Full-bleed band, in the current or the legacy form.
|
||||
fn is_band(f: &Fill) -> bool {
|
||||
*f == band_fill() || *f == legacy_band_fill()
|
||||
}
|
||||
|
||||
/// Apply idea `id` to slide `index` of `doc` (`sel` resolves inherited placeholder boxes).
|
||||
pub fn apply_idea(doc: &mut Presentation, sel: &Selection, index: usize, id: &str) -> Result<()> {
|
||||
if !ideas_for(doc, index).iter().any(|i| i.0 == id) {
|
||||
return Err(bad("design.applyIdea", format!("no idea `{id}` for this slide (see design.ideas)")));
|
||||
}
|
||||
let (sw, sh) = (doc.slide_size.width.max(1.0), doc.slide_size.height.max(1.0));
|
||||
let sel = Selection { slide: index, ..sel.clone() };
|
||||
let shapes = doc.slides.get(index).map(|s| s.shapes.clone()).ok_or_else(|| bad("design.applyIdea", "no slide"))?;
|
||||
let r = roles(&shapes);
|
||||
let boxes: Vec<Xfrm> = shapes.iter().map(|s| xfrm_of(doc, &sel, s)).collect();
|
||||
let m = sw.min(sh) * 0.06;
|
||||
let mut place: Vec<(usize, Xfrm)> = vec![];
|
||||
// Text shapes stacked in a box.
|
||||
let stack = |b: Xfrm, out: &mut Vec<(usize, Xfrm)>| {
|
||||
let n = r.text.len().max(1) as f64;
|
||||
let gap = m * 0.4;
|
||||
let h = ((b.h - gap * (n - 1.0)) / n).max(1.0);
|
||||
for (k, i) in r.text.iter().enumerate() {
|
||||
out.push((*i, Xfrm::new(b.x, b.y + k as f64 * (h + gap), b.w, h)));
|
||||
}
|
||||
};
|
||||
let mut band = false;
|
||||
match id {
|
||||
"fullBleed" => {
|
||||
if let Some(p) = r.picture {
|
||||
place.push((p, Xfrm::new(0.0, 0.0, sw, sh)));
|
||||
}
|
||||
if let Some(t) = r.title {
|
||||
place.push((t, Xfrm::new(m, sh * 0.68, sw - 2.0 * m, sh * 0.2)));
|
||||
band = true;
|
||||
}
|
||||
stack(Xfrm::new(m, sh * 0.06, sw * 0.45, sh * 0.5), &mut place);
|
||||
}
|
||||
"pictureLeft" | "pictureRight" => {
|
||||
let left = id == "pictureLeft";
|
||||
let half = sw * 0.5;
|
||||
let (px, tx) = if left { (0.0, half + m) } else { (half, m) };
|
||||
if let Some(p) = r.picture {
|
||||
place.push((p, Xfrm::new(px, 0.0, half, sh)));
|
||||
}
|
||||
let tw = half - 2.0 * m;
|
||||
if let Some(t) = r.title {
|
||||
place.push((t, Xfrm::new(tx, m, tw, sh * 0.24)));
|
||||
}
|
||||
stack(Xfrm::new(tx, m + sh * 0.28, tw, sh - 2.0 * m - sh * 0.28), &mut place);
|
||||
}
|
||||
"framed" => {
|
||||
if let Some(p) = r.picture {
|
||||
place.push((p, Xfrm::new(sw * 0.42, m * 1.5, sw * 0.58 - m * 1.5, sh - 3.0 * m)));
|
||||
}
|
||||
let tw = sw * 0.42 - 2.0 * m;
|
||||
if let Some(t) = r.title {
|
||||
place.push((t, Xfrm::new(m, m * 1.5, tw, sh * 0.3)));
|
||||
}
|
||||
stack(Xfrm::new(m, m * 1.5 + sh * 0.34, tw, sh - 3.0 * m - sh * 0.34), &mut place);
|
||||
}
|
||||
"titleColumn" => {
|
||||
if let Some(t) = r.title {
|
||||
place.push((t, Xfrm::new(m, m, sw * 0.32, sh - 2.0 * m)));
|
||||
}
|
||||
stack(Xfrm::new(sw * 0.36, m, sw * 0.64 - m, sh - 2.0 * m), &mut place);
|
||||
}
|
||||
"centred" => {
|
||||
if let Some(t) = r.title {
|
||||
place.push((t, Xfrm::new(sw * 0.1, m, sw * 0.8, sh * 0.26)));
|
||||
}
|
||||
stack(Xfrm::new(sw * 0.18, m + sh * 0.3, sw * 0.64, sh - 2.0 * m - sh * 0.3), &mut place);
|
||||
}
|
||||
"titleBottom" => {
|
||||
stack(Xfrm::new(m, m, sw - 2.0 * m, sh * 0.62), &mut place);
|
||||
if let Some(t) = r.title {
|
||||
place.push((t, Xfrm::new(m, sh * 0.72, sw - 2.0 * m, sh * 0.2)));
|
||||
}
|
||||
}
|
||||
_ => return Err(bad("design.applyIdea", format!("no idea `{id}`"))),
|
||||
}
|
||||
let slide = doc.slides.get_mut(index).ok_or_else(|| bad("design.applyIdea", "no slide"))?;
|
||||
let slide = std::sync::Arc::make_mut(slide);
|
||||
for (i, x) in place {
|
||||
let (Some(s), Some(old)) = (slide.shapes.get_mut(i), boxes.get(i)) else { continue };
|
||||
if let ShapeKind::Picture { fill } = &mut s.kind {
|
||||
// The source's aspect, from the current box and crop.
|
||||
let c = fill.crop;
|
||||
let (fw, fh) = ((1.0 - c[0] - c[2]).max(0.01), (1.0 - c[1] - c[3]).max(0.01));
|
||||
let src = (old.w / fw) / (old.h / fh).max(1e-6);
|
||||
fill.crop = cover_crop(src, x.w / x.h.max(1e-6));
|
||||
}
|
||||
s.xfrm = Some(Xfrm { rot: 0.0, ..x });
|
||||
}
|
||||
// The picture goes to the back, so text over it stays readable.
|
||||
if let Some(p) = r.picture
|
||||
&& p < slide.shapes.len()
|
||||
{
|
||||
let pic = slide.shapes.remove(p);
|
||||
slide.shapes.insert(0, pic);
|
||||
}
|
||||
if let Some(t) = slide.shapes.iter_mut().find(|s| !s.hidden && s.ph.as_ref().is_some_and(|p| p.kind.is_title())) {
|
||||
if band {
|
||||
t.fill = Some(band_fill());
|
||||
} else if t.fill.as_ref().is_some_and(is_band) {
|
||||
// The band belongs to full-bleed only: another idea takes it away again.
|
||||
t.fill = None;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ideas_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let i = usize_param(p, "slide").unwrap_or(st.selection.slide);
|
||||
Ok(json!(ideas_for(&st.doc, i).iter().map(|(id, label)| json!({"id": id, "label": label})).collect::<Vec<_>>()))
|
||||
}
|
||||
|
||||
fn apply_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = str_param(p, "id").ok_or_else(|| bad("design.applyIdea", "missing `id`"))?.to_string();
|
||||
let slide = usize_param(p, "slide");
|
||||
s.edit(|doc, sel| {
|
||||
let i = slide.unwrap_or(sel.slide);
|
||||
apply_idea(doc, sel, i, &id)?;
|
||||
Ok(json!({"slide": i, "id": id}))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::style::PictureFill;
|
||||
use deckcraft_model::{PhType, Placeholder, ShapeId};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn deck(picture: bool) -> Session {
|
||||
let mut s = Session::with_new();
|
||||
let st = s.doc_mut().unwrap();
|
||||
let doc = Arc::make_mut(&mut st.doc);
|
||||
let slide = Arc::make_mut(&mut doc.slides[0]);
|
||||
slide.shapes.clear();
|
||||
let title = Shape {
|
||||
id: ShapeId(500),
|
||||
ph: Some(Placeholder { kind: PhType::Title, ..Default::default() }),
|
||||
xfrm: Some(Xfrm::new(50.0, 30.0, 860.0, 90.0)),
|
||||
text: Some(deckcraft_model::text::TextBody::from_text("Harbour at dawn")),
|
||||
..Default::default()
|
||||
};
|
||||
let body = Shape {
|
||||
id: ShapeId(501),
|
||||
text_box: true,
|
||||
xfrm: Some(Xfrm::new(50.0, 140.0, 860.0, 350.0)),
|
||||
text: Some(deckcraft_model::text::TextBody::from_text("one\ntwo")),
|
||||
..Default::default()
|
||||
};
|
||||
slide.shapes.push(title);
|
||||
slide.shapes.push(body);
|
||||
if picture {
|
||||
// A 2:1 picture covering most of the slide, over the title.
|
||||
let pic = Shape {
|
||||
id: ShapeId(502),
|
||||
kind: ShapeKind::Picture { fill: PictureFill::default() },
|
||||
xfrm: Some(Xfrm::new(0.0, 0.0, 900.0, 450.0)),
|
||||
..Default::default()
|
||||
};
|
||||
slide.shapes.push(pic);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn shape(s: &Session, id: u32) -> Shape {
|
||||
s.doc().unwrap().doc.slides[0].shapes.iter().find(|x| x.id == ShapeId(id)).cloned().unwrap()
|
||||
}
|
||||
|
||||
fn overlaps(a: Xfrm, b: Xfrm) -> bool {
|
||||
a.x < b.x + b.w - 0.5 && b.x < a.x + a.w - 0.5 && a.y < b.y + b.h - 0.5 && b.y < a.y + a.h - 0.5
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picture_slides_get_layout_ideas_and_text_slides_get_text_ideas() {
|
||||
let s = deck(true);
|
||||
let ids: Vec<&str> = ideas_for(&s.doc().unwrap().doc, 0).iter().map(|i| i.0).collect();
|
||||
assert_eq!(ids, ["fullBleed", "pictureLeft", "pictureRight", "framed"]);
|
||||
let s = deck(false);
|
||||
assert_eq!(ideas_for(&s.doc().unwrap().doc, 0).len(), 3);
|
||||
assert!(ideas_for(&s.doc().unwrap().doc, 9).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picture_left_puts_text_beside_the_picture_cropped_to_fill() {
|
||||
let mut s = deck(true);
|
||||
let theme_before = s.doc().unwrap().doc.masters[0].theme.name.clone();
|
||||
s.execute("design.applyIdea", &json!({"id": "pictureLeft"})).unwrap();
|
||||
let (pic, title, body) = (shape(&s, 502), shape(&s, 500), shape(&s, 501));
|
||||
let px = pic.xfrm.unwrap();
|
||||
assert_eq!((px.x, px.y, px.w, px.h), (0.0, 0.0, 480.0, 540.0));
|
||||
assert!(!overlaps(px, title.xfrm.unwrap()) && !overlaps(px, body.xfrm.unwrap()));
|
||||
assert!(!overlaps(title.xfrm.unwrap(), body.xfrm.unwrap()));
|
||||
// 2:1 source into an 8:9 box: cut equally from left and right.
|
||||
let ShapeKind::Picture { fill } = &pic.kind else { panic!() };
|
||||
assert!(fill.crop[0] > 0.25 && (fill.crop[0] - fill.crop[2]).abs() < 1e-9 && fill.crop[1] == 0.0, "{:?}", fill.crop);
|
||||
// Only this slide's arrangement changed, not the theme; undo restores it.
|
||||
assert_eq!(s.doc().unwrap().doc.masters[0].theme.name, theme_before);
|
||||
assert_eq!(s.doc().unwrap().doc.slides[0].shapes[0].id, ShapeId(502), "picture at the back");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(shape(&s, 502).xfrm.unwrap().w, 900.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_bleed_covers_the_slide_and_puts_the_title_on_a_band() {
|
||||
let mut s = deck(true);
|
||||
s.execute("design.applyIdea", &json!({"id": "fullBleed"})).unwrap();
|
||||
let px = shape(&s, 502).xfrm.unwrap();
|
||||
assert_eq!((px.w, px.h), (960.0, 540.0));
|
||||
let t = shape(&s, 500);
|
||||
assert!(t.fill.is_some());
|
||||
assert!(t.xfrm.unwrap().y > 300.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_idea_keeps_shapes_on_the_slide_and_unknown_ideas_are_refused() {
|
||||
for pic in [true, false] {
|
||||
let probe = deck(pic);
|
||||
for (id, _) in ideas_for(&probe.doc().unwrap().doc, 0) {
|
||||
let mut s = deck(pic);
|
||||
s.execute("design.applyIdea", &json!({"id": id})).unwrap();
|
||||
for sh in &s.doc().unwrap().doc.slides[0].shapes {
|
||||
let x = sh.xfrm.unwrap();
|
||||
assert!(x.x >= -0.01 && x.y >= -0.01 && x.x + x.w <= 960.01 && x.y + x.h <= 540.01 && x.w > 0.0 && x.h > 0.0, "{id}: {x:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut s = deck(false);
|
||||
assert!(s.execute("design.applyIdea", &json!({"id": "fullBleed"})).is_err());
|
||||
assert!(s.execute("design.applyIdea", &json!({"id": "nope"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_from_full_bleed_removes_the_title_band() {
|
||||
let mut s = deck(true);
|
||||
s.execute("design.applyIdea", &json!({"id": "fullBleed"})).unwrap();
|
||||
assert_eq!(shape(&s, 500).fill, Some(band_fill()));
|
||||
s.execute("design.applyIdea", &json!({"id": "pictureLeft"})).unwrap();
|
||||
assert!(shape(&s, 500).fill.is_none(), "the band belongs to full-bleed only");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_band_goes_on_the_visible_title_not_a_hidden_one_before_it() {
|
||||
let mut s = deck(true);
|
||||
let st = s.doc_mut().unwrap();
|
||||
let slide = Arc::make_mut(&mut Arc::make_mut(&mut st.doc).slides[0]);
|
||||
let hidden = Shape {
|
||||
id: ShapeId(503),
|
||||
hidden: true,
|
||||
ph: Some(Placeholder { kind: PhType::Title, ..Default::default() }),
|
||||
xfrm: Some(Xfrm::new(50.0, 30.0, 860.0, 90.0)),
|
||||
text: Some(deckcraft_model::text::TextBody::from_text("Old title")),
|
||||
..Default::default()
|
||||
};
|
||||
slide.shapes.insert(0, hidden);
|
||||
s.execute("design.applyIdea", &json!({"id": "fullBleed"})).unwrap();
|
||||
assert_eq!(shape(&s, 500).fill, Some(band_fill()), "visible title gets the band");
|
||||
assert!(shape(&s, 503).fill.is_none(), "hidden title is untouched");
|
||||
s.execute("design.applyIdea", &json!({"id": "pictureLeft"})).unwrap();
|
||||
assert!(shape(&s, 500).fill.is_none(), "switching clears the visible title's band");
|
||||
assert!(shape(&s, 503).fill.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_ideas_removes_the_band_after_a_pptx_round_trip() {
|
||||
let mut s = deck(true);
|
||||
s.execute("design.applyIdea", &json!({"id": "fullBleed"})).unwrap();
|
||||
let bytes = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap();
|
||||
let back = deckcraft_pptx::import(&bytes).unwrap();
|
||||
s.doc_mut().unwrap().doc = Arc::new(back);
|
||||
let title = s.doc().unwrap().doc.slides[0].shapes.iter().position(|x| x.ph.as_ref().is_some_and(|p| p.kind.is_title())).unwrap();
|
||||
let id = s.doc().unwrap().doc.slides[0].shapes[title].id.0;
|
||||
assert_eq!(shape(&s, id).fill, Some(band_fill()), "band survives the save");
|
||||
s.execute("design.applyIdea", &json!({"id": "pictureLeft"})).unwrap();
|
||||
assert!(shape(&s, id).fill.is_none(), "the band is removed after reopening");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_ideas_removes_a_band_saved_by_earlier_versions() {
|
||||
let mut s = deck(true);
|
||||
let legacy = Fill::solid(deckcraft_model::ColorRef::rgb(Rgba::rgba(255, 255, 255, 220)));
|
||||
let st = s.doc_mut().unwrap();
|
||||
let slide = Arc::make_mut(&mut Arc::make_mut(&mut st.doc).slides[0]);
|
||||
slide.shapes.iter_mut().find(|x| x.id == ShapeId(500)).unwrap().fill = Some(legacy);
|
||||
s.execute("design.applyIdea", &json!({"id": "pictureLeft"})).unwrap();
|
||||
assert!(shape(&s, 500).fill.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_title_fill_set_by_the_user_survives_other_ideas() {
|
||||
// Set through the command, and set to exactly the band's own fill.
|
||||
let mut s = deck(true);
|
||||
s.execute("shape.fill", &json!({"id": 500, "color": "#ffffff", "transparency": 0.14})).unwrap();
|
||||
let user = shape(&s, 500).fill;
|
||||
assert!(user.is_some());
|
||||
s.execute("design.applyIdea", &json!({"id": "pictureLeft"})).unwrap();
|
||||
assert_eq!(shape(&s, 500).fill, user);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_bleed_twice_keeps_the_band_and_undo_after_switching_brings_it_back() {
|
||||
let mut s = deck(true);
|
||||
s.execute("design.applyIdea", &json!({"id": "fullBleed"})).unwrap();
|
||||
s.execute("design.applyIdea", &json!({"id": "fullBleed"})).unwrap();
|
||||
assert_eq!(shape(&s, 500).fill, Some(band_fill()));
|
||||
s.execute("design.applyIdea", &json!({"id": "framed"})).unwrap();
|
||||
assert!(shape(&s, 500).fill.is_none());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(shape(&s, 500).fill, Some(band_fill()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slides_with_diagrams_or_other_graphics_get_no_ideas() {
|
||||
// A filled autoshape with text (a diagram box) is not text to restack.
|
||||
let mut s = deck(false);
|
||||
let slide = Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]);
|
||||
slide.shapes.push(Shape {
|
||||
id: ShapeId(510),
|
||||
xfrm: Some(Xfrm::new(300.0, 200.0, 200.0, 80.0)),
|
||||
fill: Some(Fill::solid(deckcraft_model::ColorRef::rgb(Rgba::rgba(200, 80, 20, 255)))),
|
||||
text: Some(deckcraft_model::text::TextBody::from_text("Solar wind")),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(ideas_for(&s.doc().unwrap().doc, 0).is_empty());
|
||||
assert!(s.execute("design.applyIdea", &json!({"id": "titleColumn"})).is_err());
|
||||
// A connector too, and a hidden one does not count.
|
||||
let mut s = deck(false);
|
||||
let slide = Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]);
|
||||
slide.shapes.push(Shape {
|
||||
id: ShapeId(511),
|
||||
kind: ShapeKind::Connector { start: None, end: None },
|
||||
xfrm: Some(Xfrm::new(300.0, 200.0, 100.0, 0.0)),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(ideas_for(&s.doc().unwrap().doc, 0).is_empty());
|
||||
Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]).shapes[2].hidden = true;
|
||||
assert_eq!(ideas_for(&s.doc().unwrap().doc, 0).len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_visible_pictures_get_no_ideas_but_a_hidden_second_one_does_not_count() {
|
||||
let mut s = deck(true);
|
||||
let slide = Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]);
|
||||
slide.shapes.push(Shape {
|
||||
id: ShapeId(503),
|
||||
kind: ShapeKind::Picture { fill: PictureFill::default() },
|
||||
xfrm: Some(Xfrm::new(600.0, 300.0, 200.0, 100.0)),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(ideas_for(&s.doc().unwrap().doc, 0).is_empty());
|
||||
assert!(s.execute("design.applyIdea", &json!({"id": "pictureLeft"})).is_err());
|
||||
Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]).shapes[3].hidden = true;
|
||||
assert_eq!(ideas_for(&s.doc().unwrap().doc, 0).len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_text_box_blocks_ideas_only_when_it_shows() {
|
||||
let mut s = deck(false);
|
||||
let slide = Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]);
|
||||
slide.shapes.push(Shape { id: ShapeId(512), text_box: true, xfrm: Some(Xfrm::new(10.0, 10.0, 100.0, 40.0)), ..Default::default() });
|
||||
assert_eq!(ideas_for(&s.doc().unwrap().doc, 0).len(), 3, "invisible and empty: nothing in the way");
|
||||
let slide = Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]);
|
||||
slide.shapes[2].fill = Some(Fill::solid(deckcraft_model::ColorRef::rgb(Rgba::rgba(200, 80, 20, 255))));
|
||||
assert!(ideas_for(&s.doc().unwrap().doc, 0).is_empty(), "a filled empty box would stay on top");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_box_with_an_explicit_no_fill_does_not_block_ideas() {
|
||||
let mut s = deck(false);
|
||||
let slide = Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]);
|
||||
slide.shapes.push(Shape {
|
||||
id: ShapeId(512),
|
||||
text_box: true,
|
||||
xfrm: Some(Xfrm::new(10.0, 10.0, 100.0, 40.0)),
|
||||
fill: Some(Fill::None),
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(ideas_for(&s.doc().unwrap().doc, 0).len(), 3, "an explicit no fill shows nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_box_filled_through_its_style_blocks_ideas() {
|
||||
let mut s = deck(false);
|
||||
let slide = Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]);
|
||||
let mut style = deckcraft_model::style::ShapeStyle::accent(deckcraft_model::SchemeSlot::Accent1);
|
||||
style.line_ref.0 = 0;
|
||||
slide.shapes.push(Shape {
|
||||
id: ShapeId(512),
|
||||
text_box: true,
|
||||
xfrm: Some(Xfrm::new(10.0, 10.0, 100.0, 40.0)),
|
||||
style: Some(style),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(ideas_for(&s.doc().unwrap().doc, 0).is_empty(), "the theme fill shows, so the box would stay on top");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cover_crop_matches_the_box_aspect() {
|
||||
let c = cover_crop(2.0, 1.0);
|
||||
assert!((c[0] - 0.25).abs() < 1e-12 && c[1] == 0.0);
|
||||
let c = cover_crop(1.0, 2.0);
|
||||
assert!((c[1] - 0.25).abs() < 1e-12 && c[0] == 0.0);
|
||||
assert_eq!(cover_crop(f64::NAN, 1.0), [0.0; 4]);
|
||||
}
|
||||
}
|
||||
+1585
-103
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,7 @@ use crate::{Result, Session};
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "document.inspect", "Inspect Presentation", [], None, "{} → slides with titles, layouts, shape counts; selection; size; theme", has_doc, document),
|
||||
cmd!(query "slide.inspect", "Inspect Slide", [], None, "{index?} → the slide's shapes with ids, kinds, boxes, text, fills, animations, transition, notes", has_doc, slide),
|
||||
cmd!(query "slide.inspect", "Inspect Slide", [], None, "{index?} → the slide's shapes with ids, kinds, boxes, text, fills, animations, transition, notes (text, and notesBody with its formatting)", has_doc, slide),
|
||||
cmd!(query "shape.inspect", "Inspect Shape", [], None, "{id} → full shape JSON plus its effective box", has_doc, shape),
|
||||
cmd!(query "commands.list", "List Commands", [], None, "{filter?: substring} → [{id, label, params, enabled}]", always, commands),
|
||||
]
|
||||
@@ -129,6 +129,7 @@ fn slide(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"transition": serde_json::to_value(&sl.transition).unwrap_or_default(),
|
||||
"animations": serde_json::to_value(&sl.animations).unwrap_or_default(),
|
||||
"notes": sl.notes_text(),
|
||||
"notesBody": sl.notes,
|
||||
"comments": sl.comments.len(),
|
||||
"hidden": sl.hidden,
|
||||
}))
|
||||
@@ -136,7 +137,8 @@ fn slide(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn shape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let id = id_param(p, "id").or_else(|| st.selection.shapes.first().copied()).ok_or_else(|| bad("shape.inspect", "missing `id`"))?;
|
||||
let id =
|
||||
id_param(p, "id", "shape.inspect")?.or_else(|| st.selection.shapes.first().copied()).ok_or_else(|| bad("shape.inspect", "missing `id`"))?;
|
||||
let sh = st.shape(id).ok_or_else(|| bad("shape.inspect", format!("no shape {id}")))?;
|
||||
let x = xfrm_of(&st.doc, &st.selection, sh);
|
||||
Ok(json!({"shape": serde_json::to_value(sh).unwrap_or_default(), "box": x}))
|
||||
|
||||
@@ -139,7 +139,7 @@ fn poster_frame(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let data = s.doc()?.doc.media(m.media).map(|i| i.data.clone()).ok_or_else(|| EngineError::Other("the media data is missing".into()))?;
|
||||
let png = poster_png(&data, ms).ok_or_else(|| EngineError::Other("no frame could be decoded there".into()))?;
|
||||
let name = format!("poster{id}.png");
|
||||
let poster = s.edit(|doc, _| Ok(doc.add_media(&name, "image/png", png)))?;
|
||||
let poster = s.edit(|doc, _| doc.add_media(&name, "image/png", png).ok_or_else(|| bad("media.posterFrame", NO_ID_SPACE)))?;
|
||||
edit_shapes(s, &json!({"id": id}), "media.posterFrame", |sh| {
|
||||
if let ShapeKind::Media(m) = &mut sh.kind {
|
||||
m.poster = Some(poster);
|
||||
@@ -228,6 +228,21 @@ mod tests {
|
||||
assert!(s.execute("media.posterFrame", &json!({})).is_err(), "audio has no poster frame");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_video_reopened_from_pptx_keeps_its_length_and_size() {
|
||||
let mut s = Session::with_new();
|
||||
let clip = include_bytes!("../../../media/tests/fixtures/clip-h264.mp4");
|
||||
s.execute("insert.video", &json!({"name": "clip.mp4", "data": base64_encode(clip)})).expect("insert");
|
||||
let before = s.execute("media.info", &json!({})).expect("info");
|
||||
assert!(before["durationMs"].as_u64().is_some_and(|ms| ms > 0));
|
||||
let saved = s.execute("file.saveBytes", &json!({"format": "pptx"})).expect("save");
|
||||
s.execute("file.openBytes", &json!({"name": "deck.pptx", "data": saved["data"]})).expect("open");
|
||||
let after = s.execute("media.info", &json!({})).expect("info");
|
||||
for key in ["durationMs", "width", "height"] {
|
||||
assert_eq!(after[key], before[key], "{key}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_commands_need_media() {
|
||||
let mut s = Session::with_new();
|
||||
|
||||
+144
-27
@@ -30,39 +30,87 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
)]
|
||||
}
|
||||
|
||||
/// The filled outline of `s` in slide points (curves flattened).
|
||||
/// The filled outline of `s` in slide points (curves flattened). A text box with no fill and no
|
||||
/// outline is its letters, as in PowerPoint (merging text with a picture fills the letters).
|
||||
fn area_of(doc: &deckcraft_model::Presentation, sel: &crate::Selection, s: &Shape) -> Area {
|
||||
if let Some((letters, _)) = letters_of(doc, sel, s) {
|
||||
return letters;
|
||||
}
|
||||
let x = xfrm_of(doc, sel, s);
|
||||
let geo = deckcraft_render::shape_geometry(s, x.w, x.h);
|
||||
let a = x.affine();
|
||||
let mut out: Area = vec![];
|
||||
for sp in geo.paths.iter().filter(|p| p.fill != deckcraft_geom::preset::FillMode::None) {
|
||||
let mut cur: Vec<[f64; 2]> = vec![];
|
||||
let flush = |cur: &mut Vec<[f64; 2]>, out: &mut Area| {
|
||||
if cur.len() > 2 {
|
||||
out.push(std::mem::take(cur));
|
||||
} else {
|
||||
cur.clear();
|
||||
}
|
||||
};
|
||||
deckcraft_geom::preset_flatten(&sp.path, &mut |el| match el {
|
||||
PathEl::MoveTo(p) => {
|
||||
flush(&mut cur, &mut out);
|
||||
let q = a * p;
|
||||
cur.push([q.x, q.y]);
|
||||
}
|
||||
PathEl::LineTo(p) => {
|
||||
let q = a * p;
|
||||
cur.push([q.x, q.y]);
|
||||
}
|
||||
PathEl::ClosePath => flush(&mut cur, &mut out),
|
||||
_ => {}
|
||||
});
|
||||
flush(&mut cur, &mut out);
|
||||
flatten(&sp.path, a, &mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The letters of a text box with no fill and no outline, in slide points, and their colour.
|
||||
fn letters_of(doc: &deckcraft_model::Presentation, sel: &crate::Selection, s: &Shape) -> Option<(Area, Option<deckcraft_color::Rgba>)> {
|
||||
use deckcraft_model::resolve;
|
||||
use deckcraft_model::style::Fill;
|
||||
if !matches!(s.kind, ShapeKind::Shape) || s.text.as_ref().is_none_or(|t| t.is_empty()) {
|
||||
return None;
|
||||
}
|
||||
let ctx = super::text::ctx_for(doc, sel)?;
|
||||
let none = |f: Option<&Fill>| f.is_none_or(Fill::is_none);
|
||||
if !none(resolve::fill(&ctx, s).0.as_ref()) || !none(resolve::line(&ctx, s).0.fill.as_ref()) {
|
||||
return None;
|
||||
}
|
||||
let (path, colour) = deckcraft_render::text_outline(&ctx, s, xfrm_of(doc, sel, s).affine());
|
||||
let mut out: Area = vec![];
|
||||
flatten(&path, deckcraft_geom::Affine::IDENTITY, &mut out);
|
||||
(!out.is_empty()).then_some((out, colour))
|
||||
}
|
||||
|
||||
/// Append the closed polygons of `path` (mapped by `a`) to `out`.
|
||||
fn flatten(path: &deckcraft_geom::BezPath, a: deckcraft_geom::Affine, out: &mut Area) {
|
||||
let mut cur: Vec<[f64; 2]> = vec![];
|
||||
let flush = |cur: &mut Vec<[f64; 2]>, out: &mut Area| {
|
||||
if cur.len() > 2 {
|
||||
out.push(std::mem::take(cur));
|
||||
} else {
|
||||
cur.clear();
|
||||
}
|
||||
};
|
||||
deckcraft_geom::preset_flatten(path, &mut |el| match el {
|
||||
PathEl::MoveTo(p) => {
|
||||
flush(&mut cur, out);
|
||||
let q = a * p;
|
||||
cur.push([q.x, q.y]);
|
||||
}
|
||||
PathEl::LineTo(p) => {
|
||||
let q = a * p;
|
||||
cur.push([q.x, q.y]);
|
||||
}
|
||||
PathEl::ClosePath => flush(&mut cur, out),
|
||||
_ => {}
|
||||
});
|
||||
flush(&mut cur, out);
|
||||
}
|
||||
|
||||
/// Keep a picture where it was on the slide when its box becomes `to`: crop it to the new box.
|
||||
/// Only for upright, unflipped pictures stretched over their box.
|
||||
fn recrop(fill: &mut deckcraft_model::style::PictureFill, from: &Xfrm, to: &Xfrm) {
|
||||
use deckcraft_model::style::PictureMode;
|
||||
if from.rot != 0.0 || from.flip_h || from.flip_v || !matches!(fill.mode, PictureMode::Stretch { fill_rect } if fill_rect == [0.0; 4]) {
|
||||
return;
|
||||
}
|
||||
let [l, t, r, b] = fill.crop;
|
||||
let (fw, fh) = (1.0 - l - r, 1.0 - t - b);
|
||||
if !(fw > 1e-9 && fh > 1e-9 && from.w > 0.0 && from.h > 0.0) {
|
||||
return;
|
||||
}
|
||||
// The whole image in slide points.
|
||||
let (iw, ih) = (from.w / fw, from.h / fh);
|
||||
let (ix, iy) = (from.x - l * iw, from.y - t * ih);
|
||||
let c = [(to.x - ix) / iw, (to.y - iy) / ih, (ix + iw - to.x - to.w) / iw, (iy + ih - to.y - to.h) / ih];
|
||||
if c.iter().all(|v| v.is_finite()) {
|
||||
fill.crop = c;
|
||||
}
|
||||
}
|
||||
|
||||
fn overlay(a: &Area, b: &Area, rule: OverlayRule) -> Area {
|
||||
if a.is_empty() && matches!(rule, OverlayRule::Union | OverlayRule::Xor) {
|
||||
return b.clone();
|
||||
@@ -155,7 +203,17 @@ fn merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let areas: Vec<Area> = shapes.iter().map(|x| area_of(&st.doc, &st.selection, x)).collect();
|
||||
let results = merge_areas(&op, &areas);
|
||||
let template = shapes[0].clone();
|
||||
let mut template = shapes[0].clone();
|
||||
let template_box = xfrm_of(&st.doc, &st.selection, &template);
|
||||
// Letters merged become shapes filled with the text's colour.
|
||||
let letters = letters_of(&st.doc, &st.selection, &template);
|
||||
if let Some((_, colour)) = &letters {
|
||||
use deckcraft_model::style::{ColorRef, Fill, Line};
|
||||
template.fill = Some(Fill::solid(ColorRef::rgb(colour.unwrap_or(deckcraft_color::Rgba::BLACK))));
|
||||
template.line = Some(Line { fill: Some(Fill::None), ..Default::default() });
|
||||
template.text_box = false;
|
||||
}
|
||||
let drop_text = letters.is_some() || shapes.iter().skip(1).any(|x| letters_of(&st.doc, &st.selection, x).is_some());
|
||||
let mut new_ids = vec![];
|
||||
s.edit(|doc, sel| {
|
||||
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("shape.merge", "no slide"))?;
|
||||
@@ -169,14 +227,17 @@ fn merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
sh.xfrm = Some(x);
|
||||
sh.geom = geom;
|
||||
sh.name = format!("Freeform {}", k + 1);
|
||||
// Text stays with the first piece only.
|
||||
if k > 0 {
|
||||
if let ShapeKind::Picture { fill } = &mut sh.kind {
|
||||
recrop(fill, &template_box, &x);
|
||||
}
|
||||
// Text stays with the first piece only, and not at all once it became outlines.
|
||||
if k > 0 || drop_text {
|
||||
sh.text = None;
|
||||
}
|
||||
made.push(sh);
|
||||
}
|
||||
for sh in &mut made {
|
||||
sh.id = ShapeId(doc.alloc_id());
|
||||
sh.id = ShapeId(doc.alloc_id().ok_or_else(|| bad("shape.merge", NO_ID_SPACE))?);
|
||||
new_ids.push(sh.id);
|
||||
}
|
||||
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("shape.merge", "no slide"))?;
|
||||
@@ -216,4 +277,60 @@ mod tests {
|
||||
// Disjoint intersect is empty.
|
||||
assert!(merge_areas("intersect", &[square(0.0, 0.0, 1.0), square(5.0, 5.0, 1.0)]).is_empty());
|
||||
}
|
||||
|
||||
fn picture_and_text(s: &mut Session) -> (ShapeId, ShapeId) {
|
||||
let mut png = Vec::new();
|
||||
image::RgbaImage::from_pixel(40, 20, image::Rgba([0, 128, 255, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.expect("png");
|
||||
let _ = s.execute("slide.new", &json!({"layout": "blank"}));
|
||||
let r = s
|
||||
.execute("insert.picture", &json!({"name": "a.png", "data": base64_encode(&png), "rect": [100.0, 100.0, 400.0, 200.0]}))
|
||||
.expect("picture");
|
||||
let pic = ShapeId(r["id"].as_u64().expect("id") as u32);
|
||||
let r = s.execute("insert.textBox", &json!({"rect": [150.0, 120.0, 300.0, 100.0], "text": "HI"})).expect("text box");
|
||||
let text = ShapeId(r["id"].as_u64().expect("id") as u32);
|
||||
(pic, text)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_picture_intersected_with_text_fills_the_letters() {
|
||||
// #653: the letters didn't change and the picture vanished (the text box counted as its
|
||||
// whole box, and the merge took the text's outline but kept the box's formatting).
|
||||
let mut s = Session::with_new();
|
||||
let (pic, text) = picture_and_text(&mut s);
|
||||
let st = s.doc().expect("doc");
|
||||
let letters = letters_of(&st.doc, &st.selection, st.shape(text).expect("text")).expect("the text box is its letters").0;
|
||||
let box_area = 300.0 * 100.0;
|
||||
assert!(area_size(&letters) > 10.0 && area_size(&letters) < box_area * 0.5, "{}", area_size(&letters));
|
||||
let r = s.execute("shape.merge", &json!({"op": "intersect", "ids": [pic.0, text.0]})).expect("merge");
|
||||
let ids = r["ids"].as_array().expect("ids");
|
||||
assert_eq!(ids.len(), 1);
|
||||
let st = s.doc().expect("doc");
|
||||
let out = st.shape(ShapeId(ids[0].as_u64().expect("id") as u32)).expect("result").clone();
|
||||
let ShapeKind::Picture { fill } = &out.kind else { panic!("the picture fills the letters: {:?}", out.kind) };
|
||||
assert!(out.text.is_none(), "the letters are outlines now");
|
||||
// The result is the letters' outline, well inside the text box.
|
||||
let x = out.xfrm.expect("box");
|
||||
assert!(x.x >= 150.0 && x.y >= 120.0 && x.x + x.w <= 450.0 && x.y + x.h <= 220.0, "{x:?}");
|
||||
let merged = area_of(&st.doc, &st.selection, &out);
|
||||
assert!((area_size(&merged) - area_size(&letters)).abs() < area_size(&letters) * 0.05, "{} vs {}", area_size(&merged), area_size(&letters));
|
||||
// The picture stays where it was on the slide: cropped to the new box.
|
||||
let [l, t, rr, b] = fill.crop;
|
||||
let iw = x.w / (1.0 - l - rr);
|
||||
let ih = x.h / (1.0 - t - b);
|
||||
assert!((x.x - l * iw - 100.0).abs() < 1e-6 && (iw - 400.0).abs() < 1e-6, "{:?}", fill.crop);
|
||||
assert!((x.y - t * ih - 100.0).abs() < 1e-6 && (ih - 200.0).abs() < 1e-6, "{:?}", fill.crop);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_merged_first_becomes_shapes_in_its_colour() {
|
||||
let mut s = Session::with_new();
|
||||
let (pic, text) = picture_and_text(&mut s);
|
||||
let r = s.execute("shape.merge", &json!({"op": "union", "ids": [text.0, pic.0]})).expect("merge");
|
||||
let st = s.doc().expect("doc");
|
||||
let out = st.shape(ShapeId(r["ids"][0].as_u64().expect("id") as u32)).expect("result");
|
||||
assert!(matches!(out.kind, ShapeKind::Shape) && out.text.is_none());
|
||||
assert!(matches!(out.fill, Some(deckcraft_model::style::Fill::Solid { .. })), "{:?}", out.fill);
|
||||
}
|
||||
}
|
||||
|
||||
+291
-10
@@ -2,20 +2,34 @@
|
||||
|
||||
pub mod animation;
|
||||
pub mod arrange;
|
||||
pub mod artistic;
|
||||
pub mod background;
|
||||
pub mod compress;
|
||||
pub mod design;
|
||||
pub mod edit;
|
||||
pub mod file;
|
||||
pub mod find;
|
||||
pub mod format;
|
||||
pub mod ideas;
|
||||
pub mod insert;
|
||||
pub mod inspect;
|
||||
pub mod media;
|
||||
pub mod merge;
|
||||
pub mod picture_style;
|
||||
pub mod points;
|
||||
pub mod recolor;
|
||||
pub mod reuse;
|
||||
pub mod review;
|
||||
mod richclip;
|
||||
pub mod selection;
|
||||
pub mod shape;
|
||||
pub mod slide;
|
||||
pub mod smartart;
|
||||
pub mod table;
|
||||
pub mod text;
|
||||
pub mod three_d;
|
||||
pub mod transition;
|
||||
pub mod zoom;
|
||||
|
||||
use deckcraft_geom::Xfrm;
|
||||
use deckcraft_model::ShapeId;
|
||||
@@ -33,6 +47,9 @@ pub struct CommandSpec {
|
||||
/// Where it lives: `["Home", "Font"]` (ribbon tab and group) or `["Arrange"]` (menu).
|
||||
pub menu: &'static [&'static str],
|
||||
pub shortcut: Option<&'static str>,
|
||||
/// The parameters as `describe` and `commands` show them, read by [`parse_params`]: `?` marks
|
||||
/// an optional key, a spaced ` | ` separates alternative sets of keys (so alternative values
|
||||
/// are written unspaced, `ms|null`), `(…)` is a remark and `→` starts the answer.
|
||||
pub params: &'static str,
|
||||
pub enabled: Enabled,
|
||||
pub run: Run,
|
||||
@@ -53,6 +70,28 @@ pub struct CommandInfo {
|
||||
}
|
||||
|
||||
impl CommandSpec {
|
||||
/// Holds because `Session::execute` skips the selection check when `id` or `ids` is given.
|
||||
pub fn acts_on(&self) -> Option<String> {
|
||||
let keys = param_keys(self.params).unwrap_or_default();
|
||||
let named = if keys.contains(&"ids") {
|
||||
Some("the shapes in `ids`")
|
||||
} else if keys.contains(&"id") {
|
||||
Some("the shape in `id`")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let reason = (self.enabled)(&Session::with_new()).err()?;
|
||||
Some(match (reason.as_str(), named) {
|
||||
(SELECT_TEXT_OR_SHAPE, _) => "Acts on the selected text or shapes.".into(),
|
||||
(NOTHING_SELECTED, Some(n)) => format!("Acts on {n}, else on the selected shapes."),
|
||||
(NOTHING_SELECTED, None) => "Acts on the selected shapes.".into(),
|
||||
(SELECT_TWO, Some(n)) => format!("Acts on two or more shapes: {n}, else the selected ones."),
|
||||
(SELECT_TWO, None) => "Acts on two or more selected shapes.".into(),
|
||||
(NO_TEXT_POINT, _) => "Acts on the text being edited; text.edit starts editing a shape's text.".into(),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn info(&self, s: &Session) -> CommandInfo {
|
||||
let e = (self.enabled)(s);
|
||||
CommandInfo {
|
||||
@@ -79,6 +118,8 @@ pub fn has_slide(s: &Session) -> std::result::Result<(), String> {
|
||||
}
|
||||
pub const NOTHING_SELECTED: &str = "nothing selected";
|
||||
pub const SELECT_TWO: &str = "select two or more objects";
|
||||
pub const NO_TEXT_POINT: &str = "no text insertion point";
|
||||
pub const SELECT_TEXT_OR_SHAPE: &str = "select text or a shape";
|
||||
pub fn has_selection(s: &Session) -> std::result::Result<(), String> {
|
||||
has_doc(s)?;
|
||||
if s.active().is_some_and(|d| !d.selection.shapes.is_empty()) { Ok(()) } else { Err(NOTHING_SELECTED.into()) }
|
||||
@@ -89,17 +130,14 @@ pub fn has_two(s: &Session) -> std::result::Result<(), String> {
|
||||
}
|
||||
pub fn has_text(s: &Session) -> std::result::Result<(), String> {
|
||||
has_doc(s)?;
|
||||
if s.active().is_some_and(|d| d.selection.text.is_some()) { Ok(()) } else { Err("no text insertion point".into()) }
|
||||
if s.active().is_some_and(|d| d.selection.text.is_some()) { Ok(()) } else { Err(NO_TEXT_POINT.into()) }
|
||||
}
|
||||
/// Text editing or shapes with text selected (formatting commands apply to either).
|
||||
/// Text editing or selected shapes (formatting commands apply to either).
|
||||
pub fn has_text_or_shapes(s: &Session) -> std::result::Result<(), String> {
|
||||
has_doc(s)?;
|
||||
if s.active().is_some_and(|d| d.selection.text.is_some() || !d.selection.shapes.is_empty()) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("select text or a shape".into())
|
||||
}
|
||||
if s.active().is_some_and(|d| d.selection.text.is_some() || !d.selection.shapes.is_empty()) { Ok(()) } else { Err(SELECT_TEXT_OR_SHAPE.into()) }
|
||||
}
|
||||
|
||||
pub fn can_undo(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.active().is_some_and(|d| !d.history.undo.is_empty()) { Ok(()) } else { Err("nothing to undo".into()) }
|
||||
}
|
||||
@@ -127,19 +165,31 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(file::specs());
|
||||
v.extend(edit::specs());
|
||||
v.extend(slide::specs());
|
||||
v.extend(reuse::specs());
|
||||
v.extend(insert::specs());
|
||||
v.extend(smartart::specs());
|
||||
v.extend(shape::specs());
|
||||
v.extend(background::specs());
|
||||
v.extend(picture_style::specs());
|
||||
v.extend(recolor::specs());
|
||||
v.extend(arrange::specs());
|
||||
v.extend(selection::specs());
|
||||
v.extend(merge::specs());
|
||||
v.extend(points::specs());
|
||||
v.extend(text::specs());
|
||||
v.extend(format::specs());
|
||||
v.extend(design::specs());
|
||||
v.extend(ideas::specs());
|
||||
v.extend(transition::specs());
|
||||
v.extend(animation::specs());
|
||||
v.extend(table::specs());
|
||||
v.extend(find::specs());
|
||||
v.extend(review::specs());
|
||||
v.extend(inspect::specs());
|
||||
v.extend(media::specs());
|
||||
v.extend(artistic::specs());
|
||||
v.extend(zoom::specs());
|
||||
v.extend(compress::specs());
|
||||
v
|
||||
})
|
||||
}
|
||||
@@ -148,8 +198,105 @@ pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
|
||||
command_specs().iter().find(|c| c.id == id)
|
||||
}
|
||||
|
||||
pub(crate) fn param_keys(doc: &str) -> std::result::Result<Vec<&str>, String> {
|
||||
let mut keys = vec![];
|
||||
for p in parse_params(doc)? {
|
||||
if !keys.contains(&p.name) {
|
||||
keys.push(p.name);
|
||||
}
|
||||
}
|
||||
Ok(keys)
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct Param<'a> {
|
||||
pub name: &'a str,
|
||||
pub optional: bool,
|
||||
/// What the doc says of it: its values, unit or remark (`rect|roundRect|…`, `[x,y,w,h] pt`).
|
||||
pub about: &'a str,
|
||||
/// An alternative to the parameter before it in the same object (`color? | none?`).
|
||||
pub or: bool,
|
||||
/// Which of the doc's alternative objects it belongs to: `{index} | {id}` puts id in object 1.
|
||||
pub object: usize,
|
||||
}
|
||||
|
||||
/// The top-level parameters of a params doc: `{preset, rect?: [x,y,w,h] pt} | {id}` gives preset,
|
||||
/// rect (optional, `[x,y,w,h] pt`) and id (in the second object). Text after `→` is the result
|
||||
/// and is skipped. Errs where the doc strays from that grammar.
|
||||
pub fn parse_params(doc: &str) -> std::result::Result<Vec<Param<'_>>, String> {
|
||||
let doc = doc.split('→').next().unwrap_or_default();
|
||||
let mut params: Vec<Param> = vec![];
|
||||
let mut open: Vec<char> = vec![];
|
||||
let mut expect_key = false;
|
||||
let mut or = false;
|
||||
let mut objects: usize = 0;
|
||||
let mut about_from: Option<usize> = None;
|
||||
let mut chars = doc.char_indices().peekable();
|
||||
let mut prev = ' ';
|
||||
fn end_about<'a>(doc: &'a str, params: &mut [Param<'a>], from: &mut Option<usize>, to: usize) {
|
||||
if let (Some(f), Some(p)) = (from.take(), params.last_mut()) {
|
||||
p.about = doc.get(f..to).unwrap_or_default().trim();
|
||||
}
|
||||
}
|
||||
while let Some((i, c)) = chars.next() {
|
||||
let in_object = open == ['{'];
|
||||
match c {
|
||||
'{' | '[' | '(' => {
|
||||
open.push(c);
|
||||
if open == ['{'] {
|
||||
expect_key = true;
|
||||
objects += 1;
|
||||
or = false;
|
||||
}
|
||||
}
|
||||
'}' | ']' | ')' => {
|
||||
if in_object {
|
||||
end_about(doc, &mut params, &mut about_from, i);
|
||||
}
|
||||
open.pop();
|
||||
}
|
||||
',' if in_object => {
|
||||
end_about(doc, &mut params, &mut about_from, i);
|
||||
expect_key = true;
|
||||
}
|
||||
'|' if in_object && prev == ' ' && chars.peek().is_some_and(|(_, n)| *n == ' ') => {
|
||||
end_about(doc, &mut params, &mut about_from, i);
|
||||
expect_key = true;
|
||||
or = true;
|
||||
}
|
||||
c if in_object && expect_key && (c.is_ascii_alphabetic() || c == '_') => {
|
||||
let mut end = i + c.len_utf8();
|
||||
while let Some((j, n)) = chars.peek().copied().filter(|(_, n)| n.is_ascii_alphanumeric() || *n == '_') {
|
||||
end = j + n.len_utf8();
|
||||
chars.next();
|
||||
}
|
||||
let after = doc.get(end..).unwrap_or_default();
|
||||
let rest = after.trim_start_matches('?').trim_start();
|
||||
let at = doc.len() - rest.len();
|
||||
about_from = match rest.chars().next() {
|
||||
Some(':') => Some(at + 1),
|
||||
Some('(') => Some(at),
|
||||
Some(',' | '}' | '|') => None,
|
||||
_ => return Err(format!("`{}` is followed by `{}`", &doc[i..end], rest.chars().take(12).collect::<String>())),
|
||||
};
|
||||
params.push(Param { name: &doc[i..end], optional: after.starts_with('?'), about: "", or, object: objects.saturating_sub(1) });
|
||||
or = false;
|
||||
expect_key = false;
|
||||
}
|
||||
c if in_object && expect_key && !c.is_whitespace() => return Err(format!("expected a key at `{}`", &doc[i..])),
|
||||
_ => {}
|
||||
}
|
||||
prev = c;
|
||||
}
|
||||
Ok(params)
|
||||
}
|
||||
|
||||
// ---------- param helpers ----------
|
||||
|
||||
/// The deck has used every object id (a hostile file can arrive that way): it cannot grow, and
|
||||
/// growing would duplicate ids (#407).
|
||||
pub(crate) const NO_ID_SPACE: &str = "the presentation has used every object id and can hold no more";
|
||||
|
||||
pub(crate) fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
|
||||
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
|
||||
}
|
||||
@@ -171,8 +318,25 @@ pub(crate) fn str_param<'a>(p: &'a Value, key: &str) -> Option<&'a str> {
|
||||
pub(crate) fn usize_param(p: &Value, key: &str) -> Option<usize> {
|
||||
p.get(key).and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok())
|
||||
}
|
||||
pub(crate) fn id_param(p: &Value, key: &str) -> Option<ShapeId> {
|
||||
p.get(key).and_then(Value::as_u64).and_then(|v| u32::try_from(v).ok()).map(ShapeId)
|
||||
/// An index param: `Ok(None)` when absent (callers may fall back to the selection), `Err` when
|
||||
/// present but not a valid index (negative, non-integer), so a bad index never acts on the selection.
|
||||
pub(crate) fn opt_usize_param(p: &Value, key: &str, cmd: &str) -> Result<Option<usize>> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => v.as_u64().and_then(|v| usize::try_from(v).ok()).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` is not a valid index: {v}"))),
|
||||
}
|
||||
}
|
||||
/// A shape id param: `Ok(None)` when absent (callers may fall back to the selection), `Err` when
|
||||
/// present but not a valid id (negative, non-integer, beyond `u32`), so a bad id never edits the selection.
|
||||
pub(crate) fn id_param(p: &Value, key: &str, cmd: &str) -> Result<Option<ShapeId>> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => v
|
||||
.as_u64()
|
||||
.and_then(|v| u32::try_from(v).ok())
|
||||
.map(|v| Some(ShapeId(v)))
|
||||
.ok_or_else(|| bad(cmd, format!("`{key}` is not a valid shape id: {v}"))),
|
||||
}
|
||||
}
|
||||
pub(crate) fn ids_param(p: &Value, key: &str) -> Option<Vec<ShapeId>> {
|
||||
p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).filter_map(|v| u32::try_from(v).ok()).map(ShapeId).collect())
|
||||
@@ -196,7 +360,7 @@ pub(crate) fn targets(s: &Session, p: &Value) -> Result<Vec<ShapeId>> {
|
||||
{
|
||||
return Ok(ids);
|
||||
}
|
||||
if let Some(id) = id_param(p, "id") {
|
||||
if let Some(id) = id_param(p, "id", "shape")? {
|
||||
return Ok(vec![id]);
|
||||
}
|
||||
Ok(s.doc()?.selection.shapes.clone())
|
||||
@@ -272,6 +436,9 @@ fn ctx_xfrm(doc: &deckcraft_model::Presentation, sel: &crate::Selection, shape:
|
||||
match sel.target {
|
||||
crate::Target::Slides => doc.slides.get(sel.slide).and_then(|s| Ctx::for_slide(doc, s)).map(|c| resolve::xfrm(&c, shape)),
|
||||
crate::Target::Master { master } => doc.masters.get(master).map(|m| resolve::xfrm(&Ctx::for_master(doc, m), shape)),
|
||||
t @ (crate::Target::Handout | crate::Target::NotesMaster) => {
|
||||
crate::page_master(doc, t).map(|m| resolve::xfrm(&Ctx::for_master(doc, m), shape))
|
||||
}
|
||||
crate::Target::Layout { master, layout } => {
|
||||
doc.masters.get(master).and_then(|m| m.layouts.get(layout).map(|l| resolve::xfrm(&Ctx::for_layout(doc, m, l), shape)))
|
||||
}
|
||||
@@ -309,14 +476,99 @@ pub(crate) fn edit_shapes(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut
|
||||
})
|
||||
}
|
||||
|
||||
/// [`edit_shapes`] for edits a locked object refuses (move, size, rotate, flip, reshape, crop):
|
||||
/// locked targets are left out, and when every target is locked nothing changes.
|
||||
pub(crate) fn edit_geometry(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut deckcraft_model::Shape) -> Result<()>) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
let st = s.doc()?;
|
||||
let free: Vec<u32> = ids.iter().filter(|id| !st.shape(**id).is_some_and(|x| x.locked)).map(|id| id.0).collect();
|
||||
if !ids.is_empty() && free.is_empty() {
|
||||
return Err(bad(cmd, "the object is locked"));
|
||||
}
|
||||
edit_shapes(s, &with_param(p, "ids", Value::from(free)), cmd, f)
|
||||
}
|
||||
|
||||
pub(crate) fn current_shapes<'a>(doc: &'a deckcraft_model::Presentation, sel: &crate::Selection) -> Option<&'a Vec<deckcraft_model::Shape>> {
|
||||
match sel.target {
|
||||
crate::Target::Slides => doc.slides.get(sel.slide).map(|s| &s.shapes),
|
||||
crate::Target::Master { master } => doc.masters.get(master).map(|m| &m.shapes),
|
||||
t @ (crate::Target::Handout | crate::Target::NotesMaster) => crate::page_master(doc, t).map(|m| &m.shapes),
|
||||
crate::Target::Layout { master, layout } => doc.masters.get(master).and_then(|m| m.layouts.get(layout)).map(|l| &l.shapes),
|
||||
}
|
||||
}
|
||||
|
||||
/// The command palette runs a command with `{}`. True when the params doc names a value that
|
||||
/// call would have to supply (`path`, `rows`, `path? | data`), so the palette must not offer it.
|
||||
/// A name ending in `?` is optional. Text after `→` is the result, not an input.
|
||||
pub fn needs_arguments(params: &str) -> bool {
|
||||
let input = params.split('→').next().unwrap_or(params);
|
||||
let Some(start) = input.find('{') else { return false };
|
||||
let inside = input.get(start + 1..).unwrap_or("");
|
||||
let inside = inside.split('}').next().unwrap_or(inside);
|
||||
split_fields(inside).iter().any(|f| field_requires(f.trim()))
|
||||
}
|
||||
|
||||
fn split_fields(s: &str) -> Vec<&str> {
|
||||
let mut out = Vec::new();
|
||||
let mut start = 0;
|
||||
let mut depth = 0i32;
|
||||
let mut quote = false;
|
||||
for (i, c) in s.char_indices() {
|
||||
if quote {
|
||||
if c == '"' {
|
||||
quote = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match c {
|
||||
'"' => quote = true,
|
||||
'[' | '(' => depth += 1,
|
||||
']' | ')' => depth = depth.saturating_sub(1),
|
||||
',' if depth == 0 => {
|
||||
out.push(s.get(start..i).unwrap_or(""));
|
||||
start = i + c.len_utf8();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out.push(s.get(start..).unwrap_or(""));
|
||||
out
|
||||
}
|
||||
|
||||
/// A field needs a value unless every alternative (`path? | data`) is an optional parameter.
|
||||
fn field_requires(field: &str) -> bool {
|
||||
if field.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let alts: Vec<&str> = if field.contains(" | ") { field.split(" | ").map(str::trim).filter(|s| !s.is_empty()).collect() } else { vec![field] };
|
||||
let mut saw = false;
|
||||
let mut all_optional = true;
|
||||
for alt in alts {
|
||||
let Some(optional) = param_optional(alt) else { continue };
|
||||
saw = true;
|
||||
all_optional &= optional;
|
||||
}
|
||||
saw && !all_optional
|
||||
}
|
||||
|
||||
/// `Some(true)` when `alt` starts with an optional parameter name, `Some(false)` when required.
|
||||
fn param_optional(alt: &str) -> Option<bool> {
|
||||
let alt = alt.trim();
|
||||
let name_end = alt.find(|c: char| !c.is_ascii_alphanumeric()).unwrap_or(alt.len());
|
||||
let name = alt.get(..name_end).unwrap_or("");
|
||||
if name.is_empty() || !name.starts_with(|c: char| c.is_ascii_alphabetic()) {
|
||||
return None;
|
||||
}
|
||||
let rest = alt.get(name_end..).unwrap_or("").trim_start();
|
||||
if rest.starts_with('?') {
|
||||
Some(true)
|
||||
} else if rest.is_empty() || rest.starts_with(':') {
|
||||
Some(false)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Base64 (standard alphabet) for binary payloads in JSON params and results.
|
||||
pub fn base64_encode(data: &[u8]) -> String {
|
||||
const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
@@ -359,3 +611,32 @@ pub fn base64_decode(s: &str) -> Option<Vec<u8>> {
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_palette_skips_a_command_that_needs_a_value() {
|
||||
assert!(needs_arguments("{path}"));
|
||||
assert!(needs_arguments("{path, format: png|jpeg|pptx, slide?: index}"));
|
||||
assert!(needs_arguments("{path? | data: base64, name?, rect?}"));
|
||||
assert!(needs_arguments("{rows, cols, rect?}"));
|
||||
assert!(needs_arguments("{axis: horizontal|vertical, ids?}"));
|
||||
assert!(needs_arguments("{percent}"));
|
||||
assert!(!needs_arguments("{}"));
|
||||
assert!(!needs_arguments("{on?: bool}"));
|
||||
assert!(!needs_arguments("{theme?: name, size?: [w,h] pt, blank?: bool}"));
|
||||
assert!(!needs_arguments("{uid?: one entry, else all}"));
|
||||
assert!(!needs_arguments("{locked?: bool (default: toggle), ids?}"));
|
||||
assert!(!needs_arguments("{} → [{name, label, category}]"));
|
||||
assert!(!needs_arguments("{title?, subject?} → properties"));
|
||||
assert!(!needs_arguments("{slides?: [index] | \"1-3,5\", includeHidden?}"));
|
||||
let export = command_specs().iter().find(|c| c.id == "file.export").expect("export");
|
||||
let save = command_specs().iter().find(|c| c.id == "file.save").expect("save");
|
||||
let audio = command_specs().iter().find(|c| c.id == "insert.audio").expect("audio");
|
||||
assert!(needs_arguments(export.params));
|
||||
assert!(!needs_arguments(save.params));
|
||||
assert!(needs_arguments(audio.params));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
//! Picture Format ▸ Picture Styles: PowerPoint's quick styles for pictures, each a frame (shape,
|
||||
//! outline) and effects applied as one step, replacing the previous style.
|
||||
|
||||
use deckcraft_color::{ColorTransform, Rgba};
|
||||
use deckcraft_model::style::{Effects, Reflection, Shadow};
|
||||
use deckcraft_model::{ColorRef, Geom, Line, Shape};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"picture.style",
|
||||
"Picture Styles",
|
||||
["Picture Format", "Picture Styles"],
|
||||
None,
|
||||
"{style: simpleFrameWhite|dropShadowRectangle|softEdgeOval|… | none (see picture.styles), ids?}",
|
||||
has_selection,
|
||||
style
|
||||
),
|
||||
cmd!(query "picture.styles", "Picture Style Gallery", [], None, "{} → [{id, label}]", always, |_s, _p| Ok(json!(
|
||||
STYLES.iter().map(|(id, label)| json!({"id": id, "label": label})).collect::<Vec<_>>()
|
||||
))),
|
||||
]
|
||||
}
|
||||
|
||||
/// (id, label), in PowerPoint's gallery order.
|
||||
pub const STYLES: [(&str, &str); 24] = [
|
||||
("simpleFrameWhite", "Simple Frame, White"),
|
||||
("beveledMatteWhite", "Beveled Matte, White"),
|
||||
("metalFrame", "Metal Frame"),
|
||||
("dropShadowRectangle", "Drop Shadow Rectangle"),
|
||||
("roundedDiagonalCornerWhite", "Rounded Diagonal Corner, White"),
|
||||
("snipDiagonalCornerWhite", "Snip Diagonal Corner, White"),
|
||||
("moderateFrameWhite", "Moderate Frame, White"),
|
||||
("rotatedWhite", "Rotated, White"),
|
||||
("perspectiveShadowWhite", "Perspective Shadow, White"),
|
||||
("softEdgeRectangle", "Soft Edge Rectangle"),
|
||||
("doubleFrameBlack", "Double Frame, Black"),
|
||||
("thickMatteBlack", "Thick Matte, Black"),
|
||||
("simpleFrameBlack", "Simple Frame, Black"),
|
||||
("beveledOvalBlack", "Beveled Oval, Black"),
|
||||
("compoundFrameBlack", "Compound Frame, Black"),
|
||||
("centerShadowRectangle", "Center Shadow Rectangle"),
|
||||
("softEdgeOval", "Soft Edge Oval"),
|
||||
("reflectedRoundedRectangle", "Reflected Rounded Rectangle"),
|
||||
("reflectedBevelWhite", "Reflected Bevel, White"),
|
||||
("metalRoundedRectangle", "Metal Rounded Rectangle"),
|
||||
("moderateFrameBlack", "Moderate Frame, Black"),
|
||||
("bevelRectangle", "Bevel Rectangle"),
|
||||
("bevelPerspective", "Bevel Perspective"),
|
||||
("metalOval", "Metal Oval"),
|
||||
];
|
||||
|
||||
/// The look of a style: geometry, outline (colour, width) and effects.
|
||||
pub struct Look {
|
||||
pub preset: &'static str,
|
||||
pub frame: Option<(Rgba, f64)>,
|
||||
pub shadow: Option<&'static str>,
|
||||
pub soft_edge: Option<f64>,
|
||||
pub reflection: bool,
|
||||
pub bevel: Option<&'static str>,
|
||||
pub rot: Option<f64>,
|
||||
}
|
||||
|
||||
pub fn look(id: &str) -> Option<Look> {
|
||||
let white = Rgba::rgb(0xFF, 0xFF, 0xFF);
|
||||
let black = Rgba::rgb(0, 0, 0);
|
||||
let silver = Rgba::rgb(0xC0, 0xC0, 0xC0);
|
||||
let l = |preset, frame, shadow| Look { preset, frame, shadow, soft_edge: None, reflection: false, bevel: None, rot: None };
|
||||
Some(match id {
|
||||
"simpleFrameWhite" => l("rect", Some((white, 7.0)), Some("soft")),
|
||||
"beveledMatteWhite" => Look { bevel: Some("circle"), ..l("rect", Some((white, 6.0)), Some("soft")) },
|
||||
"metalFrame" => Look { bevel: Some("angle"), ..l("rect", Some((silver, 7.0)), Some("soft")) },
|
||||
"dropShadowRectangle" => l("rect", None, Some("drop")),
|
||||
"roundedDiagonalCornerWhite" => l("round2DiagRect", Some((white, 6.0)), Some("soft")),
|
||||
"snipDiagonalCornerWhite" => l("snip2DiagRect", Some((white, 6.0)), Some("soft")),
|
||||
"moderateFrameWhite" => l("rect", Some((white, 4.5)), Some("soft")),
|
||||
"rotatedWhite" => Look { rot: Some(356.0), ..l("rect", Some((white, 6.0)), Some("soft")) },
|
||||
"perspectiveShadowWhite" => l("rect", Some((white, 4.0)), Some("perspective")),
|
||||
"softEdgeRectangle" => Look { soft_edge: Some(10.0), ..l("rect", None, None) },
|
||||
"doubleFrameBlack" => l("rect", Some((black, 6.0)), None),
|
||||
"thickMatteBlack" => l("rect", Some((black, 10.0)), Some("soft")),
|
||||
"simpleFrameBlack" => l("rect", Some((black, 5.0)), None),
|
||||
"beveledOvalBlack" => Look { bevel: Some("circle"), ..l("ellipse", Some((black, 4.0)), Some("soft")) },
|
||||
"compoundFrameBlack" => l("rect", Some((black, 4.5)), None),
|
||||
"centerShadowRectangle" => l("rect", None, Some("center")),
|
||||
"softEdgeOval" => Look { soft_edge: Some(12.0), ..l("ellipse", None, None) },
|
||||
"reflectedRoundedRectangle" => Look { reflection: true, ..l("roundRect", None, None) },
|
||||
"reflectedBevelWhite" => Look { reflection: true, bevel: Some("circle"), ..l("rect", Some((white, 4.0)), None) },
|
||||
"metalRoundedRectangle" => Look { bevel: Some("angle"), ..l("roundRect", Some((silver, 6.0)), Some("soft")) },
|
||||
"moderateFrameBlack" => l("rect", Some((black, 4.5)), Some("soft")),
|
||||
"bevelRectangle" => Look { bevel: Some("angle"), ..l("rect", None, Some("soft")) },
|
||||
"bevelPerspective" => Look { bevel: Some("relaxedInset"), ..l("rect", None, Some("perspective")) },
|
||||
"metalOval" => Look { bevel: Some("angle"), ..l("ellipse", Some((silver, 6.0)), Some("soft")) },
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn shadow(kind: &str) -> Shadow {
|
||||
let base = Shadow {
|
||||
color: ColorRef::rgb(Rgba::BLACK).with(ColorTransform::Alpha(40000)),
|
||||
blur: 4.0,
|
||||
dist: 3.0,
|
||||
dir: 45.0,
|
||||
inner: false,
|
||||
sx: 1.0,
|
||||
sy: 1.0,
|
||||
kx: 0.0,
|
||||
ky: 0.0,
|
||||
align: "tl".into(),
|
||||
rotate_with_shape: false,
|
||||
};
|
||||
match kind {
|
||||
"drop" => Shadow { blur: 12.0, dist: 6.0, dir: 90.0, ..base },
|
||||
"center" => Shadow { blur: 16.0, dist: 0.0, ..base },
|
||||
"perspective" => Shadow { sy: 0.23, kx: -20.0, dir: 90.0, dist: 0.0, blur: 6.0, align: "bl".into(), ..base },
|
||||
_ => base,
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply style `id` (or clear the style with `none`) to `sh`.
|
||||
pub fn apply(sh: &mut Shape, id: &str) -> bool {
|
||||
if id == "none" {
|
||||
sh.geom = Geom::preset("rect");
|
||||
sh.line = None;
|
||||
sh.effects = None;
|
||||
return true;
|
||||
}
|
||||
let Some(lk) = look(id) else { return false };
|
||||
sh.geom = Geom::preset(lk.preset);
|
||||
sh.line = Some(lk.frame.map_or_else(Line::none, |(c, w)| Line::solid(ColorRef::rgb(c), w)));
|
||||
let e = Effects {
|
||||
outer_shadow: lk.shadow.map(shadow),
|
||||
soft_edge: lk.soft_edge,
|
||||
reflection: lk.reflection.then_some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.35, dist: 0.0 }),
|
||||
bevel: lk.bevel.map(str::to_string),
|
||||
..Default::default()
|
||||
};
|
||||
sh.effects = (!e.is_empty()).then_some(e);
|
||||
if let (Some(r), Some(x)) = (lk.rot, sh.xfrm.as_mut()) {
|
||||
x.rot = r;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = str_param(p, "style").ok_or_else(|| bad("picture.style", "missing `style`"))?.to_string();
|
||||
if id != "none" && look(&id).is_none() {
|
||||
return Err(bad("picture.style", format!("unknown style `{id}`; see picture.styles")));
|
||||
}
|
||||
edit_shapes(s, p, "picture.style", |sh| {
|
||||
apply(sh, &id);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,954 @@
|
||||
//! Edit Points (Shape Format ▸ Edit Shape ▸ Edit Points): turn a shape into a freeform and move,
|
||||
//! add or delete the vertices of its outline.
|
||||
//!
|
||||
//! While points are edited every custom path of the shape is kept in shape-local points (its
|
||||
//! `w`/`h` equal the shape's size), so a vertex's path coordinates are its offset in the shape.
|
||||
//! After each change the shape's box is fitted around the outline again, keeping it in place on
|
||||
//! the slide (rotation and flips included).
|
||||
|
||||
use deckcraft_geom::{PathEl, Point, Xfrm};
|
||||
use deckcraft_model::{CustomPath, Geom, Shape, ShapeId, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"shape.editPoints",
|
||||
"Edit Points",
|
||||
["Shape Format", "Insert Shapes", "Edit Shape"],
|
||||
None,
|
||||
"{on?: bool (default: toggle), id?} — enter or leave Edit Points for one selected shape; entering turns a preset into a freeform",
|
||||
has_selection,
|
||||
edit_points
|
||||
),
|
||||
cmd!(query "shape.points", "Points", [], None, "{id?} → {id, paths: [[[x, y] slide pt]], types: [[smooth|straight|corner]], segments: [[straight|curved|null]]} vertices of a freeform and the segment ending at each", has_selection, points),
|
||||
cmd!("shape.movePoint", "Move Point", [], None, "{path?: 0, index, x, y: slide pt, id?}", has_selection, move_point),
|
||||
cmd!(
|
||||
"shape.addPoint",
|
||||
"Add Point",
|
||||
[],
|
||||
None,
|
||||
"{path?: 0, segment: index of the vertex ending it, t?: 0..1 (0.5), id?}",
|
||||
has_selection,
|
||||
add_point
|
||||
),
|
||||
cmd!(
|
||||
"shape.pointType",
|
||||
"Point Type",
|
||||
[],
|
||||
None,
|
||||
"{path?: 0, index, type: smooth|straight|corner, id?} — smooth: handles in line and equal; straight: in line; corner: independent",
|
||||
has_selection,
|
||||
point_type
|
||||
),
|
||||
cmd!(
|
||||
"shape.moveHandle",
|
||||
"Move Handle",
|
||||
[],
|
||||
None,
|
||||
"{path?: 0, index, side: in|out, x, y: slide pt, type?: smooth|straight|corner (default: read from the handles), id?}",
|
||||
has_selection,
|
||||
move_handle
|
||||
),
|
||||
cmd!("shape.deletePoint", "Delete Point", [], None, "{path?: 0, index, id?}", has_selection, delete_point),
|
||||
cmd!(
|
||||
"shape.segmentType",
|
||||
"Segment Type",
|
||||
[],
|
||||
None,
|
||||
"{path?: 0, segment: index of the vertex ending it, type: straight|curved, id?} — straight drops the segment's handles; curved gives it handles at its thirds",
|
||||
has_selection,
|
||||
segment_type
|
||||
),
|
||||
cmd!(
|
||||
"shape.openPath",
|
||||
"Open Path",
|
||||
[],
|
||||
None,
|
||||
"{path?: 0, index, id?} — break a closed outline at a point, which becomes its two ends",
|
||||
has_selection,
|
||||
open_path
|
||||
),
|
||||
cmd!(
|
||||
"shape.closePath",
|
||||
"Close Path",
|
||||
[],
|
||||
None,
|
||||
"{path?: 0, index: any point of the open outline, id?} — join its ends with a straight segment (one point when they meet)",
|
||||
has_selection,
|
||||
close_path
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// One path command, in path units.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Seg {
|
||||
M(Point),
|
||||
L(Point),
|
||||
C(Point, Point, Point),
|
||||
Q(Point, Point),
|
||||
Z,
|
||||
}
|
||||
|
||||
impl Seg {
|
||||
pub fn end(&self) -> Option<Point> {
|
||||
match self {
|
||||
Seg::M(p) | Seg::L(p) | Seg::C(_, _, p) | Seg::Q(_, p) => Some(*p),
|
||||
Seg::Z => None,
|
||||
}
|
||||
}
|
||||
fn points_mut(&mut self) -> Vec<&mut Point> {
|
||||
match self {
|
||||
Seg::M(p) | Seg::L(p) => vec![p],
|
||||
Seg::C(a, b, p) => vec![a, b, p],
|
||||
Seg::Q(a, p) => vec![a, p],
|
||||
Seg::Z => vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses path data (`M x y L x y C … Q … Z`); malformed commands are skipped.
|
||||
pub fn parse(d: &str) -> Vec<Seg> {
|
||||
let toks: Vec<&str> = d.split(|c: char| c.is_whitespace() || c == ',').filter(|t| !t.is_empty()).collect();
|
||||
let num = |i: usize| toks.get(i).and_then(|t| t.parse::<f64>().ok()).filter(|v| v.is_finite());
|
||||
let pt = |i: usize| Some(Point::new(num(i)?, num(i + 1)?));
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < toks.len() && out.len() < 100_000 {
|
||||
let (seg, n) = match toks.get(i).copied().unwrap_or("") {
|
||||
"M" => (pt(i + 1).map(Seg::M), 3),
|
||||
"L" => (pt(i + 1).map(Seg::L), 3),
|
||||
"C" => ((|| Some(Seg::C(pt(i + 1)?, pt(i + 3)?, pt(i + 5)?)))(), 7),
|
||||
"Q" => ((|| Some(Seg::Q(pt(i + 1)?, pt(i + 3)?)))(), 5),
|
||||
"Z" => (Some(Seg::Z), 1),
|
||||
_ => (None, 1),
|
||||
};
|
||||
// Make a closing edge explicit, so it is a segment that can take points.
|
||||
if matches!(seg, Some(Seg::Z)) {
|
||||
let start = out.iter().rev().find_map(|x| if let Seg::M(p) = x { Some(*p) } else { None });
|
||||
let end = out.last().and_then(Seg::end);
|
||||
if let (Some(a), Some(b)) = (start, end)
|
||||
&& (a - b).hypot() > 1e-6
|
||||
{
|
||||
out.push(Seg::L(a));
|
||||
}
|
||||
}
|
||||
if let Some(s) = seg {
|
||||
// A path starts with a move.
|
||||
let s = match (out.is_empty(), s) {
|
||||
(true, Seg::L(p)) => Seg::M(p),
|
||||
(true, Seg::C(..) | Seg::Q(..) | Seg::Z) => {
|
||||
i += n;
|
||||
continue;
|
||||
}
|
||||
(_, s) => s,
|
||||
};
|
||||
out.push(s);
|
||||
}
|
||||
i += n;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Path data for `segs`.
|
||||
pub fn write(segs: &[Seg]) -> String {
|
||||
let f = |v: f64| {
|
||||
let s = format!("{:.3}", v);
|
||||
let s = s.trim_end_matches('0').trim_end_matches('.');
|
||||
if s == "-0" { "0".to_string() } else { s.to_string() }
|
||||
};
|
||||
let p = |q: &Point| format!("{} {}", f(q.x), f(q.y));
|
||||
segs.iter()
|
||||
.map(|s| match s {
|
||||
Seg::M(a) => format!("M {}", p(a)),
|
||||
Seg::L(a) => format!("L {}", p(a)),
|
||||
Seg::C(a, b, c) => format!("C {} {} {}", p(a), p(b), p(c)),
|
||||
Seg::Q(a, b) => format!("Q {} {}", p(a), p(b)),
|
||||
Seg::Z => "Z".into(),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/// Whether segment `i` ends back on its sub-path's start right before a `Z` (the closing edge of
|
||||
/// a closed outline). Its end is the same vertex as the start, not a vertex of its own.
|
||||
fn closes_onto_start(segs: &[Seg], i: usize) -> bool {
|
||||
if !matches!(segs.get(i + 1), Some(Seg::Z)) {
|
||||
return false;
|
||||
}
|
||||
let start = segs.get(..=i).and_then(|s| s.iter().rev().find_map(|x| if let Seg::M(p) = x { Some(*p) } else { None }));
|
||||
match (start, segs.get(i).and_then(Seg::end)) {
|
||||
(Some(a), Some(b)) => !matches!(segs.get(i), Some(Seg::M(_))) && (a - b).hypot() < 1e-6,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The segments whose end is a vertex, in order.
|
||||
pub fn vertices(segs: &[Seg]) -> Vec<usize> {
|
||||
(0..segs.len()).filter(|i| segs.get(*i).and_then(Seg::end).is_some() && !closes_onto_start(segs, *i)).collect()
|
||||
}
|
||||
|
||||
fn shift(segs: &mut [Seg], i: usize, which: &[usize], d: deckcraft_geom::Vec2) {
|
||||
if let Some(s) = segs.get_mut(i) {
|
||||
let mut pts = s.points_mut();
|
||||
for w in which {
|
||||
if let Some(p) = pts.get_mut(*w) {
|
||||
**p += d;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The indices of the points of segment `i` that sit on its end vertex (the end and, for a
|
||||
/// cubic, its incoming control point).
|
||||
fn end_points(seg: Option<&Seg>) -> &'static [usize] {
|
||||
match seg {
|
||||
Some(Seg::C(..)) => &[1, 2],
|
||||
Some(Seg::Q(..)) => &[1],
|
||||
Some(Seg::M(_) | Seg::L(_)) => &[0],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves the vertex ending segment `si` by `d`, with the control points attached to it.
|
||||
fn move_vertex(segs: &mut [Seg], si: usize, d: deckcraft_geom::Vec2) {
|
||||
// A closed sub-path's start is also its closing edge's end.
|
||||
let close = if matches!(segs.get(si), Some(Seg::M(_))) {
|
||||
(si + 1..segs.len()).take_while(|j| !matches!(segs.get(*j), Some(Seg::M(_)))).find(|j| closes_onto_start(segs, *j))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let own = end_points(segs.get(si));
|
||||
shift(segs, si, own, d);
|
||||
// The outgoing curve's first control point follows the vertex.
|
||||
if matches!(segs.get(si + 1), Some(Seg::C(..))) {
|
||||
shift(segs, si + 1, &[0], d);
|
||||
}
|
||||
if let Some(j) = close {
|
||||
let w = end_points(segs.get(j));
|
||||
shift(segs, j, w, d);
|
||||
}
|
||||
}
|
||||
|
||||
/// The point where segment `si` starts.
|
||||
fn seg_start(segs: &[Seg], si: usize) -> Option<Point> {
|
||||
let prev = segs.get(..si)?;
|
||||
match prev.last()? {
|
||||
Seg::Z => prev.iter().rev().find_map(|x| if let Seg::M(p) = x { Some(*p) } else { None }),
|
||||
s => s.end(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Splits segment `si` at `t`, adding a vertex there. Returns false for a move or close.
|
||||
fn split(segs: &mut Vec<Seg>, si: usize, t: f64) -> bool {
|
||||
let Some(p0) = seg_start(segs, si) else { return false };
|
||||
let lerp = |a: Point, b: Point| a.lerp(b, t);
|
||||
let (a, b) = match segs.get(si).copied() {
|
||||
Some(Seg::L(p1)) => (Seg::L(lerp(p0, p1)), Seg::L(p1)),
|
||||
Some(Seg::Q(c, p1)) => {
|
||||
let (m0, m1) = (lerp(p0, c), lerp(c, p1));
|
||||
let m = m0.lerp(m1, t);
|
||||
(Seg::Q(m0, m), Seg::Q(m1, p1))
|
||||
}
|
||||
Some(Seg::C(c1, c2, p1)) => {
|
||||
let (a1, a2, a3) = (lerp(p0, c1), lerp(c1, c2), lerp(c2, p1));
|
||||
let (b1, b2) = (a1.lerp(a2, t), a2.lerp(a3, t));
|
||||
let m = b1.lerp(b2, t);
|
||||
(Seg::C(a1, b1, m), Seg::C(b2, a3, p1))
|
||||
}
|
||||
_ => return false,
|
||||
};
|
||||
if let Some(s) = segs.get_mut(si) {
|
||||
*s = a;
|
||||
}
|
||||
segs.insert(si + 1, b);
|
||||
true
|
||||
}
|
||||
|
||||
/// Removes the vertex ending segment `si`. A sub-path keeps at least two vertices.
|
||||
fn remove_vertex(segs: &mut Vec<Seg>, si: usize) -> bool {
|
||||
// The vertices of `si`'s sub-path.
|
||||
let from = segs.get(..=si).and_then(|s| s.iter().rposition(|x| matches!(x, Seg::M(_)))).unwrap_or(0);
|
||||
let to = (si + 1..segs.len()).find(|j| matches!(segs.get(*j), Some(Seg::M(_)))).unwrap_or(segs.len());
|
||||
let count = vertices(segs).iter().filter(|v| (from..to).contains(*v)).count();
|
||||
if count <= 2 {
|
||||
return false;
|
||||
}
|
||||
if matches!(segs.get(si), Some(Seg::M(_))) {
|
||||
// The next vertex becomes the start; a closed outline's closing edge now ends there.
|
||||
let Some(next) = segs.get(si + 1).and_then(Seg::end) else { return false };
|
||||
segs.remove(si + 1);
|
||||
if let Some(s) = segs.get_mut(si) {
|
||||
*s = Seg::M(next);
|
||||
}
|
||||
let close = (si + 1..segs.len()).take_while(|j| !matches!(segs.get(*j), Some(Seg::M(_)))).find(|j| matches!(segs.get(j + 1), Some(Seg::Z)));
|
||||
if let Some(j) = close
|
||||
&& let Some(Seg::L(p) | Seg::C(_, _, p) | Seg::Q(_, p)) = segs.get_mut(j)
|
||||
{
|
||||
*p = next;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// Merge with the next segment: it now starts where the removed vertex's segment started.
|
||||
match (segs.get(si).copied(), segs.get(si + 1).copied()) {
|
||||
(Some(Seg::C(c1, _, _)), Some(Seg::C(_, c2, p))) => {
|
||||
segs.remove(si + 1);
|
||||
if let Some(s) = segs.get_mut(si) {
|
||||
*s = Seg::C(c1, c2, p);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
segs.remove(si);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The sub-path holding segment `si`: from its move up to the next move (or the end).
|
||||
fn subpath(segs: &[Seg], si: usize) -> (usize, usize) {
|
||||
let from = segs.get(..=si).and_then(|s| s.iter().rposition(|x| matches!(x, Seg::M(_)))).unwrap_or(0);
|
||||
let to = (si + 1..segs.len()).find(|j| matches!(segs.get(*j), Some(Seg::M(_)))).unwrap_or(segs.len());
|
||||
(from, to)
|
||||
}
|
||||
|
||||
/// The segment that ends at the vertex ending segment `si`: the closing edge for a closed
|
||||
/// sub-path's start, none for an open one's.
|
||||
fn ending_segment(segs: &[Seg], si: usize) -> Option<usize> {
|
||||
if matches!(segs.get(si), Some(Seg::M(_))) {
|
||||
(si + 1..segs.len()).take_while(|j| !matches!(segs.get(*j), Some(Seg::M(_)))).find(|j| closes_onto_start(segs, *j))
|
||||
} else {
|
||||
Some(si)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether segment `j` is a straight line or a curve.
|
||||
fn segment_kind(segs: &[Seg], j: usize) -> Option<&'static str> {
|
||||
match segs.get(j)? {
|
||||
Seg::L(_) => Some("straight"),
|
||||
Seg::C(..) | Seg::Q(..) => Some("curved"),
|
||||
Seg::M(_) | Seg::Z => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Makes segment `j` a straight line (dropping its handles) or a curve with handles at its
|
||||
/// thirds, so it keeps its shape until a handle is dragged.
|
||||
fn set_segment(segs: &mut [Seg], j: usize, curved: bool) {
|
||||
if curved {
|
||||
to_cubic(segs, j);
|
||||
} else if let Some(s) = segs.get_mut(j)
|
||||
&& let Seg::C(_, _, p) | Seg::Q(_, p) = *s
|
||||
{
|
||||
*s = Seg::L(p);
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the closed sub-path through the vertex ending segment `si` there: it now starts and ends
|
||||
/// on that vertex, as two points. Returns false for an open sub-path.
|
||||
fn open_at(segs: &mut Vec<Seg>, si: usize) -> bool {
|
||||
let (from, to) = subpath(segs, si);
|
||||
let Some(z) = to.checked_sub(1).filter(|z| *z > si && matches!(segs.get(*z), Some(Seg::Z))) else { return false };
|
||||
let Some(v) = segs.get(si).and_then(Seg::end) else { return false };
|
||||
let (Some(after), Some(before)) = (segs.get(si + 1..z), segs.get(from + 1..=si)) else { return false };
|
||||
let mut out = vec![Seg::M(v)];
|
||||
out.extend_from_slice(after);
|
||||
out.extend_from_slice(before);
|
||||
segs.splice(from..to, out);
|
||||
true
|
||||
}
|
||||
|
||||
/// Closes the open sub-path through the vertex ending segment `si`: a straight segment joins its
|
||||
/// ends, or they become one point when they meet. Returns false for a closed sub-path.
|
||||
fn close_at(segs: &mut Vec<Seg>, si: usize) -> bool {
|
||||
let (from, to) = subpath(segs, si);
|
||||
let last = to.saturating_sub(1);
|
||||
if last <= from || matches!(segs.get(last), Some(Seg::Z)) {
|
||||
return false;
|
||||
}
|
||||
let (Some(Seg::M(start)), Some(end)) = (segs.get(from).copied(), segs.get(last).and_then(Seg::end)) else { return false };
|
||||
let mut add = Vec::new();
|
||||
if (start - end).hypot() >= 1e-6 {
|
||||
add.push(Seg::L(start));
|
||||
}
|
||||
add.push(Seg::Z);
|
||||
segs.splice(to..to, add);
|
||||
true
|
||||
}
|
||||
|
||||
/// Custom paths of a preset shape, in shape-local points.
|
||||
fn to_custom(sh: &Shape, w: f64, h: f64) -> Vec<CustomPath> {
|
||||
let geo = deckcraft_render::shape_geometry(sh, w, h);
|
||||
geo.paths
|
||||
.iter()
|
||||
.map(|sp| {
|
||||
let segs: Vec<Seg> = sp
|
||||
.path
|
||||
.elements()
|
||||
.iter()
|
||||
.map(|e| match *e {
|
||||
PathEl::MoveTo(p) => Seg::M(p),
|
||||
PathEl::LineTo(p) => Seg::L(p),
|
||||
PathEl::QuadTo(a, p) => Seg::Q(a, p),
|
||||
PathEl::CurveTo(a, b, p) => Seg::C(a, b, p),
|
||||
PathEl::ClosePath => Seg::Z,
|
||||
})
|
||||
.collect();
|
||||
CustomPath { w, h, d: write(&segs), fill: sp.fill, stroke: sp.stroke }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Rescales every path of a custom geometry to shape-local points.
|
||||
fn normalize(paths: &mut [CustomPath], w: f64, h: f64) {
|
||||
for p in paths {
|
||||
let (sx, sy) = (if p.w > 0.0 { w / p.w } else { 1.0 }, if p.h > 0.0 { h / p.h } else { 1.0 });
|
||||
if (sx - 1.0).abs() > 1e-12 || (sy - 1.0).abs() > 1e-12 {
|
||||
let mut segs = parse(&p.d);
|
||||
for s in &mut segs {
|
||||
for q in s.points_mut() {
|
||||
*q = Point::new(q.x * sx, q.y * sy);
|
||||
}
|
||||
}
|
||||
p.d = write(&segs);
|
||||
}
|
||||
p.w = w;
|
||||
p.h = h;
|
||||
}
|
||||
}
|
||||
|
||||
/// Fits the box around the outline, keeping the drawing where it is on the slide.
|
||||
fn refit(sh: &mut Shape) {
|
||||
let (Some(x), Geom::Custom { paths }) = (sh.xfrm, &mut sh.geom) else { return };
|
||||
let mut all: Vec<Vec<Seg>> = paths.iter().map(|p| parse(&p.d)).collect();
|
||||
let mut bb: Option<deckcraft_geom::Rect> = None;
|
||||
for s in all.iter_mut().flatten() {
|
||||
for q in s.points_mut() {
|
||||
bb = Some(bb.map_or(deckcraft_geom::Rect::from_points(*q, *q), |b| b.union_pt(*q)));
|
||||
}
|
||||
}
|
||||
let Some(bb) = bb else { return };
|
||||
let (w, h) = (bb.width().max(1.0), bb.height().max(1.0));
|
||||
if bb.x0.abs() < 1e-6 && bb.y0.abs() < 1e-6 && (w - x.w).abs() < 1e-6 && (h - x.h).abs() < 1e-6 {
|
||||
return;
|
||||
}
|
||||
let c = x.affine() * Point::new(bb.x0 + w / 2.0, bb.y0 + h / 2.0);
|
||||
let off = deckcraft_geom::Vec2::new(bb.x0, bb.y0);
|
||||
for (p, segs) in paths.iter_mut().zip(&mut all) {
|
||||
for s in segs.iter_mut() {
|
||||
for q in s.points_mut() {
|
||||
*q -= off;
|
||||
}
|
||||
}
|
||||
p.d = write(segs);
|
||||
p.w = w;
|
||||
p.h = h;
|
||||
}
|
||||
sh.xfrm = Some(Xfrm { x: c.x - w / 2.0, y: c.y - h / 2.0, w, h, ..x });
|
||||
}
|
||||
|
||||
/// The one shape whose points are edited: `id`, or the single selected shape.
|
||||
fn target(s: &Session, p: &Value, cmd: &str) -> Result<ShapeId> {
|
||||
let st = s.doc()?;
|
||||
let id = match id_param(p, "id", cmd)? {
|
||||
Some(i) => i,
|
||||
None => match st.selection.shapes.as_slice() {
|
||||
[one] => *one,
|
||||
_ => return Err(bad(cmd, "select one shape")),
|
||||
},
|
||||
};
|
||||
let sh = st.shape(id).ok_or_else(|| bad(cmd, "no such shape"))?;
|
||||
if !st.shapes().iter().any(|t| t.id == id) {
|
||||
return Err(bad(cmd, "a shape in a group can't have its points edited; ungroup it first"));
|
||||
}
|
||||
if !matches!(sh.kind, ShapeKind::Shape) || sh.is_line() || sh.xfrm.is_none() {
|
||||
return Err(bad(cmd, "only a shape with an outline has points to edit"));
|
||||
}
|
||||
if sh.locked {
|
||||
return Err(bad(cmd, "the shape is locked"));
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Makes `id` a freeform in shape-local points (the state Edit Points works in).
|
||||
pub(crate) fn prepare(s: &mut Session, id: ShapeId) -> Result<()> {
|
||||
let st = s.doc()?;
|
||||
let Some(sh) = st.shape(id) else { return Ok(()) };
|
||||
let Some(x) = sh.xfrm else { return Ok(()) };
|
||||
let ready = matches!(&sh.geom, Geom::Custom { paths } if paths.iter().all(|p| p.w == x.w && p.h == x.h));
|
||||
if ready {
|
||||
return Ok(());
|
||||
}
|
||||
s.edit(|doc, sel| {
|
||||
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("shape.editPoints", "no slide"))?;
|
||||
if let Some(sh) = deckcraft_model::find_shape_mut(list, id) {
|
||||
match &mut sh.geom {
|
||||
Geom::Custom { paths } => normalize(paths, x.w, x.h),
|
||||
Geom::Preset { .. } => {
|
||||
let paths = to_custom(sh, x.w, x.h);
|
||||
sh.geom = Geom::Custom { paths };
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn edit_points(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let on = p.get("on").and_then(Value::as_bool).unwrap_or(s.tool.edit_points.is_none());
|
||||
s.tool.edit_vertex = None;
|
||||
s.tool.edit_segment = None;
|
||||
if !on {
|
||||
s.tool.edit_points = None;
|
||||
return Ok(json!({"editing": Value::Null}));
|
||||
}
|
||||
let id = target(s, p, "shape.editPoints")?;
|
||||
prepare(s, id)?;
|
||||
s.tool.edit_points = Some(id);
|
||||
Ok(json!({"editing": id.0}))
|
||||
}
|
||||
|
||||
/// Vertices of every path of `sh` in slide points.
|
||||
pub fn slide_vertices(doc: &deckcraft_model::Presentation, sel: &crate::Selection, sh: &Shape) -> Vec<Vec<Point>> {
|
||||
let Geom::Custom { paths } = &sh.geom else { return vec![] };
|
||||
let x = xfrm_of(doc, sel, sh);
|
||||
let a = x.affine();
|
||||
paths
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let (sx, sy) = (if p.w > 0.0 { x.w / p.w } else { 1.0 }, if p.h > 0.0 { x.h / p.h } else { 1.0 });
|
||||
let segs = parse(&p.d);
|
||||
vertices(&segs).iter().filter_map(|i| segs.get(*i).and_then(Seg::end)).map(|q| a * Point::new(q.x * sx, q.y * sy)).collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn points(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let id = match id_param(p, "id", "shape.points")? {
|
||||
Some(i) => i,
|
||||
None => *st.selection.shapes.first().ok_or_else(|| bad("shape.points", "select a shape"))?,
|
||||
};
|
||||
let sh = st.shape(id).ok_or_else(|| bad("shape.points", "no such shape"))?;
|
||||
let paths: Vec<Vec<[f64; 2]>> =
|
||||
slide_vertices(&st.doc, &st.selection, sh).into_iter().map(|v| v.into_iter().map(|q| [q.x, q.y]).collect()).collect();
|
||||
let (mut types, mut kinds): (Vec<Vec<&str>>, Vec<Vec<Option<&str>>>) = (vec![], vec![]);
|
||||
if let Geom::Custom { paths } = &sh.geom {
|
||||
for cp in paths {
|
||||
let segs = parse(&cp.d);
|
||||
let vs = vertices(&segs);
|
||||
types.push(vs.iter().map(|si| point_kind(&segs, *si)).collect());
|
||||
kinds.push(vs.iter().map(|si| ending_segment(&segs, *si).and_then(|j| segment_kind(&segs, j))).collect());
|
||||
}
|
||||
}
|
||||
Ok(json!({"id": id.0, "paths": paths, "types": types, "segments": kinds}))
|
||||
}
|
||||
|
||||
/// Runs `f` on the segments of path `path` of `id` (in shape-local points), then refits the box.
|
||||
/// `f` gets the segment indices of the vertices and returns whether it changed anything.
|
||||
pub(crate) fn edit_path(
|
||||
s: &mut Session,
|
||||
id: ShapeId,
|
||||
path: usize,
|
||||
cmd: &'static str,
|
||||
f: impl Fn(&mut Vec<Seg>, &[usize], &Xfrm) -> Result<bool>,
|
||||
) -> Result<Value> {
|
||||
prepare(s, id)?;
|
||||
s.edit(|doc, sel| {
|
||||
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad(cmd, "no slide"))?;
|
||||
let sh = deckcraft_model::find_shape_mut(list, id).ok_or_else(|| bad(cmd, "no such shape"))?;
|
||||
let x = sh.xfrm.ok_or_else(|| bad(cmd, "the shape has no position"))?;
|
||||
let Geom::Custom { paths } = &mut sh.geom else { return Err(bad(cmd, "not a freeform")) };
|
||||
let cp = paths.get_mut(path).ok_or_else(|| bad(cmd, "no such path"))?;
|
||||
let mut segs = parse(&cp.d);
|
||||
let vs = vertices(&segs);
|
||||
if f(&mut segs, &vs, &x)? {
|
||||
cp.d = write(&segs);
|
||||
refit(sh);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
fn index_param(p: &Value, key: &str, cmd: &str) -> Result<usize> {
|
||||
p.get(key).and_then(Value::as_u64).map(|v| v as usize).ok_or_else(|| bad(cmd, format!("give `{key}`")))
|
||||
}
|
||||
|
||||
fn move_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = target(s, p, "shape.movePoint")?;
|
||||
let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let index = index_param(p, "index", "shape.movePoint")?;
|
||||
let to = match (f64_param(p, "x"), f64_param(p, "y")) {
|
||||
(Some(x), Some(y)) if x.is_finite() && y.is_finite() => Point::new(x.clamp(-1e5, 1e5), y.clamp(-1e5, 1e5)),
|
||||
_ => return Err(bad("shape.movePoint", "give `x` and `y` in slide points")),
|
||||
};
|
||||
edit_path(s, id, path, "shape.movePoint", move |segs, vs, x| {
|
||||
let si = *vs.get(index).ok_or_else(|| bad("shape.movePoint", "no such point"))?;
|
||||
let local = x.affine().inverse() * to;
|
||||
let Some(cur) = segs.get(si).and_then(Seg::end) else { return Ok(false) };
|
||||
move_vertex(segs, si, local - cur);
|
||||
Ok(true)
|
||||
})
|
||||
}
|
||||
|
||||
fn add_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = target(s, p, "shape.addPoint")?;
|
||||
let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let seg = index_param(p, "segment", "shape.addPoint")?;
|
||||
let t = f64_param(p, "t").filter(|t| t.is_finite()).unwrap_or(0.5).clamp(0.01, 0.99);
|
||||
edit_path(s, id, path, "shape.addPoint", move |segs, vs, _| {
|
||||
let si = *vs.get(seg).ok_or_else(|| bad("shape.addPoint", "no such segment"))?;
|
||||
// The closing edge of a closed outline ends at the start: split it, not the start.
|
||||
let si = if matches!(segs.get(si), Some(Seg::M(_))) {
|
||||
(si + 1..segs.len()).find(|j| closes_onto_start(segs, *j)).ok_or_else(|| bad("shape.addPoint", "the first point ends no segment"))?
|
||||
} else {
|
||||
si
|
||||
};
|
||||
if split(segs, si, t) { Ok(true) } else { Err(bad("shape.addPoint", "no segment ends at that point")) }
|
||||
})
|
||||
}
|
||||
|
||||
fn delete_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = target(s, p, "shape.deletePoint")?;
|
||||
let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let index = index_param(p, "index", "shape.deletePoint")?;
|
||||
edit_path(s, id, path, "shape.deletePoint", move |segs, vs, _| {
|
||||
let si = *vs.get(index).ok_or_else(|| bad("shape.deletePoint", "no such point"))?;
|
||||
if remove_vertex(segs, si) { Ok(true) } else { Err(bad("shape.deletePoint", "an outline keeps at least two points")) }
|
||||
})
|
||||
}
|
||||
|
||||
/// The vertex of `sh` near slide point `p`: (path, index).
|
||||
pub fn vertex_at(doc: &deckcraft_model::Presentation, sel: &crate::Selection, sh: &Shape, p: Point, tol: f64) -> Option<(usize, usize)> {
|
||||
for (pi, vs) in slide_vertices(doc, sel, sh).iter().enumerate() {
|
||||
for (i, q) in vs.iter().enumerate() {
|
||||
if (*q - p).hypot() <= tol {
|
||||
return Some((pi, i));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The segment of `sh`'s outline near slide point `p`: (path, index of the vertex ending it, t).
|
||||
pub fn segment_at(doc: &deckcraft_model::Presentation, sel: &crate::Selection, sh: &Shape, p: Point, tol: f64) -> Option<(usize, usize, f64)> {
|
||||
let Geom::Custom { paths } = &sh.geom else { return None };
|
||||
let x = xfrm_of(doc, sel, sh);
|
||||
let local = x.affine().inverse() * p;
|
||||
let mut best: Option<(f64, usize, usize, f64)> = None;
|
||||
for (pi, cp) in paths.iter().enumerate() {
|
||||
let (sx, sy) = (if cp.w > 0.0 { x.w / cp.w } else { 1.0 }, if cp.h > 0.0 { x.h / cp.h } else { 1.0 });
|
||||
let segs = parse(&cp.d);
|
||||
let vs = vertices(&segs);
|
||||
for si in 0..segs.len() {
|
||||
let Some(p0) = seg_start(&segs, si) else { continue };
|
||||
let at = |t: f64| -> Option<Point> {
|
||||
let q = match segs.get(si)? {
|
||||
Seg::L(p1) => p0.lerp(*p1, t),
|
||||
Seg::Q(c, p1) => p0.lerp(*c, t).lerp(c.lerp(*p1, t), t),
|
||||
Seg::C(c1, c2, p1) => {
|
||||
let (a, b, c) = (p0.lerp(*c1, t), c1.lerp(*c2, t), c2.lerp(*p1, t));
|
||||
a.lerp(b, t).lerp(b.lerp(c, t), t)
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(Point::new(q.x * sx, q.y * sy))
|
||||
};
|
||||
// The vertex that ends this segment (a closing edge ends on the start).
|
||||
let end_vertex = if closes_onto_start(&segs, si) {
|
||||
let start = segs.get(..si).and_then(|s| s.iter().rposition(|x| matches!(x, Seg::M(_))));
|
||||
start.and_then(|m| vs.iter().position(|v| *v == m))
|
||||
} else {
|
||||
vs.iter().position(|v| *v == si)
|
||||
};
|
||||
let Some(ev) = end_vertex else { continue };
|
||||
for k in 1..32 {
|
||||
let t = k as f64 / 32.0;
|
||||
if let Some(q) = at(t) {
|
||||
let d = (q - local).hypot();
|
||||
if d <= tol && best.is_none_or(|b| d < b.0) {
|
||||
best = Some((d, pi, ev, t));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, pi, ev, t)| (pi, ev, t))
|
||||
}
|
||||
|
||||
/// Moves vertex `index` of path `path` of `id` to slide point `to` (pointer drags).
|
||||
pub(crate) fn move_point_raw(s: &mut Session, id: ShapeId, path: usize, index: usize, to: Point) -> Result<Value> {
|
||||
move_point(s, &json!({"id": id.0, "path": path, "index": index, "x": to.x, "y": to.y}))
|
||||
}
|
||||
|
||||
/// Where the control handles of the vertex ending segment `si` live: (segment, point index) for
|
||||
/// the incoming and the outgoing one, when those segments are cubics.
|
||||
fn handle_slots(segs: &[Seg], si: usize) -> (Option<(usize, usize)>, Option<(usize, usize)>) {
|
||||
let incoming = if matches!(segs.get(si), Some(Seg::M(_))) {
|
||||
(si + 1..segs.len()).take_while(|j| !matches!(segs.get(*j), Some(Seg::M(_)))).find(|j| closes_onto_start(segs, *j))
|
||||
} else {
|
||||
Some(si)
|
||||
};
|
||||
let inc = incoming.filter(|j| matches!(segs.get(*j), Some(Seg::C(..)))).map(|j| (j, 1));
|
||||
let out = Some(si + 1).filter(|j| matches!(segs.get(*j), Some(Seg::C(..)))).map(|j| (j, 0));
|
||||
(inc, out)
|
||||
}
|
||||
|
||||
fn get_pt(segs: &[Seg], slot: (usize, usize)) -> Option<Point> {
|
||||
match segs.get(slot.0)? {
|
||||
Seg::C(a, b, c) => [*a, *b, *c].get(slot.1).copied(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn set_pt(segs: &mut [Seg], slot: (usize, usize), p: Point) {
|
||||
if let Some(s) = segs.get_mut(slot.0)
|
||||
&& let Some(q) = s.points_mut().into_iter().nth(slot.1)
|
||||
{
|
||||
*q = p;
|
||||
}
|
||||
}
|
||||
|
||||
/// Turns segment `j` into a cubic with the same shape.
|
||||
fn to_cubic(segs: &mut [Seg], j: usize) {
|
||||
let Some(p0) = seg_start(segs, j) else { return };
|
||||
let c = match segs.get(j).copied() {
|
||||
Some(Seg::L(p1)) => Seg::C(p0.lerp(p1, 1.0 / 3.0), p0.lerp(p1, 2.0 / 3.0), p1),
|
||||
Some(Seg::Q(c, p1)) => Seg::C(p0.lerp(c, 2.0 / 3.0), p1.lerp(c, 2.0 / 3.0), p1),
|
||||
_ => return,
|
||||
};
|
||||
if let Some(s) = segs.get_mut(j) {
|
||||
*s = c;
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the point type of the vertex ending segment `si`. Its segments become curves so it has
|
||||
/// handles; smooth and straight points get their handles in line through the vertex.
|
||||
fn set_type(segs: &mut [Seg], si: usize, kind: &str) {
|
||||
let incoming = if matches!(segs.get(si), Some(Seg::M(_))) {
|
||||
(si + 1..segs.len()).take_while(|j| !matches!(segs.get(*j), Some(Seg::M(_)))).find(|j| closes_onto_start(segs, *j))
|
||||
} else {
|
||||
Some(si)
|
||||
};
|
||||
if let Some(j) = incoming {
|
||||
to_cubic(segs, j);
|
||||
}
|
||||
to_cubic(segs, si + 1);
|
||||
if kind == "corner" {
|
||||
return;
|
||||
}
|
||||
let (Some(v), (Some(hi), Some(ho))) = (segs.get(si).and_then(Seg::end), handle_slots(segs, si)) else { return };
|
||||
let (Some(a), Some(b)) = (get_pt(segs, hi), get_pt(segs, ho)) else { return };
|
||||
let (li, lo) = ((a - v).hypot(), (b - v).hypot());
|
||||
let mut dir = b - a;
|
||||
if dir.hypot() < 1e-9 {
|
||||
// Both handles on the vertex: point them along the neighbours.
|
||||
let prev = seg_start(segs, hi.0).unwrap_or(v);
|
||||
let next = segs.get(ho.0).and_then(Seg::end).unwrap_or(v);
|
||||
dir = next - prev;
|
||||
}
|
||||
let len = dir.hypot();
|
||||
if len < 1e-9 {
|
||||
return;
|
||||
}
|
||||
let u = dir / len;
|
||||
let (li, lo) = if kind == "smooth" {
|
||||
let m = if li + lo > 1e-9 { (li + lo) / 2.0 } else { len / 6.0 };
|
||||
(m, m)
|
||||
} else {
|
||||
(li, lo)
|
||||
};
|
||||
set_pt(segs, hi, v - u * li);
|
||||
set_pt(segs, ho, v + u * lo);
|
||||
}
|
||||
|
||||
/// The type a vertex's handles show: smooth (in line, equal), straight (in line) or corner.
|
||||
pub fn point_kind(segs: &[Seg], si: usize) -> &'static str {
|
||||
let (Some(v), (Some(hi), Some(ho))) = (segs.get(si).and_then(Seg::end), handle_slots(segs, si)) else { return "corner" };
|
||||
let (Some(a), Some(b)) = (get_pt(segs, hi), get_pt(segs, ho)) else { return "corner" };
|
||||
let (da, db) = (a - v, b - v);
|
||||
let (la, lb) = (da.hypot(), db.hypot());
|
||||
if la < 1e-6 || lb < 1e-6 {
|
||||
return "corner";
|
||||
}
|
||||
let cos = da.dot(db) / (la * lb);
|
||||
if cos > -0.9998 {
|
||||
"corner"
|
||||
} else if (la - lb).abs() <= 0.01 * la.max(lb) {
|
||||
"smooth"
|
||||
} else {
|
||||
"straight"
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves handle `side` of the vertex ending segment `si` to `p`; the other handle follows as
|
||||
/// the point's type says.
|
||||
fn set_handle(segs: &mut [Seg], si: usize, out: bool, p: Point, kind: Option<&str>) -> bool {
|
||||
let kind = kind.unwrap_or_else(|| point_kind(segs, si));
|
||||
let (hi, ho) = handle_slots(segs, si);
|
||||
let (mine, other) = if out { (ho, hi) } else { (hi, ho) };
|
||||
let Some(mine) = mine else { return false };
|
||||
let Some(v) = segs.get(si).and_then(Seg::end) else { return false };
|
||||
set_pt(segs, mine, p);
|
||||
if let Some(o) = other
|
||||
&& kind != "corner"
|
||||
&& let Some(q) = get_pt(segs, o)
|
||||
{
|
||||
let d = p - v;
|
||||
let l = d.hypot();
|
||||
if l > 1e-9 {
|
||||
let lo = if kind == "smooth" { l } else { (q - v).hypot() };
|
||||
set_pt(segs, o, v - d / l * lo);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Handles of vertex `index` of path `path` of `sh` in slide points: (incoming, outgoing, vertex).
|
||||
pub fn slide_handles(
|
||||
doc: &deckcraft_model::Presentation,
|
||||
sel: &crate::Selection,
|
||||
sh: &Shape,
|
||||
path: usize,
|
||||
index: usize,
|
||||
) -> Option<(Option<Point>, Option<Point>, Point)> {
|
||||
let Geom::Custom { paths } = &sh.geom else { return None };
|
||||
let cp = paths.get(path)?;
|
||||
let x = xfrm_of(doc, sel, sh);
|
||||
let (sx, sy) = (if cp.w > 0.0 { x.w / cp.w } else { 1.0 }, if cp.h > 0.0 { x.h / cp.h } else { 1.0 });
|
||||
let a = x.affine();
|
||||
let to = |q: Point| a * Point::new(q.x * sx, q.y * sy);
|
||||
let segs = parse(&cp.d);
|
||||
let si = *vertices(&segs).get(index)?;
|
||||
let v = segs.get(si).and_then(Seg::end)?;
|
||||
let (hi, ho) = handle_slots(&segs, si);
|
||||
Some((hi.and_then(|s| get_pt(&segs, s)).map(to), ho.and_then(|s| get_pt(&segs, s)).map(to), to(v)))
|
||||
}
|
||||
|
||||
fn point_type(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = target(s, p, "shape.pointType")?;
|
||||
let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let index = index_param(p, "index", "shape.pointType")?;
|
||||
let kind = match str_param(p, "type") {
|
||||
Some(k @ ("smooth" | "straight" | "corner")) => k.to_string(),
|
||||
_ => return Err(bad("shape.pointType", "`type` is smooth, straight or corner")),
|
||||
};
|
||||
// A straight point's handles can be as long as each other; remember the choice for drags.
|
||||
s.tool.vertex_kind = Some((path, index, kind.clone()));
|
||||
edit_path(s, id, path, "shape.pointType", move |segs, vs, _| {
|
||||
let si = *vs.get(index).ok_or_else(|| bad("shape.pointType", "no such point"))?;
|
||||
set_type(segs, si, &kind);
|
||||
Ok(true)
|
||||
})
|
||||
}
|
||||
|
||||
fn move_handle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = target(s, p, "shape.moveHandle")?;
|
||||
let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let index = index_param(p, "index", "shape.moveHandle")?;
|
||||
let out = match str_param(p, "side") {
|
||||
Some("out") => true,
|
||||
Some("in") => false,
|
||||
_ => return Err(bad("shape.moveHandle", "`side` is in or out")),
|
||||
};
|
||||
let to = match (f64_param(p, "x"), f64_param(p, "y")) {
|
||||
(Some(x), Some(y)) if x.is_finite() && y.is_finite() => Point::new(x.clamp(-1e5, 1e5), y.clamp(-1e5, 1e5)),
|
||||
_ => return Err(bad("shape.moveHandle", "give `x` and `y` in slide points")),
|
||||
};
|
||||
let kind = str_param(p, "type").filter(|k| matches!(*k, "smooth" | "straight" | "corner")).map(str::to_string);
|
||||
edit_path(s, id, path, "shape.moveHandle", move |segs, vs, x| {
|
||||
let si = *vs.get(index).ok_or_else(|| bad("shape.moveHandle", "no such point"))?;
|
||||
if set_handle(segs, si, out, x.affine().inverse() * to, kind.as_deref()) {
|
||||
Ok(true)
|
||||
} else {
|
||||
Err(bad("shape.moveHandle", "that side of the point has no handle"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The type of vertex `index` of path `path` of `sh`.
|
||||
pub fn kind_of(sh: &Shape, path: usize, index: usize) -> Option<&'static str> {
|
||||
let Geom::Custom { paths } = &sh.geom else { return None };
|
||||
let segs = parse(&paths.get(path)?.d);
|
||||
let si = *vertices(&segs).get(index)?;
|
||||
Some(point_kind(&segs, si))
|
||||
}
|
||||
|
||||
/// `shape.moveHandle` for pointer drags.
|
||||
pub(crate) fn move_handle_raw(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
move_handle(s, p)
|
||||
}
|
||||
|
||||
fn segment_type(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = target(s, p, "shape.segmentType")?;
|
||||
let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let seg = index_param(p, "segment", "shape.segmentType")?;
|
||||
let curved = match str_param(p, "type") {
|
||||
Some("straight") => false,
|
||||
Some("curved") => true,
|
||||
_ => return Err(bad("shape.segmentType", "`type` is straight or curved")),
|
||||
};
|
||||
// Already that type: nothing to change, so no undo step.
|
||||
let want = if curved { "curved" } else { "straight" };
|
||||
if s.doc()?.shape(id).is_some_and(|sh| segment_kind_of(sh, path, seg) == Some(want)) {
|
||||
return ok();
|
||||
}
|
||||
edit_path(s, id, path, "shape.segmentType", move |segs, vs, _| {
|
||||
let si = *vs.get(seg).ok_or_else(|| bad("shape.segmentType", "no such segment"))?;
|
||||
let j = ending_segment(segs, si).ok_or_else(|| bad("shape.segmentType", "the first point of an open outline ends no segment"))?;
|
||||
set_segment(segs, j, curved);
|
||||
Ok(true)
|
||||
})
|
||||
}
|
||||
|
||||
fn open_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = target(s, p, "shape.openPath")?;
|
||||
let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let index = index_param(p, "index", "shape.openPath")?;
|
||||
let r = edit_path(s, id, path, "shape.openPath", move |segs, vs, _| {
|
||||
let si = *vs.get(index).ok_or_else(|| bad("shape.openPath", "no such point"))?;
|
||||
if open_at(segs, si) { Ok(true) } else { Err(bad("shape.openPath", "the outline is open already")) }
|
||||
})?;
|
||||
forget_kinds(s, path);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
fn close_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = target(s, p, "shape.closePath")?;
|
||||
let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let index = index_param(p, "index", "shape.closePath")?;
|
||||
let r = edit_path(s, id, path, "shape.closePath", move |segs, vs, _| {
|
||||
let si = *vs.get(index).ok_or_else(|| bad("shape.closePath", "no such point"))?;
|
||||
if close_at(segs, si) { Ok(true) } else { Err(bad("shape.closePath", "the outline is closed already")) }
|
||||
})?;
|
||||
forget_kinds(s, path);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Opening or closing renumbers the points of `path`: a point type remembered for one of them
|
||||
/// would follow the wrong point.
|
||||
fn forget_kinds(s: &mut Session, path: usize) {
|
||||
if s.tool.vertex_kind.as_ref().is_some_and(|k| k.0 == path) {
|
||||
s.tool.vertex_kind = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the segment ending at vertex `index` of path `path` of `sh` is straight or curved.
|
||||
pub fn segment_kind_of(sh: &Shape, path: usize, index: usize) -> Option<&'static str> {
|
||||
let Geom::Custom { paths } = &sh.geom else { return None };
|
||||
let segs = parse(&paths.get(path)?.d);
|
||||
let si = *vertices(&segs).get(index)?;
|
||||
segment_kind(&segs, ending_segment(&segs, si)?)
|
||||
}
|
||||
|
||||
/// Whether the outline through vertex `index` of path `path` of `sh` is closed.
|
||||
pub fn closed_at(sh: &Shape, path: usize, index: usize) -> Option<bool> {
|
||||
let Geom::Custom { paths } = &sh.geom else { return None };
|
||||
let segs = parse(&paths.get(path)?.d);
|
||||
let si = *vertices(&segs).get(index)?;
|
||||
let (_, to) = subpath(&segs, si);
|
||||
Some(matches!(segs.get(to.checked_sub(1)?), Some(Seg::Z)))
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
//! Picture Format ▸ Color: Recolor presets and Set Transparent Color.
|
||||
|
||||
use deckcraft_color::{Rgba, SchemeSlot};
|
||||
use deckcraft_geom::{Affine, Point, Rect, Xfrm};
|
||||
use deckcraft_model::style::{PictureAdjust, PictureMode};
|
||||
use deckcraft_model::{ColorRef, Shape, ShapeId, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"picture.recolor",
|
||||
"Recolor",
|
||||
["Picture Format", "Adjust", "Color"],
|
||||
None,
|
||||
"{preset: none|grayscale|sepia|washout|bw25|bw50|bw75|accent1Dark…accent6Dark|accent1Light…accent6Light|tx2Dark|tx2Light (see picture.recolors), ids?}",
|
||||
has_selection,
|
||||
recolor
|
||||
),
|
||||
cmd!(query "picture.recolors", "Recolor Gallery", [], None, "{} → [{id, label}]", always, |_s, _p| Ok(json!(
|
||||
presets().into_iter().map(|(id, label)| json!({"id": id, "label": label})).collect::<Vec<_>>()
|
||||
))),
|
||||
cmd!(
|
||||
"picture.transparentColor",
|
||||
"Set Transparent Color",
|
||||
["Picture Format", "Adjust", "Color"],
|
||||
None,
|
||||
"{color: #RRGGBB | at: [x, y] | none: true, ids?} (pixels of that colour become transparent; `at` takes the colour of the picture's own pixel under that slide point, as a click does in PowerPoint)",
|
||||
has_selection,
|
||||
transparent_color
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
const ACCENTS: [(&str, &str, SchemeSlot); 7] = [
|
||||
("tx2", "Text color 2", SchemeSlot::Dk2),
|
||||
("accent1", "Accent color 1", SchemeSlot::Accent1),
|
||||
("accent2", "Accent color 2", SchemeSlot::Accent2),
|
||||
("accent3", "Accent color 3", SchemeSlot::Accent3),
|
||||
("accent4", "Accent color 4", SchemeSlot::Accent4),
|
||||
("accent5", "Accent color 5", SchemeSlot::Accent5),
|
||||
("accent6", "Accent color 6", SchemeSlot::Accent6),
|
||||
];
|
||||
|
||||
/// (id, label), in PowerPoint's gallery order: colour modes, then dark and light variations.
|
||||
pub fn presets() -> Vec<(String, String)> {
|
||||
let mut v: Vec<(String, String)> = [
|
||||
("none", "No Recolor"),
|
||||
("grayscale", "Grayscale"),
|
||||
("sepia", "Sepia"),
|
||||
("washout", "Washout"),
|
||||
("bw25", "Black and White: 25%"),
|
||||
("bw50", "Black and White: 50%"),
|
||||
("bw75", "Black and White: 75%"),
|
||||
]
|
||||
.iter()
|
||||
.map(|(a, b)| (a.to_string(), b.to_string()))
|
||||
.collect();
|
||||
for (id, label, _) in ACCENTS {
|
||||
v.push((format!("{id}Dark"), format!("{label} Dark")));
|
||||
}
|
||||
for (id, label, _) in ACCENTS {
|
||||
v.push((format!("{id}Light"), format!("{label} Light")));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Set `a`'s recolour to `preset`, clearing any earlier one. `false` for an unknown preset.
|
||||
pub fn apply(a: &mut PictureAdjust, preset: &str) -> bool {
|
||||
let washed = a.brightness == 0.7 && a.contrast == -0.7;
|
||||
let mut n = PictureAdjust { grayscale: false, duotone: None, bilevel: None, ..a.clone() };
|
||||
if washed {
|
||||
n.brightness = 0.0;
|
||||
n.contrast = 0.0;
|
||||
}
|
||||
let rgb = |r, g, b| ColorRef::rgb(Rgba::rgb(r, g, b));
|
||||
match preset {
|
||||
"none" => {}
|
||||
"grayscale" => n.grayscale = true,
|
||||
// PowerPoint's sepia is a duotone from dark brown to light cream.
|
||||
"sepia" => n.duotone = Some((rgb(0x40, 0x2A, 0x10), rgb(0xF2, 0xE3, 0xC6))),
|
||||
// Washout is PowerPoint's +70% brightness, −70% contrast.
|
||||
"washout" => {
|
||||
n.brightness = 0.7;
|
||||
n.contrast = -0.7;
|
||||
}
|
||||
"bw25" => n.bilevel = Some(0.25),
|
||||
"bw50" => n.bilevel = Some(0.5),
|
||||
"bw75" => n.bilevel = Some(0.75),
|
||||
other => {
|
||||
let Some((slot, dark)) = ACCENTS.iter().find_map(|(id, _, slot)| {
|
||||
other.strip_prefix(id).and_then(|rest| match rest {
|
||||
"Dark" => Some((*slot, true)),
|
||||
"Light" => Some((*slot, false)),
|
||||
_ => None,
|
||||
})
|
||||
}) else {
|
||||
return false;
|
||||
};
|
||||
let c = ColorRef::scheme(slot);
|
||||
n.duotone = Some(if dark { (rgb(0, 0, 0), c) } else { (c, rgb(0xFF, 0xFF, 0xFF)) });
|
||||
}
|
||||
}
|
||||
*a = n;
|
||||
true
|
||||
}
|
||||
|
||||
fn recolor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let preset = str_param(p, "preset").ok_or_else(|| bad("picture.recolor", "missing `preset`"))?.to_string();
|
||||
if !presets().iter().any(|(id, _)| *id == preset) {
|
||||
return Err(bad("picture.recolor", format!("unknown preset `{preset}`; see picture.recolors")));
|
||||
}
|
||||
edit_shapes(s, p, "picture.recolor", |sh| {
|
||||
if let ShapeKind::Picture { fill } = &mut sh.kind {
|
||||
apply(&mut fill.adjust, &preset);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn transparent_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(at) = p.get("at") {
|
||||
let (id, c) = picture_pixel_at(s, p, at)?;
|
||||
return edit_shapes(s, &json!({"ids": [id]}), "picture.transparentColor", |sh| {
|
||||
if let ShapeKind::Picture { fill } = &mut sh.kind {
|
||||
fill.adjust.clear_color = Some(c);
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
let c = if bool_or(p, "none", false) {
|
||||
None
|
||||
} else {
|
||||
let hex = str_param(p, "color").ok_or_else(|| bad("picture.transparentColor", "give `color` (#RRGGBB) or `none: true`"))?;
|
||||
Some(Rgba::from_hex(hex.trim_start_matches('#')).ok_or_else(|| bad("picture.transparentColor", format!("bad colour `{hex}`")))?)
|
||||
};
|
||||
edit_shapes(s, p, "picture.transparentColor", |sh| {
|
||||
if let ShapeKind::Picture { fill } = &mut sh.kind {
|
||||
fill.adjust.clear_color = c;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// The topmost target picture under slide point `at` and the colour of its own pixel there: the
|
||||
/// picture as stored, before corrections and recolouring, so the colour matches the pixels that
|
||||
/// `clear_color` is compared with.
|
||||
fn picture_pixel_at(s: &Session, p: &Value, at: &Value) -> Result<(ShapeId, Rgba)> {
|
||||
const CMD: &str = "picture.transparentColor";
|
||||
let xy = |i: usize| at.get(i).and_then(Value::as_f64).filter(|v| v.is_finite());
|
||||
let (Some(x), Some(y)) = (xy(0), xy(1)) else {
|
||||
return Err(bad(CMD, "`at` is [x, y] in slide units"));
|
||||
};
|
||||
let mut ids = targets(s, p)?;
|
||||
let st = s.doc()?;
|
||||
let shapes = current_shapes(&st.doc, &st.selection).ok_or_else(|| bad(CMD, "no slide"))?;
|
||||
// Same order as the hit-test: later in the shape list is in front, and a group's children sit
|
||||
// where the group is. The selection order says nothing about what is on top.
|
||||
ids.sort_by_cached_key(|id| std::cmp::Reverse(stack_path(shapes, *id)));
|
||||
for id in ids {
|
||||
let Some((sh, xf, to_slide)) = placed(&st.doc, &st.selection, shapes, id, Affine::IDENTITY, 0) else { continue };
|
||||
let ShapeKind::Picture { fill } = &sh.kind else { continue };
|
||||
// The camera turns the picture where it is drawn, so the click is mapped back through it too.
|
||||
let turn = text::ctx_for(&st.doc, &st.selection).and_then(|c| deckcraft_render::camera_turn(&c, sh, xf.w, xf.h)).unwrap_or(Affine::IDENTITY);
|
||||
let local = (to_slide * turn).inverse() * Point::new(x, y);
|
||||
if !(local.is_finite() && (0.0..=xf.w).contains(&local.x) && (0.0..=xf.h).contains(&local.y)) {
|
||||
continue;
|
||||
}
|
||||
let PictureMode::Stretch { fill_rect: [l, t, r, b] } = fill.mode else {
|
||||
return Err(bad(CMD, "a tiled picture has no single pixel under a point; give `color`"));
|
||||
};
|
||||
let inner = Rect::new(l * xf.w, t * xf.h, xf.w - r * xf.w, xf.h - b * xf.h);
|
||||
let dest = deckcraft_render::crop_dest(inner, fill.crop);
|
||||
let img =
|
||||
st.doc.media(fill.media).and_then(|m| deckcraft_render::decode_image(&m.data)).ok_or_else(|| bad(CMD, "the picture can't be read"))?;
|
||||
let (u, v) = ((local.x - dest.x0) / dest.width(), (local.y - dest.y0) / dest.height());
|
||||
if !((0.0..=1.0).contains(&u) && (0.0..=1.0).contains(&v)) {
|
||||
continue;
|
||||
}
|
||||
let (w, h) = (img.width(), img.height());
|
||||
let px = ((u * f64::from(w)) as u32).min(w.saturating_sub(1));
|
||||
let py = ((v * f64::from(h)) as u32).min(h.saturating_sub(1));
|
||||
let Some(c) = img.get_pixel_checked(px, py) else { continue };
|
||||
// A transparent pixel keeps a colour nobody sees (often black): clearing it would clear
|
||||
// that colour where it is drawn.
|
||||
if c[3] == 0 {
|
||||
return Err(bad(CMD, "that spot of the picture is already transparent"));
|
||||
}
|
||||
return Ok((id, Rgba::rgb(c[0], c[1], c[2])));
|
||||
}
|
||||
Err(bad(CMD, "the point is not on a selected picture"))
|
||||
}
|
||||
|
||||
/// Where `id` sits in the paint order: its index in each list from the slide down to it. A larger
|
||||
/// path is drawn later, so in front.
|
||||
fn stack_path(shapes: &[Shape], id: ShapeId) -> Option<Vec<usize>> {
|
||||
for (i, sh) in shapes.iter().enumerate() {
|
||||
if sh.id == id {
|
||||
return Some(vec![i]);
|
||||
}
|
||||
if let ShapeKind::Group { children, .. } = &sh.kind
|
||||
&& let Some(mut rest) = stack_path(children, id)
|
||||
{
|
||||
rest.insert(0, i);
|
||||
return Some(rest);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Shape `id` among `shapes` (searched into groups), its box, and the map from that box to slide
|
||||
/// space.
|
||||
fn placed<'a>(
|
||||
doc: &deckcraft_model::Presentation,
|
||||
sel: &crate::Selection,
|
||||
shapes: &'a [Shape],
|
||||
id: ShapeId,
|
||||
to_slide: Affine,
|
||||
depth: usize,
|
||||
) -> Option<(&'a Shape, Xfrm, Affine)> {
|
||||
if depth > 64 {
|
||||
return None;
|
||||
}
|
||||
for sh in shapes {
|
||||
let x = xfrm_of(doc, sel, sh);
|
||||
if sh.id == id {
|
||||
return Some((sh, x, to_slide * x.affine()));
|
||||
}
|
||||
if let ShapeKind::Group { children, child } = &sh.kind {
|
||||
let ch = Rect::new(child.x, child.y, child.x + child.w, child.y + child.h);
|
||||
if let Some(found) = placed(doc, sel, children, id, to_slide * deckcraft_geom::group_child_affine(&x, ch), depth + 1) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
//! Home ▸ New Slide ▸ Reuse Slides: insert slides from another presentation.
|
||||
//!
|
||||
//! The slides take the destination's look by default (each one moves to the destination layout
|
||||
//! with the same name, else the same kind); with `keepFormatting` the source master and its
|
||||
//! layouts come along. Pictures, media and sounds are copied under fresh ids, and shape ids are
|
||||
//! renumbered with animations, triggers and connector ends following them. Links to another
|
||||
//! reused slide follow its copy.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::text::{Action, Hyperlink, RunProps, TextBody};
|
||||
use deckcraft_model::{LayoutId, Master, MasterId, MediaId, Presentation, ShapeId, ShapeKind, Slide, SlideId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::{Result, Session, media_refs};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
query "slide.reuseList",
|
||||
"List Slides to Reuse",
|
||||
[],
|
||||
None,
|
||||
"{path | data: base64, name?} → {name, slides: [{index, title, hidden}]}: the slides of another presentation, for choosing which ones `slide.reuse` brings in",
|
||||
has_doc,
|
||||
reuse_list
|
||||
),
|
||||
cmd!(
|
||||
"slide.reuse",
|
||||
"Reuse Slides…",
|
||||
["Home", "Slides", "New Slide"],
|
||||
None,
|
||||
"{path? | data: base64, name?, slides?: [index] (0-based, default all), keepFormatting?: bool (default false: use the destination theme), at?: index (default after the current slide)}",
|
||||
has_doc,
|
||||
reuse
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn reuse_list(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (name, bytes) = super::insert::media_bytes(p, "slide.reuseList")?;
|
||||
let src = super::file::open_presentation(&name, &bytes)?;
|
||||
let slides: Vec<Value> =
|
||||
src.slides.iter().enumerate().map(|(index, sl)| json!({"index": index, "title": sl.title(), "hidden": sl.hidden})).collect();
|
||||
Ok(json!({"name": name, "slides": slides}))
|
||||
}
|
||||
|
||||
/// Point every media id under `x` at its copy in the destination (`map`: source → destination).
|
||||
fn remap(map: &HashMap<MediaId, MediaId>) -> impl FnMut(&mut MediaId) + '_ {
|
||||
move |id: &mut MediaId| {
|
||||
if let Some(to) = map.get(id) {
|
||||
*id = *to;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn shape_ids(shapes: &[deckcraft_model::Shape]) -> Vec<ShapeId> {
|
||||
let mut v = vec![];
|
||||
deckcraft_model::walk(shapes, &mut |sh, _| v.push(sh.id));
|
||||
v
|
||||
}
|
||||
|
||||
/// Point connector ends at the renumbered shapes.
|
||||
fn remap_connectors(shapes: &mut [deckcraft_model::Shape], map: &HashMap<ShapeId, ShapeId>, depth: usize) {
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
for sh in shapes {
|
||||
if let ShapeKind::Connector { start, end } = &mut sh.kind {
|
||||
for c in [start, end] {
|
||||
if let Some((id, _)) = c.as_mut() {
|
||||
*id = map.get(id).copied().unwrap_or(*id);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(ch) = sh.children_mut() {
|
||||
remap_connectors(ch, map, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Point a link to a reused slide at its copy (`map`: source → destination).
|
||||
fn relink(h: &mut Option<Hyperlink>, map: &HashMap<SlideId, SlideId>) {
|
||||
let Some(h) = h else { return };
|
||||
if let Action::Slide { slide } = &mut h.action {
|
||||
*slide = map.get(slide).copied().unwrap_or(*slide);
|
||||
}
|
||||
if let Some(z) = &mut h.zoom_return {
|
||||
*z = map.get(z).copied().unwrap_or(*z);
|
||||
}
|
||||
}
|
||||
|
||||
fn relink_run(r: &mut RunProps, map: &HashMap<SlideId, SlideId>) {
|
||||
relink(&mut r.link, map);
|
||||
relink(&mut r.hover, map);
|
||||
}
|
||||
|
||||
fn relink_text(t: &mut TextBody, map: &HashMap<SlideId, SlideId>) {
|
||||
for lv in t.list_style.levels.iter_mut().flatten() {
|
||||
relink_run(&mut lv.run, map);
|
||||
}
|
||||
for p in &mut t.paragraphs {
|
||||
relink_run(&mut p.def_props, map);
|
||||
relink_run(&mut p.end_props, map);
|
||||
for r in &mut p.runs {
|
||||
relink_run(&mut r.props, map);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Point the slide links under `shapes` (click and hover actions, text links) at the copies.
|
||||
fn relink_shapes(shapes: &mut [deckcraft_model::Shape], map: &HashMap<SlideId, SlideId>, depth: usize) {
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
for sh in shapes {
|
||||
relink(&mut sh.click, map);
|
||||
relink(&mut sh.hover, map);
|
||||
if let Some(t) = &mut sh.text {
|
||||
relink_text(t, map);
|
||||
}
|
||||
if let ShapeKind::Table(t) = &mut sh.kind {
|
||||
for c in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
|
||||
relink_text(&mut c.text, map);
|
||||
}
|
||||
}
|
||||
if let Some(ch) = sh.children_mut() {
|
||||
relink_shapes(ch, map, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fresh shape ids for `shapes`, and the old → new map.
|
||||
fn renumber(cmd: &str, doc: &mut Presentation, shapes: &mut [deckcraft_model::Shape]) -> Result<HashMap<ShapeId, ShapeId>> {
|
||||
let old = shape_ids(shapes);
|
||||
super::edit::reid(cmd, doc, shapes)?;
|
||||
let map: HashMap<ShapeId, ShapeId> = old.into_iter().zip(shape_ids(shapes)).collect();
|
||||
remap_connectors(shapes, &map, 0);
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
/// The destination layout a reused slide moves to: same name, else same kind, else the first.
|
||||
fn matching_layout(doc: &Presentation, name: &str, kind: deckcraft_model::LayoutType) -> LayoutId {
|
||||
let all = || doc.masters.iter().flat_map(|m| m.layouts.iter());
|
||||
all()
|
||||
.find(|l| !name.is_empty() && l.name.eq_ignore_ascii_case(name))
|
||||
.or_else(|| all().find(|l| l.kind == kind))
|
||||
.or_else(|| all().next())
|
||||
.map(|l| l.id)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (name, bytes) = super::insert::media_bytes(p, "slide.reuse")?;
|
||||
let src = super::file::open_presentation(&name, &bytes)?;
|
||||
let n = src.slides.len();
|
||||
let picked: Vec<usize> = match p.get("slides") {
|
||||
Some(Value::Array(a)) => {
|
||||
let mut v = vec![];
|
||||
for x in a {
|
||||
let i = x.as_u64().map(|i| i as usize).filter(|i| *i < n);
|
||||
let i = i.ok_or_else(|| bad("slide.reuse", format!("{name} has {n} slides; {x} isn't one of them (indices count from 0)")))?;
|
||||
if !v.contains(&i) {
|
||||
v.push(i);
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
Some(Value::String(t)) if !t.trim().is_empty() => super::file::parse_slide_range(t, n)?,
|
||||
_ => (0..n).collect(),
|
||||
};
|
||||
if picked.is_empty() {
|
||||
return Err(bad("slide.reuse", format!("{name} has no slides to reuse")));
|
||||
}
|
||||
let keep = bool_or(p, "keepFormatting", false);
|
||||
let at = usize_param(p, "at");
|
||||
s.edit(|doc, sel| {
|
||||
let at = at.unwrap_or(sel.slide + 1).min(doc.slides.len());
|
||||
// Media: what the slides use is copied under fresh ids.
|
||||
let mut media_map: HashMap<MediaId, MediaId> = HashMap::new();
|
||||
// Keep Source Formatting: bring each source master used, once, with fresh ids.
|
||||
let mut layout_map: HashMap<LayoutId, LayoutId> = HashMap::new();
|
||||
if keep {
|
||||
let mut masters_done: Vec<MasterId> = vec![];
|
||||
for &i in &picked {
|
||||
let Some((m, _)) = src.slides.get(i).and_then(|sl| src.master_for(sl)) else { continue };
|
||||
if masters_done.contains(&m.id) {
|
||||
continue;
|
||||
}
|
||||
masters_done.push(m.id);
|
||||
let m = import_master("slide.reuse", doc, &src, m, &mut media_map, &mut layout_map)?;
|
||||
doc.masters.push(Arc::new(m));
|
||||
}
|
||||
}
|
||||
let mut copies: Vec<Slide> = vec![];
|
||||
// Source slide → its copy, so links between reused slides follow (forward ones too).
|
||||
let mut slide_map: HashMap<SlideId, SlideId> = HashMap::new();
|
||||
for &i in &picked {
|
||||
let Some(from) = src.slides.get(i) else { continue };
|
||||
copy_media("slide.reuse", doc, &src, &media_refs::slide_media(from), &mut media_map)?;
|
||||
let mut sl: Slide = from.as_ref().clone();
|
||||
media_refs::visit_mut::slide(&mut sl, &mut remap(&media_map));
|
||||
sl.id = SlideId(doc.alloc_id().ok_or_else(|| bad("slide.reuse", NO_ID_SPACE))?);
|
||||
let map = renumber("slide.reuse", doc, &mut sl.shapes)?;
|
||||
for a in &mut sl.animations {
|
||||
a.shape = map.get(&a.shape).copied().unwrap_or(a.shape);
|
||||
a.trigger = a.trigger.map(|t| map.get(&t).copied().unwrap_or(t));
|
||||
}
|
||||
sl.layout = match layout_map.get(&sl.layout) {
|
||||
Some(l) => *l,
|
||||
None => {
|
||||
let (lname, kind) =
|
||||
src.layout(from.layout).map(|(_, l)| (l.name.clone(), l.kind)).unwrap_or_else(|| (String::new(), Default::default()));
|
||||
matching_layout(doc, &lname, kind)
|
||||
}
|
||||
};
|
||||
slide_map.insert(from.id, sl.id);
|
||||
copies.push(sl);
|
||||
}
|
||||
let mut new_ids = vec![];
|
||||
for (k, mut sl) in copies.into_iter().enumerate() {
|
||||
relink_shapes(&mut sl.shapes, &slide_map, 0);
|
||||
relink_text(&mut sl.notes, &slide_map);
|
||||
new_ids.push(sl.id);
|
||||
doc.slides.insert((at + k).min(doc.slides.len()), Arc::new(sl));
|
||||
}
|
||||
sel.slide = (at + new_ids.len()).saturating_sub(1).min(doc.slides.len().saturating_sub(1));
|
||||
sel.slides = new_ids.clone();
|
||||
sel.shapes.clear();
|
||||
sel.text = None;
|
||||
Ok(json!({"slides": new_ids.len(), "ids": new_ids, "from": name, "keepFormatting": keep}))
|
||||
})
|
||||
}
|
||||
|
||||
/// A copy of `src`'s master `m` ready to join `doc`: its media copied in, fresh master, layout and
|
||||
/// shape ids. `layouts` gains each source layout id → its copy's.
|
||||
pub(crate) fn import_master(
|
||||
cmd: &str,
|
||||
doc: &mut Presentation,
|
||||
src: &Presentation,
|
||||
m: &Master,
|
||||
media_map: &mut HashMap<MediaId, MediaId>,
|
||||
layouts: &mut HashMap<LayoutId, LayoutId>,
|
||||
) -> Result<Master> {
|
||||
let mut used = vec![];
|
||||
media_refs::visit::master(m, &mut |id: &MediaId| used.push(*id));
|
||||
copy_media(cmd, doc, src, &used, media_map)?;
|
||||
let mut m = m.clone();
|
||||
media_refs::visit_mut::master(&mut m, &mut remap(media_map));
|
||||
m.id = MasterId(doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE))?);
|
||||
renumber(cmd, doc, &mut m.shapes)?;
|
||||
for l in &mut m.layouts {
|
||||
let new = LayoutId(doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE))?);
|
||||
layouts.insert(l.id, new);
|
||||
l.id = new;
|
||||
renumber(cmd, doc, &mut l.shapes)?;
|
||||
}
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
/// Copy the source media `used` into `doc` under fresh ids, once each (identical bytes already in
|
||||
/// `doc` are shared; a linked item keeps its link).
|
||||
fn copy_media(cmd: &str, doc: &mut Presentation, src: &Presentation, used: &[MediaId], map: &mut HashMap<MediaId, MediaId>) -> Result<()> {
|
||||
for &id in used {
|
||||
if map.contains_key(&id) {
|
||||
continue;
|
||||
}
|
||||
let Some(m) = src.media(id) else { continue };
|
||||
let new = if m.link.is_some() {
|
||||
let mut item = m.clone();
|
||||
item.id = MediaId(doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE))?);
|
||||
let new = item.id;
|
||||
doc.media.push(item);
|
||||
new
|
||||
} else {
|
||||
doc.add_media(&m.name, &m.content_type, m.data.as_ref().clone()).ok_or_else(|| bad(cmd, NO_ID_SPACE))?
|
||||
};
|
||||
map.insert(id, new);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use deckcraft_model::style::{Artistic, Fill, PictureFill};
|
||||
use deckcraft_model::text::{Action, Bullet, Hyperlink, TextBody};
|
||||
use deckcraft_model::{Background, MediaClip, Shape, ShapeKind, defaults};
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Every kind of media a slide can hold comes along, each pointing at its own bytes: the
|
||||
/// artistic-effect original, a background picture, poster, sounds and picture bullets too.
|
||||
#[test]
|
||||
fn reused_slides_bring_every_media_reference() {
|
||||
let mut src = defaults::new_presentation(None);
|
||||
let blobs: Vec<Vec<u8>> = (0..8u8).map(|i| vec![0xD0, i, i, i]).collect();
|
||||
let ids: Vec<MediaId> = blobs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, b)| src.add_media(&format!("m{i}.bin"), "application/octet-stream", b.clone()).expect("media"))
|
||||
.collect();
|
||||
let id = |i: usize| ids.get(i).copied().unwrap_or_default();
|
||||
let mut text = TextBody::from_text("bullet");
|
||||
if let Some(p) = text.paragraphs.first_mut() {
|
||||
p.props.bullet = Some(Bullet::Picture { media: id(6) });
|
||||
}
|
||||
let slide = Arc::make_mut(src.slides.first_mut().expect("slide"));
|
||||
slide.shapes = vec![
|
||||
Shape {
|
||||
id: ShapeId(9001),
|
||||
kind: ShapeKind::Picture {
|
||||
fill: PictureFill {
|
||||
media: id(0),
|
||||
artistic: Some(Artistic { effect: "artisticBlur".into(), original: id(1) }),
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
Shape {
|
||||
id: ShapeId(9002),
|
||||
kind: ShapeKind::Media(MediaClip { media: id(2), poster: Some(id(3)), ..Default::default() }),
|
||||
..Default::default()
|
||||
},
|
||||
Shape {
|
||||
id: ShapeId(9003),
|
||||
click: Some(Hyperlink {
|
||||
action: Action::NextSlide,
|
||||
tooltip: String::new(),
|
||||
highlight_click: false,
|
||||
sound: Some(id(5)),
|
||||
stop_sound: false,
|
||||
zoom_return: None,
|
||||
}),
|
||||
text: Some(text),
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
slide.background = Some(Background::Fill { fill: Fill::Picture(PictureFill { media: id(4), ..Default::default() }) });
|
||||
slide.transition = Some(deckcraft_model::anim::Transition { sound: Some(id(7)), ..Default::default() });
|
||||
let bytes = deckcraft_format::save(&src).expect("save");
|
||||
|
||||
let mut s = Session::with_new();
|
||||
let own = vec![1u8, 2, 3];
|
||||
let own_id = s.edit(|doc, _| Ok(doc.add_media("own.bin", "application/octet-stream", own.clone()).expect("media"))).expect("own media");
|
||||
s.execute("slide.reuse", &json!({"data": super::super::base64_encode(&bytes), "name": "src.deckcraft", "slides": [0]})).expect("reuse");
|
||||
let doc = &s.doc().expect("doc").doc;
|
||||
assert_eq!(doc.media(own_id).map(|m| m.data.as_slice() == own.as_slice()), Some(true), "own media untouched");
|
||||
let reused = doc.slides.get(1).expect("reused slide");
|
||||
let got: Vec<Vec<u8>> =
|
||||
media_refs::slide_media(reused).iter().map(|m| doc.media(*m).map(|m| m.data.as_ref().clone()).unwrap_or_default()).collect();
|
||||
let mut got = got;
|
||||
got.sort();
|
||||
let mut want = blobs.clone();
|
||||
want.sort();
|
||||
assert_eq!(got, want, "every reference resolves to its own copied bytes");
|
||||
}
|
||||
|
||||
fn link_to(slide: SlideId) -> Hyperlink {
|
||||
Hyperlink {
|
||||
action: Action::Slide { slide },
|
||||
tooltip: String::new(),
|
||||
highlight_click: false,
|
||||
sound: None,
|
||||
stop_sound: false,
|
||||
zoom_return: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn linked_text(slide: SlideId) -> TextBody {
|
||||
let mut text = TextBody::from_text("go");
|
||||
if let Some(r) = text.paragraphs.first_mut().and_then(|p| p.runs.first_mut()) {
|
||||
r.props.link = Some(link_to(slide));
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
/// Links to a slide that is reused too follow the copy; links to a slide left behind stay.
|
||||
#[test]
|
||||
fn reused_slides_link_to_each_others_copies() {
|
||||
let mut src = defaults::new_presentation(None);
|
||||
let template = src.slides.first().map(|s| s.as_ref().clone()).expect("slide");
|
||||
for _ in 0..2 {
|
||||
let mut sl = template.clone();
|
||||
sl.id = SlideId(src.alloc_id().expect("id"));
|
||||
src.slides.push(Arc::new(sl));
|
||||
}
|
||||
let ids: Vec<SlideId> = src.slides.iter().map(|s| s.id).collect();
|
||||
let id = |i: usize| ids.get(i).copied().unwrap_or_default();
|
||||
if let Some(sl) = src.slides.first_mut().map(Arc::make_mut) {
|
||||
sl.shapes = vec![
|
||||
Shape { id: ShapeId(9001), click: Some(link_to(id(1))), ..Default::default() },
|
||||
Shape { id: ShapeId(9002), text: Some(linked_text(id(1))), ..Default::default() },
|
||||
Shape { id: ShapeId(9003), click: Some(link_to(id(2))), ..Default::default() },
|
||||
];
|
||||
}
|
||||
if let Some(sl) = src.slides.get_mut(1).map(Arc::make_mut) {
|
||||
sl.shapes = vec![Shape { id: ShapeId(9004), click: Some(link_to(id(0))), ..Default::default() }];
|
||||
}
|
||||
let bytes = deckcraft_format::save(&src).expect("save");
|
||||
let reopened = crate::cmd::file::open_presentation("src.deckcraft", &bytes).expect("open");
|
||||
let left_behind = reopened.slides.get(2).map(|s| s.id).expect("third slide");
|
||||
|
||||
let mut s = Session::with_new();
|
||||
for _ in 0..3 {
|
||||
s.execute("slide.new", &json!({})).expect("new");
|
||||
}
|
||||
let out = s
|
||||
.execute("slide.reuse", &json!({"data": super::super::base64_encode(&bytes), "name": "src.deckcraft", "slides": [0, 1]}))
|
||||
.expect("reuse");
|
||||
let new: Vec<SlideId> = serde_json::from_value(out.get("ids").cloned().unwrap_or_default()).expect("ids");
|
||||
let doc = &s.doc().expect("doc").doc;
|
||||
let copy = |i: usize| new.get(i).and_then(|id| doc.slide(*id)).expect("copied slide");
|
||||
let click = |sh: Option<&Shape>| sh.and_then(|sh| sh.click.as_ref()).map(|h| h.action.clone());
|
||||
let first = copy(0);
|
||||
let to = |i: usize| Some(Action::Slide { slide: new.get(i).copied().unwrap_or_default() });
|
||||
assert_eq!(click(first.shapes.first()), to(1), "shape click follows the copy");
|
||||
let run_link = first
|
||||
.shapes
|
||||
.get(1)
|
||||
.and_then(|sh| sh.text.as_ref())
|
||||
.and_then(|t| t.paragraphs.first())
|
||||
.and_then(|p| p.runs.first())
|
||||
.and_then(|r| r.props.link.as_ref())
|
||||
.map(|h| h.action.clone());
|
||||
assert_eq!(run_link, to(1), "text link follows the copy");
|
||||
assert_eq!(click(first.shapes.get(2)), Some(Action::Slide { slide: left_behind }), "a slide not reused keeps its link");
|
||||
assert_eq!(click(copy(1).shapes.first()), to(0), "a link back to an earlier copy follows too");
|
||||
}
|
||||
|
||||
/// The list that lets a dialog offer the slides of the other file; picking from it brings in
|
||||
/// exactly those.
|
||||
#[test]
|
||||
fn reuse_list_names_the_slides_and_the_picked_ones_come_in() {
|
||||
let mut src = Session::with_new();
|
||||
for t in ["Alpha", "Beta", "Gamma"] {
|
||||
src.execute("slide.new", &json!({"layout": "titleAndContent", "title": t})).expect("new");
|
||||
}
|
||||
let last_slide = src.doc().expect("doc").doc.slides.len() - 1;
|
||||
src.execute("slide.hide", &json!({"index": last_slide})).expect("hide");
|
||||
let bytes = crate::cmd::file::save_bytes(&src.doc().expect("doc").doc, "deckcraft").expect("save");
|
||||
let data = super::super::base64_encode(&bytes);
|
||||
|
||||
let mut s = Session::with_new();
|
||||
let listed = s.execute("slide.reuseList", &json!({"data": data, "name": "src.deckcraft"})).expect("list");
|
||||
let slides = listed["slides"].as_array().expect("slides");
|
||||
let title = |i: usize| slides.iter().find(|x| x["index"] == i).and_then(|x| x["title"].as_str()).unwrap_or_default().to_string();
|
||||
assert!(slides.len() >= 3);
|
||||
let (a, b, g) = (title(slides.len() - 3), title(slides.len() - 2), title(slides.len() - 1));
|
||||
assert_eq!((a.as_str(), b.as_str(), g.as_str()), ("Alpha", "Beta", "Gamma"));
|
||||
assert_eq!(slides.last().map(|x| x["hidden"].clone()), Some(json!(true)));
|
||||
assert_eq!(s.doc().expect("doc").doc.slides.len(), 1, "listing changes nothing");
|
||||
|
||||
let last = slides.len() - 1;
|
||||
s.execute("slide.reuse", &json!({"data": data, "name": "src.deckcraft", "slides": [last - 2, last]})).expect("reuse");
|
||||
let titles: Vec<String> = s.doc().expect("doc").doc.slides.iter().map(|x| x.title()).collect();
|
||||
assert!(titles.iter().any(|t| t == "Alpha") && titles.iter().any(|t| t == "Gamma"));
|
||||
assert!(!titles.iter().any(|t| t == "Beta"), "a slide that was not picked stays out");
|
||||
}
|
||||
}
|
||||
+623
-44
@@ -6,7 +6,7 @@ use deckcraft_model::Comment;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::{Result, Session, UiRequest};
|
||||
use crate::{Result, Session, Target, UiRequest};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -15,8 +15,60 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("comment.delete", "Delete Comment", ["Review", "Comments"], None, "{index? | all?: bool}", has_slide, delete),
|
||||
cmd!("comment.resolve", "Resolve Thread", ["Comments"], None, "{index, resolved?: bool}", has_slide, resolve),
|
||||
cmd!(query "comment.list", "Comments", [], None, "{slide?} → comments", has_slide, list),
|
||||
cmd!(
|
||||
noundo "comment.next",
|
||||
"Next Comment",
|
||||
["Review", "Comments"],
|
||||
None,
|
||||
"{slide?, index?} → {slide, index, text}",
|
||||
has_slide,
|
||||
|s, p| neighbour(s, p, true)
|
||||
),
|
||||
cmd!(
|
||||
noundo "comment.prev",
|
||||
"Previous Comment",
|
||||
["Review", "Comments"],
|
||||
None,
|
||||
"{slide?, index?} → {slide, index, text}",
|
||||
has_slide,
|
||||
|s, p| neighbour(s, p, false)
|
||||
),
|
||||
cmd!(query "review.accessibility", "Check Accessibility", ["Review", "Accessibility"], None, "{} → [{slide, shape, issue}]", has_doc, accessibility),
|
||||
cmd!(query "review.spelling", "Spelling", ["Review", "Proofing"], Some("F7"), "{} → [{slide, shape, word}] (words not in the built-in list)", has_doc, spelling),
|
||||
cmd!(query "review.spelling", "Spelling", ["Review", "Proofing"], Some("F7"), "{lang?, ignore?:[word]} → [{slide, shape?, row?, col?, notes?, para, start, end, word, lang, suggestions}] (shapes, table cells and notes; repeated words as {repeated, deleteFrom}; runs marked noProof skipped; each run in its own language, lang? for runs without one; Hunspell dictionaries installed on the system, else a built-in list of common misspellings)", has_doc, spelling),
|
||||
cmd!(
|
||||
"review.spellingChange",
|
||||
"Change Spelling",
|
||||
["Review", "Proofing"],
|
||||
None,
|
||||
"{shape, word, to, all?, para?, start?, row?, col?} → {changed} (replace the whole word in that shape's text, the one at para/start, or in every shape with all)",
|
||||
has_doc,
|
||||
spelling_change
|
||||
),
|
||||
cmd!(noundo "review.addToDictionary", "Add to Dictionary", ["Review", "Proofing"], None, "{word} → {word, file} (append to the user dictionary file; not an undo step)", always, add_to_dictionary),
|
||||
cmd!(query "review.dictionary", "Spelling Dictionary", ["Review", "Proofing"], None, "{lang?} → {lang, available, found, folders} (whether review.spelling has a dictionary, and which file)", always, dictionary_info),
|
||||
cmd!(query "review.thesaurus", "Thesaurus", ["Review", "Proofing"], None, "{word? (default: the selected word, or the word at the caret)} → {word, senses: [{word, synonyms}]}", has_doc, thesaurus),
|
||||
cmd!(
|
||||
"review.replaceWord",
|
||||
"Insert Synonym",
|
||||
[],
|
||||
None,
|
||||
"{with}: replace the selected word (or the word at the caret) while editing text",
|
||||
has_text,
|
||||
insert_synonym
|
||||
),
|
||||
cmd!(noundo "review.compare", "Compare", ["Review", "Compare"], None, "{path} | {name, data: base64} — compare with a revised copy → {name, changes}", has_doc, compare),
|
||||
cmd!(query "review.revisions", "Revisions", [], None, "{} → {name, changes: [{kind: inserted|deleted|changed, slide, revised, details}]}", has_doc, revisions),
|
||||
cmd!("review.accept", "Accept Revision", ["Review", "Compare"], None, "{index} | {all: true}", comparing, accept),
|
||||
cmd!(
|
||||
noundo "review.reject",
|
||||
"Reject Revision",
|
||||
["Review", "Compare"],
|
||||
None,
|
||||
"{index} | {all: true} → {rejected} (drop revisions without applying them: the open deck stays as it is; not an undo step)",
|
||||
comparing,
|
||||
reject
|
||||
),
|
||||
cmd!(noundo "review.endCompare", "End Review", ["Review", "Compare"], None, "{}", comparing, end_compare),
|
||||
cmd!(noundo "show.fromStart", "Play from Start", ["Slide Show", "Start Slide Show"], Some("Cmd+Shift+Enter"), "{}", has_slide, |s, _| start(s, 0)),
|
||||
cmd!(noundo "show.fromCurrent", "Play from Current Slide", ["Slide Show", "Start Slide Show"], Some("Cmd+Enter"), "{}", has_slide, |s, _| {
|
||||
let i = s.doc()?.selection.slide;
|
||||
@@ -27,7 +79,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Set Up Slide Show…",
|
||||
["Slide Show", "Set Up"],
|
||||
None,
|
||||
"{type?: speaker|browsed|kiosk, loop?: bool, noNarration?, noAnimation?, useTimings?, from?, to?}",
|
||||
"{type?: speaker|browsed|kiosk, loop?: bool, noNarration?, noAnimation?, useTimings?, showMediaControls?, from?, to?, custom?: name}",
|
||||
has_doc,
|
||||
setup
|
||||
),
|
||||
@@ -43,24 +95,33 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
fn now() -> String {
|
||||
/// Seconds since the Unix epoch: the system clock, or the browser's on wasm (which has no `SystemTime`).
|
||||
fn now_secs() -> u64 {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
|
||||
let (y, m, d) = super::design::civil((secs / 86_400) as i64);
|
||||
let t = secs % 86_400;
|
||||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", t / 3600, t / 60 % 60, t % 60)
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
String::new()
|
||||
(js_sys::Date::now() / 1000.0).max(0.0) as u64
|
||||
}
|
||||
}
|
||||
|
||||
/// A UTC timestamp as saved on comments: `2026-10-10T13:10:35Z`.
|
||||
fn iso_utc(secs: u64) -> String {
|
||||
let (y, m, d) = super::design::civil((secs / 86_400) as i64);
|
||||
let t = secs % 86_400;
|
||||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", t / 3600, t / 60 % 60, t % 60)
|
||||
}
|
||||
|
||||
fn now() -> String {
|
||||
iso_utc(now_secs())
|
||||
}
|
||||
|
||||
fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let text = str_param(p, "text").ok_or_else(|| bad("comment.add", "missing `text`"))?.to_string();
|
||||
let author = s.prefs.author.clone();
|
||||
let shape = id_param(p, "id").or_else(|| s.active().and_then(|d| d.selection.shapes.first().copied()));
|
||||
let shape = id_param(p, "id", "comment.add")?.or_else(|| s.active().and_then(|d| d.selection.shapes.first().copied()));
|
||||
let (x, y) = (f64_or(p, "x", 10.0), f64_or(p, "y", 10.0));
|
||||
s.edit(|doc, sel| {
|
||||
let sl = Arc::make_mut(doc.slides.get_mut(sel.slide).ok_or_else(|| bad("comment.add", "no slide"))?);
|
||||
@@ -85,7 +146,14 @@ fn reply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let all = bool_or(p, "all", false);
|
||||
let i = usize_param(p, "index");
|
||||
let i = match p.get("index") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(
|
||||
v.as_u64()
|
||||
.and_then(|v| usize::try_from(v).ok())
|
||||
.ok_or_else(|| bad("comment.delete", format!("`index` is not a valid comment index: {v}")))?,
|
||||
),
|
||||
};
|
||||
s.edit(|doc, sel| {
|
||||
if all {
|
||||
for sl in &mut doc.slides {
|
||||
@@ -123,6 +191,51 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(serde_json::to_value(st.doc.slides.get(i).map(|x| x.comments.clone()).unwrap_or_default()).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Flat `(slide, comment index)` list in deck order.
|
||||
fn comment_threads(doc: &deckcraft_model::Presentation) -> Vec<(usize, usize)> {
|
||||
let mut out = Vec::new();
|
||||
for (si, sl) in doc.slides.iter().enumerate() {
|
||||
for ci in 0..sl.comments.len() {
|
||||
out.push((si, ci));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Step to the next or previous comment. With `{slide, index}` matching a thread, move one step
|
||||
/// and stop at the ends. Without `index`, next is the first thread on or after `slide` (the
|
||||
/// current slide when omitted) and previous is the last thread strictly before it.
|
||||
fn neighbour(s: &mut Session, p: &Value, forward: bool) -> Result<Value> {
|
||||
let cmd = if forward { "comment.next" } else { "comment.prev" };
|
||||
let (threads, slide, index) = {
|
||||
let st = s.doc()?;
|
||||
(comment_threads(&st.doc), usize_param(p, "slide").unwrap_or(st.selection.slide), usize_param(p, "index"))
|
||||
};
|
||||
if threads.is_empty() {
|
||||
return Err(bad(cmd, "no comments"));
|
||||
}
|
||||
let pos = index.and_then(|i| threads.iter().position(|&(sl, c)| sl == slide && c == i));
|
||||
let target = match (pos, forward) {
|
||||
(Some(i), true) => threads.get(i + 1).copied(),
|
||||
(Some(i), false) => i.checked_sub(1).and_then(|j| threads.get(j).copied()),
|
||||
(None, true) => threads.iter().copied().find(|(sl, _)| *sl >= slide),
|
||||
(None, false) => threads.iter().copied().rev().find(|(sl, _)| *sl < slide),
|
||||
};
|
||||
let Some((slide, index)) = target else {
|
||||
return Err(bad(cmd, "no more comments"));
|
||||
};
|
||||
s.select(|doc, sel| {
|
||||
sel.slide = slide;
|
||||
sel.slides = doc.slides.get(slide).map(|x| vec![x.id]).unwrap_or_default();
|
||||
sel.shapes.clear();
|
||||
sel.text = None;
|
||||
sel.cells = None;
|
||||
sel.target = Target::Slides;
|
||||
})?;
|
||||
let text = s.doc()?.doc.slides.get(slide).and_then(|sl| sl.comments.get(index)).map(|c| c.text.clone()).unwrap_or_default();
|
||||
Ok(json!({"slide": slide, "index": index, "text": text}))
|
||||
}
|
||||
|
||||
fn accessibility(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let mut out = vec![];
|
||||
@@ -168,52 +281,271 @@ fn accessibility(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
Ok(Value::Array(out))
|
||||
}
|
||||
|
||||
fn spelling(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
// A small built-in list of frequent misspellings (full dictionaries come later).
|
||||
const COMMON: &[&str] = &[
|
||||
"teh",
|
||||
"recieve",
|
||||
"seperate",
|
||||
"occured",
|
||||
"untill",
|
||||
"wich",
|
||||
"becuase",
|
||||
"definately",
|
||||
"thier",
|
||||
"alot",
|
||||
"accomodate",
|
||||
"acheive",
|
||||
"adress",
|
||||
"begining",
|
||||
"beleive",
|
||||
"calender",
|
||||
"commited",
|
||||
"enviroment",
|
||||
"goverment",
|
||||
"independant",
|
||||
"neccessary",
|
||||
"occassion",
|
||||
"publically",
|
||||
"reccomend",
|
||||
"tommorow",
|
||||
"wierd",
|
||||
];
|
||||
/// Frequent misspellings, used when no dictionary for the language is installed.
|
||||
const COMMON: &[&str] = &[
|
||||
"teh",
|
||||
"recieve",
|
||||
"seperate",
|
||||
"occured",
|
||||
"untill",
|
||||
"wich",
|
||||
"becuase",
|
||||
"definately",
|
||||
"thier",
|
||||
"alot",
|
||||
"accomodate",
|
||||
"acheive",
|
||||
"adress",
|
||||
"begining",
|
||||
"beleive",
|
||||
"calender",
|
||||
"commited",
|
||||
"enviroment",
|
||||
"goverment",
|
||||
"independant",
|
||||
"neccessary",
|
||||
"occassion",
|
||||
"publically",
|
||||
"reccomend",
|
||||
"tommorow",
|
||||
"wierd",
|
||||
];
|
||||
|
||||
/// The language a check uses: the `lang` parameter, else the first run that says, else US English.
|
||||
fn check_lang(doc: &deckcraft_model::Presentation, p: &Value) -> String {
|
||||
if let Some(l) = str_param(p, "lang") {
|
||||
return l.to_string();
|
||||
}
|
||||
let mut found = None;
|
||||
for sl in &doc.slides {
|
||||
deckcraft_model::walk(&sl.shapes, &mut |sh, _| {
|
||||
if found.is_none()
|
||||
&& let Some(t) = &sh.text
|
||||
{
|
||||
found = t.paragraphs.iter().flat_map(|p| &p.runs).find_map(|r| r.props.lang.clone());
|
||||
}
|
||||
});
|
||||
}
|
||||
found.unwrap_or_else(|| "en-US".into())
|
||||
}
|
||||
|
||||
fn spelling(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let default = check_lang(&st.doc, p);
|
||||
let ignore: Vec<String> =
|
||||
p.get("ignore").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_lowercase).collect()).unwrap_or_default();
|
||||
let mut dicts: std::collections::HashMap<String, Option<Arc<crate::spell::Dictionary>>> = Default::default();
|
||||
let mut out = vec![];
|
||||
// Each misspelt word of `para`, reported with `at` (where the paragraph is) merged in.
|
||||
let mut check = |para: &deckcraft_model::Paragraph, at: Value, out: &mut Vec<Value>| {
|
||||
for w in crate::spell::repeated(para, &default) {
|
||||
let mut v = json!({"start": w.start, "end": w.end, "word": w.text, "lang": w.lang, "repeated": true, "deleteFrom": w.repeated, "suggestions": []});
|
||||
if let (Some(o), Some(a)) = (v.as_object_mut(), at.as_object()) {
|
||||
o.extend(a.clone());
|
||||
}
|
||||
out.push(v);
|
||||
}
|
||||
let rep: Vec<usize> = crate::spell::repeated(para, &default).iter().map(|w| w.start).collect();
|
||||
for w in crate::spell::paragraph_words(para, &default) {
|
||||
if ignore.contains(&w.text.to_lowercase()) || rep.contains(&w.start) {
|
||||
continue;
|
||||
}
|
||||
let dict = dicts.entry(w.lang.clone()).or_insert_with(|| crate::spell::dictionary(&w.lang)).clone();
|
||||
let bad = match &dict {
|
||||
Some(d) => !crate::spell::is_correct(d, &w.text),
|
||||
None => COMMON.contains(&w.text.to_lowercase().as_str()),
|
||||
};
|
||||
if bad {
|
||||
let sug = dict.as_ref().map(|d| d.suggest(&w.text, 5)).unwrap_or_default();
|
||||
let mut v = json!({"start": w.start, "end": w.end, "word": w.text, "lang": w.lang, "suggestions": sug});
|
||||
if let (Some(o), Some(a)) = (v.as_object_mut(), at.as_object()) {
|
||||
o.extend(a.clone());
|
||||
}
|
||||
out.push(v);
|
||||
}
|
||||
}
|
||||
};
|
||||
for (i, sl) in st.doc.slides.iter().enumerate() {
|
||||
deckcraft_model::walk(&sl.shapes, &mut |sh, _| {
|
||||
if let Some(t) = &sh.text {
|
||||
for w in t.text().split(|c: char| !c.is_alphanumeric() && c != '\'') {
|
||||
if COMMON.contains(&w.to_lowercase().as_str()) {
|
||||
out.push(json!({"slide": i, "shape": sh.id, "word": w}));
|
||||
for (pi, para) in t.paragraphs.iter().enumerate() {
|
||||
check(para, json!({"slide": i, "shape": sh.id, "para": pi}), &mut out);
|
||||
}
|
||||
}
|
||||
if let deckcraft_model::ShapeKind::Table(tb) = &sh.kind {
|
||||
for (r, row) in tb.rows.iter().enumerate() {
|
||||
for (c, cell) in row.cells.iter().enumerate().filter(|(_, c)| !c.is_covered()) {
|
||||
for (pi, para) in cell.text.paragraphs.iter().enumerate() {
|
||||
check(para, json!({"slide": i, "shape": sh.id, "row": r, "col": c, "para": pi}), &mut out);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
// Speaker notes, a paragraph per line.
|
||||
for (pi, para) in sl.notes.paragraphs.iter().enumerate() {
|
||||
check(para, json!({"slide": i, "notes": true, "para": pi}), &mut out);
|
||||
}
|
||||
}
|
||||
Ok(Value::Array(out))
|
||||
}
|
||||
|
||||
fn add_to_dictionary(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let word = str_param(p, "word").filter(|w| !w.trim().is_empty()).ok_or_else(|| bad("review.addToDictionary", "missing `word`"))?;
|
||||
crate::spell::add_user_word(word).map_err(|e| bad("review.addToDictionary", e.to_string()))?;
|
||||
Ok(json!({"word": word.trim(), "file": crate::spell::user_dictionary_path().map(|p| p.display().to_string())}))
|
||||
}
|
||||
|
||||
/// Replace whole-word `from` with `to` in `text`; how many were replaced.
|
||||
fn replace_word(text: &mut String, from: &str, to: &str, all: bool) -> usize {
|
||||
let is_word = |c: char| c.is_alphanumeric() || c == '\'' || c == '’';
|
||||
let mut n = 0;
|
||||
let mut at = 0;
|
||||
while let Some(i) = text.get(at..).and_then(|t| t.find(from)).map(|i| i + at) {
|
||||
let end = i + from.len();
|
||||
let before = text.get(..i).and_then(|t| t.chars().next_back()).is_some_and(is_word);
|
||||
let after = text.get(end..).and_then(|t| t.chars().next()).is_some_and(is_word);
|
||||
if before || after {
|
||||
at = end;
|
||||
continue;
|
||||
}
|
||||
text.replace_range(i..end, to);
|
||||
n += 1;
|
||||
at = i + to.len();
|
||||
if !all {
|
||||
break;
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
fn spelling_change(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let word = str_param(p, "word").filter(|w| !w.is_empty()).ok_or_else(|| bad("review.spellingChange", "missing `word`"))?.to_string();
|
||||
let to = str_param(p, "to").ok_or_else(|| bad("review.spellingChange", "missing `to`"))?.to_string();
|
||||
let all = bool_or(p, "all", false);
|
||||
let shape = p.get("shape").and_then(Value::as_u64);
|
||||
if shape.is_none() && !all {
|
||||
return Err(bad("review.spellingChange", "give `shape`, or `all: true`"));
|
||||
}
|
||||
// One exact occurrence (a squiggled word): paragraph `para`, from character `start`.
|
||||
if let (Some(id), Some(para), Some(start)) = (shape, usize_param(p, "para"), usize_param(p, "start")) {
|
||||
let id = deckcraft_model::ShapeId(u32::try_from(id).map_err(|_| bad("review.spellingChange", "bad `shape`"))?);
|
||||
let end = start + word.chars().count();
|
||||
let cell = usize_param(p, "row").zip(usize_param(p, "col"));
|
||||
let slide = {
|
||||
let st = s.doc()?;
|
||||
let found = st.doc.slides.iter().position(|sl| {
|
||||
deckcraft_model::find_shape(&sl.shapes, id)
|
||||
.and_then(|sh| match (cell, &sh.kind) {
|
||||
(Some((r, c)), deckcraft_model::ShapeKind::Table(tb)) => tb.cell(r, c)?.text.paragraphs.get(para),
|
||||
(Some(_), _) => None,
|
||||
(None, _) => sh.text.as_ref()?.paragraphs.get(para),
|
||||
})
|
||||
.is_some_and(|pp| pp.text().chars().skip(start).take(end - start).collect::<String>() == word)
|
||||
});
|
||||
found.ok_or_else(|| bad("review.spellingChange", "the word isn't there"))?
|
||||
};
|
||||
s.edit(|doc, _| {
|
||||
let sl = doc.slides.get_mut(slide).ok_or_else(|| bad("review.spellingChange", "no slide"))?;
|
||||
let sh = deckcraft_model::find_shape_mut(&mut Arc::make_mut(sl).shapes, id).ok_or_else(|| bad("review.spellingChange", "no shape"))?;
|
||||
let pp = match (cell, &mut sh.kind) {
|
||||
(Some((r, c)), deckcraft_model::ShapeKind::Table(tb)) => tb.cell_mut(r, c).and_then(|cl| cl.text.paragraphs.get_mut(para)),
|
||||
(Some(_), _) => None,
|
||||
(None, _) => sh.text.as_mut().and_then(|t| t.paragraphs.get_mut(para)),
|
||||
};
|
||||
if let Some(pp) = pp {
|
||||
replace_chars(pp, start, end, &to);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
return Ok(json!({"changed": 1}));
|
||||
}
|
||||
// Nothing to change makes no undo step.
|
||||
let mut any = false;
|
||||
let st = s.doc()?;
|
||||
for sl in &st.doc.slides {
|
||||
deckcraft_model::walk(&sl.shapes, &mut |sh, _| {
|
||||
if shape.is_none_or(|id| serde_json::to_value(sh.id).ok().and_then(|v| v.as_u64()) == Some(id))
|
||||
&& let Some(t) = &sh.text
|
||||
{
|
||||
any |= t.paragraphs.iter().flat_map(|p| &p.runs).any(|r| replace_word(&mut r.text.clone(), &word, &to, false) > 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
if !any {
|
||||
return Ok(json!({"changed": 0}));
|
||||
}
|
||||
let changed = s.edit(|doc, _| {
|
||||
let mut n = 0;
|
||||
let id_of = |sh: &deckcraft_model::Shape| serde_json::to_value(sh.id).ok().and_then(|v| v.as_u64());
|
||||
for sl in &mut doc.slides {
|
||||
let mut hit = false;
|
||||
deckcraft_model::walk(&sl.shapes, &mut |sh, _| {
|
||||
hit |= shape.is_none_or(|id| id_of(sh) == Some(id)) && sh.text.as_ref().is_some_and(|t| t.text().contains(word.as_str()));
|
||||
});
|
||||
if !hit || (!all && n > 0) {
|
||||
continue;
|
||||
}
|
||||
walk_text_mut(&mut Arc::make_mut(sl).shapes, 0, &mut |sh| {
|
||||
if shape.is_some_and(|id| id_of(sh) != Some(id)) || (!all && n > 0) {
|
||||
return;
|
||||
}
|
||||
let Some(t) = sh.text.as_mut() else { return };
|
||||
for r in t.paragraphs.iter_mut().flat_map(|p| p.runs.iter_mut()) {
|
||||
if !all && n > 0 {
|
||||
break;
|
||||
}
|
||||
n += replace_word(&mut r.text, &word, &to, all);
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(n)
|
||||
})?;
|
||||
Ok(json!({"changed": changed}))
|
||||
}
|
||||
|
||||
/// Replace characters `start..end` of `p` with `to`, in the run where they start (later runs lose
|
||||
/// the rest of the range).
|
||||
fn replace_chars(p: &mut deckcraft_model::Paragraph, start: usize, end: usize, to: &str) {
|
||||
let mut at = 0;
|
||||
let mut placed = false;
|
||||
for r in &mut p.runs {
|
||||
let n = r.char_len();
|
||||
let (a, b) = (start.max(at), end.min(at + n));
|
||||
if a < b && r.kind == deckcraft_model::text::RunKind::Text {
|
||||
let byte = |c: usize| r.text.char_indices().nth(c).map_or(r.text.len(), |(i, _)| i);
|
||||
let (x, y) = (byte(a - at), byte(b - at));
|
||||
r.text.replace_range(x..y, if placed { "" } else { to });
|
||||
placed = true;
|
||||
}
|
||||
at += n;
|
||||
}
|
||||
p.runs.retain(|r| !(r.kind == deckcraft_model::text::RunKind::Text && r.text.is_empty()));
|
||||
}
|
||||
|
||||
fn walk_text_mut(shapes: &mut [deckcraft_model::Shape], depth: usize, f: &mut dyn FnMut(&mut deckcraft_model::Shape)) {
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
for sh in shapes.iter_mut() {
|
||||
f(sh);
|
||||
if let deckcraft_model::ShapeKind::Group { children, .. } = &mut sh.kind {
|
||||
walk_text_mut(children, depth + 1, f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dictionary_info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let lang = match s.doc() {
|
||||
Ok(st) => check_lang(&st.doc, p),
|
||||
Err(_) => str_param(p, "lang").unwrap_or("en-US").to_string(),
|
||||
};
|
||||
let dirs = crate::spell::search_dirs();
|
||||
let found = crate::spell::find_in(&dirs, &lang).map(|(a, _)| a.with_extension("").display().to_string());
|
||||
let available = crate::spell::dictionary(&lang).is_some();
|
||||
Ok(json!({"lang": lang, "available": available, "found": found, "folders": dirs.iter().map(|d| d.display().to_string()).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
fn start(s: &mut Session, from: usize) -> Result<Value> {
|
||||
s.ui_requests.push(UiRequest::StartShow { from });
|
||||
Ok(json!({"from": from}))
|
||||
@@ -240,11 +572,25 @@ fn setup(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(v) = bool_param(p, "useTimings") {
|
||||
sh.use_timings = v;
|
||||
}
|
||||
if let Some(v) = bool_param(p, "showMediaControls") {
|
||||
sh.show_media_controls = v;
|
||||
}
|
||||
match (p.get("from").and_then(Value::as_u64), p.get("to").and_then(Value::as_u64)) {
|
||||
(Some(a), Some(b)) => sh.range = Some((a.min(u32::MAX as u64) as u32, b.min(u32::MAX as u64) as u32)),
|
||||
(None, None) if p.get("all").is_some() => sh.range = None,
|
||||
_ => {}
|
||||
}
|
||||
if p.get("all").and_then(Value::as_bool) == Some(true) {
|
||||
sh.range = None;
|
||||
sh.custom_show = None;
|
||||
}
|
||||
if p.get("custom").is_some() {
|
||||
let name = p.get("custom").and_then(Value::as_str).unwrap_or("");
|
||||
sh.custom_show = if name.is_empty() { None } else { Some(name.to_string()) };
|
||||
if sh.custom_show.is_some() {
|
||||
sh.range = None;
|
||||
}
|
||||
}
|
||||
Ok(serde_json::to_value(&*sh).unwrap_or_default())
|
||||
})
|
||||
}
|
||||
@@ -264,3 +610,236 @@ fn custom_show(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
/// The selected text when it lies in one paragraph, else the word at the caret.
|
||||
fn word_under_caret(s: &Session) -> Option<String> {
|
||||
let st = s.doc().ok()?;
|
||||
let t = st.selection.text.clone()?;
|
||||
let body = super::text::body_of(st, &t)?;
|
||||
let (a, b) = if t.anchor != t.caret { t.ordered() } else { deckcraft_model::edit::word_at(body, t.caret) };
|
||||
if a.0 != b.0 {
|
||||
return None;
|
||||
}
|
||||
let text = body.paragraphs.get(a.0)?.text();
|
||||
let w: String = text.chars().skip(a.1).take(b.1.saturating_sub(a.1)).collect();
|
||||
let w = w.trim().to_string();
|
||||
(!w.is_empty()).then_some(w)
|
||||
}
|
||||
|
||||
fn thesaurus(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let word = str_param(p, "word").map(str::to_string).or_else(|| word_under_caret(s)).unwrap_or_default();
|
||||
let senses = crate::thesaurus::lookup(&word);
|
||||
Ok(json!({"word": word, "senses": senses}))
|
||||
}
|
||||
|
||||
fn insert_synonym(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let with = str_param(p, "with").filter(|w| !w.trim().is_empty()).ok_or_else(|| bad("review.replaceWord", "missing `with`"))?.to_string();
|
||||
if with.chars().count() > 200 {
|
||||
return Err(bad("review.replaceWord", "too long"));
|
||||
}
|
||||
let collapsed = s.doc()?.selection.text.as_ref().is_some_and(|t| t.anchor == t.caret);
|
||||
if collapsed {
|
||||
s.execute("text.selectWord", &json!({}))?;
|
||||
}
|
||||
s.execute("text.insert", &json!({"text": with}))
|
||||
}
|
||||
|
||||
fn comparing(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.active().is_some_and(|d| d.compare.is_some()) { Ok(()) } else { Err("not comparing".into()) }
|
||||
}
|
||||
|
||||
fn revised_bytes(p: &Value) -> Result<(String, Vec<u8>)> {
|
||||
if let Some(data) = str_param(p, "data") {
|
||||
let bytes = base64_decode(data).ok_or_else(|| bad("review.compare", "invalid base64 `data`"))?;
|
||||
return Ok((str_param(p, "name").unwrap_or("Revised").to_string(), bytes));
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(path) = str_param(p, "path") {
|
||||
let bytes = crate::io::read_file(path).map_err(crate::EngineError::Other)?;
|
||||
return Ok((path.to_string(), bytes));
|
||||
}
|
||||
Err(bad("review.compare", "give `path` or `data`"))
|
||||
}
|
||||
|
||||
fn revisions_json(st: &crate::DocState) -> Result<Value> {
|
||||
let c = st.compare.as_ref().ok_or_else(|| bad("review.revisions", "not comparing"))?;
|
||||
let name = std::path::Path::new(&c.name).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| c.name.clone());
|
||||
Ok(json!({"name": name, "changes": crate::compare::pending(&st.doc, c), "byPosition": c.by_position}))
|
||||
}
|
||||
|
||||
fn compare(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (name, bytes) = revised_bytes(p)?;
|
||||
let revised = super::file::open_presentation(&name, &bytes)?;
|
||||
let st = s.doc_mut()?;
|
||||
let by_position = crate::compare::unrelated(&st.doc, &revised);
|
||||
st.compare = Some(crate::compare::Compare { name, revised: Arc::new(revised), by_position, rejected: Default::default() });
|
||||
s.ui_requests.push(UiRequest::Pane { id: "revisions".into() });
|
||||
revisions_json(s.doc()?)
|
||||
}
|
||||
|
||||
fn revisions(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
revisions_json(s.doc()?)
|
||||
}
|
||||
|
||||
fn accept(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmp = s.doc()?.compare.clone().ok_or_else(|| bad("review.accept", "not comparing"))?;
|
||||
let rev = cmp.revised.clone();
|
||||
let all = bool_param(p, "all").unwrap_or(false);
|
||||
let index = usize_param(p, "index");
|
||||
if !all && index.is_none() {
|
||||
return Err(bad("review.accept", "give `index` or `all`"));
|
||||
}
|
||||
s.edit(|doc, sel| {
|
||||
let mut accepted = 0usize;
|
||||
// Each accept shifts indices, so the list is recomputed; bounded by the slide counts.
|
||||
let limit = doc.slides.len().saturating_add(rev.slides.len()).saturating_add(1);
|
||||
for _ in 0..limit {
|
||||
let changes = crate::compare::pending(doc, &cmp);
|
||||
let c = if all {
|
||||
changes.into_iter().find(|c| !(c.kind == crate::compare::ChangeKind::Deleted && doc.slides.len() <= 1))
|
||||
} else {
|
||||
changes.into_iter().nth(index.unwrap_or(0))
|
||||
};
|
||||
let Some(c) = c else { break };
|
||||
let Some(at) = crate::compare::accept(doc, &rev, &c)? else { break };
|
||||
accepted += 1;
|
||||
sel.slide = at;
|
||||
sel.shapes.clear();
|
||||
sel.text = None;
|
||||
if !all {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if accepted == 0 {
|
||||
return Err(bad("review.accept", "no such revision"));
|
||||
}
|
||||
Ok(json!({"accepted": accepted}))
|
||||
})
|
||||
}
|
||||
|
||||
fn reject(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let all = bool_param(p, "all").unwrap_or(false);
|
||||
let index = usize_param(p, "index");
|
||||
if !all && index.is_none() {
|
||||
return Err(bad("review.reject", "give `index` or `all`"));
|
||||
}
|
||||
let st = s.doc_mut()?;
|
||||
let doc = st.doc.clone();
|
||||
let c = st.compare.as_mut().ok_or_else(|| bad("review.reject", "not comparing"))?;
|
||||
let pending = crate::compare::pending(&doc, c);
|
||||
let picked: Vec<_> = if all { pending } else { pending.into_iter().nth(index.unwrap_or(0)).into_iter().collect() };
|
||||
if picked.is_empty() {
|
||||
return Err(bad("review.reject", "no such revision"));
|
||||
}
|
||||
c.rejected.extend(picked.iter().map(|ch| crate::compare::ChangeKey::of(&doc, ch)));
|
||||
Ok(json!({"rejected": picked.len()}))
|
||||
}
|
||||
|
||||
fn end_compare(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
s.doc_mut()?.compare = None;
|
||||
ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn previous_and_next_comment_walk_the_deck() {
|
||||
let mut s = Session::with_new();
|
||||
let err = s.execute("comment.next", &json!({})).unwrap_err();
|
||||
assert!(err.to_string().contains("no comments"), "{err}");
|
||||
|
||||
s.execute("comment.add", &json!({"text": "on first"})).unwrap();
|
||||
s.execute("shape.insert", &json!({"preset": "rect", "rect": [10.0, 10.0, 40.0, 40.0]})).unwrap();
|
||||
s.execute("slide.new", &json!({})).unwrap();
|
||||
s.execute("comment.add", &json!({"text": "on second a"})).unwrap();
|
||||
s.execute("comment.add", &json!({"text": "on second b"})).unwrap();
|
||||
s.execute("slide.new", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.slide, 2);
|
||||
|
||||
let err = s.execute("comment.next", &json!({})).unwrap_err();
|
||||
assert!(err.to_string().contains("no more comments"), "{err}");
|
||||
|
||||
let undo_before = s.doc().unwrap().history.undo.len();
|
||||
let back = s.execute("comment.prev", &json!({})).unwrap();
|
||||
assert_eq!(back["slide"], 1);
|
||||
assert_eq!(back["index"], 1);
|
||||
assert_eq!(back["text"], "on second b");
|
||||
assert_eq!(s.doc().unwrap().selection.slide, 1);
|
||||
assert!(s.doc().unwrap().selection.shapes.is_empty());
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo_before);
|
||||
|
||||
let prev = s.execute("comment.prev", &json!({"slide": 1, "index": 1})).unwrap();
|
||||
assert_eq!(prev["index"], 0);
|
||||
assert_eq!(prev["text"], "on second a");
|
||||
let first = s.execute("comment.prev", &json!({"slide": prev["slide"], "index": prev["index"]})).unwrap();
|
||||
assert_eq!(first["slide"], 0);
|
||||
assert_eq!(first["text"], "on first");
|
||||
let err = s.execute("comment.prev", &json!({"slide": 0, "index": 0})).unwrap_err();
|
||||
assert!(err.to_string().contains("no more comments"), "{err}");
|
||||
|
||||
let next = s.execute("comment.next", &json!({"slide": 0, "index": 0})).unwrap();
|
||||
assert_eq!(next["slide"], 1);
|
||||
assert_eq!(next["text"], "on second a");
|
||||
s.execute("slide.go", &json!({"index": 0})).unwrap();
|
||||
let here = s.execute("comment.next", &json!({})).unwrap();
|
||||
assert_eq!(here["text"], "on first");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamps_are_iso_utc() {
|
||||
assert_eq!(iso_utc(0), "1970-01-01T00:00:00Z");
|
||||
assert_eq!(iso_utc(1_791_637_835), "2026-10-10T13:10:35Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_clock_is_not_empty() {
|
||||
// On wasm this used to return an empty string, so browser comments were saved undated.
|
||||
assert!(now().starts_with("20"), "{}", now());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_media_controls_follow_setup() {
|
||||
let mut s = Session::with_new();
|
||||
assert!(s.doc().expect("doc").doc.show.show_media_controls);
|
||||
let off = s.execute("show.setup", &json!({"showMediaControls": false})).expect("off");
|
||||
assert_eq!(off["showMediaControls"], false);
|
||||
assert_eq!(off["useTimings"], true, "other setup flags stay");
|
||||
let timings = s.execute("show.setup", &json!({"useTimings": false})).expect("timings");
|
||||
assert_eq!(timings["showMediaControls"], false);
|
||||
assert_eq!(timings["useTimings"], false);
|
||||
s.execute("show.setup", &json!({"showMediaControls": true})).expect("on");
|
||||
assert!(s.doc().expect("doc").doc.show.show_media_controls);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setup_remembers_a_custom_show() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({})).unwrap();
|
||||
s.execute("slide.new", &json!({})).unwrap();
|
||||
s.execute("show.customShow", &json!({"name": "Short", "slides": [0, 1]})).unwrap();
|
||||
let v = s.execute("show.setup", &json!({"custom": "Short"})).unwrap();
|
||||
assert_eq!(v.get("customShow").and_then(Value::as_str), Some("Short"));
|
||||
assert!(s.active().unwrap().doc.show.range.is_none());
|
||||
s.execute("show.setup", &json!({"custom": ""})).unwrap();
|
||||
assert!(s.active().unwrap().doc.show.custom_show.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_rejects_an_invalid_index_and_keeps_the_comments() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("comment.add", &json!({"text": "a"})).unwrap();
|
||||
s.execute("comment.add", &json!({"text": "b"})).unwrap();
|
||||
for bad_index in [json!(-1), json!("2"), json!(1.5)] {
|
||||
let r = s.execute("comment.delete", &json!({"index": bad_index}));
|
||||
assert!(r.is_err(), "index {bad_index} gave {r:?}");
|
||||
assert_eq!(s.active().unwrap().doc.slides[0].comments.len(), 2, "index {bad_index}");
|
||||
}
|
||||
s.execute("comment.delete", &json!({})).unwrap();
|
||||
assert!(s.active().unwrap().doc.slides[0].comments.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
//! Rich text on the system clipboard, as HTML.
|
||||
//!
|
||||
//! Pages and Keynote put copied text on the pasteboard as HTML (and plain text). DeckCraft writes
|
||||
//! the same pair when it copies, and reads it back on paste: bold, italic and underline survive.
|
||||
//! Anything else in the markup is skipped. A hostile fragment is capped, never executed.
|
||||
|
||||
use deckcraft_model::text::{Paragraph, Run, RunProps, TextBody};
|
||||
|
||||
const MAX_HTML: usize = 1 << 20;
|
||||
const MAX_PARAS: usize = 2_000;
|
||||
const MAX_RUNS: usize = 10_000;
|
||||
|
||||
/// HTML for `body`: one `<p>` per paragraph, `<b>`, `<i>` and `<u>` for the formatting.
|
||||
pub fn body_to_html(body: &TextBody) -> String {
|
||||
let mut out = String::new();
|
||||
for (i, p) in body.paragraphs.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push_str("<br>");
|
||||
}
|
||||
out.push_str("<p>");
|
||||
for r in &p.runs {
|
||||
let (open, close) = tags(&r.props);
|
||||
out.push_str(&open);
|
||||
match &r.kind {
|
||||
deckcraft_model::text::RunKind::Break => out.push_str("<br>"),
|
||||
_ => push_escaped(&mut out, &r.text),
|
||||
}
|
||||
out.push_str(&close);
|
||||
if out.len() > MAX_HTML {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out.push_str("</p>");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn tags(props: &RunProps) -> (String, String) {
|
||||
let mut open = String::new();
|
||||
let mut close = String::new();
|
||||
if props.bold == Some(true) {
|
||||
open.push_str("<b>");
|
||||
close.insert_str(0, "</b>");
|
||||
}
|
||||
if props.italic == Some(true) {
|
||||
open.push_str("<i>");
|
||||
close.insert_str(0, "</i>");
|
||||
}
|
||||
if props.underline.as_deref().is_some_and(|u| !u.eq_ignore_ascii_case("none")) {
|
||||
open.push_str("<u>");
|
||||
close.insert_str(0, "</u>");
|
||||
}
|
||||
(open, close)
|
||||
}
|
||||
|
||||
fn push_escaped(out: &mut String, text: &str) {
|
||||
for c in text.chars() {
|
||||
match c {
|
||||
'&' => out.push_str("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
_ => out.push(c),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The formatted text of an HTML fragment, or `None` when it has no text.
|
||||
///
|
||||
/// Understands `<b>`, `<strong>`, `<i>`, `<em>`, `<u>`, `<br>`, paragraphs, and a `style` that
|
||||
/// says bold, italic or underline. Other tags are ignored. The result is capped.
|
||||
pub fn html_to_body(html: &str) -> Option<TextBody> {
|
||||
if html.len() > MAX_HTML {
|
||||
return None;
|
||||
}
|
||||
let bytes = html.as_bytes();
|
||||
let mut i = 0;
|
||||
let mut bold = 0i32;
|
||||
let mut italic = 0i32;
|
||||
let mut underline = 0i32;
|
||||
let mut stack: Vec<(&'static str, i32, i32, i32)> = Vec::new();
|
||||
let mut paragraphs: Vec<Paragraph> = vec![Paragraph::default()];
|
||||
let mut runs = 0usize;
|
||||
let mut skip = 0i32;
|
||||
while i < bytes.len() && paragraphs.len() <= MAX_PARAS && runs <= MAX_RUNS {
|
||||
if bytes[i] == b'<' {
|
||||
let end = bytes[i + 1..].iter().position(|c| *c == b'>').map(|n| i + 1 + n).unwrap_or(bytes.len());
|
||||
let raw = &html[i + 1..end];
|
||||
i = (end + 1).min(bytes.len());
|
||||
let token = raw.trim();
|
||||
if token.starts_with('!') || token.starts_with('?') {
|
||||
continue;
|
||||
}
|
||||
let closing = token.starts_with('/');
|
||||
let name_src = token.trim_start_matches('/').trim();
|
||||
let name_end = name_src.find(|c: char| c.is_whitespace() || c == '/' || c == '>').unwrap_or(name_src.len());
|
||||
let name = name_src[..name_end].to_ascii_lowercase();
|
||||
if name == "script" || name == "style" || name == "head" {
|
||||
if closing {
|
||||
skip = (skip - 1).max(0);
|
||||
} else if !token.ends_with('/') {
|
||||
skip += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if skip > 0 {
|
||||
continue;
|
||||
}
|
||||
if closing {
|
||||
let kind = tag_kind(&name);
|
||||
if !kind.is_empty() {
|
||||
let search_start = stack.len().saturating_sub(64);
|
||||
if let Some(offset) = stack[search_start..].iter().rposition(|e| e.0 == kind) {
|
||||
let pos = search_start + offset;
|
||||
let (_, db, di, du) = stack[pos];
|
||||
bold -= db;
|
||||
italic -= di;
|
||||
underline -= du;
|
||||
stack.truncate(pos);
|
||||
}
|
||||
}
|
||||
if is_block(&name) {
|
||||
new_para(&mut paragraphs);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if is_break(&name) {
|
||||
new_para(&mut paragraphs);
|
||||
continue;
|
||||
}
|
||||
let (db, di, du) = tag_style(&name, name_src);
|
||||
if db + di + du > 0 {
|
||||
bold += db;
|
||||
italic += di;
|
||||
underline += du;
|
||||
if !token.ends_with('/') && stack.len() < 128 {
|
||||
stack.push((tag_kind(&name), db, di, du));
|
||||
} else {
|
||||
bold -= db;
|
||||
italic -= di;
|
||||
underline -= du;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if skip > 0 {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let end = bytes[i..].iter().position(|c| *c == b'<').map(|n| i + n).unwrap_or(bytes.len());
|
||||
let mut text = decode_text(&html[i..end]);
|
||||
i = end;
|
||||
// A space at the start of a paragraph, or after a space, does not render in HTML.
|
||||
let after_space = paragraphs.last().and_then(|p| p.runs.last()).is_none_or(|r| r.text.ends_with(' '));
|
||||
if after_space {
|
||||
text = text.trim_start_matches(' ').to_string();
|
||||
}
|
||||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
push_run(&mut paragraphs, &mut runs, text, bold > 0, italic > 0, underline > 0);
|
||||
}
|
||||
while paragraphs.last().is_some_and(Paragraph::is_empty) && paragraphs.len() > 1 {
|
||||
paragraphs.pop();
|
||||
}
|
||||
trim_end_space(&mut paragraphs);
|
||||
if paragraphs.iter().all(Paragraph::is_empty) {
|
||||
return None;
|
||||
}
|
||||
for p in &mut paragraphs {
|
||||
p.normalize();
|
||||
}
|
||||
Some(TextBody { paragraphs, ..Default::default() })
|
||||
}
|
||||
|
||||
fn tag_kind(name: &str) -> &'static str {
|
||||
match name {
|
||||
"b" | "strong" => "b",
|
||||
"i" | "em" => "i",
|
||||
"u" => "u",
|
||||
"span" | "font" => "span",
|
||||
"p" | "div" | "li" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" => "block",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
fn is_break(name: &str) -> bool {
|
||||
name == "br" || name == "hr"
|
||||
}
|
||||
|
||||
fn is_block(name: &str) -> bool {
|
||||
matches!(name, "p" | "div" | "li" | "tr" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "blockquote")
|
||||
}
|
||||
|
||||
fn tag_style(name: &str, src: &str) -> (i32, i32, i32) {
|
||||
let mut bold = i32::from(name == "b" || name == "strong");
|
||||
let mut italic = i32::from(name == "i" || name == "em");
|
||||
let mut underline = i32::from(name == "u");
|
||||
if let Some(style) = attr(src, "style") {
|
||||
let s = style.to_ascii_lowercase();
|
||||
if let Some(on) = weight_bold(&s) {
|
||||
bold = i32::from(on);
|
||||
}
|
||||
if s.contains("font-style:italic") || s.contains("font-style: italic") {
|
||||
italic = 1;
|
||||
}
|
||||
if s.contains("text-decoration:underline")
|
||||
|| s.contains("text-decoration: underline")
|
||||
|| s.contains("text-decoration-line:underline")
|
||||
|| s.contains("text-decoration-line: underline")
|
||||
{
|
||||
underline = 1;
|
||||
}
|
||||
}
|
||||
(bold, italic, underline)
|
||||
}
|
||||
|
||||
/// Whether a `font-weight` in `style` turns bold on (`Some(true)`) or off (`Some(false)`).
|
||||
fn weight_bold(style: &str) -> Option<bool> {
|
||||
let at = style.find("font-weight")?;
|
||||
let rest = style[at + "font-weight".len()..].trim_start_matches(|c: char| c == ':' || c.is_whitespace());
|
||||
if rest.starts_with("bold") {
|
||||
return Some(true);
|
||||
}
|
||||
if rest.starts_with("normal") || rest.starts_with("lighter") {
|
||||
return Some(false);
|
||||
}
|
||||
let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
|
||||
let weight: u32 = digits.parse().ok()?;
|
||||
Some(weight >= 600)
|
||||
}
|
||||
|
||||
fn attr<'a>(tag: &'a str, key: &str) -> Option<&'a str> {
|
||||
let lower = tag.to_ascii_lowercase();
|
||||
let at = lower.find(key)?;
|
||||
let after = tag.get(at + key.len()..)?.trim_start();
|
||||
let after = after.strip_prefix('=')?.trim_start();
|
||||
let quote = after.chars().next()?;
|
||||
if quote != '"' && quote != '\'' {
|
||||
return None;
|
||||
}
|
||||
let rest = after.get(quote.len_utf8()..)?;
|
||||
let end = rest.find(quote)?;
|
||||
rest.get(..end)
|
||||
}
|
||||
|
||||
fn new_para(paragraphs: &mut Vec<Paragraph>) {
|
||||
if paragraphs.len() >= MAX_PARAS {
|
||||
return;
|
||||
}
|
||||
if paragraphs.last().is_some_and(|p| !p.is_empty()) {
|
||||
trim_end_space(paragraphs);
|
||||
paragraphs.push(Paragraph::default());
|
||||
}
|
||||
}
|
||||
|
||||
/// Whitespace at the end of a paragraph does not render in HTML.
|
||||
fn trim_end_space(paragraphs: &mut [Paragraph]) {
|
||||
if let Some(run) = paragraphs.last_mut().and_then(|p| p.runs.last_mut()) {
|
||||
let len = run.text.trim_end_matches(' ').len();
|
||||
run.text.truncate(len);
|
||||
}
|
||||
}
|
||||
|
||||
fn push_run(paragraphs: &mut [Paragraph], runs: &mut usize, text: String, bold: bool, italic: bool, underline: bool) {
|
||||
if *runs >= MAX_RUNS {
|
||||
return;
|
||||
}
|
||||
let Some(p) = paragraphs.last_mut() else { return };
|
||||
let mut props = RunProps::default();
|
||||
if bold {
|
||||
props.bold = Some(true);
|
||||
}
|
||||
if italic {
|
||||
props.italic = Some(true);
|
||||
}
|
||||
if underline {
|
||||
props.underline = Some("sng".into());
|
||||
}
|
||||
p.runs.push(Run::with(text, props));
|
||||
*runs += 1;
|
||||
}
|
||||
|
||||
/// Text with each run of HTML whitespace (spaces, tabs, line breaks) collapsed to one space.
|
||||
fn collapse_space(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut space = false;
|
||||
for c in s.chars() {
|
||||
if matches!(c, ' ' | '\t' | '\n' | '\r') {
|
||||
if !space {
|
||||
out.push(' ');
|
||||
}
|
||||
space = true;
|
||||
} else {
|
||||
out.push(c);
|
||||
space = false;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn decode_text(raw: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut rest = raw;
|
||||
while let Some(at) = rest.find('&') {
|
||||
out.push_str(&collapse_space(&rest[..at]));
|
||||
rest = &rest[at..];
|
||||
let search_window = rest.len().min(32);
|
||||
let Some(end) = rest[..search_window].find(';') else {
|
||||
out.push('&');
|
||||
rest = &rest[1..];
|
||||
continue;
|
||||
};
|
||||
let entity = &rest[1..end];
|
||||
let decoded = match entity {
|
||||
"amp" => Some('&'),
|
||||
"lt" => Some('<'),
|
||||
"gt" => Some('>'),
|
||||
"quot" => Some('"'),
|
||||
"apos" => Some('\''),
|
||||
"nbsp" => Some('\u{a0}'),
|
||||
_ => entity.strip_prefix('#').and_then(numeric_char),
|
||||
};
|
||||
if let Some(c) = decoded {
|
||||
out.push(c);
|
||||
rest = &rest[end + 1..];
|
||||
} else {
|
||||
// Not a well-formed entity (e.g. a bare "R&D"): keep the '&' as typed.
|
||||
out.push('&');
|
||||
rest = &rest[1..];
|
||||
}
|
||||
}
|
||||
if !rest.is_empty() && !rest.starts_with('&') {
|
||||
out.push_str(&collapse_space(rest));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn numeric_char(digits: &str) -> Option<char> {
|
||||
let hex = digits.strip_prefix('x').or_else(|| digits.strip_prefix('X'));
|
||||
// `parse` and `from_str_radix` accept a leading '+', which is not a valid reference.
|
||||
let (body, radix) = hex.map_or((digits, 10), |h| (h, 16));
|
||||
if body.is_empty() || !body.chars().all(|c| c.is_digit(radix)) {
|
||||
return None;
|
||||
}
|
||||
let n = u32::from_str_radix(body, radix).ok()?;
|
||||
char::from_u32(n).filter(|c| !c.is_control() || *c == '\n')
|
||||
}
|
||||
|
||||
/// Do the HTML's words and the paste's plain text say the same thing?
|
||||
pub fn same_words(html_plain: &str, pasted: &str) -> bool {
|
||||
let a = fold(html_plain);
|
||||
let b = fold(pasted);
|
||||
a == b
|
||||
}
|
||||
|
||||
fn fold(s: &str) -> String {
|
||||
s.replace("\r\n", "\n").replace('\r', "\n").replace('\u{00a0}', " ").trim_matches(['\n', ' ', '\t']).to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn signed_numeric_entities_are_kept_as_typed() {
|
||||
assert_eq!(decode_text("a&#+65;b"), "a&#+65;b");
|
||||
assert_eq!(decode_text("a&#x+41;b"), "a&#x+41;b");
|
||||
assert_eq!(decode_text("aAbA"), "aAbA");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn google_docs_normal_weight_wrapper_is_not_bold() {
|
||||
let html = "<meta charset=\"utf-8\"><b style=\"font-weight:normal;\" id=\"docs-internal-guid-1\"><span style=\"font-weight:400\">plain words</span></b>";
|
||||
let body = html_to_body(html).unwrap();
|
||||
assert_eq!(body.text(), "plain words");
|
||||
assert_eq!(body.paragraphs[0].runs[0].props.bold, None);
|
||||
let light = html_to_body("<b style=\"font-weight:300\">thin</b>").unwrap();
|
||||
assert_eq!(light.paragraphs[0].runs[0].props.bold, None);
|
||||
let plain = html_to_body("<b>strong</b>").unwrap();
|
||||
assert_eq!(plain.paragraphs[0].runs[0].props.bold, Some(true));
|
||||
let heavy = html_to_body("<span style=\"font-weight:700\">heavy</span>").unwrap();
|
||||
assert_eq!(heavy.paragraphs[0].runs[0].props.bold, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bold_italic_and_underline_round_trip() {
|
||||
let mut body = TextBody::from_text("Hi there");
|
||||
body.paragraphs[0].runs = vec![
|
||||
Run::with("Hi", RunProps { bold: Some(true), ..Default::default() }),
|
||||
Run::new(" "),
|
||||
Run::with("there", RunProps { italic: Some(true), underline: Some("sng".into()), ..Default::default() }),
|
||||
];
|
||||
let html = body_to_html(&body);
|
||||
assert!(html.contains("<b>Hi</b>"), "{html}");
|
||||
assert!(html.contains("<i><u>there</u></i>") || html.contains("<u><i>there</i></u>"), "{html}");
|
||||
let back = html_to_body(&html).unwrap();
|
||||
assert_eq!(back.text(), "Hi there");
|
||||
assert_eq!(back.paragraphs[0].runs[0].props.bold, Some(true));
|
||||
let last = back.paragraphs[0].runs.last().unwrap();
|
||||
assert_eq!(last.props.italic, Some(true));
|
||||
assert!(last.props.underline.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_ampersand_keeps_text_up_to_next_semicolon() {
|
||||
let text = |html: &str| html_to_body(html).unwrap().text();
|
||||
assert_eq!(text("<p>R&D costs; up</p>"), "R&D costs; up");
|
||||
assert_eq!(text("<p>AT&T; Q&A</p>"), "AT&T; Q&A");
|
||||
assert_eq!(text("<p>A & B & C & D</p>"), "A & B & C & D");
|
||||
|
||||
// Verify many bare ampersands do not cause quadratic scan freeze
|
||||
let hostile = format!("<p>{}</p>", "&".repeat(2000));
|
||||
assert_eq!(text(&hostile), "&".repeat(2000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pages_style_and_escapes() {
|
||||
let html = "<html><head><style>p{}</style></head><body><p><span style=\"font-weight: bold\">A & B</span></p><p><span style=\"font-style: italic\">C</span></p></body></html>";
|
||||
let body = html_to_body(html).unwrap();
|
||||
assert_eq!(body.text(), "A & B\nC");
|
||||
assert_eq!(body.paragraphs[0].runs[0].props.bold, Some(true));
|
||||
assert_eq!(body.paragraphs[1].runs[0].props.italic, Some(true));
|
||||
assert!(same_words(&body.text(), "A & B\nC"));
|
||||
assert!(!same_words(&body.text(), "other"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_newline_in_html_text_is_a_space() {
|
||||
assert_eq!(decode_text("Hello\nworld"), "Hello world");
|
||||
let body = html_to_body("<p>Hello\r\n world</p>").unwrap();
|
||||
assert_eq!(body.text(), "Hello world");
|
||||
let body = html_to_body("<p>\n<b>Hello</b>\n<i>world</i>\n</p>\n<p>next</p>").unwrap();
|
||||
assert_eq!(body.text(), "Hello world\nnext");
|
||||
assert!(same_words(&body.text(), "Hello world\nnext"));
|
||||
assert_eq!(body.paragraphs[0].runs[0].props.bold, Some(true));
|
||||
assert_eq!(body.paragraphs[0].runs.last().unwrap().props.italic, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_huge_fragment_is_refused() {
|
||||
let html = "x".repeat(MAX_HTML + 1);
|
||||
assert!(html_to_body(&html).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//! The ruler's data and tab-stop editing.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn editing() -> (Session, u64) {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
let id = s.execute("insert.textBox", &json!({"rect": [100, 50, 400, 40]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("text.edit", &json!({"id": id})).unwrap();
|
||||
s.execute("text.insert", &json!({"text": "a\tb"})).unwrap();
|
||||
(s, id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruler_reports_the_text_area_and_indents() {
|
||||
let (mut s, _) = editing();
|
||||
let r = s.execute("format.ruler", &json!({})).unwrap();
|
||||
assert!((r["x"].as_f64().unwrap() - 107.2).abs() < 0.01, "{r}");
|
||||
assert_eq!(r["rotated"], false);
|
||||
assert_eq!(r["tabs"].as_array().unwrap().len(), 0);
|
||||
s.execute("format.paragraph", &json!({"indentLeft": 36, "indentFirst": -18})).unwrap();
|
||||
let r = s.execute("format.ruler", &json!({})).unwrap();
|
||||
assert_eq!((r["marginLeft"].as_f64(), r["indent"].as_f64()), (Some(36.0), Some(-18.0)));
|
||||
s.execute("text.exit", &json!({})).unwrap();
|
||||
assert!(s.execute("format.ruler", &json!({})).is_err(), "only while editing text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tabs_add_move_remove_clear_and_undo() {
|
||||
let (mut s, _) = editing();
|
||||
let tabs = |s: &mut Session| -> Vec<(f64, String)> {
|
||||
s.execute("format.ruler", &json!({})).unwrap()["tabs"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|t| (t["pos"].as_f64().unwrap(), t["align"].as_str().unwrap().to_string()))
|
||||
.collect()
|
||||
};
|
||||
s.execute("format.tabs", &json!({"add": {"pos": 144, "align": "center"}})).unwrap();
|
||||
s.execute("format.tabs", &json!({"add": {"pos": 72}})).unwrap();
|
||||
assert_eq!(tabs(&mut s), vec![(72.0, "l".into()), (144.0, "ctr".into())], "sorted");
|
||||
s.execute("format.tabs", &json!({"add": {"pos": 72.2, "align": "r"}})).unwrap();
|
||||
assert_eq!(tabs(&mut s), vec![(72.2, "r".into()), (144.0, "ctr".into())], "a stop at the same place is replaced");
|
||||
s.execute("format.tabs", &json!({"move": {"from": 144, "to": 200}})).unwrap();
|
||||
s.execute("format.tabs", &json!({"remove": 72.2})).unwrap();
|
||||
assert_eq!(tabs(&mut s), vec![(200.0, "ctr".into())]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(tabs(&mut s).len(), 2);
|
||||
s.execute("format.tabs", &json!({"clear": true})).unwrap();
|
||||
assert!(tabs(&mut s).is_empty());
|
||||
s.ui_requests.clear();
|
||||
s.execute("format.tabs", &json!({})).unwrap();
|
||||
assert!(matches!(s.ui_requests.last(), Some(crate::UiRequest::Dialog { id, .. }) if id == "tabs"), "no fields opens the Tabs dialog");
|
||||
assert!(s.execute("format.tabs", &json!({"add": {"pos": f64::MAX}})).is_ok(), "clamped, not a crash");
|
||||
for i in 0..100 {
|
||||
s.execute("format.tabs", &json!({"add": {"pos": i as f64 * 3.0}})).unwrap();
|
||||
}
|
||||
assert!(tabs(&mut s).len() <= 32);
|
||||
s.execute("format.tabs", &json!({"set": [{"pos": 90, "align": "dec"}, {"pos": 30, "align": "right"}, {"pos": "x"}], "defaultTab": 36})).unwrap();
|
||||
assert_eq!(tabs(&mut s), vec![(30.0, "r".into()), (90.0, "dec".into())], "set replaces all, sorted, bad entries dropped");
|
||||
assert_eq!(s.execute("format.ruler", &json!({})).unwrap()["defaultTab"].as_f64(), Some(36.0));
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
//! Selection pane: picking objects from the list, Show All and Hide All. Its other actions are
|
||||
//! ordinary commands: `shape.visible` (eye), `shape.lock` (padlock), `shape.rename`,
|
||||
//! `arrange.bringForward` / `arrange.sendBackward` (arrows) and `arrange.reorder` (drag and drop).
|
||||
|
||||
use deckcraft_model::{Shape, ShapeId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"selection.select",
|
||||
"Select",
|
||||
["Selection Pane"],
|
||||
None,
|
||||
"{ids | id, toggle?: bool (Ctrl+click), add?: bool} → {selected}: objects in groups too; a hidden object picked from the list is shown",
|
||||
has_slide,
|
||||
select
|
||||
),
|
||||
cmd!("selection.showAll", "Show All", ["Selection Pane"], None, "{} shows every object on the slide, inside groups too", has_slide, show_all),
|
||||
cmd!("selection.hideAll", "Hide All", ["Selection Pane"], None, "{} hides every top-level object on the slide", has_slide, hide_all),
|
||||
]
|
||||
}
|
||||
|
||||
/// The Selection pane's click. Objects inside groups and top-level objects can't be selected
|
||||
/// together (Ctrl+click on the other kind is ignored, as in PowerPoint), and picking a hidden
|
||||
/// object from the list shows it again.
|
||||
fn select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = match ids_param(p, "ids") {
|
||||
Some(ids) => ids,
|
||||
None => id_param(p, "id", "selection.select")?.map(|i| vec![i]).unwrap_or_default(),
|
||||
};
|
||||
let (toggle, add) = (bool_or(p, "toggle", false), bool_or(p, "add", false));
|
||||
let st = s.doc()?;
|
||||
let in_group = |id: &ShapeId| deckcraft_model::parent_of(st.shapes(), *id).map(|p| p.is_some());
|
||||
let mut valid: Vec<ShapeId> = ids.iter().copied().filter(|i| in_group(i).is_some()).collect();
|
||||
if valid.is_empty() {
|
||||
return Err(bad("selection.select", "no such shape"));
|
||||
}
|
||||
let kind = valid.first().and_then(in_group);
|
||||
valid.retain(|i| in_group(i) == kind);
|
||||
let keep: Vec<ShapeId> = if toggle || add {
|
||||
let current = st.selection.shapes.clone();
|
||||
if current.first().is_some_and(|c| in_group(c) != kind) {
|
||||
// Can't mix objects in groups with top-level ones: nothing changes.
|
||||
return Ok(json!({"selected": current}));
|
||||
}
|
||||
current
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
let mut next = keep;
|
||||
for i in &valid {
|
||||
match next.iter().position(|x| x == i) {
|
||||
Some(k) if toggle => {
|
||||
next.remove(k);
|
||||
}
|
||||
Some(_) => {}
|
||||
None => next.push(*i),
|
||||
}
|
||||
}
|
||||
let reveal: Vec<ShapeId> = next.iter().copied().filter(|i| valid.contains(i) && st.shape(*i).is_some_and(|x| x.hidden)).collect();
|
||||
if reveal.is_empty() {
|
||||
s.select(|_, sel| {
|
||||
sel.text = None;
|
||||
sel.cells = None;
|
||||
sel.shapes = next.clone();
|
||||
})?;
|
||||
} else {
|
||||
s.edit(|doc, sel| {
|
||||
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad("selection.select", "no slide"))?;
|
||||
for id in &reveal {
|
||||
if let Some(sh) = deckcraft_model::find_shape_mut(shapes, *id) {
|
||||
sh.hidden = false;
|
||||
}
|
||||
}
|
||||
sel.text = None;
|
||||
sel.cells = None;
|
||||
sel.shapes = next.clone();
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(json!({"selected": s.doc()?.selection.shapes}))
|
||||
}
|
||||
|
||||
fn show_all(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
fn any_hidden(v: &[Shape]) -> bool {
|
||||
let mut found = false;
|
||||
deckcraft_model::walk(v, &mut |sh, _| found |= sh.hidden);
|
||||
found
|
||||
}
|
||||
if !any_hidden(s.doc()?.shapes()) {
|
||||
return ok();
|
||||
}
|
||||
s.edit(|doc, sel| {
|
||||
fn rec(v: &mut [Shape], depth: usize) {
|
||||
for sh in v {
|
||||
sh.hidden = false;
|
||||
if depth < 64
|
||||
&& let Some(c) = sh.children_mut()
|
||||
{
|
||||
rec(c, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
rec(crate::shapes_mut(doc, sel).ok_or_else(|| bad("selection.showAll", "no slide"))?, 0);
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
fn hide_all(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
if s.doc()?.shapes().iter().all(|x| x.hidden) {
|
||||
return ok();
|
||||
}
|
||||
s.edit(|doc, sel| {
|
||||
for sh in crate::shapes_mut(doc, sel).ok_or_else(|| bad("selection.hideAll", "no slide"))? {
|
||||
sh.hidden = true;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "selection_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,264 @@
|
||||
//! Selection pane: reorder, groups, hide/show, lock and undo.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::tools::{Mods, PointerEvent, PointerKind};
|
||||
use crate::*;
|
||||
|
||||
/// A blank slide with A, B, C (back to front) and G = group [D, H = group [E, F]] in front.
|
||||
fn deck() -> (Session, Vec<u32>) {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
let mut ids = vec![];
|
||||
for (i, n) in ["A", "B", "C", "D", "E", "F"].iter().enumerate() {
|
||||
let x = 20.0 + 110.0 * i as f64;
|
||||
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, 50, 100, 80]})).unwrap();
|
||||
let id = r["id"].as_u64().unwrap() as u32;
|
||||
s.execute("shape.rename", &json!({"id": id, "name": n})).unwrap();
|
||||
ids.push(id);
|
||||
}
|
||||
let h = s.execute("arrange.group", &json!({"ids": [ids[4], ids[5]]})).unwrap()["id"].as_u64().unwrap() as u32;
|
||||
s.execute("shape.rename", &json!({"id": h, "name": "H"})).unwrap();
|
||||
let g = s.execute("arrange.group", &json!({"ids": [ids[3], h]})).unwrap()["id"].as_u64().unwrap() as u32;
|
||||
s.execute("shape.rename", &json!({"id": g, "name": "G"})).unwrap();
|
||||
ids.extend([h, g]);
|
||||
(s, ids)
|
||||
}
|
||||
|
||||
/// The tree as text, back to front: `A B C G[D H[E F]]`.
|
||||
fn tree(s: &Session) -> String {
|
||||
fn rec(v: &[deckcraft_model::Shape]) -> String {
|
||||
v.iter().map(|x| if x.is_group() { format!("{}[{}]", x.name, rec(x.children())) } else { x.name.clone() }).collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
rec(s.doc().unwrap().shapes())
|
||||
}
|
||||
|
||||
/// (hidden, locked)
|
||||
fn flags(s: &Session, id: u32) -> (bool, bool) {
|
||||
let sh = s.doc().unwrap().shape(deckcraft_model::ShapeId(id)).unwrap();
|
||||
(sh.hidden, sh.locked)
|
||||
}
|
||||
|
||||
fn selected(s: &Session) -> Vec<u32> {
|
||||
s.doc().unwrap().selection.shapes.iter().map(|i| i.0).collect()
|
||||
}
|
||||
|
||||
fn undo(s: &mut Session) {
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
|
||||
fn bx(s: &mut Session, id: u32) -> Value {
|
||||
s.execute("shape.inspect", &json!({"id": id})).unwrap()["box"].clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bring_forward_and_send_backward_stay_inside_the_group() {
|
||||
let (mut s, ids) = deck();
|
||||
let (a, d, e, h) = (ids[0], ids[3], ids[4], ids[6]);
|
||||
assert_eq!(tree(&s), "A B C G[D H[E F]]");
|
||||
s.execute("arrange.bringForward", &json!({"ids": [a]})).unwrap();
|
||||
assert_eq!(tree(&s), "B A C G[D H[E F]]");
|
||||
// A child moves among its siblings only and stops at the group's front.
|
||||
s.execute("arrange.bringForward", &json!({"ids": [d]})).unwrap();
|
||||
assert_eq!(tree(&s), "B A C G[H[E F] D]");
|
||||
s.execute("arrange.bringForward", &json!({"ids": [d]})).unwrap();
|
||||
assert_eq!(tree(&s), "B A C G[H[E F] D]");
|
||||
s.execute("arrange.sendBackward", &json!({"ids": [e]})).unwrap();
|
||||
assert_eq!(tree(&s), "B A C G[H[E F] D]", "E is already at the back of H");
|
||||
s.execute("arrange.bringToFront", &json!({"ids": [e]})).unwrap();
|
||||
assert_eq!(tree(&s), "B A C G[H[F E] D]");
|
||||
s.execute("arrange.sendToBack", &json!({"ids": [d]})).unwrap();
|
||||
assert_eq!(tree(&s), "B A C G[D H[F E]]");
|
||||
s.execute("arrange.bringForward", &json!({"ids": [h]})).unwrap();
|
||||
assert_eq!(tree(&s), "B A C G[D H[F E]]", "H is already in front inside G");
|
||||
// Steps that changed nothing left no undo step.
|
||||
undo(&mut s);
|
||||
assert_eq!(tree(&s), "B A C G[H[F E] D]");
|
||||
undo(&mut s);
|
||||
undo(&mut s);
|
||||
assert_eq!(tree(&s), "B A C G[D H[E F]]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reorder_is_the_drag_and_drop_among_siblings() {
|
||||
let (mut s, ids) = deck();
|
||||
let (a, c, d, f, g) = (ids[0], ids[2], ids[3], ids[5], ids[7]);
|
||||
// Drag A to the front of the slide (an index past the end clamps).
|
||||
assert_eq!(s.execute("arrange.reorder", &json!({"id": a, "index": 99})).unwrap()["index"], 3);
|
||||
assert_eq!(tree(&s), "B C G[D H[E F]] A");
|
||||
s.execute("arrange.reorder", &json!({"id": g, "index": 0})).unwrap();
|
||||
assert_eq!(tree(&s), "G[D H[E F]] B C A");
|
||||
// A child is dragged within its group: it never leaves it.
|
||||
s.execute("arrange.reorder", &json!({"id": d, "index": 5})).unwrap();
|
||||
assert_eq!(tree(&s), "G[H[E F] D] B C A");
|
||||
s.execute("arrange.reorder", &json!({"id": f, "index": 0})).unwrap();
|
||||
assert_eq!(tree(&s), "G[H[F E] D] B C A");
|
||||
// Dropping where it already is changes nothing (no undo step).
|
||||
let n = s.doc().unwrap().history.undo.len();
|
||||
s.execute("arrange.reorder", &json!({"id": c, "index": 2})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), n);
|
||||
// One undo step per drop.
|
||||
undo(&mut s);
|
||||
assert_eq!(tree(&s), "G[H[E F] D] B C A");
|
||||
undo(&mut s);
|
||||
undo(&mut s);
|
||||
undo(&mut s);
|
||||
assert_eq!(tree(&s), "A B C G[D H[E F]]");
|
||||
assert!(s.execute("arrange.reorder", &json!({"id": 4242, "index": 0})).is_err());
|
||||
assert!(s.execute("arrange.reorder", &json!({"id": a})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eye_show_all_and_hide_all() {
|
||||
let (mut s, ids) = deck();
|
||||
let (a, b, d, e, g) = (ids[0], ids[1], ids[3], ids[4], ids[7]);
|
||||
s.execute("shape.visible", &json!({"id": b, "visible": false})).unwrap();
|
||||
assert!(flags(&s, b).0);
|
||||
// Hiding a group hides only the group; its children keep their own flags.
|
||||
s.execute("shape.visible", &json!({"id": g})).unwrap();
|
||||
assert!(flags(&s, g).0 && !flags(&s, d).0);
|
||||
s.execute("shape.visible", &json!({"id": e, "visible": false})).unwrap();
|
||||
// Hide All hides the top level only.
|
||||
s.execute("selection.hideAll", &json!({})).unwrap();
|
||||
assert!(flags(&s, a).0 && flags(&s, g).0 && !flags(&s, d).0 && flags(&s, e).0);
|
||||
// Show All shows everything, children included.
|
||||
s.execute("selection.showAll", &json!({})).unwrap();
|
||||
for id in &ids {
|
||||
assert!(!flags(&s, *id).0, "{id} still hidden");
|
||||
}
|
||||
let n = s.doc().unwrap().history.undo.len();
|
||||
s.execute("selection.showAll", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), n, "nothing to show: no undo step");
|
||||
undo(&mut s);
|
||||
assert!(flags(&s, a).0 && flags(&s, e).0);
|
||||
undo(&mut s);
|
||||
assert!(!flags(&s, a).0 && flags(&s, g).0 && flags(&s, e).0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_cascades_through_groups_and_undoes() {
|
||||
let (mut s, ids) = deck();
|
||||
let (a, d, e, h, g) = (ids[0], ids[3], ids[4], ids[6], ids[7]);
|
||||
s.execute("shape.lock", &json!({"id": a})).unwrap();
|
||||
assert!(flags(&s, a).1);
|
||||
s.execute("shape.lock", &json!({"id": a})).unwrap();
|
||||
assert!(!flags(&s, a).1, "toggles");
|
||||
// Locking a group locks everything in it.
|
||||
s.execute("shape.lock", &json!({"id": g, "locked": true})).unwrap();
|
||||
assert!(flags(&s, g).1 && flags(&s, d).1 && flags(&s, h).1 && flags(&s, e).1);
|
||||
// A child can be unlocked on its own.
|
||||
s.execute("shape.lock", &json!({"id": d, "locked": false})).unwrap();
|
||||
assert!(flags(&s, g).1 && !flags(&s, d).1);
|
||||
// Unlocking the group unlocks everything in it.
|
||||
s.execute("shape.lock", &json!({"id": g})).unwrap();
|
||||
assert!(!flags(&s, g).1 && !flags(&s, h).1 && !flags(&s, e).1);
|
||||
undo(&mut s);
|
||||
assert!(flags(&s, g).1 && !flags(&s, d).1 && flags(&s, e).1);
|
||||
undo(&mut s);
|
||||
undo(&mut s);
|
||||
assert!(!flags(&s, g).1 && !flags(&s, e).1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_objects_refuse_moving_sizing_rotating_but_take_formatting() {
|
||||
let (mut s, ids) = deck();
|
||||
let (a, b) = (ids[0], ids[1]);
|
||||
s.execute("shape.lock", &json!({"id": a, "locked": true})).unwrap();
|
||||
let before = bx(&mut s, a);
|
||||
for (cmd, p) in [
|
||||
("shape.move", json!({"ids": [a], "dx": 10})),
|
||||
("shape.resize", json!({"ids": [a], "w": 300})),
|
||||
("shape.setBounds", json!({"ids": [a], "x": 1, "y": 1, "w": 2, "h": 2})),
|
||||
("shape.rotate", json!({"ids": [a], "deg": 45})),
|
||||
("shape.flip", json!({"ids": [a], "axis": "vertical"})),
|
||||
("arrange.align", json!({"ids": [a], "edge": "right"})),
|
||||
("shape.change", json!({"ids": [a], "preset": "ellipse"})),
|
||||
] {
|
||||
assert!(s.execute(cmd, &p).is_err(), "{cmd} changed a locked shape");
|
||||
}
|
||||
assert_eq!(bx(&mut s, a), before);
|
||||
// Mixed selections: the unlocked ones move, the locked one stays.
|
||||
let bb = bx(&mut s, b);
|
||||
s.execute("shape.move", &json!({"ids": [a, b], "dx": 10})).unwrap();
|
||||
assert_eq!(bx(&mut s, a), before);
|
||||
assert_eq!(bx(&mut s, b)["x"].as_f64().unwrap(), bb["x"].as_f64().unwrap() + 10.0);
|
||||
// Formatting and the name still change.
|
||||
s.execute("shape.fill", &json!({"ids": [a], "color": "#00ff00"})).unwrap();
|
||||
s.execute("shape.rename", &json!({"id": a, "name": "Anchor"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().shape(deckcraft_model::ShapeId(a)).unwrap().name, "Anchor");
|
||||
// The arrow keys don't nudge it.
|
||||
s.execute("edit.select", &json!({"ids": [a]})).unwrap();
|
||||
let _ = s.key("ArrowRight", Mods::default());
|
||||
assert_eq!(bx(&mut s, a), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_objects_are_picked_on_the_canvas_but_not_dragged() {
|
||||
let (mut s, ids) = deck();
|
||||
let a = ids[0];
|
||||
s.prefs.snap_to_grid = false;
|
||||
s.prefs.smart_guides = false;
|
||||
s.execute("shape.lock", &json!({"id": a, "locked": true})).unwrap();
|
||||
s.execute("edit.select", &json!({"ids": []})).unwrap();
|
||||
let n = s.doc().unwrap().history.undo.len();
|
||||
let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure: None };
|
||||
// A: x 20..120, y 50..130.
|
||||
s.pointer(ev(PointerKind::Down, 60.0, 90.0)).unwrap();
|
||||
assert_eq!(selected(&s), vec![a], "a click selects a locked object");
|
||||
s.pointer(ev(PointerKind::Drag, 160.0, 190.0)).unwrap();
|
||||
s.pointer(ev(PointerKind::Up, 160.0, 190.0)).unwrap();
|
||||
assert_eq!(bx(&mut s, a)["x"], 20.0);
|
||||
// No resize handle: pressing on the SE corner and dragging doesn't size it.
|
||||
s.pointer(ev(PointerKind::Down, 120.0, 130.0)).unwrap();
|
||||
s.pointer(ev(PointerKind::Drag, 200.0, 200.0)).unwrap();
|
||||
s.pointer(ev(PointerKind::Up, 200.0, 200.0)).unwrap();
|
||||
assert_eq!(bx(&mut s, a)["w"], 100.0);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), n, "no undo steps for gestures that changed nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_from_the_pane() {
|
||||
let (mut s, ids) = deck();
|
||||
let (a, b, c, d, e, g) = (ids[0], ids[1], ids[2], ids[3], ids[4], ids[7]);
|
||||
s.execute("selection.select", &json!({"id": b})).unwrap();
|
||||
assert_eq!(selected(&s), vec![b]);
|
||||
s.execute("selection.select", &json!({"id": c, "toggle": true})).unwrap();
|
||||
assert_eq!(selected(&s), vec![b, c]);
|
||||
s.execute("selection.select", &json!({"id": b, "toggle": true})).unwrap();
|
||||
assert_eq!(selected(&s), vec![c]);
|
||||
s.execute("selection.select", &json!({"ids": [a, b], "add": true})).unwrap();
|
||||
assert_eq!(selected(&s), vec![c, a, b]);
|
||||
// A child inside a group; children across nesting levels go together.
|
||||
s.execute("selection.select", &json!({"id": d})).unwrap();
|
||||
assert_eq!(selected(&s), vec![d]);
|
||||
s.execute("selection.select", &json!({"id": e, "toggle": true})).unwrap();
|
||||
assert_eq!(selected(&s), vec![d, e]);
|
||||
// ...but not with top-level objects: Ctrl+click on one is ignored.
|
||||
s.execute("selection.select", &json!({"id": g, "toggle": true})).unwrap();
|
||||
assert_eq!(selected(&s), vec![d, e]);
|
||||
s.execute("selection.select", &json!({"id": a})).unwrap();
|
||||
s.execute("selection.select", &json!({"id": d, "toggle": true})).unwrap();
|
||||
assert_eq!(selected(&s), vec![a]);
|
||||
// Picking makes no undo step...
|
||||
let n = s.doc().unwrap().history.undo.len();
|
||||
s.execute("selection.select", &json!({"id": b})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), n);
|
||||
// ...unless it shows a hidden object again.
|
||||
s.execute("shape.visible", &json!({"id": c, "visible": false})).unwrap();
|
||||
s.execute("selection.select", &json!({"id": c})).unwrap();
|
||||
assert_eq!(selected(&s), vec![c]);
|
||||
assert!(!flags(&s, c).0, "picking a hidden object shows it");
|
||||
undo(&mut s);
|
||||
assert!(flags(&s, c).0);
|
||||
assert!(s.execute("selection.select", &json!({"id": 4242})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_from_the_pane_undoes() {
|
||||
let (mut s, ids) = deck();
|
||||
let e = ids[4];
|
||||
s.execute("shape.rename", &json!({"id": e, "name": "Eagle"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().shape(deckcraft_model::ShapeId(e)).unwrap().name, "Eagle");
|
||||
undo(&mut s);
|
||||
assert_eq!(s.doc().unwrap().shape(deckcraft_model::ShapeId(e)).unwrap().name, "E");
|
||||
}
|
||||
+878
-63
File diff suppressed because it is too large
Load Diff
+152
-38
@@ -32,7 +32,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("slide.hide", "Hide Slide", ["Slide Show", "Set Up"], None, "{index?, hidden?: bool}", has_slide, hide),
|
||||
cmd!("slide.layout", "Layout", ["Home", "Slides"], None, "{layout: name or kind, index?}", has_slide, set_layout),
|
||||
cmd!("slide.reset", "Reset", ["Home", "Slides"], None, "{index?}", has_slide, reset),
|
||||
cmd!("slide.notes", "Notes", ["View"], None, "{text, index?}", has_slide, notes),
|
||||
cmd!(
|
||||
"slide.notes",
|
||||
"Notes",
|
||||
["View"],
|
||||
None,
|
||||
"{text | body: formatted notes (as in slide.inspect's notesBody), index?} (text as typed: untouched notes keep their formatting)",
|
||||
has_slide,
|
||||
notes
|
||||
),
|
||||
cmd!("slide.rename", "Rename Slide", [], None, "{name, index?}", has_slide, rename),
|
||||
cmd!("section.add", "Add Section", ["Home", "Slides", "Section"], None, "{name?, at?: slide index}", has_slide, section_add),
|
||||
cmd!("section.rename", "Rename Section", ["Home", "Slides", "Section"], None, "{index, name}", has_slide, section_rename),
|
||||
@@ -47,7 +55,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
cmd!("section.removeAll", "Remove All Sections", ["Home", "Slides", "Section"], None, "{}", has_slide, section_remove_all),
|
||||
cmd!("section.move", "Move Section", [], None, "{index, to}", has_slide, section_move),
|
||||
cmd!("slide.fromOutline", "Slides from Outline…", ["Insert", "Slides From"], None, "{text}", has_doc, from_outline),
|
||||
cmd!(
|
||||
"slide.fromOutline",
|
||||
"Slides from Outline…",
|
||||
["Insert", "Slides From"],
|
||||
None,
|
||||
"{text, markdown?: bool (headings are slides, list items bullets)}",
|
||||
has_doc,
|
||||
from_outline
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -99,16 +115,21 @@ fn new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut slide = defaults::new_slide(doc, layout);
|
||||
let mut slide = defaults::new_slide(doc, layout).ok_or_else(|| bad("slide.new", NO_ID_SPACE))?;
|
||||
if let Some(t) = str_param(p, "title")
|
||||
&& let Some(sh) = slide.shapes.iter_mut().find(|x| x.ph_type().is_some_and(|k| k.is_title()))
|
||||
{
|
||||
sh.text = Some(TextBody::from_text(t));
|
||||
}
|
||||
if let Some(b) = str_param(p, "body")
|
||||
&& let Some(sh) = slide.shapes.iter_mut().find(|x| x.ph_type().is_some_and(|k| !k.is_title()))
|
||||
&& let Some(sh) = body_placeholder(&mut slide.shapes)
|
||||
{
|
||||
sh.text = Some(TextBody::from_text(b));
|
||||
let mut body = TextBody::from_text(b);
|
||||
// Leading tabs set the outline level, as in text.set.
|
||||
for para in &mut body.paragraphs {
|
||||
super::text::apply_leading_tabs(para);
|
||||
}
|
||||
sh.text = Some(body);
|
||||
}
|
||||
let at = usize_param(p, "at").unwrap_or(if doc.slides.is_empty() { 0 } else { sel.slide + 1 }).min(doc.slides.len());
|
||||
let id = slide.id;
|
||||
@@ -137,9 +158,28 @@ fn new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The placeholder a new slide's `body` goes into: the layout's main content placeholder.
|
||||
/// Prefers `idx="1"` (the content slot PowerPoint fills), then a full-size text placeholder over
|
||||
/// a `half`/`quarter` one, then a text placeholder over a picture/chart/table one; ties keep
|
||||
/// shape order, so a layout with a single body placeholder behaves as before.
|
||||
fn body_placeholder(shapes: &mut [deckcraft_model::Shape]) -> Option<&mut deckcraft_model::Shape> {
|
||||
use deckcraft_model::PhType;
|
||||
let rank = |sh: &deckcraft_model::Shape| {
|
||||
let ph = sh.ph.as_ref()?;
|
||||
if ph.kind.is_title() {
|
||||
return None;
|
||||
}
|
||||
let text = matches!(ph.kind, PhType::Body | PhType::Obj | PhType::SubTitle);
|
||||
let small = matches!(ph.size.as_deref(), Some("half" | "quarter"));
|
||||
Some((ph.idx != 1, small, !text))
|
||||
};
|
||||
let best = shapes.iter().enumerate().filter_map(|(i, sh)| rank(sh).map(|r| (r, i))).min()?.1;
|
||||
shapes.get_mut(best)
|
||||
}
|
||||
|
||||
fn index_of(s: &Session, p: &Value) -> Result<usize> {
|
||||
let st = s.doc()?;
|
||||
let i = usize_param(p, "index").unwrap_or(st.selection.slide);
|
||||
let i = opt_usize_param(p, "index", "slide")?.unwrap_or(st.selection.slide);
|
||||
if i >= st.doc.slides.len() {
|
||||
return Err(bad("slide", format!("no slide {i}")));
|
||||
}
|
||||
@@ -147,7 +187,7 @@ fn index_of(s: &Session, p: &Value) -> Result<usize> {
|
||||
}
|
||||
|
||||
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let indices: Vec<usize> = match usize_param(p, "index") {
|
||||
let indices: Vec<usize> = match opt_usize_param(p, "index", "slide.duplicate")? {
|
||||
Some(i) => vec![i],
|
||||
None => {
|
||||
let st = s.doc()?;
|
||||
@@ -165,25 +205,10 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for (k, i) in indices.iter().enumerate() {
|
||||
let Some(src) = doc.slides.get(*i).map(|x| x.as_ref().clone()) else { continue };
|
||||
let mut copy = src;
|
||||
copy.id = SlideId(doc.alloc_id());
|
||||
super::edit::reid(doc, &mut copy.shapes);
|
||||
// Animations follow the new shape ids by position.
|
||||
let old_ids: Vec<_> = doc
|
||||
.slides
|
||||
.get(*i)
|
||||
.map(|x| {
|
||||
let mut v = vec![];
|
||||
deckcraft_model::walk(&x.shapes, &mut |sh, _| v.push(sh.id));
|
||||
v
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let mut new_shape_ids = vec![];
|
||||
deckcraft_model::walk(©.shapes, &mut |sh, _| new_shape_ids.push(sh.id));
|
||||
for a in &mut copy.animations {
|
||||
if let Some(pos) = old_ids.iter().position(|x| *x == a.shape) {
|
||||
a.shape = new_shape_ids.get(pos).copied().unwrap_or(a.shape);
|
||||
}
|
||||
}
|
||||
copy.id = SlideId(doc.alloc_id().ok_or_else(|| bad("slide.duplicate", NO_ID_SPACE))?);
|
||||
// Animations follow the new shape ids, triggers included.
|
||||
let map = super::edit::reid("slide.duplicate", doc, &mut copy.shapes)?;
|
||||
super::edit::remap_animations(&mut copy.animations, &map);
|
||||
let section = doc.section_of(src_id(doc, *i));
|
||||
let at = (last + 1 + k).min(doc.slides.len());
|
||||
new_ids.push(copy.id);
|
||||
@@ -193,6 +218,9 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
&& let Some(sec) = doc.sections.get_mut(si)
|
||||
{
|
||||
sec.slides.push(nid);
|
||||
// Keep the section list in document order so the copy sits next to its source.
|
||||
let order: Vec<SlideId> = doc.slides.iter().map(|x| x.id).collect();
|
||||
sec.slides.sort_by_key(|x| order.iter().position(|o| o == x).unwrap_or(usize::MAX));
|
||||
}
|
||||
}
|
||||
sel.slide = (last + new_ids.len()).min(doc.slides.len().saturating_sub(1));
|
||||
@@ -212,7 +240,7 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
if let Some(a) = p.get("ids").and_then(Value::as_array) {
|
||||
a.iter().filter_map(Value::as_u64).filter_map(|v| u32::try_from(v).ok()).map(SlideId).collect()
|
||||
} else if let Some(i) = usize_param(p, "index") {
|
||||
} else if let Some(i) = opt_usize_param(p, "index", "slide.delete")? {
|
||||
st.doc.slides.get(i).map(|x| vec![x.id]).unwrap_or_default()
|
||||
} else if !st.selection.slides.is_empty() {
|
||||
st.selection.slides.clone()
|
||||
@@ -238,7 +266,7 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn move_slide(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let from = usize_param(p, "from").unwrap_or(s.doc()?.selection.slide);
|
||||
let from = opt_usize_param(p, "from", "slide.move")?.unwrap_or(s.doc()?.selection.slide);
|
||||
let to = usize_param(p, "to").ok_or_else(|| bad("slide.move", "missing `to`"))?;
|
||||
s.edit(|doc, sel| {
|
||||
if from >= doc.slides.len() {
|
||||
@@ -283,9 +311,16 @@ fn go_to(s: &mut Session, i: usize) -> Result<Value> {
|
||||
fn go(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = match (usize_param(p, "index"), p.get("id").and_then(Value::as_u64)) {
|
||||
(Some(i), _) => i,
|
||||
(None, Some(id)) => s.doc()?.doc.slide_index(SlideId(u32::try_from(id).unwrap_or(0))).ok_or_else(|| bad("slide.go", "no such slide"))?,
|
||||
(None, Some(id)) => s
|
||||
.doc()?
|
||||
.doc
|
||||
.slide_index(SlideId(u32::try_from(id).map_err(|_| bad("slide.go", "no such slide"))?))
|
||||
.ok_or_else(|| bad("slide.go", "no such slide"))?,
|
||||
_ => return Err(bad("slide.go", "missing `index`")),
|
||||
};
|
||||
if i >= s.doc()?.doc.slides.len() {
|
||||
return Err(bad("slide.go", format!("no slide {i}")));
|
||||
}
|
||||
go_to(s, i)
|
||||
}
|
||||
fn next(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
@@ -338,7 +373,7 @@ fn select_slides(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn hide(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let targets: Vec<usize> = match usize_param(p, "index") {
|
||||
let targets: Vec<usize> = match opt_usize_param(p, "index", "slide.hide")? {
|
||||
Some(i) => vec![i],
|
||||
None => {
|
||||
let st = s.doc()?;
|
||||
@@ -364,7 +399,7 @@ pub fn apply_layout(doc: &mut Presentation, index: usize, layout: LayoutId) -> R
|
||||
doc.layout(layout).map(|(_, l)| l.shapes.clone()).ok_or_else(|| bad("slide.layout", "no such layout"))?;
|
||||
let mut fresh = vec![];
|
||||
for ls in lay_shapes.iter().filter(|x| x.ph.as_ref().is_some_and(|p| !p.kind.is_footer_kind())) {
|
||||
let id = deckcraft_model::ShapeId(doc.alloc_id());
|
||||
let id = deckcraft_model::ShapeId(doc.alloc_id().ok_or_else(|| bad("slide.layout", NO_ID_SPACE))?);
|
||||
fresh.push((id, ls.clone()));
|
||||
}
|
||||
let slide = doc.slides.get_mut(index).ok_or_else(|| bad("slide.layout", "no slide"))?;
|
||||
@@ -407,7 +442,7 @@ pub fn apply_layout(doc: &mut Presentation, index: usize, layout: LayoutId) -> R
|
||||
|
||||
fn set_layout(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let key = p.get("layout").cloned().ok_or_else(|| bad("slide.layout", "missing `layout`"))?;
|
||||
let targets: Vec<usize> = match usize_param(p, "index") {
|
||||
let targets: Vec<usize> = match opt_usize_param(p, "index", "slide.layout")? {
|
||||
Some(i) => vec![i],
|
||||
None => {
|
||||
let st = s.doc()?;
|
||||
@@ -463,10 +498,20 @@ fn reset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn notes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = index_of(s, p)?;
|
||||
// Formatted notes as a whole (the notes editors), or text: then only what was retyped
|
||||
// changes, so bold, levels and bullets around it survive.
|
||||
let body = match p.get("body") {
|
||||
Some(b) => Some(serde_json::from_value::<TextBody>(b.clone()).map_err(|e| bad("slide.notes", format!("invalid `body`: {e}")))?),
|
||||
None => None,
|
||||
};
|
||||
let text = str_param(p, "text").unwrap_or("").to_string();
|
||||
s.edit(|doc, _| {
|
||||
if let Some(sl) = doc.slides.get_mut(i) {
|
||||
Arc::make_mut(sl).notes = TextBody::from_text(&text);
|
||||
let notes = &mut Arc::make_mut(sl).notes;
|
||||
match body {
|
||||
Some(b) => *notes = b,
|
||||
None => deckcraft_model::edit::replace_text(notes, &text),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -486,7 +531,7 @@ fn rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn section_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let at = usize_param(p, "at").unwrap_or(s.doc()?.selection.slide);
|
||||
let at = opt_usize_param(p, "at", "section.add")?.unwrap_or(s.doc()?.selection.slide);
|
||||
let name = str_param(p, "name").unwrap_or("Untitled Section").to_string();
|
||||
s.edit(|doc, _| {
|
||||
let at = at.min(doc.slides.len().saturating_sub(1));
|
||||
@@ -494,9 +539,9 @@ fn section_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if doc.sections.is_empty() {
|
||||
// The slides before `at` go into a "Default Section".
|
||||
if at > 0 {
|
||||
doc.sections.push(Section { name: "Default Section".into(), slides: ids.get(..at).map(|x| x.to_vec()).unwrap_or_default() });
|
||||
doc.sections.push(Section { name: "Default Section".into(), slides: ids.get(..at).map(<[_]>::to_vec).unwrap_or_default() });
|
||||
}
|
||||
doc.sections.push(Section { name, slides: ids.get(at..).map(|x| x.to_vec()).unwrap_or_default() });
|
||||
doc.sections.push(Section { name, slides: ids.get(at..).map(<[_]>::to_vec).unwrap_or_default() });
|
||||
} else {
|
||||
let Some(start) = ids.get(at).copied() else { return Err(bad("section.add", "no slide")) };
|
||||
let si = doc.section_of(start).unwrap_or(0);
|
||||
@@ -572,15 +617,84 @@ fn section_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn from_outline(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let text = str_param(p, "text").unwrap_or("").to_string();
|
||||
let markdown = bool_param(p, "markdown").unwrap_or(false);
|
||||
s.edit(|doc, sel| {
|
||||
let before = doc.slides.len();
|
||||
let n = deckcraft_format::outline_to_slides(doc, &text);
|
||||
// outline_to_slides appends; move them after the current slide.
|
||||
let n = if markdown { deckcraft_format::markdown_to_slides(doc, &text) } else { deckcraft_format::outline_to_slides(doc, &text) };
|
||||
// both append; move them after the current slide.
|
||||
let at = (sel.slide + 1).min(before);
|
||||
let new: Vec<_> = doc.slides.drain(before..).collect();
|
||||
for (k, sl) in new.into_iter().enumerate() {
|
||||
doc.slides.insert(at + k, sl);
|
||||
}
|
||||
// Join the section of the slide before, as slide.new does.
|
||||
let ids: Vec<SlideId> = doc.slides.iter().skip(at).take(n).map(|x| x.id).collect();
|
||||
let prev = at.checked_sub(1).and_then(|i| doc.slides.get(i)).map(|x| x.id);
|
||||
let section = prev.and_then(|p| doc.section_of(p));
|
||||
if let Some(sec) = section.and_then(|si| doc.sections.get_mut(si)) {
|
||||
let pos = prev.and_then(|p| sec.slides.iter().position(|x| *x == p)).map(|k| k + 1).unwrap_or(sec.slides.len());
|
||||
let pos = pos.min(sec.slides.len());
|
||||
sec.slides.splice(pos..pos, ids);
|
||||
} else if let Some(first) = doc.sections.first_mut() {
|
||||
first.slides.splice(0..0, ids);
|
||||
}
|
||||
Ok(json!({"slides": n}))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn invalid_index_params_are_rejected_not_defaulted_to_selection() {
|
||||
let mut s = Session::with_new();
|
||||
while s.doc().expect("doc").doc.slides.len() < 3 {
|
||||
s.execute("slide.new", &json!({})).expect("new slide");
|
||||
}
|
||||
for bad in [json!(-1), json!(1.5)] {
|
||||
for (cmd, p) in [
|
||||
("slide.delete", json!({"index": bad})),
|
||||
("slide.duplicate", json!({"index": bad})),
|
||||
("slide.move", json!({"from": bad, "to": 0})),
|
||||
("section.add", json!({"at": bad})),
|
||||
] {
|
||||
assert!(s.execute(cmd, &p).is_err(), "{cmd} must reject {p}");
|
||||
assert_eq!(s.doc().expect("doc").doc.slides.len(), 3, "{cmd} {p}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_keeps_section_order_in_step_with_slides() {
|
||||
let mut s = Session::with_new();
|
||||
while s.doc().expect("doc").doc.slides.len() < 3 {
|
||||
s.execute("slide.new", &json!({})).expect("new slide");
|
||||
}
|
||||
s.execute("section.add", &json!({"at": 0})).expect("section");
|
||||
let orig = s.doc().expect("doc").doc.slides.first().expect("slide").id;
|
||||
s.execute("slide.duplicate", &json!({"index": 0})).expect("dup");
|
||||
let st = s.doc().expect("doc");
|
||||
let all: Vec<_> = st.doc.slides.iter().map(|x| x.id).collect();
|
||||
let sec = &st.doc.sections.first().expect("section").slides;
|
||||
assert_eq!(all.len(), 4);
|
||||
assert_eq!(sec, &all, "section order must match slide order");
|
||||
assert_eq!(sec.first(), Some(&orig));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_slide_body_leading_tabs_become_levels() {
|
||||
let mut s = Session::with_new();
|
||||
let text = "Level one\n\tLevel two\n\t\tLevel three\nLevel one again";
|
||||
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "Indent", "body": text})).expect("new slide");
|
||||
let st = s.doc().expect("doc");
|
||||
let slide = st.doc.slides.get(st.selection.slide).expect("slide");
|
||||
let body = slide.shapes.iter().find(|x| x.ph_type().is_some_and(|k| !k.is_title())).expect("body");
|
||||
let paras = &body.text.as_ref().expect("text").paragraphs;
|
||||
let got: Vec<(u8, String)> = paras.iter().map(|p| (p.level, p.text())).collect();
|
||||
let want = [(0, "Level one"), (1, "Level two"), (2, "Level three"), (0, "Level one again")];
|
||||
assert_eq!(got, want.iter().map(|(l, t)| (*l, t.to_string())).collect::<Vec<_>>());
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user