mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-28 20:47:15 -06:00
Compare commits
555
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fee716092c | ||
|
|
edf07dfc14 | ||
|
|
494daaa311 | ||
|
|
c2f3397483 | ||
|
|
29f0f7772c | ||
|
|
d97a596780 | ||
|
|
6d09d3770f | ||
|
|
6ddd2fb869 | ||
|
|
db245a7029 | ||
|
|
8c6c72e39f | ||
|
|
1a203c4a73 | ||
|
|
7f1ebc2a5c | ||
|
|
8e4d0e6b4c | ||
|
|
3f853db3f6 | ||
|
|
d9749fbcc5 | ||
|
|
0e7cd01381 | ||
|
|
9c52950536 | ||
|
|
c58f19f1b5 | ||
|
|
4de3a4dc8b | ||
|
|
f441ea93a2 | ||
|
|
6a59eb8cef | ||
|
|
e03ff82504 | ||
|
|
55e5ee56b4 | ||
|
|
f2f4dfabc0 | ||
|
|
8cb3365596 | ||
|
|
88c2ceff5f | ||
|
|
2f4a6d906a | ||
|
|
53e183495b | ||
|
|
8445d98e24 | ||
|
|
2c9edcc394 | ||
|
|
428c8809ea | ||
|
|
abee4d7522 | ||
|
|
4d9b325d35 | ||
|
|
ee875abffa | ||
|
|
020e53e7a0 | ||
|
|
12001d14e7 | ||
|
|
bbf7a8b8c7 | ||
|
|
96f0ac204e | ||
|
|
6b57773373 | ||
|
|
838d51ed59 | ||
|
|
5b02b1744c | ||
|
|
8b456dce41 | ||
|
|
15bf821535 | ||
|
|
d7c06b53eb | ||
|
|
97b98767f1 | ||
|
|
d5180fbb70 | ||
|
|
9c222be711 | ||
|
|
28371e27d7 | ||
|
|
4bdd5b5bee | ||
|
|
99688fe45a | ||
|
|
ada66524a2 | ||
|
|
e84ba9def1 | ||
|
|
9545ff4d61 | ||
|
|
c8a6363a0f | ||
|
|
462310faed | ||
|
|
d428b4804a | ||
|
|
9ab4ed9581 | ||
|
|
a094f37ed9 | ||
|
|
742e660afd | ||
|
|
8338e21ecf | ||
|
|
7ab59a0cd3 | ||
|
|
a730e66757 | ||
|
|
ac3c3b7b2a | ||
|
|
1368d06eeb | ||
|
|
af61c1cf3a | ||
|
|
099dfa7670 | ||
|
|
d1022cdaa4 | ||
|
|
dcd06db2d3 | ||
|
|
82fb8e7680 | ||
|
|
59e4b272d8 | ||
|
|
62aa7aa437 | ||
|
|
af9b8c1adb | ||
|
|
d6026de7c2 | ||
|
|
d9199598e1 | ||
|
|
a2194407a1 | ||
|
|
b440c2ff40 | ||
|
|
65a3ddc8f0 | ||
|
|
00eb5caad6 | ||
|
|
50feadc965 | ||
|
|
e9a7413c87 | ||
|
|
b48467943b | ||
|
|
0f4665ebe2 | ||
|
|
8528674c57 | ||
|
|
90a3ffe766 | ||
|
|
03025647d6 | ||
|
|
583f2dfb1b | ||
|
|
33a0df30d6 | ||
|
|
36f454b60f | ||
|
|
9ce54ad73f | ||
|
|
2fb85dfa2f | ||
|
|
1a2f8c0b0f | ||
|
|
b7a343d9cf | ||
|
|
f987fe63a7 | ||
|
|
899cd0e0dc | ||
|
|
d741144240 | ||
|
|
27009cd941 | ||
|
|
4bb73d5d34 | ||
|
|
aaf6c1c38a | ||
|
|
16fe4ebc65 | ||
|
|
430d0a91c0 | ||
|
|
c93e0a0bac | ||
|
|
aa3a1baa01 | ||
|
|
edfee40392 | ||
|
|
580b577cf2 | ||
|
|
230b6485d9 | ||
|
|
3418dec9e4 | ||
|
|
a78d9a9a19 | ||
|
|
560f80abe2 | ||
|
|
2aa0310e27 | ||
|
|
b6fd616977 | ||
|
|
9d23ff97b2 | ||
|
|
1bee1857d6 | ||
|
|
13ed8889da | ||
|
|
dbc5e164dd | ||
|
|
51eef9927f | ||
|
|
222ef368f4 | ||
|
|
d318583867 | ||
|
|
d4945d0443 | ||
|
|
b58df1095d | ||
|
|
b70a6fda35 | ||
|
|
5d9c3818a5 | ||
|
|
31cf44c2ff | ||
|
|
d20b08e3af | ||
|
|
4504495e2b | ||
|
|
5e9043afa6 | ||
|
|
f1bb876e28 | ||
|
|
5648c4d7ec | ||
|
|
d85e397be4 | ||
|
|
0e78adc675 | ||
|
|
556048436e | ||
|
|
637bbcdf51 | ||
|
|
b6c1fe628c | ||
|
|
ad664b2cdd | ||
|
|
8b2c0f456a | ||
|
|
ea4001a26e | ||
|
|
4268cab027 | ||
|
|
c8a13561b3 | ||
|
|
90f723918b | ||
|
|
5f5f341d27 | ||
|
|
d217820576 | ||
|
|
d6d30bbf20 | ||
|
|
7a9786b355 | ||
|
|
5e2f1993c3 | ||
|
|
24905e7057 | ||
|
|
3c4d6e12de | ||
|
|
7d6445e060 | ||
|
|
b21d71e648 | ||
|
|
ec274ec2fe | ||
|
|
178edac4ac | ||
|
|
d0411d0332 | ||
|
|
f034901719 | ||
|
|
547807c58d | ||
|
|
5ed7844cb9 | ||
|
|
f9b564cd00 | ||
|
|
7d4c49051c | ||
|
|
14f40c408c | ||
|
|
52d0fcd6c7 | ||
|
|
121441a079 | ||
|
|
9ec3926793 | ||
|
|
79bf9a688c | ||
|
|
1330ce4b86 | ||
|
|
abdf5f337b | ||
|
|
95a8f90d1a | ||
|
|
a378c78c25 | ||
|
|
29bd07e1b8 | ||
|
|
6523a49fb7 | ||
|
|
8a730912c2 | ||
|
|
ea72d9d712 | ||
|
|
6a24586412 | ||
|
|
9c985e6a6a | ||
|
|
a0eeb341d2 | ||
|
|
80f13011d5 | ||
|
|
399bc5a79f | ||
|
|
3821a8f7f7 | ||
|
|
08cc9ada67 | ||
|
|
7d21b25330 | ||
|
|
cc4629c2cd | ||
|
|
4668e0b45a | ||
|
|
ca919b0c9b | ||
|
|
85db5caed4 | ||
|
|
2b9b83ef83 | ||
|
|
aaaee73c6f | ||
|
|
d482443dd8 | ||
|
|
d863fac1c6 | ||
|
|
b23e6f2c8f | ||
|
|
da8db49aef | ||
|
|
149ea6b8da | ||
|
|
b913a0c509 | ||
|
|
2fd61c7c0c | ||
|
|
e167e08e41 | ||
|
|
6a1c7354c1 | ||
|
|
261e74e0c8 | ||
|
|
be56b0a1ac | ||
|
|
ab43e4b8f2 | ||
|
|
2ddd2abe67 | ||
|
|
a7ba879a3c | ||
|
|
d384bbc496 | ||
|
|
98ce1b35be | ||
|
|
ba59df768b | ||
|
|
0cffe2ca08 | ||
|
|
92ce587cd6 | ||
|
|
2e54272415 | ||
|
|
a7ab15d942 | ||
|
|
8145cc1bac | ||
|
|
a9cafe64fc | ||
|
|
052afb3efd | ||
|
|
8f31193cc0 | ||
|
|
9341ae0616 | ||
|
|
9e9e13fc2c | ||
|
|
1525dae1bd | ||
|
|
53bdcbafef | ||
|
|
9830b57fbe | ||
|
|
00f9df7d8d | ||
|
|
68c21778ef | ||
|
|
0c0cce3764 | ||
|
|
b8f707165b | ||
|
|
205ca7dfc0 | ||
|
|
7d4bc14c1d | ||
|
|
8236a9441b | ||
|
|
61b3108be7 | ||
|
|
c15e260794 | ||
|
|
744c01f66f | ||
|
|
35f240e453 | ||
|
|
8bc0afaff5 | ||
|
|
66e369de26 | ||
|
|
291725c78e | ||
|
|
507fdc3767 | ||
|
|
2d3997d2e3 | ||
|
|
c207d6eb0f | ||
|
|
e89c824c6d | ||
|
|
38d2ade2b7 | ||
|
|
a8f1573fba | ||
|
|
53eda1e8df | ||
|
|
aece097096 | ||
|
|
79c1d578be | ||
|
|
44d5a0e7b9 | ||
|
|
920925abae | ||
|
|
274b626007 | ||
|
|
afb52aeef3 | ||
|
|
8c4907a626 | ||
|
|
8fd1c1716f | ||
|
|
fb778030c9 | ||
|
|
7be391a1be | ||
|
|
e8910cb253 | ||
|
|
528af4e503 | ||
|
|
202255cbbb | ||
|
|
44b33a2f57 | ||
|
|
56b15df0ab | ||
|
|
ef6819b6fd | ||
|
|
a453292729 | ||
|
|
910b03f4a1 | ||
|
|
e679fa500a | ||
|
|
027075d352 | ||
|
|
f86928d0d5 | ||
|
|
2bbfe46d3e | ||
|
|
9dcb7bab03 | ||
|
|
75bec53e08 | ||
|
|
c55a9f771e | ||
|
|
63a2e099a6 | ||
|
|
c955407373 | ||
|
|
54962c6389 | ||
|
|
6189fa8bf6 | ||
|
|
238b34be00 | ||
|
|
713f605566 | ||
|
|
9641817a15 | ||
|
|
1e097e3edd | ||
|
|
9888d7c90e | ||
|
|
d94790d97a | ||
|
|
05f2a84400 | ||
|
|
9c31b567da | ||
|
|
ee248bb5cb | ||
|
|
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 | ||
|
|
e04ef2b4a8 | ||
|
|
5fadda5ecf | ||
|
|
b913c4f9d0 | ||
|
|
6d7b32351f | ||
|
|
fc914a059c | ||
|
|
18e8bb0ded | ||
|
|
52bf97d983 | ||
|
|
aaba9febc2 | ||
|
|
0c510426af | ||
|
|
3ac39f9bb6 | ||
|
|
30b7eff487 | ||
|
|
be9bf48eb2 | ||
|
|
1b03ae95de | ||
|
|
a48a2d706a | ||
|
|
9aef21e627 | ||
|
|
866f349fbf | ||
|
|
7626f74b99 | ||
|
|
8789394de1 | ||
|
|
9679c6e16f | ||
|
|
9b2ab3d2b8 | ||
|
|
2bc30fbd9a | ||
|
|
57acc28df3 | ||
|
|
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 | ||
|
|
c0128a0eb8 | ||
|
|
0ce545fcb8 | ||
|
|
07c8e2d77f | ||
|
|
df0fab857d | ||
|
|
42e89b347d | ||
|
|
a2435383b1 | ||
|
|
d01254cf2d | ||
|
|
3f399dd450 | ||
|
|
8f04a999b9 | ||
|
|
f011313a3a | ||
|
|
792829ec41 | ||
|
|
4a1d27a227 | ||
|
|
0b8a4cc23e | ||
|
|
31a828b027 | ||
|
|
1dd96d3ddc | ||
|
|
dffef33960 | ||
|
|
7f2955be1c | ||
|
|
08b7b5eb15 | ||
|
|
aa6f45ba2c | ||
|
|
e11c7e4d7e | ||
|
|
128cb0b883 | ||
|
|
8ddb977733 | ||
|
|
7dce06dca4 | ||
|
|
30d00468e2 | ||
|
|
7919429652 | ||
|
|
48dbdffe07 | ||
|
|
f71ce3fa9d | ||
|
|
3cd6a815ca | ||
|
|
fdfdb9e058 | ||
|
|
e03debfd4b | ||
|
|
906898f662 | ||
|
|
2741d3236b | ||
|
|
96697f7612 | ||
|
|
7b0252f291 | ||
|
|
2303efe7d5 | ||
|
|
2d6582163f | ||
|
|
e89a893fe1 | ||
|
|
72a9e4456e | ||
|
|
309d9f5b2c | ||
|
|
6b91132ce7 | ||
|
|
5e0a60b15e | ||
|
|
02b3940efd | ||
|
|
98b6d04413 | ||
|
|
dc69e57600 | ||
|
|
e5a407d6e2 | ||
|
|
502fb2ba73 | ||
|
|
6f83ed464d | ||
|
|
e37267f548 | ||
|
|
18f5e17b36 | ||
|
|
65564cfc1b | ||
|
|
2071ff599d | ||
|
|
1df1d2e724 | ||
|
|
e843309280 | ||
|
|
0405fb73bf | ||
|
|
0b476e823d | ||
|
|
52a9a45f6a | ||
|
|
bc12031d70 | ||
|
|
a9031d63e1 | ||
|
|
0dc39b9efc | ||
|
|
57d65faf4f | ||
|
|
7040bb0619 | ||
|
|
900f62f5a3 | ||
|
|
4b474d77a9 | ||
|
|
756ca23d31 | ||
|
|
f8c8223ff0 | ||
|
|
883cc6e391 | ||
|
|
37d69f0449 | ||
|
|
8e83e8bb81 | ||
|
|
3610cd6b6f | ||
|
|
ba194e35d8 | ||
|
|
1090ca2d70 | ||
|
|
9412d3ccaf | ||
|
|
c90ea7d9ae | ||
|
|
5beca8c1b6 | ||
|
|
5ac14f2015 | ||
|
|
9b56f83929 | ||
|
|
935724850b | ||
|
|
a49570e45b | ||
|
|
b75ea712e9 | ||
|
|
8bc3d3f110 | ||
|
|
e34169ff75 | ||
|
|
86e7879f5e | ||
|
|
a7833e094d | ||
|
|
0a0ad04f47 | ||
|
|
74bc27f27e | ||
|
|
ea98ebb615 | ||
|
|
85b994e7cd | ||
|
|
445e7c6b55 | ||
|
|
0298201b2c | ||
|
|
03993057a4 | ||
|
|
172952f6e0 | ||
|
|
e54bd2ccb7 | ||
|
|
8e9c198c7c | ||
|
|
601823d98c | ||
|
|
46dec4a660 | ||
|
|
afd6d3a3eb | ||
|
|
b0006d6af8 | ||
|
|
a55b6cfa6f | ||
|
|
8f4b0d4c3f | ||
|
|
271c6c4037 | ||
|
|
432016fc96 | ||
|
|
beb883d036 | ||
|
|
5f0a088870 | ||
|
|
013941e8e8 | ||
|
|
d28b4e0f54 | ||
|
|
13e06cace2 | ||
|
|
b27bd0519c | ||
|
|
b919e33217 | ||
|
|
79088b7060 | ||
|
|
0fea876101 | ||
|
|
283be17f93 |
@@ -0,0 +1,3 @@
|
||||
# Minimum cargo-nextest for this repository: GHSA-vc25-qg58-w57c is fixed in 0.9.150 and CI
|
||||
# installs exactly that version, so an older nextest refuses to run (exit 92 REQUIRED_VERSION_NOT_MET).
|
||||
nextest-version = "0.9.150"
|
||||
+43
-37
@@ -1,55 +1,61 @@
|
||||
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:
|
||||
timeout-minutes: 60
|
||||
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 }}
|
||||
# Keep the early manifest preflight separate from the reusable workflow. The shared gate
|
||||
# also runs with require-craft-fonts: true, so the build script enforces actual required faces.
|
||||
# Keep this pin identical to gate, freebsd.yml and release.yml.
|
||||
font-pin:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# Same craft-fonts commit as release.yml.
|
||||
CRAFT_FONTS_REF: 37827adc41fd165f9a9234ac40f94dbad353aa69
|
||||
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
|
||||
- name: Font pin gate
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
run: cargo xtask ci
|
||||
# The only pin allowed to predate the reviewed Chinese manifest. It is a sentinel, not
|
||||
# a proposed future SHA; update it together with all three workflow pins.
|
||||
PENDING_CRAFT_FONTS_PIN: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
|
||||
run: |
|
||||
manifest="$GITHUB_WORKSPACE/craft-fonts/fonts/simplified-chinese-manifest.txt"
|
||||
if [ -f "$manifest" ]; then
|
||||
echo "craft-fonts pin carries the reviewed simplified-Chinese manifest."
|
||||
elif [ "$CRAFT_FONTS_REF" = "$PENDING_CRAFT_FONTS_PIN" ]; then
|
||||
echo "::warning title=craft-fonts pin pending::The pinned craft-fonts commit has no fonts/simplified-chinese-manifest.txt, so CJK checks may skip. Advance ci.yml, freebsd.yml and release.yml together to the reviewed immutable SHA before claiming shipped Chinese fonts."
|
||||
else
|
||||
echo "::error title=craft-fonts pin missing Chinese manifest::CRAFT_FONTS_REF advanced past the known pending pin ($PENDING_CRAFT_FONTS_PIN) but still has no fonts/simplified-chinese-manifest.txt. Update the pin to the reviewed commit that carries it."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
gate:
|
||||
needs: font-pin
|
||||
# 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: 37827adc41fd165f9a9234ac40f94dbad353aa69
|
||||
require-craft-fonts: true
|
||||
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:
|
||||
@@ -26,4 +27,8 @@ jobs:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
|
||||
with:
|
||||
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
|
||||
# Keep identical to ci.yml and release.yml. The shared FreeBSD workflow passes
|
||||
# CRAFT_FONTS_DIR to its tests but has no CRAFT_FONTS_REQUIRED input yet, so it does not
|
||||
# qualify the required Chinese input after the pin advances; that gate stays explicitly
|
||||
# pending in docs/egui-037-migration-plan.md section 5.1.
|
||||
craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69
|
||||
|
||||
@@ -21,3 +21,27 @@ jobs:
|
||||
lint:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
|
||||
|
||||
windows-install-policy:
|
||||
timeout-minutes: 10
|
||||
name: Windows installation policy (no app build)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install the pinned packaging toolchain
|
||||
shell: pwsh
|
||||
run: |
|
||||
$tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix'
|
||||
dotnet tool install --tool-path $tools wix --version 5.0.2
|
||||
if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' }
|
||||
$tools >> $env:GITHUB_PATH
|
||||
$policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json
|
||||
foreach ($extension in @($policy.extensions)) {
|
||||
& (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2"
|
||||
if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." }
|
||||
}
|
||||
- name: Build and check non-executable installer fixtures
|
||||
shell: pwsh
|
||||
run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests')
|
||||
|
||||
@@ -24,7 +24,10 @@ jobs:
|
||||
with:
|
||||
app-name: deckcraft
|
||||
app-title: DeckCraft
|
||||
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
|
||||
# Keep identical to ci.yml and freebsd.yml. The shared release workflow sets
|
||||
# CRAFT_FONTS_REQUIRED=1 in its packaging steps, so a pin without the reviewed Chinese
|
||||
# manifest fails the release instead of silently skipping it.
|
||||
craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69
|
||||
version: ${{ inputs.version }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
|
||||
@@ -11,7 +11,8 @@ permissions:
|
||||
|
||||
concurrency:
|
||||
group: sonarcloud-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
# 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:
|
||||
|
||||
@@ -10,8 +10,9 @@ on:
|
||||
paths:
|
||||
- .github/workflows/windows-arm64.yml
|
||||
- packaging/windows/**
|
||||
# Nightly, so dependency and runner-image drift shows up within a day.
|
||||
schedule:
|
||||
- cron: "41 3 * * 1"
|
||||
- cron: "3 5 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
@@ -23,8 +24,10 @@ permissions:
|
||||
|
||||
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
|
||||
# Immutable supporting workflow revision until the new install contract lands upstream.
|
||||
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f
|
||||
with:
|
||||
app-name: deckcraft
|
||||
app-dir: DeckCraft
|
||||
install-relative-path: Programs/ArtCraft/DeckCraft
|
||||
install-scope: perUser
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
+21
-5
@@ -27,7 +27,7 @@ artwork. The ribbon and menu icons stay original drawings.
|
||||
| `assets/app-icon/icon-source.deckcraft` | DeckCraft contributors | made with `deckcraft-cli run` (see git history) | MIT OR Apache-2.0 | Source of the placeholder icon |
|
||||
| `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/clip-*` (`clip-h264.mp4`, `clip-h264.mov`, `clip-hevc.mp4`, `clip-vp8.webm`, `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 / libvpx-vp9 / 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 |
|
||||
@@ -41,8 +41,9 @@ artwork. The ribbon and menu icons stay original drawings.
|
||||
|
||||
| Asset | Author | Source | Licence | Used for |
|
||||
|---|---|---|---|---|
|
||||
| Ubuntu Light (`Ubuntu-Light.ttf`, via the `epaint_default_fonts` crate 0.36) | Dalton Maag Ltd for Canonical | https://crates.io/crates/epaint_default_fonts | Ubuntu Font Licence 1.0 | Last-resort text face when no other font is available; UI fallback |
|
||||
| Hack Regular, Noto Emoji, emoji-icon-font (via `epaint_default_fonts` / egui) | Source Foundry; Google; Jakub Steiner et al. | https://crates.io/crates/epaint_default_fonts | MIT / Bitstream-Vera, OFL-1.1, MIT | egui UI fallback glyphs |
|
||||
| Ubuntu Light (`Ubuntu-Light.ttf`, via the `epaint_default_fonts` crate 0.37) | Dalton Maag Ltd for Canonical | https://crates.io/crates/epaint_default_fonts | Ubuntu Font Licence 1.0 | Last-resort text face when no other font is available; UI fallback |
|
||||
| Hack Regular, Noto Emoji (via `epaint_default_fonts` / egui) | Source Foundry; Google | https://crates.io/crates/epaint_default_fonts | MIT / Bitstream-Vera, OFL-1.1 | egui UI fallback glyphs |
|
||||
| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 6d518fc7](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset, included by default_fonts independently of monochrome_emoji_fonts; emoji-icon-font.ttf is the full monochrome fallback. Dependency assets unmodified here; no font file copied into this repository. |
|
||||
|
||||
## Embedded at build time from craft-fonts (`CRAFT_FONTS_DIR`)
|
||||
|
||||
@@ -58,9 +59,14 @@ families excluded by `crates/fonts/build.rs`:
|
||||
| Carlito, Caladea | OFL-1.1 | Metric-compatible stand-ins for documents that ask for Calibri / Cambria |
|
||||
| Liberation Sans, Serif, Mono | OFL-1.1 | Metric-compatible stand-ins for Arial, Times New Roman, Courier New |
|
||||
| Montserrat, Lato, Open Sans, Roboto, Merriweather, Playfair Display, Poppins, Nunito Sans | OFL-1.1 | Theme fonts |
|
||||
| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic | OFL-1.1 | Japanese and Arabic text |
|
||||
| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho | OFL-1.1 | Japanese text |
|
||||
| Noto Sans Arabic, Noto Sans Hebrew | OFL-1.1 | Arabic and Hebrew text |
|
||||
| Noto Sans Thai, Noto Sans Georgian | OFL-1.1 | Thai and Georgian text |
|
||||
| NanumGothic | OFL-1.1 | Korean text and interface fallback |
|
||||
| Noto Sans Symbols, Noto Sans Symbols 2 | OFL-1.1 | Interface symbol fallback |
|
||||
|
||||
Web (wasm32) builds embed only Inter Regular/Bold and BIZ UDPGothic Regular.
|
||||
Web (wasm32) builds embed only Inter Regular/Bold (and a Variable face where the Latin manifest
|
||||
provides one) and BIZ UDPGothic Regular.
|
||||
|
||||
## PDF writer colour profiles
|
||||
|
||||
@@ -75,3 +81,13 @@ is copied into this repository. The licence text is in `licenses/pdf-writer/LICE
|
||||
| `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 |
|
||||
|
||||
### Optional native Chinese fallback
|
||||
|
||||
Native builds also read craft-fonts' `fonts/simplified-chinese-manifest.txt` when present.
|
||||
This opt-in does not enter the wasm font allowlist. Font bytes and the full licence remain
|
||||
in craft-fonts, including the LXGW Reserved Font Names and original Klee notice.
|
||||
|
||||
| Family | Author | Source | Licence | Use |
|
||||
|---|---|---|---|---|
|
||||
| LXGW WenKai Lite Regular 1.530 | LXGW; original Klee Project Authors / Fontworks | https://github.com/lxgw/LxgwWenKai-Lite/tree/b712c83d98acf977bacfb7fb010207802808bda9 | OFL-1.1, unmodified upstream font | Native Chinese glyph fallback after existing UI faces; font SHA256 `f799a538a3355877e597a0ae04050e897932c9ae7689eb9227c2953c5dd549ea`, OFL SHA256 `0333a85fe077b6849f8e417acc44e8bd879442ceb681e271acf93e2d57fd4e2e` |
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
Read [AGENTS.md](AGENTS.md) first: its rules (asset policy, clean room, never crash) bind you and
|
||||
win over anything here.
|
||||
|
||||
New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit
|
||||
`[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes
|
||||
before modules or other crate items; crate-level `//!` documentation may precede them.
|
||||
|
||||
## Start every session
|
||||
1. Read `plan/STATUS.md`, then the current milestone in `plan/execution-plan.md`.
|
||||
2. Read `../../craftrules/AGENTS.md` and the standards it lists for your task.
|
||||
|
||||
Generated
+978
-488
File diff suppressed because it is too large
Load Diff
+37
-9
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.9.0"
|
||||
version = "0.12.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -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,16 +59,20 @@ 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"] }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.1.0", default-features = false, features = ["write"] }
|
||||
craft-media = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false, features = ["write", "write-simple-text"] }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0" }
|
||||
i_overlay = "9"
|
||||
quick-xml = "0.41"
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
|
||||
# ui
|
||||
egui = "0.36"
|
||||
egui_extras = { version = "0.36", default-features = false }
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
egui_kittest = "0.36"
|
||||
egui = { version = "0.37", default-features = false }
|
||||
egui_extras = { version = "0.37", default-features = false }
|
||||
eframe = { version = "0.37", default-features = false }
|
||||
egui_kittest = "0.37"
|
||||
web-time = "1.1"
|
||||
rfd = "0.17"
|
||||
arboard = { version = "3.6", default-features = false }
|
||||
|
||||
@@ -74,19 +80,26 @@ arboard = { version = "3.6", default-features = false }
|
||||
unsafe_code = "forbid"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
cognitive_complexity = "deny"
|
||||
too_many_arguments = "deny"
|
||||
wildcard_imports = "deny"
|
||||
dbg_macro = "warn"
|
||||
todo = "warn"
|
||||
too_many_arguments = "allow"
|
||||
type_complexity = "allow"
|
||||
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
|
||||
@@ -95,4 +108,19 @@ codegen-units = 16
|
||||
debug = 0
|
||||
|
||||
[patch.crates-io]
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.8.2" }
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.8.2" }
|
||||
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
|
||||
|
||||
@@ -18,7 +18,13 @@ list (author, source, license) is in ATTRIBUTION.md.
|
||||
- Builds made with craft-fonts (CRAFT_FONTS_DIR; all official releases) embed the fonts listed in
|
||||
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.
|
||||
Arabic, Noto Sans Thai, Noto Sans Georgian, NanumGothic, Noto Sans Symbols, Noto Sans Symbols 2,
|
||||
Noto Sans Hebrew) under the SIL Open Font License 1.1; their licence texts ship with release
|
||||
packages (the exact font files and OFL texts are retained in craft-fonts releases). Native
|
||||
builds may additionally read craft-fonts' separate Simplified Chinese manifest and embed LXGW
|
||||
WenKai Lite 1.530 under OFL-1.1; its OFL text ships with release packages, and the exact font
|
||||
and licence files are retained in craft-fonts with the LXGW Reserved Font Names. That face is
|
||||
not in the web payload.
|
||||
- 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
|
||||
@@ -80,3 +86,20 @@ Code dependencies
|
||||
(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.
|
||||
|
||||
|
||||
egui icon font subset (dependency asset)
|
||||
----------------------------------------
|
||||
epaint_default_fonts 0.37 embeds egui-icons.ttf, a five-glyph subset of
|
||||
emoji-icon-font by John Slegers. The full monochrome emoji-icon-font fallback
|
||||
uses the same MIT license. Upstream license text follows unchanged.
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2014 John Slegers
|
||||
|
||||
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.
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
</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">
|
||||
@@ -220,6 +224,41 @@ DirectX 12 shaders are compiled with FXC, which is part of Windows, so no `dxcom
|
||||
|
||||
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 |
|
||||
|
||||
+77
-1
@@ -2,7 +2,13 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~20 points and ~120–210 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** 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-24 · **Change:** trivial (progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: 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-24 · **Change:** trivial (progress log: Ctrl+wheel zoom during the slide show, #1286; previous: trivial, 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-24 · **Change:** trivial (progress log: picture and embedded object fills; previous: trivial, 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)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-21 · **Change:** minor (proofing language for the whole presentation; 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
|
||||
@@ -131,19 +137,67 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-25 | Windows installer: the Start menu shortcut takes the icon embedded in `deckcraft.exe` instead of an MSI `<Icon>`, so a taskbar pin keeps its icon after an update (#1114); a pin that already lost it has to be unpinned and pinned again once |
|
||||
| 2026-10-24 | AutoFormat as you type: a paragraph that starts with `*`, `-`, `--`, `>`, `<>`, `1.`, `1)`, `I.`, `i.`, `A.`, `a.` or `a)` and a space or tab becomes a bulleted or numbered list in that style; Backspace right after puts the typed text back, and Tools ▸ AutoCorrect and `text.autocorrect {lists}` turn it off |
|
||||
| 2026-10-24 | Tables: while the caret is in a cell, dragging into another cell selects that range of cells (shaded, merged cells taken in whole), and Shift+arrow grows or shrinks it; formatting applies to the selected cells only, and Delete clears their text and keeps the table |
|
||||
| 2026-10-24 | Preferences ▸ Measurement units: inches or centimetres. The Format pane's Size and Position fields, Shape Format ▸ Size on the ribbon, the Slide Size dialog and the ruler show and take the chosen unit; the deck still stores points (#1326) |
|
||||
| 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-24 | A picture's own fill shows behind its transparent pixels, and an embedded object (an old equation, `p:oleObj`) takes the fill and outline of its fallback picture, as PowerPoint for Mac draws them (synthetic probe: a half-transparent PNG on orange and on yellow with an outline); on a private 166-deck corpus 51 slides of equations on coloured boxes move closer to PowerPoint's PDF, none further |
|
||||
| 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-24 | Columns dialog: Home › Columns › More Columns… and Format Shape › Text Box › Columns… set the number of text columns (1 to 16) and the spacing between them in inches, in one undo step; `format.columns` with no fields opens it, and a key left out keeps the text box's own value |
|
||||
| 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-24 | Slide Show: Ctrl+wheel (Cmd+wheel on a Mac) over the slide zooms in and out around the pointer, four notches per doubling, up to 4×, as in PowerPoint; zooming back to the whole slide leaves the zoom, and the plain wheel still turns the page (#1286) |
|
||||
| 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-21 | Review › Language has *Whole presentation*: one proofing language for every text box, table cell, note, master and layout, and the default for new text (`format.languageAll`, one undo step); the per-selection language still works |
|
||||
| 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-24 | Format Shape and Format Background ▸ Pattern fill: a gallery of the 48 preset patterns PowerPoint offers, drawn in the current colors, with Foreground and Background color menus. Format Shape could only set 25 % in accent 1 on background 1, and Format Background listed 10 presets by their XML names |
|
||||
| 2026-10-21 | Reuse Slides: pick which slides of the other presentation to insert from a list (`slide.reuseList`, #1103) |
|
||||
| 2026-10-21 | Slide list and slide sorter: Shift+click selects a range of slides and Ctrl+Shift+click adds it to the selection (`slide.selectRange`, #1105) |
|
||||
| 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-24 | Edit Points: hold Shift, Ctrl or Alt while dragging a point's handle to make it a smooth, straight or corner point, as in PowerPoint (Cmd and Option work as Ctrl and Alt on the Mac); the point keeps that type for the next drag |
|
||||
| 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-24 | Edit Points: Esc pressed before the mouse button is released cancels a point or handle drag, as in PowerPoint, and leaves the shape as it was with no undo step. Edit Points stays on after the cancel |
|
||||
| 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-24 | Edit Points: right-click a segment for Add Point (at the pointer), Delete Segment (a closed outline opens there; grayed out on an open one) and Open/Close Path, as in PowerPoint (`shape.deleteSegment`) |
|
||||
| 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. |
|
||||
@@ -240,11 +294,33 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-25 | trivial | Progress log: Windows shortcut icon survives updates (#1114). Scores and milestone names unchanged |
|
||||
| 2026-10-24 | trivial | Progress log: selecting a range of table cells by dragging. Scores and milestone names unchanged |
|
||||
| 2026-10-24 | trivial | Progress log: measurement units in centimetres (#1326). Scores unchanged |
|
||||
| 2026-10-22 | trivial | Progress log: Animation Pane ▸ Play From. Scores unchanged |
|
||||
| 2026-10-24 | trivial | Progress log: Ctrl+wheel zoom during the slide show (#1286). Scores and milestone names unchanged |
|
||||
| 2026-10-23 | trivial | Progress log: scroll bars on the zoomed slide pane (#1157). Scores and milestone names unchanged |
|
||||
| 2026-10-24 | trivial | Progress log: the Columns dialog. Scores and milestone names unchanged |
|
||||
| 2026-10-24 | trivial | Progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option. 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-21 | trivial | Progress log: slide range selection (#1105). 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-24 | trivial | Progress log: picture and embedded object fills |
|
||||
| 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 |
|
||||
|
||||
@@ -73,7 +73,7 @@ fn main() -> ExitCode {
|
||||
Some("app") => report(app(rest)),
|
||||
Some("mcp") => report(mcp(rest)),
|
||||
Some("links") => {
|
||||
use deckcraft_engine::links::*;
|
||||
use deckcraft_engine::links::{APP_PAGE, DISCORD, GITHUB, WEBSITE};
|
||||
outln!("Discord {DISCORD}\nWebsite {WEBSITE}\nApp page {APP_PAGE}\nGitHub {GITHUB}");
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
@@ -208,7 +208,7 @@ fn bench(args: &[String]) -> Result<(), String> {
|
||||
("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))?),
|
||||
("export .pdf", time_ms(runs, || 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);
|
||||
@@ -407,7 +407,7 @@ fn frame_paths(out: &str, ts: &[f64]) -> Result<Vec<String>, String> {
|
||||
return Ok(vec![out.to_string()]);
|
||||
}
|
||||
let p = std::path::Path::new(out);
|
||||
let stem = p.file_stem().and_then(|x| x.to_str()).unwrap_or("frame");
|
||||
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();
|
||||
|
||||
@@ -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;
|
||||
@@ -1,6 +1,4 @@
|
||||
//! `deckcraft-cli mcp` with the automation root flags, run as the real binary.
|
||||
// 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)]
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
@@ -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; }
|
||||
|
||||
@@ -11,6 +11,8 @@ const VIDEO_EXTS: &[&str] = &["mp4", "m4v", "mov", "webm"];
|
||||
const CANVAS_ID: &str = "deckcraft_canvas";
|
||||
const LOADING_ID: &str = "deckcraft_loading";
|
||||
|
||||
/// Start the browser shell: install logging, locate the canvas, launch the eframe runner,
|
||||
/// build the sample or blank deck, and replace the loading element when launch finishes.
|
||||
pub fn start() {
|
||||
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
||||
wasm_bindgen_futures::spawn_local(async {
|
||||
@@ -36,6 +38,7 @@ pub fn start() {
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
log::info!("deckcraft-web {}: wgpu backend {:?}", env!("CARGO_PKG_VERSION"), rs.adapter.get_info().backend);
|
||||
}
|
||||
deckcraft_ui_egui::install_ime_pass_hooks(&cc.egui_ctx);
|
||||
let inbox: Inbox = Inbox::default();
|
||||
let mut app = SlideApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone()));
|
||||
let q = query();
|
||||
@@ -74,11 +77,17 @@ struct WebShell {
|
||||
}
|
||||
|
||||
impl eframe::App for WebShell {
|
||||
/// Delegate one eframe logic pass to the wrapped application.
|
||||
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.
|
||||
self.app.logic(ctx);
|
||||
}
|
||||
|
||||
/// Consume browser drops with fresh input, capture the identity before the asynchronous read,
|
||||
/// and deliver the id, name, and bytes through the inbox.
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
// Browsers cannot read dropped files synchronously. Consume this pass's drops here, with
|
||||
// fresh input, and capture the document/slide before the asynchronous read finishes.
|
||||
for f in std::mem::take(&mut raw.dropped_files) {
|
||||
let id = self.app.begin_external_file("drop");
|
||||
let inbox = self.inbox.clone();
|
||||
let ctx = ctx.clone();
|
||||
@@ -97,11 +106,7 @@ impl eframe::App for WebShell {
|
||||
}
|
||||
});
|
||||
}
|
||||
self.app.logic(ctx);
|
||||
}
|
||||
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.app.raw_input_hook(raw);
|
||||
self.app.raw_input_hook(ctx, raw);
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
|
||||
@@ -18,12 +18,14 @@ 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.
|
||||
winresource = "0.1"
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
craft-update = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
arboard = { workspace = true, features = ["image-data"] }
|
||||
image = { workspace = true }
|
||||
|
||||
@@ -41,8 +41,9 @@ pub fn poll(inbox: &Inbox, app: &mut SlideApp, ctx: &egui::Context) {
|
||||
Event::Open(paths) => {
|
||||
for p in paths.iter().filter_map(|p| p.to_str()) {
|
||||
if let Err(e) = app.open_path(p) {
|
||||
log::error!("{p}: {e}");
|
||||
app.set_status(format!("Couldn't open {p}: {e}"));
|
||||
let safe_p = p.replace(['\r', '\n'], " ");
|
||||
log::error!("{safe_p}: {e}");
|
||||
app.set_status(format!("Couldn't open {safe_p}: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
//! socket may sit idle or blocked on a write.
|
||||
|
||||
use std::io::{BufRead, BufReader, ErrorKind, Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
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;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use deckcraft_ui_egui::ControlRequest;
|
||||
use serde_json::{Value, json};
|
||||
@@ -61,16 +61,35 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 longest a connection refused as busy is drained before it is closed.
|
||||
const REFUSED_GRACE: Duration = Duration::from_millis(250);
|
||||
|
||||
/// Refused connections drained at once, each on a thread of its own. Past this many, a refused
|
||||
/// connection gets the busy reply and is closed without a drain.
|
||||
const MAX_REFUSING: usize = 16;
|
||||
|
||||
/// 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));
|
||||
let refusing = Arc::new(AtomicUsize::new(0));
|
||||
std::thread::spawn(move || {
|
||||
for mut 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"}));
|
||||
refuse(stream, &refusing, limits.write_timeout.min(REFUSED_GRACE));
|
||||
continue;
|
||||
}
|
||||
let slot = Slot(Arc::clone(&active));
|
||||
@@ -88,6 +107,27 @@ fn serve_listener(listener: TcpListener, limits: Limits, ctx: egui::Context) ->
|
||||
rx
|
||||
}
|
||||
|
||||
/// Close a connection that was told it is refused. A peer that wrote a line before it read the
|
||||
/// reply would see only a reset, so the connection is drained for up to `grace` first, on a
|
||||
/// thread of its own: the accepting thread goes straight back to accepting. Past
|
||||
/// [`MAX_REFUSING`] drains at once, the connection is closed without one.
|
||||
fn refuse(stream: TcpStream, refusing: &Arc<AtomicUsize>, grace: Duration) {
|
||||
if refusing.fetch_add(1, Ordering::SeqCst) >= MAX_REFUSING {
|
||||
refusing.fetch_sub(1, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
let slot = Slot(Arc::clone(refusing));
|
||||
let spawned = std::thread::Builder::new().name("control-refused".into()).spawn(move || {
|
||||
let _slot = slot;
|
||||
if let Ok(read) = stream.try_clone() {
|
||||
close_politely(&stream, &mut BufReader::new(read), grace);
|
||||
}
|
||||
});
|
||||
if spawned.is_err() {
|
||||
log::warn!("control server could not start a thread to close a refused connection");
|
||||
}
|
||||
}
|
||||
|
||||
/// Holds one connection slot; gives it back when the client thread ends, however it ends.
|
||||
struct Slot(Arc<AtomicUsize>);
|
||||
|
||||
@@ -170,6 +210,37 @@ fn is_http_request_line(text: &str) -> bool {
|
||||
|| 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. The timeout is set on the handle that
|
||||
// reads: on Windows, a read timeout set on one handle of a `try_clone` pair can fail to apply
|
||||
// to reads through the other.
|
||||
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() || reader.get_ref().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;
|
||||
@@ -182,6 +253,7 @@ fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context
|
||||
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,
|
||||
@@ -189,8 +261,11 @@ fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context
|
||||
let reply = match parse(&line) {
|
||||
Parsed::Blank => continue,
|
||||
Parsed::Reject(e) => {
|
||||
// The connection closes either way; a peer that can't read the reason loses nothing.
|
||||
// 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 } => {
|
||||
@@ -232,202 +307,4 @@ fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
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 /\"}"));
|
||||
}
|
||||
|
||||
#[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();
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
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() {
|
||||
// A slot comes back only when the thread serving a peer ends, which can be after the next
|
||||
// peer connects, so every peer has a slot of its own.
|
||||
const PEERS: usize = 12;
|
||||
let (addr, rx) = server(Limits { max_clients: PEERS, ..small() });
|
||||
for _ in 0..PEERS {
|
||||
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");
|
||||
}
|
||||
|
||||
/// A peer refused because every connection is taken still reads why when it had already
|
||||
/// written a line: closing with that line unread resets the connection and discards the
|
||||
/// reply. Repeated because it depends on the timing of the two.
|
||||
#[test]
|
||||
fn a_pipelined_peer_refused_as_busy_still_reads_why() {
|
||||
let (addr, rx) = server(Limits { max_clients: 1, idle_timeout: Duration::from_secs(30), ..small() });
|
||||
ui(rx);
|
||||
// Take the only slot, and make sure it is being served.
|
||||
let (mut hr, mut hw) = client(addr);
|
||||
writeln!(hw, r#"{{"id":1,"method":"m"}}"#).unwrap();
|
||||
assert!(reply(&mut hr).is_some());
|
||||
for _ in 0..50 {
|
||||
let (mut r, mut w) = client(addr);
|
||||
let _ = w.write_all(b"{\"id\":2,\"method\":\"m\"}\n");
|
||||
let replies = replies_until_closed(&mut r);
|
||||
assert_eq!(replies.len(), 1, "{replies:?}");
|
||||
assert!(replies[0]["error"].as_str().unwrap_or_default().starts_with("busy"), "{replies:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Peers refused as busy that stay connected without sending anything do not hold up the
|
||||
/// connections after them: each one's drain runs to its deadline on a thread of its own, and
|
||||
/// past `MAX_REFUSING` drains at once a refused connection is closed without one.
|
||||
#[test]
|
||||
fn silent_refused_peers_do_not_delay_the_next_refusal() {
|
||||
let (addr, rx) = server(Limits { max_clients: 1, idle_timeout: Duration::from_secs(30), ..small() });
|
||||
ui(rx);
|
||||
let (mut hr, mut hw) = client(addr);
|
||||
writeln!(hw, r#"{{"id":1,"method":"m"}}"#).unwrap();
|
||||
assert!(reply(&mut hr).is_some());
|
||||
// Twenty drains of 250 ms each, one after another, would take five seconds.
|
||||
let silent: Vec<_> = (0..MAX_REFUSING + 4).map(|_| client(addr)).collect();
|
||||
let start = Instant::now();
|
||||
let (mut r, _w) = client(addr);
|
||||
let refused = reply(&mut r).unwrap();
|
||||
assert!(refused["error"].as_str().unwrap_or_default().starts_with("busy"), "{refused}");
|
||||
assert!(start.elapsed() < Duration::from_secs(1), "refused after {:?}", start.elapsed());
|
||||
drop(silent);
|
||||
}
|
||||
|
||||
/// A peer that keeps its connection open after its line was rejected gives its slot back at
|
||||
/// the drain deadline (the write timeout), not at the idle timeout.
|
||||
#[test]
|
||||
fn a_rejected_peer_that_stays_connected_frees_its_slot_at_the_deadline() {
|
||||
let (addr, rx) = server(Limits { max_clients: 1, idle_timeout: Duration::from_secs(30), ..small() });
|
||||
ui(rx);
|
||||
let (mut r, mut w) = client(addr);
|
||||
w.write_all(b"hello\n").unwrap();
|
||||
assert_eq!(reply(&mut r).unwrap()["ok"], false);
|
||||
// `w` stays open, sending nothing.
|
||||
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));
|
||||
}
|
||||
drop(w);
|
||||
}
|
||||
|
||||
#[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() {
|
||||
// `a` sends nothing. Under a 300 ms idle timeout, a slow run could close it before the
|
||||
// third peer connected, and that peer then took its slot instead of reading `busy`.
|
||||
let (addr, rx) = server(Limits { idle_timeout: Duration::from_secs(30), ..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 request that reaches the one-slot queue first fills it and times out; the
|
||||
// other finds it full. Each connection has its own thread, so either request can be first.
|
||||
let (mut ar, mut aw) = client(addr);
|
||||
let (mut br, mut bw) = client(addr);
|
||||
writeln!(aw, r#"{{"id":1,"method":"m"}}"#).unwrap();
|
||||
writeln!(bw, r#"{{"id":2,"method":"m"}}"#).unwrap();
|
||||
let a = reply(&mut ar).unwrap();
|
||||
let b = reply(&mut br).unwrap();
|
||||
assert_eq!((&a["id"], &b["id"]), (&json!(1), &json!(2)));
|
||||
let mut errors = [a["error"].as_str().unwrap_or_default(), b["error"].as_str().unwrap_or_default()];
|
||||
errors.sort_unstable();
|
||||
assert_eq!(errors, ["busy: too many queued requests", "timeout"], "{a} {b}");
|
||||
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();
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -42,8 +42,9 @@ impl eframe::App for App {
|
||||
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) {
|
||||
self.0.raw_input_hook(raw);
|
||||
/// Forward native raw input to the wrapped application once per pass.
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.0.raw_input_hook(ctx, raw);
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.0.ui(ui);
|
||||
@@ -180,10 +181,19 @@ fn app_icon() -> Option<egui::IconData> {
|
||||
eframe::icon_data::from_png_bytes(png).map_err(|e| log::warn!("app icon: {e}")).ok()
|
||||
}
|
||||
|
||||
/// Install logging, parse startup arguments and environment, launch the native app, and
|
||||
/// propagate an eframe startup failure.
|
||||
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();
|
||||
if let Some(done) = craft_update::self_update::finish(&craft_update::suite::DECKCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
|
||||
if let Err(e) = done {
|
||||
eprintln!("DeckCraft update helper failed: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
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 => {
|
||||
@@ -255,8 +265,10 @@ fn main() -> eframe::Result {
|
||||
"DeckCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
deckcraft_ui_egui::install_ime_pass_hooks(&cc.egui_ctx);
|
||||
let mut app = SlideApp::new(Session::new(), services());
|
||||
load_prefs(&mut app);
|
||||
app.enable_update_checks();
|
||||
// AutoRecover: reopen what a previous run left unsaved, then keep it current.
|
||||
if std::env::var_os("DECKCRAFT_NO_RECOVERY").is_none() {
|
||||
app.session.recovery_dir = deckcraft_engine::recovery::default_dir();
|
||||
@@ -418,4 +430,27 @@ mod tests {
|
||||
assert_eq!(mac, win, "macOS and Windows offer different file types");
|
||||
assert_eq!(mac, linux, "macOS and Linux offer different file types");
|
||||
}
|
||||
|
||||
/// #1114: a shortcut that names an MSI `<Icon>` shows a blank icon once an update deletes the
|
||||
/// cached copy of it, and a taskbar pin (a copy of the shortcut) is never repaired. Shortcuts
|
||||
/// take the icon embedded in `deckcraft.exe`; only `ARPPRODUCTICON` uses the MSI `<Icon>`.
|
||||
#[test]
|
||||
fn windows_shortcuts_use_the_icon_embedded_in_the_exe() {
|
||||
let wxs = include_str!("../../../packaging/windows/deckcraft.wxs");
|
||||
let shortcuts: Vec<&str> = wxs.split("<Shortcut").skip(1).filter_map(|s| s.split("/>").next()).collect();
|
||||
assert!(!shortcuts.is_empty(), "deckcraft.wxs has no shortcut");
|
||||
for shortcut in shortcuts {
|
||||
assert!(!shortcut.contains("Icon="), "a shortcut names an MSI <Icon>:{shortcut}");
|
||||
}
|
||||
assert!(wxs.contains("<Property Id=\"ARPPRODUCTICON\""), "Apps and features keeps its icon");
|
||||
}
|
||||
|
||||
/// The shortcuts take the exe's own icon, and `package.ps1 -SkipBuild` packages an existing
|
||||
/// exe without `DECKCRAFT_REQUIRE_WINRES`, so the script has to refuse one with no icon.
|
||||
#[test]
|
||||
fn the_windows_package_script_rejects_an_exe_without_an_icon() {
|
||||
let script = include_str!("../../../packaging/windows/package.ps1");
|
||||
assert!(script.contains("ExtractIconEx"), "package.ps1 does not count the exe's icons");
|
||||
assert!(script.contains("has no embedded icon"), "package.ps1 does not reject an iconless exe");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,3 +2,7 @@ allow-unwrap-in-tests = true
|
||||
allow-expect-in-tests = true
|
||||
allow-panic-in-tests = true
|
||||
allow-indexing-slicing-in-tests = true
|
||||
disallowed-types = [
|
||||
{ path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" },
|
||||
]
|
||||
cognitive-complexity-threshold = 30
|
||||
|
||||
@@ -59,3 +59,12 @@ real_name = "Ryan C. Shoemaker"
|
||||
real_name = "Marco Arnulfo"
|
||||
display_name = "Marco Arnulfo"
|
||||
sync_github_name = false
|
||||
|
||||
[people.GhostMitra]
|
||||
sync_github_name = true
|
||||
|
||||
[people.Katacc]
|
||||
sync_github_name = true
|
||||
|
||||
[people.SofiaAFigueredo]
|
||||
real_name = "Sofia Alves Figueredo"
|
||||
|
||||
@@ -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;
|
||||
|
||||
+21
-21
@@ -1,10 +1,10 @@
|
||||
//! Slide show navigation: click steps, slide order (hidden slides, custom shows, ranges), loop
|
||||
//! and automatic advance.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use deckcraft_model::Presentation;
|
||||
use deckcraft_model::text::{Action, Hyperlink};
|
||||
use deckcraft_model::{Presentation, SlideId};
|
||||
|
||||
use crate::Timeline;
|
||||
use crate::interact::SlideRun;
|
||||
@@ -29,18 +29,8 @@ pub enum ShowAction {
|
||||
Exit,
|
||||
}
|
||||
|
||||
/// Can a link from a (possibly untrusted) deck be opened outside the show? Only web pages and
|
||||
/// mail: `file:`, `javascript:`, relative paths and other schemes (OS protocol handlers) are not
|
||||
/// opened on a click.
|
||||
pub fn safe_url(url: &str) -> bool {
|
||||
let u = url.trim();
|
||||
let Some((scheme, rest)) = u.split_once(':') else { return false };
|
||||
match scheme.to_ascii_lowercase().as_str() {
|
||||
"http" | "https" => rest.starts_with("//") && rest.len() > 2,
|
||||
"mailto" => !rest.is_empty(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
// Which links the show opens on a click; PDF export writes the same ones.
|
||||
pub use deckcraft_model::text::safe_url;
|
||||
|
||||
/// What a clicked link does in the show (`ShowState::follow`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -83,12 +73,25 @@ pub struct ShowState {
|
||||
pub zoom: Option<(usize, usize)>,
|
||||
}
|
||||
|
||||
/// Index of each slide by id (the first slide wins a duplicate id), so resolving a custom show
|
||||
/// costs the sum of its two lists, not their product.
|
||||
fn slide_indices(pres: &Presentation) -> HashMap<SlideId, usize> {
|
||||
let mut map = HashMap::with_capacity(pres.slides.len());
|
||||
for (i, s) in pres.slides.iter().enumerate() {
|
||||
map.entry(s.id).or_insert(i);
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
/// Slide indices the show visits in order: the custom show or range from Set Up Slide Show, minus
|
||||
/// hidden slides.
|
||||
pub fn show_order(pres: &Presentation) -> Vec<usize> {
|
||||
let n = pres.slides.len();
|
||||
let base: Vec<usize> = match pres.show.custom_show.as_ref().and_then(|name| pres.custom_shows.iter().find(|c| &c.name == name)) {
|
||||
Some(cs) => cs.slides.iter().filter_map(|id| pres.slides.iter().position(|s| s.id == *id)).collect(),
|
||||
Some(cs) => {
|
||||
let index = slide_indices(pres);
|
||||
cs.slides.iter().filter_map(|id| index.get(id).copied()).collect()
|
||||
}
|
||||
None => match pres.show.range {
|
||||
Some((a, b)) => {
|
||||
let a = (a.max(1) as usize).saturating_sub(1);
|
||||
@@ -104,12 +107,9 @@ pub fn show_order(pres: &Presentation) -> Vec<usize> {
|
||||
/// 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();
|
||||
let index = slide_indices(pres);
|
||||
let order: Vec<usize> =
|
||||
cs.slides.iter().filter_map(|id| index.get(id).copied()).filter(|&i| pres.slides.get(i).is_some_and(|s| !s.hidden)).collect();
|
||||
if order.is_empty() { None } else { Some(order) }
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,10 @@ use deckcraft_model::{
|
||||
AnimClass, AnimStart, Animation, ColorRef, CustomShow, Presentation, Rgba, Shape, ShapeId, Slide, SlideId, TextBody, Transition, Xfrm,
|
||||
};
|
||||
|
||||
use crate::*;
|
||||
use crate::{
|
||||
AnimState, Layer, LinkJump, MotionPath, ShowAction, ShowState, Source, Timeline, TriggerPlay, default_path, ease, morph_crop, morph_frame,
|
||||
morph_pairs, morph_xfrm, show_order, transition_frame, transition_layers,
|
||||
};
|
||||
|
||||
const W: f64 = 960.0;
|
||||
const H: f64 = 540.0;
|
||||
@@ -344,6 +347,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(
|
||||
@@ -1052,3 +1085,53 @@ fn show_returns_to_the_zoom_slide() {
|
||||
s.goto(&p, 4);
|
||||
assert_eq!(s.next(&p), ShowAction::Slide(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_show_resolves_duplicate_ids_to_the_first_slide() {
|
||||
let mut p = pres(3);
|
||||
Arc::make_mut(&mut p.slides[2]).id = p.slides[0].id;
|
||||
p.custom_shows = vec![CustomShow { name: "Dup".into(), slides: vec![p.slides[0].id, SlideId(1001), SlideId(424_242)] }];
|
||||
p.show.custom_show = Some("Dup".into());
|
||||
assert_eq!(show_order(&p), vec![0, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_show_with_many_entries_resolves_quickly() {
|
||||
let mut p = pres(20_000);
|
||||
let ids: Vec<SlideId> = p.slides.iter().map(|s| s.id).collect();
|
||||
p.custom_shows = vec![CustomShow { name: "All".into(), slides: ids.iter().rev().copied().collect() }];
|
||||
p.show.custom_show = Some("All".into());
|
||||
let t = std::time::Instant::now();
|
||||
let order = show_order(&p);
|
||||
assert_eq!(order.len(), 20_000);
|
||||
assert_eq!(order[0], 19_999);
|
||||
assert!(t.elapsed().as_secs() < 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeline_targets_the_first_shape_with_a_duplicate_id() {
|
||||
let mut s = slide_with(vec![anim(7, AnimClass::Entrance, "fade", AnimStart::OnClick)]);
|
||||
s.shapes[0] = shape(7, "first");
|
||||
s.shapes[1] = shape(7, "second");
|
||||
s.shapes[0].xfrm = Some(Xfrm::new(10.0, 10.0, 20.0, 20.0));
|
||||
let tl = Timeline::new(&s, W, H);
|
||||
assert_eq!(tl.effects().len(), 1);
|
||||
assert_eq!(tl.steps(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_by_paragraph_animations_on_one_shape_share_paragraph_targets() {
|
||||
let mut s = slide_with(vec![]);
|
||||
s.shapes[0].text = Some(TextBody::from_text("one\ntwo\nthree"));
|
||||
s.animations = (0..2_000)
|
||||
.map(|_| {
|
||||
let mut a = anim(1, AnimClass::Path, "customPath", AnimStart::OnClick);
|
||||
a.text_build = TextBuild::ByParagraph;
|
||||
a.path = Some("M 0 0 L 0.5 0.5 E".into());
|
||||
a
|
||||
})
|
||||
.collect();
|
||||
let tl = Timeline::new(&s, W, H);
|
||||
assert_eq!(tl.effects().len(), 6_000);
|
||||
assert_eq!(tl.paragraph_targets(ShapeId(1)), vec![0, 1, 2]);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::sync::Arc;
|
||||
use deckcraft_model::text::Action;
|
||||
use deckcraft_model::{AnimClass, AnimStart, Animation, Presentation, Shape, ShapeId, Slide, SlideId, Transition, Xfrm};
|
||||
|
||||
use crate::*;
|
||||
use crate::{Click, HitShape, LinkJump, ShowAction, ShowState, Target, Timeline};
|
||||
|
||||
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() }
|
||||
|
||||
+72
-27
@@ -1,10 +1,11 @@
|
||||
//! A slide's animation sequence: click steps, effect start times and per-shape state over time.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::anim::TextBuild;
|
||||
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};
|
||||
use deckcraft_model::{AnimClass, AnimStart, Animation, ColorScheme, Rgba, SchemeSlot, Shape, ShapeId, Slide, Xfrm};
|
||||
|
||||
use crate::AnimState;
|
||||
use crate::effects::{EffectSpec, exit_base, motion_path};
|
||||
@@ -12,7 +13,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.
|
||||
@@ -86,7 +86,9 @@ enum After {
|
||||
#[derive(Clone, Debug)]
|
||||
struct Effect {
|
||||
timing: EffectTiming,
|
||||
spec: EffectSpec,
|
||||
/// Shared by every paragraph effect of one animation, so a large motion path is not copied
|
||||
/// per paragraph.
|
||||
spec: Arc<EffectSpec>,
|
||||
after: After,
|
||||
}
|
||||
|
||||
@@ -163,6 +165,14 @@ impl Timeline {
|
||||
let accent = scheme.map(|s| s.get(SchemeSlot::Accent1)).unwrap_or(Rgba::rgb(0x15, 0x60, 0x82));
|
||||
|
||||
let mut tl = Timeline::default();
|
||||
if slide.animations.is_empty() {
|
||||
return tl;
|
||||
}
|
||||
// Every shape by id, built once: resolving each animation's target by tree search would
|
||||
// cost shapes x animations.
|
||||
let shapes = shape_index(&slide.shapes);
|
||||
// Paragraph targets of each shape, worked out for its first by-paragraph animation only.
|
||||
let mut para_cache: HashMap<ShapeId, Arc<Vec<Option<usize>>>> = HashMap::new();
|
||||
// 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())];
|
||||
@@ -191,34 +201,38 @@ impl Timeline {
|
||||
if tl.effects.len() >= MAX_EFFECTS {
|
||||
break;
|
||||
}
|
||||
let shape = slide.shape(a.shape);
|
||||
let shape = shapes.get(&a.shape).copied();
|
||||
// Paragraph targets of this animation.
|
||||
let paras: Vec<Option<usize>> = match (a.paragraph, a.text_build) {
|
||||
(Some(p), _) => vec![Some(p as usize)],
|
||||
let paras: Arc<Vec<Option<usize>>> = match (a.paragraph, a.text_build) {
|
||||
(Some(p), _) => Arc::new(vec![Some(p as usize)]),
|
||||
(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
|
||||
&& grouped.insert((a.shape, i, o))
|
||||
{
|
||||
tl.groups.push((a.shape, i, o));
|
||||
let cached = para_cache.entry(a.shape).or_insert_with(|| {
|
||||
let mut v: Vec<Option<usize>> = vec![];
|
||||
if let Some(t) = shape.and_then(|s| s.text.as_ref()) {
|
||||
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 => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if v.is_empty() { vec![None] } else { v }
|
||||
if v.is_empty() {
|
||||
v.push(None)
|
||||
}
|
||||
Arc::new(v)
|
||||
});
|
||||
Arc::clone(cached)
|
||||
}
|
||||
_ => vec![None],
|
||||
_ => Arc::new(vec![None]),
|
||||
};
|
||||
let xf = xfrm_of(a.shape).or_else(|| shape.and_then(|s| s.xfrm)).unwrap_or(Xfrm::new(sw / 4.0, sh / 4.0, sw / 2.0, sh / 2.0));
|
||||
let xf = sanitize_xfrm(xf, sw, sh);
|
||||
let spec = build_spec(a, xf, sw, sh, &resolve);
|
||||
let spec = Arc::new(build_spec(a, xf, sw, sh, &resolve));
|
||||
let dur = secs(a.duration_ms);
|
||||
let delay = secs(a.delay_ms);
|
||||
let until_next_click = a.repeat == u32::MAX;
|
||||
@@ -287,7 +301,7 @@ impl Timeline {
|
||||
span,
|
||||
class: a.class,
|
||||
},
|
||||
spec: spec.clone(),
|
||||
spec: Arc::clone(&spec),
|
||||
after: after.clone(),
|
||||
});
|
||||
}
|
||||
@@ -298,12 +312,13 @@ impl Timeline {
|
||||
}
|
||||
}
|
||||
// Base colours of animated shapes.
|
||||
let mut colored: HashSet<ShapeId> = HashSet::new();
|
||||
for e in &tl.effects {
|
||||
let id = e.timing.shape;
|
||||
if tl.colors.iter().any(|(s, _)| *s == id) {
|
||||
if !colored.insert(id) {
|
||||
continue;
|
||||
}
|
||||
let c = match slide.shape(id).and_then(|s| s.fill.as_ref()) {
|
||||
let c = match shapes.get(&id).copied().and_then(|s| s.fill.as_ref()) {
|
||||
Some(Fill::Solid { color }) => resolve(color),
|
||||
_ => accent,
|
||||
};
|
||||
@@ -359,6 +374,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()
|
||||
@@ -523,6 +551,23 @@ fn status(e: &EffectTiming, step: usize, t: f64) -> Status {
|
||||
Status::Active(p.clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
/// Shapes by id in the order `Slide::shape` searches (a shape before its children, groups at most
|
||||
/// 64 deep); the first shape with an id wins a duplicate.
|
||||
fn shape_index(shapes: &[Shape]) -> HashMap<ShapeId, &Shape> {
|
||||
fn walk<'a>(shapes: &'a [Shape], depth: usize, out: &mut HashMap<ShapeId, &'a Shape>) {
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
for s in shapes {
|
||||
out.entry(s.id).or_insert(s);
|
||||
walk(s.children(), depth + 1, out);
|
||||
}
|
||||
}
|
||||
let mut out = HashMap::new();
|
||||
walk(shapes, 0, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn build_spec(a: &Animation, xf: Xfrm, sw: f64, sh: f64, resolve: &dyn Fn(&deckcraft_model::ColorRef) -> Rgba) -> EffectSpec {
|
||||
let effect = if a.class == AnimClass::Exit { exit_base(&a.effect).to_string() } else { a.effect.clone() };
|
||||
let path = if a.class == AnimClass::Path { Some(motion_path(&a.effect, &a.option, a.path.as_deref())) } else { None };
|
||||
|
||||
@@ -9,7 +9,10 @@ use std::f64::consts::PI;
|
||||
|
||||
use crate::easing::ease;
|
||||
use crate::transition::{FULL, Layer, R, Source};
|
||||
use crate::transition_geom::*;
|
||||
use crate::transition_geom::{
|
||||
Cam, V3, boundary, card, center, clip, dir, edge, ein, eout, facing_shade, fan, field, hash64, moved, offset, poly_layers, pow_inout, q2,
|
||||
rect_poly, rnd, rot, rot_quad, scale_rect, scaled, soft_rect, ss, v3, win,
|
||||
};
|
||||
|
||||
use Source::{Black, New, NewBack, NewContent, Old, OldBack, OldContent, White};
|
||||
|
||||
|
||||
+118
-77
@@ -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
|
||||
@@ -45,7 +45,7 @@ fn cdef_block(fs: &FrameState, out: &mut FrameBuf, r: usize, c: usize, idx: usiz
|
||||
let var_str = if (var >> 6) != 0 { (31 - ((var >> 6) as u32).leading_zeros()).min(12) as i32 } else { 0 };
|
||||
pri = if var != 0 { (pri * (4 + var_str) + 8) >> 4 } else { 0 };
|
||||
let damping = cp.damping as i32 + coeff_shift as i32;
|
||||
filter(fs, out, 0, r, c, pri, sec, damping, dir);
|
||||
filter(fs, out, 0, (r, c), (pri, sec), damping, dir);
|
||||
if fs.num_planes == 1 {
|
||||
return true;
|
||||
}
|
||||
@@ -53,8 +53,8 @@ fn cdef_block(fs: &FrameState, out: &mut FrameBuf, r: usize, c: usize, idx: usiz
|
||||
let sec = (cp.uv_sec[idx] << coeff_shift) as i32;
|
||||
let dir = if pri == 0 { 0 } else { CDEF_UV_DIR[fs.ssx][fs.ssy][y_dir] as usize };
|
||||
let damping = cp.damping as i32 + coeff_shift as i32 - 1;
|
||||
filter(fs, out, 1, r, c, pri, sec, damping, dir);
|
||||
filter(fs, out, 2, r, c, pri, sec, damping, dir);
|
||||
filter(fs, out, 1, (r, c), (pri, sec), damping, dir);
|
||||
filter(fs, out, 2, (r, c), (pri, sec), damping, dir);
|
||||
true
|
||||
}
|
||||
|
||||
@@ -124,33 +124,74 @@ fn constrain(diff: i32, threshold: i32, adj: u32) -> i32 {
|
||||
if diff < 0 { -mag } else { mag }
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn filter(fs: &FrameState, out: &mut FrameBuf, plane: usize, r: usize, c: usize, pri: i32, sec: i32, damping: i32, dir: usize) {
|
||||
if pri == 0 && sec == 0 {
|
||||
// Every tap contributes 0 and the clamp to [min, max] keeps x: CdefFrame = CurrFrame.
|
||||
return;
|
||||
/// Tap offsets, weights and strengths of one block.
|
||||
struct Taps {
|
||||
pri: i32,
|
||||
sec: i32,
|
||||
pri_adj: u32,
|
||||
sec_adj: u32,
|
||||
pri_off: [isize; 2],
|
||||
sec_off: [[isize; 2]; 2],
|
||||
pri_taps: [i32; 2],
|
||||
sec_taps: [i32; 2],
|
||||
}
|
||||
|
||||
impl Taps {
|
||||
fn new(coeff_shift: u32, (pri, sec): (i32, i32), damping: i32, dir: usize) -> Taps {
|
||||
let pri_tap = ((pri >> coeff_shift) & 1) as usize;
|
||||
let off = |d: usize, k: usize| CDEF_DIRECTIONS[d][k][0] as isize * PAD_STRIDE as isize + CDEF_DIRECTIONS[d][k][1] as isize;
|
||||
let pri_off = [off(dir, 0), off(dir, 1)];
|
||||
let d_lo = (dir + 6) & 7;
|
||||
let d_hi = (dir + 2) & 7;
|
||||
let sec_off = [[off(d_lo, 0), off(d_hi, 0)], [off(d_lo, 1), off(d_hi, 1)]];
|
||||
let pri_taps = [CDEF_PRI_TAPS[pri_tap][0] as i32, CDEF_PRI_TAPS[pri_tap][1] as i32];
|
||||
let sec_taps = [CDEF_SEC_TAPS[pri_tap][0] as i32, CDEF_SEC_TAPS[pri_tap][1] as i32];
|
||||
let adj = |t: i32| if t == 0 { 0 } else { (damping - (31 - (t as u32).leading_zeros()) as i32).max(0) as u32 };
|
||||
Taps { pri, sec, pri_adj: adj(pri), sec_adj: adj(sec), pri_off, sec_off, pri_taps, sec_taps }
|
||||
}
|
||||
let coeff_shift = fs.bit_depth - 8;
|
||||
|
||||
/// The 12 (offset, weight, strength, adjustment) taps in filtering order.
|
||||
fn tap_list(&self) -> [(isize, i32, i32, u32); 12] {
|
||||
let mut taps = [(0isize, 0i32, 0i32, 0u32); 12];
|
||||
let mut n = 0;
|
||||
for k in 0..2 {
|
||||
for sign in [-1isize, 1] {
|
||||
taps[n] = (sign * self.pri_off[k], self.pri_taps[k], self.pri, self.pri_adj);
|
||||
taps[n + 1] = (sign * self.sec_off[k][0], self.sec_taps[k], self.sec, self.sec_adj);
|
||||
taps[n + 2] = (sign * self.sec_off[k][1], self.sec_taps[k], self.sec, self.sec_adj);
|
||||
n += 3;
|
||||
}
|
||||
}
|
||||
taps
|
||||
}
|
||||
}
|
||||
|
||||
/// Position and size of a block within its plane.
|
||||
#[derive(Clone, Copy)]
|
||||
struct BlockRect {
|
||||
x0: usize,
|
||||
y0: usize,
|
||||
w: usize,
|
||||
h: usize,
|
||||
}
|
||||
|
||||
/// The block with two samples of padding on every side, plus whether every sample is available
|
||||
/// (CdefAvailable: its 4x4 block is inside the frame).
|
||||
fn padded_block(fs: &FrameState, plane: usize, src: &Plane, rect: BlockRect) -> ([i32; PAD_STRIDE * PAD_STRIDE], bool) {
|
||||
let (sub_x, sub_y) = if plane > 0 { (fs.ssx, fs.ssy) } else { (0, 0) };
|
||||
let x0 = (c * 4) >> sub_x;
|
||||
let y0 = (r * 4) >> sub_y;
|
||||
let w = 8 >> sub_x;
|
||||
let h = 8 >> sub_y;
|
||||
let src = &fs.cur.planes[plane];
|
||||
let (mi_rows, mi_cols) = (fs.fh.mi_rows as isize, fs.fh.mi_cols as isize);
|
||||
// CdefAvailable per sample: its 4x4 block is inside the frame.
|
||||
let mut buf = [CDEF_NA; PAD_STRIDE * PAD_STRIDE];
|
||||
let mut all_avail = true;
|
||||
for i in 0..h + 4 {
|
||||
let y = (y0 + i) as isize - 2;
|
||||
for i in 0..rect.h + 4 {
|
||||
let y = (rect.y0 + i) as isize - 2;
|
||||
let cand_r = (y << sub_y) >> 2;
|
||||
if y < 0 || cand_r >= mi_rows {
|
||||
all_avail = false;
|
||||
continue;
|
||||
}
|
||||
let row = src.row(y as usize);
|
||||
for j in 0..w + 4 {
|
||||
let x = (x0 + j) as isize - 2;
|
||||
for j in 0..rect.w + 4 {
|
||||
let x = (rect.x0 + j) as isize - 2;
|
||||
let cand_c = (x << sub_x) >> 2;
|
||||
if x >= 0 && cand_c < mi_cols {
|
||||
buf[i * PAD_STRIDE + j] = row[x as usize] as i32;
|
||||
@@ -159,70 +200,70 @@ fn filter(fs: &FrameState, out: &mut FrameBuf, plane: usize, r: usize, c: usize,
|
||||
}
|
||||
}
|
||||
}
|
||||
let pri_tap = ((pri >> coeff_shift) & 1) as usize;
|
||||
let off = |d: usize, k: usize| CDEF_DIRECTIONS[d][k][0] as isize * PAD_STRIDE as isize + CDEF_DIRECTIONS[d][k][1] as isize;
|
||||
let pri_off = [off(dir, 0), off(dir, 1)];
|
||||
let d_lo = (dir + 6) & 7;
|
||||
let d_hi = (dir + 2) & 7;
|
||||
let sec_off = [[off(d_lo, 0), off(d_hi, 0)], [off(d_lo, 1), off(d_hi, 1)]];
|
||||
let pri_taps = [CDEF_PRI_TAPS[pri_tap][0] as i32, CDEF_PRI_TAPS[pri_tap][1] as i32];
|
||||
let sec_taps = [CDEF_SEC_TAPS[pri_tap][0] as i32, CDEF_SEC_TAPS[pri_tap][1] as i32];
|
||||
let adj = |t: i32| if t == 0 { 0 } else { (damping - (31 - (t as u32).leading_zeros()) as i32).max(0) as u32 };
|
||||
let (pri_adj, sec_adj) = (adj(pri), adj(sec));
|
||||
(buf, all_avail)
|
||||
}
|
||||
|
||||
/// Filters one sample whose taps may be unavailable (CDEF_NA).
|
||||
fn filter_sample(buf: &[i32; PAD_STRIDE * PAD_STRIDE], taps: &Taps, ci: isize, x: i32) -> u16 {
|
||||
let mut sum = 0i32;
|
||||
let mut max = x;
|
||||
let mut min = x;
|
||||
for k in 0..2 {
|
||||
for sign in [-1isize, 1] {
|
||||
let p = buf[(ci + sign * taps.pri_off[k]) as usize];
|
||||
if p != CDEF_NA {
|
||||
if taps.pri != 0 {
|
||||
sum += taps.pri_taps[k] * constrain(p - x, taps.pri, taps.pri_adj);
|
||||
}
|
||||
max = max.max(p);
|
||||
min = min.min(p);
|
||||
}
|
||||
for s in 0..2 {
|
||||
let q = buf[(ci + sign * taps.sec_off[k][s]) as usize];
|
||||
if q != CDEF_NA {
|
||||
if taps.sec != 0 {
|
||||
sum += taps.sec_taps[k] * constrain(q - x, taps.sec, taps.sec_adj);
|
||||
}
|
||||
max = max.max(q);
|
||||
min = min.min(q);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(x + ((8 + sum - (sum < 0) as i32) >> 4)).clamp(min, max) as u16
|
||||
}
|
||||
|
||||
fn filter(fs: &FrameState, out: &mut FrameBuf, plane: usize, (r, c): (usize, usize), strengths: (i32, i32), damping: i32, dir: usize) {
|
||||
let (pri, sec) = strengths;
|
||||
if pri == 0 && sec == 0 {
|
||||
// Every tap contributes 0 and the clamp to [min, max] keeps x: CdefFrame = CurrFrame.
|
||||
return;
|
||||
}
|
||||
let coeff_shift = fs.bit_depth - 8;
|
||||
let (sub_x, sub_y) = if plane > 0 { (fs.ssx, fs.ssy) } else { (0, 0) };
|
||||
let rect = BlockRect { x0: (c * 4) >> sub_x, y0: (r * 4) >> sub_y, w: 8 >> sub_x, h: 8 >> sub_y };
|
||||
let src = &fs.cur.planes[plane];
|
||||
let (buf, all_avail) = padded_block(fs, plane, src, rect);
|
||||
let taps = Taps::new(coeff_shift, strengths, damping, dir);
|
||||
let dst = &mut out.planes[plane];
|
||||
if all_avail {
|
||||
// Every tap is available: whole rows at a time (constrain() with threshold 0 is 0).
|
||||
let mut taps = [(0isize, 0i32, 0i32, 0u32); 12];
|
||||
let mut n = 0;
|
||||
for k in 0..2 {
|
||||
for sign in [-1isize, 1] {
|
||||
taps[n] = (sign * pri_off[k], pri_taps[k], pri, pri_adj);
|
||||
taps[n + 1] = (sign * sec_off[k][0], sec_taps[k], sec, sec_adj);
|
||||
taps[n + 2] = (sign * sec_off[k][1], sec_taps[k], sec, sec_adj);
|
||||
n += 3;
|
||||
}
|
||||
}
|
||||
let rows = (y0, x0, h);
|
||||
match w {
|
||||
8 => filter_rows::<8>(&buf, &taps, dst, rows),
|
||||
_ => filter_rows::<4>(&buf, &taps, dst, rows),
|
||||
let list = taps.tap_list();
|
||||
let rows = (rect.y0, rect.x0, rect.h);
|
||||
match rect.w {
|
||||
8 => filter_rows::<8>(&buf, &list, dst, rows),
|
||||
_ => filter_rows::<4>(&buf, &list, dst, rows),
|
||||
}
|
||||
return;
|
||||
}
|
||||
for i in 0..h {
|
||||
for i in 0..rect.h {
|
||||
// The centre sample is read even where CdefAvailable is 0 (8x8 blocks straddling an odd
|
||||
// MiRows / MiCols edge).
|
||||
let srow = &src.row(y0 + i)[x0..x0 + w];
|
||||
let drow = &mut dst.row_mut(y0 + i)[x0..x0 + w];
|
||||
for j in 0..w {
|
||||
let srow = &src.row(rect.y0 + i)[rect.x0..rect.x0 + rect.w];
|
||||
let drow = &mut dst.row_mut(rect.y0 + i)[rect.x0..rect.x0 + rect.w];
|
||||
for j in 0..rect.w {
|
||||
let ci = ((i + 2) * PAD_STRIDE + j + 2) as isize;
|
||||
let x = srow[j] as i32;
|
||||
let mut sum = 0i32;
|
||||
let mut max = x;
|
||||
let mut min = x;
|
||||
for k in 0..2 {
|
||||
for sign in [-1isize, 1] {
|
||||
let p = buf[(ci + sign * pri_off[k]) as usize];
|
||||
if p != CDEF_NA {
|
||||
if pri != 0 {
|
||||
sum += pri_taps[k] * constrain(p - x, pri, pri_adj);
|
||||
}
|
||||
max = max.max(p);
|
||||
min = min.min(p);
|
||||
}
|
||||
for s in 0..2 {
|
||||
let q = buf[(ci + sign * sec_off[k][s]) as usize];
|
||||
if q != CDEF_NA {
|
||||
if sec != 0 {
|
||||
sum += sec_taps[k] * constrain(q - x, sec, sec_adj);
|
||||
}
|
||||
max = max.max(q);
|
||||
min = min.min(q);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
drow[j] = (x + ((8 + sum - (sum < 0) as i32) >> 4)).clamp(min, max) as u16;
|
||||
drow[j] = filter_sample(&buf, &taps, ci, srow[j] as i32);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -242,14 +283,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.
|
||||
|
||||
+88
-58
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -401,49 +405,35 @@ impl Decoder {
|
||||
r
|
||||
}
|
||||
|
||||
/// Whether an OBU belongs to the selected operating point (spec 7.5: OBUs of other layers are dropped).
|
||||
fn in_operating_point(&self, obu: &ObuUnit) -> bool {
|
||||
if obu.obu_type != OBU_SEQUENCE_HEADER_T
|
||||
&& obu.obu_type != OBU_TEMPORAL_DELIMITER_T
|
||||
&& obu.ext
|
||||
&& let Some(seq) = &self.seq
|
||||
&& seq.op_idc != 0
|
||||
{
|
||||
let idc = seq.op_idc;
|
||||
let in_t = (idc >> obu.temporal_id) & 1;
|
||||
let in_s = (idc >> (obu.spatial_id + 8)) & 1;
|
||||
if in_t == 0 || in_s == 0 {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn parse_obus(&mut self, data: &[u8], out: &mut Vec<(Output, usize, u32)>) -> Result<()> {
|
||||
let mut tu = 0usize;
|
||||
let mut pos = 0;
|
||||
while pos < data.len() {
|
||||
let h = data[pos];
|
||||
if h & 0x80 != 0 {
|
||||
return Err(Error::Invalid("obu_forbidden_bit"));
|
||||
let obu = parse_obu_unit(data, pos)?;
|
||||
pos = obu.end;
|
||||
if !self.in_operating_point(&obu) {
|
||||
continue;
|
||||
}
|
||||
let obu_type = ((h >> 3) & 0xf) as u32;
|
||||
let ext = h & 4 != 0;
|
||||
let has_size = h & 2 != 0;
|
||||
let mut p = pos + 1;
|
||||
let (mut temporal_id, mut spatial_id) = (0, 0);
|
||||
if ext {
|
||||
let e = *data.get(p).ok_or(Error::Truncated)?;
|
||||
temporal_id = (e >> 5) as u32;
|
||||
spatial_id = ((e >> 3) & 3) as u32;
|
||||
p += 1;
|
||||
}
|
||||
let size = if has_size {
|
||||
let (v, n) = leb128(data.get(p..).ok_or(Error::Truncated)?)?;
|
||||
p += n;
|
||||
v as usize
|
||||
} else {
|
||||
data.len().checked_sub(p).ok_or(Error::Truncated)?
|
||||
};
|
||||
let end = p.checked_add(size).filter(|&e| e <= data.len()).ok_or(Error::Truncated)?;
|
||||
let payload = &data[p..end];
|
||||
pos = end;
|
||||
if obu_type != OBU_SEQUENCE_HEADER_T
|
||||
&& obu_type != OBU_TEMPORAL_DELIMITER_T
|
||||
&& ext
|
||||
&& let Some(seq) = &self.seq
|
||||
&& seq.op_idc != 0
|
||||
{
|
||||
let idc = seq.op_idc;
|
||||
let in_t = (idc >> temporal_id) & 1;
|
||||
let in_s = (idc >> (spatial_id + 8)) & 1;
|
||||
if in_t == 0 || in_s == 0 {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
match obu_type {
|
||||
let (payload, spatial_id) = (obu.payload, obu.spatial_id);
|
||||
match obu.obu_type {
|
||||
OBU_SEQUENCE_HEADER_T => {
|
||||
let s = SequenceHeader::parse(payload)?;
|
||||
self.seq = Some(s);
|
||||
@@ -453,24 +443,11 @@ impl Decoder {
|
||||
tu += 1;
|
||||
}
|
||||
OBU_FRAME_HEADER_T | OBU_REDUNDANT_FRAME_HEADER_T | OBU_FRAME_T => {
|
||||
if self.seen_frame_header {
|
||||
// frame_header_copy(): identical to the active header
|
||||
if obu_type != OBU_FRAME_T {
|
||||
continue;
|
||||
if let Some(o) = self.frame_obu(&obu)? {
|
||||
if out.iter().filter(|(o, _, _)| matches!(o, Output::Existing { .. })).count() >= 32 {
|
||||
return Err(Error::Invalid("too many frame outputs in chunk"));
|
||||
}
|
||||
}
|
||||
let mut r = BitReader::new(payload);
|
||||
let shown = self.frame_header(&mut r, temporal_id, spatial_id)?;
|
||||
if let Some(o) = shown {
|
||||
out.push((o, tu, spatial_id));
|
||||
continue;
|
||||
}
|
||||
if obu_type == OBU_FRAME_T {
|
||||
r.byte_align();
|
||||
let rest = payload.get(r.byte_pos()..).ok_or(Error::Truncated)?;
|
||||
if let Some(o) = self.tile_group(rest)? {
|
||||
out.push((o, tu, spatial_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
OBU_TILE_GROUP_T => {
|
||||
@@ -485,6 +462,22 @@ impl Decoder {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A frame header or frame OBU: returns the picture it completes, if any.
|
||||
fn frame_obu(&mut self, obu: &ObuUnit) -> Result<Option<Output>> {
|
||||
// frame_header_copy(): identical to the active header
|
||||
if self.seen_frame_header && obu.obu_type != OBU_FRAME_T {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut r = BitReader::new(obu.payload);
|
||||
let shown = self.frame_header(&mut r, obu.temporal_id, obu.spatial_id)?;
|
||||
if shown.is_some() || obu.obu_type != OBU_FRAME_T {
|
||||
return Ok(shown);
|
||||
}
|
||||
r.byte_align();
|
||||
let rest = obu.payload.get(r.byte_pos()..).ok_or(Error::Truncated)?;
|
||||
self.tile_group(rest)
|
||||
}
|
||||
|
||||
/// frame_header_obu( ): returns the output for show_existing_frame.
|
||||
fn frame_header(&mut self, r: &mut BitReader, temporal_id: u32, spatial_id: u32) -> Result<Option<Output>> {
|
||||
let seq = self.seq.clone().ok_or(Error::Invalid("frame before sequence header"))?;
|
||||
@@ -1010,6 +1003,43 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// One OBU: its header fields, payload and the position just past it.
|
||||
struct ObuUnit<'a> {
|
||||
obu_type: u32,
|
||||
ext: bool,
|
||||
temporal_id: u32,
|
||||
spatial_id: u32,
|
||||
payload: &'a [u8],
|
||||
end: usize,
|
||||
}
|
||||
|
||||
fn parse_obu_unit(data: &[u8], pos: usize) -> Result<ObuUnit<'_>> {
|
||||
let h = data[pos];
|
||||
if h & 0x80 != 0 {
|
||||
return Err(Error::Invalid("obu_forbidden_bit"));
|
||||
}
|
||||
let obu_type = ((h >> 3) & 0xf) as u32;
|
||||
let ext = h & 4 != 0;
|
||||
let has_size = h & 2 != 0;
|
||||
let mut p = pos + 1;
|
||||
let (mut temporal_id, mut spatial_id) = (0, 0);
|
||||
if ext {
|
||||
let e = *data.get(p).ok_or(Error::Truncated)?;
|
||||
temporal_id = (e >> 5) as u32;
|
||||
spatial_id = ((e >> 3) & 3) as u32;
|
||||
p += 1;
|
||||
}
|
||||
let size = if has_size {
|
||||
let (v, n) = leb128(data.get(p..).ok_or(Error::Truncated)?)?;
|
||||
p += n;
|
||||
v as usize
|
||||
} else {
|
||||
data.len().checked_sub(p).ok_or(Error::Truncated)?
|
||||
};
|
||||
let end = p.checked_add(size).filter(|&e| e <= data.len()).ok_or(Error::Truncated)?;
|
||||
Ok(ObuUnit { obu_type, ext, temporal_id, spatial_id, payload: &data[p..end], end })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod guard_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+313
-167
@@ -55,21 +55,79 @@ impl Rng {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn apply(pic: &mut Picture, g: &FilmGrainParams, seq: &SequenceHeader) {
|
||||
let _ = seq;
|
||||
let bd = pic.bit_depth as u32;
|
||||
let mono = pic.mono_chrome;
|
||||
let num_planes = if mono { 1 } else { 3 };
|
||||
let (sub_x, sub_y) = (pic.subsampling_x as usize, pic.subsampling_y as usize);
|
||||
let w = pic.width as usize;
|
||||
let h = pic.height as usize;
|
||||
let grain_center = 128i32 << (bd - 8);
|
||||
let grain_min = -grain_center;
|
||||
let grain_max = (256i32 << (bd - 8)) - 1 - grain_center;
|
||||
/// Picture-level parameters shared by the grain synthesis stages.
|
||||
struct GrainCtx<'a> {
|
||||
g: &'a FilmGrainParams,
|
||||
bd: u32,
|
||||
mono: bool,
|
||||
sub_x: usize,
|
||||
sub_y: usize,
|
||||
w: usize,
|
||||
h: usize,
|
||||
grain_min: i32,
|
||||
grain_max: i32,
|
||||
}
|
||||
|
||||
// Generate grain (7.18.3.3).
|
||||
let mut rng = Rng(g.grain_seed);
|
||||
let shift = 12 - bd + g.grain_scale_shift;
|
||||
impl GrainCtx<'_> {
|
||||
fn num_planes(&self) -> usize {
|
||||
if self.mono { 1 } else { 3 }
|
||||
}
|
||||
|
||||
fn grain_shift(&self) -> u32 {
|
||||
12 - self.bd + self.g.grain_scale_shift
|
||||
}
|
||||
|
||||
fn ar_shift(&self) -> u32 {
|
||||
self.g.ar_coeff_shift_minus_6 + 6
|
||||
}
|
||||
|
||||
fn lag(&self) -> i32 {
|
||||
self.g.ar_coeff_lag as i32
|
||||
}
|
||||
|
||||
fn stripe_w(&self) -> usize {
|
||||
self.w + 64
|
||||
}
|
||||
}
|
||||
|
||||
type GrainTemplate = Vec<[i32; 82]>;
|
||||
|
||||
/// The luma, Cb and Cr grain templates (7.18.3.3).
|
||||
struct Templates {
|
||||
luma: GrainTemplate,
|
||||
cb: GrainTemplate,
|
||||
cr: GrainTemplate,
|
||||
}
|
||||
|
||||
impl Templates {
|
||||
fn plane(&self, plane: usize) -> &GrainTemplate {
|
||||
match plane {
|
||||
0 => &self.luma,
|
||||
1 => &self.cb,
|
||||
_ => &self.cr,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn luma_ar_sum(g: &FilmGrainParams, luma: &[[i32; 82]], lag: i32, y: usize, x: usize) -> i32 {
|
||||
let mut s = 0;
|
||||
let mut pos = 0;
|
||||
'outer: for dr in -lag..=0 {
|
||||
for dc in -lag..=lag {
|
||||
if dr == 0 && dc == 0 {
|
||||
break 'outer;
|
||||
}
|
||||
let c = g.ar_coeffs_y_plus_128[pos] as i32 - 128;
|
||||
s += luma[(y as i32 + dr) as usize][(x as i32 + dc) as usize] * c;
|
||||
pos += 1;
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn luma_grain(c: &GrainCtx, rng: &mut Rng) -> GrainTemplate {
|
||||
let g = c.g;
|
||||
let shift = c.grain_shift();
|
||||
let mut luma = vec![[0i32; 82]; 73];
|
||||
for row in luma.iter_mut() {
|
||||
for v in row.iter_mut() {
|
||||
@@ -77,77 +135,89 @@ pub(crate) fn apply(pic: &mut Picture, g: &FilmGrainParams, seq: &SequenceHeader
|
||||
*v = round2(gv, shift);
|
||||
}
|
||||
}
|
||||
let ar_shift = g.ar_coeff_shift_minus_6 + 6;
|
||||
let lag = g.ar_coeff_lag as i32;
|
||||
let ar_shift = c.ar_shift();
|
||||
let lag = c.lag();
|
||||
for y in 3..73 {
|
||||
for x in 3..82 - 3 {
|
||||
let mut s = 0;
|
||||
let mut pos = 0;
|
||||
'outer: for dr in -lag..=0 {
|
||||
for dc in -lag..=lag {
|
||||
if dr == 0 && dc == 0 {
|
||||
break 'outer;
|
||||
}
|
||||
let c = g.ar_coeffs_y_plus_128[pos] as i32 - 128;
|
||||
s += luma[(y as i32 + dr) as usize][(x as i32 + dc) as usize] * c;
|
||||
pos += 1;
|
||||
}
|
||||
}
|
||||
luma[y][x] = (luma[y][x] + round2(s, ar_shift)).clamp(grain_min, grain_max);
|
||||
}
|
||||
}
|
||||
let chroma_w = if sub_x != 0 { 44 } else { 82 };
|
||||
let chroma_h = if sub_y != 0 { 38 } else { 73 };
|
||||
let mut cb = vec![[0i32; 82]; 73];
|
||||
let mut cr = vec![[0i32; 82]; 73];
|
||||
if !mono {
|
||||
for (arr, seed, on) in [(&mut cb, 0xb524u32, g.num_cb_points > 0), (&mut cr, 0x49d8, g.num_cr_points > 0)] {
|
||||
rng.0 = g.grain_seed ^ seed;
|
||||
for row in arr.iter_mut().take(chroma_h) {
|
||||
for v in row.iter_mut().take(chroma_w) {
|
||||
let gv = if on || g.chroma_scaling_from_luma { GAUSSIAN_SEQUENCE[rng.next(11) as usize] as i32 } else { 0 };
|
||||
*v = round2(gv, shift);
|
||||
}
|
||||
}
|
||||
}
|
||||
for y in 3..chroma_h {
|
||||
for x in 3..chroma_w - 3 {
|
||||
let mut s0 = 0;
|
||||
let mut s1 = 0;
|
||||
let mut pos = 0;
|
||||
'outer: for dr in -lag..=0 {
|
||||
for dc in -lag..=lag {
|
||||
let c0 = g.ar_coeffs_cb_plus_128[pos] as i32 - 128;
|
||||
let c1 = g.ar_coeffs_cr_plus_128[pos] as i32 - 128;
|
||||
if dr == 0 && dc == 0 {
|
||||
if g.num_y_points > 0 {
|
||||
let mut l = 0;
|
||||
let lx = ((x - 3) << sub_x) + 3;
|
||||
let ly = ((y - 3) << sub_y) + 3;
|
||||
for i in 0..=sub_y {
|
||||
for j in 0..=sub_x {
|
||||
l += luma[ly + i][lx + j];
|
||||
}
|
||||
}
|
||||
let l = round2(l, (sub_x + sub_y) as u32);
|
||||
s0 += l * c0;
|
||||
s1 += l * c1;
|
||||
}
|
||||
break 'outer;
|
||||
}
|
||||
let (yy, xx) = ((y as i32 + dr) as usize, (x as i32 + dc) as usize);
|
||||
s0 += cb[yy][xx] * c0;
|
||||
s1 += cr[yy][xx] * c1;
|
||||
pos += 1;
|
||||
}
|
||||
}
|
||||
cb[y][x] = (cb[y][x] + round2(s0, ar_shift)).clamp(grain_min, grain_max);
|
||||
cr[y][x] = (cr[y][x] + round2(s1, ar_shift)).clamp(grain_min, grain_max);
|
||||
}
|
||||
let s = luma_ar_sum(g, &luma, lag, y, x);
|
||||
luma[y][x] = (luma[y][x] + round2(s, ar_shift)).clamp(c.grain_min, c.grain_max);
|
||||
}
|
||||
}
|
||||
luma
|
||||
}
|
||||
|
||||
// Scaling lookup (7.18.3.4).
|
||||
/// The luma contribution to the chroma autoregressive filter at chroma position (y, x).
|
||||
fn chroma_luma_term(c: &GrainCtx, luma: &[[i32; 82]], y: usize, x: usize) -> i32 {
|
||||
let mut l = 0;
|
||||
let lx = ((x - 3) << c.sub_x) + 3;
|
||||
let ly = ((y - 3) << c.sub_y) + 3;
|
||||
for i in 0..=c.sub_y {
|
||||
for j in 0..=c.sub_x {
|
||||
l += luma[ly + i][lx + j];
|
||||
}
|
||||
}
|
||||
round2(l, (c.sub_x + c.sub_y) as u32)
|
||||
}
|
||||
|
||||
fn chroma_ar_sums(c: &GrainCtx, t: &Templates, y: usize, x: usize) -> (i32, i32) {
|
||||
let g = c.g;
|
||||
let lag = c.lag();
|
||||
let mut s0 = 0;
|
||||
let mut s1 = 0;
|
||||
let mut pos = 0;
|
||||
'outer: for dr in -lag..=0 {
|
||||
for dc in -lag..=lag {
|
||||
let c0 = g.ar_coeffs_cb_plus_128[pos] as i32 - 128;
|
||||
let c1 = g.ar_coeffs_cr_plus_128[pos] as i32 - 128;
|
||||
if dr == 0 && dc == 0 {
|
||||
if g.num_y_points > 0 {
|
||||
let l = chroma_luma_term(c, &t.luma, y, x);
|
||||
s0 += l * c0;
|
||||
s1 += l * c1;
|
||||
}
|
||||
break 'outer;
|
||||
}
|
||||
let (yy, xx) = ((y as i32 + dr) as usize, (x as i32 + dc) as usize);
|
||||
s0 += t.cb[yy][xx] * c0;
|
||||
s1 += t.cr[yy][xx] * c1;
|
||||
pos += 1;
|
||||
}
|
||||
}
|
||||
(s0, s1)
|
||||
}
|
||||
|
||||
/// Chroma grain templates; zero for monochrome pictures.
|
||||
fn chroma_grain(c: &GrainCtx, rng: &mut Rng, luma: GrainTemplate) -> Templates {
|
||||
let g = c.g;
|
||||
let mut t = Templates { luma, cb: vec![[0i32; 82]; 73], cr: vec![[0i32; 82]; 73] };
|
||||
if c.mono {
|
||||
return t;
|
||||
}
|
||||
let shift = c.grain_shift();
|
||||
let ar_shift = c.ar_shift();
|
||||
let chroma_w = if c.sub_x != 0 { 44 } else { 82 };
|
||||
let chroma_h = if c.sub_y != 0 { 38 } else { 73 };
|
||||
for (arr, seed, on) in [(&mut t.cb, 0xb524u32, g.num_cb_points > 0), (&mut t.cr, 0x49d8, g.num_cr_points > 0)] {
|
||||
rng.0 = g.grain_seed ^ seed;
|
||||
for row in arr.iter_mut().take(chroma_h) {
|
||||
for v in row.iter_mut().take(chroma_w) {
|
||||
let gv = if on || g.chroma_scaling_from_luma { GAUSSIAN_SEQUENCE[rng.next(11) as usize] as i32 } else { 0 };
|
||||
*v = round2(gv, shift);
|
||||
}
|
||||
}
|
||||
}
|
||||
for y in 3..chroma_h {
|
||||
for x in 3..chroma_w - 3 {
|
||||
let (s0, s1) = chroma_ar_sums(c, &t, y, x);
|
||||
t.cb[y][x] = (t.cb[y][x] + round2(s0, ar_shift)).clamp(c.grain_min, c.grain_max);
|
||||
t.cr[y][x] = (t.cr[y][x] + round2(s1, ar_shift)).clamp(c.grain_min, c.grain_max);
|
||||
}
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
/// Scaling lookup tables (7.18.3.4), one per plane.
|
||||
fn scaling_luts(g: &FilmGrainParams, num_planes: usize) -> [[i32; 256]; 3] {
|
||||
let mut lut = [[0i32; 256]; 3];
|
||||
for (plane, l) in lut.iter_mut().enumerate().take(num_planes) {
|
||||
let (n, xs, ys) = if plane == 0 || g.chroma_scaling_from_luma {
|
||||
@@ -159,101 +229,141 @@ pub(crate) fn apply(pic: &mut Picture, g: &FilmGrainParams, seq: &SequenceHeader
|
||||
};
|
||||
*l = scaling_lut(n, xs, ys);
|
||||
}
|
||||
let scale_lut = |plane: usize, index: i32| -> i32 {
|
||||
let sh = bd - 8;
|
||||
let x = index >> sh;
|
||||
let rem = index - (x << sh);
|
||||
if bd == 8 || x == 255 {
|
||||
lut[plane][x as usize]
|
||||
} else {
|
||||
let start = lut[plane][x as usize];
|
||||
let end = lut[plane][x as usize + 1];
|
||||
start + round2((end - start) * rem, sh)
|
||||
}
|
||||
};
|
||||
lut
|
||||
}
|
||||
|
||||
// Noise stripes (7.18.3.5).
|
||||
let stripe_w = w + 64;
|
||||
let n_stripes = (h.div_ceil(2)).div_ceil(16).max(1);
|
||||
let mut stripes: Vec<[Vec<i32>; 3]> = (0..n_stripes).map(|_| [vec![0; 34 * stripe_w], vec![0; 34 * stripe_w], vec![0; 34 * stripe_w]]).collect();
|
||||
let overlap = g.overlap_flag;
|
||||
for (luma_num, stripe) in stripes.iter_mut().enumerate() {
|
||||
let mut r = g.grain_seed;
|
||||
r ^= (((luma_num * 37 + 178) & 255) << 8) as u32;
|
||||
r ^= ((luma_num * 173 + 105) & 255) as u32;
|
||||
rng.0 = r;
|
||||
let mut x = 0;
|
||||
while x < w.div_ceil(2) {
|
||||
let rand = rng.next(8) as usize;
|
||||
let off_x = rand >> 4;
|
||||
let off_y = rand & 15;
|
||||
for (plane, s) in stripe.iter_mut().enumerate().take(num_planes) {
|
||||
let psx = if plane > 0 { sub_x } else { 0 };
|
||||
let psy = if plane > 0 { sub_y } else { 0 };
|
||||
let pox = if psx != 0 { 6 + off_x } else { 9 + off_x * 2 };
|
||||
let poy = if psy != 0 { 6 + off_y } else { 9 + off_y * 2 };
|
||||
let src = match plane {
|
||||
0 => &luma,
|
||||
1 => &cb,
|
||||
_ => &cr,
|
||||
fn scale_lookup(lut: &[[i32; 256]; 3], bd: u32, plane: usize, index: i32) -> i32 {
|
||||
let sh = bd - 8;
|
||||
let x = index >> sh;
|
||||
let rem = index - (x << sh);
|
||||
if bd == 8 || x == 255 {
|
||||
lut[plane][x as usize]
|
||||
} else {
|
||||
let start = lut[plane][x as usize];
|
||||
let end = lut[plane][x as usize + 1];
|
||||
start + round2((end - start) * rem, sh)
|
||||
}
|
||||
}
|
||||
|
||||
/// Fills one plane of a noise stripe from the grain template at stripe column `x`.
|
||||
fn fill_stripe_plane(c: &GrainCtx, src: &GrainTemplate, s: &mut [i32], plane: usize, offsets: (usize, usize), x: usize) {
|
||||
let (off_x, off_y) = offsets;
|
||||
let stripe_w = c.stripe_w();
|
||||
let overlap = c.g.overlap_flag;
|
||||
let psx = if plane > 0 { c.sub_x } else { 0 };
|
||||
let psy = if plane > 0 { c.sub_y } else { 0 };
|
||||
let pox = if psx != 0 { 6 + off_x } else { 9 + off_x * 2 };
|
||||
let poy = if psy != 0 { 6 + off_y } else { 9 + off_y * 2 };
|
||||
for i in 0..(34 >> psy) {
|
||||
for j in 0..(34 >> psx) {
|
||||
let mut gv = src[poy + i][pox + j];
|
||||
let (idx, blend) = if psx == 0 { (i * stripe_w + x * 2 + j, j < 2) } else { (i * stripe_w + x + j, j == 0) };
|
||||
if blend && overlap && x > 0 {
|
||||
let old = s[idx];
|
||||
gv = if psx != 0 {
|
||||
old * 23 + gv * 22
|
||||
} else if j == 0 {
|
||||
old * 27 + gv * 17
|
||||
} else {
|
||||
old * 17 + gv * 27
|
||||
};
|
||||
for i in 0..(34 >> psy) {
|
||||
for j in 0..(34 >> psx) {
|
||||
let mut gv = src[poy + i][pox + j];
|
||||
if psx == 0 {
|
||||
let idx = i * stripe_w + x * 2 + j;
|
||||
if j < 2 && overlap && x > 0 {
|
||||
let old = s[idx];
|
||||
gv = if j == 0 { old * 27 + gv * 17 } else { old * 17 + gv * 27 };
|
||||
gv = round2(gv, 5).clamp(grain_min, grain_max);
|
||||
}
|
||||
s[idx] = gv;
|
||||
} else {
|
||||
let idx = i * stripe_w + x + j;
|
||||
if j == 0 && overlap && x > 0 {
|
||||
let old = s[idx];
|
||||
gv = round2(old * 23 + gv * 22, 5).clamp(grain_min, grain_max);
|
||||
}
|
||||
s[idx] = gv;
|
||||
}
|
||||
}
|
||||
}
|
||||
gv = round2(gv, 5).clamp(c.grain_min, c.grain_max);
|
||||
}
|
||||
x += 16;
|
||||
s[idx] = gv;
|
||||
}
|
||||
}
|
||||
let noise_at = |plane: usize, y: usize, x: usize| -> i32 {
|
||||
let psy = if plane > 0 { sub_y } else { 0 };
|
||||
}
|
||||
|
||||
/// Noise stripes (7.18.3.5).
|
||||
struct Stripes {
|
||||
data: Vec<[Vec<i32>; 3]>,
|
||||
stripe_w: usize,
|
||||
overlap: bool,
|
||||
sub_y: usize,
|
||||
grain_min: i32,
|
||||
grain_max: i32,
|
||||
}
|
||||
|
||||
impl Stripes {
|
||||
fn build(c: &GrainCtx, t: &Templates, rng: &mut Rng) -> Stripes {
|
||||
let g = c.g;
|
||||
let stripe_w = c.stripe_w();
|
||||
let n_stripes = (c.h.div_ceil(2)).div_ceil(16).max(1);
|
||||
let mut data: Vec<[Vec<i32>; 3]> = (0..n_stripes).map(|_| [vec![0; 34 * stripe_w], vec![0; 34 * stripe_w], vec![0; 34 * stripe_w]]).collect();
|
||||
for (luma_num, stripe) in data.iter_mut().enumerate() {
|
||||
let mut r = g.grain_seed;
|
||||
r ^= (((luma_num * 37 + 178) & 255) << 8) as u32;
|
||||
r ^= ((luma_num * 173 + 105) & 255) as u32;
|
||||
rng.0 = r;
|
||||
let mut x = 0;
|
||||
while x < c.w.div_ceil(2) {
|
||||
let rand = rng.next(8) as usize;
|
||||
let offsets = (rand >> 4, rand & 15);
|
||||
for (plane, s) in stripe.iter_mut().enumerate().take(c.num_planes()) {
|
||||
fill_stripe_plane(c, t.plane(plane), s, plane, offsets, x);
|
||||
}
|
||||
x += 16;
|
||||
}
|
||||
}
|
||||
Stripes { data, stripe_w, overlap: g.overlap_flag, sub_y: c.sub_y, grain_min: c.grain_min, grain_max: c.grain_max }
|
||||
}
|
||||
|
||||
fn noise_at(&self, plane: usize, y: usize, x: usize) -> i32 {
|
||||
let psy = if plane > 0 { self.sub_y } else { 0 };
|
||||
let luma_num = y >> (5 - psy);
|
||||
let i = y - (luma_num << (5 - psy));
|
||||
let mut gv = stripes[luma_num][plane][i * stripe_w + x];
|
||||
let mut gv = self.data[luma_num][plane][i * self.stripe_w + x];
|
||||
if psy == 0 {
|
||||
if i < 2 && luma_num > 0 && overlap {
|
||||
let old = stripes[luma_num - 1][plane][(i + 32) * stripe_w + x];
|
||||
if i < 2 && luma_num > 0 && self.overlap {
|
||||
let old = self.data[luma_num - 1][plane][(i + 32) * self.stripe_w + x];
|
||||
gv = if i == 0 { old * 27 + gv * 17 } else { old * 17 + gv * 27 };
|
||||
gv = round2(gv, 5).clamp(grain_min, grain_max);
|
||||
gv = round2(gv, 5).clamp(self.grain_min, self.grain_max);
|
||||
}
|
||||
} else if i < 1 && luma_num > 0 && overlap {
|
||||
let old = stripes[luma_num - 1][plane][(i + 16) * stripe_w + x];
|
||||
gv = round2(old * 23 + gv * 22, 5).clamp(grain_min, grain_max);
|
||||
} else if i < 1 && luma_num > 0 && self.overlap {
|
||||
let old = self.data[luma_num - 1][plane][(i + 16) * self.stripe_w + x];
|
||||
gv = round2(old * 23 + gv * 22, 5).clamp(self.grain_min, self.grain_max);
|
||||
}
|
||||
gv
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Blend (7.18.3.5).
|
||||
let (min_value, max_luma, max_chroma) = if g.clip_to_restricted_range {
|
||||
let ml = 235 << (bd - 8);
|
||||
(16 << (bd - 8), ml, if pic.matrix_coefficients as usize == MC_IDENTITY { ml } else { 240 << (bd - 8) })
|
||||
} else {
|
||||
let m = (256 << (bd - 8)) - 1;
|
||||
(0, m, m)
|
||||
};
|
||||
let pix_max = (1i32 << bd) - 1;
|
||||
let scaling_shift = g.grain_scaling_minus_8 + 8;
|
||||
if !mono {
|
||||
/// Output clipping range and scaling shift for the blend step (7.18.3.5).
|
||||
struct BlendRange {
|
||||
min_value: i32,
|
||||
max_luma: i32,
|
||||
max_chroma: i32,
|
||||
pix_max: i32,
|
||||
scaling_shift: u32,
|
||||
}
|
||||
|
||||
impl BlendRange {
|
||||
fn new(c: &GrainCtx, matrix_coefficients: usize) -> BlendRange {
|
||||
let bd = c.bd;
|
||||
let (min_value, max_luma, max_chroma) = if c.g.clip_to_restricted_range {
|
||||
let ml = 235 << (bd - 8);
|
||||
(16 << (bd - 8), ml, if matrix_coefficients == MC_IDENTITY { ml } else { 240 << (bd - 8) })
|
||||
} else {
|
||||
let m = (256 << (bd - 8)) - 1;
|
||||
(0, m, m)
|
||||
};
|
||||
BlendRange { min_value, max_luma, max_chroma, pix_max: (1i32 << bd) - 1, scaling_shift: c.g.grain_scaling_minus_8 + 8 }
|
||||
}
|
||||
}
|
||||
|
||||
struct BlendEnv<'a> {
|
||||
c: &'a GrainCtx<'a>,
|
||||
lut: &'a [[i32; 256]; 3],
|
||||
stripes: &'a Stripes,
|
||||
range: &'a BlendRange,
|
||||
}
|
||||
|
||||
impl BlendEnv<'_> {
|
||||
fn blend_chroma(&self, py: &[u16], pu: &mut [u16], pv: &mut [u16]) {
|
||||
let c = self.c;
|
||||
let g = c.g;
|
||||
let (w, h, sub_x, sub_y, bd) = (c.w, c.h, c.sub_x, c.sub_y, c.bd);
|
||||
let cw = (w + sub_x) >> sub_x;
|
||||
let ch = (h + sub_y) >> sub_y;
|
||||
let [py, pu, pv] = &mut pic.planes;
|
||||
for y in 0..ch {
|
||||
for x in 0..cw {
|
||||
let lx = x << sub_x;
|
||||
@@ -272,26 +382,62 @@ pub(crate) fn apply(pic: &mut Picture, g: &FilmGrainParams, seq: &SequenceHeader
|
||||
avg
|
||||
} else {
|
||||
let combined = avg * (luma_mult as i32 - 128) + orig * (mult as i32 - 128);
|
||||
((combined >> 6) + ((offset as i32 - 256) << (bd - 8))).clamp(0, pix_max)
|
||||
((combined >> 6) + ((offset as i32 - 256) << (bd - 8))).clamp(0, self.range.pix_max)
|
||||
};
|
||||
let noise = round2(scale_lut(plane, merged) * noise_at(plane, y, x), scaling_shift);
|
||||
out[y * cw + x] = (orig + noise).clamp(min_value, max_chroma) as u16;
|
||||
let noise = round2(scale_lookup(self.lut, bd, plane, merged) * self.stripes.noise_at(plane, y, x), self.range.scaling_shift);
|
||||
out[y * cw + x] = (orig + noise).clamp(self.range.min_value, self.range.max_chroma) as u16;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if g.num_y_points > 0 {
|
||||
let py = &mut pic.planes[0];
|
||||
|
||||
fn blend_luma(&self, py: &mut [u16]) {
|
||||
let (w, h) = (self.c.w, self.c.h);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let orig = py[y * w + x] as i32;
|
||||
let noise = round2(scale_lut(0, orig) * noise_at(0, y, x), scaling_shift);
|
||||
py[y * w + x] = (orig + noise).clamp(min_value, max_luma) as u16;
|
||||
let noise = round2(scale_lookup(self.lut, self.c.bd, 0, orig) * self.stripes.noise_at(0, y, x), self.range.scaling_shift);
|
||||
py[y * w + x] = (orig + noise).clamp(self.range.min_value, self.range.max_luma) as u16;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn apply(pic: &mut Picture, g: &FilmGrainParams, seq: &SequenceHeader) {
|
||||
let _ = seq;
|
||||
let bd = pic.bit_depth as u32;
|
||||
let grain_center = 128i32 << (bd - 8);
|
||||
let c = GrainCtx {
|
||||
g,
|
||||
bd,
|
||||
mono: pic.mono_chrome,
|
||||
sub_x: pic.subsampling_x as usize,
|
||||
sub_y: pic.subsampling_y as usize,
|
||||
w: pic.width as usize,
|
||||
h: pic.height as usize,
|
||||
grain_min: -grain_center,
|
||||
grain_max: (256i32 << (bd - 8)) - 1 - grain_center,
|
||||
};
|
||||
|
||||
// Generate grain (7.18.3.3).
|
||||
let mut rng = Rng(g.grain_seed);
|
||||
let luma = luma_grain(&c, &mut rng);
|
||||
let templates = chroma_grain(&c, &mut rng, luma);
|
||||
let lut = scaling_luts(g, c.num_planes());
|
||||
let stripes = Stripes::build(&c, &templates, &mut rng);
|
||||
|
||||
// Blend (7.18.3.5).
|
||||
let range = BlendRange::new(&c, pic.matrix_coefficients as usize);
|
||||
let env = BlendEnv { c: &c, lut: &lut, stripes: &stripes, range: &range };
|
||||
if !c.mono {
|
||||
let [py, pu, pv] = &mut pic.planes;
|
||||
env.blend_chroma(py, pu, pv);
|
||||
}
|
||||
if g.num_y_points > 0 {
|
||||
env.blend_luma(&mut pic.planes[0]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+232
-179
@@ -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;
|
||||
@@ -422,6 +429,38 @@ pub struct FrameHeader {
|
||||
pub spatial_id: u32,
|
||||
}
|
||||
|
||||
/// mark_ref_frames( idLen ): invalidates reference slots whose frame id is too far from the current one.
|
||||
fn mark_ref_frames(st: &mut HeaderState, cur: u32, id_len: u32) {
|
||||
let diff_len = st.seq.delta_frame_id_length_minus_2 + 2;
|
||||
for rs in st.refs.iter_mut() {
|
||||
if cur > (1 << diff_len) {
|
||||
if rs.frame_id > cur || rs.frame_id < cur - (1 << diff_len) {
|
||||
rs.valid = false;
|
||||
}
|
||||
} else if rs.frame_id > cur && rs.frame_id < (1 << id_len) + cur - (1 << diff_len) {
|
||||
rs.valid = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Skips buffer_removal_time for every operating point that includes this layer.
|
||||
fn skip_buffer_removal_times(r: &mut BitReader, seq: &SequenceHeader, temporal_id: u32, spatial_id: u32) -> Result<()> {
|
||||
if !(seq.decoder_model_info_present && r.flag()?) {
|
||||
return Ok(());
|
||||
}
|
||||
for op in 0..=seq.operating_points_cnt_minus_1 {
|
||||
if seq.decoder_model_present_for_this_op[op] {
|
||||
let idc = seq.operating_point_idc[op];
|
||||
let in_t = (idc >> temporal_id) & 1;
|
||||
let in_s = (idc >> (spatial_id + 8)) & 1;
|
||||
if idc == 0 || (in_t == 1 && in_s == 1) {
|
||||
r.f(seq.buffer_removal_time_length_minus_1 + 1)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl FrameHeader {
|
||||
pub fn get_relative_dist(&self, seq: &SequenceHeader, a: u32, b: u32) -> i32 {
|
||||
relative_dist(seq, a, b)
|
||||
@@ -487,35 +526,10 @@ impl FrameHeader {
|
||||
} else {
|
||||
h.show_existing_frame = r.flag()?;
|
||||
if h.show_existing_frame {
|
||||
h.frame_to_show_map_idx = r.f(3)? as usize;
|
||||
if seq.decoder_model_info_present && !seq.equal_picture_interval {
|
||||
r.f(seq.frame_presentation_time_length_minus_1 + 1)?;
|
||||
}
|
||||
if seq.frame_id_numbers_present {
|
||||
r.f(id_len)?;
|
||||
}
|
||||
let rs = &st.refs[h.frame_to_show_map_idx];
|
||||
if !rs.valid {
|
||||
return Err(Error::Invalid("show_existing_frame of an empty slot"));
|
||||
}
|
||||
h.frame_type = rs.frame_type;
|
||||
if h.frame_type == KEY_FRAME as u8 {
|
||||
h.refresh_frame_flags = all_frames;
|
||||
}
|
||||
if seq.film_grain_params_present {
|
||||
h.film_grain = rs.grain.clone();
|
||||
}
|
||||
h.parse_show_existing_frame(r, st, id_len)?;
|
||||
return Ok(h);
|
||||
}
|
||||
h.frame_type = r.f(2)? as u8;
|
||||
h.frame_is_intra = h.frame_type == INTRA_ONLY_FRAME as u8 || h.frame_type == KEY_FRAME as u8;
|
||||
h.show_frame = r.flag()?;
|
||||
if h.show_frame && seq.decoder_model_info_present && !seq.equal_picture_interval {
|
||||
r.f(seq.frame_presentation_time_length_minus_1 + 1)?;
|
||||
}
|
||||
h.showable_frame = if h.show_frame { h.frame_type != KEY_FRAME as u8 } else { r.flag()? };
|
||||
h.error_resilient_mode =
|
||||
if h.frame_type == SWITCH_FRAME as u8 || (h.frame_type == KEY_FRAME as u8 && h.show_frame) { true } else { r.flag()? };
|
||||
h.parse_frame_type_and_flags(r, seq)?;
|
||||
}
|
||||
if h.frame_type == KEY_FRAME as u8 && h.show_frame {
|
||||
for rs in st.refs.iter_mut() {
|
||||
@@ -526,33 +540,11 @@ impl FrameHeader {
|
||||
h.order_hints[LAST_FRAME + i] = 0;
|
||||
}
|
||||
}
|
||||
h.disable_cdf_update = r.flag()?;
|
||||
h.allow_screen_content_tools = if seq.seq_force_screen_content_tools == SELECT_SCREEN_CONTENT_TOOLS as u32 {
|
||||
r.flag()?
|
||||
} else {
|
||||
seq.seq_force_screen_content_tools != 0
|
||||
};
|
||||
if h.allow_screen_content_tools {
|
||||
h.force_integer_mv = if seq.seq_force_integer_mv == SELECT_INTEGER_MV as u32 { r.flag()? } else { seq.seq_force_integer_mv != 0 };
|
||||
}
|
||||
if h.frame_is_intra {
|
||||
h.force_integer_mv = true;
|
||||
}
|
||||
h.parse_screen_content_flags(r, seq)?;
|
||||
if seq.frame_id_numbers_present {
|
||||
h.current_frame_id = r.f(id_len)?;
|
||||
// mark_ref_frames( idLen )
|
||||
let diff_len = seq.delta_frame_id_length_minus_2 + 2;
|
||||
let cur = h.current_frame_id;
|
||||
for rs in st.refs.iter_mut() {
|
||||
if cur > (1 << diff_len) {
|
||||
if rs.frame_id > cur || rs.frame_id < cur - (1 << diff_len) {
|
||||
rs.valid = false;
|
||||
}
|
||||
} else if rs.frame_id > cur && rs.frame_id < (1 << id_len) + cur - (1 << diff_len) {
|
||||
rs.valid = false;
|
||||
}
|
||||
}
|
||||
st.current_frame_id = cur;
|
||||
mark_ref_frames(st, h.current_frame_id, id_len);
|
||||
st.current_frame_id = h.current_frame_id;
|
||||
}
|
||||
h.frame_size_override_flag = if h.frame_type == SWITCH_FRAME as u8 {
|
||||
true
|
||||
@@ -563,18 +555,7 @@ impl FrameHeader {
|
||||
};
|
||||
h.order_hint = r.f(seq.order_hint_bits)?;
|
||||
h.primary_ref_frame = if h.frame_is_intra || h.error_resilient_mode { PRIMARY_REF_NONE } else { r.f(3)? as usize };
|
||||
if seq.decoder_model_info_present && r.flag()? {
|
||||
for op in 0..=seq.operating_points_cnt_minus_1 {
|
||||
if seq.decoder_model_present_for_this_op[op] {
|
||||
let idc = seq.operating_point_idc[op];
|
||||
let in_t = (idc >> temporal_id) & 1;
|
||||
let in_s = (idc >> (spatial_id + 8)) & 1;
|
||||
if idc == 0 || (in_t == 1 && in_s == 1) {
|
||||
r.f(seq.buffer_removal_time_length_minus_1 + 1)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
skip_buffer_removal_times(r, seq, temporal_id, spatial_id)?;
|
||||
h.refresh_frame_flags =
|
||||
if h.frame_type == SWITCH_FRAME as u8 || (h.frame_type == KEY_FRAME as u8 && h.show_frame) { all_frames } else { r.f(8)? };
|
||||
if (!h.frame_is_intra || h.refresh_frame_flags != all_frames) && h.error_resilient_mode && seq.enable_order_hint {
|
||||
@@ -592,126 +573,16 @@ impl FrameHeader {
|
||||
h.allow_intrabc = r.flag()?;
|
||||
}
|
||||
} else {
|
||||
let mut short = false;
|
||||
if seq.enable_order_hint {
|
||||
short = r.flag()?;
|
||||
if short {
|
||||
let last = r.f(3)? as usize;
|
||||
let gold = r.f(3)? as usize;
|
||||
h.set_frame_refs(seq, st.refs, last, gold);
|
||||
}
|
||||
}
|
||||
for i in 0..REFS_PER_FRAME {
|
||||
if !short {
|
||||
h.ref_frame_idx[i] = r.f(3)? as usize;
|
||||
}
|
||||
if seq.frame_id_numbers_present {
|
||||
r.f(seq.delta_frame_id_length_minus_2 + 2)?;
|
||||
}
|
||||
}
|
||||
for i in 0..REFS_PER_FRAME {
|
||||
if !st.refs[h.ref_frame_idx[i]].valid {
|
||||
return Err(Error::Invalid("reference to an empty slot"));
|
||||
}
|
||||
}
|
||||
if h.frame_size_override_flag && !h.error_resilient_mode {
|
||||
// frame_size_with_refs( )
|
||||
let mut found = false;
|
||||
for i in 0..REFS_PER_FRAME {
|
||||
if r.flag()? {
|
||||
let rs = &st.refs[h.ref_frame_idx[i]];
|
||||
h.upscaled_width = rs.upscaled_width;
|
||||
h.frame_width = h.upscaled_width;
|
||||
h.frame_height = rs.frame_height;
|
||||
h.render_width = rs.render_width;
|
||||
h.render_height = rs.render_height;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
h.frame_size(r, seq)?;
|
||||
h.render_size(r)?;
|
||||
} else {
|
||||
h.superres_params(r, seq)?;
|
||||
h.compute_image_size();
|
||||
}
|
||||
} else {
|
||||
h.frame_size(r, seq)?;
|
||||
h.render_size(r)?;
|
||||
}
|
||||
h.allow_high_precision_mv = if h.force_integer_mv { false } else { r.flag()? };
|
||||
h.interpolation_filter = if r.flag()? { SWITCHABLE } else { r.f(2)? as u8 };
|
||||
h.is_motion_mode_switchable = r.flag()?;
|
||||
h.use_ref_frame_mvs = if h.error_resilient_mode || !seq.enable_ref_frame_mvs { false } else { r.flag()? };
|
||||
for i in 0..REFS_PER_FRAME {
|
||||
let rf = LAST_FRAME + i;
|
||||
let hint = st.refs[h.ref_frame_idx[i]].order_hint;
|
||||
h.order_hints[rf] = hint;
|
||||
h.ref_frame_sign_bias[rf] = seq.enable_order_hint && relative_dist(seq, hint, h.order_hint) > 0;
|
||||
}
|
||||
h.parse_inter_frame_refs(r, st)?;
|
||||
}
|
||||
h.disable_frame_end_update_cdf = if seq.reduced_still_picture_header || h.disable_cdf_update { true } else { r.flag()? };
|
||||
if h.primary_ref_frame == PRIMARY_REF_NONE {
|
||||
// setup_past_independence( )
|
||||
st.seg_features = SegmentationFeatures::default();
|
||||
st.prev_gm_params = default_gm_params();
|
||||
st.lf_deltas = LoopFilterDeltas::defaults();
|
||||
h.lf.delta_enabled = true;
|
||||
} else {
|
||||
// load_previous( )
|
||||
let prev = &st.refs[h.ref_frame_idx[h.primary_ref_frame]];
|
||||
st.prev_gm_params = prev.gm_params;
|
||||
st.lf_deltas = prev.lf_deltas;
|
||||
st.seg_features = prev.seg_features;
|
||||
}
|
||||
h.setup_or_load_previous(st);
|
||||
h.prev_gm_params = st.prev_gm_params;
|
||||
h.tile_info(r, seq)?;
|
||||
h.quantization_params(r, seq)?;
|
||||
h.segmentation_params(r, st)?;
|
||||
// delta_q_params( )
|
||||
if h.quant.base_q_idx > 0 {
|
||||
h.delta_q_present = r.flag()?;
|
||||
}
|
||||
if h.delta_q_present {
|
||||
h.delta_q_res = r.f(2)?;
|
||||
}
|
||||
// delta_lf_params( )
|
||||
if h.delta_q_present {
|
||||
if !h.allow_intrabc {
|
||||
h.delta_lf_present = r.flag()?;
|
||||
}
|
||||
if h.delta_lf_present {
|
||||
h.delta_lf_res = r.f(2)?;
|
||||
h.delta_lf_multi = r.flag()?;
|
||||
}
|
||||
}
|
||||
h.coded_lossless = true;
|
||||
for seg_id in 0..MAX_SEGMENTS {
|
||||
let q = h.get_qindex(true, seg_id, 0);
|
||||
let ll = q == 0
|
||||
&& h.quant.delta_q_y_dc == 0
|
||||
&& h.quant.delta_q_u_ac == 0
|
||||
&& h.quant.delta_q_u_dc == 0
|
||||
&& h.quant.delta_q_v_ac == 0
|
||||
&& h.quant.delta_q_v_dc == 0;
|
||||
h.lossless_array[seg_id] = ll;
|
||||
if !ll {
|
||||
h.coded_lossless = false;
|
||||
}
|
||||
if h.quant.using_qmatrix {
|
||||
if ll {
|
||||
h.seg_qm_level[0][seg_id] = 15;
|
||||
h.seg_qm_level[1][seg_id] = 15;
|
||||
h.seg_qm_level[2][seg_id] = 15;
|
||||
} else {
|
||||
h.seg_qm_level[0][seg_id] = h.quant.qm_y;
|
||||
h.seg_qm_level[1][seg_id] = h.quant.qm_u;
|
||||
h.seg_qm_level[2][seg_id] = h.quant.qm_v;
|
||||
}
|
||||
}
|
||||
}
|
||||
h.all_lossless = h.coded_lossless && h.frame_width == h.upscaled_width;
|
||||
h.delta_q_lf_params(r)?;
|
||||
h.compute_lossless();
|
||||
h.loop_filter_params(r, seq, st)?;
|
||||
h.cdef_params(r, seq)?;
|
||||
h.lr_params(r, seq)?;
|
||||
@@ -732,6 +603,182 @@ impl FrameHeader {
|
||||
Ok(h)
|
||||
}
|
||||
|
||||
/// The show_existing_frame branch: picks the slot to show and inherits its frame type and grain.
|
||||
fn parse_show_existing_frame(&mut self, r: &mut BitReader, st: &HeaderState, id_len: u32) -> Result<()> {
|
||||
let seq = st.seq;
|
||||
self.frame_to_show_map_idx = r.f(3)? as usize;
|
||||
if seq.decoder_model_info_present && !seq.equal_picture_interval {
|
||||
r.f(seq.frame_presentation_time_length_minus_1 + 1)?;
|
||||
}
|
||||
if seq.frame_id_numbers_present {
|
||||
r.f(id_len)?;
|
||||
}
|
||||
let rs = &st.refs[self.frame_to_show_map_idx];
|
||||
if !rs.valid {
|
||||
return Err(Error::Invalid("show_existing_frame of an empty slot"));
|
||||
}
|
||||
self.frame_type = rs.frame_type;
|
||||
if self.frame_type == KEY_FRAME as u8 {
|
||||
self.refresh_frame_flags = (1u32 << NUM_REF_FRAMES) - 1;
|
||||
}
|
||||
if seq.film_grain_params_present {
|
||||
self.film_grain = rs.grain.clone();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// frame_type, show_frame, showable_frame and error_resilient_mode.
|
||||
fn parse_frame_type_and_flags(&mut self, r: &mut BitReader, seq: &SequenceHeader) -> Result<()> {
|
||||
self.frame_type = r.f(2)? as u8;
|
||||
self.frame_is_intra = self.frame_type == INTRA_ONLY_FRAME as u8 || self.frame_type == KEY_FRAME as u8;
|
||||
self.show_frame = r.flag()?;
|
||||
if self.show_frame && seq.decoder_model_info_present && !seq.equal_picture_interval {
|
||||
r.f(seq.frame_presentation_time_length_minus_1 + 1)?;
|
||||
}
|
||||
self.showable_frame = if self.show_frame { self.frame_type != KEY_FRAME as u8 } else { r.flag()? };
|
||||
self.error_resilient_mode =
|
||||
if self.frame_type == SWITCH_FRAME as u8 || (self.frame_type == KEY_FRAME as u8 && self.show_frame) { true } else { r.flag()? };
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// disable_cdf_update, allow_screen_content_tools and force_integer_mv.
|
||||
fn parse_screen_content_flags(&mut self, r: &mut BitReader, seq: &SequenceHeader) -> Result<()> {
|
||||
self.disable_cdf_update = r.flag()?;
|
||||
self.allow_screen_content_tools = if seq.seq_force_screen_content_tools == SELECT_SCREEN_CONTENT_TOOLS as u32 {
|
||||
r.flag()?
|
||||
} else {
|
||||
seq.seq_force_screen_content_tools != 0
|
||||
};
|
||||
if self.allow_screen_content_tools {
|
||||
self.force_integer_mv = if seq.seq_force_integer_mv == SELECT_INTEGER_MV as u32 { r.flag()? } else { seq.seq_force_integer_mv != 0 };
|
||||
}
|
||||
if self.frame_is_intra {
|
||||
self.force_integer_mv = true;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reference selection, frame size, motion vector precision and filter flags of an inter frame.
|
||||
fn parse_inter_frame_refs(&mut self, r: &mut BitReader, st: &HeaderState) -> Result<()> {
|
||||
let seq = st.seq;
|
||||
let mut short = false;
|
||||
if seq.enable_order_hint {
|
||||
short = r.flag()?;
|
||||
if short {
|
||||
let last = r.f(3)? as usize;
|
||||
let gold = r.f(3)? as usize;
|
||||
self.set_frame_refs(seq, st.refs, last, gold);
|
||||
}
|
||||
}
|
||||
for i in 0..REFS_PER_FRAME {
|
||||
if !short {
|
||||
self.ref_frame_idx[i] = r.f(3)? as usize;
|
||||
}
|
||||
if seq.frame_id_numbers_present {
|
||||
r.f(seq.delta_frame_id_length_minus_2 + 2)?;
|
||||
}
|
||||
}
|
||||
for i in 0..REFS_PER_FRAME {
|
||||
if !st.refs[self.ref_frame_idx[i]].valid {
|
||||
return Err(Error::Invalid("reference to an empty slot"));
|
||||
}
|
||||
}
|
||||
if self.frame_size_override_flag && !self.error_resilient_mode {
|
||||
self.frame_size_with_refs(r, seq, st.refs)?;
|
||||
} else {
|
||||
self.frame_size(r, seq)?;
|
||||
self.render_size(r)?;
|
||||
}
|
||||
self.allow_high_precision_mv = if self.force_integer_mv { false } else { r.flag()? };
|
||||
self.interpolation_filter = if r.flag()? { SWITCHABLE } else { r.f(2)? as u8 };
|
||||
self.is_motion_mode_switchable = r.flag()?;
|
||||
self.use_ref_frame_mvs = if self.error_resilient_mode || !seq.enable_ref_frame_mvs { false } else { r.flag()? };
|
||||
for i in 0..REFS_PER_FRAME {
|
||||
let rf = LAST_FRAME + i;
|
||||
let hint = st.refs[self.ref_frame_idx[i]].order_hint;
|
||||
self.order_hints[rf] = hint;
|
||||
self.ref_frame_sign_bias[rf] = seq.enable_order_hint && relative_dist(seq, hint, self.order_hint) > 0;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// frame_size_with_refs( ).
|
||||
fn frame_size_with_refs(&mut self, r: &mut BitReader, seq: &SequenceHeader, refs: &[RefInfo; NUM_REF_FRAMES]) -> Result<()> {
|
||||
for i in 0..REFS_PER_FRAME {
|
||||
if r.flag()? {
|
||||
let rs = &refs[self.ref_frame_idx[i]];
|
||||
self.upscaled_width = rs.upscaled_width;
|
||||
self.frame_width = self.upscaled_width;
|
||||
self.frame_height = rs.frame_height;
|
||||
self.render_width = rs.render_width;
|
||||
self.render_height = rs.render_height;
|
||||
self.superres_params(r, seq)?;
|
||||
self.compute_image_size();
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
self.frame_size(r, seq)?;
|
||||
self.render_size(r)
|
||||
}
|
||||
|
||||
/// setup_past_independence( ) or load_previous( ), depending on primary_ref_frame.
|
||||
fn setup_or_load_previous(&mut self, st: &mut HeaderState) {
|
||||
if self.primary_ref_frame == PRIMARY_REF_NONE {
|
||||
st.seg_features = SegmentationFeatures::default();
|
||||
st.prev_gm_params = default_gm_params();
|
||||
st.lf_deltas = LoopFilterDeltas::defaults();
|
||||
self.lf.delta_enabled = true;
|
||||
} else {
|
||||
let prev = &st.refs[self.ref_frame_idx[self.primary_ref_frame]];
|
||||
st.prev_gm_params = prev.gm_params;
|
||||
st.lf_deltas = prev.lf_deltas;
|
||||
st.seg_features = prev.seg_features;
|
||||
}
|
||||
}
|
||||
|
||||
/// delta_q_params( ) and delta_lf_params( ).
|
||||
fn delta_q_lf_params(&mut self, r: &mut BitReader) -> Result<()> {
|
||||
if self.quant.base_q_idx > 0 {
|
||||
self.delta_q_present = r.flag()?;
|
||||
}
|
||||
if self.delta_q_present {
|
||||
self.delta_q_res = r.f(2)?;
|
||||
if !self.allow_intrabc {
|
||||
self.delta_lf_present = r.flag()?;
|
||||
}
|
||||
if self.delta_lf_present {
|
||||
self.delta_lf_res = r.f(2)?;
|
||||
self.delta_lf_multi = r.flag()?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Per-segment lossless flags, quantizer matrix levels and the frame-wide lossless flags.
|
||||
fn compute_lossless(&mut self) {
|
||||
self.coded_lossless = true;
|
||||
for seg_id in 0..MAX_SEGMENTS {
|
||||
let q = self.get_qindex(true, seg_id, 0);
|
||||
let ll = q == 0
|
||||
&& self.quant.delta_q_y_dc == 0
|
||||
&& self.quant.delta_q_u_ac == 0
|
||||
&& self.quant.delta_q_u_dc == 0
|
||||
&& self.quant.delta_q_v_ac == 0
|
||||
&& self.quant.delta_q_v_dc == 0;
|
||||
self.lossless_array[seg_id] = ll;
|
||||
if !ll {
|
||||
self.coded_lossless = false;
|
||||
}
|
||||
if self.quant.using_qmatrix {
|
||||
let levels = if ll { [15; 3] } else { [self.quant.qm_y, self.quant.qm_u, self.quant.qm_v] };
|
||||
for (plane, level) in levels.into_iter().enumerate() {
|
||||
self.seg_qm_level[plane][seg_id] = level;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.all_lossless = self.coded_lossless && self.frame_width == self.upscaled_width;
|
||||
}
|
||||
|
||||
fn superres_params(&mut self, r: &mut BitReader, seq: &SequenceHeader) -> Result<()> {
|
||||
self.use_superres = if seq.enable_superres { r.flag()? } else { false };
|
||||
self.superres_denom = if self.use_superres { r.f(SUPERRES_DENOM_BITS as u32)? + SUPERRES_DENOM_MIN as u32 } else { SUPERRES_NUM as u32 };
|
||||
@@ -899,6 +946,9 @@ impl FrameHeader {
|
||||
let mut widest = 0;
|
||||
let mut start = 0;
|
||||
while start < sb_cols {
|
||||
if t.mi_col_starts.len() >= MAX_TILE_COLS {
|
||||
return Err(Error::Invalid("tile_cols exceeds MAX_TILE_COLS"));
|
||||
}
|
||||
t.mi_col_starts.push(start << sb_shift);
|
||||
let max_width = (sb_cols - start).min(max_tile_width_sb);
|
||||
let size = r.ns(max_width)? + 1;
|
||||
@@ -916,6 +966,9 @@ impl FrameHeader {
|
||||
let max_tile_height_sb = (max_tile_area_sb / widest.max(1)).max(1);
|
||||
let mut start = 0;
|
||||
while start < sb_rows {
|
||||
if t.mi_row_starts.len() >= MAX_TILE_ROWS {
|
||||
return Err(Error::Invalid("tile_rows exceeds MAX_TILE_ROWS"));
|
||||
}
|
||||
t.mi_row_starts.push(start << sb_shift);
|
||||
let max_height = (sb_rows - start).min(max_tile_height_sb);
|
||||
let size = r.ns(max_height)? + 1;
|
||||
|
||||
+72
-50
@@ -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;
|
||||
@@ -411,20 +420,8 @@ impl TileDecoder<'_, '_> {
|
||||
let fh = &self.fs.fh;
|
||||
for ref_list in 0..1 + is_compound as usize {
|
||||
let ref_frame = cand_refs[ref_list];
|
||||
let ym = self.b.y_mode;
|
||||
let global =
|
||||
(ym == GLOBALMV || ym == GLOBAL_GLOBALMV) && ref_frame > INTRA_FRAME as i8 && fh.gm_type[ref_frame as usize] as usize > TRANSLATION;
|
||||
let global_valid = global && setup_shear(&fh.gm_params[ref_frame as usize]).0;
|
||||
let ref_idx: i32 = if self.b.use_intrabc { -1 } else { fh.ref_frame_idx[ref_frame as usize - LAST_FRAME] as i32 };
|
||||
let use_warp = if w < 8 || h < 8 || fh.force_integer_mv {
|
||||
0
|
||||
} else if self.b.motion_mode as usize == LOCALWARP && self.b.local_valid {
|
||||
1
|
||||
} else if global && !self.is_scaled_ref(ref_idx) && global_valid {
|
||||
2
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let use_warp = self.warp_mode(ref_frame, ref_idx, w, h);
|
||||
let mv = [cand_mvs[ref_list].row as i32, cand_mvs[ref_list].col as i32];
|
||||
if use_warp != 0 {
|
||||
let params = if use_warp == 1 { self.b.local_warp_params } else { fh.gm_params[ref_frame as usize] };
|
||||
@@ -439,40 +436,7 @@ impl TileDecoder<'_, '_> {
|
||||
}
|
||||
}
|
||||
let ct = self.b.compound_type;
|
||||
if ct == COMPOUND_WEDGE && plane == 0 {
|
||||
let m = &wedge_masks()[self.b.mi_size][self.b.wedge_index][self.b.wedge_sign];
|
||||
for i in 0..h {
|
||||
for j in 0..w {
|
||||
self.mask[i * PRED_STRIDE + j] = m[i * w + j] as i32;
|
||||
}
|
||||
}
|
||||
} else if ct == COMPOUND_INTRA {
|
||||
let size_scale = MAX_SB_SIZE / h.max(w);
|
||||
let im = self.b.interintra_mode;
|
||||
for i in 0..h {
|
||||
for j in 0..w {
|
||||
self.mask[i * PRED_STRIDE + j] = if im == II_V_PRED {
|
||||
II_WEIGHTS_1D[i * size_scale] as i32
|
||||
} else if im == II_H_PRED {
|
||||
II_WEIGHTS_1D[j * size_scale] as i32
|
||||
} else if im == II_SMOOTH_PRED {
|
||||
II_WEIGHTS_1D[i.min(j) * size_scale] as i32
|
||||
} else {
|
||||
32
|
||||
};
|
||||
}
|
||||
}
|
||||
} else if ct == COMPOUND_DIFFWTD && plane == 0 {
|
||||
for i in 0..h {
|
||||
for j in 0..w {
|
||||
let k = i * PRED_STRIDE + j;
|
||||
let diff = (preds[0][k] - preds[1][k]).abs();
|
||||
let diff = round2(diff, (bd - 8) + rnd.2);
|
||||
let m = (38 + diff / 16).clamp(0, 64);
|
||||
self.mask[k] = if self.b.mask_type == 1 { 64 - m } else { m };
|
||||
}
|
||||
}
|
||||
}
|
||||
self.fill_blend_mask(plane, w, h, &preds, rnd.2);
|
||||
let (fwd, bck) = if ct == COMPOUND_DISTANCE { self.distance_weights(cand_refs) } else { (0, 0) };
|
||||
let max = (1i32 << bd) - 1;
|
||||
let is_inter_intra = self.b.is_inter && self.b.ref_frame[1] == INTRA_FRAME as i8;
|
||||
@@ -512,6 +476,64 @@ impl TileDecoder<'_, '_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Warp selection for one reference: 0 none, 1 local warp, 2 global warp.
|
||||
fn warp_mode(&self, ref_frame: i8, ref_idx: i32, w: usize, h: usize) -> u8 {
|
||||
let fh = &self.fs.fh;
|
||||
let ym = self.b.y_mode;
|
||||
let global =
|
||||
(ym == GLOBALMV || ym == GLOBAL_GLOBALMV) && ref_frame > INTRA_FRAME as i8 && fh.gm_type[ref_frame as usize] as usize > TRANSLATION;
|
||||
let global_valid = global && setup_shear(&fh.gm_params[ref_frame as usize]).0;
|
||||
if w < 8 || h < 8 || fh.force_integer_mv {
|
||||
0
|
||||
} else if self.b.motion_mode as usize == LOCALWARP && self.b.local_valid {
|
||||
1
|
||||
} else if global && !self.is_scaled_ref(ref_idx) && global_valid {
|
||||
2
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the blend mask (wedge, inter-intra or difference-weighted) for the current compound type.
|
||||
fn fill_blend_mask(&mut self, plane: usize, w: usize, h: usize, preds: &[Vec<i32>; 2], round_post: u32) {
|
||||
let bd = self.fs.bit_depth;
|
||||
let ct = self.b.compound_type;
|
||||
if ct == COMPOUND_WEDGE && plane == 0 {
|
||||
let m = &wedge_masks()[self.b.mi_size][self.b.wedge_index][self.b.wedge_sign];
|
||||
for i in 0..h {
|
||||
for j in 0..w {
|
||||
self.mask[i * PRED_STRIDE + j] = m[i * w + j] as i32;
|
||||
}
|
||||
}
|
||||
} else if ct == COMPOUND_INTRA {
|
||||
let size_scale = MAX_SB_SIZE / h.max(w);
|
||||
let im = self.b.interintra_mode;
|
||||
for i in 0..h {
|
||||
for j in 0..w {
|
||||
self.mask[i * PRED_STRIDE + j] = if im == II_V_PRED {
|
||||
II_WEIGHTS_1D[i * size_scale] as i32
|
||||
} else if im == II_H_PRED {
|
||||
II_WEIGHTS_1D[j * size_scale] as i32
|
||||
} else if im == II_SMOOTH_PRED {
|
||||
II_WEIGHTS_1D[i.min(j) * size_scale] as i32
|
||||
} else {
|
||||
32
|
||||
};
|
||||
}
|
||||
}
|
||||
} else if ct == COMPOUND_DIFFWTD && plane == 0 {
|
||||
for i in 0..h {
|
||||
for j in 0..w {
|
||||
let k = i * PRED_STRIDE + j;
|
||||
let diff = (preds[0][k] - preds[1][k]).abs();
|
||||
let diff = round2(diff, (bd - 8) + round_post);
|
||||
let m = (38 + diff / 16).clamp(0, 64);
|
||||
self.mask[k] = if self.b.mask_type == 1 { 64 - m } else { m };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_scaled_ref(&self, ref_idx: i32) -> bool {
|
||||
if ref_idx < 0 {
|
||||
return false;
|
||||
@@ -678,7 +700,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 +721,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};
|
||||
|
||||
|
||||
+118
-79
@@ -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).
|
||||
@@ -69,57 +73,70 @@ impl LrCtx<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Position and size of a (at most 4x4) restoration block within its plane.
|
||||
#[derive(Clone, Copy)]
|
||||
struct LrBlock {
|
||||
x: i32,
|
||||
y: i32,
|
||||
w: i32,
|
||||
h: i32,
|
||||
}
|
||||
|
||||
/// Loop restoration (7.17): returns LrFrame given UpscaledCurrFrame and UpscaledCdefFrame.
|
||||
pub(crate) fn loop_restoration(fs: &FrameState, cur: &FrameBuf, cdef: &FrameBuf) -> FrameBuf {
|
||||
let fh = &fs.fh;
|
||||
let mut lr = cdef.clone();
|
||||
let up_w = fh.upscaled_width as i32;
|
||||
let fr_h = fh.frame_height as i32;
|
||||
let bd = fs.bit_depth;
|
||||
for plane in 0..fs.num_planes {
|
||||
let rtype_frame = fh.lr.frame_restoration_type[plane];
|
||||
if rtype_frame == RESTORE_NONE as u8 {
|
||||
if fs.fh.lr.frame_restoration_type[plane] == RESTORE_NONE as u8 {
|
||||
continue;
|
||||
}
|
||||
let (sub_x, sub_y) = if plane > 0 { (fs.ssx as i32, fs.ssy as i32) } else { (0, 0) };
|
||||
let unit_size = fh.lr.loop_restoration_size[plane] as i32;
|
||||
let unit_rows = fs.lr_unit_rows[plane] as i32;
|
||||
let unit_cols = fs.lr_unit_cols[plane] as i32;
|
||||
let plane_end_x = ((up_w + sub_x) >> sub_x) - 1;
|
||||
let plane_end_y = ((fr_h + sub_y) >> sub_y) - 1;
|
||||
let mut row = 0;
|
||||
while row * 4 < fr_h {
|
||||
let luma_y = row * 4;
|
||||
let stripe_num = (luma_y + 8) / 64;
|
||||
let stripe_start_y = (-8 + stripe_num * 64) >> sub_y;
|
||||
let stripe_end_y = stripe_start_y + (64 >> sub_y) - 1;
|
||||
let ctx = LrCtx { cur: &cur.planes[plane], cdef: &cdef.planes[plane], plane_end_x, plane_end_y, stripe_start_y, stripe_end_y };
|
||||
let unit_row = (unit_rows - 1).min(((row * 4 + 8) >> sub_y) / unit_size);
|
||||
let mut col = 0;
|
||||
while col * 4 < up_w {
|
||||
let unit_col = (unit_cols - 1).min(((col * 4) >> sub_x) / unit_size);
|
||||
let x = (col * 4) >> sub_x;
|
||||
let y = (row * 4) >> sub_y;
|
||||
let w = (4 >> sub_x).min(plane_end_x - x + 1);
|
||||
let h = (4 >> sub_y).min(plane_end_y - y + 1);
|
||||
if w > 0 && h > 0 {
|
||||
let ui = (unit_row * unit_cols + unit_col) as usize;
|
||||
match fs.lr.lr_type[plane][ui] {
|
||||
t if t == RESTORE_WIENER as u8 => wiener(fs, &ctx, &mut lr.planes[plane], plane, ui, x, y, w, h, bd),
|
||||
t if t == RESTORE_SGRPROJ as u8 => self_guided(fs, &ctx, &mut lr.planes[plane], plane, ui, x, y, w, h, bd),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
col += 1;
|
||||
}
|
||||
row += 1;
|
||||
}
|
||||
restore_plane(fs, cur, cdef, &mut lr.planes[plane], plane);
|
||||
}
|
||||
lr
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn wiener(fs: &FrameState, ctx: &LrCtx, out: &mut Plane, plane: usize, ui: usize, x: i32, y: i32, w: i32, h: i32, bd: u32) {
|
||||
fn restore_plane(fs: &FrameState, cur: &FrameBuf, cdef: &FrameBuf, out: &mut Plane, plane: usize) {
|
||||
let fh = &fs.fh;
|
||||
let up_w = fh.upscaled_width as i32;
|
||||
let fr_h = fh.frame_height as i32;
|
||||
let bd = fs.bit_depth;
|
||||
let (sub_x, sub_y) = if plane > 0 { (fs.ssx as i32, fs.ssy as i32) } else { (0, 0) };
|
||||
let unit_size = fh.lr.loop_restoration_size[plane] as i32;
|
||||
let unit_rows = fs.lr_unit_rows[plane] as i32;
|
||||
let unit_cols = fs.lr_unit_cols[plane] as i32;
|
||||
let plane_end_x = ((up_w + sub_x) >> sub_x) - 1;
|
||||
let plane_end_y = ((fr_h + sub_y) >> sub_y) - 1;
|
||||
let mut row = 0;
|
||||
while row * 4 < fr_h {
|
||||
let luma_y = row * 4;
|
||||
let stripe_num = (luma_y + 8) / 64;
|
||||
let stripe_start_y = (-8 + stripe_num * 64) >> sub_y;
|
||||
let stripe_end_y = stripe_start_y + (64 >> sub_y) - 1;
|
||||
let ctx = LrCtx { cur: &cur.planes[plane], cdef: &cdef.planes[plane], plane_end_x, plane_end_y, stripe_start_y, stripe_end_y };
|
||||
let unit_row = (unit_rows - 1).min(((row * 4 + 8) >> sub_y) / unit_size);
|
||||
let mut col = 0;
|
||||
while col * 4 < up_w {
|
||||
let unit_col = (unit_cols - 1).min(((col * 4) >> sub_x) / unit_size);
|
||||
let x = (col * 4) >> sub_x;
|
||||
let y = (row * 4) >> sub_y;
|
||||
let w = (4 >> sub_x).min(plane_end_x - x + 1);
|
||||
let h = (4 >> sub_y).min(plane_end_y - y + 1);
|
||||
if w > 0 && h > 0 {
|
||||
let ui = (unit_row * unit_cols + unit_col) as usize;
|
||||
let blk = LrBlock { x, y, w, h };
|
||||
match fs.lr.lr_type[plane][ui] {
|
||||
t if t == RESTORE_WIENER as u8 => wiener(fs, &ctx, out, (plane, ui), blk, bd),
|
||||
t if t == RESTORE_SGRPROJ as u8 => self_guided(fs, &ctx, out, (plane, ui), blk, bd),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
col += 1;
|
||||
}
|
||||
row += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn wiener(fs: &FrameState, ctx: &LrCtx, out: &mut Plane, (plane, ui): (usize, usize), blk: LrBlock, bd: u32) {
|
||||
let LrBlock { x, y, w, h } = blk;
|
||||
let round0: u32 = if bd == 12 { 5 } else { 3 };
|
||||
let round1: u32 = if bd == 12 { 9 } else { 11 };
|
||||
let coef = &fs.lr.lr_wiener[plane][ui];
|
||||
@@ -162,13 +179,13 @@ fn wiener(fs: &FrameState, ctx: &LrCtx, out: &mut Plane, plane: usize, ui: usize
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn self_guided(fs: &FrameState, ctx: &LrCtx, out: &mut Plane, plane: usize, ui: usize, x: i32, y: i32, w: i32, h: i32, bd: u32) {
|
||||
fn self_guided(fs: &FrameState, ctx: &LrCtx, out: &mut Plane, (plane, ui): (usize, usize), blk: LrBlock, bd: u32) {
|
||||
let LrBlock { x, y, w, h } = blk;
|
||||
let set = fs.lr.lr_sgr_set[plane][ui] as usize;
|
||||
let mut flt0 = [[0i32; 4]; 4];
|
||||
let mut flt1 = [[0i32; 4]; 4];
|
||||
box_filter(ctx, x, y, w, h, set, 0, bd, &mut flt0);
|
||||
box_filter(ctx, x, y, w, h, set, 1, bd, &mut flt1);
|
||||
box_filter(ctx, blk, (set, 0), bd, &mut flt0);
|
||||
box_filter(ctx, blk, (set, 1), bd, &mut flt1);
|
||||
let xqd = fs.lr.lr_sgr_xqd[plane][ui];
|
||||
let w0 = xqd[0] as i32;
|
||||
let w1 = xqd[1] as i32;
|
||||
@@ -194,8 +211,56 @@ fn round2_64(x: i64, n: u32) -> i64 {
|
||||
if n == 0 { x } else { (x + (1i64 << (n - 1))) >> n }
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn box_filter(ctx: &LrCtx, x: i32, y: i32, w: i32, h: i32, set: usize, pass: usize, bd: u32, f: &mut [[i32; 4]; 4]) {
|
||||
/// Box sums of the samples within `r` of (x, y): (sum of squares, sum).
|
||||
fn box_sums(ctx: &LrCtx, x: i32, y: i32, r: i32) -> (i64, i64) {
|
||||
let mut a = 0i64;
|
||||
let mut b = 0i64;
|
||||
for dy in -r..=r {
|
||||
for dx in -r..=r {
|
||||
let c = ctx.sample(x + dx, y + dy) as i64;
|
||||
a += c * c;
|
||||
b += c;
|
||||
}
|
||||
}
|
||||
(a, b)
|
||||
}
|
||||
|
||||
/// Self-guided filter constants of one pass.
|
||||
struct SgrConsts {
|
||||
n: i64,
|
||||
s: i64,
|
||||
one_over_n: i64,
|
||||
}
|
||||
|
||||
/// (A, B) coefficients of one sample from its box sums.
|
||||
fn sgr_coefficients(a: i64, b: i64, k: &SgrConsts, bd: u32) -> (i64, i64) {
|
||||
let a = round2_64(a, 2 * (bd - 8));
|
||||
let d = round2_64(b, bd - 8);
|
||||
let p = (a * k.n - d * d).max(0);
|
||||
let z = round2_64(p * k.s, SGRPROJ_MTABLE_BITS as u32);
|
||||
let a2 = if z >= 255 {
|
||||
256
|
||||
} else if z == 0 {
|
||||
1
|
||||
} else {
|
||||
((z << SGRPROJ_SGR_BITS) + z / 2) / (z + 1)
|
||||
};
|
||||
let b2 = ((1i64 << SGRPROJ_SGR_BITS) - a2) * b * k.one_over_n;
|
||||
(a2, round2_64(b2, SGRPROJ_RECIP_BITS as u32))
|
||||
}
|
||||
|
||||
fn box_weight(pass: usize, i: i32, dx: i32, dy: i32) -> i64 {
|
||||
if pass == 0 {
|
||||
if ((i + dy) & 1) != 0 { if dx == 0 { 6 } else { 5 } } else { 0 }
|
||||
} else if dx == 0 || dy == 0 {
|
||||
4
|
||||
} else {
|
||||
3
|
||||
}
|
||||
}
|
||||
|
||||
fn box_filter(ctx: &LrCtx, blk: LrBlock, (set, pass): (usize, usize), bd: u32, f: &mut [[i32; 4]; 4]) {
|
||||
let LrBlock { x, y, w, h } = blk;
|
||||
let r = SGR_PARAMS[set][pass * 2] as i32;
|
||||
if r == 0 {
|
||||
return;
|
||||
@@ -203,35 +268,15 @@ fn box_filter(ctx: &LrCtx, x: i32, y: i32, w: i32, h: i32, set: usize, pass: usi
|
||||
let eps = SGR_PARAMS[set][pass * 2 + 1] as i64;
|
||||
let n = ((2 * r + 1) * (2 * r + 1)) as i64;
|
||||
let n2e = n * n * eps;
|
||||
let s = ((1i64 << SGRPROJ_MTABLE_BITS) + n2e / 2) / n2e;
|
||||
let one_over_n = ((1i64 << SGRPROJ_RECIP_BITS) + n / 2) / n;
|
||||
let k = SgrConsts { n, s: ((1i64 << SGRPROJ_MTABLE_BITS) + n2e / 2) / n2e, one_over_n: ((1i64 << SGRPROJ_RECIP_BITS) + n / 2) / n };
|
||||
let mut a_arr = [[0i64; 6]; 6];
|
||||
let mut b_arr = [[0i64; 6]; 6];
|
||||
for i in -1..h + 1 {
|
||||
for j in -1..w + 1 {
|
||||
let mut a = 0i64;
|
||||
let mut b = 0i64;
|
||||
for dy in -r..=r {
|
||||
for dx in -r..=r {
|
||||
let c = ctx.sample(x + j + dx, y + i + dy) as i64;
|
||||
a += c * c;
|
||||
b += c;
|
||||
}
|
||||
}
|
||||
let a = round2_64(a, 2 * (bd - 8));
|
||||
let d = round2_64(b, bd - 8);
|
||||
let p = (a * n - d * d).max(0);
|
||||
let z = round2_64(p * s, SGRPROJ_MTABLE_BITS as u32);
|
||||
let a2 = if z >= 255 {
|
||||
256
|
||||
} else if z == 0 {
|
||||
1
|
||||
} else {
|
||||
((z << SGRPROJ_SGR_BITS) + z / 2) / (z + 1)
|
||||
};
|
||||
let b2 = ((1i64 << SGRPROJ_SGR_BITS) - a2) * b * one_over_n;
|
||||
let (a, b) = box_sums(ctx, x + j, y + i, r);
|
||||
let (a2, b2) = sgr_coefficients(a, b, &k, bd);
|
||||
a_arr[(i + 1) as usize][(j + 1) as usize] = a2;
|
||||
b_arr[(i + 1) as usize][(j + 1) as usize] = round2_64(b2, SGRPROJ_RECIP_BITS as u32);
|
||||
b_arr[(i + 1) as usize][(j + 1) as usize] = b2;
|
||||
}
|
||||
}
|
||||
for i in 0..h {
|
||||
@@ -241,13 +286,7 @@ fn box_filter(ctx: &LrCtx, x: i32, y: i32, w: i32, h: i32, set: usize, pass: usi
|
||||
let mut b = 0i64;
|
||||
for dy in -1..=1i32 {
|
||||
for dx in -1..=1i32 {
|
||||
let weight = if pass == 0 {
|
||||
if ((i + dy) & 1) != 0 { if dx == 0 { 6 } else { 5 } } else { 0 }
|
||||
} else if dx == 0 || dy == 0 {
|
||||
4
|
||||
} else {
|
||||
3
|
||||
};
|
||||
let weight = box_weight(pass, i, dx, dy);
|
||||
a += weight * a_arr[(i + dy + 1) as usize][(j + dx + 1) as usize];
|
||||
b += weight * b_arr[(i + dy + 1) as usize][(j + dx + 1) as usize];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
+106
-69
@@ -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;
|
||||
@@ -1662,6 +1682,87 @@ impl<'a, 'f> TileDecoder<'a, 'f> {
|
||||
}
|
||||
}
|
||||
|
||||
/// eob_pt, eob_extra and the remaining eob bits.
|
||||
fn read_eob(&mut self, tx_sz: usize, tx_sz_ctx: usize, ptype: usize, tx_class: usize) -> usize {
|
||||
let eob_multisize = (TX_WIDTH_LOG2[tx_sz] as usize).min(5) + (TX_HEIGHT_LOG2[tx_sz] as usize).min(5) - 4;
|
||||
let ectx = (tx_class != TX_CLASS_2D) as usize;
|
||||
let eob_pt = 1 + match eob_multisize {
|
||||
0 => self.sd.read_symbol(&mut self.cdf.eob_pt_16[ptype][ectx]),
|
||||
1 => self.sd.read_symbol(&mut self.cdf.eob_pt_32[ptype][ectx]),
|
||||
2 => self.sd.read_symbol(&mut self.cdf.eob_pt_64[ptype][ectx]),
|
||||
3 => self.sd.read_symbol(&mut self.cdf.eob_pt_128[ptype][ectx]),
|
||||
4 => self.sd.read_symbol(&mut self.cdf.eob_pt_256[ptype][ectx]),
|
||||
5 => self.sd.read_symbol(&mut self.cdf.eob_pt_512[ptype]),
|
||||
_ => self.sd.read_symbol(&mut self.cdf.eob_pt_1024[ptype]),
|
||||
};
|
||||
let mut eob = if eob_pt < 2 { eob_pt } else { (1 << (eob_pt - 2)) + 1 };
|
||||
let eob_shift = eob_pt as i32 - 3;
|
||||
if eob_shift >= 0 {
|
||||
let extra = self.sd.read_symbol(&mut self.cdf.eob_extra[tx_sz_ctx][ptype][eob_pt - 3]);
|
||||
if extra == 1 {
|
||||
eob += 1 << eob_shift;
|
||||
}
|
||||
for i in 1..(eob_pt as i32 - 2).max(0) {
|
||||
let sh = (eob_pt as i32 - 2).max(0) - 1 - i;
|
||||
if self.sd.read_literal(1) == 1 {
|
||||
eob += 1 << sh;
|
||||
}
|
||||
}
|
||||
}
|
||||
eob
|
||||
}
|
||||
|
||||
/// Exp-Golomb coded remainder of a coefficient above the base range (read_golomb).
|
||||
fn read_golomb(&mut self) -> u32 {
|
||||
let mut length = 0;
|
||||
loop {
|
||||
length += 1;
|
||||
if self.sd.read_literal(1) == 1 || length > 32 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let mut x: u32 = 1;
|
||||
for _ in (0..length - 1).rev() {
|
||||
x = (x << 1) | self.sd.read_literal(1);
|
||||
}
|
||||
x
|
||||
}
|
||||
|
||||
/// Reads the signs and Golomb remainders of the first `scan.len()` (= eob) coefficients, applies
|
||||
/// them to `self.quant`, and returns the culLevel and dcCategory contexts. `blk4` is (x4, y4, w4, h4).
|
||||
fn read_signs_and_golomb(&mut self, plane: usize, blk4: (usize, usize, usize, usize), scan: &[u16]) -> (u32, u8) {
|
||||
let (x4, y4, w4, h4) = blk4;
|
||||
let ptype = (plane > 0) as usize;
|
||||
let mut cul_level = 0u32;
|
||||
let mut dc_category = 0u8;
|
||||
for (c, &p) in scan.iter().enumerate() {
|
||||
let pos = p as usize;
|
||||
let sign = if self.quant[pos] != 0 {
|
||||
if c == 0 {
|
||||
let ctx = self.dc_sign_ctx(plane, x4, y4, w4, h4);
|
||||
self.sd.read_symbol(&mut self.cdf.dc_sign[ptype][ctx]) == 1
|
||||
} else {
|
||||
self.sd.read_literal(1) == 1
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if self.quant[pos] > (NUM_BASE_LEVELS + COEFF_BASE_RANGE) as i32 {
|
||||
let x = self.read_golomb();
|
||||
self.quant[pos] = (x as i64 + COEFF_BASE_RANGE as i64 + NUM_BASE_LEVELS as i64).min(i32::MAX as i64) as i32;
|
||||
}
|
||||
if pos == 0 && self.quant[pos] > 0 {
|
||||
dc_category = if sign { 1 } else { 2 };
|
||||
}
|
||||
self.quant[pos] &= 0xFFFFF;
|
||||
cul_level += self.quant[pos] as u32;
|
||||
if sign {
|
||||
self.quant[pos] = -self.quant[pos];
|
||||
}
|
||||
}
|
||||
(cul_level.min(63), dc_category)
|
||||
}
|
||||
|
||||
/// coeffs( ): returns eob.
|
||||
fn coeffs(&mut self, plane: usize, start_x: usize, start_y: usize, tx_sz: usize) -> usize {
|
||||
let x4 = start_x >> 2;
|
||||
@@ -1687,32 +1788,8 @@ impl<'a, 'f> TileDecoder<'a, 'f> {
|
||||
}
|
||||
self.plane_tx_type = self.compute_tx_type(plane, tx_sz, x4, y4);
|
||||
let scan = self.get_scan(tx_sz);
|
||||
let eob_multisize = (TX_WIDTH_LOG2[tx_sz] as usize).min(5) + (TX_HEIGHT_LOG2[tx_sz] as usize).min(5) - 4;
|
||||
let tx_class = get_tx_class(self.plane_tx_type);
|
||||
let ectx = (tx_class != TX_CLASS_2D) as usize;
|
||||
let eob_pt = 1 + match eob_multisize {
|
||||
0 => self.sd.read_symbol(&mut self.cdf.eob_pt_16[ptype][ectx]),
|
||||
1 => self.sd.read_symbol(&mut self.cdf.eob_pt_32[ptype][ectx]),
|
||||
2 => self.sd.read_symbol(&mut self.cdf.eob_pt_64[ptype][ectx]),
|
||||
3 => self.sd.read_symbol(&mut self.cdf.eob_pt_128[ptype][ectx]),
|
||||
4 => self.sd.read_symbol(&mut self.cdf.eob_pt_256[ptype][ectx]),
|
||||
5 => self.sd.read_symbol(&mut self.cdf.eob_pt_512[ptype]),
|
||||
_ => self.sd.read_symbol(&mut self.cdf.eob_pt_1024[ptype]),
|
||||
};
|
||||
eob = if eob_pt < 2 { eob_pt } else { (1 << (eob_pt - 2)) + 1 };
|
||||
let eob_shift = eob_pt as i32 - 3;
|
||||
if eob_shift >= 0 {
|
||||
let extra = self.sd.read_symbol(&mut self.cdf.eob_extra[tx_sz_ctx][ptype][eob_pt - 3]);
|
||||
if extra == 1 {
|
||||
eob += 1 << eob_shift;
|
||||
}
|
||||
for i in 1..(eob_pt as i32 - 2).max(0) {
|
||||
let sh = (eob_pt as i32 - 2).max(0) - 1 - i;
|
||||
if self.sd.read_literal(1) == 1 {
|
||||
eob += 1 << sh;
|
||||
}
|
||||
}
|
||||
}
|
||||
eob = self.read_eob(tx_sz, tx_sz_ctx, ptype, tx_class);
|
||||
let adj = ADJUSTED_TX_SIZE[tx_sz] as usize;
|
||||
let bwl = TX_WIDTH_LOG2[adj] as usize;
|
||||
let height = TX_HEIGHT[adj] as usize;
|
||||
@@ -1760,45 +1837,7 @@ impl<'a, 'f> TileDecoder<'a, 'f> {
|
||||
lev[li] = level as u8;
|
||||
}
|
||||
self.levels = lev;
|
||||
for c in 0..eob {
|
||||
let pos = scan[c] as usize;
|
||||
let sign = if self.quant[pos] != 0 {
|
||||
if c == 0 {
|
||||
let ctx = self.dc_sign_ctx(plane, x4, y4, w4, h4);
|
||||
self.sd.read_symbol(&mut self.cdf.dc_sign[ptype][ctx]) == 1
|
||||
} else {
|
||||
self.sd.read_literal(1) == 1
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if self.quant[pos] > (NUM_BASE_LEVELS + COEFF_BASE_RANGE) as i32 {
|
||||
let mut length = 0;
|
||||
loop {
|
||||
length += 1;
|
||||
if self.sd.read_literal(1) == 1 {
|
||||
break;
|
||||
}
|
||||
if length > 32 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let mut x: u32 = 1;
|
||||
for _ in (0..length - 1).rev() {
|
||||
x = (x << 1) | self.sd.read_literal(1);
|
||||
}
|
||||
self.quant[pos] = (x as i64 + COEFF_BASE_RANGE as i64 + NUM_BASE_LEVELS as i64).min(i32::MAX as i64) as i32;
|
||||
}
|
||||
if pos == 0 && self.quant[pos] > 0 {
|
||||
dc_category = if sign { 1 } else { 2 };
|
||||
}
|
||||
self.quant[pos] &= 0xFFFFF;
|
||||
cul_level += self.quant[pos] as u32;
|
||||
if sign {
|
||||
self.quant[pos] = -self.quant[pos];
|
||||
}
|
||||
}
|
||||
cul_level = cul_level.min(63);
|
||||
(cul_level, dc_category) = self.read_signs_and_golomb(plane, (x4, y4, w4, h4), &scan[..eob]);
|
||||
}
|
||||
for i in 0..w4 {
|
||||
if x4 + i < self.t.above_level[plane].len() {
|
||||
@@ -2222,18 +2261,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 {
|
||||
@@ -101,7 +104,7 @@ impl<const L: usize> Lane for [i32; L] {
|
||||
}
|
||||
#[inline(always)]
|
||||
fn neg(self) -> Self {
|
||||
self.map(|v| v.wrapping_neg())
|
||||
self.map(i32::wrapping_neg)
|
||||
}
|
||||
#[inline(always)]
|
||||
fn mul_round12(self, m: i32) -> Self {
|
||||
@@ -162,6 +165,15 @@ pub(crate) fn inverse_dct<T: Lane>(t: &mut [T], n: u32, r: u32) {
|
||||
for i in 0..len {
|
||||
t[i] = copy[brev(n, i)];
|
||||
}
|
||||
inverse_dct_stage1(t, n, r);
|
||||
inverse_dct_stage2(t, n, r);
|
||||
inverse_dct_stage3(t, n, r);
|
||||
inverse_dct_stage4(t, n, r);
|
||||
inverse_dct_stage5(t, n, r);
|
||||
inverse_dct_stage6(t, n, r);
|
||||
}
|
||||
|
||||
fn inverse_dct_stage1<T: Lane>(t: &mut [T], n: u32, r: u32) {
|
||||
if n == 6 {
|
||||
for i in 0..16 {
|
||||
bf(t, 32 + i, 63 - i, 63 - 4 * brev(4, i) as i32, false);
|
||||
@@ -187,6 +199,9 @@ pub(crate) fn inverse_dct<T: Lane>(t: &mut [T], n: u32, r: u32) {
|
||||
hd(t, 16 + 2 * i, 17 + 2 * i, i & 1 == 1, r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn inverse_dct_stage2<T: Lane>(t: &mut [T], n: u32, r: u32) {
|
||||
if n == 6 {
|
||||
for i in 0..4 {
|
||||
for j in 0..2 {
|
||||
@@ -218,6 +233,9 @@ pub(crate) fn inverse_dct<T: Lane>(t: &mut [T], n: u32, r: u32) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn inverse_dct_stage3<T: Lane>(t: &mut [T], n: u32, r: u32) {
|
||||
for i in 0..2 {
|
||||
bf(t, 2 * i, 2 * i + 1, 32 + 16 * i as i32, i == 0);
|
||||
}
|
||||
@@ -245,6 +263,9 @@ pub(crate) fn inverse_dct<T: Lane>(t: &mut [T], n: u32, r: u32) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn inverse_dct_stage4<T: Lane>(t: &mut [T], n: u32, r: u32) {
|
||||
for i in 0..2 {
|
||||
hd(t, i, 3 - i, false, r);
|
||||
}
|
||||
@@ -270,6 +291,9 @@ pub(crate) fn inverse_dct<T: Lane>(t: &mut [T], n: u32, r: u32) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn inverse_dct_stage5<T: Lane>(t: &mut [T], n: u32, r: u32) {
|
||||
if n >= 3 {
|
||||
for i in 0..4 {
|
||||
hd(t, i, 7 - i, false, r);
|
||||
@@ -297,6 +321,9 @@ pub(crate) fn inverse_dct<T: Lane>(t: &mut [T], n: u32, r: u32) {
|
||||
hd(t, i, 15 - i, false, r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn inverse_dct_stage6<T: Lane>(t: &mut [T], n: u32, r: u32) {
|
||||
if n >= 5 {
|
||||
for i in 0..4 {
|
||||
bf(t, 27 - i, 20 + i, 32, true);
|
||||
@@ -528,10 +555,9 @@ pub(crate) fn inverse_transform_2d(dequant: &[i32], residual: &mut [i32], tx_sz:
|
||||
let col_clamp = (bit_depth + 6).max(16);
|
||||
let rk = row_kind(tx_type);
|
||||
let ck = col_kind(tx_type);
|
||||
let mut t = [0i32; 64];
|
||||
let rect2 = log2w.abs_diff(log2h) == 1;
|
||||
let p = Tx2d { w, h, log2w, log2h, row_shift, col_shift, row_clamp, col_clamp, rk, ck, rect2 };
|
||||
if !lossless && bit_depth <= 10 {
|
||||
let p = Tx2d { w, h, log2w, log2h, row_shift, col_shift, row_clamp, col_clamp, rk, ck, rect2 };
|
||||
if w >= 8 && h >= 8 {
|
||||
transform_lanes::<8>(dequant, residual, &p);
|
||||
} else {
|
||||
@@ -539,6 +565,13 @@ pub(crate) fn inverse_transform_2d(dequant: &[i32], residual: &mut [i32], tx_sz:
|
||||
}
|
||||
return;
|
||||
}
|
||||
scalar_row_pass(dequant, residual, &p, lossless);
|
||||
scalar_col_pass(residual, &p, lossless);
|
||||
}
|
||||
|
||||
fn scalar_row_pass(dequant: &[i32], residual: &mut [i32], p: &Tx2d, lossless: bool) {
|
||||
let (w, h, log2w, row_shift, row_clamp, col_clamp, rk, rect2) = (p.w, p.h, p.log2w, p.row_shift, p.row_clamp, p.col_clamp, p.rk, p.rect2);
|
||||
let mut t = [0i32; 64];
|
||||
let in_w = w.min(32);
|
||||
for i in 0..h {
|
||||
// Every 1D transform maps an all-zero row to zeros (each step is linear with
|
||||
@@ -569,6 +602,11 @@ pub(crate) fn inverse_transform_2d(dequant: &[i32], residual: &mut [i32], tx_sz:
|
||||
residual[i * w + j] = round2(t[j], row_shift).clamp(lo, hi);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn scalar_col_pass(residual: &mut [i32], p: &Tx2d, lossless: bool) {
|
||||
let (w, h, log2h, col_shift, col_clamp, ck) = (p.w, p.h, p.log2h, p.col_shift, p.col_clamp, p.ck);
|
||||
let mut t = [0i32; 64];
|
||||
for j in 0..w {
|
||||
for i in 0..h {
|
||||
t[i] = residual[i * w + j];
|
||||
@@ -614,11 +652,16 @@ fn transform_1d<T: Lane>(t: &mut [T], kind: Kind1d, n: u32, r: u32) {
|
||||
/// The 2D inverse transform with `L` rows (row pass) or columns (column pass) at a time;
|
||||
/// bit-identical to the one-at-a-time process for bit depths up to 10.
|
||||
fn transform_lanes<const L: usize>(dequant: &[i32], residual: &mut [i32], p: &Tx2d) {
|
||||
let mut t = [[0i32; L]; 64];
|
||||
lanes_row_pass(dequant, residual, p, &mut t);
|
||||
lanes_col_pass(residual, p, &mut t);
|
||||
}
|
||||
|
||||
fn lanes_row_pass<const L: usize>(dequant: &[i32], residual: &mut [i32], p: &Tx2d, t: &mut [[i32; L]; 64]) {
|
||||
let (w, h) = (p.w, p.h);
|
||||
let in_w = w.min(32);
|
||||
let lo = -(1i32 << (p.col_clamp - 1));
|
||||
let hi = (1i32 << (p.col_clamp - 1)) - 1;
|
||||
let mut t = [[0i32; L]; 64];
|
||||
let mut i0 = 0;
|
||||
while i0 < h {
|
||||
// Rows without coefficients (and rows 32..63) transform to zeros.
|
||||
@@ -646,6 +689,10 @@ fn transform_lanes<const L: usize>(dequant: &[i32], residual: &mut [i32], p: &Tx
|
||||
}
|
||||
i0 += L;
|
||||
}
|
||||
}
|
||||
|
||||
fn lanes_col_pass<const L: usize>(residual: &mut [i32], p: &Tx2d, t: &mut [[i32; L]; 64]) {
|
||||
let (w, h) = (p.w, p.h);
|
||||
let mut j0 = 0;
|
||||
while j0 < w {
|
||||
for i in 0..h {
|
||||
|
||||
@@ -9,6 +9,4 @@ rust-version.workspace = true
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
craft-media = { workspace = true, features = ["bitstream"] }
|
||||
|
||||
+4
-485
@@ -1,495 +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.
|
||||
//! - [`budget`]: the memory budget video decoders check their picture store against.
|
||||
//! - [`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::*;
|
||||
|
||||
pub mod budget;
|
||||
|
||||
#[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,
|
||||
/// A NAL length prefix width outside 1..=4 bytes.
|
||||
InvalidLengthSize,
|
||||
}
|
||||
|
||||
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"),
|
||||
BitError::InvalidLengthSize => f.write_str("unsupported NAL length prefix size"),
|
||||
}
|
||||
}
|
||||
}
|
||||
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)`. Codes whose value does not fit an `i32` (the code for `2^31`) are rejected.
|
||||
pub fn read_se(&mut self) -> Result<i32> {
|
||||
let k = self.read_ue()? as i64;
|
||||
let v = if k & 1 == 1 { (k + 1) / 2 } else { -(k / 2) };
|
||||
i32::try_from(v).map_err(|_| BitError::InvalidExpGolomb)
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
self.write_ue_u64(v as u64);
|
||||
}
|
||||
fn write_ue_u64(&mut self, v: u64) {
|
||||
let x = v + 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);
|
||||
}
|
||||
}
|
||||
/// Signed Exp-Golomb `se(v)`. `i32::MIN` needs a code above `u32::MAX`, which `read_se` rejects.
|
||||
pub fn write_se(&mut self, v: i32) {
|
||||
let v = v as i64;
|
||||
let k = if v > 0 { (v as u64) * 2 - 1 } else { ((-v) as u64) * 2 };
|
||||
self.write_ue_u64(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..=4 bytes, as the 2-bit `lengthSizeMinusOne` allows) into NAL units.
|
||||
pub fn length_prefixed_nals(data: &[u8], length_size: usize) -> Result<Vec<&[u8]>> {
|
||||
if !(1..=4).contains(&length_size) {
|
||||
return Err(BitError::InvalidLengthSize);
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < data.len() {
|
||||
if length_size > data.len() - i {
|
||||
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 len > data.len() - i {
|
||||
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 signed_exp_golomb_extremes() {
|
||||
let mut w = BitWriter::new();
|
||||
for v in [i32::MAX, i32::MIN + 1, i32::MIN] {
|
||||
w.write_se(v);
|
||||
}
|
||||
w.rbsp_trailing();
|
||||
let b = w.finish();
|
||||
let mut r = BitReader::new(&b);
|
||||
assert_eq!(r.read_se().unwrap(), i32::MAX);
|
||||
assert_eq!(r.read_se().unwrap(), i32::MIN + 1);
|
||||
assert_eq!(r.read_se(), Err(BitError::InvalidExpGolomb));
|
||||
// ue code 0xFFFF_FFFF maps to +2^31, which must not wrap to a negative i32.
|
||||
let mut w = BitWriter::new();
|
||||
w.write_ue(u32::MAX);
|
||||
w.rbsp_trailing();
|
||||
let b = w.finish();
|
||||
assert_eq!(BitReader::new(&b).read_se(), Err(BitError::InvalidExpGolomb));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn length_prefixed_rejects_bad_width_and_oversized_length() {
|
||||
assert_eq!(length_prefixed_nals(&[1, 2, 3, 4, 5, 6], 5), Err(BitError::InvalidLengthSize));
|
||||
assert_eq!(length_prefixed_nals(&[1, 2, 3], 0), Err(BitError::InvalidLengthSize));
|
||||
assert_eq!(length_prefixed_nals(&[0, 0, 1, 7], 3).unwrap(), vec![&[7u8][..]]);
|
||||
assert_eq!(length_prefixed_nals(&[0xFF, 0xFF, 0xFF, 0xFF, 1], 4), Err(BitError::Eof));
|
||||
assert_eq!(length_prefixed_nals(&[0, 0, 0, 1, 9], 4).unwrap(), vec![&[9u8][..]]);
|
||||
}
|
||||
|
||||
#[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)); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+258
-2
@@ -34,8 +34,9 @@ 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() {
|
||||
let s = s.trim();
|
||||
let s = s.strip_prefix('#').unwrap_or(s);
|
||||
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());
|
||||
@@ -515,10 +516,265 @@ pub fn preset(name: &str) -> Option<Rgba> {
|
||||
Some(Rgba::rgb(c.0, c.1, c.2))
|
||||
}
|
||||
|
||||
/// Every preset colour name a file may write (`ST_PresetColorVal`, with the later editions' aliases).
|
||||
pub const PRESET_NAMES: [&str; 190] = [
|
||||
"aliceBlue",
|
||||
"antiqueWhite",
|
||||
"aqua",
|
||||
"aquamarine",
|
||||
"azure",
|
||||
"beige",
|
||||
"bisque",
|
||||
"black",
|
||||
"blanchedAlmond",
|
||||
"blue",
|
||||
"blueViolet",
|
||||
"brown",
|
||||
"burlyWood",
|
||||
"cadetBlue",
|
||||
"chartreuse",
|
||||
"chocolate",
|
||||
"coral",
|
||||
"cornflowerBlue",
|
||||
"cornsilk",
|
||||
"crimson",
|
||||
"cyan",
|
||||
"darkBlue",
|
||||
"darkCyan",
|
||||
"darkGoldenrod",
|
||||
"darkGray",
|
||||
"darkGrey",
|
||||
"darkGreen",
|
||||
"darkKhaki",
|
||||
"darkMagenta",
|
||||
"darkOliveGreen",
|
||||
"darkOrange",
|
||||
"darkOrchid",
|
||||
"darkRed",
|
||||
"darkSalmon",
|
||||
"darkSeaGreen",
|
||||
"darkSlateBlue",
|
||||
"darkSlateGray",
|
||||
"darkSlateGrey",
|
||||
"darkTurquoise",
|
||||
"darkViolet",
|
||||
"dkBlue",
|
||||
"dkCyan",
|
||||
"dkGoldenrod",
|
||||
"dkGray",
|
||||
"dkGrey",
|
||||
"dkGreen",
|
||||
"dkKhaki",
|
||||
"dkMagenta",
|
||||
"dkOliveGreen",
|
||||
"dkOrange",
|
||||
"dkOrchid",
|
||||
"dkRed",
|
||||
"dkSalmon",
|
||||
"dkSeaGreen",
|
||||
"dkSlateBlue",
|
||||
"dkSlateGray",
|
||||
"dkSlateGrey",
|
||||
"dkTurquoise",
|
||||
"dkViolet",
|
||||
"deepPink",
|
||||
"deepSkyBlue",
|
||||
"dimGray",
|
||||
"dimGrey",
|
||||
"dodgerBlue",
|
||||
"firebrick",
|
||||
"floralWhite",
|
||||
"forestGreen",
|
||||
"fuchsia",
|
||||
"gainsboro",
|
||||
"ghostWhite",
|
||||
"gold",
|
||||
"goldenrod",
|
||||
"gray",
|
||||
"grey",
|
||||
"green",
|
||||
"greenYellow",
|
||||
"honeydew",
|
||||
"hotPink",
|
||||
"indianRed",
|
||||
"indigo",
|
||||
"ivory",
|
||||
"khaki",
|
||||
"lavender",
|
||||
"lavenderBlush",
|
||||
"lawnGreen",
|
||||
"lemonChiffon",
|
||||
"lightBlue",
|
||||
"lightCoral",
|
||||
"lightCyan",
|
||||
"lightGoldenrodYellow",
|
||||
"lightGray",
|
||||
"lightGrey",
|
||||
"lightGreen",
|
||||
"lightPink",
|
||||
"lightSalmon",
|
||||
"lightSeaGreen",
|
||||
"lightSkyBlue",
|
||||
"lightSlateGray",
|
||||
"lightSlateGrey",
|
||||
"lightSteelBlue",
|
||||
"lightYellow",
|
||||
"ltBlue",
|
||||
"ltCoral",
|
||||
"ltCyan",
|
||||
"ltGoldenrodYellow",
|
||||
"ltGray",
|
||||
"ltGrey",
|
||||
"ltGreen",
|
||||
"ltPink",
|
||||
"ltSalmon",
|
||||
"ltSeaGreen",
|
||||
"ltSkyBlue",
|
||||
"ltSlateGray",
|
||||
"ltSlateGrey",
|
||||
"ltSteelBlue",
|
||||
"ltYellow",
|
||||
"lime",
|
||||
"limeGreen",
|
||||
"linen",
|
||||
"magenta",
|
||||
"maroon",
|
||||
"medAquamarine",
|
||||
"medBlue",
|
||||
"medOrchid",
|
||||
"medPurple",
|
||||
"medSeaGreen",
|
||||
"medSlateBlue",
|
||||
"medSpringGreen",
|
||||
"medTurquoise",
|
||||
"medVioletRed",
|
||||
"mediumAquamarine",
|
||||
"mediumBlue",
|
||||
"mediumOrchid",
|
||||
"mediumPurple",
|
||||
"mediumSeaGreen",
|
||||
"mediumSlateBlue",
|
||||
"mediumSpringGreen",
|
||||
"mediumTurquoise",
|
||||
"mediumVioletRed",
|
||||
"midnightBlue",
|
||||
"mintCream",
|
||||
"mistyRose",
|
||||
"moccasin",
|
||||
"navajoWhite",
|
||||
"navy",
|
||||
"oldLace",
|
||||
"olive",
|
||||
"oliveDrab",
|
||||
"orange",
|
||||
"orangeRed",
|
||||
"orchid",
|
||||
"paleGoldenrod",
|
||||
"paleGreen",
|
||||
"paleTurquoise",
|
||||
"paleVioletRed",
|
||||
"papayaWhip",
|
||||
"peachPuff",
|
||||
"peru",
|
||||
"pink",
|
||||
"plum",
|
||||
"powderBlue",
|
||||
"purple",
|
||||
"red",
|
||||
"rosyBrown",
|
||||
"royalBlue",
|
||||
"saddleBrown",
|
||||
"salmon",
|
||||
"sandyBrown",
|
||||
"seaGreen",
|
||||
"seaShell",
|
||||
"sienna",
|
||||
"silver",
|
||||
"skyBlue",
|
||||
"slateBlue",
|
||||
"slateGray",
|
||||
"slateGrey",
|
||||
"snow",
|
||||
"springGreen",
|
||||
"steelBlue",
|
||||
"tan",
|
||||
"teal",
|
||||
"thistle",
|
||||
"tomato",
|
||||
"turquoise",
|
||||
"violet",
|
||||
"wheat",
|
||||
"white",
|
||||
"whiteSmoke",
|
||||
"yellow",
|
||||
"yellowGreen",
|
||||
];
|
||||
|
||||
/// Every system colour name a file may write (`ST_SystemColorVal`).
|
||||
pub const SYSTEM_NAMES: [&str; 30] = [
|
||||
"scrollBar",
|
||||
"background",
|
||||
"activeCaption",
|
||||
"inactiveCaption",
|
||||
"menu",
|
||||
"window",
|
||||
"windowFrame",
|
||||
"menuText",
|
||||
"windowText",
|
||||
"captionText",
|
||||
"activeBorder",
|
||||
"inactiveBorder",
|
||||
"appWorkspace",
|
||||
"highlight",
|
||||
"highlightText",
|
||||
"btnFace",
|
||||
"btnShadow",
|
||||
"grayText",
|
||||
"btnText",
|
||||
"inactiveCaptionText",
|
||||
"btnHighlight",
|
||||
"3dDkShadow",
|
||||
"3dLight",
|
||||
"infoText",
|
||||
"infoBk",
|
||||
"hotLight",
|
||||
"gradientActiveCaption",
|
||||
"gradientInactiveCaption",
|
||||
"menuHighlight",
|
||||
"menuBar",
|
||||
];
|
||||
|
||||
/// Whether `name` is a preset colour name a file may write.
|
||||
pub fn is_preset_name(name: &str) -> bool {
|
||||
PRESET_NAMES.contains(&name)
|
||||
}
|
||||
|
||||
/// Whether `name` is a system colour name a file may write.
|
||||
pub fn is_system_name(name: &str) -> bool {
|
||||
SYSTEM_NAMES.contains(&name)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn from_hex_rejects_multiple_hashes() {
|
||||
assert_eq!(Rgba::from_hex("##FF0000"), None);
|
||||
assert_eq!(Rgba::from_hex("####FF0000"), None);
|
||||
assert_eq!(Rgba::from_hex("#FF0000"), Some(Rgba::rgb(255, 0, 0)));
|
||||
assert_eq!(Rgba::from_hex("FF0000"), Some(Rgba::rgb(255, 0, 0)));
|
||||
assert_eq!(Rgba::from_hex("#FF000080"), Some(Rgba::rgba(255, 0, 0, 128)));
|
||||
}
|
||||
|
||||
#[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,6 +25,7 @@ i_overlay = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
image = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
deckcraft-anim = { workspace = true }
|
||||
|
||||
@@ -318,6 +318,14 @@ 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())
|
||||
}
|
||||
/// 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>> {
|
||||
@@ -347,6 +355,11 @@ pub(crate) fn with_param(p: &Value, key: &str, v: Value) -> Value {
|
||||
|
||||
/// Targets: `ids` / `id` params, else the selection.
|
||||
pub(crate) fn targets(s: &Session, p: &Value) -> Result<Vec<ShapeId>> {
|
||||
if let Some(a) = p.get("ids").and_then(Value::as_array)
|
||||
&& let Some(v) = a.iter().find(|v| v.as_u64().and_then(|v| u32::try_from(v).ok()).is_none())
|
||||
{
|
||||
return Err(bad("shape", format!("`ids` has an invalid shape id: {v}")));
|
||||
}
|
||||
if let Some(ids) = ids_param(p, "ids")
|
||||
&& !ids.is_empty()
|
||||
{
|
||||
@@ -363,6 +376,8 @@ pub(crate) fn ok() -> Result<Value> {
|
||||
}
|
||||
|
||||
/// Parse a colour param: `"#RRGGBB"`, `"accent1"`…, or `{"scheme":"accent2","lumMod":75000}`.
|
||||
/// A preset or system colour keeps its base: `{"preset":"red"}`,
|
||||
/// `{"system":"windowText","last":"#000000"}`.
|
||||
pub(crate) fn color_param(p: &Value, key: &str) -> Option<deckcraft_model::ColorRef> {
|
||||
let v = p.get(key)?;
|
||||
color_value(v)
|
||||
@@ -383,6 +398,17 @@ pub(crate) fn color_value(v: &Value) -> Option<deckcraft_model::ColorRef> {
|
||||
Value::Object(o) => {
|
||||
let mut c = if let Some(s) = o.get("scheme").and_then(Value::as_str) {
|
||||
ColorRef::scheme(deckcraft_color::SchemeSlot::from_xml(s)?)
|
||||
} else if let Some(name) = o.get("preset").and_then(Value::as_str) {
|
||||
if !deckcraft_color::is_preset_name(name) {
|
||||
return None;
|
||||
}
|
||||
ColorRef { base: deckcraft_model::ColorBase::Preset { name: name.to_string() }, mods: vec![] }
|
||||
} else if let Some(name) = o.get("system").and_then(Value::as_str) {
|
||||
if !deckcraft_color::is_system_name(name) {
|
||||
return None;
|
||||
}
|
||||
let last = deckcraft_color::Rgba::from_hex(o.get("last").and_then(Value::as_str)?)?;
|
||||
ColorRef { base: deckcraft_model::ColorBase::System { name: name.to_string(), last }, mods: vec![] }
|
||||
} else {
|
||||
ColorRef::rgb(deckcraft_color::Rgba::from_hex(o.get("rgb").and_then(Value::as_str)?)?)
|
||||
};
|
||||
@@ -489,6 +515,10 @@ pub(crate) fn current_shapes<'a>(doc: &'a deckcraft_model::Presentation, sel: &c
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn current_names(doc: &deckcraft_model::Presentation, sel: &crate::Selection) -> std::collections::HashSet<String> {
|
||||
current_shapes(doc, sel).map(|v| v.iter().map(|s| s.name.clone()).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -631,4 +661,22 @@ mod tests {
|
||||
assert!(!needs_arguments(save.params));
|
||||
assert!(needs_arguments(audio.params));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preset_and_system_colors_keep_their_base() {
|
||||
use deckcraft_color::{ColorTransform, Rgba};
|
||||
use deckcraft_model::{ColorBase, ColorRef};
|
||||
use serde_json::json;
|
||||
let preset = color_value(&json!({"preset": "red", "lumMod": 50_000}));
|
||||
assert_eq!(preset, Some(ColorRef { base: ColorBase::Preset { name: "red".into() }, mods: vec![ColorTransform::LumMod(50_000)] }));
|
||||
let system = color_value(&json!({"system": "windowText", "last": "#123456"}));
|
||||
assert_eq!(system, Some(ColorRef { base: ColorBase::System { name: "windowText".into(), last: Rgba::rgb(0x12, 0x34, 0x56) }, mods: vec![] }));
|
||||
assert_eq!(color_value(&json!({"system": "windowText"})), None, "a system color needs its last value");
|
||||
assert_eq!(color_value(&json!({"preset": "notAColor"})), None);
|
||||
assert_eq!(color_value(&json!({"system": "notAColor", "last": "#000000"})), None);
|
||||
for name in ["aliceBlue", "darkBlue", "ltSlateGrey"] {
|
||||
assert!(color_value(&json!({"preset": name})).is_some(), "{name}");
|
||||
}
|
||||
assert!(color_value(&json!({"system": "3dDkShadow", "last": "#000000"})).is_some());
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,9 @@ use std::sync::Arc;
|
||||
use deckcraft_model::anim::{ANIMATIONS, AnimClass, AnimStart, Animation, TextBuild, animation_info};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{
|
||||
CommandSpec, always, bad, bool_or, bool_param, cmd, has_selection, has_slide, id_param, ok, opt_usize_param, str_param, targets, usize_param,
|
||||
};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -230,7 +232,7 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let index = usize_param(p, "index");
|
||||
let index = opt_usize_param(p, "index", "animation.remove")?;
|
||||
let ids = if index.is_none() { targets(s, p)? } else { vec![] };
|
||||
s.edit(|doc, sel| {
|
||||
let sl = Arc::make_mut(doc.slides.get_mut(sel.slide).ok_or_else(|| bad("animation.remove", "no slide"))?);
|
||||
@@ -247,7 +249,7 @@ fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn move_anim(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = usize_param(p, "index").ok_or_else(|| bad("animation.move", "missing `index`"))?;
|
||||
let i = opt_usize_param(p, "index", "animation.move")?.ok_or_else(|| bad("animation.move", "missing `index`"))?;
|
||||
let to = usize_param(p, "to").ok_or_else(|| bad("animation.move", "missing `to`"))?;
|
||||
s.edit(|doc, sel| {
|
||||
let sl = Arc::make_mut(doc.slides.get_mut(sel.slide).ok_or_else(|| bad("animation.move", "no slide"))?);
|
||||
@@ -263,7 +265,7 @@ fn move_anim(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn each_target(s: &mut Session, p: &Value, f: impl Fn(&mut Animation)) -> Result<Value> {
|
||||
let index = usize_param(p, "index");
|
||||
let index = opt_usize_param(p, "index", "animation")?;
|
||||
let ids = if index.is_none() { targets(s, p)? } else { vec![] };
|
||||
s.edit(|doc, sel| {
|
||||
let sl = Arc::make_mut(doc.slides.get_mut(sel.slide).ok_or_else(|| bad("animation", "no slide"))?);
|
||||
@@ -525,4 +527,67 @@ 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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_index_is_an_error_not_the_selection() {
|
||||
let mut s = Session::with_new();
|
||||
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10.0, 10.0, 100.0, 100.0]})).unwrap();
|
||||
let id = r.get("id").unwrap().as_u64().unwrap();
|
||||
s.execute("animation.add", &json!({"effect": "fade", "class": "entrance", "ids": [id]})).unwrap();
|
||||
s.select(|_, sel| {
|
||||
sel.shapes = vec![deckcraft_model::ShapeId(id as u32)];
|
||||
})
|
||||
.unwrap();
|
||||
for bad_index in [json!(-1), json!(1.5), json!("x")] {
|
||||
for cmd in ["animation.remove", "animation.timing", "animation.options"] {
|
||||
let r = s.execute(cmd, &json!({"index": bad_index, "duration": 100}));
|
||||
assert!(r.is_err(), "{cmd} accepted index {bad_index}: {r:?}");
|
||||
}
|
||||
let r = s.execute("animation.move", &json!({"index": bad_index, "to": 0}));
|
||||
assert!(r.is_err(), "animation.move accepted index {bad_index}: {r:?}");
|
||||
assert_eq!(s.execute("animation.get", &json!({})).unwrap().as_array().unwrap().len(), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@ use deckcraft_geom::{Point, Rect, Xfrm};
|
||||
use deckcraft_model::{Shape, ShapeId, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{
|
||||
CommandSpec, NO_ID_SPACE, bad, bool_or, cmd, edit_geometry, has_doc, has_selection, has_slide, has_two, id_param, ok, str_param, targets,
|
||||
usize_param, with_param, xfrm_of,
|
||||
};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -171,6 +174,22 @@ pub(crate) fn union_bounds(xs: &[Xfrm]) -> Option<Rect> {
|
||||
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| {
|
||||
@@ -179,6 +198,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));
|
||||
@@ -208,7 +230,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));
|
||||
@@ -322,9 +344,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 };
|
||||
|
||||
@@ -9,7 +9,7 @@ use deckcraft_model::{MediaId, ShapeId, ShapeKind};
|
||||
use image::{GrayImage, Rgba, RgbaImage};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, NO_ID_SPACE, bad, cmd, has_selection, str_param, targets};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// The effects offered: `a14` element name and label.
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::collections::VecDeque;
|
||||
use deckcraft_model::{ShapeId, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, NO_ID_SPACE, bad, cmd, f64_or, has_selection, targets};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// Larger pictures are refused rather than matted slowly (16 megapixels).
|
||||
@@ -184,7 +184,12 @@ fn remove_background(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use deckcraft_model::{ShapeId, ShapeKind};
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use crate::Session;
|
||||
use crate::cmd::base64_encode;
|
||||
|
||||
/// A red disc on a white-to-grey gradient.
|
||||
fn disc() -> image::RgbaImage {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::*;
|
||||
use crate::Session;
|
||||
|
||||
fn deck() -> Session {
|
||||
let mut s = Session::with_new();
|
||||
|
||||
@@ -8,7 +8,7 @@ use deckcraft_model::style::PictureFill;
|
||||
use deckcraft_model::{MediaId, Shape, ShapeId, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, NO_ID_SPACE, bad, bool_or, cmd, f64_param, has_doc, targets};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
+83
-1683
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,291 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::text::RunKind;
|
||||
use deckcraft_model::{Background, Fill, PhType};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Session, Target};
|
||||
|
||||
#[test]
|
||||
fn applies_the_theme_of_another_deck_and_undoes() {
|
||||
let mut other = Session::with_new();
|
||||
other.execute("design.fonts", &json!({"major": "Lato", "minor": "Source Sans 3"})).unwrap();
|
||||
other.execute("design.colors", &json!({"colors": {"accent1": "#123456"}})).unwrap();
|
||||
let data = other.execute("file.saveBytes", &json!({"format": "pptx"})).unwrap()["data"].as_str().unwrap().to_string();
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "T", "body": "B"})).unwrap();
|
||||
let before = s.doc().unwrap().doc.masters[0].theme.clone();
|
||||
s.execute("design.browseTheme", &json!({"name": "Brand.pptx", "data": data})).unwrap();
|
||||
let t = &s.doc().unwrap().doc.masters[0].theme;
|
||||
assert_eq!(t.fonts.major.latin, "Lato");
|
||||
assert_eq!(t.fonts.minor.latin, "Source Sans 3");
|
||||
assert_eq!(t.colors.get(deckcraft_color::SchemeSlot::Accent1).hex().to_lowercase(), "123456");
|
||||
assert!(s.doc().unwrap().doc.validate().is_empty());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.masters[0].theme, before);
|
||||
assert!(s.execute("design.browseTheme", &json!({"data": "AAAA"})).is_err());
|
||||
assert!(s.execute("design.browseTheme", &json!({})).is_err());
|
||||
}
|
||||
|
||||
/// A 4:3 brand template: a logo and a background on the master, its layouts renamed (so slides
|
||||
/// can only find them by kind) and its content layout's title moved.
|
||||
fn brand_template() -> (String, Vec<u8>, deckcraft_model::Xfrm) {
|
||||
let mut img = image::RgbaImage::new(2, 2);
|
||||
img.put_pixel(0, 0, image::Rgba([200, 20, 30, 255]));
|
||||
let mut logo = Vec::new();
|
||||
image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut logo), image::ImageFormat::Png).unwrap();
|
||||
let mut t = Session::with_new();
|
||||
t.execute("design.slideSize", &json!({"preset": "standard", "scale": "none"})).unwrap();
|
||||
let title = t
|
||||
.edit(|doc, _| {
|
||||
let media = doc.add_media("logo.png", "image/png", logo.clone()).unwrap();
|
||||
let id = deckcraft_model::ShapeId(doc.alloc_id().unwrap());
|
||||
let m = Arc::make_mut(doc.masters.get_mut(0).unwrap());
|
||||
m.shapes.push(deckcraft_model::Shape {
|
||||
id,
|
||||
name: "Logo".into(),
|
||||
kind: deckcraft_model::ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { media, ..Default::default() } },
|
||||
xfrm: Some(deckcraft_model::Xfrm { x: 600.0, y: 20.0, w: 100.0, h: 40.0, ..Default::default() }),
|
||||
..Default::default()
|
||||
});
|
||||
m.background = Some(Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x0A, 0x2B, 0x4C))) });
|
||||
for l in &mut m.layouts {
|
||||
l.name = format!("Brand {}", l.name);
|
||||
}
|
||||
let obj = m.layouts.iter_mut().find(|l| l.kind == deckcraft_model::LayoutType::TitleAndContent).unwrap();
|
||||
let title = obj.shapes.iter_mut().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap();
|
||||
let x = deckcraft_model::Xfrm { x: 72.0, y: 400.0, w: 576.0, h: 80.0, ..Default::default() };
|
||||
title.xfrm = Some(x);
|
||||
Ok(x)
|
||||
})
|
||||
.unwrap();
|
||||
(crate::cmd::base64_encode(&deckcraft_pptx::export_as(&t.doc().unwrap().doc, "potx").unwrap()), logo, title)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_template_brings_its_master_and_layouts_and_slides_follow() {
|
||||
let (data, logo, title) = brand_template();
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "Agenda", "body": "Point one"})).unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let n = undo_len(&s);
|
||||
let r = s.execute("design.browseTheme", &json!({"name": "Brand.potx", "data": data})).unwrap();
|
||||
assert_eq!(r["slides"], json!(2), "{r}");
|
||||
assert_eq!(undo_len(&s), n + 1, "one undo step");
|
||||
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert!(doc.validate().is_empty(), "{:?}", doc.validate());
|
||||
assert_eq!(doc.masters.len(), 1, "the old master is replaced");
|
||||
let m = &doc.masters[0];
|
||||
assert_ne!(m.id, before.masters[0].id);
|
||||
assert!(m.layouts.iter().all(|l| l.name.starts_with("Brand ")));
|
||||
assert!(matches!(m.background, Some(Background::Fill { fill: Fill::Solid { .. } })));
|
||||
let pic = m.shapes.iter().find(|sh| sh.name == "Logo").expect("the master's logo comes along");
|
||||
let deckcraft_model::ShapeKind::Picture { fill } = &pic.kind else { panic!("logo is a picture") };
|
||||
assert_eq!(doc.media(fill.media).map(|m| m.data.as_ref().clone()), Some(logo));
|
||||
// The 4:3 master stretches to this deck's 16:9.
|
||||
let k = doc.slide_size.width / 720.0;
|
||||
let px = pic.xfrm.unwrap();
|
||||
assert!((px.x - 600.0 * k).abs() < 0.01 && (px.w - 100.0 * k).abs() < 0.01 && (px.y - 20.0).abs() < 0.01, "{px:?}");
|
||||
|
||||
// Each slide moved to the brand layout of its kind, and kept its text.
|
||||
for (old, new) in before.slides.iter().zip(&doc.slides) {
|
||||
let (_, was) = before.layout(old.layout).unwrap();
|
||||
let (owner, now) = doc.layout(new.layout).expect("on a layout of the new master");
|
||||
assert_eq!(owner.id, m.id);
|
||||
assert_eq!(now.kind, was.kind);
|
||||
}
|
||||
let agenda = doc.slides.iter().find(|sl| sl.shapes.iter().any(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Agenda"))).unwrap();
|
||||
assert!(agenda.shapes.iter().any(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Point one")));
|
||||
let (_, lay) = doc.layout(agenda.layout).unwrap();
|
||||
let lt = lay.shapes.iter().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap().xfrm.unwrap();
|
||||
assert!((lt.x - title.x * k).abs() < 0.01 && (lt.y - title.y).abs() < 0.01, "{lt:?}");
|
||||
let st = agenda.shapes.iter().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap();
|
||||
assert!(st.xfrm.is_none(), "the slide title follows the new layout");
|
||||
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert_eq!(doc.masters, before.masters);
|
||||
assert_eq!(doc.slides, before.slides);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_preserved_master_stays_beside_the_template() {
|
||||
let (data, _, _) = brand_template();
|
||||
let mut s = Session::with_new();
|
||||
s.execute("view.slideMaster", &json!({"master": 0, "layout": 1})).unwrap();
|
||||
s.execute("master.preserve", &json!({"preserve": true})).unwrap();
|
||||
s.execute("view.slideMaster", &json!({"master": 0})).unwrap();
|
||||
s.execute("master.preserve", &json!({"preserve": true})).unwrap();
|
||||
let old = s.doc().unwrap().doc.masters[0].id;
|
||||
s.execute("view.slideMaster", &json!({"master": 0, "layout": 1})).unwrap();
|
||||
s.execute("design.browseTheme", &json!({"name": "Brand.pptx", "data": data})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.doc.masters.len(), 2);
|
||||
assert_eq!(st.doc.masters[1].id, old);
|
||||
assert_eq!(st.selection.target, Target::Master { master: 0 }, "Slide Master view shows the new master");
|
||||
let new = &st.doc.masters[0];
|
||||
assert!(st.doc.slides.iter().all(|sl| new.layout(sl.layout).is_some()));
|
||||
}
|
||||
|
||||
fn undo_len(s: &crate::Session) -> usize {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_scheme_replaces_format_and_keeps_colours_and_fonts() {
|
||||
let mut s = crate::Session::with_new();
|
||||
let listed = s.execute("design.themes", &json!({})).unwrap();
|
||||
let names: Vec<String> = listed["effectSchemes"].as_array().unwrap().iter().filter_map(|v| v.as_str().map(str::to_string)).collect();
|
||||
assert_eq!(names, ["DeckCraft", "Soft", "Crisp", "Lift", "Glow", "Plain"]);
|
||||
|
||||
let n = undo_len(&s);
|
||||
s.execute("design.effects", &json!({"name": "DeckCraft"})).unwrap();
|
||||
assert_eq!(undo_len(&s), n, "the scheme already on the theme is not an undo step");
|
||||
let err = s.execute("design.effects", &json!({"name": "Office"})).unwrap_err().to_string();
|
||||
assert!(err.contains("no effect scheme"), "{err}");
|
||||
assert!(s.execute("design.effects", &json!({})).is_err());
|
||||
assert_eq!(undo_len(&s), n);
|
||||
|
||||
s.execute("design.theme", &json!({"name": "Harbor"})).unwrap();
|
||||
let before = s.doc().unwrap().doc.masters[0].theme.clone();
|
||||
s.execute("design.effects", &json!({"name": "soft"})).unwrap();
|
||||
let theme = s.doc().unwrap().doc.masters[0].theme.clone();
|
||||
assert_eq!(theme.name, "Harbor");
|
||||
assert_eq!(theme.colors, before.colors);
|
||||
assert_eq!(theme.fonts, before.fonts);
|
||||
assert_eq!(theme.format.name, "Soft");
|
||||
assert!(theme.format.effects[0].outer_shadow.is_some());
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Effects");
|
||||
|
||||
let n = undo_len(&s);
|
||||
s.execute("design.effects", &json!({"name": "Soft"})).unwrap();
|
||||
assert_eq!(undo_len(&s), n, "the same scheme is not an undo step");
|
||||
|
||||
s.execute("design.effects", &json!({"name": "Glow"})).unwrap();
|
||||
let glow = s.doc().unwrap().doc.masters[0].theme.format.effects[2].glow.clone();
|
||||
assert!(glow.is_some_and(|g| (g.radius - 12.0).abs() < 1e-6));
|
||||
s.execute("design.effects", &json!({"name": "Plain"})).unwrap();
|
||||
let plain = s.doc().unwrap().doc.masters[0].theme.format.clone();
|
||||
assert!(plain.effects.iter().all(|e| e.outer_shadow.is_none() && e.glow.is_none()));
|
||||
assert_eq!(plain.lines[2].width, Some(0.75));
|
||||
}
|
||||
|
||||
fn shapes(s: &Session) -> Vec<deckcraft_model::Shape> {
|
||||
s.doc().unwrap().shapes().to_vec()
|
||||
}
|
||||
|
||||
fn has_title(s: &Session) -> bool {
|
||||
shapes(s).iter().any(|sh| sh.ph_type().is_some_and(PhType::is_title))
|
||||
}
|
||||
|
||||
fn has_footers(s: &Session) -> bool {
|
||||
shapes(s).iter().any(|sh| sh.ph_type().is_some_and(PhType::is_footer_kind))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserve_and_placeholders_edit_the_open_master_or_layout() {
|
||||
for id in ["master.preserve", "master.placeholders"] {
|
||||
assert!(crate::command_specs().iter().any(|c| c.id == id), "{id}");
|
||||
}
|
||||
|
||||
let mut s = Session::with_new();
|
||||
let blank = s.doc().unwrap().doc.masters[0].layouts.iter().position(|l| l.name == "Blank").unwrap();
|
||||
|
||||
s.execute("view.slideMaster", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.target, Target::Layout { master: 0, layout: 0 });
|
||||
s.execute("view.slideMaster", &json!({"master": 0})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.target, Target::Master { master: 0 });
|
||||
assert!(!s.doc().unwrap().doc.masters[0].preserve);
|
||||
|
||||
let n = s.doc().unwrap().history.undo.len();
|
||||
s.execute("master.preserve", &json!({"preserve": false})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), n, "the same value is not an undo step");
|
||||
s.execute("master.preserve", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.masters[0].preserve);
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Preserve");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(!s.doc().unwrap().doc.masters[0].preserve);
|
||||
|
||||
s.execute("master.placeholders", &json!({"footers": false})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Layout Placeholders");
|
||||
assert!(has_title(&s));
|
||||
assert!(!has_footers(&s));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(has_footers(&s));
|
||||
|
||||
master_view_closed_rejects(&mut s, blank);
|
||||
}
|
||||
|
||||
fn master_view_closed_rejects(s: &mut Session, blank: usize) {
|
||||
s.execute("view.closeMaster", &json!({})).unwrap();
|
||||
let n = s.doc().unwrap().history.undo.len();
|
||||
let err = s.execute("master.preserve", &json!({})).unwrap_err();
|
||||
assert!(err.to_string().contains("open Slide Master view"), "{err}");
|
||||
let err = s.execute("master.placeholders", &json!({"title": true})).unwrap_err();
|
||||
assert!(err.to_string().contains("open Slide Master view"), "{err}");
|
||||
let err = s.execute("master.placeholders", &json!({})).unwrap_err();
|
||||
assert!(err.to_string().contains("give title or footers"), "{err}");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), n);
|
||||
|
||||
layout_placeholders_edit_one_undo_step(s, blank);
|
||||
}
|
||||
|
||||
fn layout_placeholders_edit_one_undo_step(s: &mut Session, blank: usize) {
|
||||
s.execute("view.slideMaster", &json!({"master": 0, "layout": blank})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.target, Target::Layout { master: 0, layout: blank });
|
||||
assert!(s.doc().unwrap().doc.masters[0].layouts[blank].preserve);
|
||||
assert!(!has_title(s));
|
||||
assert!(has_footers(s));
|
||||
|
||||
let n = s.doc().unwrap().history.undo.len();
|
||||
s.execute("master.placeholders", &json!({"title": false, "footers": true})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), n, "already in that state");
|
||||
|
||||
s.execute("master.placeholders", &json!({"title": true, "footers": false})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), n + 1, "both keys are one undo step");
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Layout Placeholders");
|
||||
assert!(has_title(s));
|
||||
assert!(!has_footers(s));
|
||||
let title = shapes(s).into_iter().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap();
|
||||
assert_eq!(title.name, "Title 1");
|
||||
assert_eq!(title.ph.unwrap().idx, 0);
|
||||
assert!(title.text.unwrap().paragraphs.iter().all(|para| para.runs.is_empty()));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(!has_title(s));
|
||||
assert!(has_footers(s));
|
||||
|
||||
s.execute("master.placeholders", &json!({"footers": false})).unwrap();
|
||||
s.execute("master.placeholders", &json!({"footers": true})).unwrap();
|
||||
let restored = shapes(s);
|
||||
assert!(restored.iter().any(|sh| sh.ph_type() == Some(PhType::Date)));
|
||||
assert!(restored.iter().any(|sh| sh.ph_type() == Some(PhType::Footer)));
|
||||
let num = restored.iter().find(|sh| sh.ph_type() == Some(PhType::SlideNum)).unwrap();
|
||||
let run = &num.text.as_ref().unwrap().paragraphs[0].runs[0];
|
||||
assert_eq!(run.text, "‹#›");
|
||||
assert!(matches!(&run.kind, RunKind::Field { field } if field == "slidenum"));
|
||||
|
||||
assert!(s.doc().unwrap().doc.masters[0].layouts[blank].preserve);
|
||||
s.execute("master.preserve", &json!({"preserve": false})).unwrap();
|
||||
assert!(!s.doc().unwrap().doc.masters[0].layouts[blank].preserve);
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Preserve");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.masters[0].layouts[blank].preserve);
|
||||
|
||||
missing_title_placeholder_is_recreated(s);
|
||||
}
|
||||
|
||||
fn missing_title_placeholder_is_recreated(s: &mut Session) {
|
||||
s.edit(|doc, _| {
|
||||
let m = std::sync::Arc::make_mut(doc.masters.get_mut(0).unwrap());
|
||||
m.shapes.retain(|sh| !sh.ph_type().is_some_and(PhType::is_title));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("master.placeholders", &json!({"title": true})).unwrap();
|
||||
let title = shapes(s).into_iter().find(|sh| sh.ph_type() == Some(PhType::Title)).unwrap();
|
||||
assert_eq!(title.name, "Title");
|
||||
let box_ = title.xfrm.unwrap();
|
||||
assert!((box_.x - 60.0).abs() < 0.01 && (box_.y - 29.0).abs() < 0.01);
|
||||
assert!((box_.w - 840.0).abs() < 0.01 && (box_.h - 104.0).abs() < 0.01);
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
use super::{
|
||||
Arc, Result, Session, Value, bad, builtin_color_schemes, builtin_font_schemes, builtin_format_schemes, json, master_index, ok, str_param,
|
||||
};
|
||||
|
||||
pub(super) fn colors(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name_v = p.get("name");
|
||||
let colors_v = p.get("colors");
|
||||
if name_v.is_none() && colors_v.is_none() {
|
||||
let sc = current_colors(s)?;
|
||||
s.ui_requests.push(crate::UiRequest::Dialog { id: "themeColors".into(), params: scheme_dialog_params(&sc) });
|
||||
return ok();
|
||||
}
|
||||
let given = match name_v {
|
||||
None => None,
|
||||
Some(Value::String(n)) => {
|
||||
let n = n.trim();
|
||||
if n.is_empty() {
|
||||
return Err(bad("design.colors", "name is empty"));
|
||||
}
|
||||
Some(n.to_string())
|
||||
}
|
||||
Some(_) => return Err(bad("design.colors", "`name` must be a string")),
|
||||
};
|
||||
let slots = parse_color_slots(colors_v)?;
|
||||
let current = current_colors(s)?;
|
||||
let next = next_scheme(¤t, given.as_deref(), slots.as_deref())?;
|
||||
if next == current {
|
||||
return ok();
|
||||
}
|
||||
s.edit(|doc, sel| {
|
||||
let mi = master_index(doc, sel);
|
||||
let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("design.colors", "no master"))?);
|
||||
m.theme.colors = next;
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
pub(super) fn variant(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let index = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("design.variant", "missing `index` (0..3)"))? as usize;
|
||||
s.edit(|doc, sel| {
|
||||
let mi = master_index(doc, sel);
|
||||
let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("design.variant", "no master"))?);
|
||||
let v = deckcraft_model::theme::theme_variants(&m.theme);
|
||||
m.theme.colors =
|
||||
v.get(index).cloned().ok_or_else(|| bad("design.variant", format!("no variant {index}; 0..{}", v.len().saturating_sub(1))))?;
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"variant": index}))
|
||||
}
|
||||
|
||||
pub(super) fn variants(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let mi = master_index(&st.doc, &st.selection);
|
||||
let th = st.doc.masters.get(mi).map(|m| m.theme.clone()).unwrap_or_default();
|
||||
let v = deckcraft_model::theme::theme_variants(&th);
|
||||
let current = v.iter().position(|c| c.colors == th.colors.colors);
|
||||
Ok(json!({
|
||||
"theme": th.name,
|
||||
"current": current,
|
||||
"variants": v.iter().map(|c| json!({"name": c.name, "colors": c.colors.iter().map(|x| format!("#{}", x.hex())).collect::<Vec<_>>()})).collect::<Vec<_>>(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn current_colors(s: &Session) -> Result<deckcraft_color::ColorScheme> {
|
||||
let st = s.doc()?;
|
||||
let mi = master_index(&st.doc, &st.selection);
|
||||
st.doc.masters.get(mi).map(|m| m.theme.colors.clone()).ok_or_else(|| bad("design.colors", "no master"))
|
||||
}
|
||||
|
||||
fn scheme_dialog_params(sc: &deckcraft_color::ColorScheme) -> Value {
|
||||
let mut colors = serde_json::Map::new();
|
||||
for slot in deckcraft_color::SchemeSlot::THEME {
|
||||
colors.insert(slot.xml_name().to_string(), Value::String(format!("#{}", sc.get(slot).hex())));
|
||||
}
|
||||
json!({"name": sc.name, "colors": colors})
|
||||
}
|
||||
|
||||
/// `None` when the key is absent. Present values must be the twelve stored slots; aliases such
|
||||
/// as `bg1` are rejected because `ColorScheme::set` ignores them.
|
||||
fn parse_color_slots(colors_v: Option<&Value>) -> Result<Option<Vec<(deckcraft_color::SchemeSlot, deckcraft_color::Rgba)>>> {
|
||||
let Some(v) = colors_v else { return Ok(None) };
|
||||
let Some(map) = v.as_object() else {
|
||||
return Err(bad("design.colors", "`colors` must be an object"));
|
||||
};
|
||||
if map.is_empty() {
|
||||
return Err(bad("design.colors", "`colors` is empty"));
|
||||
}
|
||||
let mut slots = Vec::with_capacity(map.len());
|
||||
for (k, v) in map {
|
||||
let Some(slot) = deckcraft_color::SchemeSlot::from_xml(k) else {
|
||||
return Err(bad("design.colors", format!("unknown colour `{k}`")));
|
||||
};
|
||||
if !deckcraft_color::SchemeSlot::THEME.contains(&slot) {
|
||||
return Err(bad("design.colors", format!("`{k}` is not a theme colour")));
|
||||
}
|
||||
let Some(rgb) = v.as_str().and_then(deckcraft_color::Rgba::from_hex) else {
|
||||
return Err(bad("design.colors", format!("`{k}` needs a colour like #RRGGBB")));
|
||||
};
|
||||
slots.push((slot, rgb));
|
||||
}
|
||||
Ok(Some(slots))
|
||||
}
|
||||
|
||||
/// A builtin `name` is applied first, then `colors` overlays those slots. The stored scheme name
|
||||
/// is the given name, or `Custom` when only colours are sent. An unknown name without colours is an error.
|
||||
fn next_scheme(
|
||||
current: &deckcraft_color::ColorScheme,
|
||||
given: Option<&str>,
|
||||
slots: Option<&[(deckcraft_color::SchemeSlot, deckcraft_color::Rgba)]>,
|
||||
) -> Result<deckcraft_color::ColorScheme> {
|
||||
let mut next = current.clone();
|
||||
if let Some(name) = given {
|
||||
if let Some(builtin) = builtin_color_schemes().into_iter().find(|c| c.name.eq_ignore_ascii_case(name)) {
|
||||
next = builtin;
|
||||
} else if slots.is_none() {
|
||||
return Err(bad("design.colors", format!("no colour scheme `{name}`")));
|
||||
}
|
||||
next.name = name.to_string();
|
||||
}
|
||||
if let Some(changes) = slots {
|
||||
for (slot, rgb) in changes {
|
||||
next.set(*slot, *rgb);
|
||||
}
|
||||
if given.is_none() {
|
||||
next.name = "Custom".into();
|
||||
}
|
||||
}
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
pub(super) fn fonts(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name_v = p.get("name");
|
||||
let major_v = p.get("major");
|
||||
let minor_v = p.get("minor");
|
||||
if name_v.is_none() && major_v.is_none() && minor_v.is_none() {
|
||||
let scheme = current_fonts(s)?;
|
||||
s.ui_requests.push(crate::UiRequest::Dialog { id: "themeFonts".into(), params: font_dialog_params(&scheme) });
|
||||
return ok();
|
||||
}
|
||||
let given = match name_v {
|
||||
None => None,
|
||||
Some(Value::String(n)) => {
|
||||
let n = n.trim();
|
||||
if n.is_empty() {
|
||||
return Err(bad("design.fonts", "name is empty"));
|
||||
}
|
||||
Some(n.to_string())
|
||||
}
|
||||
Some(_) => return Err(bad("design.fonts", "`name` must be a string")),
|
||||
};
|
||||
let major = face_param("major", major_v)?;
|
||||
let minor = face_param("minor", minor_v)?;
|
||||
let current = current_fonts(s)?;
|
||||
let next = next_font_scheme(¤t, given.as_deref(), major.as_deref(), minor.as_deref())?;
|
||||
if next == current {
|
||||
return ok();
|
||||
}
|
||||
s.edit(|doc, sel| {
|
||||
let mi = master_index(doc, sel);
|
||||
Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("design.fonts", "no master"))?).theme.fonts = next;
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
fn current_fonts(s: &Session) -> Result<deckcraft_model::theme::FontScheme> {
|
||||
let st = s.doc()?;
|
||||
let mi = master_index(&st.doc, &st.selection);
|
||||
st.doc.masters.get(mi).map(|m| m.theme.fonts.clone()).ok_or_else(|| bad("design.fonts", "no master"))
|
||||
}
|
||||
|
||||
fn font_dialog_params(scheme: &deckcraft_model::theme::FontScheme) -> Value {
|
||||
json!({"name": scheme.name, "major": scheme.major.latin, "minor": scheme.minor.latin})
|
||||
}
|
||||
|
||||
/// `None` when the key is absent. A present face must be a non-blank string.
|
||||
fn face_param(key: &str, v: Option<&Value>) -> Result<Option<String>> {
|
||||
match v {
|
||||
None => Ok(None),
|
||||
Some(Value::String(s)) => {
|
||||
let s = s.trim();
|
||||
if s.is_empty() { Err(bad("design.fonts", format!("`{key}` is empty"))) } else { Ok(Some(s.to_string())) }
|
||||
}
|
||||
Some(_) => Err(bad("design.fonts", format!("`{key}` must be a string"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// A builtin `name` is applied first, then `major` / `minor` replace only the latin faces (East
|
||||
/// Asian, complex-script and per-script slots stay). The stored name is the given string when
|
||||
/// faces are overlaid or the name is not a builtin; a name-only builtin keeps its own name.
|
||||
/// Faces without a name are a custom scheme: an omitted `minor` copies `major`.
|
||||
fn next_font_scheme(
|
||||
current: &deckcraft_model::theme::FontScheme,
|
||||
given: Option<&str>,
|
||||
major: Option<&str>,
|
||||
minor: Option<&str>,
|
||||
) -> Result<deckcraft_model::theme::FontScheme> {
|
||||
let mut next = current.clone();
|
||||
if let Some(name) = given {
|
||||
let replaced = if let Some(builtin) = builtin_font_schemes().into_iter().find(|f| f.name.eq_ignore_ascii_case(name)) {
|
||||
next = builtin;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if !replaced && major.is_none() && minor.is_none() {
|
||||
return Err(bad("design.fonts", format!("no font scheme `{name}`")));
|
||||
}
|
||||
if major.is_some() || minor.is_some() || !replaced {
|
||||
next.name = name.to_string();
|
||||
}
|
||||
}
|
||||
if given.is_none() {
|
||||
if let Some(face) = major {
|
||||
next.major.latin = face.to_string();
|
||||
if minor.is_none() {
|
||||
next.minor.latin = face.to_string();
|
||||
}
|
||||
}
|
||||
if let Some(face) = minor {
|
||||
next.minor.latin = face.to_string();
|
||||
}
|
||||
next.name = "Custom".into();
|
||||
} else {
|
||||
if let Some(face) = major {
|
||||
next.major.latin = face.to_string();
|
||||
}
|
||||
if let Some(face) = minor {
|
||||
next.minor.latin = face.to_string();
|
||||
}
|
||||
}
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
pub(super) fn effects(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("design.effects", "missing `name`"))?;
|
||||
let scheme = builtin_format_schemes()
|
||||
.into_iter()
|
||||
.find(|f| f.name.eq_ignore_ascii_case(name))
|
||||
.ok_or_else(|| bad("design.effects", format!("no effect scheme `{name}`")))?;
|
||||
let stored = scheme.name.clone();
|
||||
let same = {
|
||||
let st = s.doc()?;
|
||||
let mi = master_index(&st.doc, &st.selection);
|
||||
st.doc.masters.get(mi).is_some_and(|m| m.theme.format == scheme)
|
||||
};
|
||||
if same {
|
||||
return Ok(json!({"name": stored}));
|
||||
}
|
||||
s.edit(|doc, sel| {
|
||||
let mi = master_index(doc, sel);
|
||||
Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("design.effects", "no master"))?).theme.format = scheme;
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"name": stored}))
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
use super::{
|
||||
Arc, Fill, NO_ID_SPACE, PhType, Result, Run, RunKind, Session, Shape, ShapeId, Size, Target, TextBody, Value, bad, bool_param, json,
|
||||
master_index, ok, opt_usize_param, rect_param, str_param, usize_param,
|
||||
};
|
||||
|
||||
pub(super) fn master_view(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let mi = usize_param(p, "master").unwrap_or_else(|| master_index(&st.doc, &st.selection));
|
||||
// A master thumbnail sends `{master}` and means the master itself. `{master, layout}` selects
|
||||
// that layout. With neither key, follow the current slide's layout.
|
||||
let layout = if p.get("master").is_some() && p.get("layout").is_none() {
|
||||
None
|
||||
} else {
|
||||
usize_param(p, "layout")
|
||||
.or_else(|| st.current_slide().and_then(|sl| st.doc.masters.get(mi).and_then(|m| m.layouts.iter().position(|l| l.id == sl.layout))))
|
||||
};
|
||||
let target = match layout {
|
||||
Some(l) => Target::Layout { master: mi, layout: l },
|
||||
None => Target::Master { master: mi },
|
||||
};
|
||||
s.select(|_, sel| {
|
||||
sel.target = target;
|
||||
sel.shapes.clear();
|
||||
sel.text = None;
|
||||
})?;
|
||||
Ok(serde_json::to_value(target).unwrap_or_default())
|
||||
}
|
||||
|
||||
pub(super) fn in_handout(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.active().is_some_and(|d| d.selection.target == Target::Handout) { Ok(()) } else { Err("open View ▸ Handout Master first".into()) }
|
||||
}
|
||||
|
||||
/// The four handout-master placeholders PowerPoint offers, in a page of `size`.
|
||||
pub fn handout_placeholder(kind: PhType, size: Size, id: ShapeId) -> Shape {
|
||||
let (w, h) = (size.width, size.height);
|
||||
let (name, idx, x, y) = match kind {
|
||||
PhType::Header => ("Header Placeholder", 0, 0.0, 0.0),
|
||||
PhType::Date => ("Date Placeholder", 1, w * 0.57, 0.0),
|
||||
PhType::Footer => ("Footer Placeholder", 2, 0.0, h * 0.95),
|
||||
_ => ("Slide Number Placeholder", 3, w * 0.57, h * 0.95),
|
||||
};
|
||||
let mut text = TextBody::default();
|
||||
if let Some(p) = text.paragraphs.first_mut()
|
||||
&& matches!(kind, PhType::Date | PhType::SlideNum)
|
||||
{
|
||||
p.props.align = Some(deckcraft_model::text::Align::Right);
|
||||
}
|
||||
Shape {
|
||||
id,
|
||||
name: format!("{name} {}", idx + 1),
|
||||
xfrm: Some(deckcraft_geom::Xfrm::new(x, y, w * 0.43, h * 0.05)),
|
||||
ph: Some(deckcraft_model::Placeholder { kind, idx, size: Some("quarter".into()), ..Default::default() }),
|
||||
text: Some(text),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
const HANDOUT_KINDS: [(&str, PhType); 4] =
|
||||
[("header", PhType::Header), ("date", PhType::Date), ("footer", PhType::Footer), ("pageNumber", PhType::SlideNum)];
|
||||
|
||||
pub(super) fn handout_view(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let created = s.doc()?.doc.handout_master.is_none();
|
||||
if created {
|
||||
s.edit(|doc, _| {
|
||||
let mut m = deckcraft_model::Master {
|
||||
name: "Handout Master".into(),
|
||||
theme: doc.masters.first().map(|m| m.theme.clone()).unwrap_or_default(),
|
||||
color_map: doc.masters.first().map(|m| m.color_map.clone()).unwrap_or_default(),
|
||||
..Default::default()
|
||||
};
|
||||
let size = doc.notes_size;
|
||||
for (_, k) in HANDOUT_KINDS {
|
||||
let id = ShapeId(doc.alloc_id().ok_or_else(|| bad("view.handoutMaster", NO_ID_SPACE))?);
|
||||
m.shapes.push(handout_placeholder(k, size, id));
|
||||
}
|
||||
doc.handout_master = Some(Arc::new(m));
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
s.select(|_, sel| {
|
||||
sel.target = Target::Handout;
|
||||
sel.shapes.clear();
|
||||
sel.text = None;
|
||||
})?;
|
||||
Ok(json!({"created": created}))
|
||||
}
|
||||
|
||||
pub(super) fn handout_placeholders(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.edit(|doc, _| {
|
||||
let size = doc.notes_size;
|
||||
let mut ids = vec![];
|
||||
for (key, _) in HANDOUT_KINDS {
|
||||
let id = match p.get(key).and_then(Value::as_bool) {
|
||||
Some(_) => Some(ShapeId(doc.alloc_id().ok_or_else(|| bad("handout.placeholders", NO_ID_SPACE))?)),
|
||||
None => None,
|
||||
};
|
||||
ids.push(id);
|
||||
}
|
||||
let m = Arc::make_mut(doc.handout_master.as_mut().ok_or_else(|| bad("handout.placeholders", "no handout master"))?);
|
||||
for ((key, kind), id) in HANDOUT_KINDS.into_iter().zip(ids) {
|
||||
let (Some(on), Some(id)) = (p.get(key).and_then(Value::as_bool), id) else { continue };
|
||||
let has = m.shapes.iter().any(|x| x.ph_type() == Some(kind));
|
||||
if on && !has {
|
||||
m.shapes.push(handout_placeholder(kind, size, id));
|
||||
} else if !on {
|
||||
m.shapes.retain(|x| x.ph_type() != Some(kind));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
let m = s.doc()?.doc.handout_master.clone();
|
||||
let shown = |k: PhType| m.as_ref().is_some_and(|m| m.shapes.iter().any(|x| x.ph_type() == Some(k)));
|
||||
Ok(json!({"header": shown(PhType::Header), "date": shown(PhType::Date), "footer": shown(PhType::Footer), "pageNumber": shown(PhType::SlideNum)}))
|
||||
}
|
||||
|
||||
pub(super) fn in_notes_master(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.active().is_some_and(|d| d.selection.target == Target::NotesMaster) { Ok(()) } else { Err("open View ▸ Notes Master first".into()) }
|
||||
}
|
||||
|
||||
const NOTES_KINDS: [(&str, PhType); 6] = [
|
||||
("header", PhType::Header),
|
||||
("date", PhType::Date),
|
||||
("slideImage", PhType::SlideImage),
|
||||
("body", PhType::Body),
|
||||
("footer", PhType::Footer),
|
||||
("pageNumber", PhType::SlideNum),
|
||||
];
|
||||
|
||||
/// A notes-master placeholder in a page of `size`, laid out as PowerPoint's default notes master.
|
||||
pub fn notes_placeholder(kind: PhType, size: Size, id: ShapeId) -> Shape {
|
||||
let (w, h) = (size.width, size.height);
|
||||
match kind {
|
||||
PhType::SlideImage => Shape {
|
||||
id,
|
||||
name: "Slide Image Placeholder 3".into(),
|
||||
xfrm: Some(deckcraft_geom::Xfrm::new(w * 0.125, h * 0.125, w * 0.75, h * 0.375)),
|
||||
ph: Some(deckcraft_model::Placeholder { kind, idx: 2, ..Default::default() }),
|
||||
fill: Some(Fill::None),
|
||||
line: Some(deckcraft_model::Line::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::BLACK), 1.0)),
|
||||
..Default::default()
|
||||
},
|
||||
PhType::Body => {
|
||||
let mut text = TextBody::default();
|
||||
text.paragraphs = vec![deckcraft_model::text::Paragraph::new("Click to edit Master text styles")];
|
||||
Shape {
|
||||
id,
|
||||
name: "Notes Placeholder 4".into(),
|
||||
xfrm: Some(deckcraft_geom::Xfrm::new(w * 0.1, h * 0.53, w * 0.8, h * 0.36)),
|
||||
ph: Some(deckcraft_model::Placeholder { kind, idx: 3, size: Some("quarter".into()), ..Default::default() }),
|
||||
text: Some(text),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let mut s = handout_placeholder(kind, size, id);
|
||||
if let Some(ph) = s.ph.as_mut() {
|
||||
ph.idx = match kind {
|
||||
PhType::Header => 0,
|
||||
PhType::Date => 1,
|
||||
PhType::Footer => 4,
|
||||
_ => 5,
|
||||
};
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn notes_view(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let created = s.doc()?.doc.notes_master.as_ref().is_none_or(|m| m.shapes.is_empty());
|
||||
if created {
|
||||
s.edit(|doc, _| {
|
||||
let mut m = doc.notes_master.as_deref().cloned().unwrap_or_else(|| deckcraft_model::Master {
|
||||
name: "Notes Master".into(),
|
||||
theme: doc.masters.first().map(|m| m.theme.clone()).unwrap_or_default(),
|
||||
color_map: doc.masters.first().map(|m| m.color_map.clone()).unwrap_or_default(),
|
||||
..Default::default()
|
||||
});
|
||||
let size = doc.notes_size;
|
||||
for (_, k) in NOTES_KINDS {
|
||||
let id = ShapeId(doc.alloc_id().ok_or_else(|| bad("view.notesMaster", NO_ID_SPACE))?);
|
||||
m.shapes.push(notes_placeholder(k, size, id));
|
||||
}
|
||||
doc.notes_master = Some(Arc::new(m));
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
s.select(|_, sel| {
|
||||
sel.target = Target::NotesMaster;
|
||||
sel.shapes.clear();
|
||||
sel.text = None;
|
||||
})?;
|
||||
Ok(json!({"created": created}))
|
||||
}
|
||||
|
||||
pub(super) fn notes_placeholders(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.edit(|doc, _| {
|
||||
let size = doc.notes_size;
|
||||
let ids: Vec<ShapeId> =
|
||||
NOTES_KINDS.iter().map(|_| doc.alloc_id().map(ShapeId).ok_or_else(|| bad("notes.placeholders", NO_ID_SPACE))).collect::<Result<_>>()?;
|
||||
let m = Arc::make_mut(doc.notes_master.as_mut().ok_or_else(|| bad("notes.placeholders", "no notes master"))?);
|
||||
for ((key, kind), id) in NOTES_KINDS.into_iter().zip(ids) {
|
||||
let Some(on) = p.get(key).and_then(Value::as_bool) else { continue };
|
||||
let has = m.shapes.iter().any(|x| x.ph_type() == Some(kind));
|
||||
if on && !has {
|
||||
m.shapes.push(notes_placeholder(kind, size, id));
|
||||
} else if !on {
|
||||
m.shapes.retain(|x| x.ph_type() != Some(kind));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
let m = s.doc()?.doc.notes_master.clone();
|
||||
let mut out = serde_json::Map::new();
|
||||
for (key, kind) in NOTES_KINDS {
|
||||
out.insert(key.into(), json!(m.as_ref().is_some_and(|m| m.shapes.iter().any(|x| x.ph_type() == Some(kind)))));
|
||||
}
|
||||
Ok(Value::Object(out))
|
||||
}
|
||||
|
||||
pub(super) fn close_master(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
s.select(|_, sel| {
|
||||
sel.target = Target::Slides;
|
||||
sel.shapes.clear();
|
||||
sel.text = None;
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
pub(super) fn insert_layout(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").unwrap_or("Custom Layout").to_string();
|
||||
s.edit(|doc, sel| {
|
||||
let mi = master_index(doc, sel);
|
||||
let id = deckcraft_model::LayoutId(doc.alloc_id().ok_or_else(|| bad("master.insertLayout", NO_ID_SPACE))?);
|
||||
let tid = ShapeId(doc.alloc_id().ok_or_else(|| bad("master.insertLayout", NO_ID_SPACE))?);
|
||||
let size = doc.slide_size;
|
||||
let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("master.insertLayout", "no master"))?);
|
||||
let title = m.shapes.iter().find(|x| x.ph_type() == Some(PhType::Title)).cloned().map(|mut t| {
|
||||
t.id = tid;
|
||||
t.text = Some(TextBody::default());
|
||||
t
|
||||
});
|
||||
let _ = size;
|
||||
let at = match sel.target {
|
||||
Target::Layout { layout, .. } => layout + 1,
|
||||
_ => m.layouts.len(),
|
||||
};
|
||||
m.layouts.insert(
|
||||
at.min(m.layouts.len()),
|
||||
deckcraft_model::Layout {
|
||||
id,
|
||||
name,
|
||||
kind: deckcraft_model::LayoutType::Custom,
|
||||
shapes: title.into_iter().collect(),
|
||||
background: None,
|
||||
show_master_shapes: true,
|
||||
preserve: true,
|
||||
color_map: None,
|
||||
raw_ext: None,
|
||||
},
|
||||
);
|
||||
sel.target = Target::Layout { master: mi, layout: at.min(m.layouts.len().saturating_sub(1)) };
|
||||
Ok(json!({"id": id}))
|
||||
})
|
||||
}
|
||||
|
||||
fn layout_target(s: &Session, p: &Value, cmd: &str) -> Result<(usize, usize)> {
|
||||
let st = s.doc()?;
|
||||
let (m, l) = match st.selection.target {
|
||||
Target::Layout { master, layout } => (master, layout),
|
||||
_ => (master_index(&st.doc, &st.selection), 0),
|
||||
};
|
||||
Ok((opt_usize_param(p, "master", cmd)?.unwrap_or(m), opt_usize_param(p, "layout", cmd)?.unwrap_or(l)))
|
||||
}
|
||||
|
||||
pub(super) fn rename_layout(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (mi, li) = layout_target(s, p, "master.renameLayout")?;
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("master.renameLayout", "missing `name`"))?.to_string();
|
||||
s.edit(|doc, _| {
|
||||
let l =
|
||||
doc.masters.get_mut(mi).and_then(|m| Arc::make_mut(m).layouts.get_mut(li)).ok_or_else(|| bad("master.renameLayout", "no such layout"))?;
|
||||
l.name = name;
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
pub(super) fn delete_layout(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (mi, li) = layout_target(s, p, "master.deleteLayout")?;
|
||||
s.edit(|doc, sel| {
|
||||
let id = doc.masters.get(mi).and_then(|m| m.layouts.get(li)).map(|l| l.id).ok_or_else(|| bad("master.deleteLayout", "no such layout"))?;
|
||||
if doc.slides.iter().any(|x| x.layout == id) {
|
||||
return Err(bad("master.deleteLayout", "slides use this layout"));
|
||||
}
|
||||
let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("master.deleteLayout", "no master"))?);
|
||||
if m.layouts.len() <= 1 {
|
||||
return Err(bad("master.deleteLayout", "a master needs at least one layout"));
|
||||
}
|
||||
m.layouts.remove(li);
|
||||
sel.target = Target::Layout { master: mi, layout: li.min(m.layouts.len().saturating_sub(1)) };
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
pub(super) fn insert_placeholder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let kind = match str_param(p, "kind").unwrap_or("content") {
|
||||
"text" => PhType::Body,
|
||||
"picture" => PhType::Picture,
|
||||
"chart" => PhType::Chart,
|
||||
"table" => PhType::Table,
|
||||
"media" => PhType::Media,
|
||||
_ => PhType::Obj,
|
||||
};
|
||||
if !matches!(s.doc()?.selection.target, Target::Layout { .. }) {
|
||||
return Err(bad("master.insertPlaceholder", "select a layout in Slide Master view"));
|
||||
}
|
||||
let size = s.doc()?.doc.slide_size;
|
||||
let rect = rect_param(p, "rect").unwrap_or(deckcraft_geom::Xfrm::new(size.width * 0.25, size.height * 0.3, size.width * 0.5, size.height * 0.4));
|
||||
let idx = s.doc()?.shapes().iter().filter_map(|x| x.ph.as_ref().map(|p| p.idx)).max().unwrap_or(0) + 1;
|
||||
let shape = Shape {
|
||||
xfrm: Some(rect),
|
||||
ph: Some(deckcraft_model::Placeholder { kind, idx, ..Default::default() }),
|
||||
text: Some(TextBody::default()),
|
||||
..Default::default()
|
||||
};
|
||||
let id = crate::cmd::insert::add_shape(s, "master.insertPlaceholder", shape, true)?;
|
||||
Ok(json!({"id": id}))
|
||||
}
|
||||
|
||||
pub(super) fn preserve(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let (on_master, mi, li, current) = match st.selection.target {
|
||||
Target::Master { master } => {
|
||||
let on = st.doc.masters.get(master).map(|m| m.preserve).ok_or_else(|| bad("master.preserve", "no such master"))?;
|
||||
(true, master, 0, on)
|
||||
}
|
||||
Target::Layout { master, layout } => {
|
||||
let on = st
|
||||
.doc
|
||||
.masters
|
||||
.get(master)
|
||||
.and_then(|m| m.layouts.get(layout))
|
||||
.map(|l| l.preserve)
|
||||
.ok_or_else(|| bad("master.preserve", "no such layout"))?;
|
||||
(false, master, layout, on)
|
||||
}
|
||||
Target::Slides | Target::Handout | Target::NotesMaster => return Err(bad("master.preserve", "open Slide Master view")),
|
||||
};
|
||||
let next = bool_param(p, "preserve").unwrap_or(!current);
|
||||
if next == current {
|
||||
return Ok(json!({"preserve": next}));
|
||||
}
|
||||
s.edit(|doc, _| {
|
||||
if on_master {
|
||||
let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("master.preserve", "no such master"))?);
|
||||
m.preserve = next;
|
||||
} else {
|
||||
let l =
|
||||
doc.masters.get_mut(mi).and_then(|m| Arc::make_mut(m).layouts.get_mut(li)).ok_or_else(|| bad("master.preserve", "no such layout"))?;
|
||||
l.preserve = next;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"preserve": next}))
|
||||
}
|
||||
|
||||
pub(super) fn placeholders(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let title = bool_param(p, "title");
|
||||
let footers = bool_param(p, "footers");
|
||||
if title.is_none() && footers.is_none() {
|
||||
return Err(bad("master.placeholders", "give title or footers"));
|
||||
}
|
||||
let st = s.doc()?;
|
||||
if matches!(st.selection.target, Target::Slides) {
|
||||
return Err(bad("master.placeholders", "open Slide Master view"));
|
||||
}
|
||||
let has_title = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_title));
|
||||
let has_footers = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_footer_kind));
|
||||
let title_changes = title.is_some_and(|want| want != has_title);
|
||||
let footer_changes = footers.is_some_and(|want| want != has_footers);
|
||||
if !title_changes && !footer_changes {
|
||||
return Ok(json!({"title": has_title, "footers": has_footers}));
|
||||
}
|
||||
let mut add = Vec::new();
|
||||
if title == Some(true) && !has_title {
|
||||
add.push(PhType::Title);
|
||||
}
|
||||
if footers == Some(true) && !has_footers {
|
||||
add.extend([PhType::Date, PhType::Footer, PhType::SlideNum]);
|
||||
}
|
||||
let size = st.doc.slide_size;
|
||||
s.edit(|doc, sel| {
|
||||
let mut ids = Vec::with_capacity(add.len());
|
||||
for _ in 0..add.len() {
|
||||
ids.push(ShapeId(doc.alloc_id().ok_or_else(|| bad("master.placeholders", NO_ID_SPACE))?));
|
||||
}
|
||||
let prototypes = match sel.target {
|
||||
Target::Master { master } | Target::Layout { master, .. } => doc.masters.get(master).map(|m| m.shapes.clone()).unwrap_or_default(),
|
||||
Target::Slides => Vec::new(),
|
||||
Target::Handout | Target::NotesMaster => return Err(bad("master.placeholders", "open Slide Master view")),
|
||||
};
|
||||
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad("master.placeholders", "open Slide Master view"))?;
|
||||
if title == Some(false) {
|
||||
shapes.retain(|sh| !sh.ph_type().is_some_and(PhType::is_title));
|
||||
}
|
||||
if footers == Some(false) {
|
||||
shapes.retain(|sh| !sh.ph_type().is_some_and(PhType::is_footer_kind));
|
||||
}
|
||||
for (kind, id) in add.into_iter().zip(ids) {
|
||||
let idx = free_ph_idx(shapes, preferred_ph_idx(kind));
|
||||
let proto = prototypes.iter().find(|sh| match kind {
|
||||
PhType::Title => sh.ph_type().is_some_and(PhType::is_title),
|
||||
other => sh.ph_type() == Some(other),
|
||||
});
|
||||
let shape = match proto {
|
||||
Some(src) => retarget_placeholder(src.clone(), id, idx),
|
||||
None => synthesized_placeholder(kind, id, idx, size),
|
||||
};
|
||||
shapes.push(shape);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
let st = s.doc()?;
|
||||
let title = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_title));
|
||||
let footers = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_footer_kind));
|
||||
Ok(json!({"title": title, "footers": footers}))
|
||||
}
|
||||
|
||||
fn preferred_ph_idx(kind: PhType) -> u32 {
|
||||
match kind {
|
||||
PhType::Title | PhType::CtrTitle => 0,
|
||||
PhType::Date => 10,
|
||||
PhType::Footer => 11,
|
||||
PhType::SlideNum => 12,
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn free_ph_idx(shapes: &[Shape], preferred: u32) -> u32 {
|
||||
let taken = |idx: u32| shapes.iter().any(|sh| sh.ph.as_ref().is_some_and(|p| p.idx == idx));
|
||||
if !taken(preferred) { preferred } else { shapes.iter().filter_map(|sh| sh.ph.as_ref().map(|p| p.idx)).max().unwrap_or(0).saturating_add(1) }
|
||||
}
|
||||
|
||||
/// A copy of a master placeholder, with a new id and index. Prompt text is cleared; a slide
|
||||
/// number keeps its `‹#›` field.
|
||||
fn retarget_placeholder(mut sh: Shape, id: ShapeId, idx: u32) -> Shape {
|
||||
sh.id = id;
|
||||
if let Some(ph) = sh.ph.as_mut() {
|
||||
ph.idx = idx;
|
||||
}
|
||||
let slide_num = sh.ph_type() == Some(PhType::SlideNum);
|
||||
if slide_num {
|
||||
let paras = vec![slide_number_paragraph()];
|
||||
if let Some(text) = sh.text.as_mut() {
|
||||
text.paragraphs = paras;
|
||||
} else {
|
||||
sh.text = Some(TextBody { paragraphs: paras, ..Default::default() });
|
||||
}
|
||||
} else if let Some(text) = sh.text.as_mut() {
|
||||
text.paragraphs.clear();
|
||||
}
|
||||
sh
|
||||
}
|
||||
|
||||
fn synthesized_placeholder(kind: PhType, id: ShapeId, idx: u32, size: Size) -> Shape {
|
||||
let (name, text) = match kind {
|
||||
PhType::Date => ("Date Placeholder", TextBody::default()),
|
||||
PhType::Footer => ("Footer Placeholder", TextBody::default()),
|
||||
PhType::SlideNum => ("Slide Number Placeholder", TextBody { paragraphs: vec![slide_number_paragraph()], ..Default::default() }),
|
||||
_ => ("Title", TextBody::default()),
|
||||
};
|
||||
Shape {
|
||||
id,
|
||||
name: name.into(),
|
||||
xfrm: Some(placeholder_rect(kind, size)),
|
||||
ph: Some(deckcraft_model::Placeholder { kind, idx, ..Default::default() }),
|
||||
text: Some(text),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Standard placeholder boxes at 960×540, scaled to the slide size.
|
||||
fn placeholder_rect(kind: PhType, size: Size) -> deckcraft_geom::Xfrm {
|
||||
let sx = size.width / 960.0;
|
||||
let sy = size.height / 540.0;
|
||||
let (x, y, w, h) = match kind {
|
||||
PhType::Date => (60.0, 500.0, 216.0, 28.0),
|
||||
PhType::Footer => (318.0, 500.0, 324.0, 28.0),
|
||||
PhType::SlideNum => (684.0, 500.0, 216.0, 28.0),
|
||||
_ => (60.0, 29.0, 840.0, 104.0),
|
||||
};
|
||||
deckcraft_geom::Xfrm::new(x * sx, y * sy, w * sx, h * sy)
|
||||
}
|
||||
|
||||
fn slide_number_paragraph() -> deckcraft_model::Paragraph {
|
||||
deckcraft_model::Paragraph {
|
||||
runs: vec![Run { text: "‹#›".into(), props: Default::default(), kind: RunKind::Field { field: "slidenum".into() } }],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
use super::{
|
||||
Arc, BTreeSet, Background, Fill, NO_ID_SPACE, Presentation, Result, Session, Target, Value, bad, bool_or, bool_param, color_param, color_value,
|
||||
f64_param, ok, usize_param, with_param,
|
||||
};
|
||||
|
||||
pub(super) fn background(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if bool_or(p, "copy", false) {
|
||||
return copy_slide_background(s);
|
||||
}
|
||||
let props = p.get("pictureProps").filter(|v| v.is_object()).cloned();
|
||||
let transparency = f64_param(p, "transparency");
|
||||
let reset = bool_or(p, "reset", false);
|
||||
let color = color_param(p, "color");
|
||||
let style = usize_param(p, "style");
|
||||
let op = if reset {
|
||||
BgOp::Set(None)
|
||||
} else if let Some(mut c) = color {
|
||||
if let Some(t) = transparency {
|
||||
set_solid_transparency(&mut c, t);
|
||||
}
|
||||
BgOp::Set(Some(Background::Fill { fill: Fill::solid(c) }))
|
||||
} else if let Some(i) = style {
|
||||
// Background styles: 3 theme bg fills × 4 colours (bg1, bg2, tx1, tx2).
|
||||
let slots =
|
||||
[deckcraft_color::SchemeSlot::Bg1, deckcraft_color::SchemeSlot::Bg2, deckcraft_color::SchemeSlot::Tx2, deckcraft_color::SchemeSlot::Tx1];
|
||||
let i = i.clamp(1, 12) - 1;
|
||||
BgOp::Set(Some(Background::Ref {
|
||||
idx: 1001 + (i / 4) as u32,
|
||||
color: deckcraft_model::ColorRef::scheme(slots.get(i % 4).copied().unwrap_or(deckcraft_color::SchemeSlot::Bg1)),
|
||||
}))
|
||||
} else if let Some(g) = p.get("gradient") {
|
||||
let mut g = crate::cmd::shape::gradient_param(g).ok_or_else(|| bad("design.background", "gradient needs two or more `stops`"))?;
|
||||
g.rotate_with_shape = false;
|
||||
BgOp::Set(Some(Background::Fill { fill: Fill::Gradient(g) }))
|
||||
} else if let Some(pt) = p.get("pattern") {
|
||||
BgOp::Set(Some(Background::Fill {
|
||||
fill: Fill::Pattern(deckcraft_model::style::PatternFill {
|
||||
preset: pt.get("preset").and_then(Value::as_str).unwrap_or("pct50").to_string(),
|
||||
fg: pt.get("fg").and_then(color_value).unwrap_or(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent1)),
|
||||
bg: pt.get("bg").and_then(color_value).unwrap_or(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Bg1)),
|
||||
}),
|
||||
}))
|
||||
} else if p.get("picture").is_some() {
|
||||
let (name, bytes) =
|
||||
crate::cmd::insert::media_bytes(&with_param(p, "data", p.get("picture").cloned().unwrap_or_default()), "design.background")?;
|
||||
let ct = crate::cmd::insert::content_type(&name, &bytes);
|
||||
let media = s.edit(|doc, _| doc.add_media(&name, ct, bytes).ok_or_else(|| bad("design.background", NO_ID_SPACE)))?;
|
||||
let mut pf = deckcraft_model::style::PictureFill { media, ..Default::default() };
|
||||
if let Some(pp) = &props {
|
||||
apply_picture_props(&mut pf, pp);
|
||||
}
|
||||
if let Some(t) = transparency {
|
||||
set_picture_transparency(&mut pf, t);
|
||||
}
|
||||
BgOp::Set(Some(Background::Fill { fill: Fill::Picture(pf) }))
|
||||
} else if props.is_some() || transparency.is_some() {
|
||||
BgOp::Adjust
|
||||
} else {
|
||||
return Err(bad(
|
||||
"design.background",
|
||||
"give `color`, `gradient`, `pattern`, `picture`, `style`, `reset`, `pictureProps`, `transparency` or `copy`",
|
||||
));
|
||||
};
|
||||
let all = bool_or(p, "all", false);
|
||||
let index = usize_param(p, "index");
|
||||
if matches!(op, BgOp::Adjust) {
|
||||
let next = {
|
||||
let st = s.doc()?;
|
||||
let current = background_of(&st.doc, &st.selection, index);
|
||||
let picture = matches!(current, Some(Background::Fill { fill: Fill::Picture(_) }));
|
||||
let solid = matches!(current, Some(Background::Fill { fill: Fill::Solid { .. } }));
|
||||
if props.is_some() && !picture {
|
||||
return Err(bad("design.background", "picture options need a picture background"));
|
||||
}
|
||||
if transparency.is_some() && !picture && !solid {
|
||||
return Err(bad("design.background", "transparency needs a solid or picture background"));
|
||||
}
|
||||
let mut next = current.clone();
|
||||
if let Some(bg) = next.as_mut() {
|
||||
adjust_background(bg, props.as_ref(), transparency);
|
||||
}
|
||||
if next == current { None } else { Some(next) }
|
||||
};
|
||||
let Some(next) = next else { return ok() };
|
||||
s.edit(|doc, sel| {
|
||||
write_background(doc, sel, index, false, next);
|
||||
Ok(())
|
||||
})?;
|
||||
return ok();
|
||||
}
|
||||
let BgOp::Set(bg) = op else { return ok() };
|
||||
let unchanged = {
|
||||
let st = s.doc()?;
|
||||
backgrounds_already(&st.doc, &st.selection, index, all, &bg)
|
||||
};
|
||||
if unchanged {
|
||||
return ok();
|
||||
}
|
||||
s.edit(|doc, sel| {
|
||||
write_background(doc, sel, index, all, bg);
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
enum BgOp {
|
||||
Set(Option<Background>),
|
||||
Adjust,
|
||||
}
|
||||
|
||||
fn background_of(doc: &Presentation, sel: &crate::Selection, index: Option<usize>) -> Option<Background> {
|
||||
match sel.target {
|
||||
Target::Master { master } => doc.masters.get(master).and_then(|m| m.background.clone()),
|
||||
Target::Layout { master, layout } => doc.masters.get(master).and_then(|m| m.layouts.get(layout)).and_then(|l| l.background.clone()),
|
||||
t @ (Target::Handout | Target::NotesMaster) => crate::page_master(doc, t).and_then(|m| m.background.clone()),
|
||||
Target::Slides => doc.slides.get(index.unwrap_or(sel.slide)).and_then(|sl| sl.background.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn used_master_indices(doc: &Presentation) -> BTreeSet<usize> {
|
||||
let fallback = (!doc.masters.is_empty()).then_some(0);
|
||||
doc.slides.iter().filter_map(|sl| doc.masters.iter().position(|m| m.layout(sl.layout).is_some()).or(fallback)).collect()
|
||||
}
|
||||
|
||||
fn backgrounds_already(doc: &Presentation, sel: &crate::Selection, index: Option<usize>, all: bool, bg: &Option<Background>) -> bool {
|
||||
match sel.target {
|
||||
Target::Master { master } => doc.masters.get(master).is_some_and(|m| m.background == *bg),
|
||||
Target::Layout { master, layout } => doc.masters.get(master).and_then(|m| m.layouts.get(layout)).is_some_and(|l| l.background == *bg),
|
||||
t @ (Target::Handout | Target::NotesMaster) => crate::page_master(doc, t).is_some_and(|m| m.background == *bg),
|
||||
Target::Slides => {
|
||||
let list: Vec<usize> = if all { (0..doc.slides.len()).collect() } else { vec![index.unwrap_or(sel.slide)] };
|
||||
let slides_match = !list.is_empty() && list.iter().all(|i| doc.slides.get(*i).is_some_and(|sl| sl.background == *bg));
|
||||
if all && bg.is_some() {
|
||||
let masters = used_master_indices(doc);
|
||||
let masters_match = !masters.is_empty() && masters.iter().all(|&idx| doc.masters.get(idx).is_some_and(|m| m.background == *bg));
|
||||
let layouts_match = !masters.is_empty()
|
||||
&& masters.iter().all(|&idx| doc.masters.get(idx).is_some_and(|m| m.layouts.iter().all(|l| l.background.is_none())));
|
||||
slides_match && masters_match && layouts_match
|
||||
} else {
|
||||
slides_match
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_background(doc: &mut Presentation, sel: &crate::Selection, index: Option<usize>, all: bool, bg: Option<Background>) {
|
||||
match sel.target {
|
||||
Target::Master { master } => {
|
||||
if let Some(m) = doc.masters.get_mut(master) {
|
||||
Arc::make_mut(m).background = bg;
|
||||
}
|
||||
}
|
||||
Target::Layout { master, layout } => {
|
||||
if let Some(l) = doc.masters.get_mut(master).and_then(|m| Arc::make_mut(m).layouts.get_mut(layout)) {
|
||||
l.background = bg;
|
||||
}
|
||||
}
|
||||
t @ (Target::Handout | Target::NotesMaster) => {
|
||||
if let Some(m) = crate::page_master_mut(doc, t) {
|
||||
Arc::make_mut(m).background = bg;
|
||||
}
|
||||
}
|
||||
Target::Slides => {
|
||||
if all && bg.is_some() {
|
||||
for idx in used_master_indices(doc) {
|
||||
if let Some(m) = doc.masters.get_mut(idx) {
|
||||
let m = Arc::make_mut(m);
|
||||
m.background = bg.clone();
|
||||
for l in &mut m.layouts {
|
||||
l.background = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let list: Vec<usize> = if all { (0..doc.slides.len()).collect() } else { vec![index.unwrap_or(sel.slide)] };
|
||||
for i in list {
|
||||
if let Some(sl) = doc.slides.get_mut(i) {
|
||||
Arc::make_mut(sl).background = bg.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_slide_background(s: &mut Session) -> Result<Value> {
|
||||
let bg = {
|
||||
let st = s.doc()?;
|
||||
if !matches!(st.selection.target, Target::Slides) {
|
||||
return Err(bad("design.background", "copy applies to slides"));
|
||||
}
|
||||
let bg = st.doc.slides.get(st.selection.slide).and_then(|sl| sl.background.clone());
|
||||
let slides_match = st.doc.slides.iter().all(|sl| sl.background == bg);
|
||||
let masters_match = if bg.is_some() {
|
||||
let masters = used_master_indices(&st.doc);
|
||||
!masters.is_empty() && masters.iter().all(|&idx| st.doc.masters.get(idx).is_some_and(|m| m.background == bg))
|
||||
} else {
|
||||
true
|
||||
};
|
||||
if slides_match && masters_match {
|
||||
return ok();
|
||||
}
|
||||
bg
|
||||
};
|
||||
s.edit(|doc, sel| {
|
||||
write_background(doc, sel, None, true, bg);
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
fn adjust_background(bg: &mut Background, props: Option<&Value>, transparency: Option<f64>) {
|
||||
match bg {
|
||||
Background::Fill { fill: Fill::Picture(pf) } => {
|
||||
if let Some(pp) = props {
|
||||
apply_picture_props(pf, pp);
|
||||
}
|
||||
if let Some(t) = transparency {
|
||||
set_picture_transparency(pf, t);
|
||||
}
|
||||
}
|
||||
Background::Fill { fill: Fill::Solid { color } } => {
|
||||
if let Some(t) = transparency {
|
||||
set_solid_transparency(color, t);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_picture_transparency(pf: &mut deckcraft_model::style::PictureFill, t: f64) {
|
||||
let a = 1.0 - t.clamp(0.0, 1.0);
|
||||
pf.alpha = (a < 1.0).then_some(a);
|
||||
}
|
||||
|
||||
fn set_solid_transparency(color: &mut deckcraft_model::ColorRef, t: f64) {
|
||||
color.mods.retain(|m| !matches!(m, deckcraft_color::ColorTransform::Alpha(_)));
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
if t > 0.0 {
|
||||
color.mods.push(deckcraft_color::ColorTransform::Alpha(((1.0 - t) * 100_000.0).round() as i32));
|
||||
}
|
||||
}
|
||||
|
||||
/// Picture-fill options for Format Background (tile, offsets, scale, alignment, mirror, stretch).
|
||||
fn apply_picture_props(pf: &mut deckcraft_model::style::PictureFill, pp: &Value) {
|
||||
use deckcraft_model::style::PictureMode;
|
||||
let f = |k: &str| pp.get(k).and_then(Value::as_f64).filter(|v| v.is_finite());
|
||||
if let Some(up) = pp.get("rotateWithShape").and_then(Value::as_bool) {
|
||||
pf.upright = !up;
|
||||
}
|
||||
match pp.get("tile").and_then(Value::as_bool) {
|
||||
Some(true) if !matches!(pf.mode, PictureMode::Tile { .. }) => {
|
||||
pf.mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 1.0, sy: 1.0, flip: "none".into(), align: "tl".into() };
|
||||
}
|
||||
Some(false) if !matches!(pf.mode, PictureMode::Stretch { .. }) => pf.mode = PictureMode::default(),
|
||||
_ => {}
|
||||
}
|
||||
match &mut pf.mode {
|
||||
PictureMode::Tile { tx, ty, sx, sy, flip, align } => {
|
||||
if let Some(v) = f("offsetX") {
|
||||
*tx = v.clamp(-4000.0, 4000.0);
|
||||
}
|
||||
if let Some(v) = f("offsetY") {
|
||||
*ty = v.clamp(-4000.0, 4000.0);
|
||||
}
|
||||
if let Some(v) = f("scaleX") {
|
||||
*sx = v.clamp(0.01, 10.0);
|
||||
}
|
||||
if let Some(v) = f("scaleY") {
|
||||
*sy = v.clamp(0.01, 10.0);
|
||||
}
|
||||
if let Some(m) = pp.get("mirror").and_then(Value::as_str).filter(|m| matches!(*m, "none" | "x" | "y" | "xy")) {
|
||||
*flip = m.to_string();
|
||||
}
|
||||
if let Some(a) =
|
||||
pp.get("align").and_then(Value::as_str).filter(|a| matches!(*a, "tl" | "t" | "tr" | "l" | "ctr" | "r" | "bl" | "b" | "br"))
|
||||
{
|
||||
*align = a.to_string();
|
||||
}
|
||||
}
|
||||
PictureMode::Stretch { fill_rect } => {
|
||||
if let Some(arr) = pp.get("stretch").and_then(Value::as_array) {
|
||||
for (slot, v) in fill_rect.iter_mut().zip(arr) {
|
||||
if let Some(v) = v.as_f64().filter(|v| v.is_finite()) {
|
||||
*slot = v.clamp(-10.0, 0.99);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn hide_bg_graphics(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.edit(|doc, sel| {
|
||||
if let Some(sl) = doc.slides.get_mut(sel.slide) {
|
||||
let sl = Arc::make_mut(sl);
|
||||
sl.show_master_shapes = !bool_param(p, "hide").unwrap_or(sl.show_master_shapes);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
use super::{
|
||||
Arc, NO_ID_SPACE, PhType, Result, Run, RunKind, Session, Shape, ShapeId, Size, TextBody, Value, bad, bool_or, bool_param, defaults, f64_param,
|
||||
json, ok, str_param,
|
||||
};
|
||||
|
||||
pub(super) fn scale_shapes(shapes: &mut [Shape], sx: f64, sy: f64, k: f64, ox: f64, oy: f64) {
|
||||
for sh in shapes {
|
||||
if let Some(x) = sh.xfrm.as_mut() {
|
||||
x.x = x.x * sx + ox;
|
||||
x.y = x.y * sy + oy;
|
||||
x.w *= sx;
|
||||
x.h *= sy;
|
||||
}
|
||||
if let Some(t) = sh.text.as_mut() {
|
||||
deckcraft_model::edit::format_all(t, &|r| {
|
||||
if let Some(sz) = r.size.as_mut() {
|
||||
*sz = (*sz * k).max(1.0);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn slide_size(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let size = if let Some(pre) = str_param(p, "preset") {
|
||||
let k = pre.to_ascii_lowercase();
|
||||
let found = defaults::SLIDE_SIZES.iter().find(|(l, _, _)| l.to_ascii_lowercase().starts_with(&k) || l.to_ascii_lowercase().contains(&k));
|
||||
let (_, w, h) = found.ok_or_else(|| bad("design.slideSize", format!("unknown preset `{pre}`")))?;
|
||||
Size::new(*w, *h)
|
||||
} else {
|
||||
Size::new(
|
||||
f64_param(p, "w").ok_or_else(|| bad("design.slideSize", "missing `w`"))?.clamp(72.0, 4032.0),
|
||||
f64_param(p, "h").ok_or_else(|| bad("design.slideSize", "missing `h`"))?.clamp(72.0, 4032.0),
|
||||
)
|
||||
};
|
||||
let size = match str_param(p, "orientation") {
|
||||
Some("portrait") if size.width > size.height => Size::new(size.height, size.width),
|
||||
Some("landscape") if size.height > size.width => Size::new(size.height, size.width),
|
||||
Some("portrait" | "landscape") | None => size,
|
||||
Some(o) => return Err(bad("design.slideSize", format!("unknown orientation `{o}`"))),
|
||||
};
|
||||
let mode = str_param(p, "scale").unwrap_or("ensureFit").to_string();
|
||||
s.edit(|doc, _| {
|
||||
let old = doc.slide_size;
|
||||
let (sx, sy) = (size.width / old.width.max(1.0), size.height / old.height.max(1.0));
|
||||
let (sx, sy, k, ox, oy) = match mode.as_str() {
|
||||
"none" => (1.0, 1.0, 1.0, 0.0, 0.0),
|
||||
"maximize" => {
|
||||
let k = sx.max(sy);
|
||||
(k, k, k, (size.width - old.width * k) / 2.0, (size.height - old.height * k) / 2.0)
|
||||
}
|
||||
_ => {
|
||||
let k = sx.min(sy);
|
||||
(k, k, k, (size.width - old.width * k) / 2.0, (size.height - old.height * k) / 2.0)
|
||||
}
|
||||
};
|
||||
// Masters and layouts stretch to the new size; slides scale by mode.
|
||||
let (msx, msy) = (size.width / old.width.max(1.0), size.height / old.height.max(1.0));
|
||||
for m in &mut doc.masters {
|
||||
let m = Arc::make_mut(m);
|
||||
scale_shapes(&mut m.shapes, msx, msy, 1.0, 0.0, 0.0);
|
||||
for l in &mut m.layouts {
|
||||
scale_shapes(&mut l.shapes, msx, msy, 1.0, 0.0, 0.0);
|
||||
}
|
||||
}
|
||||
for sl in &mut doc.slides {
|
||||
scale_shapes(&mut Arc::make_mut(sl).shapes, sx, sy, k, ox, oy);
|
||||
}
|
||||
doc.slide_size = size;
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"w": size.width, "h": size.height}))
|
||||
}
|
||||
|
||||
pub(super) fn header_footer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.edit(|doc, sel| {
|
||||
let hf = &mut doc.header_footer;
|
||||
if let Some(v) = bool_param(p, "date") {
|
||||
hf.date = v;
|
||||
}
|
||||
if let Some(v) = str_param(p, "dateText") {
|
||||
hf.date_text = v.into();
|
||||
}
|
||||
if let Some(v) = str_param(p, "dateFormat") {
|
||||
// Refused rather than ignored: a format that renders nothing would leave the
|
||||
// placeholder showing its saved text for ever.
|
||||
if !deckcraft_text::datetime::DATE_KINDS.contains(&v) {
|
||||
return Err(bad("design.headerFooter", "dateFormat: one of datetime1…datetime13"));
|
||||
}
|
||||
hf.date_format = v.into();
|
||||
}
|
||||
if let Some(v) = bool_param(p, "slideNumber") {
|
||||
hf.slide_number = v;
|
||||
}
|
||||
if let Some(v) = bool_param(p, "footer") {
|
||||
hf.footer = v;
|
||||
}
|
||||
if let Some(v) = str_param(p, "footerText") {
|
||||
hf.footer_text = v.into();
|
||||
hf.footer = hf.footer || !v.is_empty();
|
||||
}
|
||||
if let Some(v) = bool_param(p, "hideOnTitle") {
|
||||
hf.hide_on_title = v;
|
||||
}
|
||||
let hf = hf.clone();
|
||||
let date_given = str_param(p, "dateText").is_some();
|
||||
let format_given = str_param(p, "dateFormat").is_some();
|
||||
let all = bool_or(p, "all", true);
|
||||
let list: Vec<usize> = if all { (0..doc.slides.len()).collect() } else { vec![sel.slide] };
|
||||
for i in list {
|
||||
let Some(layout) = doc.slides.get(i).map(|x| x.layout) else { continue };
|
||||
let lay = doc.layout(layout).map(|(_, l)| l.clone());
|
||||
let is_title = lay.as_ref().is_some_and(|l| l.kind == deckcraft_model::LayoutType::Title);
|
||||
let want = |k: PhType| {
|
||||
!(hf.hide_on_title && is_title)
|
||||
&& match k {
|
||||
PhType::Date => hf.date,
|
||||
PhType::SlideNum => hf.slide_number,
|
||||
PhType::Footer => hf.footer,
|
||||
_ => false,
|
||||
}
|
||||
};
|
||||
let mut new_ids = vec![];
|
||||
for _ in 0..3 {
|
||||
new_ids.push(ShapeId(doc.alloc_id().ok_or_else(|| bad("design.headerFooter", NO_ID_SPACE))?));
|
||||
}
|
||||
let Some(sl) = doc.slides.get_mut(i) else { continue };
|
||||
let sl = Arc::make_mut(sl);
|
||||
for (n, k) in [PhType::Date, PhType::Footer, PhType::SlideNum].into_iter().enumerate() {
|
||||
let has = sl.shapes.iter().position(|x| x.ph_type() == Some(k));
|
||||
match (has, want(k)) {
|
||||
(Some(pos), false) => {
|
||||
sl.shapes.remove(pos);
|
||||
}
|
||||
(None, true) => {
|
||||
let lp = lay.as_ref().and_then(|l| l.shapes.iter().find(|x| x.ph_type() == Some(k)).cloned());
|
||||
let Some(lp) = lp else { continue };
|
||||
let body = match k {
|
||||
PhType::SlideNum => TextBody {
|
||||
paragraphs: vec![deckcraft_model::Paragraph {
|
||||
runs: vec![Run {
|
||||
text: "‹#›".into(),
|
||||
props: Default::default(),
|
||||
kind: RunKind::Field { field: "slidenum".into() },
|
||||
}],
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
PhType::Date if hf.date_text.is_empty() => TextBody {
|
||||
paragraphs: vec![deckcraft_model::Paragraph {
|
||||
runs: vec![Run {
|
||||
text: date_now(&hf.date_format),
|
||||
props: Default::default(),
|
||||
kind: RunKind::Field { field: date_kind(&hf.date_format).into() },
|
||||
}],
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
PhType::Date => TextBody::from_text(&hf.date_text),
|
||||
_ => TextBody::from_text(&hf.footer_text),
|
||||
};
|
||||
sl.shapes.push(Shape {
|
||||
id: new_ids.get(n).copied().unwrap_or_default(),
|
||||
name: lp.name.clone(),
|
||||
ph: lp.ph.clone(),
|
||||
text: Some(body),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
(Some(pos), true) if k == PhType::Footer => {
|
||||
if let Some(sh) = sl.shapes.get_mut(pos) {
|
||||
sh.text = Some(TextBody::from_text(&hf.footer_text));
|
||||
}
|
||||
}
|
||||
(Some(pos), true) if k == PhType::Date && (date_given || format_given) => {
|
||||
if let Some(sh) = sl.shapes.get_mut(pos) {
|
||||
set_date(sh, &hf.date_text, &hf.date_format);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
/// The date field type `format` names, `datetime1` when it is empty or unknown.
|
||||
fn date_kind(format: &str) -> &str {
|
||||
if deckcraft_text::datetime::DATE_KINDS.contains(&format) { format } else { "datetime1" }
|
||||
}
|
||||
|
||||
/// The saved text of a date field of `format`, as it shows now.
|
||||
fn date_now(format: &str) -> String {
|
||||
let kind = date_kind(format);
|
||||
deckcraft_text::datetime::DateTime::now_local().and_then(|now| deckcraft_text::datetime::field_text(kind, None, now)).unwrap_or_else(today)
|
||||
}
|
||||
|
||||
/// Point a slide's date placeholder at a fixed date (its own plain text) or, for an empty
|
||||
/// `fixed`, at an automatic date field (one it already has keeps its format).
|
||||
fn set_date(sh: &mut Shape, fixed: &str, format: &str) {
|
||||
let runs = || sh.text.iter().flat_map(|t| &t.paragraphs).flat_map(|p| &p.runs);
|
||||
let field_now = runs().find_map(|r| match &r.kind {
|
||||
RunKind::Field { field } if deckcraft_text::datetime::is_date_field(field) => Some(field.clone()),
|
||||
_ => None,
|
||||
});
|
||||
let has_field = field_now.is_some();
|
||||
let props = runs().next().map(|r| r.props.clone()).unwrap_or_default();
|
||||
let want = date_kind(format);
|
||||
let (text, kind) = match fixed {
|
||||
// Automatic: keep the field, but follow a format that has changed.
|
||||
"" if field_now.as_deref() == Some(want) => return,
|
||||
"" if has_field && format.is_empty() => return,
|
||||
"" => (date_now(format), RunKind::Field { field: want.into() }),
|
||||
_ if !has_field && sh.text.as_ref().is_some_and(|t| t.text() == fixed) => return,
|
||||
_ => (fixed.to_string(), RunKind::Text),
|
||||
};
|
||||
let para = deckcraft_model::Paragraph { runs: vec![Run { text, props, kind }], ..Default::default() };
|
||||
sh.text = Some(TextBody { paragraphs: vec![para], ..sh.text.clone().unwrap_or_default() });
|
||||
}
|
||||
|
||||
/// Today's local date as a `datetime1` field shows it (M/D/YYYY): the saved text of a new date
|
||||
/// field. Empty where there is no clock (wasm).
|
||||
pub fn today() -> String {
|
||||
date_field_text("datetime1")
|
||||
}
|
||||
|
||||
/// What date field `kind` shows now (empty if unknown or there is no clock).
|
||||
pub fn date_field_text(kind: &str) -> String {
|
||||
use deckcraft_text::datetime::{DateTime, field_text};
|
||||
DateTime::now_local().and_then(|now| field_text(kind, None, now)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 → (year, month, day), exact for every `i64`.
|
||||
pub fn civil(z: i64) -> (i64, u32, u32) {
|
||||
use chrono::Datelike;
|
||||
// `NaiveDate` covers only about ±262143 years, and the Gregorian calendar repeats every 400
|
||||
// years (146_097 days). chrono converts the day within its cycle and whole cycles go back
|
||||
// into the year (an i128 for i64::MAX/MIN). 1970-01-01 is `num_days_from_ce` 719_163, and
|
||||
// 0..146_097 days from it always fits.
|
||||
let (cycles, rest) = (z.div_euclid(146_097), z.rem_euclid(146_097));
|
||||
match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
|
||||
Some(d) => {
|
||||
let y = i128::from(d.year()) + i128::from(cycles) * 400;
|
||||
(i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.month(), d.day())
|
||||
}
|
||||
None => (1970, 1, 1),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod header_footer_date_tests {
|
||||
use deckcraft_model::PhType;
|
||||
use deckcraft_model::text::RunKind;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// The date field of the Date placeholders of every slide: (saved text, field type).
|
||||
fn dates(s: &Session) -> Vec<(String, Option<String>)> {
|
||||
let d = &s.doc().unwrap().doc;
|
||||
d.slides
|
||||
.iter()
|
||||
.flat_map(|sl| sl.shapes.iter())
|
||||
.filter(|sh| sh.ph_type() == Some(PhType::Date))
|
||||
.flat_map(|sh| sh.text.iter())
|
||||
.flat_map(|t| t.paragraphs.iter())
|
||||
.flat_map(|p| p.runs.iter())
|
||||
.map(|r| {
|
||||
let kind = match &r.kind {
|
||||
RunKind::Field { field } => Some(field.clone()),
|
||||
_ => None,
|
||||
};
|
||||
(r.text.clone(), kind)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn sample(kind: &str) -> String {
|
||||
let now = deckcraft_text::datetime::DateTime::now_local().expect("a local date");
|
||||
deckcraft_text::datetime::field_text(kind, None, now).expect("a sample")
|
||||
}
|
||||
|
||||
/// Runs `design.headerFooter` and checks the slide's date run is an automatic `kind` field.
|
||||
/// The clock is read before and after the command, so a second ticking over between the
|
||||
/// command and the read cannot fail the comparison. Returns the run text.
|
||||
fn run_automatic(s: &mut Session, params: serde_json::Value, kind: &str) -> String {
|
||||
let before = sample(kind);
|
||||
s.execute("design.headerFooter", ¶ms).unwrap();
|
||||
let after = sample(kind);
|
||||
let got = dates(s);
|
||||
assert_eq!(got.len(), 1, "{got:?}");
|
||||
assert_eq!(got[0].1.as_deref(), Some(kind));
|
||||
assert!(got[0].0 == before || got[0].0 == after, "{got:?} is neither {before:?} nor {after:?}");
|
||||
got[0].0.clone()
|
||||
}
|
||||
|
||||
/// Header & Footer's automatic date takes the format it is given. `HeaderFooter::date_format`
|
||||
/// existed for this and was never read: the placeholder was always `datetime1`, and a
|
||||
/// `dateFormat` parameter was accepted and silently dropped.
|
||||
#[test]
|
||||
fn the_automatic_date_takes_the_format_it_is_given() {
|
||||
let mut s = Session::with_new();
|
||||
|
||||
// Default: the short date, as before.
|
||||
run_automatic(&mut s, json!({"date": true, "dateText": "", "all": true}), "datetime1");
|
||||
|
||||
// A format: the field type changes and the saved text is that format's.
|
||||
run_automatic(&mut s, json!({"date": true, "dateFormat": "datetime5", "all": true}), "datetime5");
|
||||
assert_eq!(s.doc().unwrap().doc.header_footer.date_format, "datetime5");
|
||||
|
||||
// A time format works the same way, and these two differ, so the format is really read.
|
||||
let time = run_automatic(&mut s, json!({"date": true, "dateFormat": "datetime11", "all": true}), "datetime11");
|
||||
assert_ne!(time, sample("datetime5"));
|
||||
|
||||
// Asking again changes nothing (and does not rewrite the run).
|
||||
let again = run_automatic(&mut s, json!({"date": true, "dateFormat": "datetime11", "all": true}), "datetime11");
|
||||
assert_eq!(again, time);
|
||||
|
||||
// A fixed date still wins: plain text, no field.
|
||||
s.execute("design.headerFooter", &json!({"date": true, "dateText": "4 July 1776", "all": true})).unwrap();
|
||||
assert_eq!(dates(&s), vec![("4 July 1776".to_string(), None)]);
|
||||
|
||||
// Back to automatic: the remembered format comes back, not datetime1.
|
||||
run_automatic(&mut s, json!({"date": true, "dateText": "", "dateFormat": "datetime11", "all": true}), "datetime11");
|
||||
|
||||
// An unknown format is refused rather than ignored, and changes nothing.
|
||||
for bad in ["datetime0", "datetime14", "nonsense", ""] {
|
||||
assert!(s.execute("design.headerFooter", &json!({"date": true, "dateFormat": bad})).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().doc.header_footer.date_format, "datetime11");
|
||||
let got = dates(&s);
|
||||
assert_eq!(got.len(), 1, "{got:?}");
|
||||
assert_eq!(got[0].1.as_deref(), Some("datetime11"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_color::SchemeSlot;
|
||||
use deckcraft_model::style::PictureMode;
|
||||
use deckcraft_model::theme::{FontScheme, ScriptFont, builtin_color_schemes, builtin_font_schemes};
|
||||
use deckcraft_model::{Background, Fill};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Session, UiRequest};
|
||||
|
||||
fn scheme(s: &Session) -> deckcraft_color::ColorScheme {
|
||||
s.doc().unwrap().doc.masters[0].theme.colors.clone()
|
||||
}
|
||||
|
||||
fn font_scheme(s: &Session) -> FontScheme {
|
||||
s.doc().unwrap().doc.masters[0].theme.fonts.clone()
|
||||
}
|
||||
|
||||
fn undo_len(s: &Session) -> usize {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
fn png() -> String {
|
||||
let mut img = image::RgbaImage::new(2, 2);
|
||||
img.put_pixel(0, 0, image::Rgba([10, 20, 30, 255]));
|
||||
let mut bytes = Vec::new();
|
||||
image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png).unwrap();
|
||||
crate::cmd::base64_encode(&bytes)
|
||||
}
|
||||
|
||||
fn picture(s: &Session) -> deckcraft_model::style::PictureFill {
|
||||
match &s.doc().unwrap().doc.slides[0].background {
|
||||
Some(Background::Fill { fill: Fill::Picture(pf) }) => pf.clone(),
|
||||
other => panic!("expected a picture background, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_commands_reject_invalid_index() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("master.insertLayout", &json!({})).unwrap();
|
||||
let count = |s: &Session| s.doc().unwrap().doc.masters.iter().map(|m| m.layouts.len()).sum::<usize>();
|
||||
let names = |s: &Session| s.doc().unwrap().doc.masters.iter().flat_map(|m| m.layouts.iter().map(|l| l.name.clone())).collect::<Vec<_>>();
|
||||
let n = count(&s);
|
||||
let before = names(&s);
|
||||
for bad in [json!(-1), json!(1.5)] {
|
||||
assert!(s.execute("master.deleteLayout", &json!({"layout": bad})).is_err());
|
||||
assert!(s.execute("master.deleteLayout", &json!({"master": bad})).is_err());
|
||||
assert!(s.execute("master.renameLayout", &json!({"layout": bad, "name": "x"})).is_err());
|
||||
assert_eq!(count(&s), n);
|
||||
assert_eq!(names(&s), before);
|
||||
}
|
||||
s.execute("master.deleteLayout", &json!({})).unwrap();
|
||||
assert_eq!(count(&s), n - 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn civil_dates() {
|
||||
assert_eq!(super::civil(0), (1970, 1, 1));
|
||||
assert_eq!(super::civil(20_734), (2026, 10, 8));
|
||||
// Past `NaiveDate`'s range the 400-year cycles carry the year, exactly, up to the extremes.
|
||||
assert_eq!(super::civil(95_026_602), (262_144, 1, 1));
|
||||
assert_eq!(super::civil(-96_465_964), (-262_145, 3, 1));
|
||||
assert_eq!(super::civil(i64::MAX), (25_252_734_927_768_524, 7, 27));
|
||||
assert_eq!(super::civil(i64::MIN), (-25_252_734_927_764_585, 6, 7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colors_dialog_apply_and_reject() {
|
||||
let mut s = Session::with_new();
|
||||
let original = scheme(&s);
|
||||
let n = undo_len(&s);
|
||||
s.execute("design.colors", &json!({})).unwrap();
|
||||
s.execute("design.colors", &Value::Null).unwrap();
|
||||
assert_eq!(scheme(&s), original);
|
||||
assert_eq!(undo_len(&s), n, "opening the dialog does not edit");
|
||||
assert_eq!(s.ui_requests.len(), 2);
|
||||
match &s.ui_requests[0] {
|
||||
UiRequest::Dialog { id, params } => {
|
||||
assert_eq!(id, "themeColors");
|
||||
assert_eq!(params["name"], "DeckCraft");
|
||||
let colors = params["colors"].as_object().unwrap();
|
||||
assert_eq!(colors.len(), 12);
|
||||
assert_eq!(colors["accent1"].as_str().unwrap(), format!("#{}", original.get(SchemeSlot::Accent1).hex()));
|
||||
assert!(colors.contains_key("folHlink"));
|
||||
}
|
||||
other => panic!("expected a dialog, got {other:?}"),
|
||||
}
|
||||
|
||||
s.execute("design.colors", &json!({"name": "Harbor"})).unwrap();
|
||||
let harbor = builtin_color_schemes().into_iter().find(|c| c.name == "Harbor").unwrap();
|
||||
assert_eq!(scheme(&s).name, "Harbor");
|
||||
assert_eq!(scheme(&s).get(SchemeSlot::Accent1), harbor.get(SchemeSlot::Accent1));
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Colors");
|
||||
s.execute("design.colors", &json!({"name": "Harbor"})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1, "the same scheme is not an undo step");
|
||||
colors_dialog_rejects(&mut s, n, &harbor);
|
||||
}
|
||||
|
||||
fn colors_dialog_rejects(s: &mut Session, n: usize, harbor: &deckcraft_color::ColorScheme) {
|
||||
let err = s.execute("design.colors", &json!({"name": "Nope"})).unwrap_err().to_string();
|
||||
assert!(err.contains("no colour scheme `Nope`"), "{err}");
|
||||
assert!(s.execute("design.colors", &json!({"name": " "})).is_err());
|
||||
assert!(s.execute("design.colors", &json!({"name": 1})).is_err());
|
||||
assert!(s.execute("design.colors", &json!({"colors": {}})).is_err());
|
||||
assert!(s.execute("design.colors", &json!({"colors": "red"})).is_err());
|
||||
assert!(s.execute("design.colors", &json!({"colors": {"bg1": "#112233"}})).is_err());
|
||||
assert!(s.execute("design.colors", &json!({"colors": {"tx1": "#112233"}})).is_err());
|
||||
assert!(s.execute("design.colors", &json!({"colors": {"phClr": "#112233"}})).is_err());
|
||||
assert!(s.execute("design.colors", &json!({"colors": {"nope": "#112233"}})).is_err());
|
||||
assert!(s.execute("design.colors", &json!({"colors": {"accent1": "zz"}})).is_err());
|
||||
assert!(s.execute("design.colors", &json!({"name": "Harbor", "colors": {}})).is_err());
|
||||
assert_eq!(scheme(s).name, "Harbor");
|
||||
assert_eq!(scheme(s).get(SchemeSlot::Accent1), harbor.get(SchemeSlot::Accent1));
|
||||
assert_eq!(undo_len(s), n + 1);
|
||||
colors_dialog_custom_overrides(s, n, harbor);
|
||||
}
|
||||
|
||||
fn colors_dialog_custom_overrides(s: &mut Session, n: usize, harbor: &deckcraft_color::ColorScheme) {
|
||||
s.execute("design.colors", &json!({"colors": {"accent1": "112233"}})).unwrap();
|
||||
assert_eq!(scheme(s).name, "Custom");
|
||||
assert_eq!(scheme(s).get(SchemeSlot::Accent1).hex(), "112233");
|
||||
assert_eq!(scheme(s).get(SchemeSlot::Accent2), harbor.get(SchemeSlot::Accent2));
|
||||
assert_eq!(undo_len(s), n + 2);
|
||||
|
||||
let meadow = builtin_color_schemes().into_iter().find(|c| c.name == "Meadow").unwrap();
|
||||
s.execute("design.colors", &json!({"name": "Meadow", "colors": {"lt1": "#010203"}})).unwrap();
|
||||
assert_eq!(scheme(s).name, "Meadow");
|
||||
assert_eq!(scheme(s).get(SchemeSlot::Lt1).hex(), "010203");
|
||||
assert_eq!(scheme(s).get(SchemeSlot::Accent1), meadow.get(SchemeSlot::Accent1));
|
||||
assert_eq!(undo_len(s), n + 3);
|
||||
|
||||
s.execute("design.colors", &json!({"name": "Talk", "colors": {"dk1": "#ABCDEF"}})).unwrap();
|
||||
assert_eq!(scheme(s).name, "Talk");
|
||||
assert_eq!(scheme(s).get(SchemeSlot::Dk1).hex(), "ABCDEF");
|
||||
assert_eq!(scheme(s).get(SchemeSlot::Lt1).hex(), "010203");
|
||||
assert_eq!(scheme(s).get(SchemeSlot::Accent1), meadow.get(SchemeSlot::Accent1));
|
||||
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(scheme(s).name, "Meadow");
|
||||
assert_eq!(scheme(s).get(SchemeSlot::Dk1), meadow.get(SchemeSlot::Dk1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fonts_dialog_apply_and_reject() {
|
||||
let mut s = Session::with_new();
|
||||
let original = font_scheme(&s);
|
||||
let n = undo_len(&s);
|
||||
s.execute("design.fonts", &json!({})).unwrap();
|
||||
s.execute("design.fonts", &Value::Null).unwrap();
|
||||
s.execute("design.fonts", &json!([1, 2])).unwrap();
|
||||
assert_eq!(font_scheme(&s), original);
|
||||
assert_eq!(undo_len(&s), n, "opening the dialog does not edit");
|
||||
assert_eq!(s.ui_requests.len(), 3);
|
||||
match &s.ui_requests[0] {
|
||||
UiRequest::Dialog { id, params } => {
|
||||
assert_eq!(id, "themeFonts");
|
||||
assert_eq!(params["name"], original.name);
|
||||
assert_eq!(params["major"], original.major.latin);
|
||||
assert_eq!(params["minor"], original.minor.latin);
|
||||
}
|
||||
other => panic!("expected a dialog, got {other:?}"),
|
||||
}
|
||||
|
||||
s.execute("design.fonts", &json!({"name": "classic"})).unwrap();
|
||||
let classic = builtin_font_schemes().into_iter().find(|f| f.name == "Classic").unwrap();
|
||||
assert_eq!(font_scheme(&s), classic, "a builtin name keeps that scheme, including its own name");
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Fonts");
|
||||
s.execute("design.fonts", &json!({"name": "Classic"})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1, "the same scheme is not an undo step");
|
||||
|
||||
let err = s.execute("design.fonts", &json!({"name": "Nope"})).unwrap_err().to_string();
|
||||
assert!(err.contains("no font scheme `Nope`"), "{err}");
|
||||
assert!(s.execute("design.fonts", &json!({"name": " "})).is_err());
|
||||
assert!(s.execute("design.fonts", &json!({"name": 1})).is_err());
|
||||
assert!(s.execute("design.fonts", &json!({"major": ""})).is_err());
|
||||
assert!(s.execute("design.fonts", &json!({"major": " "})).is_err());
|
||||
assert!(s.execute("design.fonts", &json!({"minor": 1})).is_err());
|
||||
assert!(s.execute("design.fonts", &json!({"name": "Classic", "major": ""})).is_err());
|
||||
assert_eq!(font_scheme(&s), classic);
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
fonts_dialog_custom_overrides(&mut s);
|
||||
}
|
||||
|
||||
fn fonts_dialog_custom_overrides(s: &mut Session) {
|
||||
s.edit(|doc, _| {
|
||||
let fonts = &mut std::sync::Arc::make_mut(&mut doc.masters[0]).theme.fonts;
|
||||
fonts.major.ea = "EA Major".into();
|
||||
fonts.minor.cs = "CS Minor".into();
|
||||
fonts.major.scripts.push(ScriptFont { script: "Arab".into(), typeface: "Noto Naskh Arabic".into() });
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let marked = font_scheme(s);
|
||||
let n = undo_len(s);
|
||||
|
||||
s.execute("design.fonts", &json!({"major": " Lato "})).unwrap();
|
||||
let custom = font_scheme(s);
|
||||
assert_eq!(custom.name, "Custom");
|
||||
assert_eq!(custom.major.latin, "Lato");
|
||||
assert_eq!(custom.minor.latin, "Lato", "an omitted minor copies major");
|
||||
assert_eq!(custom.major.ea, "EA Major");
|
||||
assert_eq!(custom.minor.cs, "CS Minor");
|
||||
assert_eq!(custom.major.scripts, marked.major.scripts);
|
||||
assert_eq!(undo_len(s), n + 1);
|
||||
|
||||
s.execute("design.fonts", &json!({"minor": "Roboto"})).unwrap();
|
||||
assert_eq!(font_scheme(s).name, "Custom");
|
||||
assert_eq!(font_scheme(s).major.latin, "Lato");
|
||||
assert_eq!(font_scheme(s).minor.latin, "Roboto");
|
||||
assert_eq!(font_scheme(s).major.ea, "EA Major");
|
||||
assert_eq!(undo_len(s), n + 2);
|
||||
|
||||
s.execute("design.fonts", &json!({"name": "Classic", "major": "Inter"})).unwrap();
|
||||
assert_eq!(font_scheme(s).name, "Classic");
|
||||
assert_eq!(font_scheme(s).major.latin, "Inter");
|
||||
assert_eq!(font_scheme(s).minor.latin, "Open Sans");
|
||||
assert_eq!(font_scheme(s).major.ea, "");
|
||||
assert!(font_scheme(s).major.scripts.is_empty(), "a builtin is applied before the latin overlay");
|
||||
assert_eq!(undo_len(s), n + 3);
|
||||
|
||||
s.edit(|doc, _| {
|
||||
std::sync::Arc::make_mut(&mut doc.masters[0]).theme.fonts.major.ea = "Kept".into();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("design.fonts", &json!({"name": "Talk", "minor": "Nunito Sans"})).unwrap();
|
||||
assert_eq!(font_scheme(s).name, "Talk");
|
||||
assert_eq!(font_scheme(s).major.latin, "Inter");
|
||||
assert_eq!(font_scheme(s).minor.latin, "Nunito Sans");
|
||||
assert_eq!(font_scheme(s).major.ea, "Kept");
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Fonts");
|
||||
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(font_scheme(s).name, "Classic");
|
||||
assert_eq!(font_scheme(s).major.ea, "Kept");
|
||||
assert_eq!(font_scheme(s).minor.latin, "Open Sans");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_picture_options_tile_transparency_and_copy() {
|
||||
let mut s = Session::with_new();
|
||||
let n = undo_len(&s);
|
||||
let err = s.execute("design.background", &json!({"pictureProps": {"tile": true}})).unwrap_err().to_string();
|
||||
assert!(err.contains("picture background"), "{err}");
|
||||
assert!(s.execute("design.background", &json!({"transparency": 0.2})).is_err());
|
||||
assert_eq!(undo_len(&s), n);
|
||||
|
||||
s.execute("design.background", &json!({"color": "accent1", "transparency": 0.25})).unwrap();
|
||||
match &s.doc().unwrap().doc.slides[0].background {
|
||||
Some(Background::Fill { fill: Fill::Solid { color } }) => assert!((color.alpha() - 0.75).abs() < 1e-4, "{}", color.alpha()),
|
||||
other => panic!("expected a solid background, got {other:?}"),
|
||||
}
|
||||
let n = undo_len(&s);
|
||||
s.execute("design.background", &json!({"transparency": 0.25})).unwrap();
|
||||
assert_eq!(undo_len(&s), n, "the same transparency is not an undo step");
|
||||
|
||||
s.execute("design.background", &json!({"picture": png(), "name": "dot.png", "transparency": 0.4, "pictureProps": {"tile": true}})).unwrap();
|
||||
let pf = picture(&s);
|
||||
assert!((pf.alpha.unwrap_or(1.0) - 0.6).abs() < 1e-6);
|
||||
assert!(matches!(pf.mode, PictureMode::Tile { .. }));
|
||||
let n = undo_len(&s);
|
||||
|
||||
s.execute(
|
||||
"design.background",
|
||||
&json!({"pictureProps": {"offsetX": 12.0, "offsetY": -3.0, "scaleX": 0.5, "scaleY": 50.0, "align": "ctr", "mirror": "xy", "rotateWithShape": false}}),
|
||||
)
|
||||
.unwrap();
|
||||
match picture(&s).mode {
|
||||
PictureMode::Tile { tx, ty, sx, sy, flip, align } => {
|
||||
assert_eq!((tx, ty, sx, sy), (12.0, -3.0, 0.5, 10.0));
|
||||
assert_eq!(flip, "xy");
|
||||
assert_eq!(align, "ctr");
|
||||
}
|
||||
other => panic!("expected a tile, got {other:?}"),
|
||||
}
|
||||
assert!(picture(&s).upright);
|
||||
s.execute("design.background", &json!({"pictureProps": {"align": "bogus", "offsetX": "nope"}})).unwrap();
|
||||
match picture(&s).mode {
|
||||
PictureMode::Tile { tx, align, .. } => {
|
||||
assert_eq!(tx, 12.0);
|
||||
assert_eq!(align, "ctr");
|
||||
}
|
||||
other => panic!("expected the tile to stay, got {other:?}"),
|
||||
}
|
||||
assert_eq!(undo_len(&s), n + 1, "ignored options are not an undo step");
|
||||
|
||||
s.execute("design.background", &json!({"pictureProps": {"tile": false, "stretch": [0.1, 0.2, 0.0, 0.0]}})).unwrap();
|
||||
match picture(&s).mode {
|
||||
PictureMode::Stretch { fill_rect } => assert_eq!(fill_rect, [0.1, 0.2, 0.0, 0.0]),
|
||||
other => panic!("expected a stretch, got {other:?}"),
|
||||
}
|
||||
|
||||
s.execute("slide.new", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.slides[1].background.is_none());
|
||||
s.execute("slide.go", &json!({"index": 0})).unwrap();
|
||||
let n = undo_len(&s);
|
||||
s.execute("design.background", &json!({"copy": true})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.slides[1].background, s.doc().unwrap().doc.slides[0].background);
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Format Background");
|
||||
s.execute("design.background", &json!({"copy": true})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1, "copying an already shared background is not an undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_all_applies_to_master_and_new_slides_inherit() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap();
|
||||
let s0 = s.execute("slide.inspect", &json!({"index": 0})).unwrap();
|
||||
assert_eq!(s0["background"], "#121826");
|
||||
|
||||
let n = undo_len(&s);
|
||||
s.execute("design.background", &json!({"color": "#0D0D0D", "all": true})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
|
||||
// Idempotency: re-applying the same background to all is a no-op (no extra undo step)
|
||||
s.execute("design.background", &json!({"color": "#0D0D0D", "all": true})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1, "re-applying already applied background is not an undo step");
|
||||
|
||||
// Slide 0 has new background, master has new background, layouts cleared
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert!(matches!(doc.slides[0].background, Some(Background::Fill { .. })));
|
||||
assert_eq!(doc.masters[0].background, doc.slides[0].background);
|
||||
for l in &doc.masters[0].layouts {
|
||||
assert!(l.background.is_none());
|
||||
}
|
||||
|
||||
// Newly added slides inherit the master background (fixes #891)
|
||||
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "new"})).unwrap();
|
||||
let s0_after = s.execute("slide.inspect", &json!({"index": 0})).unwrap();
|
||||
let s1_after = s.execute("slide.inspect", &json!({"index": 1})).unwrap();
|
||||
assert_eq!(s0_after["background"], "#0D0D0D");
|
||||
assert_eq!(s1_after["background"], "#0D0D0D");
|
||||
|
||||
// Undo restores previous slide and master backgrounds
|
||||
s.execute("edit.undo", &json!({})).unwrap(); // undos slide.new
|
||||
assert_eq!(s.doc().unwrap().doc.slides.len(), 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap(); // undos design.background
|
||||
let s0_reverted = s.execute("slide.inspect", &json!({"index": 0})).unwrap();
|
||||
assert_eq!(s0_reverted["background"], "#121826");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_copy_from_slide_following_master_preserves_master_and_layout_backgrounds() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap();
|
||||
s.execute("slide.new", &json!({"layout": "titleAndContent"})).unwrap();
|
||||
|
||||
let custom_layout_bg = Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x22, 0x22, 0x22))) };
|
||||
s.edit(|doc, _| {
|
||||
let m = Arc::make_mut(&mut doc.masters[0]);
|
||||
m.layouts[0].background = Some(custom_layout_bg.clone());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// Slide 0 follows master (background: None)
|
||||
assert!(s.doc().unwrap().doc.slides[0].background.is_none());
|
||||
// Give Slide 1 an explicit override
|
||||
s.execute("design.background", &json!({"color": "#FF0000", "index": 1})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.slides[1].background.is_some());
|
||||
|
||||
let orig_master_bg = s.doc().unwrap().doc.masters[0].background.clone();
|
||||
assert!(orig_master_bg.is_some());
|
||||
|
||||
// Select slide 0 (which follows master) and apply copy to all
|
||||
s.execute("slide.go", &json!({"index": 0})).unwrap();
|
||||
let n = undo_len(&s);
|
||||
s.execute("design.background", &json!({"copy": true})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
|
||||
// Slide 1 has been reset to None (follows master), slide 0 remains None
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert!(doc.slides[0].background.is_none());
|
||||
assert!(doc.slides[1].background.is_none());
|
||||
|
||||
// Master and layout backgrounds survived!
|
||||
assert_eq!(doc.masters[0].background, orig_master_bg);
|
||||
assert_eq!(doc.masters[0].layouts[0].background, Some(custom_layout_bg));
|
||||
|
||||
// Idempotency: re-applying copy is a no-op
|
||||
s.execute("design.background", &json!({"copy": true})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1, "re-copying when already matching is a no-op");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_reset_all_preserves_master_and_layout_backgrounds() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap();
|
||||
s.execute("slide.new", &json!({"layout": "titleAndContent"})).unwrap();
|
||||
|
||||
let custom_layout_bg = Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x33, 0x33, 0x33))) };
|
||||
s.edit(|doc, _| {
|
||||
let m = Arc::make_mut(&mut doc.masters[0]);
|
||||
m.layouts[0].background = Some(custom_layout_bg.clone());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let orig_master_bg = s.doc().unwrap().doc.masters[0].background.clone();
|
||||
assert!(orig_master_bg.is_some());
|
||||
|
||||
// Set explicit backgrounds on both slides individually (master remains untouched)
|
||||
s.execute("design.background", &json!({"color": "#0D0D0D", "index": 0})).unwrap();
|
||||
s.execute("design.background", &json!({"color": "#0D0D0D", "index": 1})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.slides[0].background.is_some());
|
||||
assert!(s.doc().unwrap().doc.slides[1].background.is_some());
|
||||
|
||||
// Now perform reset with all
|
||||
let n = undo_len(&s);
|
||||
s.execute("design.background", &json!({"reset": true, "all": true})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
|
||||
// Slides reset to None to follow master
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert!(doc.slides[0].background.is_none());
|
||||
assert!(doc.slides[1].background.is_none());
|
||||
|
||||
// Master and layout backgrounds survived!
|
||||
assert_eq!(doc.masters[0].background, orig_master_bg);
|
||||
assert_eq!(doc.masters[0].layouts[0].background, Some(custom_layout_bg));
|
||||
|
||||
// Idempotency: re-running reset all is a no-op
|
||||
s.execute("design.background", &json!({"reset": true, "all": true})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1, "re-resetting when already reset is a no-op");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_all_only_touches_used_masters() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap();
|
||||
|
||||
// Add a second master to presentation
|
||||
let second_master_bg = Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x55, 0x55, 0x55))) };
|
||||
s.edit(|doc, _| {
|
||||
let mut m2 = (*doc.masters[0]).clone();
|
||||
m2.id = deckcraft_model::MasterId(999);
|
||||
m2.background = Some(second_master_bg.clone());
|
||||
doc.masters.push(Arc::new(m2));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(s.doc().unwrap().doc.masters.len(), 2);
|
||||
// Slides only use master 0 (default layout)
|
||||
s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap();
|
||||
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
// Master 0 updated
|
||||
assert_eq!(doc.masters[0].background, doc.slides[0].background);
|
||||
// Master 1 preserved!
|
||||
assert_eq!(doc.masters[1].background, Some(second_master_bg));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_all_clears_layout_overrides_even_if_slides_and_master_match() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap();
|
||||
|
||||
// Set all slides and master to #AA0000
|
||||
s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.masters[0].background, s.doc().unwrap().doc.slides[0].background);
|
||||
|
||||
// Add a layout override on layout 0
|
||||
let custom_bg = Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x12, 0x34, 0x56))) };
|
||||
s.edit(|doc, _| {
|
||||
let m = Arc::make_mut(&mut doc.masters[0]);
|
||||
m.layouts[0].background = Some(custom_bg);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// Re-run Apply to All with #AA0000. Must not be skipped as no-op; must clear layout override.
|
||||
let n = undo_len(&s);
|
||||
s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1, "should execute and clear layout override, not treat as no-op");
|
||||
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert!(doc.masters[0].layouts[0].background.is_none());
|
||||
|
||||
// Now that layout override is cleared, repeating is an actual no-op
|
||||
s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1, "now it is truly a no-op");
|
||||
}
|
||||
@@ -7,7 +7,10 @@ use deckcraft_model::text::TextBody;
|
||||
use deckcraft_model::{MediaId, Shape, ShapeId, Slide, SlideId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{
|
||||
CommandSpec, NO_ID_SPACE, bad, base64_encode, bool_or, can_redo, can_undo, cmd, has_doc, has_selection, has_slide, id_param, ids_param, ok,
|
||||
richclip, str_param, targets, xfrm_of,
|
||||
};
|
||||
use crate::{HistoryEntry, Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -44,14 +47,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
|
||||
fn restore(s: &mut Session, redo: bool) -> Result<Value> {
|
||||
let vertex_kind = s.tool.vertex_kind.clone();
|
||||
let st = s.doc_mut()?;
|
||||
let (from, to) = if redo { (&mut st.history.redo, &mut st.history.undo) } else { (&mut st.history.undo, &mut st.history.redo) };
|
||||
let Some(e) = from.pop() else { return ok() };
|
||||
to.push(HistoryEntry { label: e.label.clone(), doc: st.doc.clone(), selection: st.selection.clone() });
|
||||
to.push(HistoryEntry { label: e.label.clone(), doc: st.doc.clone(), selection: st.selection.clone(), vertex_kind });
|
||||
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;
|
||||
// The point type Edit Points remembered with the restored document: handle drags follow it.
|
||||
s.tool.vertex_kind = e.vertex_kind;
|
||||
Ok(json!({"label": e.label}))
|
||||
}
|
||||
|
||||
@@ -67,30 +75,6 @@ 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())
|
||||
}
|
||||
|
||||
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),
|
||||
deckcraft_model::ShapeKind::Media(m) => {
|
||||
out.push(m.media);
|
||||
if let Some(p) = m.poster {
|
||||
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 {
|
||||
out.push(pf.media);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn copy_inner(s: &mut Session, p: &Value) -> Result<String> {
|
||||
let st = s.doc()?;
|
||||
// Text selection.
|
||||
@@ -111,16 +95,18 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result<String> {
|
||||
let slides: Vec<Slide> = ids.iter().filter_map(|id| st.doc.slide(*id).cloned()).collect();
|
||||
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);
|
||||
}
|
||||
mids.extend(crate::media_refs::slide_media(sl));
|
||||
}
|
||||
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");
|
||||
s.clipboard = crate::Clipboard { slides, media, plain: plain.clone(), ..Default::default() };
|
||||
return Ok(plain);
|
||||
}
|
||||
// Selected table cells copy as their text: a tab between cells and a paragraph per row.
|
||||
if let Some(plain) = super::table::selected_cells_text(st).filter(|_| p.get("id").is_none() && p.get("ids").is_none()) {
|
||||
s.clipboard = crate::Clipboard { text: Some(TextBody::from_text(&plain)), plain: plain.clone(), ..Default::default() };
|
||||
return Ok(plain);
|
||||
}
|
||||
let shapes: Vec<Shape> = st
|
||||
.selection
|
||||
.shapes
|
||||
@@ -134,8 +120,7 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result<String> {
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut mids = vec![];
|
||||
media_of_shapes(&shapes, &mut mids);
|
||||
let mids = crate::media_refs::shapes_media(&shapes);
|
||||
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");
|
||||
let source_theme = st.current_slide().and_then(|sl| st.doc.master_for(sl)).map(|(m, _)| (m.scheme(), m.theme.fonts.clone()));
|
||||
@@ -248,32 +233,13 @@ fn walk_mut(shapes: &mut [Shape], f: &mut dyn FnMut(&mut Shape)) {
|
||||
rec(shapes, f, 0);
|
||||
}
|
||||
|
||||
/// 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| {
|
||||
/// Rewrite a media id the clipboard used (`map`: clipboard id to destination id).
|
||||
fn remap_media(map: &HashMap<MediaId, MediaId>) -> impl FnMut(&mut MediaId) + '_ {
|
||||
move |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
|
||||
@@ -282,7 +248,11 @@ fn bring_media(cmd: &str, doc: &mut deckcraft_model::Presentation, media: &[deck
|
||||
let mut map = HashMap::new();
|
||||
for m in media {
|
||||
match doc.media(m.id) {
|
||||
None => doc.media.push(m.clone()),
|
||||
None => {
|
||||
// Keeps the foreign id, so later ids must start past it.
|
||||
doc.media.push(m.clone());
|
||||
doc.next_id = doc.next_id.max(m.id.0);
|
||||
}
|
||||
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))?;
|
||||
@@ -326,12 +296,7 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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;
|
||||
}
|
||||
crate::media_refs::visit_mut::slide(&mut sl, &mut remap_media(&media));
|
||||
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();
|
||||
}
|
||||
@@ -355,8 +320,19 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let media = bring_media("edit.paste", doc, &clip.media)?;
|
||||
let mut shapes = clip.shapes.clone();
|
||||
reid("edit.paste", doc, &mut shapes)?;
|
||||
remap_media(&mut shapes, &media);
|
||||
crate::media_refs::visit_mut::shapes(&mut shapes, &mut remap_media(&media));
|
||||
let mut taken = super::current_names(doc, sel);
|
||||
for sh in &mut shapes {
|
||||
// Only a name already on the slide is changed (two shapes with one name are what the
|
||||
// issue is about); otherwise it stays, as Morph pairs shapes across slides by name.
|
||||
if taken.contains(&sh.name)
|
||||
&& let Some((base, n)) = sh.name.rsplit_once(' ')
|
||||
&& !n.is_empty()
|
||||
&& n.bytes().all(|b| b.is_ascii_digit())
|
||||
{
|
||||
sh.name = super::insert::first_free_name(base, &taken);
|
||||
}
|
||||
taken.insert(sh.name.clone());
|
||||
if let Some(x) = sh.xfrm.as_mut() {
|
||||
x.x += off;
|
||||
x.y += off;
|
||||
@@ -502,7 +478,8 @@ fn paste_picture(s: &mut Session) -> Result<Value> {
|
||||
}
|
||||
let st = s.doc()?;
|
||||
let mut doc = (*st.doc).clone();
|
||||
bring_media("edit.pasteSpecial", &mut doc, &clip.media)?;
|
||||
let media = bring_media("edit.pasteSpecial", &mut doc, &clip.media)?;
|
||||
crate::media_refs::visit_mut::shapes(&mut clip.shapes, &mut remap_media(&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"))?;
|
||||
@@ -534,15 +511,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();
|
||||
// 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;
|
||||
@@ -555,8 +548,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> {
|
||||
@@ -566,18 +562,35 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if slides_scope(s, p) && ids_param(p, "ids").is_none() {
|
||||
return s.execute("slide.delete", &json!({}));
|
||||
}
|
||||
// With table cells selected, Delete clears their text and keeps the table, as in PowerPoint.
|
||||
let sel = &s.doc()?.selection;
|
||||
if ids_param(p, "ids").is_none() && p.get("id").is_none() && sel.cells.is_some_and(|c| sel.shapes == [c.0]) {
|
||||
return super::table::clear_selected_cells(s);
|
||||
}
|
||||
let ids = targets(s, p)?;
|
||||
if ids.is_empty() {
|
||||
return ok();
|
||||
}
|
||||
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;
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
//! 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::*;
|
||||
use super::{CommandSpec, always, bad, base64_decode, base64_encode, bool_or, cmd, f64_or, has_doc, ok, str_param, usize_param};
|
||||
use crate::{DocState, EngineError, Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -33,7 +37,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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|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!(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",
|
||||
@@ -156,14 +160,17 @@ fn open_other(name: &str, bytes: &[u8]) -> Result<(Presentation, Vec<String>)> {
|
||||
if deckcraft_pptx::sniff(bytes) {
|
||||
let (mut p, lost_slides) = deckcraft_pptx::import_checked(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
deckcraft_format::repair(&mut 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);
|
||||
if lower.ends_with(".md") {
|
||||
deckcraft_format::markdown_to_slides(&mut p, &text);
|
||||
@@ -178,6 +185,48 @@ fn open_other(name: &str, bytes: &[u8]) -> Result<(Presentation, Vec<String>)> {
|
||||
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);
|
||||
@@ -293,6 +342,7 @@ pub fn save_bytes(doc: &Presentation, format: &str) -> Result<Vec<u8>> {
|
||||
match format {
|
||||
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())),
|
||||
@@ -368,7 +418,7 @@ pub fn pdf_bytes(doc: &Presentation, p: &Value) -> Result<Vec<u8>> {
|
||||
}
|
||||
|
||||
/// 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"];
|
||||
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
|
||||
@@ -401,6 +451,8 @@ pub fn format_for_path(path: &str) -> &'static str {
|
||||
"gif"
|
||||
} else if l.ends_with(".txt") || l.ends_with(".md") {
|
||||
"outline"
|
||||
} else if l.ends_with(".rtf") {
|
||||
"rtf"
|
||||
} else {
|
||||
"deckcraft"
|
||||
}
|
||||
@@ -581,7 +633,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"secondsPerSlide": f64::from(out.delay_cs) / 100.0,
|
||||
}))
|
||||
}
|
||||
"pptx" | "potx" | "ppsx" | "deckcraft" | "outline" | "txt" => {
|
||||
"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()}))
|
||||
|
||||
@@ -6,7 +6,7 @@ use deckcraft_model::text::RunKind;
|
||||
use deckcraft_model::{Paragraph, Shape, ShapeKind, Slide, TextBody};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, bad, bool_or, cmd, has_doc, str_param};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -105,7 +105,7 @@ impl Opts {
|
||||
}
|
||||
|
||||
fn para_chars(p: &Paragraph) -> Vec<char> {
|
||||
p.runs.iter().flat_map(|r| r.text.chars()).collect()
|
||||
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).
|
||||
@@ -113,7 +113,7 @@ 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.text.chars().count();
|
||||
let n = r.char_len();
|
||||
spans.push((off, off + n, matches!(r.kind, RunKind::Text)));
|
||||
off += n;
|
||||
}
|
||||
@@ -125,7 +125,7 @@ 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.text.chars().collect();
|
||||
let chars: Vec<char> = r.content().chars().collect();
|
||||
let (s, e) = (off, off + chars.len());
|
||||
off = e;
|
||||
if e <= a || s >= b {
|
||||
@@ -222,6 +222,34 @@ mod tests {
|
||||
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() };
|
||||
|
||||
+90
-1089
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,457 @@
|
||||
use super::{
|
||||
Arc, Caps, ColorRef, ColorTransform, Fill, Result, RunProps, SchemeSlot, Session, Shape, ShapeId, ShapeKind, Strike, Value, bad, bool_or,
|
||||
bool_param, color_param, current_run, ed, edit_shapes, f64_param, format_run, json, ok, on_param, resolve, str_param,
|
||||
};
|
||||
|
||||
pub(super) fn bold(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = on_param(p, current_run(s).bold);
|
||||
format_run(s, &move |r| r.bold = Some(v))
|
||||
}
|
||||
|
||||
pub(super) fn italic(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = on_param(p, current_run(s).italic);
|
||||
format_run(s, &move |r| r.italic = Some(v))
|
||||
}
|
||||
|
||||
pub(super) fn underline(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cur = current_run(s).underline.is_some_and(|u| u != "none");
|
||||
let v = on_param(p, Some(cur));
|
||||
let style = str_param(p, "style").unwrap_or("sng").to_string();
|
||||
format_run(s, &move |r| r.underline = Some(if v { style.clone() } else { "none".into() }))
|
||||
}
|
||||
|
||||
pub(super) fn strike(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cur = current_run(s).strike.is_some_and(|x| x != Strike::None);
|
||||
let v = on_param(p, Some(cur));
|
||||
let d = bool_or(p, "double", false);
|
||||
format_run(s, &move |r| {
|
||||
r.strike = Some(if !v {
|
||||
Strike::None
|
||||
} else if d {
|
||||
Strike::Double
|
||||
} else {
|
||||
Strike::Single
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn superscript(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = on_param(p, Some(current_run(s).baseline.unwrap_or(0.0) > 0.0));
|
||||
format_run(s, &move |r| r.baseline = Some(if v { 0.3 } else { 0.0 }))
|
||||
}
|
||||
|
||||
pub(super) fn subscript(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = on_param(p, Some(current_run(s).baseline.unwrap_or(0.0) < 0.0));
|
||||
format_run(s, &move |r| r.baseline = Some(if v { -0.25 } else { 0.0 }))
|
||||
}
|
||||
|
||||
pub(super) fn font(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let f = str_param(p, "family").ok_or_else(|| bad("format.font", "missing `family`"))?.to_string();
|
||||
format_run(s, &move |r| r.font = Some(f.clone()))
|
||||
}
|
||||
|
||||
pub(super) fn size(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = f64_param(p, "size").ok_or_else(|| bad("format.size", "missing `size`"))?.clamp(1.0, 4000.0);
|
||||
format_run(s, &move |r| r.size = Some(v))
|
||||
}
|
||||
|
||||
/// PowerPoint's font size list (grow/shrink steps through it).
|
||||
pub const SIZES: [f64; 20] = [8.0, 9.0, 10.0, 10.5, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 24.0, 28.0, 32.0, 36.0, 40.0, 44.0, 48.0, 54.0, 60.0, 66.0];
|
||||
|
||||
fn step(cur: f64, up: bool) -> f64 {
|
||||
if up {
|
||||
SIZES.iter().copied().find(|v| *v > cur + 0.01).unwrap_or((cur + 8.0).min(4000.0))
|
||||
} else {
|
||||
SIZES.iter().rev().copied().find(|v| *v < cur - 0.01).unwrap_or((cur - 1.0).max(1.0))
|
||||
}
|
||||
}
|
||||
|
||||
fn grow_shrink(s: &mut Session, up: bool) -> Result<Value> {
|
||||
// Each run steps from its own size, like PowerPoint.
|
||||
let st = s.doc()?;
|
||||
let ctx_sizes = current_run(s).size.unwrap_or(18.0);
|
||||
let _ = st;
|
||||
let base = ctx_sizes;
|
||||
format_run(s, &move |r| {
|
||||
let cur = r.size.unwrap_or(base);
|
||||
r.size = Some(step(cur, up));
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn grow(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
grow_shrink(s, true)
|
||||
}
|
||||
|
||||
pub(super) fn shrink(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
grow_shrink(s, false)
|
||||
}
|
||||
|
||||
pub(super) fn color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = color_param(p, "color").ok_or_else(|| bad("format.color", "missing or invalid `color`"))?;
|
||||
// A selected chart has no runs: Font Color colours its text (title, axes, legend, labels),
|
||||
// which used to answer and change nothing.
|
||||
let st = s.doc()?;
|
||||
if st.selection.text.is_none() {
|
||||
let charts: Vec<ShapeId> =
|
||||
st.selection.shapes.iter().copied().filter(|id| matches!(st.shape(*id).map(|sh| &sh.kind), Some(ShapeKind::Chart(_)))).collect();
|
||||
let only_charts = charts.len() == st.selection.shapes.len();
|
||||
if !charts.is_empty() {
|
||||
let tc = c.clone();
|
||||
edit_shapes(s, &json!({"ids": charts}), "format.color", |sh| {
|
||||
if let ShapeKind::Chart(ch) = &mut sh.kind {
|
||||
ch.text_color = Some(tc.clone());
|
||||
ch.raw = None;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
if only_charts {
|
||||
return ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
format_run(s, &move |r| r.fill = Some(Fill::solid(c.clone())))
|
||||
}
|
||||
|
||||
/// `a:alpha` for `transparency` (0–1) on `c`, replacing any alpha it had; fully opaque drops it.
|
||||
fn with_transparency(c: &mut ColorRef, transparency: f64) {
|
||||
c.mods.retain(|m| !matches!(m, ColorTransform::Alpha(_)));
|
||||
if transparency > 0.0 {
|
||||
c.mods.push(ColorTransform::Alpha(((1.0 - transparency) * 100_000.0).round() as i32));
|
||||
}
|
||||
}
|
||||
|
||||
/// Text Fill › Transparency: the text keeps its colour and gets an alpha. A run without its own
|
||||
/// fill takes the colour it shows now (from the placeholder, master or theme) so only its
|
||||
/// opacity changes.
|
||||
pub(super) fn text_transparency(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = f64_param(p, "value").ok_or_else(|| bad("format.textTransparency", "missing `value` (0–1)"))?;
|
||||
if !v.is_finite() {
|
||||
return Err(bad("format.textTransparency", "`value` must be a number from 0 to 1"));
|
||||
}
|
||||
let v = v.clamp(0.0, 1.0);
|
||||
let shown = match current_run(s).fill {
|
||||
Some(f @ (Fill::Solid { .. } | Fill::Gradient(_))) => f,
|
||||
_ => Fill::solid(ColorRef::scheme(SchemeSlot::Tx1)),
|
||||
};
|
||||
format_run(s, &move |r| {
|
||||
let mut f = match &r.fill {
|
||||
Some(f @ (Fill::Solid { .. } | Fill::Gradient(_))) => f.clone(),
|
||||
Some(Fill::None) => return,
|
||||
_ => shown.clone(),
|
||||
};
|
||||
match &mut f {
|
||||
Fill::Solid { color } => with_transparency(color, v),
|
||||
Fill::Gradient(g) => g.stops.iter_mut().for_each(|st| with_transparency(&mut st.color, v)),
|
||||
_ => {}
|
||||
}
|
||||
r.fill = Some(f);
|
||||
})
|
||||
}
|
||||
|
||||
/// The `lang` and `noProof` params of the language commands, checked: a run-props setter.
|
||||
fn language_edit(id: &str, p: &Value) -> Result<impl Fn(&mut RunProps) + Send + Sync + 'static> {
|
||||
let lang = str_param(p, "lang").map(str::to_string);
|
||||
let no_proof = bool_param(p, "noProof");
|
||||
if lang.is_none() && no_proof.is_none() {
|
||||
return Err(bad(id, "give `lang` or `noProof`"));
|
||||
}
|
||||
if lang.as_deref().is_some_and(|l| l.is_empty() || l.len() > 35 || !l.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')) {
|
||||
return Err(bad(id, "`lang` is a language tag such as en-US"));
|
||||
}
|
||||
Ok(move |r: &mut RunProps| {
|
||||
if let Some(l) = &lang {
|
||||
r.lang = Some(l.clone());
|
||||
}
|
||||
if let Some(n) = no_proof {
|
||||
r.no_proof = n.then_some(true);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Review › Language on the selected text (or the selected shapes' text).
|
||||
pub(super) fn language(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let f = language_edit("format.language", p)?;
|
||||
format_run(s, &f)
|
||||
}
|
||||
|
||||
/// Every run in the deck, and the text styles new text inherits from, in one language.
|
||||
pub(super) fn language_all(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let f = language_edit("format.languageAll", p)?;
|
||||
/// Runs, end marks and the body's own list style levels (never creating one).
|
||||
fn body(t: &mut deckcraft_model::text::TextBody, f: &dyn Fn(&mut RunProps)) {
|
||||
ed::format_all(t, f);
|
||||
t.list_style.levels.iter_mut().flatten().for_each(|lv| f(&mut lv.run));
|
||||
}
|
||||
/// Every text body in `list`: shapes, table cells and groups (nested at most 64 deep).
|
||||
fn shapes(list: &mut [Shape], f: &dyn Fn(&mut RunProps), depth: usize) {
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
for sh in list {
|
||||
if let Some(t) = &mut sh.text {
|
||||
body(t, f);
|
||||
}
|
||||
match &mut sh.kind {
|
||||
deckcraft_model::ShapeKind::Table(t) => t.rows.iter_mut().flat_map(|r| &mut r.cells).for_each(|c| body(&mut c.text, f)),
|
||||
deckcraft_model::ShapeKind::Group { children, .. } => shapes(children, f, depth + 1),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A master or presentation text style's levels: what new text inherits.
|
||||
fn styles(l: &mut deckcraft_model::text::ListStyle, f: &dyn Fn(&mut RunProps)) {
|
||||
// Level 1 always, so a deck without a default text style gets one.
|
||||
if l.level(0).is_none() {
|
||||
l.set(0, Default::default());
|
||||
}
|
||||
l.levels.iter_mut().flatten().for_each(|lv| f(&mut lv.run));
|
||||
}
|
||||
s.edit(|doc, _| {
|
||||
for sl in &mut doc.slides {
|
||||
let sl = Arc::make_mut(sl);
|
||||
shapes(&mut sl.shapes, &f, 0);
|
||||
body(&mut sl.notes, &f);
|
||||
}
|
||||
let extra = [doc.notes_master.as_mut(), doc.handout_master.as_mut()];
|
||||
for m in doc.masters.iter_mut().chain(extra.into_iter().flatten()) {
|
||||
let m = Arc::make_mut(m);
|
||||
shapes(&mut m.shapes, &f, 0);
|
||||
m.layouts.iter_mut().for_each(|l| shapes(&mut l.shapes, &f, 0));
|
||||
[&mut m.title_style, &mut m.body_style, &mut m.other_style].into_iter().for_each(|l| styles(l, &f));
|
||||
}
|
||||
styles(&mut doc.default_text_style, &f);
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
pub(super) fn highlight(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = color_param(p, "color");
|
||||
format_run(s, &move |r| r.highlight = c.clone())
|
||||
}
|
||||
|
||||
pub(super) fn caps(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = match str_param(p, "caps").unwrap_or("none") {
|
||||
"small" => Caps::Small,
|
||||
"all" => Caps::All,
|
||||
_ => Caps::None,
|
||||
};
|
||||
format_run(s, &move |r| r.caps = Some(c))
|
||||
}
|
||||
|
||||
pub(super) fn spacing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = f64_param(p, "pt").unwrap_or(0.0).clamp(-100.0, 400.0);
|
||||
format_run(s, &move |r| r.spacing = Some(v))
|
||||
}
|
||||
|
||||
/// Character formatting applied together, so the Font dialog is one undo step.
|
||||
struct FontEdits {
|
||||
family: Option<String>,
|
||||
size: Option<f64>,
|
||||
bold: Option<bool>,
|
||||
italic: Option<bool>,
|
||||
underline: Option<String>,
|
||||
color: Option<deckcraft_model::ColorRef>,
|
||||
strike: Option<Strike>,
|
||||
baseline: Option<f64>,
|
||||
caps: Option<Caps>,
|
||||
spacing: Option<f64>,
|
||||
}
|
||||
|
||||
impl FontEdits {
|
||||
fn is_empty(&self) -> bool {
|
||||
self.family.is_none()
|
||||
&& self.size.is_none()
|
||||
&& self.bold.is_none()
|
||||
&& self.italic.is_none()
|
||||
&& self.underline.is_none()
|
||||
&& self.color.is_none()
|
||||
&& self.strike.is_none()
|
||||
&& self.baseline.is_none()
|
||||
&& self.caps.is_none()
|
||||
&& self.spacing.is_none()
|
||||
}
|
||||
|
||||
fn write(&self, r: &mut RunProps) {
|
||||
if let Some(f) = &self.family {
|
||||
r.font = Some(f.clone());
|
||||
}
|
||||
if let Some(v) = self.size {
|
||||
r.size = Some(v);
|
||||
}
|
||||
if let Some(v) = self.bold {
|
||||
r.bold = Some(v);
|
||||
}
|
||||
if let Some(v) = self.italic {
|
||||
r.italic = Some(v);
|
||||
}
|
||||
if let Some(u) = &self.underline {
|
||||
r.underline = Some(u.clone());
|
||||
}
|
||||
if let Some(c) = &self.color {
|
||||
r.fill = Some(Fill::solid(c.clone()));
|
||||
}
|
||||
if let Some(v) = self.strike {
|
||||
r.strike = Some(v);
|
||||
}
|
||||
if let Some(v) = self.baseline {
|
||||
r.baseline = Some(v);
|
||||
}
|
||||
if let Some(v) = self.caps {
|
||||
r.caps = Some(v);
|
||||
}
|
||||
if let Some(v) = self.spacing {
|
||||
r.spacing = Some(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn font_bool(p: &Value, key: &str) -> Result<Option<bool>> {
|
||||
match p.get(key) {
|
||||
None => Ok(None),
|
||||
Some(Value::Bool(b)) => Ok(Some(*b)),
|
||||
Some(_) => Err(bad("format.fontDialog", format!("`{key}` must be a bool"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn font_str<'a>(p: &'a Value, key: &str) -> Result<Option<&'a str>> {
|
||||
match p.get(key) {
|
||||
None => Ok(None),
|
||||
Some(Value::String(s)) => Ok(Some(s)),
|
||||
Some(_) => Err(bad("format.fontDialog", format!("`{key}` must be a string"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn font_edits(p: &Value) -> Result<FontEdits> {
|
||||
let family = match font_str(p, "family")? {
|
||||
None => None,
|
||||
Some(s) => {
|
||||
let t = s.trim();
|
||||
if t.is_empty() || t.chars().count() > 200 {
|
||||
return Err(bad("format.fontDialog", "`family` must be 1–200 characters"));
|
||||
}
|
||||
Some(t.to_string())
|
||||
}
|
||||
};
|
||||
let size = match p.get("size") {
|
||||
None => None,
|
||||
Some(v) => {
|
||||
let n = v.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad("format.fontDialog", "`size` must be a finite number"))?;
|
||||
Some(n.clamp(1.0, 4000.0))
|
||||
}
|
||||
};
|
||||
let underline = match font_str(p, "underline")? {
|
||||
None => None,
|
||||
Some(s) => {
|
||||
if !matches!(s, "none" | "sng" | "dbl" | "heavy" | "dotted" | "dash" | "wavy") {
|
||||
return Err(bad("format.fontDialog", "`underline` must be none, sng, dbl, heavy, dotted, dash or wavy"));
|
||||
}
|
||||
Some(s.to_string())
|
||||
}
|
||||
};
|
||||
let color =
|
||||
if p.get("color").is_some() { Some(color_param(p, "color").ok_or_else(|| bad("format.fontDialog", "invalid `color`"))?) } else { None };
|
||||
let strike = match font_str(p, "strike")? {
|
||||
None => None,
|
||||
Some("none") => Some(Strike::None),
|
||||
Some("sng" | "single") => Some(Strike::Single),
|
||||
Some("dbl" | "double") => Some(Strike::Double),
|
||||
Some(_) => return Err(bad("format.fontDialog", "`strike` must be none, sng or dbl")),
|
||||
};
|
||||
let baseline = match p.get("baseline") {
|
||||
None => None,
|
||||
Some(Value::String(s)) => match s.as_str() {
|
||||
"none" => Some(0.0),
|
||||
"super" | "superscript" => Some(0.3),
|
||||
"sub" | "subscript" => Some(-0.25),
|
||||
_ => return Err(bad("format.fontDialog", "`baseline` must be none, super, sub or a fraction")),
|
||||
},
|
||||
Some(Value::Number(n)) => {
|
||||
let f = n.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad("format.fontDialog", "`baseline` must be finite"))?;
|
||||
Some(f.clamp(-1.0, 1.0))
|
||||
}
|
||||
Some(_) => return Err(bad("format.fontDialog", "`baseline` must be none, super, sub or a fraction")),
|
||||
};
|
||||
let caps = match font_str(p, "caps")? {
|
||||
None => None,
|
||||
Some("none") => Some(Caps::None),
|
||||
Some("small") => Some(Caps::Small),
|
||||
Some("all") => Some(Caps::All),
|
||||
Some(_) => return Err(bad("format.fontDialog", "`caps` must be none, small or all")),
|
||||
};
|
||||
let spacing = match p.get("spacing") {
|
||||
None => None,
|
||||
Some(v) => {
|
||||
let n = v.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad("format.fontDialog", "`spacing` must be a finite number"))?;
|
||||
Some(n.clamp(-100.0, 400.0))
|
||||
}
|
||||
};
|
||||
Ok(FontEdits { family, size, bold: font_bool(p, "bold")?, italic: font_bool(p, "italic")?, underline, color, strike, baseline, caps, spacing })
|
||||
}
|
||||
|
||||
/// A colour as a `format.fontDialog` / `format.color` parameter, including transforms.
|
||||
fn color_command_value(c: &deckcraft_model::ColorRef) -> Value {
|
||||
use deckcraft_model::ColorBase;
|
||||
let mut o = serde_json::Map::new();
|
||||
for m in &c.mods {
|
||||
if let Some(v) = m.value() {
|
||||
o.insert(m.xml_name().to_string(), json!(v));
|
||||
}
|
||||
}
|
||||
match &c.base {
|
||||
ColorBase::Rgb { rgb } if o.is_empty() => json!(format!("#{}", rgb.hex())),
|
||||
ColorBase::Rgb { rgb } => {
|
||||
o.insert("rgb".into(), json!(format!("#{}", rgb.hex())));
|
||||
Value::Object(o)
|
||||
}
|
||||
ColorBase::Scheme { slot } => {
|
||||
o.insert("scheme".into(), json!(slot.xml_name()));
|
||||
Value::Object(o)
|
||||
}
|
||||
ColorBase::Preset { name } => json!(name),
|
||||
ColorBase::System { last, .. } => json!(format!("#{}", last.hex())),
|
||||
}
|
||||
}
|
||||
|
||||
fn font_dialog_params(s: &Session) -> Value {
|
||||
let r = current_run(s);
|
||||
let family =
|
||||
s.doc().ok().and_then(|st| crate::cmd::text::ctx_for(&st.doc, &st.selection).map(|c| resolve::font_family(&c, &r))).unwrap_or_default();
|
||||
let baseline = r.baseline.unwrap_or(0.0);
|
||||
let mut v = json!({
|
||||
"family": family,
|
||||
"size": r.size.unwrap_or(18.0),
|
||||
"bold": r.bold.unwrap_or(false),
|
||||
"italic": r.italic.unwrap_or(false),
|
||||
"underline": r.underline.as_deref().filter(|u| *u != "none").unwrap_or("none"),
|
||||
"strike": match r.strike {
|
||||
Some(Strike::Double) => "dbl",
|
||||
Some(Strike::Single) => "sng",
|
||||
_ => "none",
|
||||
},
|
||||
"baseline": if baseline > 0.0 { "super" } else if baseline < 0.0 { "sub" } else { "none" },
|
||||
"caps": match r.caps {
|
||||
Some(Caps::Small) => "small",
|
||||
Some(Caps::All) => "all",
|
||||
_ => "none",
|
||||
},
|
||||
"spacing": r.spacing.unwrap_or(0.0),
|
||||
});
|
||||
if let Some(Fill::Solid { color }) = &r.fill {
|
||||
v["color"] = color_command_value(color);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
pub(super) fn font_dialog(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let edits = font_edits(p)?;
|
||||
if edits.is_empty() {
|
||||
s.ui_requests.push(crate::UiRequest::Dialog { id: "font".into(), params: font_dialog_params(s) });
|
||||
return ok();
|
||||
}
|
||||
format_run(s, &|r| edits.write(r))
|
||||
}
|
||||
|
||||
pub(super) fn clear(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
format_run(s, &|r| *r = RunProps { lang: r.lang.clone(), link: r.link.clone(), ..Default::default() })
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
use super::{
|
||||
Bullet, Effects, Fill, Line, ParaProps, Result, RunProps, Session, Shape, Strike, Value, bad, base64_decode, bool_or, current_para, current_run,
|
||||
ed, edit_shapes, format_run, ids_param, json, ok, resolve, str_param,
|
||||
};
|
||||
|
||||
/// Formatting picked up by the Format Painter.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Painted {
|
||||
pub run: Option<RunProps>,
|
||||
pub para: Option<ParaProps>,
|
||||
pub fill: Option<Fill>,
|
||||
pub line: Option<Line>,
|
||||
pub effects: Option<Effects>,
|
||||
pub style: Option<deckcraft_model::ShapeStyle>,
|
||||
}
|
||||
|
||||
pub(super) fn painter_pick(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let mut painted = Painted::default();
|
||||
if let Some(t) = &st.selection.text {
|
||||
if let Some(b) = crate::cmd::text::body_of(st, t) {
|
||||
let a = t.ordered().0;
|
||||
painted.run = Some(ed::props_at(b, (a.0, a.1 + 1)));
|
||||
painted.para = b.paragraphs.get(a.0).map(|x| x.props.clone());
|
||||
}
|
||||
} else if let Some(sh) = st.selected_shapes().first() {
|
||||
painted.fill = sh.fill.clone();
|
||||
painted.line = sh.line.clone();
|
||||
painted.effects = sh.effects.clone();
|
||||
painted.style = sh.style.clone();
|
||||
painted.run = sh.text.as_ref().and_then(|t| t.paragraphs.first()).and_then(|p| p.runs.first()).map(|r| r.props.clone());
|
||||
}
|
||||
s.painter = Some((painted, bool_or(p, "sticky", false)));
|
||||
Ok(json!({"armed": true}))
|
||||
}
|
||||
|
||||
pub(crate) fn apply_painted(sh: &mut Shape, p: &Painted) {
|
||||
if p.fill.is_some() || p.style.is_some() {
|
||||
sh.fill.clone_from(&p.fill);
|
||||
sh.line.clone_from(&p.line);
|
||||
sh.effects.clone_from(&p.effects);
|
||||
sh.style.clone_from(&p.style);
|
||||
}
|
||||
if let (Some(r), Some(t)) = (&p.run, sh.text.as_mut()) {
|
||||
ed::format_all(t, &|rp| *rp = r.clone());
|
||||
}
|
||||
}
|
||||
|
||||
/// The paragraphs the text selection covers whole: it starts at or before their start and
|
||||
/// reaches their end. A paragraph the selection only touches the start of (it ends at offset
|
||||
/// 0, as after a triple-click) is not covered.
|
||||
fn covered_paragraphs(s: &Session) -> Vec<usize> {
|
||||
let Ok(st) = s.doc() else { return vec![] };
|
||||
let Some(t) = &st.selection.text else { return vec![] };
|
||||
let Some(body) = crate::cmd::text::body_of(st, t) else { return vec![] };
|
||||
let (a, b) = t.ordered();
|
||||
// A hostile selection can point past the body: walk only the paragraphs it has.
|
||||
(a.0..=b.0.min(body.paragraphs.len().saturating_sub(1)))
|
||||
.filter(|&pi| {
|
||||
let Some(para) = body.paragraphs.get(pi) else { return false };
|
||||
let starts = pi > a.0 || a.1 == 0;
|
||||
let ends = if pi < b.0 {
|
||||
true
|
||||
} else if b.0 > a.0 && b.1 == 0 {
|
||||
false
|
||||
} else {
|
||||
b.1 >= para.char_len()
|
||||
};
|
||||
starts && ends
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn painter_apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let Some((painted, sticky)) = s.painter.clone() else { return Err(bad("format.painterApply", "pick formatting with the Format Painter first")) };
|
||||
if !sticky {
|
||||
s.painter = None;
|
||||
}
|
||||
if s.doc()?.selection.text.as_ref().is_some_and(|t| t.is_range()) && ids_param(p, "ids").is_none() {
|
||||
if let Some(r) = painted.run.clone() {
|
||||
format_run(s, &move |rp| *rp = r.clone())?;
|
||||
}
|
||||
// Paragraph formatting (bullets, alignment, spacing) goes only onto paragraphs the
|
||||
// selection covers whole; painting part of a bulleted line changes just its text.
|
||||
let covered = covered_paragraphs(s);
|
||||
if let (Some(pp), false) = (painted.para.clone(), covered.is_empty()) {
|
||||
crate::cmd::text::edit_text(s, |body, _| {
|
||||
for &pi in &covered {
|
||||
if let Some(para) = body.paragraphs.get_mut(pi) {
|
||||
para.props = pp.clone();
|
||||
}
|
||||
}
|
||||
})?;
|
||||
}
|
||||
return ok();
|
||||
}
|
||||
edit_shapes(s, p, "format.painterApply", |sh| {
|
||||
apply_painted(sh, &painted);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn state(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let r = current_run(s);
|
||||
let pp = current_para(s);
|
||||
let st = s.doc()?;
|
||||
let family = crate::cmd::text::ctx_for(&st.doc, &st.selection).map(|c| resolve::font_family(&c, &r)).unwrap_or_default();
|
||||
Ok(json!({
|
||||
"font": family,
|
||||
"fontRef": r.font,
|
||||
"size": r.size,
|
||||
"bold": r.bold.unwrap_or(false),
|
||||
"italic": r.italic.unwrap_or(false),
|
||||
"underline": r.underline.as_deref().is_some_and(|u| u != "none"),
|
||||
"strike": r.strike.is_some_and(|x| x != Strike::None),
|
||||
"superscript": r.baseline.unwrap_or(0.0) > 0.0,
|
||||
"subscript": r.baseline.unwrap_or(0.0) < 0.0,
|
||||
"align": pp.align.map(|a| a.xml()),
|
||||
"bullets": matches!(pp.bullet, Some(Bullet::Char { .. })),
|
||||
"numbering": matches!(pp.bullet, Some(Bullet::AutoNum { .. })),
|
||||
"lang": r.lang,
|
||||
"lineSpacing": serde_json::to_value(pp.line_spacing).unwrap_or_default(),
|
||||
"painter": s.painter.is_some(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Add a font from its bytes for this session (it then shows in the font menu and draws).
|
||||
pub(super) fn font_add(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let data = str_param(p, "data").and_then(base64_decode).ok_or_else(|| bad("font.add", "missing or invalid base64 `data`"))?;
|
||||
if data.len() > 64 << 20 {
|
||||
return Err(bad("font.add", "font file larger than 64 MB"));
|
||||
}
|
||||
let faces = deckcraft_fonts::FontDb::global().add_font(data);
|
||||
if faces == 0 {
|
||||
return Err(bad("font.add", "not a font DeckCraft can read (or already added)"));
|
||||
}
|
||||
Ok(json!({"faces": faces}))
|
||||
}
|
||||
|
||||
/// Catalog the installed fonts again, so fonts installed while DeckCraft runs appear.
|
||||
pub(super) fn font_rescan(_s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let db = deckcraft_fonts::FontDb::global();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let faces = db.load_system_fonts();
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let faces = 0usize;
|
||||
Ok(json!({"faces": faces, "families": db.families().len()}))
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
use super::{
|
||||
Align, Anchor, AutoFit, Bullet, Result, Session, Spacing, TextDir, Value, bad, bool_or, color_param, current_para, f64_or, f64_param,
|
||||
format_body, format_para, json, ok, on_param, resolve, str_param,
|
||||
};
|
||||
|
||||
pub(super) fn align_to(s: &mut Session, a: Align) -> Result<Value> {
|
||||
format_para(s, &move |p| p.props.align = Some(a))
|
||||
}
|
||||
|
||||
pub(super) fn align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let a = match str_param(p, "align").unwrap_or("left") {
|
||||
"center" | "centre" | "ctr" => Align::Center,
|
||||
"right" | "r" => Align::Right,
|
||||
"justify" | "just" => Align::Justify,
|
||||
"justifyLow" | "justLow" => Align::JustLow,
|
||||
"distributed" | "dist" => Align::Distributed,
|
||||
_ => Align::Left,
|
||||
};
|
||||
align_to(s, a)
|
||||
}
|
||||
|
||||
/// Paragraph reading direction. Switching it mirrors left and right alignment, so text that sat
|
||||
/// at the start edge stays there.
|
||||
pub(super) fn rtl(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let on = on_param(p, current_para(s).rtl);
|
||||
format_para(s, &move |para| {
|
||||
if para.props.rtl.unwrap_or(false) != on {
|
||||
para.props.align = match para.props.align {
|
||||
None | Some(Align::Left) => Some(Align::Right),
|
||||
Some(Align::Right) => Some(Align::Left),
|
||||
other => other,
|
||||
};
|
||||
}
|
||||
para.props.rtl = Some(on);
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn bullets(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cur = matches!(current_para(s).bullet, Some(Bullet::Char { .. }));
|
||||
let on = on_param(p, Some(cur));
|
||||
let ch = str_param(p, "char").unwrap_or("•").to_string();
|
||||
let col = color_param(p, "color");
|
||||
let size = f64_param(p, "size").map(|v| (v / 100.0).clamp(0.25, 4.0));
|
||||
let explicit_char = p.get("char").is_some();
|
||||
format_para(s, &move |para| {
|
||||
if on {
|
||||
para.props.bullet = Some(Bullet::Char { char: ch.clone() });
|
||||
para.props.bullet_font = if explicit_char { Some("Arial".into()) } else { para.props.bullet_font.clone() };
|
||||
if col.is_some() {
|
||||
para.props.bullet_color = col.clone();
|
||||
}
|
||||
if size.is_some() {
|
||||
para.props.bullet_size = size;
|
||||
}
|
||||
if para.props.indent.is_none_or(|i| i >= 0.0) && para.props.margin_left.is_none_or(|m| m == 0.0) {
|
||||
para.props.margin_left = Some(18.0 + 36.0 * para.level as f64);
|
||||
para.props.indent = Some(-18.0);
|
||||
}
|
||||
} else {
|
||||
para.props.bullet = Some(Bullet::None);
|
||||
para.props.indent = Some(0.0);
|
||||
para.props.margin_left = Some(36.0 * para.level as f64);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn numbering(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cur = matches!(current_para(s).bullet, Some(Bullet::AutoNum { .. }));
|
||||
let on = on_param(p, Some(cur));
|
||||
let scheme = str_param(p, "scheme").unwrap_or("arabicPeriod").to_string();
|
||||
let start = p.get("start").and_then(Value::as_u64).map(|v| v.clamp(1, 32767) as u32).unwrap_or(1);
|
||||
let col = color_param(p, "color");
|
||||
let size = f64_param(p, "size").map(|v| (v / 100.0).clamp(0.25, 4.0));
|
||||
format_para(s, &move |para| {
|
||||
if on {
|
||||
para.props.bullet = Some(Bullet::AutoNum { scheme: scheme.clone(), start_at: start });
|
||||
if col.is_some() {
|
||||
para.props.bullet_color = col.clone();
|
||||
}
|
||||
if size.is_some() {
|
||||
para.props.bullet_size = size;
|
||||
}
|
||||
para.props.margin_left = Some(27.0 + 36.0 * para.level as f64);
|
||||
para.props.indent = Some(-27.0);
|
||||
} else {
|
||||
para.props.bullet = Some(Bullet::None);
|
||||
para.props.indent = Some(0.0);
|
||||
para.props.margin_left = Some(36.0 * para.level as f64);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn level(s: &mut Session, d: i32) -> Result<Value> {
|
||||
format_para(s, &move |p| {
|
||||
let l = (p.level as i32 + d).clamp(0, 8) as u8;
|
||||
// Explicit indents shift with the level.
|
||||
if l != p.level {
|
||||
if let Some(m) = p.props.margin_left.as_mut() {
|
||||
*m = (*m + 36.0 * d as f64).max(0.0);
|
||||
}
|
||||
p.level = l;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn line_spacing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let sp =
|
||||
if let Some(pt) = f64_param(p, "pt") { Spacing::Pts(pt.clamp(0.0, 1584.0)) } else { Spacing::Pct(f64_or(p, "lines", 1.0).clamp(0.0, 9.99)) };
|
||||
format_para(s, &move |para| para.props.line_spacing = Some(sp))
|
||||
}
|
||||
|
||||
pub(super) fn paragraph(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let a = str_param(p, "align").map(|a| match a {
|
||||
"center" => Align::Center,
|
||||
"right" => Align::Right,
|
||||
"justify" => Align::Justify,
|
||||
"distributed" => Align::Distributed,
|
||||
_ => Align::Left,
|
||||
});
|
||||
let ml = f64_param(p, "indentLeft");
|
||||
let ind = f64_param(p, "indentFirst");
|
||||
let sb = f64_param(p, "spaceBefore");
|
||||
let sa = f64_param(p, "spaceAfter");
|
||||
let ls = f64_param(p, "lineSpacing");
|
||||
let lsp = f64_param(p, "lineSpacingPt");
|
||||
format_para(s, &move |para| {
|
||||
if a.is_some() {
|
||||
para.props.align = a;
|
||||
}
|
||||
if ml.is_some() {
|
||||
para.props.margin_left = ml.map(|v| v.max(0.0));
|
||||
}
|
||||
if ind.is_some() {
|
||||
para.props.indent = ind;
|
||||
}
|
||||
if let Some(v) = sb {
|
||||
para.props.space_before = Some(Spacing::Pts(v.max(0.0)));
|
||||
}
|
||||
if let Some(v) = sa {
|
||||
para.props.space_after = Some(Spacing::Pts(v.max(0.0)));
|
||||
}
|
||||
if let Some(v) = ls {
|
||||
para.props.line_spacing = Some(Spacing::Pct(v.clamp(0.0, 9.99)));
|
||||
}
|
||||
if let Some(v) = lsp {
|
||||
para.props.line_spacing = Some(Spacing::Pts(v.max(0.0)));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const PARA_DIALOG_KEYS: &[&str] =
|
||||
&["align", "indentLeft", "indentRight", "indentFirst", "special", "specialBy", "spaceBefore", "spaceAfter", "lineSpacing", "lineSpacingPt"];
|
||||
|
||||
fn para_dialog_has_fields(p: &Value) -> bool {
|
||||
PARA_DIALOG_KEYS.iter().any(|k| p.get(*k).is_some())
|
||||
}
|
||||
|
||||
pub(super) fn finite_key(p: &Value, cmd: &str, key: &str) -> Result<Option<f64>> {
|
||||
match p.get(key) {
|
||||
None => Ok(None),
|
||||
Some(v) => {
|
||||
let n = v.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad(cmd, format!("`{key}` must be a finite number")))?;
|
||||
Ok(Some(n))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn clamp_pt(v: f64) -> f64 {
|
||||
v.clamp(0.0, 1584.0)
|
||||
}
|
||||
|
||||
fn parse_align(cmd: &str, s: &str) -> Result<Align> {
|
||||
Ok(match s {
|
||||
"left" | "l" => Align::Left,
|
||||
"center" | "ctr" => Align::Center,
|
||||
"right" | "r" => Align::Right,
|
||||
"justify" | "just" => Align::Justify,
|
||||
"justLow" => Align::JustLow,
|
||||
"distributed" | "dist" => Align::Distributed,
|
||||
_ => return Err(bad(cmd, "`align` must be left, center, right, justify, justLow or distributed")),
|
||||
})
|
||||
}
|
||||
|
||||
fn align_name(a: Align) -> &'static str {
|
||||
match a {
|
||||
Align::Left => "left",
|
||||
Align::Center => "center",
|
||||
Align::Right => "right",
|
||||
Align::Justify => "justify",
|
||||
Align::JustLow => "justLow",
|
||||
Align::Distributed => "distributed",
|
||||
}
|
||||
}
|
||||
|
||||
fn spacing_parts(s: Option<Spacing>) -> (&'static str, f64) {
|
||||
match s {
|
||||
Some(Spacing::Pct(v)) => ("pct", v),
|
||||
Some(Spacing::Pts(v)) => ("pts", v),
|
||||
None => ("pts", 0.0),
|
||||
}
|
||||
}
|
||||
|
||||
fn paragraph_dialog_params(s: &Session) -> Value {
|
||||
let pp = current_para(s);
|
||||
let indent = pp.indent.unwrap_or(0.0);
|
||||
let (special, by) = if indent > 0.05 {
|
||||
("first", indent)
|
||||
} else if indent < -0.05 {
|
||||
("hanging", -indent)
|
||||
} else {
|
||||
("none", 0.0)
|
||||
};
|
||||
let (line_kind, line_value) = match pp.line_spacing {
|
||||
Some(Spacing::Pts(v)) => ("exactly", v),
|
||||
Some(Spacing::Pct(v)) => ("multiple", v),
|
||||
None => ("multiple", 1.0),
|
||||
};
|
||||
let (before_kind, before) = spacing_parts(pp.space_before);
|
||||
let (after_kind, after) = spacing_parts(pp.space_after);
|
||||
let tabs: Vec<Value> = pp.tabs.unwrap_or_default().iter().map(|t| json!({"pos": t.pos, "align": t.align})).collect();
|
||||
json!({
|
||||
"align": align_name(pp.align.unwrap_or(Align::Left)),
|
||||
"indentLeft": pp.margin_left.unwrap_or(0.0),
|
||||
"indentRight": pp.margin_right.unwrap_or(0.0),
|
||||
"special": special,
|
||||
"specialBy": by,
|
||||
"spaceBefore": before,
|
||||
"spaceBeforeKind": before_kind,
|
||||
"spaceAfter": after,
|
||||
"spaceAfterKind": after_kind,
|
||||
"lineKind": line_kind,
|
||||
"lineValue": line_value,
|
||||
"tabs": tabs,
|
||||
"defaultTab": pp.default_tab.unwrap_or(72.0),
|
||||
})
|
||||
}
|
||||
|
||||
fn write_spacing(kind: Option<&str>, value: f64) -> Spacing {
|
||||
if kind == Some("pct") { Spacing::Pct(value.clamp(0.0, 10.0)) } else { Spacing::Pts(clamp_pt(value)) }
|
||||
}
|
||||
|
||||
pub(super) fn paragraph_dialog(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "format.paragraphDialog";
|
||||
if !para_dialog_has_fields(p) {
|
||||
s.ui_requests.push(crate::UiRequest::Dialog { id: "paragraph".into(), params: paragraph_dialog_params(s) });
|
||||
return ok();
|
||||
}
|
||||
let align = match p.get("align") {
|
||||
None => None,
|
||||
Some(Value::String(a)) => Some(parse_align(CMD, a)?),
|
||||
Some(_) => return Err(bad(CMD, "`align` must be a string")),
|
||||
};
|
||||
let indent_left = finite_key(p, CMD, "indentLeft")?.map(clamp_pt);
|
||||
let indent_right = finite_key(p, CMD, "indentRight")?.map(clamp_pt);
|
||||
let special_by = finite_key(p, CMD, "specialBy")?.map(|v| clamp_pt(v.max(0.0)));
|
||||
let indent = match p.get("special") {
|
||||
None => finite_key(p, CMD, "indentFirst")?.map(|v| v.clamp(-1584.0, 1584.0)),
|
||||
Some(Value::String(kind)) => {
|
||||
let by = special_by.unwrap_or(0.0);
|
||||
Some(match kind.as_str() {
|
||||
"none" => 0.0,
|
||||
"first" | "firstLine" => by,
|
||||
"hanging" | "hang" => -by,
|
||||
_ => return Err(bad(CMD, "`special` must be none, first or hanging")),
|
||||
})
|
||||
}
|
||||
Some(_) => return Err(bad(CMD, "`special` must be none, first or hanging")),
|
||||
};
|
||||
let space_before = finite_key(p, CMD, "spaceBefore")?;
|
||||
let space_after = finite_key(p, CMD, "spaceAfter")?;
|
||||
let before_kind = str_param(p, "spaceBeforeKind");
|
||||
let after_kind = str_param(p, "spaceAfterKind");
|
||||
for kind in [before_kind, after_kind].into_iter().flatten() {
|
||||
if !matches!(kind, "pts" | "pct") {
|
||||
return Err(bad(CMD, "spacing kind must be pts or pct"));
|
||||
}
|
||||
}
|
||||
let line_mult = finite_key(p, CMD, "lineSpacing")?.map(|v| v.clamp(0.0, 9.99));
|
||||
let line_pt = finite_key(p, CMD, "lineSpacingPt")?.map(clamp_pt);
|
||||
format_para(s, &move |para| {
|
||||
if align.is_some() {
|
||||
para.props.align = align;
|
||||
}
|
||||
if indent_left.is_some() {
|
||||
para.props.margin_left = indent_left;
|
||||
}
|
||||
if indent_right.is_some() {
|
||||
para.props.margin_right = indent_right;
|
||||
}
|
||||
if indent.is_some() {
|
||||
para.props.indent = indent;
|
||||
}
|
||||
if let Some(v) = space_before {
|
||||
para.props.space_before = Some(write_spacing(before_kind, v));
|
||||
}
|
||||
if let Some(v) = space_after {
|
||||
para.props.space_after = Some(write_spacing(after_kind, v));
|
||||
}
|
||||
if let Some(v) = line_mult {
|
||||
para.props.line_spacing = Some(Spacing::Pct(v));
|
||||
}
|
||||
if let Some(v) = line_pt {
|
||||
para.props.line_spacing = Some(Spacing::Pts(v));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn tab_align(s: &str) -> Option<&'static str> {
|
||||
Some(match s {
|
||||
"l" | "left" => "l",
|
||||
"ctr" | "center" => "ctr",
|
||||
"r" | "right" => "r",
|
||||
"dec" | "decimal" => "dec",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn tabs_params(s: &Session) -> Value {
|
||||
let pp = current_para(s);
|
||||
let stops: Vec<Value> = pp.tabs.unwrap_or_default().iter().map(|t| json!({"pos": t.pos, "align": t.align})).collect();
|
||||
json!({"tabs": stops, "defaultTab": pp.default_tab.unwrap_or(72.0)})
|
||||
}
|
||||
|
||||
pub(super) fn anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let a = match str_param(p, "anchor").unwrap_or("top") {
|
||||
"middle" | "center" | "ctr" => Anchor::Middle,
|
||||
"bottom" | "b" => Anchor::Bottom,
|
||||
_ => Anchor::Top,
|
||||
};
|
||||
format_body(s, &move |b| b.anchor = Some(a))
|
||||
}
|
||||
|
||||
pub(super) fn direction(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = match str_param(p, "dir").unwrap_or("horizontal") {
|
||||
"rotate90" | "vert" => TextDir::Vertical,
|
||||
"rotate270" | "vert270" => TextDir::Vertical270,
|
||||
"stacked" => TextDir::Stacked,
|
||||
_ => TextDir::Horizontal,
|
||||
};
|
||||
format_body(s, &move |b| b.vert = Some(d))
|
||||
}
|
||||
|
||||
/// The edited or first selected shape's columns as drawn: the count and the spacing in points.
|
||||
fn current_columns(s: &Session) -> (u32, f64) {
|
||||
let Ok(st) = s.doc() else { return (1, 0.0) };
|
||||
let id = match &st.selection.text {
|
||||
Some(t) if !t.notes => Some(t.shape),
|
||||
_ => st.selection.shapes.first().copied(),
|
||||
};
|
||||
let Some(sh) = id.and_then(|id| st.shape(id)) else { return (1, 0.0) };
|
||||
let body = match crate::cmd::text::ctx_for(&st.doc, &st.selection) {
|
||||
Some(c) => resolve::body(&c, sh),
|
||||
None => sh.text.as_ref().map(|b| b.body.clone()).unwrap_or_default(),
|
||||
};
|
||||
(body.columns.unwrap_or(1).clamp(1, 16), body.col_spacing.unwrap_or(0.0).max(0.0))
|
||||
}
|
||||
|
||||
pub(super) fn columns(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "format.columns";
|
||||
let count = match p.get("count") {
|
||||
None => None,
|
||||
Some(v) => {
|
||||
let n = v
|
||||
.as_f64()
|
||||
.filter(|n| n.fract() == 0.0 && (1.0..=16.0).contains(n))
|
||||
.ok_or_else(|| bad(CMD, "`count` must be a whole number from 1 to 16"))?;
|
||||
Some(n as u32)
|
||||
}
|
||||
};
|
||||
let spacing = finite_key(p, CMD, "spacing")?.map(clamp_pt);
|
||||
if count.is_none() && spacing.is_none() {
|
||||
let (count, spacing) = current_columns(s);
|
||||
s.ui_requests.push(crate::UiRequest::Dialog { id: "columns".into(), params: json!({"count": count, "spacing": spacing}) });
|
||||
return ok();
|
||||
}
|
||||
format_body(s, &move |b| {
|
||||
if let Some(n) = count {
|
||||
b.columns = Some(n);
|
||||
}
|
||||
if let Some(sp) = spacing {
|
||||
b.col_spacing = Some(sp);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn autofit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let m = match str_param(p, "mode").unwrap_or("none") {
|
||||
"shrink" => AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 },
|
||||
"resize" | "shape" => AutoFit::Shape,
|
||||
_ => AutoFit::None,
|
||||
};
|
||||
format_body(s, &move |b| b.autofit = Some(m))
|
||||
}
|
||||
|
||||
pub(super) fn wrap(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let on = bool_or(p, "on", true);
|
||||
format_body(s, &move |b| b.wrap = Some(on))
|
||||
}
|
||||
|
||||
pub(super) fn margins(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (l, t, r, b) = (f64_param(p, "left"), f64_param(p, "top"), f64_param(p, "right"), f64_param(p, "bottom"));
|
||||
format_body(s, &move |bp| {
|
||||
if l.is_some() {
|
||||
bp.inset_l = l.map(|v| v.max(0.0));
|
||||
}
|
||||
if t.is_some() {
|
||||
bp.inset_t = t.map(|v| v.max(0.0));
|
||||
}
|
||||
if r.is_some() {
|
||||
bp.inset_r = r.map(|v| v.max(0.0));
|
||||
}
|
||||
if b.is_some() {
|
||||
bp.inset_b = b.map(|v| v.max(0.0));
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
use super::paragraph::{clamp_pt, finite_key, tab_align, tabs_params};
|
||||
use super::{Result, Session, TabStop, TextDir, Value, bad, bool_or, current_para, format_para, json, ok, resolve};
|
||||
|
||||
/// What the horizontal ruler shows while editing text: the paragraph's indents and tab stops,
|
||||
/// and where its text area sits on the slide.
|
||||
pub(super) fn ruler(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let props = current_para(s);
|
||||
let st = s.doc()?;
|
||||
let t = st.selection.text.clone().ok_or_else(|| bad("format.ruler", "not editing text"))?;
|
||||
let (x, y, w, rotated) = match (t.notes, st.shape(t.shape)) {
|
||||
(false, Some(sh)) => {
|
||||
let ctx = crate::cmd::text::ctx_for(&st.doc, &st.selection);
|
||||
let x = match &ctx {
|
||||
Some(c) => resolve::xfrm(c, sh),
|
||||
None => sh.xfrm.unwrap_or_default(),
|
||||
};
|
||||
let body = match &ctx {
|
||||
Some(c) => resolve::body(c, sh),
|
||||
None => sh.text.as_ref().map(|b| b.body.clone()).unwrap_or_default(),
|
||||
};
|
||||
let (l, r, tp) = (body.inset_l.unwrap_or(7.2), body.inset_r.unwrap_or(7.2), body.inset_t.unwrap_or(3.6));
|
||||
(x.x + l, x.y + tp, (x.w - l - r).max(0.0), x.rot != 0.0 || body.vert.is_some_and(|v| v != TextDir::Horizontal))
|
||||
}
|
||||
_ => (0.0, 0.0, 0.0, true),
|
||||
};
|
||||
let tabs: Vec<Value> = props.tabs.clone().unwrap_or_default().iter().map(|t| json!({"pos": t.pos, "align": t.align})).collect();
|
||||
Ok(json!({
|
||||
"x": x, "y": y, "width": w, "rotated": rotated,
|
||||
"marginLeft": props.margin_left.unwrap_or(0.0),
|
||||
"indent": props.indent.unwrap_or(0.0),
|
||||
"tabs": tabs,
|
||||
"defaultTab": props.default_tab.unwrap_or(72.0),
|
||||
}))
|
||||
}
|
||||
|
||||
const MAX_TABS: usize = 32;
|
||||
|
||||
pub(super) fn tabs(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "format.tabs";
|
||||
let fin = |v: Option<f64>| v.filter(|x| x.is_finite()).map(|x| x.clamp(0.0, 4000.0));
|
||||
let align_of = |v: &Value| match v.get("align").and_then(Value::as_str).unwrap_or("l") {
|
||||
"ctr" | "center" => "ctr",
|
||||
"r" | "right" => "r",
|
||||
"dec" | "decimal" => "dec",
|
||||
_ => "l",
|
||||
};
|
||||
let add = p.get("add").and_then(|a| fin(a.get("pos").and_then(Value::as_f64)).map(|pos| (pos, align_of(a).to_string())));
|
||||
let mv = p.get("move").and_then(|m| Some((fin(m.get("from").and_then(Value::as_f64))?, fin(m.get("to").and_then(Value::as_f64))?)));
|
||||
let remove = fin(p.get("remove").and_then(Value::as_f64));
|
||||
// `set` replaces every stop: entries without a usable position are dropped, an unknown
|
||||
// alignment or a non-object entry is an error; stops within 0.05 pt collapse (the last wins).
|
||||
let set = match p.get("set") {
|
||||
None => None,
|
||||
Some(Value::Array(items)) => {
|
||||
if items.len() > 64 {
|
||||
return Err(bad(CMD, "`set` has more than 64 tab stops"));
|
||||
}
|
||||
let mut out: Vec<TabStop> = Vec::new();
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
let Some(obj) = item.as_object() else {
|
||||
return Err(bad(CMD, format!("tab stop {i} must be an object")));
|
||||
};
|
||||
let Some(pos) = obj.get("pos").and_then(Value::as_f64).filter(|n| n.is_finite()).map(clamp_pt) else {
|
||||
continue;
|
||||
};
|
||||
let align = match obj.get("align").and_then(Value::as_str) {
|
||||
None => "l",
|
||||
Some(a) => tab_align(a).ok_or_else(|| bad(CMD, format!("tab stop {i} align must be l, ctr, r or dec")))?,
|
||||
};
|
||||
if let Some(prev) = out.iter_mut().find(|t| (t.pos - pos).abs() < 0.05) {
|
||||
*prev = TabStop { pos, align: align.to_string() };
|
||||
} else {
|
||||
out.push(TabStop { pos, align: align.to_string() });
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
Some(_) => return Err(bad(CMD, "`set` must be an array")),
|
||||
};
|
||||
let clear = bool_or(p, "clear", false);
|
||||
let default_tab = finite_key(p, CMD, "defaultTab")?.map(|v| v.clamp(1.0, 1584.0));
|
||||
let edits_stops = set.is_some() || add.is_some() || mv.is_some() || remove.is_some() || clear;
|
||||
if !edits_stops && default_tab.is_none() {
|
||||
// No fields: open the Tabs dialog on the caret's paragraph.
|
||||
s.ui_requests.push(crate::UiRequest::Dialog { id: "tabs".into(), params: tabs_params(s) });
|
||||
return ok();
|
||||
}
|
||||
// The stops as the caret's paragraph shows them (inherited ones included), so editing a
|
||||
// placeholder's tab that came from its layout works like editing one set on the paragraph.
|
||||
let base = current_para(s).tabs.unwrap_or_default();
|
||||
let near = |a: f64, b: f64| (a - b).abs() < 0.5;
|
||||
let mut stops = if clear { vec![] } else { set.unwrap_or(base) };
|
||||
if let Some(at) = remove {
|
||||
stops.retain(|t| !near(t.pos, at));
|
||||
}
|
||||
if let Some((from, to)) = mv
|
||||
&& let Some(t) = stops.iter_mut().find(|t| near(t.pos, from))
|
||||
{
|
||||
t.pos = to;
|
||||
}
|
||||
if let Some((pos, align)) = add {
|
||||
stops.retain(|t| !near(t.pos, pos));
|
||||
stops.push(TabStop { pos, align });
|
||||
}
|
||||
stops.sort_by(|a, b| a.pos.total_cmp(&b.pos));
|
||||
stops.dedup_by(|a, b| near(a.pos, b.pos));
|
||||
stops.truncate(MAX_TABS);
|
||||
format_para(s, &move |para| {
|
||||
if edits_stops {
|
||||
para.props.tabs = Some(stops.clone());
|
||||
}
|
||||
if let Some(d) = default_tab {
|
||||
para.props.default_tab = Some(d);
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
use super::{
|
||||
Line, Result, Session, Value, bad, color_param, current_run, ed, edit_shapes, f64_or, format_body, format_run, json, ok, on_param, str_param,
|
||||
};
|
||||
|
||||
pub(super) fn text_shadow(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = on_param(p, Some(current_run(s).shadow.is_some()));
|
||||
let sh = deckcraft_model::style::Shadow {
|
||||
color: deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::BLACK).with(deckcraft_color::ColorTransform::Alpha(43000)),
|
||||
blur: 3.0,
|
||||
dist: 2.0,
|
||||
dir: 45.0,
|
||||
inner: false,
|
||||
sx: 1.0,
|
||||
sy: 1.0,
|
||||
kx: 0.0,
|
||||
ky: 0.0,
|
||||
align: String::new(),
|
||||
rotate_with_shape: false,
|
||||
};
|
||||
format_run(s, &move |r| r.shadow = if v { Some(sh.clone()) } else { None })
|
||||
}
|
||||
|
||||
pub(super) fn text_outline(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = color_param(p, "color");
|
||||
let w = f64_or(p, "width", 0.75);
|
||||
format_run(s, &move |r| r.outline = c.clone().map(|c| Line::solid(c, w)))
|
||||
}
|
||||
|
||||
pub(super) fn text_warp(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let preset = str_param(p, "preset").ok_or_else(|| bad("format.textWarp", "missing `preset`"))?;
|
||||
let clear = preset == "none" || preset == "textNoShape";
|
||||
if !clear && !deckcraft_geom::warp::is_supported(preset) {
|
||||
return Err(bad("format.textWarp", format!("unknown text transform `{preset}`")));
|
||||
}
|
||||
let adj = match p.get("adj") {
|
||||
None => Vec::new(),
|
||||
Some(Value::Array(arr)) => {
|
||||
if arr.len() > 8 {
|
||||
return Err(bad("format.textWarp", "at most 8 adjust values"));
|
||||
}
|
||||
let mut v = Vec::with_capacity(arr.len());
|
||||
for a in arr {
|
||||
let Some(n) = a.as_f64().filter(|n| n.is_finite()) else {
|
||||
return Err(bad("format.textWarp", "adjust values must be finite numbers"));
|
||||
};
|
||||
v.push(n.clamp(-2.0e9, 2.0e9));
|
||||
}
|
||||
v
|
||||
}
|
||||
Some(_) => return Err(bad("format.textWarp", "`adj` must be an array of numbers")),
|
||||
};
|
||||
let stored = if clear { None } else { Some(preset.to_string()) };
|
||||
format_body(s, &|b| {
|
||||
b.warp.clone_from(&stored);
|
||||
b.warp_adj.clone_from(&adj);
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn case(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mode = str_param(p, "mode").unwrap_or("cycle").to_string();
|
||||
let st = s.doc()?;
|
||||
if let Some(t) = st.selection.text.clone() {
|
||||
let (mut a, mut b) = t.ordered();
|
||||
let body = crate::cmd::text::body_of(st, &t).cloned().unwrap_or_default();
|
||||
let collapsed = a == b;
|
||||
if collapsed {
|
||||
// Shift+F3 with a bare caret changes the word it is in and puts the caret at its start.
|
||||
let (wa, wb) = ed::word_at(&body, a);
|
||||
let is_word = ed::text_range(&body, wa, wb).chars().any(char::is_alphanumeric);
|
||||
if !(is_word && wa.1 <= a.1 && a.1 < wb.1) {
|
||||
return ok();
|
||||
}
|
||||
(a, b) = (wa, wb);
|
||||
}
|
||||
let m = if mode == "cycle" { ed::next_case(&ed::text_range(&body, a, b)).to_string() } else { mode.clone() };
|
||||
crate::cmd::text::edit_text(s, move |body, t| {
|
||||
let last = body.paragraphs.len().saturating_sub(1);
|
||||
for pi in a.0..=b.0.min(last) {
|
||||
let Some(para) = body.paragraphs.get_mut(pi) else { continue };
|
||||
let n = para.char_len();
|
||||
let from = if pi == a.0 { a.1 } else { 0 };
|
||||
let to = if pi == b.0 { b.1 } else { n };
|
||||
let mut pos = 0;
|
||||
let mut pieces = Vec::new();
|
||||
for (ri, r) in para.runs.iter().enumerate() {
|
||||
// Breaks and fields are read for context (a break ends a word) but not rewritten.
|
||||
let len = r.char_len();
|
||||
let rs = from.max(pos).saturating_sub(pos);
|
||||
let re = to.min(pos + len).saturating_sub(pos);
|
||||
if re > rs {
|
||||
pieces.push((ri, rs, re));
|
||||
}
|
||||
pos += len;
|
||||
}
|
||||
let mids: Vec<String> = pieces
|
||||
.iter()
|
||||
.map(|&(ri, rs, re)| para.runs.get(ri).map(|r| r.content().chars().skip(rs).take(re - rs).collect()).unwrap_or_default())
|
||||
.collect();
|
||||
let changed = ed::change_case_parts(&mids.iter().map(String::as_str).collect::<Vec<_>>(), &m);
|
||||
for (&(ri, rs, re), new) in pieces.iter().zip(changed) {
|
||||
let Some(r) = para.runs.get_mut(ri).filter(|r| r.kind == deckcraft_model::text::RunKind::Text) else { continue };
|
||||
let pre: String = r.text.chars().take(rs).collect();
|
||||
let post: String = r.text.chars().skip(re).collect();
|
||||
r.text = format!("{pre}{new}{post}");
|
||||
}
|
||||
}
|
||||
if collapsed {
|
||||
t.anchor = a;
|
||||
t.caret = a;
|
||||
}
|
||||
})?;
|
||||
return ok();
|
||||
}
|
||||
let mode = if mode == "cycle" {
|
||||
let all: String =
|
||||
st.selection.shapes.iter().filter_map(|id| st.shape(*id)).filter_map(|sh| sh.text.as_ref()).map(|t| t.text()).collect::<Vec<_>>().join(
|
||||
"
|
||||
",
|
||||
);
|
||||
ed::next_case(&all).to_string()
|
||||
} else {
|
||||
mode
|
||||
};
|
||||
edit_shapes(s, &json!({}), "format.case", |sh| {
|
||||
if let Some(t) = sh.text.as_mut() {
|
||||
for para in &mut t.paragraphs {
|
||||
// Breaks and fields are read for context (a break ends a word) but not rewritten.
|
||||
let texts: Vec<&str> = para.runs.iter().map(|r| r.content()).collect();
|
||||
let changed = ed::change_case_parts(&texts, &mode);
|
||||
for (r, new) in para.runs.iter_mut().zip(changed) {
|
||||
if r.kind == deckcraft_model::text::RunKind::Text {
|
||||
r.text = new;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -4,10 +4,11 @@
|
||||
//! 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 super::{CommandSpec, bad, cmd, has_slide, str_param, usize_param, xfrm_of};
|
||||
use crate::{Result, Selection, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -57,9 +58,44 @@ fn roles(shapes: &[Shape]) -> Roles {
|
||||
r
|
||||
}
|
||||
|
||||
/// The ideas for slide `index` (none when it has nothing to arrange).
|
||||
/// 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()
|
||||
@@ -84,6 +120,22 @@ fn cover_crop(src: f64, dst: f64) -> [f64; 4] {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
@@ -180,8 +232,13 @@ pub fn apply_idea(doc: &mut Presentation, sel: &Selection, index: usize, id: &st
|
||||
let pic = slide.shapes.remove(p);
|
||||
slide.shapes.insert(0, pic);
|
||||
}
|
||||
if band && let Some(t) = slide.shapes.iter_mut().find(|s| s.ph.as_ref().is_some_and(|p| p.kind.is_title())) {
|
||||
t.fill = Some(Fill::solid(deckcraft_model::ColorRef::rgb(Rgba::rgba(255, 255, 255, 220))));
|
||||
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(())
|
||||
}
|
||||
@@ -228,6 +285,7 @@ mod tests {
|
||||
};
|
||||
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()
|
||||
@@ -314,6 +372,170 @@ mod tests {
|
||||
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);
|
||||
|
||||
+49
-1804
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,724 @@
|
||||
use super::picture::{content_type, media_bytes};
|
||||
use super::{NO_ID_SPACE, Result, Session, ShapeKind, Value, bad, base64_encode, json};
|
||||
|
||||
const ALBUM_MAX_ITEMS: usize = 48;
|
||||
|
||||
const ALBUM_MAX_BYTES: usize = 32 * 1024 * 1024;
|
||||
|
||||
const ALBUM_MAX_TOTAL: usize = 64 * 1024 * 1024;
|
||||
|
||||
const ALBUM_MAX_TEXT: usize = 400;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct AlbumLayout {
|
||||
cols: usize,
|
||||
rows: usize,
|
||||
per: usize,
|
||||
title: bool,
|
||||
fit: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum AlbumFrame {
|
||||
None,
|
||||
Rect,
|
||||
Rounded,
|
||||
Simple,
|
||||
}
|
||||
|
||||
struct AlbumPic {
|
||||
name: String,
|
||||
bytes: Vec<u8>,
|
||||
px_w: f64,
|
||||
px_h: f64,
|
||||
rot: f64,
|
||||
brightness: f64,
|
||||
contrast: f64,
|
||||
}
|
||||
|
||||
struct AlbumOpts {
|
||||
layout_key: String,
|
||||
frame_key: String,
|
||||
layout: AlbumLayout,
|
||||
frame: AlbumFrame,
|
||||
captions: bool,
|
||||
grayscale: bool,
|
||||
theme: Option<String>,
|
||||
}
|
||||
|
||||
enum AlbumItem {
|
||||
Picture(AlbumPic),
|
||||
Text(String),
|
||||
}
|
||||
|
||||
enum AlbumSlide {
|
||||
Pictures(Vec<AlbumPic>),
|
||||
Text(String),
|
||||
}
|
||||
|
||||
/// Insert a photo album after the current slide.
|
||||
///
|
||||
/// PowerPoint starts a new presentation. These slides go into the open one, so the album is one
|
||||
/// undo step. Omitting `pictures` asks the UI to open the dialog and does not edit.
|
||||
pub(super) fn photo_album(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if p.get("pictures").is_none_or(Value::is_null) {
|
||||
s.ui_requests.push(crate::UiRequest::Dialog { id: "photoAlbum".into(), params: json!({}) });
|
||||
return Ok(json!({"dialog": "photoAlbum"}));
|
||||
}
|
||||
let opts = album_opts(p)?;
|
||||
let items = album_items(p)?;
|
||||
commit_album(s, items, opts, None)
|
||||
}
|
||||
|
||||
pub(super) fn photo_album_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if p.get("pictures").is_none_or(Value::is_null) {
|
||||
let seed = album_seed(s)?;
|
||||
let album = seed.get("album").cloned().unwrap_or(Value::Null);
|
||||
s.ui_requests.push(crate::UiRequest::Dialog { id: "photoAlbum".into(), params: seed });
|
||||
return Ok(json!({"dialog": "photoAlbum", "album": album}));
|
||||
}
|
||||
let opts = album_opts(p)?;
|
||||
let items = album_items(p)?;
|
||||
let (id, ids) = album_target(s, p)?;
|
||||
commit_album(s, items, opts, Some((id, ids)))
|
||||
}
|
||||
|
||||
fn commit_album(s: &mut Session, items: Vec<AlbumItem>, opts: AlbumOpts, replace: Option<(u32, Vec<u64>)>) -> Result<Value> {
|
||||
let (snapshot, sel) = {
|
||||
let st = s.doc()?;
|
||||
(st.doc.clone(), st.selection.clone())
|
||||
};
|
||||
let result = (|| {
|
||||
let album_id = match replace.as_ref().map(|(id, _)| *id) {
|
||||
Some(id) if id != 0 => id,
|
||||
_ => {
|
||||
let cmd = if replace.is_some() { "insert.photoAlbumEdit" } else { "insert.photoAlbum" };
|
||||
s.edit(|doc, _| doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE)))?
|
||||
}
|
||||
};
|
||||
let at = if let Some((_, ids)) = &replace {
|
||||
let st = s.doc()?;
|
||||
let indices: Vec<usize> = ids.iter().filter_map(|id| st.doc.slide_index(deckcraft_model::SlideId(*id as u32))).collect();
|
||||
let Some(at) = indices.iter().copied().min() else {
|
||||
return Err(bad("insert.photoAlbumEdit", "that photo album is no longer in this presentation"));
|
||||
};
|
||||
s.execute("slide.delete", &json!({"ids": ids}))?;
|
||||
Some(at)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut opts = opts;
|
||||
if opts.theme.as_deref().unwrap_or("").is_empty() {
|
||||
opts.theme = s.doc().ok().and_then(|st| st.doc.masters.first().map(|m| m.theme.name.clone())).filter(|n| !n.is_empty());
|
||||
}
|
||||
apply_theme_if_needed(s, opts.theme.as_deref())?;
|
||||
insert_album(s, items, &opts, at, album_id)
|
||||
})();
|
||||
if result.is_err()
|
||||
&& let Some(st) = s.active_mut()
|
||||
{
|
||||
st.doc = snapshot;
|
||||
st.selection = sel;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn album_norm(s: &str) -> String {
|
||||
s.chars().filter(char::is_ascii_alphanumeric).collect::<String>().to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn album_layout(p: &Value) -> Result<AlbumLayout> {
|
||||
let key = match p.get("layout") {
|
||||
None => return Ok(AlbumLayout { cols: 1, rows: 1, per: 1, title: false, fit: false }),
|
||||
Some(Value::String(s)) => s.as_str(),
|
||||
Some(_) => return Err(bad("insert.photoAlbum", "`layout` must be text")),
|
||||
};
|
||||
match album_norm(key).as_str() {
|
||||
"fit" => Ok(AlbumLayout { cols: 1, rows: 1, per: 1, title: false, fit: true }),
|
||||
"1" | "one" => Ok(AlbumLayout { cols: 1, rows: 1, per: 1, title: false, fit: false }),
|
||||
"1title" | "onetitle" => Ok(AlbumLayout { cols: 1, rows: 1, per: 1, title: true, fit: false }),
|
||||
"2" | "two" => Ok(AlbumLayout { cols: 2, rows: 1, per: 2, title: false, fit: false }),
|
||||
"2title" | "twotitle" => Ok(AlbumLayout { cols: 2, rows: 1, per: 2, title: true, fit: false }),
|
||||
"4" | "four" => Ok(AlbumLayout { cols: 2, rows: 2, per: 4, title: false, fit: false }),
|
||||
"4title" | "fourtitle" => Ok(AlbumLayout { cols: 2, rows: 2, per: 4, title: true, fit: false }),
|
||||
_ => Err(bad("insert.photoAlbum", format!("unknown layout `{key}` (use fit, 1, 2, 4, 1title, 2title or 4title)"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn album_frame(p: &Value) -> Result<AlbumFrame> {
|
||||
let key = match p.get("frame") {
|
||||
None => return Ok(AlbumFrame::None),
|
||||
Some(Value::String(s)) => s.as_str(),
|
||||
Some(_) => return Err(bad("insert.photoAlbum", "`frame` must be text")),
|
||||
};
|
||||
match album_norm(key).as_str() {
|
||||
"none" => Ok(AlbumFrame::None),
|
||||
"rect" | "rectangle" => Ok(AlbumFrame::Rect),
|
||||
"rounded" | "round" | "roundedrect" | "roundedrectangle" => Ok(AlbumFrame::Rounded),
|
||||
"simple" | "simpleframe" | "simplewhite" | "white" => Ok(AlbumFrame::Simple),
|
||||
_ => Err(bad("insert.photoAlbum", format!("unknown frame `{key}` (use none, rect, rounded or simple)"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn album_captions(p: &Value) -> Result<bool> {
|
||||
match p.get("captions") {
|
||||
None => Ok(false),
|
||||
Some(Value::Bool(v)) => Ok(*v),
|
||||
Some(_) => Err(bad("insert.photoAlbum", "`captions` must be true or false")),
|
||||
}
|
||||
}
|
||||
|
||||
fn album_bool(p: &Value, key: &str) -> Result<bool> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(false),
|
||||
Some(Value::Bool(v)) => Ok(*v),
|
||||
Some(_) => Err(bad("insert.photoAlbum", format!("`{key}` must be true or false"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn layout_key_of(layout: AlbumLayout) -> String {
|
||||
if layout.fit {
|
||||
return "fit".into();
|
||||
}
|
||||
let n = match layout.per {
|
||||
2 => "2",
|
||||
4 => "4",
|
||||
_ => "1",
|
||||
};
|
||||
if layout.title { format!("{n}title") } else { n.into() }
|
||||
}
|
||||
|
||||
fn frame_key_of(frame: AlbumFrame) -> &'static str {
|
||||
match frame {
|
||||
AlbumFrame::None => "none",
|
||||
AlbumFrame::Rect => "rect",
|
||||
AlbumFrame::Rounded => "rounded",
|
||||
AlbumFrame::Simple => "simple",
|
||||
}
|
||||
}
|
||||
|
||||
fn album_opts(p: &Value) -> Result<AlbumOpts> {
|
||||
let layout = album_layout(p)?;
|
||||
let frame = album_frame(p)?;
|
||||
let theme = match p.get("theme") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(s)) if s.trim().is_empty() => None,
|
||||
Some(Value::String(s)) => {
|
||||
let name = s.trim();
|
||||
let found = deckcraft_model::theme::builtin_themes().into_iter().find(|t| t.name.eq_ignore_ascii_case(name));
|
||||
let Some(t) = found else {
|
||||
return Err(bad("insert.photoAlbum", format!("unknown theme `{name}`")));
|
||||
};
|
||||
Some(t.name)
|
||||
}
|
||||
Some(_) => return Err(bad("insert.photoAlbum", "`theme` must be a theme name")),
|
||||
};
|
||||
Ok(AlbumOpts {
|
||||
layout_key: layout_key_of(layout),
|
||||
frame_key: frame_key_of(frame).into(),
|
||||
layout,
|
||||
frame,
|
||||
captions: album_captions(p)?,
|
||||
grayscale: album_bool(p, "grayscale")?,
|
||||
theme,
|
||||
})
|
||||
}
|
||||
|
||||
/// A finite number, or 0 when the key is absent. A present non-number is an error.
|
||||
fn album_finite(item: &Value, key: &str) -> Result<f64> {
|
||||
match item.get(key) {
|
||||
None | Some(Value::Null) => Ok(0.0),
|
||||
Some(v) => v.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad("insert.photoAlbum", format!("`{key}` must be a number"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn album_rot(item: &Value) -> Result<f64> {
|
||||
let n = album_finite(item, "rot")?;
|
||||
Ok(((n % 360.0) + 360.0) % 360.0)
|
||||
}
|
||||
|
||||
fn album_unit(item: &Value, key: &str) -> Result<f64> {
|
||||
let n = album_finite(item, key)?;
|
||||
if !(-1.0..=1.0).contains(&n) {
|
||||
return Err(bad("insert.photoAlbum", format!("`{key}` must be between -1 and 1")));
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
fn album_items(p: &Value) -> Result<Vec<AlbumItem>> {
|
||||
let Some(arr) = p.get("pictures").and_then(Value::as_array) else {
|
||||
return Err(bad("insert.photoAlbum", "`pictures` must be a list"));
|
||||
};
|
||||
if arr.is_empty() {
|
||||
return Err(bad("insert.photoAlbum", "add at least one picture or text box"));
|
||||
}
|
||||
if arr.len() > ALBUM_MAX_ITEMS {
|
||||
return Err(bad("insert.photoAlbum", "a photo album holds at most 48 pictures or text boxes"));
|
||||
}
|
||||
let mut items = Vec::with_capacity(arr.len());
|
||||
let mut total = 0usize;
|
||||
for item in arr {
|
||||
let Some(obj) = item.as_object() else {
|
||||
return Err(bad("insert.photoAlbum", "each album item must be a picture or a text box"));
|
||||
};
|
||||
let has_media = obj.contains_key("data") || obj.contains_key("path");
|
||||
if !has_media {
|
||||
let Some(text) = obj.get("text").and_then(Value::as_str) else {
|
||||
return Err(bad("insert.photoAlbum", "each album item needs `path`, `data` or `text`"));
|
||||
};
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return Err(bad("insert.photoAlbum", "a text box needs some text"));
|
||||
}
|
||||
if text.chars().count() > ALBUM_MAX_TEXT {
|
||||
return Err(bad("insert.photoAlbum", "text is longer than 400 characters"));
|
||||
}
|
||||
items.push(AlbumItem::Text(text.to_string()));
|
||||
continue;
|
||||
}
|
||||
let (name, bytes) = media_bytes(item, "insert.photoAlbum")?;
|
||||
if bytes.len() > ALBUM_MAX_BYTES {
|
||||
return Err(bad("insert.photoAlbum", format!("{name} is larger than 32 MB")));
|
||||
}
|
||||
let next = total.saturating_add(bytes.len());
|
||||
if next > ALBUM_MAX_TOTAL {
|
||||
return Err(bad("insert.photoAlbum", "the album is larger than 64 MB"));
|
||||
}
|
||||
total = next;
|
||||
let ct = content_type(&name, &bytes);
|
||||
if !ct.starts_with("image/") {
|
||||
return Err(bad("insert.photoAlbum", format!("{name} is not a picture")));
|
||||
}
|
||||
if ct != "image/svg+xml" && deckcraft_render::decode_image(&bytes).is_none() {
|
||||
return Err(bad("insert.photoAlbum", format!("{name}: the picture can't be read")));
|
||||
}
|
||||
let (px_w, px_h) = image_px(&bytes);
|
||||
items.push(AlbumItem::Picture(AlbumPic {
|
||||
name,
|
||||
bytes,
|
||||
px_w,
|
||||
px_h,
|
||||
rot: album_rot(item)?,
|
||||
brightness: album_unit(item, "brightness")?,
|
||||
contrast: album_unit(item, "contrast")?,
|
||||
}));
|
||||
}
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
fn image_px(bytes: &[u8]) -> (f64, f64) {
|
||||
image::ImageReader::new(std::io::Cursor::new(bytes))
|
||||
.with_guessed_format()
|
||||
.ok()
|
||||
.and_then(|r| r.into_dimensions().ok())
|
||||
.map(|(w, h)| (w as f64, h as f64))
|
||||
.filter(|(w, h)| *w > 0.0 && *h > 0.0)
|
||||
.unwrap_or((4.0, 3.0))
|
||||
}
|
||||
|
||||
fn file_stem(name: &str) -> String {
|
||||
let base = std::path::Path::new(name).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or(name);
|
||||
let stem = match base.rsplit_once('.') {
|
||||
Some((s, ext)) if !s.is_empty() && !ext.is_empty() => s,
|
||||
_ => base,
|
||||
};
|
||||
if stem.is_empty() { "Picture".to_string() } else { stem.to_string() }
|
||||
}
|
||||
|
||||
fn album_plan(items: Vec<AlbumItem>, per: usize) -> Vec<AlbumSlide> {
|
||||
let per = per.max(1);
|
||||
let mut out = Vec::new();
|
||||
let mut batch = Vec::new();
|
||||
for item in items {
|
||||
match item {
|
||||
AlbumItem::Text(text) => {
|
||||
if !batch.is_empty() {
|
||||
out.push(AlbumSlide::Pictures(std::mem::take(&mut batch)));
|
||||
}
|
||||
out.push(AlbumSlide::Text(text));
|
||||
}
|
||||
AlbumItem::Picture(pic) => {
|
||||
batch.push(pic);
|
||||
if batch.len() >= per {
|
||||
out.push(AlbumSlide::Pictures(std::mem::take(&mut batch)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !batch.is_empty() {
|
||||
out.push(AlbumSlide::Pictures(batch));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
struct Placed {
|
||||
pic: [f64; 4],
|
||||
caption: Option<[f64; 4]>,
|
||||
}
|
||||
|
||||
fn fit_in(box_w: f64, box_h: f64, px_w: f64, px_h: f64) -> (f64, f64) {
|
||||
let aspect = if px_h > 0.0 { px_w / px_h } else { 4.0 / 3.0 };
|
||||
let box_w = box_w.max(1.0);
|
||||
let box_h = box_h.max(1.0);
|
||||
if !aspect.is_finite() || aspect <= 0.0 {
|
||||
return (box_w, box_h);
|
||||
}
|
||||
if aspect >= box_w / box_h { (box_w, (box_w / aspect).max(1.0)) } else { ((box_h * aspect).max(1.0), box_h) }
|
||||
}
|
||||
|
||||
fn place_album(width: f64, height: f64, layout: AlbumLayout, n: usize, captions: bool) -> (Option<[f64; 4]>, Vec<Placed>) {
|
||||
let margin = if layout.fit { 18.0 } else { 36.0 };
|
||||
let title_band = if layout.title { 48.0 } else { 0.0 };
|
||||
let gap = 12.0;
|
||||
let cap_h = 22.0;
|
||||
let cap_gap = 4.0;
|
||||
let left = margin;
|
||||
let top = margin + title_band;
|
||||
let grid_w = (width - margin - left).max(1.0);
|
||||
let grid_h = (height - margin - top).max(1.0);
|
||||
let cols = layout.cols.max(1);
|
||||
let rows = layout.rows.max(1);
|
||||
let cell_w = ((grid_w - gap * (cols.saturating_sub(1) as f64)) / cols as f64).max(1.0);
|
||||
let cell_h = ((grid_h - gap * (rows.saturating_sub(1) as f64)) / rows as f64).max(1.0);
|
||||
let pic_h = if captions { (cell_h - cap_h - cap_gap).max(1.0) } else { cell_h };
|
||||
let mut placed = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
let col = i % cols;
|
||||
let row = i / cols;
|
||||
if row >= rows {
|
||||
break;
|
||||
}
|
||||
let cell_x = left + col as f64 * (cell_w + gap);
|
||||
let cell_y = top + row as f64 * (cell_h + gap);
|
||||
placed.push(Placed { pic: [cell_x, cell_y, cell_w, pic_h], caption: None });
|
||||
}
|
||||
let title = if layout.title { Some([margin, 8.0, (width - 2.0 * margin).max(1.0), 32.0]) } else { None };
|
||||
(title, placed)
|
||||
}
|
||||
|
||||
fn place_pictures(width: f64, height: f64, layout: AlbumLayout, pics: &[AlbumPic], captions: bool) -> (Option<[f64; 4]>, Vec<Placed>) {
|
||||
let (title, slots) = place_album(width, height, layout, pics.len(), captions);
|
||||
let placed = pics
|
||||
.iter()
|
||||
.zip(slots)
|
||||
.map(|(pic, slot)| {
|
||||
let (dw, dh) = fit_in(slot.pic[2], slot.pic[3], pic.px_w, pic.px_h);
|
||||
let x = slot.pic[0] + (slot.pic[2] - dw) / 2.0;
|
||||
let y = slot.pic[1] + (slot.pic[3] - dh) / 2.0;
|
||||
let caption = if captions { Some([x, y + dh + 4.0, dw.max(36.0), 22.0]) } else { None };
|
||||
Placed { pic: [x, y, dw, dh], caption }
|
||||
})
|
||||
.collect();
|
||||
(title, placed)
|
||||
}
|
||||
|
||||
fn apply_frame(s: &mut Session, frame: AlbumFrame, id: u64) -> Result<()> {
|
||||
let params = match frame {
|
||||
AlbumFrame::None => return Ok(()),
|
||||
AlbumFrame::Rect => json!({"id": id, "color": "#1A1A1A", "width": 1.5}),
|
||||
AlbumFrame::Rounded => json!({"id": id, "color": "#1A1A1A", "width": 2.25, "join": "round"}),
|
||||
AlbumFrame::Simple => json!({"id": id, "color": "#FFFFFF", "width": 6.0}),
|
||||
};
|
||||
s.execute("shape.line", ¶ms)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct AlbumMark {
|
||||
id: u32,
|
||||
layout: String,
|
||||
frame: String,
|
||||
captions: bool,
|
||||
grayscale: bool,
|
||||
theme: String,
|
||||
}
|
||||
|
||||
fn parse_album_mark(raw: &str) -> Option<AlbumMark> {
|
||||
let rest = raw.strip_prefix("deckcraft-album:")?;
|
||||
let (id_str, rest) = rest.split_once(';').unwrap_or((rest, ""));
|
||||
let id = id_str.parse::<u32>().ok().filter(|id| *id != 0)?;
|
||||
let mut mark = AlbumMark { id, layout: String::new(), frame: String::new(), captions: false, grayscale: false, theme: String::new() };
|
||||
if !rest.is_empty() {
|
||||
for part in rest.split(';') {
|
||||
let Some((k, v)) = part.split_once('=') else { continue };
|
||||
match k {
|
||||
"layout" => mark.layout = v.to_string(),
|
||||
"frame" => mark.frame = v.to_string(),
|
||||
"captions" => mark.captions = v == "1",
|
||||
"gray" => mark.grayscale = v == "1",
|
||||
"theme" => mark.theme = v.to_string(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(mark)
|
||||
}
|
||||
|
||||
fn album_marker(id: u32, opts: &AlbumOpts) -> String {
|
||||
let theme = opts.theme.as_deref().unwrap_or("");
|
||||
format!(
|
||||
"deckcraft-album:{id};layout={};frame={};captions={};gray={};theme={theme}",
|
||||
opts.layout_key,
|
||||
opts.frame_key,
|
||||
if opts.captions { 1 } else { 0 },
|
||||
if opts.grayscale { 1 } else { 0 },
|
||||
)
|
||||
}
|
||||
|
||||
fn stamp_album_slide(s: &mut Session, album_id: u32, opts: &AlbumOpts) -> Result<()> {
|
||||
let marker = album_marker(album_id, opts);
|
||||
s.edit(|doc, sel| {
|
||||
let Some(slide) = doc.slides.get_mut(sel.slide) else {
|
||||
return Err(bad("insert.photoAlbum", "no slide to mark"));
|
||||
};
|
||||
let slide = std::sync::Arc::make_mut(slide);
|
||||
if slide.raw_ext.as_deref().is_none_or(|ext| ext.starts_with("deckcraft-album:")) {
|
||||
slide.raw_ext = Some(marker);
|
||||
}
|
||||
slide.name = "Photo Album".into();
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_theme_if_needed(s: &mut Session, theme: Option<&str>) -> Result<()> {
|
||||
let Some(name) = theme.filter(|n| !n.is_empty()) else { return Ok(()) };
|
||||
let current = s.doc()?.doc.masters.first().map(|m| m.theme.name.clone()).unwrap_or_default();
|
||||
if current.eq_ignore_ascii_case(name) {
|
||||
return Ok(());
|
||||
}
|
||||
s.execute("design.theme", &json!({"name": name}))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn album_id_param(p: &Value) -> Option<u32> {
|
||||
match p.get("album") {
|
||||
Some(Value::Number(n)) => n.as_u64().and_then(|v| u32::try_from(v).ok()).filter(|v| *v != 0),
|
||||
Some(Value::String(s)) => s.trim().parse::<u32>().ok().filter(|v| *v != 0),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn slides_with_album(doc: &deckcraft_model::Presentation, id: u32) -> Vec<u64> {
|
||||
doc.slides.iter().filter(|s| s.raw_ext.as_deref().and_then(parse_album_mark).is_some_and(|m| m.id == id)).map(|s| u64::from(s.id.0)).collect()
|
||||
}
|
||||
|
||||
fn album_name_run(doc: &deckcraft_model::Presentation, index: usize) -> Vec<u64> {
|
||||
if doc.slides.get(index).is_none_or(|s| s.name != "Photo Album") {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut start = index;
|
||||
while start > 0 && doc.slides.get(start - 1).is_some_and(|s| s.name == "Photo Album") {
|
||||
start -= 1;
|
||||
}
|
||||
let mut end = index;
|
||||
while doc.slides.get(end + 1).is_some_and(|s| s.name == "Photo Album") {
|
||||
end += 1;
|
||||
}
|
||||
doc.slides[start..=end].iter().map(|s| u64::from(s.id.0)).collect()
|
||||
}
|
||||
|
||||
/// The album under the selection, or the one named by `album`. Id 0 is a name-only run (no marker).
|
||||
fn album_target(s: &Session, p: &Value) -> Result<(u32, Vec<u64>)> {
|
||||
let st = s.doc()?;
|
||||
if let Some(id) = album_id_param(p) {
|
||||
let ids = slides_with_album(&st.doc, id);
|
||||
if ids.is_empty() {
|
||||
return Err(bad("insert.photoAlbumEdit", "that photo album is no longer in this presentation"));
|
||||
}
|
||||
return Ok((id, ids));
|
||||
}
|
||||
let Some(slide) = st.doc.slides.get(st.selection.slide) else {
|
||||
return Err(bad("insert.photoAlbumEdit", "this slide is not part of a photo album"));
|
||||
};
|
||||
if let Some(mark) = slide.raw_ext.as_deref().and_then(parse_album_mark) {
|
||||
let ids = slides_with_album(&st.doc, mark.id);
|
||||
if ids.is_empty() {
|
||||
return Err(bad("insert.photoAlbumEdit", "this slide is not part of a photo album"));
|
||||
}
|
||||
return Ok((mark.id, ids));
|
||||
}
|
||||
if slide.name == "Photo Album" {
|
||||
let ids = album_name_run(&st.doc, st.selection.slide);
|
||||
if !ids.is_empty() {
|
||||
return Ok((0, ids));
|
||||
}
|
||||
}
|
||||
Err(bad("insert.photoAlbumEdit", "this slide is not part of a photo album"))
|
||||
}
|
||||
|
||||
fn pic_count(slide: &deckcraft_model::Slide) -> usize {
|
||||
slide.shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).count()
|
||||
}
|
||||
|
||||
fn infer_album(slides: &[&deckcraft_model::Slide], theme_now: &str) -> (String, String, bool, bool, String) {
|
||||
let max_pics = slides.iter().map(|s| pic_count(s)).max().unwrap_or(1);
|
||||
let per = if max_pics >= 3 {
|
||||
"4"
|
||||
} else if max_pics == 2 {
|
||||
"2"
|
||||
} else {
|
||||
"1"
|
||||
};
|
||||
let title =
|
||||
slides.iter().any(|s| pic_count(s) > 0 && s.shapes.iter().any(|sh| sh.text_box && sh.xfrm.is_some_and(|x| x.y < 36.0 && x.h <= 48.0)));
|
||||
let layout = if title { format!("{per}title") } else { per.into() };
|
||||
let width = slides.iter().find_map(|s| {
|
||||
s.shapes.iter().find_map(|sh| match &sh.kind {
|
||||
ShapeKind::Picture { .. } => sh.line.as_ref().and_then(|l| l.width),
|
||||
_ => None,
|
||||
})
|
||||
});
|
||||
let frame = match width {
|
||||
None => "none",
|
||||
Some(w) if (w - 6.0).abs() < 0.2 => "simple",
|
||||
Some(w) if (w - 2.25).abs() < 0.2 => "rounded",
|
||||
Some(w) if w < 0.2 => "none",
|
||||
Some(_) => "rect",
|
||||
};
|
||||
let captions = slides.iter().any(|s| {
|
||||
let bottoms: Vec<f64> =
|
||||
s.shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).filter_map(|sh| sh.xfrm.map(|x| x.y + x.h)).collect();
|
||||
!bottoms.is_empty() && s.shapes.iter().any(|sh| sh.text_box && sh.xfrm.is_some_and(|x| bottoms.iter().any(|b| x.y + 1.0 >= *b)))
|
||||
});
|
||||
let mut any = false;
|
||||
let mut gray = true;
|
||||
for s in slides {
|
||||
for sh in &s.shapes {
|
||||
if let ShapeKind::Picture { fill } = &sh.kind {
|
||||
any = true;
|
||||
gray &= fill.adjust.grayscale;
|
||||
}
|
||||
}
|
||||
}
|
||||
(layout, frame.into(), captions, any && gray, theme_now.to_string())
|
||||
}
|
||||
|
||||
fn items_from_slides(doc: &deckcraft_model::Presentation, slides: &[&deckcraft_model::Slide]) -> Vec<Value> {
|
||||
let mut items = Vec::new();
|
||||
for slide in slides {
|
||||
let pics: Vec<&deckcraft_model::Shape> = slide.shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).collect();
|
||||
if pics.is_empty() {
|
||||
let text = slide
|
||||
.shapes
|
||||
.iter()
|
||||
.filter(|sh| sh.text_box)
|
||||
.filter_map(|sh| sh.text.as_ref().map(|t| t.text()))
|
||||
.find(|t| !t.trim().is_empty())
|
||||
.unwrap_or_default();
|
||||
if !text.trim().is_empty() {
|
||||
items.push(json!({"text": text.trim()}));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for sh in pics {
|
||||
let ShapeKind::Picture { fill } = &sh.kind else { continue };
|
||||
let media = doc.media(fill.media);
|
||||
let name = if !sh.descr.is_empty() {
|
||||
sh.descr.clone()
|
||||
} else {
|
||||
media.map(|m| m.name.clone()).filter(|n| !n.is_empty()).unwrap_or_else(|| "picture".into())
|
||||
};
|
||||
items.push(json!({
|
||||
"name": name,
|
||||
"data": media.map(|m| base64_encode(&m.data)).unwrap_or_default(),
|
||||
"rot": sh.xfrm.map(|x| x.rot).unwrap_or(0.0),
|
||||
"brightness": fill.adjust.brightness,
|
||||
"contrast": fill.adjust.contrast,
|
||||
}));
|
||||
}
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
fn album_seed(s: &Session) -> Result<Value> {
|
||||
let (id, ids) = album_target(s, &Value::Null)?;
|
||||
let st = s.doc()?;
|
||||
let slides: Vec<&deckcraft_model::Slide> =
|
||||
ids.iter().filter_map(|sid| u32::try_from(*sid).ok()).filter_map(|n| st.doc.slide(deckcraft_model::SlideId(n))).collect();
|
||||
let theme_now = st.doc.masters.first().map(|m| m.theme.name.clone()).filter(|n| !n.is_empty()).unwrap_or_else(|| "DeckCraft".into());
|
||||
let inferred = infer_album(&slides, &theme_now);
|
||||
let mark = slides.first().and_then(|sl| sl.raw_ext.as_deref().and_then(parse_album_mark));
|
||||
let (layout, frame, captions, grayscale, theme) = if let Some(mark) = mark {
|
||||
(
|
||||
if mark.layout.is_empty() { inferred.0 } else { mark.layout },
|
||||
if mark.frame.is_empty() { inferred.1 } else { mark.frame },
|
||||
mark.captions,
|
||||
mark.grayscale,
|
||||
if mark.theme.is_empty() { theme_now } else { mark.theme },
|
||||
)
|
||||
} else {
|
||||
inferred
|
||||
};
|
||||
Ok(json!({
|
||||
"album": if id == 0 { Value::Null } else { json!(id) },
|
||||
"layout": layout,
|
||||
"frame": frame,
|
||||
"captions": captions,
|
||||
"grayscale": grayscale,
|
||||
"theme": theme,
|
||||
"items": items_from_slides(&st.doc, &slides),
|
||||
}))
|
||||
}
|
||||
|
||||
fn insert_album(s: &mut Session, items: Vec<AlbumItem>, opts: &AlbumOpts, at: Option<usize>, album_id: u32) -> Result<Value> {
|
||||
let (width, height, first) = {
|
||||
let st = s.doc()?;
|
||||
let first = at.unwrap_or_else(|| if st.doc.slides.is_empty() { 0 } else { st.selection.slide.saturating_add(1) });
|
||||
(st.doc.slide_size.width, st.doc.slide_size.height, first)
|
||||
};
|
||||
let layout = opts.layout;
|
||||
let frame = opts.frame;
|
||||
let captions = opts.captions;
|
||||
let plan = album_plan(items, layout.per);
|
||||
let mut slides = 0usize;
|
||||
let mut pictures = 0usize;
|
||||
let mut pin = at;
|
||||
for slide in plan {
|
||||
if let Some(index) = pin.take() {
|
||||
s.execute("slide.new", &json!({"layout": "blank", "at": index}))?;
|
||||
} else {
|
||||
s.execute("slide.new", &json!({"layout": "blank"}))?;
|
||||
}
|
||||
stamp_album_slide(s, album_id, opts)?;
|
||||
slides += 1;
|
||||
match slide {
|
||||
AlbumSlide::Text(text) => {
|
||||
let rect = if layout.title {
|
||||
[36.0, 18.0, (width - 72.0).max(36.0), 48.0]
|
||||
} else {
|
||||
[36.0, 36.0, (width - 72.0).max(36.0), (height - 72.0).max(36.0)]
|
||||
};
|
||||
s.execute("insert.textBox", &json!({"rect": rect, "text": text}))?;
|
||||
}
|
||||
AlbumSlide::Pictures(pics) => {
|
||||
let stem = pics.first().map(|p| file_stem(&p.name)).unwrap_or_default();
|
||||
let (title, placed) = place_pictures(width, height, layout, &pics, captions);
|
||||
for (pic, cell) in pics.iter().zip(placed) {
|
||||
let data = base64_encode(&pic.bytes);
|
||||
let inserted = s.execute("insert.picture", &json!({"name": pic.name, "data": data, "rect": cell.pic}))?;
|
||||
let Some(id) = inserted.get("id").and_then(Value::as_u64) else {
|
||||
return Err(bad("insert.photoAlbum", "the picture was not inserted"));
|
||||
};
|
||||
apply_frame(s, frame, id)?;
|
||||
if opts.grayscale || pic.brightness.abs() > 1e-6 || pic.contrast.abs() > 1e-6 {
|
||||
s.execute(
|
||||
"picture.adjust",
|
||||
&json!({"id": id, "grayscale": opts.grayscale, "brightness": pic.brightness, "contrast": pic.contrast}),
|
||||
)?;
|
||||
}
|
||||
if pic.rot > 0.01 {
|
||||
s.execute("shape.rotate", &json!({"id": id, "deg": pic.rot}))?;
|
||||
}
|
||||
pictures += 1;
|
||||
if let Some(cap) = cell.caption {
|
||||
s.execute("insert.textBox", &json!({"rect": cap, "text": file_stem(&pic.name)}))?;
|
||||
}
|
||||
}
|
||||
if let Some(rect) = title {
|
||||
s.execute("insert.textBox", &json!({"rect": rect, "text": stem}))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
s.execute("slide.go", &json!({"index": first}))?;
|
||||
Ok(json!({"slides": slides, "first": first, "pictures": pictures, "album": album_id}))
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
use super::{
|
||||
Chart, ChartType, Result, RunKind, Session, Shape, ShapeKind, Value, Xfrm, add_shape, bad, bool_param, color_value, edit_shapes, json,
|
||||
rect_param, str_param,
|
||||
};
|
||||
|
||||
pub fn chart_type(name: &str) -> ChartType {
|
||||
match name.to_ascii_lowercase().replace(['-', '_', ' '], "").as_str() {
|
||||
"bar" | "clusteredbar" => ChartType::Bar,
|
||||
"stackedbar" => ChartType::StackedBar,
|
||||
"percentbar" => ChartType::PercentBar,
|
||||
"line" => ChartType::Line,
|
||||
"linemarkers" | "linewithmarkers" => ChartType::LineMarkers,
|
||||
"stackedline" => ChartType::StackedLine,
|
||||
"area" => ChartType::Area,
|
||||
"stackedarea" => ChartType::StackedArea,
|
||||
"pie" => ChartType::Pie,
|
||||
"doughnut" | "donut" => ChartType::Doughnut,
|
||||
"scatter" | "xy" => ChartType::Scatter,
|
||||
"bubble" => ChartType::Bubble,
|
||||
"radar" => ChartType::Radar,
|
||||
"filledradar" => ChartType::FilledRadar,
|
||||
"stackedcolumn" => ChartType::StackedColumn,
|
||||
"percentcolumn" => ChartType::PercentColumn,
|
||||
"waterfall" => ChartType::Waterfall,
|
||||
"funnel" => ChartType::Funnel,
|
||||
"histogram" => ChartType::Histogram,
|
||||
"pareto" => ChartType::Pareto,
|
||||
"treemap" => ChartType::Treemap,
|
||||
"sunburst" => ChartType::Sunburst,
|
||||
"boxwhisker" | "box" => ChartType::BoxWhisker,
|
||||
"stock" => ChartType::Stock,
|
||||
"surface" => ChartType::Surface,
|
||||
"combo" => ChartType::Combo,
|
||||
_ => ChartType::Column,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn chart(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let kind = chart_type(str_param(p, "type").unwrap_or("column"));
|
||||
let mut c = Chart::sample(kind);
|
||||
if let Some(cats) = p.get("categories").and_then(Value::as_array) {
|
||||
c.categories = cats.iter().map(|v| v.as_str().map(String::from).unwrap_or_else(|| v.to_string())).collect();
|
||||
}
|
||||
if let Some(series) = p.get("series").and_then(Value::as_array) {
|
||||
c.series = series
|
||||
.iter()
|
||||
.map(|sv| deckcraft_model::chart::Series {
|
||||
name: sv.get("name").and_then(Value::as_str).unwrap_or("Series").to_string(),
|
||||
values: sv.get("values").and_then(Value::as_array).map(|a| a.iter().map(Value::as_f64).collect()).unwrap_or_default(),
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
if let Some(t) = p.get("title") {
|
||||
c.title = t.as_str().map(String::from);
|
||||
}
|
||||
let size = s.doc()?.doc.slide_size;
|
||||
let rect = rect_param(p, "rect").unwrap_or(Xfrm::new(size.width * 0.15, size.height * 0.15, size.width * 0.7, size.height * 0.7));
|
||||
let id = add_shape(s, "insert.chart", Shape { xfrm: Some(rect), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() }, true)?;
|
||||
Ok(json!({"id": id}))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn _kinds() -> RunKind {
|
||||
RunKind::Text
|
||||
}
|
||||
|
||||
fn with_chart(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut Chart) -> Result<()>) -> Result<Value> {
|
||||
edit_shapes(s, p, cmd, |sh| match &mut sh.kind {
|
||||
ShapeKind::Chart(c) => f(c),
|
||||
_ => Err(bad(cmd, "not a chart")),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn chart_type_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let k = chart_type(str_param(p, "type").unwrap_or("column"));
|
||||
with_chart(s, p, "chart.type", |c| {
|
||||
c.kind = k;
|
||||
c.vary_colors =
|
||||
matches!(k, ChartType::Pie | ChartType::Doughnut | ChartType::Funnel | ChartType::Treemap | ChartType::Sunburst | ChartType::Waterfall)
|
||||
|| c.series.len() == 1 && c.vary_colors;
|
||||
if matches!(k, ChartType::Waterfall | ChartType::Histogram | ChartType::Pareto | ChartType::BoxWhisker) {
|
||||
c.gridlines = true;
|
||||
}
|
||||
c.raw = None;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn chart_data(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cats: Option<Vec<String>> = p
|
||||
.get("categories")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| a.iter().map(|v| v.as_str().map(String::from).unwrap_or_else(|| v.to_string())).collect());
|
||||
let series: Option<Vec<deckcraft_model::chart::Series>> = p.get("series").and_then(Value::as_array).map(|a| {
|
||||
a.iter()
|
||||
.map(|sv| deckcraft_model::chart::Series {
|
||||
name: sv.get("name").and_then(Value::as_str).unwrap_or("Series").to_string(),
|
||||
values: sv.get("values").and_then(Value::as_array).map(|v| v.iter().map(Value::as_f64).collect()).unwrap_or_default(),
|
||||
..Default::default()
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
let levels: Option<Vec<Vec<String>>> = p.get("levels").and_then(Value::as_array).map(|a| {
|
||||
a.iter()
|
||||
.take(deckcraft_model::chart::MAX_LEVELS)
|
||||
.map(|l| {
|
||||
l.as_array().map(|v| v.iter().map(|x| x.as_str().map(String::from).unwrap_or_else(|| x.to_string())).collect()).unwrap_or_default()
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
with_chart(s, p, "chart.data", |c| {
|
||||
if let Some(cs) = &cats {
|
||||
c.categories = cs.clone();
|
||||
}
|
||||
if let Some(l) = &levels {
|
||||
c.category_levels = l.clone();
|
||||
}
|
||||
if let Some(se) = &series {
|
||||
// Keep series formatting by position.
|
||||
let old = std::mem::take(&mut c.series);
|
||||
c.series = se
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, n)| {
|
||||
let mut x = old.get(i).cloned().unwrap_or_default();
|
||||
x.name = n.name.clone();
|
||||
x.values = n.values.clone();
|
||||
x
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
c.raw = None;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_bin_options(c: &mut deckcraft_model::chart::Chart, bin_count: Option<Option<u32>>, bin_size: Option<Option<f64>>) {
|
||||
if let Some(n) = bin_count {
|
||||
c.bin_count = n;
|
||||
if n.is_some() {
|
||||
c.bin_size = None;
|
||||
}
|
||||
}
|
||||
if let Some(w) = bin_size {
|
||||
c.bin_size = w;
|
||||
if w.is_some() {
|
||||
c.bin_count = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_subtotals_and_bars(c: &mut deckcraft_model::chart::Chart, p: &Value, subtotals: Option<&[usize]>) {
|
||||
if let Some(t) = subtotals {
|
||||
let n = c.series.first().map_or(0, |s| s.values.len());
|
||||
let mut t: Vec<usize> = t.iter().copied().filter(|i| *i < n).collect();
|
||||
t.sort_unstable();
|
||||
t.dedup();
|
||||
c.subtotals = t;
|
||||
}
|
||||
if let Some(b) = p.get("upDownBars").and_then(Value::as_bool) {
|
||||
c.up_down_bars = b;
|
||||
c.raw = None;
|
||||
}
|
||||
if let Some(q) = p.get("quartiles").and_then(Value::as_str) {
|
||||
c.inclusive_quartiles = q.eq_ignore_ascii_case("inclusive");
|
||||
c.raw = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn chart_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Histogram bins: a count or a width (setting one clears the other; null for automatic).
|
||||
let bin_count = p.get("binCount").map(|v| v.as_u64().map(|n| n.clamp(1, deckcraft_model::chart::MAX_BINS as u64) as u32));
|
||||
let bin_size = p.get("binSize").map(|v| v.as_f64().filter(|w| w.is_finite() && *w > 0.0));
|
||||
if p.get("binSize").is_some_and(|v| !v.is_null()) && bin_size.flatten().is_none() {
|
||||
return Err(bad("chart.options", "binSize must be a positive number"));
|
||||
}
|
||||
// Waterfall total points.
|
||||
let subtotals: Option<Vec<usize>> =
|
||||
p.get("subtotals").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|i| i as usize).collect());
|
||||
// Colour of the chart's text (title, axes, legend, data labels); null goes back to PowerPoint's
|
||||
// default (tx1 at lumMod 65 % / lumOff 35 %).
|
||||
let text_color = match p.get("textColor") {
|
||||
None => None,
|
||||
Some(Value::Null) => Some(None),
|
||||
Some(v) => {
|
||||
Some(Some(color_value(v).ok_or_else(|| bad("chart.options", "`textColor` is a colour: #RRGGBB, accent1…accent6, tx1…, or null"))?))
|
||||
}
|
||||
};
|
||||
with_chart(s, p, "chart.options", |c| {
|
||||
if let Some(t) = &text_color {
|
||||
c.text_color = t.clone();
|
||||
}
|
||||
apply_bin_options(c, bin_count, bin_size);
|
||||
apply_subtotals_and_bars(c, p, subtotals.as_deref());
|
||||
if bin_count.is_some() || bin_size.is_some() || subtotals.is_some() {
|
||||
c.raw = None;
|
||||
}
|
||||
if let Some(t) = p.get("title") {
|
||||
c.title = t.as_str().map(String::from);
|
||||
}
|
||||
if let Some(l) = p.get("legend") {
|
||||
c.legend = l.as_str().map(String::from);
|
||||
}
|
||||
if let Some(v) = bool_param(p, "dataLabels") {
|
||||
c.data_labels = v;
|
||||
// A series' own setting would otherwise keep overriding the chart-wide switch.
|
||||
for series in &mut c.series {
|
||||
series.data_labels = None;
|
||||
}
|
||||
}
|
||||
if let Some(v) = bool_param(p, "gridlines") {
|
||||
c.gridlines = v;
|
||||
}
|
||||
if let Some(v) = str_param(p, "palette") {
|
||||
c.palette = v.to_string();
|
||||
}
|
||||
c.raw = None;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
use deckcraft_model::ShapeKind;
|
||||
use deckcraft_model::style::LineJoin;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// 8×8 red PNG.
|
||||
fn png() -> Vec<u8> {
|
||||
vec![
|
||||
137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 8, 0, 0, 0, 8, 8, 2, 0, 0, 0, 75, 109, 41, 220, 0, 0, 0, 17, 73, 68,
|
||||
65, 84, 120, 156, 99, 184, 163, 161, 129, 21, 49, 12, 45, 9, 0, 123, 123, 75, 1, 251, 162, 81, 240, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96,
|
||||
130,
|
||||
]
|
||||
}
|
||||
|
||||
fn pic(name: &str) -> Value {
|
||||
json!({"name": name, "data": base64_encode(&png())})
|
||||
}
|
||||
|
||||
fn n_slides(s: &Session) -> usize {
|
||||
s.active().map(|d| d.doc.slides.len()).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn pictures(s: &Session, index: usize) -> Vec<deckcraft_model::Shape> {
|
||||
s.active()
|
||||
.and_then(|d| d.doc.slides.get(index))
|
||||
.map(|sl| sl.shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).cloned().collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn box_text(s: &Session, index: usize) -> Vec<String> {
|
||||
s.active()
|
||||
.and_then(|d| d.doc.slides.get(index))
|
||||
.map(|sl| sl.shapes.iter().filter(|sh| sh.text_box).filter_map(|sh| sh.text.as_ref().map(|t| t.text())).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_pictures_one_per_slide_are_one_undo() {
|
||||
let mut s = Session::with_new();
|
||||
let before = n_slides(&s);
|
||||
let r = s.execute("insert.photoAlbum", &json!({"pictures": [pic("hill.png"), pic("lake.png")], "layout": "1"})).expect("album");
|
||||
assert_eq!(r["slides"], 2);
|
||||
assert_eq!(n_slides(&s), before + 2);
|
||||
assert_eq!(pictures(&s, 1).len(), 1);
|
||||
assert_eq!(pictures(&s, 2).len(), 1);
|
||||
assert_eq!(s.active().unwrap().selection.slide, 1);
|
||||
let undo = &s.active().unwrap().history.undo;
|
||||
assert_eq!(undo.len(), 1);
|
||||
assert_eq!(undo[0].label, "Photo Album…");
|
||||
s.execute("edit.undo", &json!({})).expect("undo");
|
||||
assert_eq!(n_slides(&s), before);
|
||||
assert!(pictures(&s, 1).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_up_shares_one_slide() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), pic("b.png")], "layout": "2"})).expect("album");
|
||||
assert_eq!(n_slides(&s), 2);
|
||||
assert_eq!(pictures(&s, 1).len(), 2);
|
||||
assert!(box_text(&s, 1).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captions_and_titles_use_the_file_stem() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("insert.photoAlbum", &json!({"pictures": [pic("harbor.png")], "layout": "1", "captions": true})).expect("captions");
|
||||
assert_eq!(box_text(&s, 1), ["harbor"]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("insert.photoAlbum", &json!({"pictures": [pic("harbor.png")], "layout": "1title"})).expect("title");
|
||||
assert_eq!(box_text(&s, 1), ["harbor"]);
|
||||
assert_eq!(pictures(&s, 1).len(), 1);
|
||||
let pic_y = pictures(&s, 1)[0].xfrm.unwrap().y;
|
||||
assert!(pic_y > 40.0, "the picture sits below the title");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_set_the_picture_line() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "frame": "rect"})).expect("rect");
|
||||
let line = pictures(&s, 1)[0].line.clone().expect("line");
|
||||
assert_eq!(line.width, Some(1.5));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "frame": "rounded"})).expect("rounded");
|
||||
let line = pictures(&s, 1)[0].line.clone().expect("line");
|
||||
assert_eq!(line.width, Some(2.25));
|
||||
assert_eq!(line.join, Some(LineJoin::Round));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "frame": "simple"})).expect("simple");
|
||||
assert_eq!(pictures(&s, 1)[0].line.as_ref().and_then(|l| l.width), Some(6.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_text_box_breaks_the_picture_row() {
|
||||
let mut s = Session::with_new();
|
||||
let r = s
|
||||
.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), {"text": "A day on the water"}, pic("b.png")], "layout": "2"}))
|
||||
.expect("album");
|
||||
assert_eq!(r["slides"], 3);
|
||||
assert_eq!(pictures(&s, 1).len(), 1);
|
||||
assert_eq!(box_text(&s, 2), ["A day on the water"]);
|
||||
assert!(pictures(&s, 2).is_empty());
|
||||
assert_eq!(pictures(&s, 3).len(), 1);
|
||||
assert_eq!(s.active().unwrap().history.undo.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_pictures_opens_the_dialog_without_editing() {
|
||||
let mut s = Session::with_new();
|
||||
let before = n_slides(&s);
|
||||
let r = s.execute("insert.photoAlbum", &json!({})).expect("open");
|
||||
assert_eq!(r["dialog"], "photoAlbum");
|
||||
assert_eq!(n_slides(&s), before);
|
||||
assert!(s.active().unwrap().history.undo.is_empty());
|
||||
assert_eq!(s.ui_requests.len(), 1);
|
||||
match &s.ui_requests[0] {
|
||||
crate::UiRequest::Dialog { id, .. } => assert_eq!(id, "photoAlbum"),
|
||||
other => panic!("dialog request, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_input_adds_no_slides() {
|
||||
let mut s = Session::with_new();
|
||||
let before = n_slides(&s);
|
||||
assert!(s.execute("insert.photoAlbum", &json!({"pictures": [], "layout": "1"})).is_err());
|
||||
assert!(s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "layout": "grid"})).is_err());
|
||||
assert!(s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "frame": "glow"})).is_err());
|
||||
assert!(s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "captions": "yes"})).is_err());
|
||||
assert!(s.execute("insert.photoAlbum", &json!({"pictures": [{"name": "notes.txt", "data": base64_encode(b"hello")}]} )).is_err());
|
||||
assert!(s.execute("insert.photoAlbum", &json!({"pictures": [{"text": " "}]})).is_err());
|
||||
let mut many = Vec::new();
|
||||
for i in 0..49 {
|
||||
many.push(json!({"text": format!("box {i}")}));
|
||||
}
|
||||
assert!(s.execute("insert.photoAlbum", &json!({"pictures": many})).is_err());
|
||||
assert_eq!(n_slides(&s), before);
|
||||
assert!(s.active().unwrap().history.undo.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_is_larger_than_one_picture_and_four_up_is_one_slide() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "layout": "1"})).unwrap();
|
||||
let one = pictures(&s, 1)[0].xfrm.unwrap().h;
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "layout": "fit"})).unwrap();
|
||||
let fitted = pictures(&s, 1)[0].xfrm.unwrap().h;
|
||||
assert!(fitted > one, "fit {fitted} should be taller than one-up {one}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), pic("b.png"), pic("c.png"), pic("d.png")], "layout": "4"})).unwrap();
|
||||
assert_eq!(n_slides(&s), 2);
|
||||
assert_eq!(pictures(&s, 1).len(), 4);
|
||||
}
|
||||
|
||||
fn adjust(s: &Session, index: usize) -> deckcraft_model::style::PictureAdjust {
|
||||
match &pictures(s, index)[0].kind {
|
||||
ShapeKind::Picture { fill } => fill.adjust.clone(),
|
||||
_ => panic!("picture"),
|
||||
}
|
||||
}
|
||||
|
||||
fn theme_name(s: &Session) -> String {
|
||||
s.active().and_then(|d| d.doc.masters.first()).map(|m| m.theme.name.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_without_pictures_reopens_the_dialog() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("insert.photoAlbum", &json!({"pictures": [pic("hill.png"), pic("lake.png")], "layout": "1"})).unwrap();
|
||||
s.ui_requests.clear();
|
||||
let before = n_slides(&s);
|
||||
let undos = s.active().unwrap().history.undo.len();
|
||||
let r = s.execute("insert.photoAlbumEdit", &json!({})).expect("reopen");
|
||||
assert_eq!(r["dialog"], "photoAlbum");
|
||||
assert_eq!(n_slides(&s), before);
|
||||
assert_eq!(s.active().unwrap().history.undo.len(), undos);
|
||||
let params = match &s.ui_requests[0] {
|
||||
crate::UiRequest::Dialog { id, params } => {
|
||||
assert_eq!(id, "photoAlbum");
|
||||
params
|
||||
}
|
||||
other => panic!("dialog request, got {other:?}"),
|
||||
};
|
||||
assert_eq!(params["items"][0]["name"], "hill.png");
|
||||
assert_eq!(params["items"][1]["name"], "lake.png");
|
||||
assert_eq!(params["layout"], "1");
|
||||
assert!(params["album"].as_u64().unwrap_or(0) > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_reflows_the_album_in_one_undo() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("insert.photoAlbum", &json!({"pictures": [pic("hill.png"), pic("lake.png")], "layout": "1"})).unwrap();
|
||||
assert_eq!(n_slides(&s), 3);
|
||||
let r = s.execute("insert.photoAlbumEdit", &json!({"pictures": [pic("hill.png"), pic("lake.png")], "layout": "2"})).expect("reflow");
|
||||
assert_eq!(r["slides"], 1);
|
||||
assert_eq!(n_slides(&s), 2);
|
||||
assert_eq!(pictures(&s, 1).len(), 2);
|
||||
let undo = &s.active().unwrap().history.undo;
|
||||
assert_eq!(undo.last().map(|u| u.label.as_str()), Some("Edit Photo Album…"));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(n_slides(&s), 3);
|
||||
assert_eq!(pictures(&s, 1).len(), 1);
|
||||
assert_eq!(pictures(&s, 2).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picture_options_and_theme_are_one_step() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute(
|
||||
"insert.photoAlbum",
|
||||
&json!({"pictures": [{"name": "hill.png", "data": base64_encode(&png()), "rot": 90, "brightness": 0.2, "contrast": -0.1}], "grayscale": true, "theme": "Harbor"}),
|
||||
)
|
||||
.unwrap();
|
||||
let adj = adjust(&s, 1);
|
||||
assert!(adj.grayscale);
|
||||
assert!((adj.brightness - 0.2).abs() < 1e-6);
|
||||
assert!((adj.contrast + 0.1).abs() < 1e-6);
|
||||
assert!((pictures(&s, 1)[0].xfrm.unwrap().rot - 90.0).abs() < 0.01);
|
||||
assert_eq!(theme_name(&s), "Harbor");
|
||||
let mark = s.active().unwrap().doc.slides[1].raw_ext.clone().unwrap_or_default();
|
||||
assert!(mark.contains("gray=1"), "{mark}");
|
||||
assert!(mark.contains("theme=Harbor"), "{mark}");
|
||||
assert_eq!(s.active().unwrap().doc.slides[1].name, "Photo Album");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(theme_name(&s), "DeckCraft");
|
||||
assert_eq!(n_slides(&s), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_theme_and_a_plain_slide_change_nothing() {
|
||||
let mut s = Session::with_new();
|
||||
let before = n_slides(&s);
|
||||
let err = s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "theme": "NotATheme"})).unwrap_err();
|
||||
assert!(err.to_string().contains("unknown theme"), "{err}");
|
||||
assert!(s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), {"brightness": "high"}]})).is_err());
|
||||
assert!(s.execute("insert.photoAlbum", &json!({"pictures": [{"name": "a.png", "data": base64_encode(&png()), "brightness": 2}]})).is_err());
|
||||
let err = s.execute("insert.photoAlbumEdit", &json!({})).unwrap_err();
|
||||
assert!(err.to_string().contains("not part of a photo album"), "{err}");
|
||||
s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")]})).unwrap();
|
||||
s.execute("slide.go", &json!({"index": 0})).unwrap();
|
||||
assert!(s.execute("insert.photoAlbumEdit", &json!({"pictures": [pic("b.png")], "layout": "2"})).is_err());
|
||||
assert_eq!(n_slides(&s), before + 1);
|
||||
assert_eq!(pictures(&s, 1).len(), 1);
|
||||
assert_eq!(theme_name(&s), "DeckCraft");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_albums_stay_independent() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), pic("b.png")], "layout": "1"})).unwrap();
|
||||
let last = n_slides(&s) - 1;
|
||||
s.execute("slide.go", &json!({"index": last})).unwrap();
|
||||
s.execute("insert.photoAlbum", &json!({"pictures": [pic("c.png"), pic("d.png")], "layout": "1"})).unwrap();
|
||||
assert_eq!(n_slides(&s), 5);
|
||||
s.execute("insert.photoAlbumEdit", &json!({"pictures": [pic("c.png"), pic("d.png")], "layout": "2"})).unwrap();
|
||||
assert_eq!(n_slides(&s), 4);
|
||||
assert_eq!(pictures(&s, 1).len(), 1);
|
||||
assert_eq!(pictures(&s, 2).len(), 1);
|
||||
assert_eq!(pictures(&s, 3).len(), 2);
|
||||
let first = s.active().unwrap().doc.slides[1].raw_ext.clone().unwrap_or_default();
|
||||
let second = s.active().unwrap().doc.slides[3].raw_ext.clone().unwrap_or_default();
|
||||
assert!(first.starts_with("deckcraft-album:"));
|
||||
assert!(second.starts_with("deckcraft-album:"));
|
||||
assert_ne!(first.split(';').next(), second.split(';').next());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_named_run_without_a_marker_still_reflows() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), pic("b.png")], "layout": "1"})).unwrap();
|
||||
{
|
||||
let doc = std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc);
|
||||
for sl in doc.slides.iter_mut().skip(1) {
|
||||
std::sync::Arc::make_mut(sl).raw_ext = None;
|
||||
}
|
||||
}
|
||||
s.execute("insert.photoAlbumEdit", &json!({"pictures": [pic("a.png"), pic("b.png")], "layout": "2"})).unwrap();
|
||||
assert_eq!(n_slides(&s), 2);
|
||||
assert_eq!(pictures(&s, 1).len(), 2);
|
||||
let mark = s.active().unwrap().doc.slides[1].raw_ext.clone().unwrap_or_default();
|
||||
assert!(mark.starts_with("deckcraft-album:"), "{mark}");
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
use super::{
|
||||
Geom, Result, SchemeSlot, Session, Shape, ShapeStyle, Value, add_shape, bad, bool_or, bool_param, default_rect, edit_shapes, json, rect_param,
|
||||
str_param, usize_param,
|
||||
};
|
||||
|
||||
pub(super) fn hyperlink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use deckcraft_model::text::{Action, Hyperlink};
|
||||
// `display`: what a caret with nothing to link inserts, the address or the slide's title.
|
||||
let (action, display) = if let Some(u) = str_param(p, "url") {
|
||||
(Action::Url { url: u.to_string() }, u.strip_prefix("mailto:").unwrap_or(u).to_string())
|
||||
} else if let Some(i) = usize_param(p, "slide") {
|
||||
let slide = s.doc()?.doc.slides.get(i).ok_or_else(|| bad("insert.hyperlink", "no such slide"))?;
|
||||
let title = slide.title();
|
||||
(Action::Slide { slide: slide.id }, if title.is_empty() { format!("Slide {}", i + 1) } else { title })
|
||||
} else {
|
||||
return Err(bad("insert.hyperlink", "missing `url` or `slide`"));
|
||||
};
|
||||
let display = str_param(p, "text").map_or(display, str::to_string);
|
||||
let link = Hyperlink {
|
||||
action,
|
||||
tooltip: str_param(p, "tooltip").unwrap_or("").to_string(),
|
||||
highlight_click: bool_or(p, "highlightClick", false),
|
||||
sound: None,
|
||||
stop_sound: false,
|
||||
zoom_return: None,
|
||||
};
|
||||
if s.doc()?.selection.text.is_some() {
|
||||
return crate::cmd::text::link_text(s, link, &display);
|
||||
}
|
||||
edit_shapes(s, p, "insert.hyperlink", |sh| {
|
||||
sh.click = Some(link.clone());
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Insert ▸ Action (Action Settings): what clicking the selected shapes, or pointing at them, does
|
||||
/// in a show.
|
||||
pub(super) fn shape_action(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use deckcraft_model::text::{Action, Hyperlink};
|
||||
const CMD: &str = "shape.action";
|
||||
let hover = match str_param(p, "on").unwrap_or("click") {
|
||||
"click" => false,
|
||||
"hover" | "over" => true,
|
||||
o => return Err(bad(CMD, format!("`on` is click or hover, not `{o}`"))),
|
||||
};
|
||||
let need = |k: &str| str_param(p, k).map(str::to_string).filter(|v| !v.trim().is_empty()).ok_or_else(|| bad(CMD, format!("missing `{k}`")));
|
||||
let action = match str_param(p, "action").unwrap_or("none") {
|
||||
"none" => None,
|
||||
"next" => Some(Action::NextSlide),
|
||||
"previous" => Some(Action::PreviousSlide),
|
||||
"first" => Some(Action::FirstSlide),
|
||||
"last" => Some(Action::LastSlide),
|
||||
"lastViewed" => Some(Action::LastViewed),
|
||||
"endShow" => Some(Action::EndShow),
|
||||
"playMedia" => Some(Action::PlayMedia),
|
||||
"url" => Some(Action::Url { url: need("url")? }),
|
||||
"customShow" => {
|
||||
let name = need("show")?;
|
||||
if !s.doc()?.doc.custom_shows.iter().any(|c| c.name == name) {
|
||||
return Err(bad(CMD, format!("no custom show `{name}`")));
|
||||
}
|
||||
Some(Action::CustomShow { name, returns: true })
|
||||
}
|
||||
"program" => Some(Action::Program { path: need("path")? }),
|
||||
"slide" => {
|
||||
let i = usize_param(p, "slide").ok_or_else(|| bad(CMD, "missing `slide`"))?;
|
||||
let slide = s.doc()?.doc.slides.get(i).ok_or_else(|| bad(CMD, "no such slide"))?;
|
||||
Some(Action::Slide { slide: slide.id })
|
||||
}
|
||||
a => return Err(bad(CMD, format!("unknown action `{a}`"))),
|
||||
};
|
||||
let highlight = bool_param(p, "highlight").unwrap_or(true);
|
||||
let tooltip = str_param(p, "tooltip").unwrap_or("").to_string();
|
||||
let link = action.map(|action| Hyperlink { action, tooltip, highlight_click: highlight, sound: None, stop_sound: false, zoom_return: None });
|
||||
edit_shapes(s, p, CMD, |sh| {
|
||||
if hover {
|
||||
sh.hover = link.clone();
|
||||
} else {
|
||||
sh.click = link.clone();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn action_button(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use deckcraft_model::text::{Action, Hyperlink};
|
||||
let kind = str_param(p, "kind").unwrap_or("custom");
|
||||
let (preset, action) = match kind {
|
||||
"back" | "previous" => ("actionButtonBackPrevious", Some(Action::PreviousSlide)),
|
||||
"forward" | "next" => ("actionButtonForwardNext", Some(Action::NextSlide)),
|
||||
"beginning" => ("actionButtonBeginning", Some(Action::FirstSlide)),
|
||||
"end" => ("actionButtonEnd", Some(Action::LastSlide)),
|
||||
"home" => ("actionButtonHome", Some(Action::FirstSlide)),
|
||||
"information" => ("actionButtonInformation", None),
|
||||
"return" => ("actionButtonReturn", Some(Action::LastViewed)),
|
||||
"movie" => ("actionButtonMovie", None),
|
||||
"document" => ("actionButtonDocument", None),
|
||||
"sound" => ("actionButtonSound", None),
|
||||
"help" => ("actionButtonHelp", None),
|
||||
_ => ("actionButtonBlank", None),
|
||||
};
|
||||
let rect = rect_param(p, "rect").unwrap_or_else(|| default_rect(s, 48.0, 48.0));
|
||||
let shape = Shape {
|
||||
xfrm: Some(rect),
|
||||
geom: Geom::preset(preset),
|
||||
style: Some(ShapeStyle::accent(SchemeSlot::Accent1)),
|
||||
click: action.map(|a| Hyperlink {
|
||||
action: a,
|
||||
tooltip: String::new(),
|
||||
highlight_click: true,
|
||||
sound: None,
|
||||
stop_sound: false,
|
||||
zoom_return: None,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let id = add_shape(s, "insert.actionButton", shape, true)?;
|
||||
Ok(json!({"id": id}))
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
use super::{
|
||||
EngineError, MediaClip, NO_ID_SPACE, PictureFill, Result, Session, Shape, ShapeKind, Value, Xfrm, add_shape, bad, base64_decode, default_rect,
|
||||
json, rect_param, str_param, xfrm_of,
|
||||
};
|
||||
|
||||
/// Read media bytes from `data` (base64) or `path`.
|
||||
pub(crate) fn media_bytes(p: &Value, cmd: &str) -> Result<(String, Vec<u8>)> {
|
||||
if let Some(d) = str_param(p, "data") {
|
||||
let bytes = base64_decode(d).ok_or_else(|| bad(cmd, "invalid base64 `data`"))?;
|
||||
return Ok((str_param(p, "name").unwrap_or("media").to_string(), bytes));
|
||||
}
|
||||
if let Some(path) = str_param(p, "path") {
|
||||
let bytes = crate::io::read_file(path).map_err(EngineError::Other)?;
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "media".into());
|
||||
return Ok((name, bytes));
|
||||
}
|
||||
Err(bad(cmd, "missing `path` or `data`"))
|
||||
}
|
||||
|
||||
pub fn content_type(name: &str, bytes: &[u8]) -> &'static str {
|
||||
let l = name.to_ascii_lowercase();
|
||||
if bytes.starts_with(&[0x89, b'P', b'N', b'G']) {
|
||||
"image/png"
|
||||
} else if bytes.starts_with(&[0xFF, 0xD8]) {
|
||||
"image/jpeg"
|
||||
} else if bytes.starts_with(b"GIF8") {
|
||||
"image/gif"
|
||||
} else if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") {
|
||||
"image/webp"
|
||||
} else if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WAVE") {
|
||||
"audio/wav"
|
||||
} else if bytes.starts_with(b"BM") {
|
||||
"image/bmp"
|
||||
} else if bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*") {
|
||||
"image/tiff"
|
||||
} else if bytes.starts_with(b"fLaC") {
|
||||
"audio/flac"
|
||||
} else if bytes.starts_with(b"OggS") {
|
||||
if l.ends_with(".ogv") { "video/ogg" } else { "audio/ogg" }
|
||||
} else if bytes.len() > 1 && bytes[0] == 0xFF && bytes[1] & 0xF6 == 0xF0 {
|
||||
// ADTS (layer bits 00): AAC.
|
||||
"audio/aac"
|
||||
} else if bytes.starts_with(b"ID3") || (bytes.len() > 1 && bytes[0] == 0xFF && (bytes[1] & 0xE0) == 0xE0 && !l.ends_with(".aac")) {
|
||||
"audio/mpeg"
|
||||
} else if bytes.starts_with(b"FORM") {
|
||||
"audio/aiff"
|
||||
} else if bytes.get(4..8) == Some(b"ftyp") {
|
||||
if l.ends_with(".m4a") || l.ends_with(".m4b") || bytes.get(8..11) == Some(b"M4A") {
|
||||
"audio/mp4"
|
||||
} else if l.ends_with(".mov") || bytes.get(8..12) == Some(b"qt ") {
|
||||
"video/quicktime"
|
||||
} else {
|
||||
"video/mp4"
|
||||
}
|
||||
} else if bytes.starts_with(&[0x1A, 0x45, 0xDF, 0xA3]) {
|
||||
if l.ends_with(".mka") || l.ends_with(".weba") {
|
||||
"audio/webm"
|
||||
} else if l.ends_with(".mkv") {
|
||||
"video/x-matroska"
|
||||
} else {
|
||||
"video/webm"
|
||||
}
|
||||
} else if bytes.starts_with(&[0x30, 0x26, 0xB2, 0x75, 0x8E, 0x66, 0xCF, 0x11]) {
|
||||
if l.ends_with(".wmv") || l.ends_with(".asf") { "video/x-ms-wmv" } else { "audio/x-ms-wma" }
|
||||
} else if bytes.starts_with(b"caff") {
|
||||
"audio/x-caf"
|
||||
} else if l.ends_with(".svg") || bytes.windows(4).take(512).any(|w| w == b"<svg") {
|
||||
"image/svg+xml"
|
||||
} else if l.ends_with(".aac") {
|
||||
"audio/aac"
|
||||
} else {
|
||||
"application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
/// Natural size of an image in points (96 dpi), fitted inside the slide.
|
||||
fn fitted(s: &Session, bytes: &[u8]) -> (f64, f64) {
|
||||
let (w, h) = image::ImageReader::new(std::io::Cursor::new(bytes))
|
||||
.with_guessed_format()
|
||||
.ok()
|
||||
.and_then(|r| r.into_dimensions().ok())
|
||||
.map(|(w, h)| (w as f64 * 0.75, h as f64 * 0.75))
|
||||
.unwrap_or((288.0, 216.0));
|
||||
let size = s.active().map(|d| d.doc.slide_size).unwrap_or(deckcraft_model::defaults::WIDE);
|
||||
let k = (size.width * 0.9 / w.max(1.0)).min(size.height * 0.9 / h.max(1.0)).min(1.0);
|
||||
(w * k, h * k)
|
||||
}
|
||||
|
||||
pub(super) fn picture(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (name, bytes) = media_bytes(p, "insert.picture")?;
|
||||
let ct = content_type(&name, &bytes);
|
||||
if !ct.starts_with("image/") {
|
||||
return Err(bad("insert.picture", format!("{name} is not a picture")));
|
||||
}
|
||||
if ct != "image/svg+xml" && deckcraft_render::decode_image(&bytes).is_none() {
|
||||
return Err(EngineError::Other(format!("{name}: the picture can't be read")));
|
||||
}
|
||||
let (w, h) = fitted(s, &bytes);
|
||||
// Into a selected empty picture/content placeholder, if there is one.
|
||||
let target_ph = s.active().and_then(|d| {
|
||||
d.selected_shapes()
|
||||
.into_iter()
|
||||
.find(|x| {
|
||||
x.ph_type()
|
||||
.is_some_and(|k| matches!(k, deckcraft_model::PhType::Picture | deckcraft_model::PhType::Obj | deckcraft_model::PhType::Body))
|
||||
&& x.text.as_ref().is_none_or(|t| t.is_empty())
|
||||
})
|
||||
.map(|x| (x.id, xfrm_of(&d.doc, &d.selection, x)))
|
||||
});
|
||||
let rect = rect_param(p, "rect").unwrap_or_else(|| match target_ph {
|
||||
Some((_, b)) => {
|
||||
// Fill the placeholder, cropping the picture to its aspect.
|
||||
b
|
||||
}
|
||||
None => default_rect(s, w, h),
|
||||
});
|
||||
let crop = match (target_ph, rect_param(p, "rect")) {
|
||||
(Some(_), None) => {
|
||||
let (ia, ba) = (w / h.max(1e-9), rect.w / rect.h.max(1e-9));
|
||||
if ia > ba {
|
||||
let c = (1.0 - ba / ia) / 2.0;
|
||||
[c, 0.0, c, 0.0]
|
||||
} else {
|
||||
let c = (1.0 - ia / ba) / 2.0;
|
||||
[0.0, c, 0.0, c]
|
||||
}
|
||||
}
|
||||
_ => [0.0; 4],
|
||||
};
|
||||
let media = s.edit(|doc, _| doc.add_media(&name, ct, bytes).ok_or_else(|| bad("insert.picture", NO_ID_SPACE)))?;
|
||||
let shape = Shape {
|
||||
xfrm: Some(rect),
|
||||
kind: ShapeKind::Picture { fill: PictureFill { media, crop, ..Default::default() } },
|
||||
descr: name.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
if let Some((ph_id, _)) = target_ph {
|
||||
// Replace the placeholder with the picture (it keeps the placeholder link).
|
||||
let phv = s.doc()?.shape(ph_id).and_then(|x| x.ph.clone());
|
||||
s.edit(|doc, sel| {
|
||||
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("insert.picture", "no slide"))?;
|
||||
if let Some(sh) = list.iter_mut().find(|x| x.id == ph_id) {
|
||||
sh.kind = shape.kind.clone();
|
||||
sh.xfrm = Some(rect);
|
||||
sh.ph = phv.map(|mut p| {
|
||||
p.kind = deckcraft_model::PhType::Picture;
|
||||
p
|
||||
});
|
||||
sh.text = None;
|
||||
sh.descr = name.clone();
|
||||
}
|
||||
sel.shapes = vec![ph_id];
|
||||
Ok(())
|
||||
})?;
|
||||
return Ok(json!({"id": ph_id}));
|
||||
}
|
||||
let id = add_shape(s, "insert.picture", shape, true)?;
|
||||
Ok(json!({"id": id}))
|
||||
}
|
||||
|
||||
fn media(s: &mut Session, p: &Value, video: bool) -> Result<Value> {
|
||||
let cmd = if video { "insert.video" } else { "insert.audio" };
|
||||
let (name, bytes) = media_bytes(p, cmd)?;
|
||||
let ct = content_type(&name, &bytes);
|
||||
let bytes = std::sync::Arc::new(bytes);
|
||||
let info = deckcraft_media::probe(&bytes);
|
||||
let has_video = info.as_ref().is_ok_and(|i| i.video.is_some());
|
||||
let has_audio = info.as_ref().is_ok_and(|i| i.audio.is_some());
|
||||
match &info {
|
||||
Ok(_) if video && !has_video => return Err(bad(cmd, format!("{name} has no video"))),
|
||||
Ok(_) if !video && !has_audio && !has_video => return Err(bad(cmd, format!("{name} has no audio"))),
|
||||
// Not something we can read: embed it anyway when the type says it's media (it still
|
||||
// round-trips through PPTX), otherwise refuse.
|
||||
Err(_) if video && !ct.starts_with("video/") => return Err(bad(cmd, format!("{name} is not a supported video ({ct})"))),
|
||||
Err(_) if !video && !ct.starts_with("audio/") => return Err(bad(cmd, format!("{name} is not a supported audio file ({ct})"))),
|
||||
_ => {}
|
||||
}
|
||||
let (duration_ms, width, height, playable) = match &info {
|
||||
Ok(i) => {
|
||||
let (w, h) = i.video.as_ref().map(|v| (v.width, v.height)).unwrap_or((0, 0));
|
||||
(i.duration_ms.min(u32::MAX as u64) as u32, w, h, i.playable())
|
||||
}
|
||||
Err(_) => (0, 0, 0, false),
|
||||
};
|
||||
let decodable_video = info.as_ref().is_ok_and(|i| i.video.as_ref().is_some_and(|v| v.decodable));
|
||||
let warning = if !playable {
|
||||
Some(match &info {
|
||||
Ok(i) => {
|
||||
let codecs: Vec<&str> = i.audio.iter().map(|a| a.codec.as_str()).chain(i.video.iter().map(|v| v.codec.as_str())).collect();
|
||||
format!("{name} was inserted, but it can't be played ({} isn't supported yet)", codecs.join(" / "))
|
||||
}
|
||||
Err(e) => format!("{name} was inserted, but it can't be played ({e})"),
|
||||
})
|
||||
} else if video && !decodable_video {
|
||||
// A video codec without a decoder (VP8, say): the clip's audio still plays, but no frame
|
||||
// can ever show — say so instead of leaving an unexplained black box (#1237).
|
||||
info.as_ref()
|
||||
.ok()
|
||||
.and_then(|i| i.video.as_ref())
|
||||
.map(|v| format!("{name} was inserted, but its video can't be played ({} isn't supported yet); only the audio plays", v.codec))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let poster_png = if video && decodable_video { crate::cmd::media::poster_png(&bytes, 0) } else { None };
|
||||
let size = s.doc()?.doc.slide_size;
|
||||
let rect = rect_param(p, "rect").unwrap_or_else(|| {
|
||||
if video {
|
||||
// Natural size (96 dpi), fitted inside 90 % of the slide and centred.
|
||||
let (w, h) =
|
||||
if width > 0 && height > 0 { (width as f64 * 0.75, height as f64 * 0.75) } else { (size.width * 0.6, size.width * 0.6 * 9.0 / 16.0) };
|
||||
let k = (size.width * 0.9 / w).min(size.height * 0.9 / h).min(1.0);
|
||||
let (w, h) = (w * k, h * k);
|
||||
Xfrm::new((size.width - w) / 2.0, (size.height - h) / 2.0, w, h)
|
||||
} else {
|
||||
Xfrm::new((size.width - 48.0) / 2.0, (size.height - 48.0) / 2.0, 48.0, 48.0)
|
||||
}
|
||||
});
|
||||
let data = std::sync::Arc::try_unwrap(bytes).unwrap_or_else(|a| (*a).clone());
|
||||
let (media, poster) = s.edit(|doc, _| {
|
||||
let media = doc.add_media(&name, ct, data).ok_or_else(|| bad("insert.media", NO_ID_SPACE))?;
|
||||
let poster = match poster_png {
|
||||
Some(png) => Some(doc.add_media(&format!("{name}-poster.png"), "image/png", png).ok_or_else(|| bad("insert.media", NO_ID_SPACE))?),
|
||||
None => None,
|
||||
};
|
||||
Ok((media, poster))
|
||||
})?;
|
||||
let clip = MediaClip { media, video, poster, volume: 1.0, duration_ms, width, height, ..Default::default() };
|
||||
let id = add_shape(s, "insert.media", Shape { xfrm: Some(rect), kind: ShapeKind::Media(clip), descr: name, ..Default::default() }, true)?;
|
||||
if let Some(w) = &warning {
|
||||
s.ui_requests.push(crate::UiRequest::Message { text: w.clone() });
|
||||
}
|
||||
let mut r =
|
||||
json!({"id": id, "contentType": ct, "durationMs": duration_ms, "width": width, "height": height, "playable": playable, "warning": warning});
|
||||
if let Ok(i) = &info {
|
||||
r["probe"] = crate::cmd::media::probe_json(i);
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
pub(super) fn audio(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
media(s, p, false)
|
||||
}
|
||||
|
||||
pub(super) fn video(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
media(s, p, true)
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
use super::{
|
||||
ColorRef, Fill, Result, RunKind, SchemeSlot, Session, TextBody, Value, Xfrm, add_shape, bad, bool_or, bool_param, json, new_text_box, ok,
|
||||
str_param, usize_param,
|
||||
};
|
||||
|
||||
pub(super) fn word_art(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let text = str_param(p, "text").unwrap_or("Your text here");
|
||||
let size = s.doc()?.doc.slide_size;
|
||||
let style = usize_param(p, "style").unwrap_or(0);
|
||||
let mut shape = new_text_box(Xfrm::new(size.width * 0.2, size.height * 0.4, size.width * 0.6, 80.0), text);
|
||||
if let Some(t) = shape.text.as_mut() {
|
||||
for para in &mut t.paragraphs {
|
||||
para.props.align = Some(deckcraft_model::text::Align::Center);
|
||||
for r in &mut para.runs {
|
||||
r.props.size = Some(54.0);
|
||||
r.props.bold = Some(style % 2 == 1);
|
||||
r.props.fill = Some(Fill::solid(ColorRef::scheme(match style % 4 {
|
||||
0 => SchemeSlot::Tx1,
|
||||
1 => SchemeSlot::Accent1,
|
||||
2 => SchemeSlot::Accent2,
|
||||
_ => SchemeSlot::Accent4,
|
||||
})));
|
||||
if style % 3 == 2 {
|
||||
r.props.outline = Some(deckcraft_model::Line::solid(ColorRef::scheme(SchemeSlot::Bg1), 1.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let id = add_shape(s, "insert.wordArt", shape, true)?;
|
||||
crate::cmd::text::fit_text_box(s, id)?;
|
||||
Ok(json!({"id": id}))
|
||||
}
|
||||
|
||||
fn field_run(s: &mut Session, field: &str, text: &str) -> Result<Value> {
|
||||
if s.doc()?.selection.text.is_some() {
|
||||
return crate::cmd::text::insert_field(s, field, text);
|
||||
}
|
||||
// Not editing: turn on the footer field for the slide (Header & Footer).
|
||||
let key = if field == "slidenum" { "slideNumber" } else { "date" };
|
||||
s.execute("design.headerFooter", &json!({key: true}))
|
||||
}
|
||||
|
||||
pub(super) fn slide_number(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
field_run(s, "slidenum", "‹#›")
|
||||
}
|
||||
|
||||
const DATE_FORMATS: [&str; 13] = [
|
||||
"datetime1",
|
||||
"datetime2",
|
||||
"datetime3",
|
||||
"datetime4",
|
||||
"datetime5",
|
||||
"datetime6",
|
||||
"datetime7",
|
||||
"datetime8",
|
||||
"datetime9",
|
||||
"datetime10",
|
||||
"datetime11",
|
||||
"datetime12",
|
||||
"datetime13",
|
||||
];
|
||||
|
||||
struct DateOpts {
|
||||
format: String,
|
||||
update: bool,
|
||||
/// BCP 47 tag when the caller set `language`; absent keeps the surrounding run's language.
|
||||
lang: Option<String>,
|
||||
sample: String,
|
||||
}
|
||||
|
||||
/// `true` when the call carries a format, update flag or language (apply it). Empty params open
|
||||
/// the dialog instead.
|
||||
fn date_time_apply(p: &Value) -> bool {
|
||||
p.as_object().is_some_and(|o| o.contains_key("format") || o.contains_key("update") || o.contains_key("language"))
|
||||
}
|
||||
|
||||
fn canon_lang(raw: &str) -> Option<&'static str> {
|
||||
match raw.trim().to_ascii_lowercase().replace('_', "-").as_str() {
|
||||
"en" | "en-us" => Some("en-US"),
|
||||
"en-gb" => Some("en-GB"),
|
||||
"fr" | "fr-fr" => Some("fr-FR"),
|
||||
"ja" | "ja-jp" => Some("ja-JP"),
|
||||
"sv" | "sv-se" => Some("sv-SE"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn date_opts(p: &Value) -> Result<DateOpts> {
|
||||
if p.get("format").is_some() {
|
||||
let f = str_param(p, "format").filter(|f| DATE_FORMATS.contains(f));
|
||||
if f.is_none() {
|
||||
return Err(bad("insert.dateTime", "`format` must be datetime1..datetime13"));
|
||||
}
|
||||
}
|
||||
if p.get("update").is_some() && bool_param(p, "update").is_none() {
|
||||
return Err(bad("insert.dateTime", "`update` must be true or false"));
|
||||
}
|
||||
let lang = if p.get("language").is_some() {
|
||||
let raw = str_param(p, "language").filter(|l| !l.trim().is_empty());
|
||||
let Some(raw) = raw else {
|
||||
return Err(bad("insert.dateTime", "`language` must be en-US, en-GB, fr-FR, ja-JP or sv-SE"));
|
||||
};
|
||||
Some(canon_lang(raw).ok_or_else(|| bad("insert.dateTime", "`language` must be en-US, en-GB, fr-FR, ja-JP or sv-SE"))?.to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let format = str_param(p, "format").unwrap_or("datetime1").to_string();
|
||||
let sample = date_sample(&format, lang.as_deref())?;
|
||||
Ok(DateOpts { format, update: bool_or(p, "update", true), lang, sample })
|
||||
}
|
||||
|
||||
/// The text `format` shows now in `lang`. Errors when that language has no names for the format
|
||||
/// (month names and AM/PM are English only). An empty string where there is no clock (wasm).
|
||||
fn date_sample(format: &str, lang: Option<&str>) -> Result<String> {
|
||||
use deckcraft_text::datetime::{DateTime, field_text};
|
||||
let probe = DateTime::new(2026, 10, 9, 20, 5, 7);
|
||||
if field_text(format, lang, probe).is_none() {
|
||||
return Err(bad("insert.dateTime", "this format isn't available in that language"));
|
||||
}
|
||||
Ok(DateTime::now_local().and_then(|now| field_text(format, lang, now)).unwrap_or_default())
|
||||
}
|
||||
|
||||
pub(super) fn date_time(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !date_time_apply(p) {
|
||||
s.ui_requests.push(crate::UiRequest::Dialog { id: "dateTime".into(), params: json!({}) });
|
||||
return ok();
|
||||
}
|
||||
let opts = date_opts(p)?;
|
||||
let (doc, sel) = {
|
||||
let st = s.doc()?;
|
||||
(st.doc.clone(), st.selection.clone())
|
||||
};
|
||||
let r = apply_date(s, &opts);
|
||||
if r.is_err()
|
||||
&& let Some(st) = s.active_mut()
|
||||
{
|
||||
st.doc = doc;
|
||||
st.selection = sel;
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
fn apply_date(s: &mut Session, o: &DateOpts) -> Result<Value> {
|
||||
if s.doc()?.selection.text.is_some() {
|
||||
if o.update {
|
||||
crate::cmd::text::insert_field(s, &o.format, &o.sample)?;
|
||||
if let Some(lang) = &o.lang {
|
||||
stamp_caret_lang(s, lang)?;
|
||||
}
|
||||
} else {
|
||||
insert_plain(s, &o.sample, o.lang.as_deref())?;
|
||||
}
|
||||
return Ok(json!({"where": "caret", "format": o.format, "update": o.update, "text": o.sample}));
|
||||
}
|
||||
// Not editing: the slide's date placeholder (this slide only). A layout with no date
|
||||
// placeholder gets a text box instead, so the chosen format is still inserted.
|
||||
s.execute("design.headerFooter", &json!({"date": true, "all": false}))?;
|
||||
if !write_date_placeholder(s, o)? {
|
||||
return insert_date_box(s, o);
|
||||
}
|
||||
Ok(json!({"where": "footer", "format": o.format, "update": o.update, "text": o.sample}))
|
||||
}
|
||||
|
||||
/// Point the current slide's date placeholder at this format (a field, or the sample as plain text).
|
||||
fn write_date_placeholder(s: &mut Session, o: &DateOpts) -> Result<bool> {
|
||||
let format = o.format.clone();
|
||||
let sample = o.sample.clone();
|
||||
let lang = o.lang.clone();
|
||||
let update = o.update;
|
||||
s.edit(|doc, sel| {
|
||||
let Some(sl) = doc.slides.get_mut(sel.slide) else { return Ok(false) };
|
||||
let sl = std::sync::Arc::make_mut(sl);
|
||||
let Some(sh) = sl.shapes.iter_mut().find(|x| x.ph_type() == Some(deckcraft_model::PhType::Date)) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let mut props = sh.text.as_ref().and_then(|t| t.paragraphs.first()).and_then(|p| p.runs.first()).map(|r| r.props.clone()).unwrap_or_default();
|
||||
if let Some(lang) = lang {
|
||||
props.lang = Some(lang);
|
||||
}
|
||||
let kind = if update { RunKind::Field { field: format } } else { RunKind::Text };
|
||||
let para = deckcraft_model::Paragraph { runs: vec![deckcraft_model::Run { text: sample, props, kind }], ..Default::default() };
|
||||
let body = sh.text.clone().unwrap_or_default();
|
||||
sh.text = Some(TextBody { paragraphs: vec![para], ..body });
|
||||
Ok(true)
|
||||
})
|
||||
}
|
||||
|
||||
fn insert_date_box(s: &mut Session, o: &DateOpts) -> Result<Value> {
|
||||
let size = s.doc()?.doc.slide_size;
|
||||
let y = (size.height - 48.0).max(0.0);
|
||||
s.execute("insert.textBox", &json!({"rect": [36.0, y, 360.0, 28.0], "text": ""}))?;
|
||||
if o.update {
|
||||
crate::cmd::text::insert_field(s, &o.format, &o.sample)?;
|
||||
} else {
|
||||
crate::cmd::text::insert_text(s, &o.sample, None)?;
|
||||
}
|
||||
if let Some(lang) = &o.lang {
|
||||
stamp_caret_lang(s, lang)?;
|
||||
}
|
||||
Ok(json!({"where": "textBox", "format": o.format, "update": o.update, "text": o.sample}))
|
||||
}
|
||||
|
||||
/// Insert `text` at the caret. `lang`, when set, is on that run before runs with equal props merge,
|
||||
/// so the date stays its own run next to the surrounding text.
|
||||
fn insert_plain(s: &mut Session, text: &str, lang: Option<&str>) -> Result<()> {
|
||||
let text = text.to_string();
|
||||
let lang = lang.map(str::to_string);
|
||||
crate::cmd::text::edit_text(s, move |body, t| {
|
||||
let (a, b) = t.ordered();
|
||||
let at = deckcraft_model::edit::delete(body, a, b);
|
||||
let mut props = deckcraft_model::edit::props_at(body, at);
|
||||
if let Some(lang) = lang.clone() {
|
||||
props.lang = Some(lang);
|
||||
}
|
||||
let c = deckcraft_model::edit::insert(body, at, &text, Some(props));
|
||||
t.anchor = c;
|
||||
t.caret = c;
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tag the run that ends at the caret with `lang`, so a date field keeps that language when it updates.
|
||||
fn stamp_caret_lang(s: &mut Session, lang: &str) -> Result<()> {
|
||||
let lang = lang.to_string();
|
||||
crate::cmd::text::edit_text(s, move |body, t| {
|
||||
let Some(p) = body.paragraphs.get_mut(t.caret.0) else { return };
|
||||
let mut at = 0usize;
|
||||
for r in &mut p.runs {
|
||||
let n = r.char_len();
|
||||
if n > 0 && at < t.caret.1 && t.caret.1 <= at + n {
|
||||
r.props.lang = Some(lang);
|
||||
return;
|
||||
}
|
||||
at += n;
|
||||
}
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn symbol(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let t = str_param(p, "text").ok_or_else(|| bad("insert.symbol", "missing `text`"))?.to_string();
|
||||
if s.doc()?.selection.text.is_none() {
|
||||
return Err(bad("insert.symbol", "place the insertion point in text first"));
|
||||
}
|
||||
crate::cmd::text::insert_text(s, &t, None)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ use deckcraft_model::resolve::{self, Ctx};
|
||||
use deckcraft_model::{Shape, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, always, bad, cmd, has_doc, id_param, str_param, usize_param, xfrm_of};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
use deckcraft_model::{MediaClip, ShapeId, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, NO_ID_SPACE, bad, bool_param, cmd, edit_shapes, has_doc, targets};
|
||||
use crate::{EngineError, MediaStatus, Result, Session, UiRequest};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -179,7 +179,11 @@ fn gif_play(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use deckcraft_model::ShapeId;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::cmd::base64_encode;
|
||||
use crate::{MediaStatus, Session, UiRequest};
|
||||
|
||||
fn wav() -> Vec<u8> {
|
||||
let rate = 8000u32;
|
||||
@@ -228,6 +232,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();
|
||||
@@ -271,4 +290,16 @@ mod tests {
|
||||
assert_eq!(r["playable"], false);
|
||||
assert!(r["warning"].as_str().is_some_and(|w| w.contains("can't be played")), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_vp8_webm_is_playable_and_warns_that_only_the_audio_plays() {
|
||||
let mut s = Session::with_new();
|
||||
let _ = s.execute("slide.new", &json!({"layout": "blank"}));
|
||||
let clip = include_bytes!("../../../media/tests/fixtures/clip-vp8.webm");
|
||||
let r = s.execute("insert.video", &json!({"name": "clip.webm", "data": base64_encode(clip)})).expect("insert");
|
||||
assert_eq!(r["playable"], true, "the Vorbis track plays: {r}");
|
||||
let w = r["warning"].as_str().expect("the VP8 track can't be shown");
|
||||
assert!(w.contains("VP8") && w.contains("only the audio plays"), "{w}");
|
||||
assert!(matches!(s.ui_requests.last(), Some(UiRequest::Message { .. })), "the warning reaches the status bar");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ use i_overlay::core::overlay_rule::OverlayRule;
|
||||
use i_overlay::float::single::SingleFloatOverlay;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, NO_ID_SPACE, bad, cmd, has_two, str_param, targets, xfrm_of};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// Closed polygons (slide points) describing a filled area (non-zero winding).
|
||||
@@ -255,7 +255,12 @@ fn merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use deckcraft_model::{ShapeId, ShapeKind};
|
||||
use serde_json::json;
|
||||
|
||||
use super::{Area, area_of, area_size, letters_of, merge_areas};
|
||||
use crate::Session;
|
||||
use crate::cmd::base64_encode;
|
||||
|
||||
fn square(x: f64, y: f64, s: f64) -> Area {
|
||||
vec![vec![[x, y], [x + s, y], [x + s, y + s], [x, y + s]]]
|
||||
|
||||
@@ -6,7 +6,7 @@ use deckcraft_model::style::{Effects, Reflection, Shadow};
|
||||
use deckcraft_model::{ColorRef, Geom, Line, Shape};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, always, bad, cmd, edit_shapes, has_selection, str_param};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
+301
-52
@@ -10,7 +10,7 @@ use deckcraft_geom::{PathEl, Point, Xfrm};
|
||||
use deckcraft_model::{CustomPath, Geom, Shape, ShapeId, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, bad, cmd, f64_param, has_selection, id_param, ok, str_param, xfrm_of};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -24,7 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection,
|
||||
edit_points
|
||||
),
|
||||
cmd!(query "shape.points", "Points", [], None, "{id?} → {id, paths: [[[x, y] slide pt]], types: [[smooth|straight|corner]]} vertices of a freeform", has_selection, 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",
|
||||
@@ -54,6 +54,42 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
move_handle
|
||||
),
|
||||
cmd!("shape.deletePoint", "Delete Point", [], None, "{path?: 0, index, id?}", has_selection, delete_point),
|
||||
cmd!(
|
||||
"shape.deleteSegment",
|
||||
"Delete Segment",
|
||||
[],
|
||||
None,
|
||||
"{path?: 0, segment: index of the vertex ending it, id?}: removes a segment of a closed outline, which opens there; it starts at the segment's end and ends at its start",
|
||||
has_selection,
|
||||
delete_segment
|
||||
),
|
||||
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
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -281,6 +317,92 @@ fn remove_vertex(segs: &mut Vec<Seg>, si: usize) -> bool {
|
||||
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
|
||||
}
|
||||
|
||||
/// Deletes the segment ending at the vertex ending segment `si` from its closed sub-path, which
|
||||
/// opens there: it now starts at the segment's end and ends at its start. Returns false for an
|
||||
/// open sub-path.
|
||||
fn delete_segment_at(segs: &mut Vec<Seg>, si: usize) -> bool {
|
||||
let (from, _) = subpath(segs, si);
|
||||
if !open_at(segs, si) {
|
||||
return false;
|
||||
}
|
||||
// The deleted segment now ends the sub-path.
|
||||
let (_, to) = subpath(segs, from);
|
||||
if to > from + 1 {
|
||||
segs.remove(to - 1);
|
||||
}
|
||||
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);
|
||||
@@ -402,6 +524,7 @@ pub(crate) fn prepare(s: &mut Session, id: ShapeId) -> Result<()> {
|
||||
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}));
|
||||
@@ -436,17 +559,16 @@ fn points(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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 types: Vec<Vec<&str>> = match &sh.geom {
|
||||
Geom::Custom { paths } => paths
|
||||
.iter()
|
||||
.map(|cp| {
|
||||
let segs = parse(&cp.d);
|
||||
vertices(&segs).iter().map(|si| point_kind(&segs, *si)).collect()
|
||||
})
|
||||
.collect(),
|
||||
Geom::Preset { .. } => vec![],
|
||||
};
|
||||
Ok(json!({"id": id.0, "paths": paths, "types": types}))
|
||||
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.
|
||||
@@ -502,26 +624,26 @@ fn add_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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 r = 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
|
||||
};
|
||||
let si = ending_segment(segs, si).ok_or_else(|| bad("shape.addPoint", "the first point ends no segment"))?;
|
||||
if split(segs, si, t) { Ok(true) } else { Err(bad("shape.addPoint", "no segment ends at that point")) }
|
||||
})
|
||||
})?;
|
||||
forget_kinds(s, path);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
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 r = 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")) }
|
||||
})
|
||||
})?;
|
||||
forget_kinds(s, path);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// The vertex of `sh` near slide point `p`: (path, index).
|
||||
@@ -536,6 +658,62 @@ pub fn vertex_at(doc: &deckcraft_model::Presentation, sel: &crate::Selection, sh
|
||||
None
|
||||
}
|
||||
|
||||
/// The point at parameter `t` of segment `si`, scaled by `scale` into the shape's box.
|
||||
fn segment_point(segs: &[Seg], si: usize, p0: Point, t: f64, scale: (f64, 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 * scale.0, q.y * scale.1))
|
||||
}
|
||||
|
||||
/// The vertex that ends segment `si` (a closing edge ends on the start).
|
||||
fn segment_end_vertex(segs: &[Seg], vs: &[usize], si: usize) -> Option<usize> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/// The distance and `t` of the point on segment `si` nearest `local`, if it lies within `tol`.
|
||||
fn nearest_on_segment(segs: &[Seg], si: usize, p0: Point, scale: (f64, f64), local: Point, tol: f64) -> Option<(f64, f64)> {
|
||||
let at = |t: f64| segment_point(segs, si, p0, t, scale);
|
||||
let mut near: Option<(f64, f64)> = None;
|
||||
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 && near.is_none_or(|n| d < n.0) {
|
||||
near = Some((d, t));
|
||||
}
|
||||
}
|
||||
}
|
||||
let (mut d, mut t) = near?;
|
||||
// Narrow `t` down to the nearest place on the segment, where Add Point puts the point.
|
||||
let mut step = 1.0 / 32.0;
|
||||
for _ in 0..4 {
|
||||
step /= 8.0;
|
||||
let c = t;
|
||||
for k in -8..=8 {
|
||||
let u = (c + f64::from(k) * step).clamp(0.0, 1.0);
|
||||
if let Some(q) = at(u) {
|
||||
let e = (q - local).hypot();
|
||||
if e < d {
|
||||
(d, t) = (e, u);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some((d, t))
|
||||
}
|
||||
|
||||
/// 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 };
|
||||
@@ -543,39 +721,15 @@ pub fn segment_at(doc: &deckcraft_model::Presentation, sel: &crate::Selection, s
|
||||
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 scale = (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));
|
||||
}
|
||||
}
|
||||
let Some(ev) = segment_end_vertex(&segs, &vs, si) else { continue };
|
||||
let Some((d, t)) = nearest_on_segment(&segs, si, p0, scale, local, tol) else { continue };
|
||||
if best.is_none_or(|b| d < b.0) {
|
||||
best = Some((d, pi, ev, t));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -740,7 +894,7 @@ fn point_type(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => 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()));
|
||||
s.tool.vertex_kind = Some((id, 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);
|
||||
@@ -784,3 +938,98 @@ pub fn kind_of(sh: &Shape, path: usize, index: usize) -> Option<&'static str> {
|
||||
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)
|
||||
}
|
||||
|
||||
fn delete_segment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = target(s, p, "shape.deleteSegment")?;
|
||||
let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let seg = index_param(p, "segment", "shape.deleteSegment")?;
|
||||
let r = edit_path(s, id, path, "shape.deleteSegment", move |segs, vs, _| {
|
||||
let si = *vs.get(seg).ok_or_else(|| bad("shape.deleteSegment", "no such segment"))?;
|
||||
if ending_segment(segs, si).is_none() {
|
||||
return Err(bad("shape.deleteSegment", "the first point of an open outline ends no segment"));
|
||||
}
|
||||
if delete_segment_at(segs, si) { Ok(true) } else { Err(bad("shape.deleteSegment", "only a segment of a closed outline can be deleted")) }
|
||||
})?;
|
||||
forget_kinds(s, path);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// The point where the segment ending at vertex `index` of path `path` of `sh` starts.
|
||||
pub fn segment_start_of(sh: &Shape, path: usize, index: usize) -> Option<usize> {
|
||||
let Geom::Custom { paths } = &sh.geom else { return None };
|
||||
let segs = parse(&paths.get(path)?.d);
|
||||
let vs = vertices(&segs);
|
||||
let j = ending_segment(&segs, *vs.get(index)?)?;
|
||||
vs.iter().position(|v| Some(*v) == j.checked_sub(1))
|
||||
}
|
||||
|
||||
/// Adding, deleting, 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.1 == 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)))
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
//! Picture Format ▸ Color: Recolor presets and Set Transparent Color.
|
||||
|
||||
use deckcraft_color::{Rgba, SchemeSlot};
|
||||
use deckcraft_model::style::PictureAdjust;
|
||||
use deckcraft_model::{ColorRef, ShapeKind};
|
||||
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 super::{CommandSpec, always, bad, bool_or, cmd, current_shapes, edit_shapes, has_selection, str_param, targets, text, xfrm_of};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -27,7 +28,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Set Transparent Color",
|
||||
["Picture Format", "Adjust", "Color"],
|
||||
None,
|
||||
"{color: #RRGGBB | none: true, ids?} (pixels of that colour become transparent)",
|
||||
"{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
|
||||
),
|
||||
@@ -121,6 +122,15 @@ fn recolor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -134,3 +144,97 @@ fn transparent_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -10,22 +10,41 @@ use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::text::{Action, Hyperlink, RunProps, TextBody};
|
||||
use deckcraft_model::{LayoutId, MasterId, MediaId, Presentation, ShapeId, ShapeKind, Slide, SlideId};
|
||||
use deckcraft_model::{LayoutId, Master, MasterId, MediaId, Presentation, ShapeId, ShapeKind, Slide, SlideId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, NO_ID_SPACE, bad, bool_or, cmd, has_doc, usize_param};
|
||||
use crate::{Result, Session, media_refs};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![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
|
||||
)]
|
||||
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).
|
||||
@@ -114,9 +133,9 @@ fn relink_shapes(shapes: &mut [deckcraft_model::Shape], map: &HashMap<SlideId, S
|
||||
}
|
||||
|
||||
/// Fresh shape ids for `shapes`, and the old → new map.
|
||||
fn renumber(doc: &mut Presentation, shapes: &mut [deckcraft_model::Shape]) -> Result<HashMap<ShapeId, ShapeId>> {
|
||||
fn renumber(cmd: &str, doc: &mut Presentation, shapes: &mut [deckcraft_model::Shape]) -> Result<HashMap<ShapeId, ShapeId>> {
|
||||
let old = shape_ids(shapes);
|
||||
super::edit::reid("slide.reuse", doc, 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)
|
||||
@@ -171,19 +190,7 @@ fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
continue;
|
||||
}
|
||||
masters_done.push(m.id);
|
||||
let mut used = vec![];
|
||||
media_refs::visit::master(m, &mut |id: &MediaId| used.push(*id));
|
||||
copy_media(doc, &src, &used, &mut 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("slide.reuse", NO_ID_SPACE))?);
|
||||
renumber(doc, &mut m.shapes)?;
|
||||
for l in &mut m.layouts {
|
||||
let new = LayoutId(doc.alloc_id().ok_or_else(|| bad("slide.reuse", NO_ID_SPACE))?);
|
||||
layout_map.insert(l.id, new);
|
||||
l.id = new;
|
||||
renumber(doc, &mut l.shapes)?;
|
||||
}
|
||||
let m = import_master("slide.reuse", doc, &src, m, &mut media_map, &mut layout_map)?;
|
||||
doc.masters.push(Arc::new(m));
|
||||
}
|
||||
}
|
||||
@@ -192,11 +199,11 @@ fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut slide_map: HashMap<SlideId, SlideId> = HashMap::new();
|
||||
for &i in &picked {
|
||||
let Some(from) = src.slides.get(i) else { continue };
|
||||
copy_media(doc, &src, &media_refs::slide_media(from), &mut media_map)?;
|
||||
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(doc, &mut sl.shapes)?;
|
||||
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));
|
||||
@@ -227,9 +234,35 @@ fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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(doc: &mut Presentation, src: &Presentation, used: &[MediaId], map: &mut HashMap<MediaId, MediaId>) -> Result<()> {
|
||||
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;
|
||||
@@ -237,12 +270,12 @@ fn copy_media(doc: &mut Presentation, src: &Presentation, used: &[MediaId], map:
|
||||
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("slide.reuse", NO_ID_SPACE))?);
|
||||
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("slide.reuse", NO_ID_SPACE))?
|
||||
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);
|
||||
}
|
||||
@@ -397,4 +430,34 @@ mod tests {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ use std::sync::Arc;
|
||||
use deckcraft_model::Comment;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{
|
||||
CommandSpec, always, bad, base64_decode, bool_or, bool_param, cmd, f64_or, has_doc, has_slide, has_text, id_param, ok, str_param, usize_param,
|
||||
};
|
||||
use crate::{Result, Session, Target, UiRequest};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -59,6 +61,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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, _| {
|
||||
@@ -421,7 +432,7 @@ fn spelling_change(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// 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 end = start.checked_add(word.chars().count()).ok_or_else(|| bad("review.spellingChange", "bad `start`"))?;
|
||||
let cell = usize_param(p, "row").zip(usize_param(p, "col"));
|
||||
let slide = {
|
||||
let st = s.doc()?;
|
||||
@@ -655,7 +666,7 @@ fn revised_bytes(p: &Value) -> Result<(String, Vec<u8>)> {
|
||||
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::diff(&st.doc, &c.revised, c.by_position), "byPosition": c.by_position}))
|
||||
Ok(json!({"name": name, "changes": crate::compare::pending(&st.doc, c), "byPosition": c.by_position}))
|
||||
}
|
||||
|
||||
fn compare(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -663,7 +674,7 @@ fn compare(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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 });
|
||||
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()?)
|
||||
}
|
||||
@@ -673,7 +684,8 @@ fn revisions(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn accept(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (rev, by_pos) = s.doc()?.compare.as_ref().map(|c| (c.revised.clone(), c.by_position)).ok_or_else(|| bad("review.accept", "not comparing"))?;
|
||||
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() {
|
||||
@@ -684,7 +696,7 @@ fn accept(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// 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::diff(doc, &rev, by_pos);
|
||||
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 {
|
||||
@@ -707,6 +719,24 @@ fn accept(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -815,3 +845,18 @@ mod tests {
|
||||
assert!(s.active().unwrap().doc.slides[0].comments.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod spelling_change_range_tests {
|
||||
use crate::{EngineError, Session};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn spelling_change_huge_start_is_bad_params() {
|
||||
let mut s = Session::with_new();
|
||||
let r = s.execute("insert.textBox", &json!({"text": "Hello"})).unwrap();
|
||||
let id = r["id"].clone();
|
||||
let err = s.execute("review.spellingChange", &json!({"shape": id, "para": 0, "start": u64::MAX, "word": "Hello", "to": "x"})).unwrap_err();
|
||||
assert!(matches!(err, EngineError::BadParams { .. }), "{err:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,14 +107,16 @@ pub fn html_to_body(html: &str) -> Option<TextBody> {
|
||||
}
|
||||
if closing {
|
||||
let kind = tag_kind(&name);
|
||||
if !kind.is_empty()
|
||||
&& let Some(pos) = stack.iter().rposition(|e| e.0 == kind)
|
||||
{
|
||||
let (_, db, di, du) = stack[pos];
|
||||
bold -= db;
|
||||
italic -= di;
|
||||
underline -= du;
|
||||
stack.truncate(pos);
|
||||
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);
|
||||
@@ -130,7 +132,7 @@ pub fn html_to_body(html: &str) -> Option<TextBody> {
|
||||
bold += db;
|
||||
italic += di;
|
||||
underline += du;
|
||||
if !token.ends_with('/') {
|
||||
if !token.ends_with('/') && stack.len() < 128 {
|
||||
stack.push((tag_kind(&name), db, di, du));
|
||||
} else {
|
||||
bold -= db;
|
||||
@@ -145,8 +147,13 @@ pub fn html_to_body(html: &str) -> Option<TextBody> {
|
||||
continue;
|
||||
}
|
||||
let end = bytes[i..].iter().position(|c| *c == b'<').map(|n| i + n).unwrap_or(bytes.len());
|
||||
let text = decode_text(&html[i..end]);
|
||||
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;
|
||||
}
|
||||
@@ -155,6 +162,7 @@ pub fn html_to_body(html: &str) -> Option<TextBody> {
|
||||
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;
|
||||
}
|
||||
@@ -223,7 +231,11 @@ fn weight_bold(style: &str) -> Option<bool> {
|
||||
|
||||
fn attr<'a>(tag: &'a str, key: &str) -> Option<&'a str> {
|
||||
let lower = tag.to_ascii_lowercase();
|
||||
let at = lower.find(key)?;
|
||||
let at = lower.match_indices(key).find_map(|(at, _)| {
|
||||
let named = lower[..at].ends_with(|c: char| c.is_ascii_whitespace());
|
||||
let eq = tag.get(at + key.len()..)?.trim_start().starts_with('=');
|
||||
(named && eq).then_some(at)
|
||||
})?;
|
||||
let after = tag.get(at + key.len()..)?.trim_start();
|
||||
let after = after.strip_prefix('=')?.trim_start();
|
||||
let quote = after.chars().next()?;
|
||||
@@ -240,10 +252,19 @@ fn new_para(paragraphs: &mut Vec<Paragraph>) {
|
||||
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;
|
||||
@@ -263,15 +284,38 @@ fn push_run(paragraphs: &mut [Paragraph], runs: &mut usize, text: String, bold:
|
||||
*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(&rest[..at].replace(['\n', '\r'], ""));
|
||||
out.push_str(&collapse_space(&rest[..at]));
|
||||
rest = &rest[at..];
|
||||
let Some(end) = rest.find(';') else {
|
||||
out.push_str(&rest.replace(['\n', '\r'], ""));
|
||||
break;
|
||||
let mut search_window = rest.len().min(32);
|
||||
while !rest.is_char_boundary(search_window) {
|
||||
search_window -= 1;
|
||||
}
|
||||
let Some(end) = rest[..search_window].find(';') else {
|
||||
out.push('&');
|
||||
rest = &rest[1..];
|
||||
continue;
|
||||
};
|
||||
let entity = &rest[1..end];
|
||||
let decoded = match entity {
|
||||
@@ -280,7 +324,7 @@ fn decode_text(raw: &str) -> String {
|
||||
"gt" => Some('>'),
|
||||
"quot" => Some('"'),
|
||||
"apos" => Some('\''),
|
||||
"nbsp" => Some(' '),
|
||||
"nbsp" => Some('\u{a0}'),
|
||||
_ => entity.strip_prefix('#').and_then(numeric_char),
|
||||
};
|
||||
if let Some(c) = decoded {
|
||||
@@ -293,15 +337,20 @@ fn decode_text(raw: &str) -> String {
|
||||
}
|
||||
}
|
||||
if !rest.is_empty() && !rest.starts_with('&') {
|
||||
out.push_str(&rest.replace(['\n', '\r'], ""));
|
||||
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'));
|
||||
let n = if let Some(hex) = hex { u32::from_str_radix(hex, 16).ok()? } else { digits.parse().ok()? };
|
||||
char::from_u32(n).filter(|c| !c.is_control() || *c == '\n')
|
||||
// `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() || matches!(*c, '\n' | '\t'))
|
||||
}
|
||||
|
||||
/// Do the HTML's words and the paste's plain text say the same thing?
|
||||
@@ -319,6 +368,27 @@ fn fold(s: &str) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn multibyte_after_ampersand_is_kept() {
|
||||
let text = "é".repeat(20);
|
||||
assert_eq!(decode_text(&format!("R&D {text}")), format!("R&D {text}"));
|
||||
let cjk = "漢".repeat(20);
|
||||
assert_eq!(decode_text(&format!("&{cjk}")), format!("&{cjk}"));
|
||||
}
|
||||
|
||||
#[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 numeric_tab_entity_decodes_to_tab() {
|
||||
assert_eq!(decode_text("a	b"), "a\tb");
|
||||
assert_eq!(decode_text("a	b"), "a\tb");
|
||||
}
|
||||
|
||||
#[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>";
|
||||
@@ -333,6 +403,20 @@ mod tests {
|
||||
assert_eq!(heavy.paragraphs[0].runs[0].props.bold, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn style_attribute_is_found_after_attributes_containing_style() {
|
||||
for tag in [
|
||||
"<span class=\"style1\" style=\"font-weight:bold\">x</span>",
|
||||
"<span data-style=\"x\" style=\"font-weight:bold\">x</span>",
|
||||
"<span class=\"mystyle\" style=\"font-weight:bold\">x</span>",
|
||||
] {
|
||||
let body = html_to_body(tag).unwrap();
|
||||
assert_eq!(body.paragraphs[0].runs[0].props.bold, Some(true), "{tag}");
|
||||
}
|
||||
let none = html_to_body("<span class=\"style\">x</span>").unwrap();
|
||||
assert_eq!(none.paragraphs[0].runs[0].props.bold, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bold_italic_and_underline_round_trip() {
|
||||
let mut body = TextBody::from_text("Hi there");
|
||||
@@ -358,6 +442,10 @@ mod tests {
|
||||
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]
|
||||
@@ -371,6 +459,18 @@ mod tests {
|
||||
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);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
use deckcraft_model::{Shape, ShapeId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, bad, bool_or, cmd, has_slide, id_param, ids_param, ok};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
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>) {
|
||||
|
||||
+178
-1186
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,515 @@
|
||||
use super::{
|
||||
ColorRef, ColorTransform, Dash, Effects, Fill, Geom, Glow, Gradient, GradientShape, GradientStop, Line, LineEnd, PictureFill, PictureMode,
|
||||
Reflection, Result, Rgba, SchemeSlot, Session, Shadow, ShapeStyle, Value, apply_fill, bad, bool_or, color_param, color_value, edit_geometry,
|
||||
edit_shapes, f64_param, inherited_fills, json, requested_fill, str_param, usize_param,
|
||||
};
|
||||
|
||||
pub(super) fn adjust(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = usize_param(p, "index").unwrap_or(0).min(16);
|
||||
let v = f64_param(p, "value").ok_or_else(|| bad("shape.adjust", "missing `value`"))?;
|
||||
edit_geometry(s, p, "shape.adjust", |sh| {
|
||||
if let Geom::Preset { name, adj } = &mut sh.geom {
|
||||
let defaults = deckcraft_geom::preset::info(name).map(|x| x.defaults).unwrap_or(&[]);
|
||||
while adj.len() <= i {
|
||||
let k = adj.len();
|
||||
adj.push(defaults.get(k).copied().unwrap_or(0.0));
|
||||
}
|
||||
if let Some(slot) = adj.get_mut(i) {
|
||||
*slot = v;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Rectangles and rounded rectangles become custom geometry whose corners are smoothed
|
||||
/// ([`deckcraft_geom::squircle`]); the radius is the rounded rectangle's own unless `radius` says.
|
||||
pub(super) fn smooth_corners(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "shape.smoothCorners";
|
||||
let smoothing =
|
||||
f64_param(p, "smoothing").filter(|v| v.is_finite()).ok_or_else(|| bad(CMD, "missing `smoothing` (0–100)"))?.clamp(0.0, 100.0) / 100.0;
|
||||
let radius = f64_param(p, "radius").filter(|v| v.is_finite()).map(|v| v.max(0.0));
|
||||
edit_geometry(s, p, CMD, |sh| {
|
||||
let Some(x) = sh.xfrm else { return Err(bad(CMD, "the shape has no size of its own")) };
|
||||
let ss = x.w.min(x.h).max(0.0);
|
||||
let own = match &sh.geom {
|
||||
Geom::Preset { name, adj } if name == "roundRect" => Some(ss * adj.first().copied().unwrap_or(16667.0).clamp(0.0, 50000.0) / 100000.0),
|
||||
Geom::Preset { name, .. } if name == "rect" => Some(ss * 16667.0 / 100000.0),
|
||||
_ => None,
|
||||
};
|
||||
// A custom outline qualifies only when it is a squircle this command drew with `radius`
|
||||
// (re-smoothing as the slider moves), never an arbitrary freeform.
|
||||
let custom = match (&sh.geom, radius) {
|
||||
(Geom::Custom { paths }, Some(r)) => match paths.as_slice() {
|
||||
[only] => (0..=100).any(|k| deckcraft_geom::squircle::path_d(only.w, only.h, r, f64::from(k) / 100.0) == only.d),
|
||||
_ => false,
|
||||
},
|
||||
_ => false,
|
||||
};
|
||||
let r = match (radius, own) {
|
||||
(Some(r), _) if own.is_some() || custom => r,
|
||||
(None, Some(r)) => r,
|
||||
_ => return Err(bad(CMD, "only rectangles, rounded rectangles and shapes already smoothed with this `radius` can be smoothed")),
|
||||
};
|
||||
sh.geom = Geom::Custom {
|
||||
paths: vec![deckcraft_model::CustomPath {
|
||||
w: x.w,
|
||||
h: x.h,
|
||||
d: deckcraft_geom::squircle::path_d(x.w, x.h, r, smoothing),
|
||||
..Default::default()
|
||||
}],
|
||||
};
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn change(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "preset").ok_or_else(|| bad("shape.change", "missing `preset`"))?;
|
||||
let info = deckcraft_geom::preset::info(name).ok_or_else(|| bad("shape.change", format!("unknown preset `{name}`")))?;
|
||||
edit_geometry(s, p, "shape.change", |sh| {
|
||||
sh.geom = Geom::preset(info.name);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// `{stops: [[pos 0–1, color], …], kind?: linear|radial|rectangular|path, angle?: deg (linear),
|
||||
/// focus?: [l, t, r, b] fractions (radial/rectangular/path: where the first stop sits),
|
||||
/// rotateWithShape?}`.
|
||||
pub(crate) fn gradient_param(v: &Value) -> Option<Gradient> {
|
||||
let stops: Vec<GradientStop> = v
|
||||
.get("stops")?
|
||||
.as_array()?
|
||||
.iter()
|
||||
.filter_map(|s| {
|
||||
let a = s.as_array()?;
|
||||
Some(GradientStop { pos: a.first()?.as_f64()?.clamp(0.0, 1.0), color: color_value(a.get(1)?)? })
|
||||
})
|
||||
.collect();
|
||||
if stops.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let focus = v
|
||||
.get("focus")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|a| {
|
||||
let f = |i: usize| a.get(i).and_then(Value::as_f64).filter(|x| x.is_finite()).map(|x| x.clamp(0.0, 1.0));
|
||||
Some([f(0)?, f(1)?, f(2)?, f(3)?])
|
||||
})
|
||||
.unwrap_or([0.5, 0.5, 0.5, 0.5]);
|
||||
let mut stops = stops;
|
||||
stops.sort_by(|a, b| a.pos.total_cmp(&b.pos));
|
||||
let path = |p: &str| GradientShape::Path { path: p.into(), focus };
|
||||
let shape = match v.get("kind").and_then(Value::as_str) {
|
||||
Some("radial" | "circle") => path("circle"),
|
||||
Some("rectangular" | "rect") => path("rect"),
|
||||
Some("path" | "shape") => path("shape"),
|
||||
_ => GradientShape::Linear {
|
||||
angle: v.get("angle").and_then(Value::as_f64).filter(|a| a.is_finite()).unwrap_or(90.0).rem_euclid(360.0),
|
||||
scaled: false,
|
||||
},
|
||||
};
|
||||
Some(Gradient { tile: [0.0; 4], stops, shape, rotate_with_shape: v.get("rotateWithShape").and_then(Value::as_bool).unwrap_or(true) })
|
||||
}
|
||||
|
||||
/// Fill and outline apply to a group's visible members as well as its container. Other edits
|
||||
/// (notably geometry) still target only the selected shape. The whole traversal stays in one edit.
|
||||
fn edit_group_appearance(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut deckcraft_model::Shape) -> Result<()>) -> Result<Value> {
|
||||
fn apply(sh: &mut deckcraft_model::Shape, depth: usize, f: &dyn Fn(&mut deckcraft_model::Shape) -> Result<()>) -> Result<()> {
|
||||
if depth > 64 {
|
||||
return Ok(());
|
||||
}
|
||||
f(sh)?;
|
||||
if let Some(children) = sh.children_mut() {
|
||||
for child in children {
|
||||
apply(child, depth + 1, f)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
edit_shapes(s, p, cmd, |sh| apply(sh, 0, &f))
|
||||
}
|
||||
|
||||
pub(super) fn fill(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let new_fill = requested_fill(s, p)?;
|
||||
let transparency = f64_param(p, "transparency");
|
||||
let pic_props = p.get("pictureProps").filter(|v| v.is_object());
|
||||
if new_fill.is_none() && transparency.is_none() && pic_props.is_none() {
|
||||
return Err(bad("shape.fill", "give `color`, `none`, `gradient`, `pattern`, `picture`, `pictureProps`, `transparency` or `reset`"));
|
||||
}
|
||||
// Transparency on an inherited (style) fill needs the effective colour.
|
||||
let inherited = inherited_fills(s, p)?;
|
||||
edit_group_appearance(s, p, "shape.fill", |sh| apply_fill(sh, new_fill.as_ref(), pic_props, transparency, &inherited))
|
||||
}
|
||||
|
||||
/// Applies Format Shape ▸ Picture or texture fill options to a picture fill.
|
||||
pub(super) fn picture_props(pf: &mut PictureFill, pp: &Value) {
|
||||
let f = |k: &str| pp.get(k).and_then(Value::as_f64).filter(|v| v.is_finite());
|
||||
if let Some(up) = pp.get("rotateWithShape").and_then(Value::as_bool) {
|
||||
pf.upright = !up;
|
||||
}
|
||||
match pp.get("tile").and_then(Value::as_bool) {
|
||||
Some(true) if !matches!(pf.mode, PictureMode::Tile { .. }) => {
|
||||
pf.mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 1.0, sy: 1.0, flip: "none".into(), align: "tl".into() };
|
||||
}
|
||||
Some(false) if !matches!(pf.mode, PictureMode::Stretch { .. }) => pf.mode = PictureMode::default(),
|
||||
_ => {}
|
||||
}
|
||||
match &mut pf.mode {
|
||||
PictureMode::Tile { tx, ty, sx, sy, flip, align } => {
|
||||
if let Some(v) = f("offsetX") {
|
||||
*tx = v.clamp(-4000.0, 4000.0);
|
||||
}
|
||||
if let Some(v) = f("offsetY") {
|
||||
*ty = v.clamp(-4000.0, 4000.0);
|
||||
}
|
||||
if let Some(v) = f("scaleX") {
|
||||
*sx = v.clamp(0.01, 10.0);
|
||||
}
|
||||
if let Some(v) = f("scaleY") {
|
||||
*sy = v.clamp(0.01, 10.0);
|
||||
}
|
||||
if let Some(m) = pp.get("mirror").and_then(Value::as_str).filter(|m| matches!(*m, "none" | "x" | "y" | "xy")) {
|
||||
*flip = m.to_string();
|
||||
}
|
||||
if let Some(a) =
|
||||
pp.get("align").and_then(Value::as_str).filter(|a| matches!(*a, "tl" | "t" | "tr" | "l" | "ctr" | "r" | "bl" | "b" | "br"))
|
||||
{
|
||||
*align = a.to_string();
|
||||
}
|
||||
}
|
||||
PictureMode::Stretch { fill_rect } => {
|
||||
if let Some(arr) = pp.get("stretch").and_then(Value::as_array) {
|
||||
for (slot, v) in fill_rect.iter_mut().zip(arr) {
|
||||
if let Some(v) = v.as_f64().filter(|v| v.is_finite()) {
|
||||
*slot = v.clamp(-10.0, 0.99);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn line_end(v: &str) -> Option<LineEnd> {
|
||||
if v == "none" { None } else { Some(LineEnd { kind: v.to_string(), w: "med".into(), len: "med".into() }) }
|
||||
}
|
||||
|
||||
pub(super) fn line(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let reset = bool_or(p, "reset", false);
|
||||
let none = bool_or(p, "none", false);
|
||||
let color = color_param(p, "color");
|
||||
let width = f64_param(p, "width").map(|w| w.clamp(0.0, 1584.0));
|
||||
let dash = str_param(p, "dash").map(|d| Dash::Preset { name: d.to_string() });
|
||||
let head = str_param(p, "head").map(line_end);
|
||||
let tail = str_param(p, "tail").map(line_end);
|
||||
let cap = str_param(p, "cap").map(|c| match c {
|
||||
"round" => deckcraft_model::style::LineCap::Round,
|
||||
"square" => deckcraft_model::style::LineCap::Square,
|
||||
_ => deckcraft_model::style::LineCap::Flat,
|
||||
});
|
||||
let compound = str_param(p, "compound").map(|c| match c {
|
||||
"double" => deckcraft_model::style::Compound::Double,
|
||||
"thickThin" => deckcraft_model::style::Compound::ThickThin,
|
||||
"thinThick" => deckcraft_model::style::Compound::ThinThick,
|
||||
"triple" => deckcraft_model::style::Compound::Triple,
|
||||
_ => deckcraft_model::style::Compound::Single,
|
||||
});
|
||||
let end_size = |k: &str| str_param(p, k).filter(|v| matches!(*v, "sm" | "med" | "lg")).map(str::to_string);
|
||||
let (head_size, tail_size) = (end_size("headSize"), end_size("tailSize"));
|
||||
let join = str_param(p, "join").map(|c| match c {
|
||||
"bevel" => deckcraft_model::style::LineJoin::Bevel,
|
||||
"miter" => deckcraft_model::style::LineJoin::Miter,
|
||||
_ => deckcraft_model::style::LineJoin::Round,
|
||||
});
|
||||
edit_group_appearance(s, p, "shape.line", |sh| {
|
||||
if reset {
|
||||
sh.line = None;
|
||||
return Ok(());
|
||||
}
|
||||
let l = sh.line.get_or_insert_with(Line::default);
|
||||
if none {
|
||||
l.fill = Some(Fill::None);
|
||||
}
|
||||
if let Some(c) = &color {
|
||||
l.fill = Some(Fill::solid(c.clone()));
|
||||
}
|
||||
if width.is_some() {
|
||||
l.width = width;
|
||||
if l.fill.is_none() || matches!(l.fill, Some(Fill::None)) && !none {
|
||||
l.fill = Some(Fill::solid(ColorRef::scheme(SchemeSlot::Tx1)));
|
||||
}
|
||||
}
|
||||
if dash.is_some() {
|
||||
l.dash.clone_from(&dash);
|
||||
}
|
||||
let keep_size = |old: &Option<LineEnd>, new: &Option<LineEnd>| {
|
||||
new.clone().map(|mut n| {
|
||||
if let Some(o) = old {
|
||||
n.w.clone_from(&o.w);
|
||||
n.len.clone_from(&o.len);
|
||||
}
|
||||
n
|
||||
})
|
||||
};
|
||||
if let Some(h) = &head {
|
||||
l.head = keep_size(&l.head, h);
|
||||
}
|
||||
if let Some(t) = &tail {
|
||||
l.tail = keep_size(&l.tail, t);
|
||||
}
|
||||
if cap.is_some() {
|
||||
l.cap = cap;
|
||||
}
|
||||
if join.is_some() {
|
||||
l.join = join;
|
||||
}
|
||||
if compound.is_some() {
|
||||
l.compound = compound;
|
||||
}
|
||||
// Arrowhead size (width and length together), kept when the kind changes.
|
||||
for (end, size) in [(&mut l.head, &head_size), (&mut l.tail, &tail_size)] {
|
||||
if let (Some(e), Some(sz)) = (end.as_mut(), size) {
|
||||
e.w.clone_from(sz);
|
||||
e.len.clone_from(sz);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn shadow_preset(v: &Value) -> Option<Option<Shadow>> {
|
||||
let black = |a: i32| ColorRef::rgb(Rgba::BLACK).with(ColorTransform::Alpha(a));
|
||||
let base = |inner: bool| Shadow {
|
||||
color: black(40000),
|
||||
blur: 4.0,
|
||||
dist: 3.0,
|
||||
dir: 45.0,
|
||||
inner,
|
||||
sx: 1.0,
|
||||
sy: 1.0,
|
||||
kx: 0.0,
|
||||
ky: 0.0,
|
||||
align: "tl".into(),
|
||||
rotate_with_shape: false,
|
||||
};
|
||||
match v {
|
||||
Value::String(s) => Some(match s.as_str() {
|
||||
"none" => None,
|
||||
"inner" => Some(base(true)),
|
||||
"perspective" => Some(Shadow { sy: 0.23, kx: -1200000.0 / 60000.0, dir: 90.0, dist: 0.0, blur: 6.0, align: "bl".into(), ..base(false) }),
|
||||
"bottom" => Some(Shadow { dir: 90.0, ..base(false) }),
|
||||
"center" => Some(Shadow { dist: 0.0, blur: 8.0, ..base(false) }),
|
||||
_ => Some(base(false)),
|
||||
}),
|
||||
Value::Object(o) => Some(Some(Shadow {
|
||||
color: o.get("color").and_then(color_value).unwrap_or(black(40000)),
|
||||
blur: o.get("blur").and_then(Value::as_f64).unwrap_or(4.0).clamp(0.0, 200.0),
|
||||
dist: o.get("dist").and_then(Value::as_f64).unwrap_or(3.0).clamp(0.0, 200.0),
|
||||
dir: o.get("dir").and_then(Value::as_f64).unwrap_or(45.0),
|
||||
inner: o.get("inner").and_then(Value::as_bool).unwrap_or(false),
|
||||
..base(false)
|
||||
})),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn effects(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let reset = bool_or(p, "reset", false);
|
||||
let shadow = p.get("shadow").and_then(shadow_preset);
|
||||
let glow: Option<Option<Glow>> = p.get("glow").map(|g| match g {
|
||||
Value::Object(o) => Some(Glow {
|
||||
color: o.get("color").and_then(color_value).unwrap_or(ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::Alpha(40000))),
|
||||
radius: o.get("radius").and_then(Value::as_f64).unwrap_or(8.0).clamp(0.0, 150.0),
|
||||
}),
|
||||
Value::Number(n) => Some(Glow {
|
||||
color: ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::Alpha(40000)),
|
||||
radius: n.as_f64().unwrap_or(8.0).clamp(0.0, 150.0),
|
||||
}),
|
||||
_ => None,
|
||||
});
|
||||
let soft = p.get("softEdges").map(|v| v.as_f64().filter(|x| *x > 0.0).map(|x| x.min(100.0)));
|
||||
let refl = str_param(p, "reflection").map(|r| match r {
|
||||
"none" => None,
|
||||
"half" => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.55, dist: 4.0 }),
|
||||
"full" => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.9, dist: 4.0 }),
|
||||
_ => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.35, dist: 0.0 }),
|
||||
});
|
||||
let three_d = p.get("threeD").filter(|v| v.is_object());
|
||||
let bevel = str_param(p, "bevel").map(|b| if b == "none" { None } else { Some(b.to_string()) });
|
||||
let shadow_props = p.get("shadowProps").filter(|v| v.is_object()).cloned();
|
||||
let glow_props = p.get("glowProps").filter(|v| v.is_object()).cloned();
|
||||
let refl_props = p.get("reflectionProps").filter(|v| v.is_object()).cloned();
|
||||
edit_shapes(s, p, "shape.effects", |sh| {
|
||||
if reset {
|
||||
sh.effects = None;
|
||||
return Ok(());
|
||||
}
|
||||
let e = sh.effects.get_or_insert_with(Effects::default);
|
||||
if let Some(sw) = &shadow {
|
||||
match sw {
|
||||
Some(x) if x.inner => {
|
||||
e.inner_shadow = Some(x.clone());
|
||||
e.outer_shadow = None;
|
||||
}
|
||||
Some(x) => {
|
||||
e.outer_shadow = Some(x.clone());
|
||||
e.inner_shadow = None;
|
||||
}
|
||||
None => {
|
||||
e.outer_shadow = None;
|
||||
e.inner_shadow = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(g) = &glow {
|
||||
e.glow.clone_from(g);
|
||||
}
|
||||
if let Some(sf) = soft {
|
||||
e.soft_edge = sf;
|
||||
}
|
||||
if let Some(r) = &refl {
|
||||
e.reflection.clone_from(r);
|
||||
}
|
||||
if bevel.is_some() || three_d.is_some() {
|
||||
let mut s3 = e.raw3d.as_deref().map(crate::cmd::three_d::Shape3d::parse).unwrap_or_default();
|
||||
if let Some(b) = &bevel {
|
||||
s3.apply(&json!({"bevelTop": b.as_deref().unwrap_or("none")}));
|
||||
}
|
||||
if let Some(t) = three_d {
|
||||
if t.get("reset").and_then(Value::as_bool) == Some(true) {
|
||||
s3 = crate::cmd::three_d::Shape3d::default();
|
||||
}
|
||||
s3.apply(t);
|
||||
}
|
||||
crate::cmd::three_d::store(e, &s3);
|
||||
}
|
||||
if let Some(v) = &shadow_props {
|
||||
let slot = if e.inner_shadow.is_some() { &mut e.inner_shadow } else { &mut e.outer_shadow };
|
||||
let mut x = slot.take().or_else(|| shadow_preset(&json!("outer")).flatten()).ok_or_else(|| bad("shape.effects", "no shadow preset"))?;
|
||||
if let Some(c) = v.get("color").and_then(color_value) {
|
||||
let a = x.color.alpha();
|
||||
x.color = with_alpha(c, a);
|
||||
}
|
||||
if let Some(t) = num(v, "transparency") {
|
||||
x.color = with_alpha(x.color.clone(), 1.0 - t.clamp(0.0, 1.0));
|
||||
}
|
||||
if let Some(b) = num(v, "blur") {
|
||||
x.blur = b.clamp(0.0, 100.0);
|
||||
}
|
||||
if let Some(d) = num(v, "dist") {
|
||||
x.dist = d.clamp(0.0, 200.0);
|
||||
}
|
||||
if let Some(d) = num(v, "dir") {
|
||||
x.dir = d.rem_euclid(360.0);
|
||||
}
|
||||
*slot = Some(x);
|
||||
}
|
||||
if let Some(v) = &glow_props {
|
||||
let mut g =
|
||||
e.glow.take().unwrap_or(Glow { color: ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::Alpha(40000)), radius: 8.0 });
|
||||
if let Some(c) = v.get("color").and_then(color_value) {
|
||||
let a = g.color.alpha();
|
||||
g.color = with_alpha(c, a);
|
||||
}
|
||||
if let Some(t) = num(v, "transparency") {
|
||||
g.color = with_alpha(g.color.clone(), 1.0 - t.clamp(0.0, 1.0));
|
||||
}
|
||||
if let Some(r) = num(v, "radius") {
|
||||
g.radius = r.clamp(0.0, 150.0);
|
||||
}
|
||||
e.glow = (g.radius > 0.0).then_some(g);
|
||||
}
|
||||
if let Some(v) = &refl_props {
|
||||
let mut r = e.reflection.take().unwrap_or(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.35, dist: 0.0 });
|
||||
if let Some(t) = num(v, "transparency") {
|
||||
r.start_alpha = 1.0 - t.clamp(0.0, 1.0);
|
||||
}
|
||||
if let Some(sz) = num(v, "size") {
|
||||
r.end_pos = sz.clamp(0.0, 1.0);
|
||||
}
|
||||
if let Some(d) = num(v, "dist") {
|
||||
r.dist = d.clamp(0.0, 100.0);
|
||||
}
|
||||
if let Some(b) = num(v, "blur") {
|
||||
r.blur = b.clamp(0.0, 100.0);
|
||||
}
|
||||
e.reflection = Some(r);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// A finite number at `key` of an effect's properties.
|
||||
fn num(v: &Value, key: &str) -> Option<f64> {
|
||||
v.get(key).and_then(Value::as_f64).filter(|x| x.is_finite())
|
||||
}
|
||||
|
||||
/// `c` with its alpha set to `a` (0–1); fully opaque drops the transform.
|
||||
fn with_alpha(mut c: ColorRef, a: f64) -> ColorRef {
|
||||
c.mods.retain(|m| !matches!(m, ColorTransform::Alpha(_)));
|
||||
if a < 1.0 {
|
||||
c.mods.push(ColorTransform::Alpha((a.clamp(0.0, 1.0) * 100_000.0).round() as i32));
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
/// Theme quick styles: 7 rows (outline only, solid fill variants, intense…) × 6 accents.
|
||||
pub fn quick_style_for(index: usize) -> (ShapeStyle, Option<Fill>, Option<Line>) {
|
||||
let slots = [
|
||||
SchemeSlot::Dk1,
|
||||
SchemeSlot::Accent1,
|
||||
SchemeSlot::Accent2,
|
||||
SchemeSlot::Accent3,
|
||||
SchemeSlot::Accent4,
|
||||
SchemeSlot::Accent5,
|
||||
SchemeSlot::Accent6,
|
||||
];
|
||||
let slot = slots.get(index % 7).copied().unwrap_or(SchemeSlot::Accent1);
|
||||
let row = index / 7;
|
||||
let mut st = ShapeStyle::accent(slot);
|
||||
let c = ColorRef::scheme(slot);
|
||||
match row {
|
||||
0 => {
|
||||
// Colored outline, light fill.
|
||||
st.fill_ref = (1, ColorRef::scheme(SchemeSlot::Lt1));
|
||||
st.line_ref = (2, c);
|
||||
st.font_ref.1 = Some(ColorRef::scheme(SchemeSlot::Dk1));
|
||||
(st, None, None)
|
||||
}
|
||||
1 => (st, None, None),
|
||||
2 => {
|
||||
st.fill_ref = (1, c.clone().with(ColorTransform::LumMod(20000)).with(ColorTransform::LumOff(80000)));
|
||||
st.line_ref = (0, c.clone());
|
||||
st.font_ref.1 = Some(c.with(ColorTransform::LumMod(50000)));
|
||||
(st, None, Some(Line::none()))
|
||||
}
|
||||
3 => {
|
||||
st.fill_ref = (2, c);
|
||||
(st, None, Some(Line::none()))
|
||||
}
|
||||
4 => {
|
||||
st.fill_ref = (3, c.clone());
|
||||
st.effect_ref = (3, c);
|
||||
(st, None, Some(Line::none()))
|
||||
}
|
||||
_ => {
|
||||
st.fill_ref = (1, c.clone().with(ColorTransform::LumMod(75000)));
|
||||
st.line_ref = (1, ColorRef::scheme(SchemeSlot::Lt1));
|
||||
(st, None, None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn quick_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = usize_param(p, "index").unwrap_or(1).min(41);
|
||||
let (st, f, l) = quick_style_for(i);
|
||||
edit_shapes(s, p, "shape.quickStyle", |sh| {
|
||||
sh.style = Some(st.clone());
|
||||
sh.fill.clone_from(&f);
|
||||
sh.line.clone_from(&l);
|
||||
sh.effects = None;
|
||||
if let Some(t) = sh.text.as_mut() {
|
||||
deckcraft_model::edit::format_all(t, &|r| r.fill = None);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user