mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-03 08:50:02 -06:00
Compare commits
552
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac2fa7bbb7 | ||
|
|
087d330a24 | ||
|
|
5b5810fe1f | ||
|
|
3b7f7ed10b | ||
|
|
5d063c5bc2 | ||
|
|
1cbb027962 | ||
|
|
a57dd81dba | ||
|
|
a4b1682b41 | ||
|
|
2f78615291 | ||
|
|
9632646c5e | ||
|
|
18ae8b2150 | ||
|
|
9a172e2241 | ||
|
|
db2b3cab99 | ||
|
|
665ded2800 | ||
|
|
d40e0eeb47 | ||
|
|
2e790d549c | ||
|
|
74b2cbb5c9 | ||
|
|
d39be3bab8 | ||
|
|
f2debdecff | ||
|
|
48f5a905b1 | ||
|
|
4abb77aa70 | ||
|
|
e855daf279 | ||
|
|
f774f6f5c0 | ||
|
|
5b952b2463 | ||
|
|
81833cfc33 | ||
|
|
48545479b0 | ||
|
|
990b57a1e8 | ||
|
|
0debb7afda | ||
|
|
bc437049fa | ||
|
|
6e8a420d28 | ||
|
|
64ebfda971 | ||
|
|
c99552c958 | ||
|
|
250a9c5164 | ||
|
|
1be606de03 | ||
|
|
685b420104 | ||
|
|
4fcc409c8b | ||
|
|
bd85c72f39 | ||
|
|
8a964609c4 | ||
|
|
4a12ab9a19 | ||
|
|
41d5284fa8 | ||
|
|
931324e049 | ||
|
|
983f1e0267 | ||
|
|
39ae5f6778 | ||
|
|
5714408dd8 | ||
|
|
964a7bc5a8 | ||
|
|
3508cac3b0 | ||
|
|
8148a239e6 | ||
|
|
acccf913ab | ||
|
|
dc48046057 | ||
|
|
22e99fc2b7 | ||
|
|
f658c872a2 | ||
|
|
a066fe5f13 | ||
|
|
ccb8ce8494 | ||
|
|
165d5725dc | ||
|
|
01a1bf3921 | ||
|
|
5a9e414d1e | ||
|
|
aadb077e07 | ||
|
|
9724f15c3d | ||
|
|
3de6ff95af | ||
|
|
65bb8b3709 | ||
|
|
c4fafc2154 | ||
|
|
df5a59275d | ||
|
|
ab91e9f96c | ||
|
|
1c8f6e9257 | ||
|
|
bc05149bd9 | ||
|
|
51c70e1606 | ||
|
|
ac2cc0c4ef | ||
|
|
9c0f61356c | ||
|
|
260a629e9e | ||
|
|
1ecb22c67d | ||
|
|
36f6244cd4 | ||
|
|
ffaeda9316 | ||
|
|
815d3f0032 | ||
|
|
937c7e532d | ||
|
|
7976368de7 | ||
|
|
0436139c57 | ||
|
|
a2435388e5 | ||
|
|
75d30d69b4 | ||
|
|
1d3e6d9ca8 | ||
|
|
d522bfd8b1 | ||
|
|
0bd3420553 | ||
|
|
54e1b1e7f3 | ||
|
|
4b8b06fe03 | ||
|
|
de3bfaf049 | ||
|
|
f8e327b068 | ||
|
|
50a8ebe307 | ||
|
|
f270dbd3b0 | ||
|
|
ffa35d62db | ||
|
|
436d444ef0 | ||
|
|
ffa127c615 | ||
|
|
8cdc172171 | ||
|
|
d603983a61 | ||
|
|
72b8818ea6 | ||
|
|
3031870ce3 | ||
|
|
1b0a59d4d2 | ||
|
|
8edd6cb8eb | ||
|
|
89a059fb2d | ||
|
|
1b485e1f90 | ||
|
|
751eb4b204 | ||
|
|
5225569d44 | ||
|
|
f2fe7654e9 | ||
|
|
7757828295 | ||
|
|
04ebc4ed42 | ||
|
|
cd683a9fd1 | ||
|
|
932a2e8955 | ||
|
|
2cf1d6c7ae | ||
|
|
f54ea731cc | ||
|
|
0404b4199a | ||
|
|
95ba3d07b4 | ||
|
|
835704f870 | ||
|
|
39336fe844 | ||
|
|
5a311f4615 | ||
|
|
8f1f3c6132 | ||
|
|
e027a4244b | ||
|
|
3c4d7adc87 | ||
|
|
cfa90220d9 | ||
|
|
8f10bd1b9f | ||
|
|
a82063e50a | ||
|
|
342de16153 | ||
|
|
d66f34b774 | ||
|
|
0a6c7605f6 | ||
|
|
fbc95e1e10 | ||
|
|
fac4b550c1 | ||
|
|
cc728757f5 | ||
|
|
3d458a08b6 | ||
|
|
dabc31eb83 | ||
|
|
03f5df4bba | ||
|
|
b8ddca30a1 | ||
|
|
80d8a9952a | ||
|
|
125fdc65ef | ||
|
|
b1a94d4d4b | ||
|
|
0a4f5b8e84 | ||
|
|
b8d3ec94b9 | ||
|
|
7f3912c1df | ||
|
|
e1191cbb13 | ||
|
|
8348af42bd | ||
|
|
440d314f9c | ||
|
|
928e32fc12 | ||
|
|
3bc47b4cc0 | ||
|
|
2e61c73cb7 | ||
|
|
75bc3eb906 | ||
|
|
1bf2735604 | ||
|
|
59cec9d8bc | ||
|
|
a209d1b268 | ||
|
|
f0c66c81df | ||
|
|
0ad6f34c49 | ||
|
|
6c21661c03 | ||
|
|
a2a0d69193 | ||
|
|
5113be5569 | ||
|
|
5a84dd2f4f | ||
|
|
4f74183576 | ||
|
|
15f50c96df | ||
|
|
1344b5204e | ||
|
|
17f3a4f919 | ||
|
|
ffc631861e | ||
|
|
188f379b85 | ||
|
|
afc3b8ad4d | ||
|
|
6d26a751f0 | ||
|
|
095e1941e3 | ||
|
|
13e8dacda7 | ||
|
|
531d474781 | ||
|
|
f01cecf25b | ||
|
|
21cddda18d | ||
|
|
10060a7b55 | ||
|
|
da59acabcb | ||
|
|
741e6c7986 | ||
|
|
f2ae6a9c36 | ||
|
|
f5c48c0fa2 | ||
|
|
bbfcf81b8d | ||
|
|
695a1b11f4 | ||
|
|
811e5aca3b | ||
|
|
19007c8e1a | ||
|
|
a2389baed5 | ||
|
|
b3b7fc6724 | ||
|
|
15417121db | ||
|
|
81df9013bf | ||
|
|
aa05b92df4 | ||
|
|
d1d17835e6 | ||
|
|
fc401225b0 | ||
|
|
253c21b33c | ||
|
|
d3d7152b45 | ||
|
|
bc8e6772d5 | ||
|
|
f7c246fb61 | ||
|
|
c507c7650f | ||
|
|
fe73d141fe | ||
|
|
fb09da5dc4 | ||
|
|
0036a9801f | ||
|
|
47983cedf0 | ||
|
|
809007e530 | ||
|
|
13f42b0a9d | ||
|
|
f7c3f3d564 | ||
|
|
5b6c461448 | ||
|
|
c5b1efb97d | ||
|
|
aedf03549f | ||
|
|
eae2919ea3 | ||
|
|
a88027f1b2 | ||
|
|
3b84c637ea | ||
|
|
6c22d68ce3 | ||
|
|
1d06547f06 | ||
|
|
e911006f2d | ||
|
|
fbf8340132 | ||
|
|
ffb21794c5 | ||
|
|
6827876f8e | ||
|
|
6990384d9d | ||
|
|
f8cacf71ba | ||
|
|
f2f25f9363 | ||
|
|
6e63f6cb28 | ||
|
|
1da89b36a4 | ||
|
|
c4fac489c8 | ||
|
|
a2d1ec38b7 | ||
|
|
dde8896fba | ||
|
|
e4ee834478 | ||
|
|
1b4c340879 | ||
|
|
b9b8440312 | ||
|
|
7bf497b392 | ||
|
|
ebce2b4d49 | ||
|
|
a1d7b85a27 | ||
|
|
8f32774c93 | ||
|
|
b698872665 | ||
|
|
9ee1062dd5 | ||
|
|
c94f586da2 | ||
|
|
e9c7dd609d | ||
|
|
fc675615ba | ||
|
|
c62e1086d7 | ||
|
|
49c1b35fec | ||
|
|
509f161170 | ||
|
|
e8692c4aff | ||
|
|
0cc8533624 | ||
|
|
3a3d4f60e2 | ||
|
|
3ec3e94546 | ||
|
|
78ff598282 | ||
|
|
f9a5dbb670 | ||
|
|
2bc90d50bf | ||
|
|
d23b025974 | ||
|
|
4158cbbc1f | ||
|
|
1547b72a92 | ||
|
|
7466008646 | ||
|
|
07a6cac5bd | ||
|
|
5dd48bc7a0 | ||
|
|
33350b3833 | ||
|
|
02120c84f7 | ||
|
|
a782b534f4 | ||
|
|
05275a5020 | ||
|
|
d859c673c1 | ||
|
|
6a2b520fa4 | ||
|
|
49c2f61373 | ||
|
|
2c99a42780 | ||
|
|
ab0eccd254 | ||
|
|
0377edabd8 | ||
|
|
438775cea1 | ||
|
|
b94821ea16 | ||
|
|
e71c9ce31d | ||
|
|
bbf07875d8 | ||
|
|
9752ca5897 | ||
|
|
e8282d957a | ||
|
|
f93c0cbe52 | ||
|
|
73af86b1bf | ||
|
|
503a533e65 | ||
|
|
27068500dd | ||
|
|
16f6145cd3 | ||
|
|
7d512a6905 | ||
|
|
54ec19ddf8 | ||
|
|
d805dd94f1 | ||
|
|
6dca584bbd | ||
|
|
b9c074a543 | ||
|
|
e900513104 | ||
|
|
ecdc90cdc4 | ||
|
|
15090b9d81 | ||
|
|
24986cdb23 | ||
|
|
5632755be4 | ||
|
|
f93fe6053f | ||
|
|
6a1e0d0002 | ||
|
|
645cbe52d4 | ||
|
|
4576b8e624 | ||
|
|
5e7790ea85 | ||
|
|
8634995c20 | ||
|
|
a44e3ec8f9 | ||
|
|
4029a4e6fc | ||
|
|
e9dd252096 | ||
|
|
8bc825825f | ||
|
|
282ebedc85 | ||
|
|
a95a570edc | ||
|
|
45c4197bc1 | ||
|
|
3c25061bab | ||
|
|
328a58dd03 | ||
|
|
7583871d45 | ||
|
|
ed1296a1d4 | ||
|
|
e216e7afc1 | ||
|
|
b654049545 | ||
|
|
ddda3c4670 | ||
|
|
b094b29958 | ||
|
|
4eda77a2de | ||
|
|
adf33b6ba7 | ||
|
|
66601038df | ||
|
|
4fd9075e99 | ||
|
|
4bb3679e41 | ||
|
|
ee7e98eafb | ||
|
|
ba27ef13fb | ||
|
|
c0627f2b0b | ||
|
|
ebf27211cf | ||
|
|
ced8576f4c | ||
|
|
7740ea650e | ||
|
|
8450531b0e | ||
|
|
25a087db43 | ||
|
|
0ecfbb57f9 | ||
|
|
6689d186f6 | ||
|
|
def2b9bd31 | ||
|
|
bec112a4b8 | ||
|
|
26187afac2 | ||
|
|
72cbbe096e | ||
|
|
722a6ab1c0 | ||
|
|
f235208630 | ||
|
|
3a1c91f2d4 | ||
|
|
acfc3d5041 | ||
|
|
01a75b99b1 | ||
|
|
364688a8c3 | ||
|
|
5819b1ca62 | ||
|
|
c14e18cfa8 | ||
|
|
eb2f0dab46 | ||
|
|
6dc062d132 | ||
|
|
a1f8be2a49 | ||
|
|
60a64acec3 | ||
|
|
722afa4201 | ||
|
|
1955793aa6 | ||
|
|
639964b0ed | ||
|
|
4c5930dac1 | ||
|
|
12afc75411 | ||
|
|
2337ff75fa | ||
|
|
2d9bcb6b1b | ||
|
|
b059da2d4e | ||
|
|
57e9e0f244 | ||
|
|
87d3ea5363 | ||
|
|
68d45318bb | ||
|
|
d69c386f8f | ||
|
|
e541a6f62f | ||
|
|
bbe1dad1e7 | ||
|
|
0f1a127e63 | ||
|
|
c7148d6ba9 | ||
|
|
bcce02ab36 | ||
|
|
b246ea166a | ||
|
|
ce7e4ea28e | ||
|
|
6c6326486f | ||
|
|
5155d2c18c | ||
|
|
3ee1978910 | ||
|
|
3cffe22c00 | ||
|
|
1ae38ec2be | ||
|
|
0f9b926497 | ||
|
|
32e4900bc5 | ||
|
|
ed78539488 | ||
|
|
b578be0459 | ||
|
|
96579e6b4e | ||
|
|
8efba59b35 | ||
|
|
4963e30eed | ||
|
|
22df7caf4d | ||
|
|
124243b1be | ||
|
|
2c4f4669c6 | ||
|
|
41de47398a | ||
|
|
582f411876 | ||
|
|
d8501a5aeb | ||
|
|
9bee82220e | ||
|
|
79e3610cbb | ||
|
|
faa8221bca | ||
|
|
e9fe3d29b6 | ||
|
|
f6398e29a9 | ||
|
|
42c254c87a | ||
|
|
89a64d1ef5 | ||
|
|
d81b43b336 | ||
|
|
fd53f15332 | ||
|
|
d41b8b48ca | ||
|
|
00ba3d4198 | ||
|
|
91c030bc9f | ||
|
|
b7403f41ad | ||
|
|
77402c4e7e | ||
|
|
1225f56187 | ||
|
|
f4c9c89c64 | ||
|
|
0aae9d05db | ||
|
|
c98efffc06 | ||
|
|
2a647661fb | ||
|
|
05ee2e0126 | ||
|
|
0d5a149093 | ||
|
|
f82319deb7 | ||
|
|
af9d5c9541 | ||
|
|
d7530233cc | ||
|
|
1fa7383264 | ||
|
|
17e648bde7 | ||
|
|
ddb67eaa45 | ||
|
|
74dbec4107 | ||
|
|
0674573b45 | ||
|
|
ee96c4c99d | ||
|
|
342855315e | ||
|
|
5a07c46534 | ||
|
|
897add99a8 | ||
|
|
629711582f | ||
|
|
4d027de402 | ||
|
|
7d31c01f6f | ||
|
|
6c20b3e609 | ||
|
|
c1eb02f23c | ||
|
|
7f203bc1c8 | ||
|
|
6bcd997be8 | ||
|
|
5a5aa591cd | ||
|
|
6a66004a06 | ||
|
|
5cd68ee632 | ||
|
|
c187515ba1 | ||
|
|
2cff091c1a | ||
|
|
e4d2dafa9d | ||
|
|
45556583a5 | ||
|
|
d785c05c53 | ||
|
|
bf2a379ec6 | ||
|
|
e251c960d9 | ||
|
|
65f3c3418f | ||
|
|
7ee101a5b9 | ||
|
|
a9cc06dc0f | ||
|
|
d8d75e4473 | ||
|
|
ab26f22c4a | ||
|
|
020194ebdb | ||
|
|
90efee7636 | ||
|
|
58898d5168 | ||
|
|
17d19b42c5 | ||
|
|
bd3d260837 | ||
|
|
3aadd0a4af | ||
|
|
dd6585f166 | ||
|
|
bbeb7e2253 | ||
|
|
a58a7618be | ||
|
|
b2f1197f84 | ||
|
|
0ecdf2ff2d | ||
|
|
878bac84ba | ||
|
|
36e43fa672 | ||
|
|
dc3a999081 | ||
|
|
e73ce9c274 | ||
|
|
6d3370635d | ||
|
|
b729cf0cb4 | ||
|
|
68407a34cd | ||
|
|
a1aa17fc14 | ||
|
|
5ee71b4862 | ||
|
|
a029d8aed1 | ||
|
|
ee03feb81d | ||
|
|
e5cffe7641 | ||
|
|
c1484abe80 | ||
|
|
49600e0344 | ||
|
|
7ef2633a7e | ||
|
|
57ba4b9ed8 | ||
|
|
62736d93df | ||
|
|
f01a107a40 | ||
|
|
dd2e2c8605 | ||
|
|
e3d93581b7 | ||
|
|
e6954843bc | ||
|
|
4148fb1171 | ||
|
|
67ffe00b32 | ||
|
|
a91e1b5ea4 | ||
|
|
b39c1ba1a3 | ||
|
|
04121fb020 | ||
|
|
5dd5e8b31b | ||
|
|
4095cc3413 | ||
|
|
6b97197f69 | ||
|
|
ea541fe66d | ||
|
|
fe9f6dbcc9 | ||
|
|
ea5780d2ab | ||
|
|
42477bd806 | ||
|
|
49731f3faa | ||
|
|
1b2f7a8c09 | ||
|
|
40a7fe6be2 | ||
|
|
1489161611 | ||
|
|
bf70f48bbd | ||
|
|
d5f23907dc | ||
|
|
f7c49cb0f8 | ||
|
|
0cb2af4f4b | ||
|
|
50a1c34647 | ||
|
|
b638caa2d2 | ||
|
|
85db9cfef5 | ||
|
|
d1db090a7c | ||
|
|
4bf785c4cf | ||
|
|
a5526724aa | ||
|
|
0696c55698 | ||
|
|
b37a732455 | ||
|
|
b0b7e6b5ea | ||
|
|
1b0faddfa8 | ||
|
|
0d2d1776dd | ||
|
|
14f0eec255 | ||
|
|
5b46f9ba4d | ||
|
|
7563ebb314 | ||
|
|
8c366eb176 | ||
|
|
77200f9c13 | ||
|
|
9003e2b0af | ||
|
|
84c2d37a49 | ||
|
|
66e5c06b25 | ||
|
|
f490f9da9a | ||
|
|
52d81d02d3 | ||
|
|
f199efde84 | ||
|
|
5d428e408f | ||
|
|
f3961d74d9 | ||
|
|
823ce76e39 | ||
|
|
944c67ba89 | ||
|
|
500f9cdfea | ||
|
|
65a0dc8dc6 | ||
|
|
0b007fd47b | ||
|
|
0bdd2a3e04 | ||
|
|
a605fa5ca7 | ||
|
|
9129d713ca | ||
|
|
f501f7cb5f | ||
|
|
4f071a5b88 | ||
|
|
de8a85dc75 | ||
|
|
7f236c3ec5 | ||
|
|
01e32ff863 | ||
|
|
9fca3bae3a | ||
|
|
c4974d9e8d | ||
|
|
401d811fa0 | ||
|
|
f06a1d3ba5 | ||
|
|
6fac5994f7 | ||
|
|
8e0353ae41 | ||
|
|
4c246f082a | ||
|
|
86b44280ac | ||
|
|
3db25e4aa9 | ||
|
|
fd071202e7 | ||
|
|
753c624a7d | ||
|
|
6f7da7345f | ||
|
|
00856d7178 | ||
|
|
3d944e735a | ||
|
|
6766118d05 | ||
|
|
6b23f3b368 | ||
|
|
3a8ff6b6bc | ||
|
|
8a18026a13 | ||
|
|
4f5e596c71 | ||
|
|
c7dfd92ff1 | ||
|
|
66e92e3698 | ||
|
|
6142567b6a | ||
|
|
02fffea7c9 | ||
|
|
8ce3eb58c7 | ||
|
|
3d51cd3db2 | ||
|
|
08da5c5433 | ||
|
|
358614e1ff | ||
|
|
f2c21cd62c | ||
|
|
a6d5820e16 | ||
|
|
338b5d6360 | ||
|
|
6c595dfd16 | ||
|
|
3a6f3fbb17 | ||
|
|
9feaa3d480 | ||
|
|
4ca965d990 | ||
|
|
3963cf3012 | ||
|
|
3d0fcaccbf | ||
|
|
4480fd50d9 | ||
|
|
a8e39fec53 | ||
|
|
faeb272559 | ||
|
|
3693759149 | ||
|
|
a2ca57ca3f | ||
|
|
08028c44c0 | ||
|
|
023cda9554 | ||
|
|
080b05a0fd | ||
|
|
0d06d94867 | ||
|
|
e4804235c5 | ||
|
|
0802788da9 | ||
|
|
af9b00dd4c | ||
|
|
0dd5b56dfe |
@@ -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"
|
||||
@@ -21,6 +21,8 @@ jobs:
|
||||
# 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: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
packages: >-
|
||||
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev libxi-dev
|
||||
libgtk-3-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
|
||||
|
||||
@@ -27,3 +27,5 @@ jobs:
|
||||
freebsd:
|
||||
# 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: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
components: rustfmt, clippy
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ on:
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
@@ -19,11 +19,15 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
release:
|
||||
# Only this job publishes the GitHub release, so only it gets write access.
|
||||
permissions:
|
||||
contents: write
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
|
||||
with:
|
||||
app-name: wordcraft
|
||||
app-title: WordCraft
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
web-craft-fonts: true
|
||||
version: ${{ inputs.version }}
|
||||
secrets:
|
||||
|
||||
@@ -31,3 +31,5 @@ jobs:
|
||||
with:
|
||||
app-name: wordcraft
|
||||
app-dir: WordCraft
|
||||
install-relative-path: Programs/ArtCraft/WordCraft
|
||||
install-scope: perUser
|
||||
|
||||
@@ -50,7 +50,7 @@ People trust WordCraft with their writing; a crash loses their work. **This outr
|
||||
Before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). Commit after every arc of work that builds, with a task id in the message (`M2.1: line spacing dialog`).
|
||||
|
||||
## Typing behaves like Word
|
||||
Typing, Enter, Backspace, Tab, lists, AutoCorrect and AutoFormat follow Word's observed behaviour in [`docs/typing-parity.md`](docs/typing-parity.md), pinned by `crates/engine/src/tests_typing.rs` (keys go through the same commands as the keyboard). Change behaviour, doc and tests together; a typing fix lands with a test there. Compare headlessly (`ui_shot`); don't drive GUIs with keystrokes on the shared machine.
|
||||
Typing, Enter, Backspace, Tab, lists, AutoCorrect and AutoFormat follow Word's observed behaviour in [`docs/typing-parity.md`](docs/typing-parity.md), pinned by `crates/engine/src/tests/typing.rs` (keys go through the same commands as the keyboard). Change behaviour, doc and tests together; a typing fix lands with a test there. Compare headlessly (`ui_shot`); don't drive GUIs with keystrokes on the shared machine.
|
||||
|
||||
## Running and looking at the app
|
||||
- `cargo run --release -p wordcraft -- --sample --control 7981` (sample document + control channel).
|
||||
@@ -82,3 +82,8 @@ Progress docs follow [`craftrules/standards/progress-docs.md`](https://github.co
|
||||
GitHub profiles). Never hand-edit `contributors.json`.
|
||||
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
|
||||
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
|
||||
|
||||
## CI workflows
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
|
||||
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
|
||||
|
||||
@@ -8,6 +8,8 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
|
||||
|
||||
| Asset | Author | Source | Licence | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `egui` dependency: `epaint_default_fonts/fonts/egui-icons.ttf` | John Slegers (emoji-icon-font) | https://github.com/storytold/egui/blob/173cc7375d35dc5fbc5b5d0994fdf787f547c5d1/crates/epaint_default_fonts/fonts/egui-icons.txt; original https://github.com/jslegers/emoji-icon-font | MIT (full copyright and permission text in NOTICE) | Small egui special-icon subset; embedded by default_fonts, retained in FontDefinitions::default() |
|
||||
| `egui` dependency: `epaint_default_fonts/fonts/emoji-icon-font.ttf` | John Slegers | https://github.com/storytold/egui/blob/173cc7375d35dc5fbc5b5d0994fdf787f547c5d1/crates/epaint_default_fonts/fonts/emoji-icon-font-mit-license.txt; original https://github.com/jslegers/emoji-icon-font | MIT (full copyright and permission text in NOTICE) | Parent monochrome icon/emoji face embedded by monochrome_emoji_fonts; separate from the egui-icons subset |
|
||||
| `assets/hyphenation/en-us.pat` | DesignCraft contributors (generated) | trained from the Moby list by DesignCraft's patgen | Public Domain (CC0-1.0) | Our own Liang hyphenation patterns (no TeX patterns) |
|
||||
| `assets/hyphenation/en-us.dic` | Grady Ward (Moby Hyphenator II word list); converted by DesignCraft contributors | https://www.gutenberg.org/ebooks/3204 (file `mhyph.txt`, SHA-256 `eeb30474c86b8af3469035ec1a0913e35905325ca885290db9dfed9881e230ac`), via DesignCraft | Public Domain | ~165k words with break points; drives spelling and hyphenation |
|
||||
| `assets/spelling/en-extra.txt` | WordCraft contributors | original word list written for this repository (common modern words the Moby list lacks); no other dictionary copied | MIT OR Apache-2.0 | ~240 lowercase English base forms supplementing spelling (#520), including British spellings (#40) |
|
||||
|
||||
@@ -13,7 +13,7 @@ People trust WordCraft with their writing; a crash loses their work. **This outr
|
||||
- **Input-derived numbers are hostile** (files, commands, MCP/control params): `get()` instead of `[i]`, slice strings only at char boundaries, checked/saturating arithmetic, clamp sizes, cap allocations, bound recursion.
|
||||
- **Last-resort guard:** `Session::run` turns an escaped panic into an error and restores the document. It's a safety net, not a licence.
|
||||
- **Prove it:** every crash fix lands with a regression test. `crates/engine/src/tests.rs::hostile_params_never_panic` runs every command with junk params — keep it green.
|
||||
- Production crate roots carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`; new crates start with it. Tests may `unwrap` (`clippy.toml`).
|
||||
- Production crate roots carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`; new crates start with it and add `[lints]` with `workspace = true` to their `Cargo.toml`. Tests may `unwrap` (`clippy.toml`).
|
||||
|
||||
## Assets: absolutely no Microsoft (or Adobe/Avid/Autodesk) iconography or images
|
||||
**This rule is non-negotiable.** Breaking it is the worst mistake you can make in this repo.
|
||||
@@ -49,7 +49,7 @@ People trust WordCraft with their writing; a crash loses their work. **This outr
|
||||
Before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). Commit after every arc of work that builds, with a task id in the message (`M2.1: line spacing dialog`).
|
||||
|
||||
## Typing behaves like Word
|
||||
Typing, Enter, Backspace, Tab, lists, AutoCorrect and AutoFormat follow Word's observed behaviour in [`docs/typing-parity.md`](docs/typing-parity.md), pinned by `crates/engine/src/tests_typing.rs` (keys go through the same commands as the keyboard). Change behaviour, doc and tests together; a typing fix lands with a test there. Compare headlessly (`ui_shot`); don't drive GUIs with keystrokes on the shared machine.
|
||||
Typing, Enter, Backspace, Tab, lists, AutoCorrect and AutoFormat follow Word's observed behaviour in [`docs/typing-parity.md`](docs/typing-parity.md), pinned by `crates/engine/src/tests/typing.rs` (keys go through the same commands as the keyboard). Change behaviour, doc and tests together; a typing fix lands with a test there. Compare headlessly (`ui_shot`); don't drive GUIs with keystrokes on the shared machine.
|
||||
|
||||
## Running and looking at the app
|
||||
- `cargo run --release -p wordcraft -- --sample --control 7981` (sample document + control channel).
|
||||
@@ -63,3 +63,8 @@ Typing, Enter, Backspace, Tab, lists, AutoCorrect and AutoFormat follow Word's o
|
||||
|
||||
## Roadmap
|
||||
Progress docs follow [`craftrules/standards/progress-docs.md`](https://github.com/storytold/craftrules/blob/main/standards/progress-docs.md): `ROADMAP.md` (stage, headline numbers, progress log), `docs/target-app-parity.md` (the assessment), `docs/gaps.md` (ranked work list), `docs/roadmap.md` (milestones, Current focus), `docs/architecture.md`, and the parity checklists `docs/file-format-parity.md`, `docs/layout-parity.md`, `docs/typing-parity.md`, `docs/ui-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. When work lands, update the gap, the parity doc and the progress log, and bump each touched doc's status line and revision history.
|
||||
|
||||
## CI workflows
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
|
||||
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
|
||||
|
||||
Generated
+507
-337
File diff suppressed because it is too large
Load Diff
+39
-11
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -52,13 +52,19 @@ harfrust = "0.13"
|
||||
# WOFF / WOFF2 web fonts added by the user (#582): pure Rust (MIT); its brotli and zlib decoders
|
||||
# (brotli-decompressor, flate2) are already in the tree.
|
||||
wuff = "0.2.9"
|
||||
# WOFF2's brotli stream, decoded ahead of wuff so its glyph point counts are checked first (#3703).
|
||||
# The same crate and version wuff already decodes it with (no new code in the build): pure Rust,
|
||||
# `forbid(unsafe_code)` in its default build, no_std-capable (builds for wasm), BSD-3-Clause/MIT.
|
||||
brotli-decompressor = "5"
|
||||
vello_cpu = "0.2"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756", version = "=0.1.0", default-features = false, features = ["write"] }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false, features = ["write"] }
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "04aa1703b32d299e54f78fcde4182f2b393a9116", features = ["egui"] }
|
||||
quick-xml = "0.41"
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
# zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates.
|
||||
miniz_oxide = "0.8"
|
||||
miniz_oxide = "0.9"
|
||||
cfb = "0.15"
|
||||
# Legacy text encodings (GBK/GB18030, Big5, Shift_JIS, EUC-KR, single-byte code pages) for plain
|
||||
# text and CSV recipient lists (#778): encoding_rs ((Apache-2.0 OR MIT) AND BSD-3-Clause) and its
|
||||
@@ -70,25 +76,32 @@ aes = "0.9"
|
||||
sha1 = "0.11"
|
||||
sha2 = "0.11"
|
||||
hmac = "0.13"
|
||||
base64 = "0.22"
|
||||
base64 = "0.23"
|
||||
getrandom = "0.3"
|
||||
zeroize = "1"
|
||||
# Unix open flags (O_NONBLOCK for the engine's checked file reads, #3666); already in the lock as a
|
||||
# dependency of other crates.
|
||||
libc = "0.2"
|
||||
|
||||
# ui
|
||||
egui = "0.36"
|
||||
egui_extras = { version = "0.36", default-features = false }
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
egui_kittest = "0.36"
|
||||
egui = "0.37"
|
||||
egui_extras = { version = "0.37", default-features = false }
|
||||
eframe = { version = "0.37", default-features = false }
|
||||
egui_kittest = "0.37"
|
||||
# A monotonic clock that works on native and in the browser (`performance.now()`).
|
||||
web-time = "1.1"
|
||||
rfd = "0.17"
|
||||
arboard = { version = "3.6", default-features = false, features = ["wayland-data-control"] }
|
||||
arboard = { version = "3.7", default-features = false, features = ["wayland-data-control"] }
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
cognitive_complexity = "deny"
|
||||
wildcard_imports = "deny"
|
||||
dbg_macro = "warn"
|
||||
todo = "warn"
|
||||
too_many_arguments = "allow"
|
||||
too_many_arguments = "deny"
|
||||
type_complexity = "allow"
|
||||
|
||||
# Line tables keep file and line in panics and backtraces; full debug info
|
||||
@@ -110,4 +123,19 @@ debug = 0
|
||||
|
||||
# Required by craft-pdf: the backend rejects writers that failed during unwinding.
|
||||
[patch.crates-io]
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756" }
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
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" }
|
||||
|
||||
@@ -95,14 +95,32 @@ list (author, source, license) is in ATTRIBUTION.md.
|
||||
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.
|
||||
- All UI icons are drawn in code (crates/ui-egui/src/icons.rs) and are original work, MIT OR Apache-2.0.
|
||||
- Application-drawn UI icons (crates/ui-egui/src/icons.rs) are original work, MIT OR Apache-2.0;
|
||||
the bundled egui font icons have the separate notice below.
|
||||
|
||||
Rust dependencies (egui, eframe, vello_cpu, skrifa, harfrust, krilla, quick-xml, zip, image, …)
|
||||
are permissively licensed (MIT/Apache-2.0/BSD/ISC/Zlib) and fetched through crates.io or pinned Git sources.
|
||||
The craft-pdf writer and Krilla 0.8.2 backend are pinned to storytold/craft-libs revision
|
||||
c3487320b2d1de657b860b3c8abb44e1f2b32bd8. The backend retains its MIT OR Apache-2.0 licensing
|
||||
b926b5c03e256c86ca10934f58d03f628810b5b7. The backend retains its MIT OR Apache-2.0 licensing
|
||||
and upstream bundled material; see that revision's licenses/pdf-writer/ and ATTRIBUTION.md.
|
||||
The backend patch prevents finalization during an unwind and rejects a writer after failed
|
||||
cleanup. WordCraft enables only writing; PDF import and its image decoders are not enabled.
|
||||
|
||||
No Microsoft, Adobe, Avid or Autodesk icons, images, templates or artwork are included.
|
||||
|
||||
Bundled egui font icons
|
||||
|
||||
The canonical egui0.37 dependency includes egui-icons.ttf (a small subset of emoji-icon-font)
|
||||
through default_fonts, and emoji-icon-font.ttf through monochrome_emoji_fonts. Both are embedded
|
||||
by the UI font definitions. Their upstream project is https://github.com/jslegers/emoji-icon-font;
|
||||
the exact copyright and permission notice from the pinned egui dependency follows.
|
||||
|
||||
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.
|
||||
|
||||
@@ -227,7 +227,11 @@ closer pagination fidelity, a stability sweep and native printing. Details and e
|
||||
| arm64 (Snapdragon and other ARM PCs) | `wordcraft-<ver>-windows-arm64.msi` | `wordcraft-<ver>-windows-arm64-portable.zip` |
|
||||
| x86 (32-bit) | `wordcraft-<ver>-windows-x86.msi` | `wordcraft-<ver>-windows-x86-portable.zip` |
|
||||
|
||||
Installers and executables are code-signed.
|
||||
The installer is one dual-scope package. By default it installs for your account only,
|
||||
into `%LOCALAPPDATA%\Programs\ArtCraft\WordCraft`, and needs no administrator rights; choosing
|
||||
"Everyone on this computer" on the first page installs into `C:\Program Files\ArtCraft\WordCraft` (`Program Files (x86)` for the 32-bit build)
|
||||
and asks for an administrator. Install only one copy: a copy for another scope is refused, so
|
||||
uninstall first to change who WordCraft is installed for. Installers and executables are code-signed.
|
||||
|
||||
**If the app doesn't open on Windows:** the desktop app initializes only DirectX 12 by default.
|
||||
Letting wgpu also create an OpenGL instance can crash some graphics drivers (AMD's
|
||||
|
||||
+86
-2
File diff suppressed because one or more lines are too long
+26
@@ -0,0 +1,26 @@
|
||||
# Security policy
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Please report security problems privately through GitHub:
|
||||
**[Report a vulnerability](https://github.com/storytold/wordcraft/security/advisories/new)**
|
||||
(Security tab › Advisories › Report a vulnerability).
|
||||
|
||||
Don't open a public issue or pull request for a vulnerability until a fix is released.
|
||||
|
||||
Useful to include:
|
||||
- the WordCraft version or commit, and the platform (macOS, Windows, Linux, BSD, web);
|
||||
- what an attacker can do, and what they need (a crafted document, MCP or control-channel access, a local account…);
|
||||
- steps or a sample file that reproduce it.
|
||||
|
||||
We'll acknowledge the report, keep you updated while we work on a fix, and credit you in the advisory unless you'd rather not be named.
|
||||
|
||||
## Supported versions
|
||||
|
||||
Fixes go into `main` and ship in the next release. Older releases don't get backported fixes.
|
||||
|
||||
## Scope
|
||||
|
||||
In scope: anything that lets a document, file, command, MCP or control-channel request, or network response read or write files it shouldn't, run code, leak data, or crash WordCraft and lose the user's work.
|
||||
|
||||
Crashes from hostile input count, because WordCraft treats every panic as a bug (see [Never crash](AGENTS.md)).
|
||||
+59
-567
@@ -36,7 +36,11 @@ USAGE:
|
||||
{\"file\"?, \"template\"?, \"commands\": [{\"command\": id,
|
||||
\"params\"?: {…}} | id, …], \"save\"?: [OUT, …]}; save
|
||||
formats follow the extension (docx and pdf in one run);
|
||||
relative paths resolve against the script's folder
|
||||
relative paths resolve against the script's folder.
|
||||
file.new and file.open open another document and
|
||||
make it active; later commands and the saves work on
|
||||
the active one; view.switchWindows lists the open
|
||||
documents and with {\"id\": N} makes one active
|
||||
wordcraft-cli commands [--json] list every command
|
||||
wordcraft-cli parity [--markdown] feature-catalog parity
|
||||
wordcraft-cli mcp [--connect HOST:PORT | --author NAME] [--automation-read-root DIR] [--automation-write-root DIR]
|
||||
@@ -387,6 +391,29 @@ fn valid_connect_addr(addr: &str) -> bool {
|
||||
!host.is_empty() && port.parse::<u16>().is_ok()
|
||||
}
|
||||
|
||||
fn load_run_script(rest: &[String], script_path: Option<&Path>) -> Result<Option<script::Script>, Failure> {
|
||||
match arg_value(rest, "--script") {
|
||||
Some(path) if !path.starts_with("--") => {
|
||||
let source = script_path.unwrap_or(Path::new(&path));
|
||||
Ok(Some(script::load(source)?))
|
||||
}
|
||||
_ if rest.iter().any(|arg| arg == "--script") => Err("run: --script requires a value".into()),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_source_paths(rest: &[String], script: Option<&script::Script>) -> (Option<String>, Option<String>) {
|
||||
// A source named on the command line wins over both of the script's.
|
||||
let (mut from_file, mut from_template) = (arg_value(rest, "--file"), arg_value(rest, "--template"));
|
||||
if from_file.is_none() && from_template.is_none() {
|
||||
from_file = script.and_then(|loaded| loaded.file.clone());
|
||||
from_template = script.and_then(|loaded| loaded.template.clone());
|
||||
}
|
||||
(from_file, from_template)
|
||||
}
|
||||
|
||||
/// Run the subcommand named by `args` (the command line after the program name). Files named
|
||||
/// there are opened by their real names, which may not be valid Unicode.
|
||||
fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
||||
let script_path = script_operand(args);
|
||||
let (args, file) = text_args(args)?;
|
||||
@@ -468,24 +495,19 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
||||
let page = parse_page_arg("run", &rest)?;
|
||||
let scale = parse_scale_arg("run", &rest)?;
|
||||
let password = password_from(&rest)?;
|
||||
let script = match arg_value(&rest, "--script") {
|
||||
Some(path) if !path.starts_with("--") => Some(script::load(script_path.as_deref().unwrap_or(Path::new(&path)))?),
|
||||
_ if rest.iter().any(|a| a == "--script") => return Err("run: --script requires a value".into()),
|
||||
_ => None,
|
||||
};
|
||||
// A source named on the command line wins over both of the script's.
|
||||
let (mut from_file, mut from_template) = (arg_value(&rest, "--file"), arg_value(&rest, "--template"));
|
||||
if from_file.is_none() && from_template.is_none() {
|
||||
from_file = script.as_ref().and_then(|c| c.file.clone());
|
||||
from_template = script.as_ref().and_then(|c| c.template.clone());
|
||||
}
|
||||
let script = load_run_script(&rest, script_path.as_deref())?;
|
||||
let (from_file, from_template) = run_source_paths(&rest, script.as_ref());
|
||||
let mut s = match (from_file, from_template) {
|
||||
(Some(f), _) => open(&f, password.as_deref())?,
|
||||
// Both go through `file.new`, which puts a blank document on the region's paper (#3152).
|
||||
// `file.new` opens beside the session's first document, which is then closed: the
|
||||
// run starts with one open document.
|
||||
(None, t) => {
|
||||
let mut s = session(wordcraft_doc::Document::new());
|
||||
let first = s.document_id();
|
||||
let params = t.map_or_else(|| json!({}), |t| json!({"template": t}));
|
||||
s.run("file.new", ¶ms).map_err(|e| e.to_string())?;
|
||||
s.close_document(first).map_err(|e| e.to_string())?;
|
||||
s
|
||||
}
|
||||
};
|
||||
@@ -657,14 +679,14 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
||||
let out = wordcraft_zotero::client::run_command(&opts, command, &mut |call| {
|
||||
let r = bridge.handle(&mut s, &mut host, call);
|
||||
if trace {
|
||||
eprintln!("← {} {}", call.method, clip(&Value::Array(call.args.clone()).to_string()));
|
||||
eprintln!("→ {}", clip(&String::from_utf8_lossy(&wordcraft_zotero::wire::encode_reply(&r))));
|
||||
eprintln!("← {} {}", escape_peer(&call.method), escape_peer(&clip(&Value::Array(call.args.clone()).to_string())));
|
||||
eprintln!("→ {}", escape_peer(&clip(&String::from_utf8_lossy(&wordcraft_zotero::wire::encode_reply(&r)))));
|
||||
}
|
||||
r
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
for a in &host.alerts {
|
||||
eprintln!("Zotero: {a}");
|
||||
eprintln!("Zotero: {}", escape_peer(a));
|
||||
}
|
||||
eprintln!("{} calls, {}", out.calls, if out.completed { "completed" } else { "ended without completing (cancelled?)" });
|
||||
if let Some(o) = arg_value(&rest, "--save") {
|
||||
@@ -687,6 +709,27 @@ fn clip(s: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Text from another program (Zotero, or whatever answered on its port) for one diagnostic
|
||||
/// line: backslashes, control characters (newline, escape, C1…), line separators and bidi
|
||||
/// overrides are shown as escapes (`\\`, `\n`, `\u{1b}`), so it can't end its line, drive the
|
||||
/// terminal or pass for other output.
|
||||
fn escape_peer(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
c if c.is_control() || matches!(c, '\u{061c}' | '\u{200e}' | '\u{200f}' | '\u{2028}'..='\u{202e}' | '\u{2066}'..='\u{2069}') => {
|
||||
out.extend(c.escape_unicode())
|
||||
}
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn parity_markdown(p: &Value) -> String {
|
||||
let mut s = String::from(
|
||||
"# WordCraft feature parity\n\nGenerated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry.\n\n",
|
||||
@@ -725,555 +768,4 @@ fn main() -> ExitCode {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Read;
|
||||
|
||||
fn args(values: &[&str]) -> Vec<String> {
|
||||
values.iter().map(|value| (*value).into()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validators_accept_only_the_documented_options() {
|
||||
assert!(validate_options("info", &args(&["file.docx"]), PASSWORD_OPTIONS).is_ok());
|
||||
assert!(validate_options("render", &args(&["in.docx", "out.png", "--page", "2", "--scale", "1.5"]), RENDER_OPTIONS).is_ok());
|
||||
assert!(validate_options("run", &args(&["--cmd", "text.insert={}", "--cmd", "edit.undo", "--print"]), RUN_OPTIONS).is_ok());
|
||||
assert!(validate_options("commands", &args(&["--json"]), COMMANDS_OPTIONS).is_ok());
|
||||
assert!(validate_options("parity", &args(&["--markdown"]), PARITY_OPTIONS).is_ok());
|
||||
assert!(validate_options("mcp", &args(&["--connect", "127.0.0.1:9000"]), MCP_OPTIONS).is_ok());
|
||||
assert!(validate_options("render", &args(&["in.docx", "out.png", "--page"]), RENDER_OPTIONS).is_ok());
|
||||
assert!(
|
||||
validate_options(
|
||||
"zotero",
|
||||
&args(&["refresh", "in.docx", "--cmd", "caret.docEnd", "--save", "out.docx", "--trace", "--port", "23119"]),
|
||||
ZOTERO_OPTIONS
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_option_value_is_never_read_as_a_path() {
|
||||
// `--password`'s value sits between the paths and would be taken for one without
|
||||
// `positionals`, so `render` would write its picture over a file called after the password.
|
||||
assert_eq!(positionals(&args(&["in.docx", "--password", "secret", "out.png"]), RENDER_OPTIONS), vec!["in.docx", "out.png"]);
|
||||
assert_eq!(positionals(&args(&["in.docx", "out.png", "--page", "2"]), RENDER_OPTIONS), vec!["in.docx", "out.png"]);
|
||||
// A bare flag takes no value, so the next argument stays a path.
|
||||
assert_eq!(positionals(&args(&["--print", "in.docx"]), RUN_OPTIONS), vec!["in.docx"]);
|
||||
// `convert` is the one with a value-taking flag between the two paths.
|
||||
assert_eq!(positionals(&args(&["in.docx", "--format", "flatopc", "out.docx"]), CONVERT_OPTIONS), vec!["in.docx", "out.docx"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_password_comes_from_an_option_a_file_or_the_environment() {
|
||||
assert_eq!(password_from(&args(&["f.docx"])), Ok(None));
|
||||
assert_eq!(password_from(&args(&["f.docx", "--password", "s3cret"])), Ok(Some("s3cret".into())));
|
||||
// `--password` wins over the other two sources.
|
||||
assert_eq!(password_from(&args(&["--password", "a", "--password-file", "/nonexistent"])), Ok(Some("a".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_password_file_loses_only_its_trailing_newline() {
|
||||
let path = std::env::temp_dir().join("wordcraft-cli-password-test.txt");
|
||||
std::fs::write(&path, "s3cret\r\n").unwrap();
|
||||
let got = password_from(&args(&["f.docx", "--password-file", &path.to_string_lossy()]));
|
||||
std::fs::remove_file(&path).ok();
|
||||
assert_eq!(got, Ok(Some("s3cret".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_password_file_is_named_without_the_password() {
|
||||
let err = password_from(&args(&["f.docx", "--password-file", "/nonexistent/secret.txt"])).unwrap_err();
|
||||
assert!(err.contains("--password-file"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_options_are_rejected_before_file_access() {
|
||||
assert_eq!(run(&args(&["info", "missing.docx", "--passwrod", "x"])), Err("info: unknown option --passwrod".into()));
|
||||
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--sclae", "2"])), Err("render: unknown option --sclae".into()));
|
||||
assert_eq!(run(&args(&["zotero", "refresh", "missing.docx", "--prot", "1"])), Err("zotero: unknown option --prot".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_or_h_after_a_subcommand_asks_for_the_usage() {
|
||||
for cmd in SUBCOMMANDS {
|
||||
assert!(wants_help(cmd, &args(&["in.docx", "--help"])), "{cmd}");
|
||||
assert!(wants_help(cmd, &args(&["-h"])), "{cmd}");
|
||||
assert!(!wants_help(cmd, &args(&["in.docx", "--hepl"])), "{cmd}");
|
||||
}
|
||||
assert!(!wants_help("frobnicate", &args(&["--help"])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_lists_exactly_the_subcommands() {
|
||||
let listed: Vec<&str> = USAGE
|
||||
.lines()
|
||||
.filter_map(|line| line.strip_prefix(" wordcraft-cli ")?.split_whitespace().next())
|
||||
.filter(|word| !word.starts_with(['-', '[']))
|
||||
.collect();
|
||||
assert_eq!(listed, SUBCOMMANDS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_keeps_bullet_and_number_labels() {
|
||||
let doc = wordcraft_engine::io::open_bytes("lists.md", b"- alpha\n- beta\n\n1. first\n2. second\n").unwrap();
|
||||
assert_eq!(listed_text(&doc).unwrap(), "\u{2022}\talpha\n\u{2022}\tbeta\n1.\tfirst\n2.\tsecond");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn author_flag_names_the_headless_session() {
|
||||
assert!(validate_options("run", &args(&["--author", "Claude (copyedit)", "--cmd", "edit.undo"]), RUN_OPTIONS).is_ok());
|
||||
assert!(validate_options("mcp", &args(&["--author", "Claude"]), MCP_OPTIONS).is_ok());
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
assert!(set_author(&mut s, " Claude (copyedit) ").is_ok());
|
||||
assert_eq!(s.author, "Claude (copyedit)");
|
||||
assert!(set_author(&mut s, " ").is_err_and(|e| e.starts_with("--author:")));
|
||||
assert!(run(&args(&["run", "--author", "Claude", "--cmd", "review.trackChanges={\"value\":true}"])).is_ok());
|
||||
// The app's user name is the person's own; a bridge flag must not rewrite it.
|
||||
assert!(run(&args(&["mcp", "--connect", "127.0.0.1:9", "--author", "Claude"])).is_err_and(|e| e.to_string().contains("--author")));
|
||||
// A malformed address fails at startup, not on every retried call.
|
||||
assert!(run(&args(&["mcp", "--connect", "127.0.0.1:notaport"])).is_err_and(|e| e.to_string().contains("numeric port")));
|
||||
assert!(run(&args(&["mcp", "--connect", "notaport"])).is_err_and(|e| e.to_string().contains("numeric port")));
|
||||
// The flag goes through the same protection check as the command.
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
|
||||
let before = s.author.clone();
|
||||
assert!(set_author(&mut s, "Claude").is_err());
|
||||
assert_eq!(s.author, before);
|
||||
assert!(s.run("file.setAuthor", &json!({"name": "Claude"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn author_flag_cannot_claim_a_named_exception_after_protection() {
|
||||
use wordcraft_doc::{CharProps, InlineObject, StoryRef};
|
||||
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Named exception."));
|
||||
let end = s.doc.end_of(StoryRef::Body);
|
||||
s.doc.insert_object(&end, InlineObject::PermEnd { id: 1 }, &CharProps::default()).unwrap();
|
||||
let start = s.doc.start_of(StoryRef::Body);
|
||||
s.doc.insert_object(&start, InlineObject::PermStart { id: 1, group: String::new(), editor: "Alice".into() }, &CharProps::default()).unwrap();
|
||||
assert_eq!(s.doc.perm_ranges().len(), 1);
|
||||
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
|
||||
s.run("select.text", &json!({"text": "Named"})).unwrap();
|
||||
assert!(s.run("text.insert", &json!({"text": "Changed"})).is_err());
|
||||
|
||||
assert!(set_author(&mut s, "Alice").is_err(), "--author changed the identity used for a protected exception");
|
||||
assert!(s.run("text.insert", &json!({"text": "Changed"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automation_root_flags_are_taken_out_of_the_mcp_arguments() {
|
||||
let base = std::env::temp_dir().join(format!("wc-cli-roots-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
std::fs::create_dir_all(base.join("in")).unwrap();
|
||||
std::fs::create_dir_all(base.join("out")).unwrap();
|
||||
let (read, write) = (base.join("in").to_string_lossy().into_owned(), base.join("out").to_string_lossy().into_owned());
|
||||
|
||||
let (roots, left) =
|
||||
automation_roots("mcp", &args(&["--automation-read-root", &read, "--author", "Claude", &format!("--automation-write-root={write}")]))
|
||||
.unwrap();
|
||||
let roots = roots.unwrap();
|
||||
assert_eq!(left, args(&["--author", "Claude"]));
|
||||
assert!(roots.read_root().is_some() && roots.write_root().is_some());
|
||||
assert!(validate_options("mcp", &left, MCP_OPTIONS).is_ok());
|
||||
|
||||
// A root left out grants none of its access.
|
||||
let (roots, _) = automation_roots("mcp", &args(&["--automation-read-root", &read])).unwrap();
|
||||
let roots = roots.unwrap();
|
||||
assert!(roots.write_root().is_none());
|
||||
assert!(
|
||||
roots.check(&format!("{write}/x.docx"), wordcraft_engine::file_access::Access::Write).unwrap_err().contains("write authority is absent")
|
||||
);
|
||||
|
||||
// No flags: nothing is confined and the arguments pass through.
|
||||
let (roots, left) = automation_roots("mcp", &args(&["--connect", "127.0.0.1:9"])).unwrap();
|
||||
assert!(roots.is_none());
|
||||
assert_eq!(left, args(&["--connect", "127.0.0.1:9"]));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automation_root_flags_reject_bad_operands_and_missing_folders() {
|
||||
for bad in [
|
||||
args(&["--automation-read-root"]),
|
||||
args(&["--automation-read-root", "--author"]),
|
||||
args(&["--automation-write-root="]),
|
||||
args(&["--automation-read-root", "a", "--automation-read-root=b"]),
|
||||
] {
|
||||
assert!(automation_roots("mcp", &bad).is_err(), "{bad:?}");
|
||||
}
|
||||
let e = automation_roots("mcp", &args(&["--automation-read-root", "/definitely/not/a/folder"])).err().unwrap();
|
||||
assert!(e.starts_with("mcp: cannot open the automation read root"), "{e}");
|
||||
// Through the command line: an error, not a server that quietly runs unconfined.
|
||||
assert!(run(&args(&["mcp", "--automation-write-root", "/definitely/not/a/folder"])).is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn only_the_opened_file_may_be_other_than_unicode() {
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
let latin1 = || OsString::from_vec(b"caf\xe9.docx".to_vec());
|
||||
let ok = |raw: Vec<OsString>| text_args(&raw).unwrap().1;
|
||||
assert_eq!(ok(vec!["zotero".into(), "refresh".into(), latin1()]), Some(PathBuf::from(latin1())));
|
||||
assert_eq!(ok(vec!["render".into(), latin1(), "out.png".into(), "--page".into(), "2".into()]), Some(PathBuf::from(latin1())));
|
||||
assert_eq!(ok(vec!["run".into(), "--print".into(), "--file".into(), latin1()]), Some(PathBuf::from(latin1())));
|
||||
assert_eq!(ok(vec!["info".into(), "in.docx".into()]), None);
|
||||
// Every command that opens a file reaches the open with it (no such file here), so
|
||||
// `OPENS_FILE` and the commands' own arguments agree.
|
||||
let missing = || OsString::from_vec(b"/definitely/not/caf\xe9.docx".to_vec());
|
||||
let mut opening: Vec<Vec<OsString>> = OPENS_FILE
|
||||
.iter()
|
||||
.map(|(cmd, n)| [vec![(*cmd).into()], vec![OsString::from("refresh"); *n], vec![missing(), "out.png".into()]].concat())
|
||||
.collect();
|
||||
opening.push(vec!["run".into(), "--file".into(), missing()]);
|
||||
for raw in opening {
|
||||
let e = run(&raw).unwrap_err().to_string();
|
||||
assert!(e.contains("No such file"), "{raw:?}: {e}");
|
||||
}
|
||||
for (raw, n) in [
|
||||
(vec!["zotero".into(), latin1(), "in.docx".into()], 2),
|
||||
(vec!["render".into(), "in.docx".into(), latin1()], 3),
|
||||
(vec!["run".into(), "--save".into(), latin1()], 3),
|
||||
(vec!["commands".into(), latin1()], 2),
|
||||
] {
|
||||
assert_eq!(text_args(&raw).unwrap_err(), format!("argument {n} is not valid Unicode: caf\u{FFFD}.docx"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automation_roots_are_an_error_with_connect() {
|
||||
let dir = std::env::temp_dir().to_string_lossy().into_owned();
|
||||
let e = run(&args(&["mcp", "--connect", "127.0.0.1:9", "--automation-read-root", &dir])).unwrap_err().to_string();
|
||||
assert!(e.contains("--automation-read-root") && e.contains("headless"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_rejects_missing_or_invalid_page_and_scale() {
|
||||
// Missing operands
|
||||
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--page"])), Err("render: --page requires a value".into()));
|
||||
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--scale"])), Err("render: --scale requires a value".into()));
|
||||
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--page", "--scale", "2"])), Err("render: --page requires a value".into()));
|
||||
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--scale", "--page", "1"])), Err("render: --scale requires a value".into()));
|
||||
|
||||
// Non-numeric operands
|
||||
assert_eq!(
|
||||
run(&args(&["render", "missing.docx", "out.png", "--page", "banana"])),
|
||||
Err("render: invalid --page `banana`: not a valid integer".into())
|
||||
);
|
||||
assert_eq!(
|
||||
run(&args(&["render", "missing.docx", "out.png", "--scale", "banana"])),
|
||||
Err("render: invalid --scale `banana`: not a number".into())
|
||||
);
|
||||
|
||||
// Out-of-range values
|
||||
assert_eq!(
|
||||
run(&args(&["render", "missing.docx", "out.png", "--page", "0"])),
|
||||
Err("render: invalid --page `0`: page numbers start at 1".into())
|
||||
);
|
||||
assert_eq!(
|
||||
run(&args(&["render", "missing.docx", "out.png", "--page", "-1"])),
|
||||
Err("render: invalid --page `-1`: not a valid integer".into())
|
||||
);
|
||||
assert_eq!(
|
||||
run(&args(&["render", "missing.docx", "out.png", "--scale", "0"])),
|
||||
Err("render: invalid --scale `0`: scale must be a positive number".into())
|
||||
);
|
||||
assert_eq!(
|
||||
run(&args(&["render", "missing.docx", "out.png", "--scale", "-2"])),
|
||||
Err("render: invalid --scale `-2`: scale must be a positive number".into())
|
||||
);
|
||||
assert_eq!(
|
||||
run(&args(&["render", "missing.docx", "out.png", "--scale", "NaN"])),
|
||||
Err("render: invalid --scale `NaN`: scale must be a positive number".into())
|
||||
);
|
||||
assert_eq!(
|
||||
run(&args(&["render", "missing.docx", "out.png", "--scale", "inf"])),
|
||||
Err("render: invalid --scale `inf`: scale must be a positive number".into())
|
||||
);
|
||||
|
||||
// Duplicate options
|
||||
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--page", "1", "--page", "2"])), Err("render: duplicate --page option".into()));
|
||||
assert_eq!(
|
||||
run(&args(&["render", "missing.docx", "out.png", "--scale", "1", "--scale", "2"])),
|
||||
Err("render: duplicate --scale option".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_rejects_missing_or_invalid_page_and_scale() {
|
||||
// Missing operands
|
||||
assert_eq!(run(&args(&["run", "--template", "sample", "--save", "out.png", "--page"])), Err("run: --page requires a value".into()));
|
||||
assert_eq!(run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale"])), Err("run: --scale requires a value".into()));
|
||||
assert_eq!(
|
||||
run(&args(&["run", "--template", "sample", "--save", "out.png", "--page", "--scale", "2"])),
|
||||
Err("run: --page requires a value".into())
|
||||
);
|
||||
assert_eq!(
|
||||
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "--page", "1"])),
|
||||
Err("run: --scale requires a value".into())
|
||||
);
|
||||
|
||||
// Non-numeric operands
|
||||
assert_eq!(
|
||||
run(&args(&["run", "--template", "sample", "--save", "out.png", "--page", "banana"])),
|
||||
Err("run: invalid --page `banana`: not a valid integer".into())
|
||||
);
|
||||
assert_eq!(
|
||||
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "banana"])),
|
||||
Err("run: invalid --scale `banana`: not a number".into())
|
||||
);
|
||||
|
||||
// Out-of-range values
|
||||
assert_eq!(
|
||||
run(&args(&["run", "--template", "sample", "--save", "out.png", "--page", "0"])),
|
||||
Err("run: invalid --page `0`: page numbers start at 1".into())
|
||||
);
|
||||
assert_eq!(
|
||||
run(&args(&["run", "--template", "sample", "--save", "out.png", "--page", "-1"])),
|
||||
Err("run: invalid --page `-1`: not a valid integer".into())
|
||||
);
|
||||
assert_eq!(
|
||||
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "0"])),
|
||||
Err("run: invalid --scale `0`: scale must be a positive number".into())
|
||||
);
|
||||
assert_eq!(
|
||||
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "-1"])),
|
||||
Err("run: invalid --scale `-1`: scale must be a positive number".into())
|
||||
);
|
||||
assert_eq!(
|
||||
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "NaN"])),
|
||||
Err("run: invalid --scale `NaN`: scale must be a positive number".into())
|
||||
);
|
||||
assert_eq!(
|
||||
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "inf"])),
|
||||
Err("run: invalid --scale `inf`: scale must be a positive number".into())
|
||||
);
|
||||
|
||||
// Duplicate options
|
||||
assert_eq!(
|
||||
run(&args(&["run", "--template", "sample", "--save", "out.png", "--page", "1", "--page", "2"])),
|
||||
Err("run: duplicate --page option".into())
|
||||
);
|
||||
assert_eq!(
|
||||
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "1", "--scale", "2"])),
|
||||
Err("run: duplicate --scale option".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_and_run_valid_options_and_defaults_produce_files() {
|
||||
let dir = std::env::temp_dir().join(format!("wc-cli-render-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
let docx_path = dir.join("sample.docx");
|
||||
// Create sample docx via `run --save sample.docx`
|
||||
assert!(run(&args(&["run", "--template", "sample", "--save", &docx_path.to_string_lossy()])).is_ok());
|
||||
assert!(docx_path.exists());
|
||||
|
||||
let docx_str = docx_path.to_string_lossy().into_owned();
|
||||
|
||||
// 1. render with defaults
|
||||
let out_default = dir.join("out_default.png");
|
||||
assert!(run(&args(&["render", &docx_str, &out_default.to_string_lossy()])).is_ok());
|
||||
assert!(out_default.exists());
|
||||
|
||||
// 2. render with valid --page and --scale
|
||||
let out_valid = dir.join("out_valid.png");
|
||||
assert!(run(&args(&["render", &docx_str, &out_valid.to_string_lossy(), "--page", "1", "--scale", "2"])).is_ok());
|
||||
assert!(out_valid.exists());
|
||||
|
||||
// 3. render with different flag order
|
||||
let out_reordered = dir.join("out_reordered.png");
|
||||
assert!(run(&args(&["render", &docx_str, &out_reordered.to_string_lossy(), "--scale", "1", "--page", "1"])).is_ok());
|
||||
assert!(out_reordered.exists());
|
||||
|
||||
// 4. render failure leaves no output file
|
||||
let out_invalid = dir.join("out_invalid.png");
|
||||
assert!(run(&args(&["render", &docx_str, &out_invalid.to_string_lossy(), "--page", "banana"])).is_err());
|
||||
assert!(!out_invalid.exists());
|
||||
|
||||
// 5. run --save out.png with defaults
|
||||
let run_default = dir.join("run_default.png");
|
||||
assert!(run(&args(&["run", "--file", &docx_str, "--save", &run_default.to_string_lossy()])).is_ok());
|
||||
assert!(run_default.exists());
|
||||
|
||||
// 6. run --save out.png with --page and --scale
|
||||
let run_valid = dir.join("run_valid.png");
|
||||
assert!(run(&args(&["run", "--file", &docx_str, "--save", &run_valid.to_string_lossy(), "--page", "1", "--scale", "1"])).is_ok());
|
||||
assert!(run_valid.exists());
|
||||
|
||||
// 7. run --save out.png with different flag order
|
||||
let run_reordered = dir.join("run_reordered.png");
|
||||
assert!(run(&args(&["run", "--file", &docx_str, "--scale", "1", "--page", "1", "--save", &run_reordered.to_string_lossy()])).is_ok());
|
||||
assert!(run_reordered.exists());
|
||||
|
||||
// 8. run --save out.png failure leaves no output file
|
||||
let run_invalid = dir.join("run_invalid.png");
|
||||
assert!(run(&args(&["run", "--file", &docx_str, "--save", &run_invalid.to_string_lossy(), "--scale", "banana"])).is_err());
|
||||
assert!(!run_invalid.exists());
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
/// A scratch folder that removes itself, even when the test fails.
|
||||
struct ScriptDir(PathBuf);
|
||||
|
||||
impl std::ops::Deref for ScriptDir {
|
||||
type Target = Path;
|
||||
fn deref(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ScriptDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn script_dir(tag: &str) -> ScriptDir {
|
||||
let dir = std::env::temp_dir().join(format!("wc-cli-script-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
ScriptDir(dir)
|
||||
}
|
||||
|
||||
fn write_script(dir: &Path, text: &str) -> String {
|
||||
let p = dir.join("job.json");
|
||||
std::fs::write(&p, text).unwrap();
|
||||
p.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_builds_a_document_and_saves_docx_and_pdf_relative_to_the_script() {
|
||||
let dir = script_dir("two");
|
||||
let script = write_script(
|
||||
&dir,
|
||||
r#"{"template":"blank","commands":[{"command":"text.insert","params":{"text":"Hello script"}},{"command":"format.bold"}],"save":["out.docx","out.pdf"]}"#,
|
||||
);
|
||||
run(&args(&["run", "--script", &script])).unwrap();
|
||||
assert!(std::fs::read(dir.join("out.pdf")).unwrap().starts_with(b"%PDF"));
|
||||
let doc = open(&dir.join("out.docx"), None).unwrap().doc;
|
||||
let json = serde_json::to_string(&doc).unwrap();
|
||||
assert!(json.contains("Hello script") && json.contains("\"bold\":true"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_with_a_bad_output_writes_no_output() {
|
||||
let dir = script_dir("badout");
|
||||
let script = write_script(&dir, r#"{"commands":["caret.docEnd"],"save":["a.docx","b.xyz"]}"#);
|
||||
let e = run(&args(&["run", "--script", &script])).unwrap_err().to_string();
|
||||
assert_eq!(e.matches("b.xyz").count(), 1, "{e}");
|
||||
assert!(!dir.join("a.docx").exists());
|
||||
let script = write_script(&dir, r#"{"commands":["caret.docEnd"],"save":["a.docx","nope/b.docx"]}"#);
|
||||
assert!(run(&args(&["run", "--script", &script])).is_err());
|
||||
assert!(!dir.join("a.docx").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_with_a_folder_as_output_writes_no_output() {
|
||||
let dir = script_dir("folderout");
|
||||
std::fs::create_dir(dir.join("b.pdf")).unwrap();
|
||||
let script = write_script(&dir, r#"{"commands":["caret.docEnd"],"save":["a.docx","b.pdf"]}"#);
|
||||
let e = run(&args(&["run", "--script", &script])).unwrap_err().to_string();
|
||||
assert!(e.contains("b.pdf"), "{e}");
|
||||
assert!(!dir.join("a.docx").exists());
|
||||
}
|
||||
|
||||
/// APFS refuses file names that aren't valid UTF-8, so the fixture can't be made on macOS
|
||||
/// (as in `tests/it/non_utf8_args.rs`).
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
#[test]
|
||||
fn script_file_name_need_not_be_unicode() {
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
let dir = script_dir("latin1");
|
||||
let mut name = dir.as_os_str().to_os_string().into_vec();
|
||||
name.extend_from_slice(b"/caf\xe9.json");
|
||||
let path = PathBuf::from(OsString::from_vec(name));
|
||||
std::fs::write(&path, r#"{"commands":["caret.docEnd"],"save":["o.txt"]}"#).unwrap();
|
||||
let raw = vec![OsString::from("run"), OsString::from("--script"), path.into_os_string()];
|
||||
run(&raw).unwrap();
|
||||
assert!(dir.join("o.txt").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_line_template_wins_over_the_scripts_file() {
|
||||
let dir = script_dir("source");
|
||||
let input = dir.join("in.docx");
|
||||
run(&args(&["run", "--template", "blank", "--cmd", "text.insert={\"text\":\"FROM FILE\"}", "--save", &input.to_string_lossy()])).unwrap();
|
||||
let script = write_script(&dir, r#"{"file":"in.docx","commands":["caret.docEnd"]}"#);
|
||||
let (a, b) = (dir.join("a.txt"), dir.join("b.txt"));
|
||||
run(&args(&["run", "--script", &script, "--template", "blank", "--save", &a.to_string_lossy()])).unwrap();
|
||||
assert!(!std::fs::read_to_string(&a).unwrap().contains("FROM FILE"));
|
||||
run(&args(&["run", "--script", &script, "--save", &b.to_string_lossy()])).unwrap();
|
||||
assert!(std::fs::read_to_string(&b).unwrap().contains("FROM FILE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_value_missing_is_reported() {
|
||||
let e = run(&args(&["run", "--script", "--cmd", "caret.docEnd"])).unwrap_err().to_string();
|
||||
assert!(e.contains("--script requires a value"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_reads_from_a_stream_and_caps_its_size() {
|
||||
let s = script::load_from(&br#"{"commands":["caret.docEnd"],"save":["o.docx"]}"#[..], "-", None).unwrap();
|
||||
assert_eq!((s.commands.len(), s.save), (1, vec!["o.docx".to_string()]));
|
||||
let big = std::io::repeat(b' ').take(script::MAX_SCRIPT_BYTES + 10);
|
||||
let e = script::load_from(big, "-", None).unwrap_err();
|
||||
assert!(e.contains("too large"), "{e}");
|
||||
assert!(script::load_from(&b"{"[..], "-", None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_commands_run_before_cmd_flags() {
|
||||
let dir = script_dir("order");
|
||||
let script = write_script(&dir, r#"{"commands":[{"command":"text.insert","params":{"text":"first"}}]}"#);
|
||||
let out = dir.join("o.txt");
|
||||
run(&args(&["run", "--script", &script, "--cmd", "text.insert={\"text\":\" second\"}", "--save", &out.to_string_lossy()])).unwrap();
|
||||
assert!(std::fs::read_to_string(&out).unwrap().contains("first second"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_failure_names_the_command_and_writes_nothing() {
|
||||
let dir = script_dir("fail");
|
||||
let script =
|
||||
write_script(&dir, r#"{"commands":[{"command":"text.insert","params":{"text":"x"}},{"command":"no.such.command"}],"save":["out.docx"]}"#);
|
||||
let e = run(&args(&["run", "--script", &script])).unwrap_err().to_string();
|
||||
assert!(e.contains("command 2") && e.contains("no.such.command"), "{e}");
|
||||
assert!(!dir.join("out.docx").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_rejects_junk_empty_missing_and_huge_input() {
|
||||
let dir = script_dir("junk");
|
||||
for text in [
|
||||
"not json",
|
||||
"[]",
|
||||
"{}",
|
||||
r#"{"commands":[]}"#,
|
||||
r#"{"commands":[{"params":{}}]}"#,
|
||||
r#"{"commands":[1]}"#,
|
||||
r#"{"commands":[{"command":"a"}],"save":[1]}"#,
|
||||
] {
|
||||
let script = write_script(&dir, text);
|
||||
assert!(run(&args(&["run", "--script", &script])).is_err(), "{text}");
|
||||
}
|
||||
assert!(run(&args(&["run", "--script", &dir.join("missing.json").to_string_lossy()])).is_err());
|
||||
let huge = write_script(&dir, &" ".repeat(5 * 1024 * 1024));
|
||||
let e = run(&args(&["run", "--script", &huge])).unwrap_err().to_string();
|
||||
assert!(e.contains("too large"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_parse_resolves_paths_against_the_base() {
|
||||
let s = script::parse(r#"{"file":"in.docx","commands":[{"command":"edit.undo"}],"save":["/abs/o.pdf","o.docx"]}"#, Some(Path::new("/base")))
|
||||
.unwrap();
|
||||
let joined = |name: &str| Path::new("/base").join(name).to_string_lossy().into_owned();
|
||||
assert_eq!(s.file, Some(joined("in.docx")));
|
||||
assert_eq!(s.save, vec!["/abs/o.pdf".to_string(), joined("o.docx")]);
|
||||
let s = script::parse(r#"{"commands":["caret.docEnd"],"save":["o.docx"]}"#, None).unwrap();
|
||||
assert_eq!(s.save, vec!["o.docx".to_string()]);
|
||||
assert_eq!(s.commands.len(), 1);
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,545 @@
|
||||
use super::*;
|
||||
use std::io::Read;
|
||||
|
||||
fn args(values: &[&str]) -> Vec<String> {
|
||||
values.iter().map(|value| (*value).into()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_text_stays_on_one_visible_line() {
|
||||
let alert = "Done.\nwrote /etc/passwd\r\u{1b}[2J\u{9b}31m\u{202e}txt.exe\\n\tok é";
|
||||
let shown = escape_peer(alert);
|
||||
assert_eq!(shown, "Done.\\nwrote /etc/passwd\\r\\u{1b}[2J\\u{9b}31m\\u{202e}txt.exe\\\\n\\tok é");
|
||||
assert!(!shown.chars().any(|c| c.is_control()));
|
||||
assert_eq!(escape_peer("Citation (Doe, 2020) — “ok”"), "Citation (Doe, 2020) — “ok”");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validators_accept_only_the_documented_options() {
|
||||
assert!(validate_options("info", &args(&["file.docx"]), PASSWORD_OPTIONS).is_ok());
|
||||
assert!(validate_options("render", &args(&["in.docx", "out.png", "--page", "2", "--scale", "1.5"]), RENDER_OPTIONS).is_ok());
|
||||
assert!(validate_options("run", &args(&["--cmd", "text.insert={}", "--cmd", "edit.undo", "--print"]), RUN_OPTIONS).is_ok());
|
||||
assert!(validate_options("commands", &args(&["--json"]), COMMANDS_OPTIONS).is_ok());
|
||||
assert!(validate_options("parity", &args(&["--markdown"]), PARITY_OPTIONS).is_ok());
|
||||
assert!(validate_options("mcp", &args(&["--connect", "127.0.0.1:9000"]), MCP_OPTIONS).is_ok());
|
||||
assert!(validate_options("render", &args(&["in.docx", "out.png", "--page"]), RENDER_OPTIONS).is_ok());
|
||||
assert!(
|
||||
validate_options(
|
||||
"zotero",
|
||||
&args(&["refresh", "in.docx", "--cmd", "caret.docEnd", "--save", "out.docx", "--trace", "--port", "23119"]),
|
||||
ZOTERO_OPTIONS
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_option_value_is_never_read_as_a_path() {
|
||||
// `--password`'s value sits between the paths and would be taken for one without
|
||||
// `positionals`, so `render` would write its picture over a file called after the password.
|
||||
assert_eq!(positionals(&args(&["in.docx", "--password", "secret", "out.png"]), RENDER_OPTIONS), vec!["in.docx", "out.png"]);
|
||||
assert_eq!(positionals(&args(&["in.docx", "out.png", "--page", "2"]), RENDER_OPTIONS), vec!["in.docx", "out.png"]);
|
||||
// A bare flag takes no value, so the next argument stays a path.
|
||||
assert_eq!(positionals(&args(&["--print", "in.docx"]), RUN_OPTIONS), vec!["in.docx"]);
|
||||
// `convert` is the one with a value-taking flag between the two paths.
|
||||
assert_eq!(positionals(&args(&["in.docx", "--format", "flatopc", "out.docx"]), CONVERT_OPTIONS), vec!["in.docx", "out.docx"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_password_comes_from_an_option_a_file_or_the_environment() {
|
||||
assert_eq!(password_from(&args(&["f.docx"])), Ok(None));
|
||||
assert_eq!(password_from(&args(&["f.docx", "--password", "s3cret"])), Ok(Some("s3cret".into())));
|
||||
// `--password` wins over the other two sources.
|
||||
assert_eq!(password_from(&args(&["--password", "a", "--password-file", "/nonexistent"])), Ok(Some("a".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_password_file_loses_only_its_trailing_newline() {
|
||||
let path = std::env::temp_dir().join("wordcraft-cli-password-test.txt");
|
||||
std::fs::write(&path, "s3cret\r\n").unwrap();
|
||||
let got = password_from(&args(&["f.docx", "--password-file", &path.to_string_lossy()]));
|
||||
std::fs::remove_file(&path).ok();
|
||||
assert_eq!(got, Ok(Some("s3cret".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_password_file_is_named_without_the_password() {
|
||||
let err = password_from(&args(&["f.docx", "--password-file", "/nonexistent/secret.txt"])).unwrap_err();
|
||||
assert!(err.contains("--password-file"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_options_are_rejected_before_file_access() {
|
||||
assert_eq!(run(&args(&["info", "missing.docx", "--passwrod", "x"])), Err("info: unknown option --passwrod".into()));
|
||||
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--sclae", "2"])), Err("render: unknown option --sclae".into()));
|
||||
assert_eq!(run(&args(&["zotero", "refresh", "missing.docx", "--prot", "1"])), Err("zotero: unknown option --prot".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_or_h_after_a_subcommand_asks_for_the_usage() {
|
||||
for cmd in SUBCOMMANDS {
|
||||
assert!(wants_help(cmd, &args(&["in.docx", "--help"])), "{cmd}");
|
||||
assert!(wants_help(cmd, &args(&["-h"])), "{cmd}");
|
||||
assert!(!wants_help(cmd, &args(&["in.docx", "--hepl"])), "{cmd}");
|
||||
}
|
||||
assert!(!wants_help("frobnicate", &args(&["--help"])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_lists_exactly_the_subcommands() {
|
||||
let listed: Vec<&str> = USAGE
|
||||
.lines()
|
||||
.filter_map(|line| line.strip_prefix(" wordcraft-cli ")?.split_whitespace().next())
|
||||
.filter(|word| !word.starts_with(['-', '[']))
|
||||
.collect();
|
||||
assert_eq!(listed, SUBCOMMANDS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_keeps_bullet_and_number_labels() {
|
||||
let doc = wordcraft_engine::io::open_bytes("lists.md", b"- alpha\n- beta\n\n1. first\n2. second\n").unwrap();
|
||||
assert_eq!(listed_text(&doc).unwrap(), "\u{2022}\talpha\n\u{2022}\tbeta\n1.\tfirst\n2.\tsecond");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn author_flag_names_the_headless_session() {
|
||||
assert!(validate_options("run", &args(&["--author", "Claude (copyedit)", "--cmd", "edit.undo"]), RUN_OPTIONS).is_ok());
|
||||
assert!(validate_options("mcp", &args(&["--author", "Claude"]), MCP_OPTIONS).is_ok());
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
assert!(set_author(&mut s, " Claude (copyedit) ").is_ok());
|
||||
assert_eq!(s.author, "Claude (copyedit)");
|
||||
assert!(set_author(&mut s, " ").is_err_and(|e| e.starts_with("--author:")));
|
||||
assert!(run(&args(&["run", "--author", "Claude", "--cmd", "review.trackChanges={\"value\":true}"])).is_ok());
|
||||
// The app's user name is the person's own; a bridge flag must not rewrite it.
|
||||
assert!(run(&args(&["mcp", "--connect", "127.0.0.1:9", "--author", "Claude"])).is_err_and(|e| e.to_string().contains("--author")));
|
||||
// A malformed address fails at startup, not on every retried call.
|
||||
assert!(run(&args(&["mcp", "--connect", "127.0.0.1:notaport"])).is_err_and(|e| e.to_string().contains("numeric port")));
|
||||
assert!(run(&args(&["mcp", "--connect", "notaport"])).is_err_and(|e| e.to_string().contains("numeric port")));
|
||||
// The flag goes through the same protection check as the command.
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
|
||||
let before = s.author.clone();
|
||||
assert!(set_author(&mut s, "Claude").is_err());
|
||||
assert_eq!(s.author, before);
|
||||
assert!(s.run("file.setAuthor", &json!({"name": "Claude"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn author_flag_cannot_claim_a_named_exception_after_protection() {
|
||||
use wordcraft_doc::{CharProps, InlineObject, StoryRef};
|
||||
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Named exception."));
|
||||
let end = s.doc.end_of(StoryRef::Body);
|
||||
s.doc.insert_object(&end, InlineObject::PermEnd { id: 1 }, &CharProps::default()).unwrap();
|
||||
let start = s.doc.start_of(StoryRef::Body);
|
||||
s.doc.insert_object(&start, InlineObject::PermStart { id: 1, group: String::new(), editor: "Alice".into() }, &CharProps::default()).unwrap();
|
||||
assert_eq!(s.doc.perm_ranges().len(), 1);
|
||||
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
|
||||
s.run("select.text", &json!({"text": "Named"})).unwrap();
|
||||
assert!(s.run("text.insert", &json!({"text": "Changed"})).is_err());
|
||||
|
||||
assert!(set_author(&mut s, "Alice").is_err(), "--author changed the identity used for a protected exception");
|
||||
assert!(s.run("text.insert", &json!({"text": "Changed"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automation_root_flags_are_taken_out_of_the_mcp_arguments() {
|
||||
let base = std::env::temp_dir().join(format!("wc-cli-roots-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
std::fs::create_dir_all(base.join("in")).unwrap();
|
||||
std::fs::create_dir_all(base.join("out")).unwrap();
|
||||
let (read, write) = (base.join("in").to_string_lossy().into_owned(), base.join("out").to_string_lossy().into_owned());
|
||||
|
||||
let (roots, left) =
|
||||
automation_roots("mcp", &args(&["--automation-read-root", &read, "--author", "Claude", &format!("--automation-write-root={write}")]))
|
||||
.unwrap();
|
||||
let roots = roots.unwrap();
|
||||
assert_eq!(left, args(&["--author", "Claude"]));
|
||||
assert!(roots.read_root().is_some() && roots.write_root().is_some());
|
||||
assert!(validate_options("mcp", &left, MCP_OPTIONS).is_ok());
|
||||
|
||||
// A root left out grants none of its access.
|
||||
let (roots, _) = automation_roots("mcp", &args(&["--automation-read-root", &read])).unwrap();
|
||||
let roots = roots.unwrap();
|
||||
assert!(roots.write_root().is_none());
|
||||
assert!(roots.check(&format!("{write}/x.docx"), wordcraft_engine::file_access::Access::Write).unwrap_err().contains("write authority is absent"));
|
||||
|
||||
// No flags: nothing is confined and the arguments pass through.
|
||||
let (roots, left) = automation_roots("mcp", &args(&["--connect", "127.0.0.1:9"])).unwrap();
|
||||
assert!(roots.is_none());
|
||||
assert_eq!(left, args(&["--connect", "127.0.0.1:9"]));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automation_root_flags_reject_bad_operands_and_missing_folders() {
|
||||
for bad in [
|
||||
args(&["--automation-read-root"]),
|
||||
args(&["--automation-read-root", "--author"]),
|
||||
args(&["--automation-write-root="]),
|
||||
args(&["--automation-read-root", "a", "--automation-read-root=b"]),
|
||||
] {
|
||||
assert!(automation_roots("mcp", &bad).is_err(), "{bad:?}");
|
||||
}
|
||||
let e = automation_roots("mcp", &args(&["--automation-read-root", "/definitely/not/a/folder"])).err().unwrap();
|
||||
assert!(e.starts_with("mcp: cannot open the automation read root"), "{e}");
|
||||
// Through the command line: an error, not a server that quietly runs unconfined.
|
||||
assert!(run(&args(&["mcp", "--automation-write-root", "/definitely/not/a/folder"])).is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn only_the_opened_file_may_be_other_than_unicode() {
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
let latin1 = || OsString::from_vec(b"caf\xe9.docx".to_vec());
|
||||
let ok = |raw: Vec<OsString>| text_args(&raw).unwrap().1;
|
||||
assert_eq!(ok(vec!["zotero".into(), "refresh".into(), latin1()]), Some(PathBuf::from(latin1())));
|
||||
assert_eq!(ok(vec!["render".into(), latin1(), "out.png".into(), "--page".into(), "2".into()]), Some(PathBuf::from(latin1())));
|
||||
assert_eq!(ok(vec!["run".into(), "--print".into(), "--file".into(), latin1()]), Some(PathBuf::from(latin1())));
|
||||
assert_eq!(ok(vec!["info".into(), "in.docx".into()]), None);
|
||||
// Every command that opens a file reaches the open with it (no such file here), so
|
||||
// `OPENS_FILE` and the commands' own arguments agree.
|
||||
let missing = || OsString::from_vec(b"/definitely/not/caf\xe9.docx".to_vec());
|
||||
let mut opening: Vec<Vec<OsString>> = OPENS_FILE
|
||||
.iter()
|
||||
.map(|(cmd, n)| [vec![(*cmd).into()], vec![OsString::from("refresh"); *n], vec![missing(), "out.png".into()]].concat())
|
||||
.collect();
|
||||
opening.push(vec!["run".into(), "--file".into(), missing()]);
|
||||
for raw in opening {
|
||||
let e = run(&raw).unwrap_err().to_string();
|
||||
assert!(e.contains("No such file"), "{raw:?}: {e}");
|
||||
}
|
||||
for (raw, n) in [
|
||||
(vec!["zotero".into(), latin1(), "in.docx".into()], 2),
|
||||
(vec!["render".into(), "in.docx".into(), latin1()], 3),
|
||||
(vec!["run".into(), "--save".into(), latin1()], 3),
|
||||
(vec!["commands".into(), latin1()], 2),
|
||||
] {
|
||||
assert_eq!(text_args(&raw).unwrap_err(), format!("argument {n} is not valid Unicode: caf\u{FFFD}.docx"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automation_roots_are_an_error_with_connect() {
|
||||
let dir = std::env::temp_dir().to_string_lossy().into_owned();
|
||||
let e = run(&args(&["mcp", "--connect", "127.0.0.1:9", "--automation-read-root", &dir])).unwrap_err().to_string();
|
||||
assert!(e.contains("--automation-read-root") && e.contains("headless"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_rejects_missing_or_invalid_page_and_scale() {
|
||||
// Missing operands
|
||||
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--page"])), Err("render: --page requires a value".into()));
|
||||
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--scale"])), Err("render: --scale requires a value".into()));
|
||||
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--page", "--scale", "2"])), Err("render: --page requires a value".into()));
|
||||
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--scale", "--page", "1"])), Err("render: --scale requires a value".into()));
|
||||
|
||||
// Non-numeric operands
|
||||
assert_eq!(
|
||||
run(&args(&["render", "missing.docx", "out.png", "--page", "banana"])),
|
||||
Err("render: invalid --page `banana`: not a valid integer".into())
|
||||
);
|
||||
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--scale", "banana"])), Err("render: invalid --scale `banana`: not a number".into()));
|
||||
|
||||
// Out-of-range values
|
||||
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--page", "0"])), Err("render: invalid --page `0`: page numbers start at 1".into()));
|
||||
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--page", "-1"])), Err("render: invalid --page `-1`: not a valid integer".into()));
|
||||
assert_eq!(
|
||||
run(&args(&["render", "missing.docx", "out.png", "--scale", "0"])),
|
||||
Err("render: invalid --scale `0`: scale must be a positive number".into())
|
||||
);
|
||||
assert_eq!(
|
||||
run(&args(&["render", "missing.docx", "out.png", "--scale", "-2"])),
|
||||
Err("render: invalid --scale `-2`: scale must be a positive number".into())
|
||||
);
|
||||
assert_eq!(
|
||||
run(&args(&["render", "missing.docx", "out.png", "--scale", "NaN"])),
|
||||
Err("render: invalid --scale `NaN`: scale must be a positive number".into())
|
||||
);
|
||||
assert_eq!(
|
||||
run(&args(&["render", "missing.docx", "out.png", "--scale", "inf"])),
|
||||
Err("render: invalid --scale `inf`: scale must be a positive number".into())
|
||||
);
|
||||
|
||||
// Duplicate options
|
||||
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--page", "1", "--page", "2"])), Err("render: duplicate --page option".into()));
|
||||
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--scale", "1", "--scale", "2"])), Err("render: duplicate --scale option".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_rejects_missing_or_invalid_page_and_scale() {
|
||||
// Missing operands
|
||||
assert_eq!(run(&args(&["run", "--template", "sample", "--save", "out.png", "--page"])), Err("run: --page requires a value".into()));
|
||||
assert_eq!(run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale"])), Err("run: --scale requires a value".into()));
|
||||
assert_eq!(
|
||||
run(&args(&["run", "--template", "sample", "--save", "out.png", "--page", "--scale", "2"])),
|
||||
Err("run: --page requires a value".into())
|
||||
);
|
||||
assert_eq!(
|
||||
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "--page", "1"])),
|
||||
Err("run: --scale requires a value".into())
|
||||
);
|
||||
|
||||
// Non-numeric operands
|
||||
assert_eq!(
|
||||
run(&args(&["run", "--template", "sample", "--save", "out.png", "--page", "banana"])),
|
||||
Err("run: invalid --page `banana`: not a valid integer".into())
|
||||
);
|
||||
assert_eq!(
|
||||
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "banana"])),
|
||||
Err("run: invalid --scale `banana`: not a number".into())
|
||||
);
|
||||
|
||||
// Out-of-range values
|
||||
assert_eq!(
|
||||
run(&args(&["run", "--template", "sample", "--save", "out.png", "--page", "0"])),
|
||||
Err("run: invalid --page `0`: page numbers start at 1".into())
|
||||
);
|
||||
assert_eq!(
|
||||
run(&args(&["run", "--template", "sample", "--save", "out.png", "--page", "-1"])),
|
||||
Err("run: invalid --page `-1`: not a valid integer".into())
|
||||
);
|
||||
assert_eq!(
|
||||
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "0"])),
|
||||
Err("run: invalid --scale `0`: scale must be a positive number".into())
|
||||
);
|
||||
assert_eq!(
|
||||
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "-1"])),
|
||||
Err("run: invalid --scale `-1`: scale must be a positive number".into())
|
||||
);
|
||||
assert_eq!(
|
||||
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "NaN"])),
|
||||
Err("run: invalid --scale `NaN`: scale must be a positive number".into())
|
||||
);
|
||||
assert_eq!(
|
||||
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "inf"])),
|
||||
Err("run: invalid --scale `inf`: scale must be a positive number".into())
|
||||
);
|
||||
|
||||
// Duplicate options
|
||||
assert_eq!(
|
||||
run(&args(&["run", "--template", "sample", "--save", "out.png", "--page", "1", "--page", "2"])),
|
||||
Err("run: duplicate --page option".into())
|
||||
);
|
||||
assert_eq!(
|
||||
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "1", "--scale", "2"])),
|
||||
Err("run: duplicate --scale option".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_and_run_valid_options_and_defaults_produce_files() {
|
||||
let dir = std::env::temp_dir().join(format!("wc-cli-render-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
let docx_path = dir.join("sample.docx");
|
||||
// Create sample docx via `run --save sample.docx`
|
||||
assert!(run(&args(&["run", "--template", "sample", "--save", &docx_path.to_string_lossy()])).is_ok());
|
||||
assert!(docx_path.exists());
|
||||
|
||||
let docx_str = docx_path.to_string_lossy().into_owned();
|
||||
|
||||
// 1. render with defaults
|
||||
let out_default = dir.join("out_default.png");
|
||||
assert!(run(&args(&["render", &docx_str, &out_default.to_string_lossy()])).is_ok());
|
||||
assert!(out_default.exists());
|
||||
|
||||
// 2. render with valid --page and --scale
|
||||
let out_valid = dir.join("out_valid.png");
|
||||
assert!(run(&args(&["render", &docx_str, &out_valid.to_string_lossy(), "--page", "1", "--scale", "2"])).is_ok());
|
||||
assert!(out_valid.exists());
|
||||
|
||||
// 3. render with different flag order
|
||||
let out_reordered = dir.join("out_reordered.png");
|
||||
assert!(run(&args(&["render", &docx_str, &out_reordered.to_string_lossy(), "--scale", "1", "--page", "1"])).is_ok());
|
||||
assert!(out_reordered.exists());
|
||||
|
||||
// 4. render failure leaves no output file
|
||||
let out_invalid = dir.join("out_invalid.png");
|
||||
assert!(run(&args(&["render", &docx_str, &out_invalid.to_string_lossy(), "--page", "banana"])).is_err());
|
||||
assert!(!out_invalid.exists());
|
||||
|
||||
// 5. run --save out.png with defaults
|
||||
let run_default = dir.join("run_default.png");
|
||||
assert!(run(&args(&["run", "--file", &docx_str, "--save", &run_default.to_string_lossy()])).is_ok());
|
||||
assert!(run_default.exists());
|
||||
|
||||
// 6. run --save out.png with --page and --scale
|
||||
let run_valid = dir.join("run_valid.png");
|
||||
assert!(run(&args(&["run", "--file", &docx_str, "--save", &run_valid.to_string_lossy(), "--page", "1", "--scale", "1"])).is_ok());
|
||||
assert!(run_valid.exists());
|
||||
|
||||
// 7. run --save out.png with different flag order
|
||||
let run_reordered = dir.join("run_reordered.png");
|
||||
assert!(run(&args(&["run", "--file", &docx_str, "--scale", "1", "--page", "1", "--save", &run_reordered.to_string_lossy()])).is_ok());
|
||||
assert!(run_reordered.exists());
|
||||
|
||||
// 8. run --save out.png failure leaves no output file
|
||||
let run_invalid = dir.join("run_invalid.png");
|
||||
assert!(run(&args(&["run", "--file", &docx_str, "--save", &run_invalid.to_string_lossy(), "--scale", "banana"])).is_err());
|
||||
assert!(!run_invalid.exists());
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
/// A scratch folder that removes itself, even when the test fails.
|
||||
struct ScriptDir(PathBuf);
|
||||
|
||||
impl std::ops::Deref for ScriptDir {
|
||||
type Target = Path;
|
||||
fn deref(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ScriptDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn script_dir(tag: &str) -> ScriptDir {
|
||||
let dir = std::env::temp_dir().join(format!("wc-cli-script-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
ScriptDir(dir)
|
||||
}
|
||||
|
||||
fn write_script(dir: &Path, text: &str) -> String {
|
||||
let p = dir.join("job.json");
|
||||
std::fs::write(&p, text).unwrap();
|
||||
p.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_builds_a_document_and_saves_docx_and_pdf_relative_to_the_script() {
|
||||
let dir = script_dir("two");
|
||||
let script = write_script(
|
||||
&dir,
|
||||
r#"{"template":"blank","commands":[{"command":"text.insert","params":{"text":"Hello script"}},{"command":"format.bold"}],"save":["out.docx","out.pdf"]}"#,
|
||||
);
|
||||
run(&args(&["run", "--script", &script])).unwrap();
|
||||
assert!(std::fs::read(dir.join("out.pdf")).unwrap().starts_with(b"%PDF"));
|
||||
let doc = open(&dir.join("out.docx"), None).unwrap().doc;
|
||||
let json = serde_json::to_string(&doc).unwrap();
|
||||
assert!(json.contains("Hello script") && json.contains("\"bold\":true"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_with_a_bad_output_writes_no_output() {
|
||||
let dir = script_dir("badout");
|
||||
let script = write_script(&dir, r#"{"commands":["caret.docEnd"],"save":["a.docx","b.xyz"]}"#);
|
||||
let e = run(&args(&["run", "--script", &script])).unwrap_err().to_string();
|
||||
assert_eq!(e.matches("b.xyz").count(), 1, "{e}");
|
||||
assert!(!dir.join("a.docx").exists());
|
||||
let script = write_script(&dir, r#"{"commands":["caret.docEnd"],"save":["a.docx","nope/b.docx"]}"#);
|
||||
assert!(run(&args(&["run", "--script", &script])).is_err());
|
||||
assert!(!dir.join("a.docx").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_with_a_folder_as_output_writes_no_output() {
|
||||
let dir = script_dir("folderout");
|
||||
std::fs::create_dir(dir.join("b.pdf")).unwrap();
|
||||
let script = write_script(&dir, r#"{"commands":["caret.docEnd"],"save":["a.docx","b.pdf"]}"#);
|
||||
let e = run(&args(&["run", "--script", &script])).unwrap_err().to_string();
|
||||
assert!(e.contains("b.pdf"), "{e}");
|
||||
assert!(!dir.join("a.docx").exists());
|
||||
}
|
||||
|
||||
/// APFS refuses file names that aren't valid UTF-8, so the fixture can't be made on macOS
|
||||
/// (as in `tests/it/non_utf8_args.rs`).
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
#[test]
|
||||
fn script_file_name_need_not_be_unicode() {
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
let dir = script_dir("latin1");
|
||||
let mut name = dir.as_os_str().to_os_string().into_vec();
|
||||
name.extend_from_slice(b"/caf\xe9.json");
|
||||
let path = PathBuf::from(OsString::from_vec(name));
|
||||
std::fs::write(&path, r#"{"commands":["caret.docEnd"],"save":["o.txt"]}"#).unwrap();
|
||||
let raw = vec![OsString::from("run"), OsString::from("--script"), path.into_os_string()];
|
||||
run(&raw).unwrap();
|
||||
assert!(dir.join("o.txt").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_line_template_wins_over_the_scripts_file() {
|
||||
let dir = script_dir("source");
|
||||
let input = dir.join("in.docx");
|
||||
run(&args(&["run", "--template", "blank", "--cmd", "text.insert={\"text\":\"FROM FILE\"}", "--save", &input.to_string_lossy()])).unwrap();
|
||||
let script = write_script(&dir, r#"{"file":"in.docx","commands":["caret.docEnd"]}"#);
|
||||
let (a, b) = (dir.join("a.txt"), dir.join("b.txt"));
|
||||
run(&args(&["run", "--script", &script, "--template", "blank", "--save", &a.to_string_lossy()])).unwrap();
|
||||
assert!(!std::fs::read_to_string(&a).unwrap().contains("FROM FILE"));
|
||||
run(&args(&["run", "--script", &script, "--save", &b.to_string_lossy()])).unwrap();
|
||||
assert!(std::fs::read_to_string(&b).unwrap().contains("FROM FILE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_value_missing_is_reported() {
|
||||
let e = run(&args(&["run", "--script", "--cmd", "caret.docEnd"])).unwrap_err().to_string();
|
||||
assert!(e.contains("--script requires a value"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_reads_from_a_stream_and_caps_its_size() {
|
||||
let s = script::load_from(&br#"{"commands":["caret.docEnd"],"save":["o.docx"]}"#[..], "-", None).unwrap();
|
||||
assert_eq!((s.commands.len(), s.save), (1, vec!["o.docx".to_string()]));
|
||||
let big = std::io::repeat(b' ').take(script::MAX_SCRIPT_BYTES + 10);
|
||||
let e = script::load_from(big, "-", None).unwrap_err();
|
||||
assert!(e.contains("too large"), "{e}");
|
||||
assert!(script::load_from(&b"{"[..], "-", None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_commands_run_before_cmd_flags() {
|
||||
let dir = script_dir("order");
|
||||
let script = write_script(&dir, r#"{"commands":[{"command":"text.insert","params":{"text":"first"}}]}"#);
|
||||
let out = dir.join("o.txt");
|
||||
run(&args(&["run", "--script", &script, "--cmd", "text.insert={\"text\":\" second\"}", "--save", &out.to_string_lossy()])).unwrap();
|
||||
assert!(std::fs::read_to_string(&out).unwrap().contains("first second"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_failure_names_the_command_and_writes_nothing() {
|
||||
let dir = script_dir("fail");
|
||||
let script =
|
||||
write_script(&dir, r#"{"commands":[{"command":"text.insert","params":{"text":"x"}},{"command":"no.such.command"}],"save":["out.docx"]}"#);
|
||||
let e = run(&args(&["run", "--script", &script])).unwrap_err().to_string();
|
||||
assert!(e.contains("command 2") && e.contains("no.such.command"), "{e}");
|
||||
assert!(!dir.join("out.docx").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_rejects_junk_empty_missing_and_huge_input() {
|
||||
let dir = script_dir("junk");
|
||||
for text in [
|
||||
"not json",
|
||||
"[]",
|
||||
"{}",
|
||||
r#"{"commands":[]}"#,
|
||||
r#"{"commands":[{"params":{}}]}"#,
|
||||
r#"{"commands":[1]}"#,
|
||||
r#"{"commands":[{"command":"a"}],"save":[1]}"#,
|
||||
] {
|
||||
let script = write_script(&dir, text);
|
||||
assert!(run(&args(&["run", "--script", &script])).is_err(), "{text}");
|
||||
}
|
||||
assert!(run(&args(&["run", "--script", &dir.join("missing.json").to_string_lossy()])).is_err());
|
||||
let huge = write_script(&dir, &" ".repeat(5 * 1024 * 1024));
|
||||
let e = run(&args(&["run", "--script", &huge])).unwrap_err().to_string();
|
||||
assert!(e.contains("too large"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_parse_resolves_paths_against_the_base() {
|
||||
let s =
|
||||
script::parse(r#"{"file":"in.docx","commands":[{"command":"edit.undo"}],"save":["/abs/o.pdf","o.docx"]}"#, Some(Path::new("/base"))).unwrap();
|
||||
let joined = |name: &str| Path::new("/base").join(name).to_string_lossy().into_owned();
|
||||
assert_eq!(s.file, Some(joined("in.docx")));
|
||||
assert_eq!(s.save, vec!["/abs/o.pdf".to_string(), joined("o.docx")]);
|
||||
let s = script::parse(r#"{"commands":["caret.docEnd"],"save":["o.docx"]}"#, None).unwrap();
|
||||
assert_eq!(s.save, vec!["o.docx".to_string()]);
|
||||
assert_eq!(s.commands.len(), 1);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//! `wordcraft-cli run` with documents opened along the way: `file.new` and `file.open` open
|
||||
//! another document and make it active, and `--save` saves the active one.
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Output};
|
||||
|
||||
fn run(args: &[&str], out: &Path) -> Output {
|
||||
let mut cli = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli"));
|
||||
cli.arg("run").args(args).arg("--save").arg(out);
|
||||
cli.output().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_saves_the_document_opened_last() {
|
||||
let dir = std::env::temp_dir().join(format!("wordcraft-cli-documents-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (src, out) = (dir.join("opened.txt"), dir.join("out.txt"));
|
||||
std::fs::write(&src, "Opened\n").unwrap();
|
||||
let open = format!(r#"file.open={{"path":{}}}"#, serde_json::json!(src.to_string_lossy()));
|
||||
|
||||
// A script that starts a new document and types into it saves that document.
|
||||
let made = run(&["--template", "sample", "--cmd", "file.new", "--cmd", r#"text.insert={"text":"Fresh"}"#], &out);
|
||||
let fresh = std::fs::read_to_string(&out).unwrap_or_default();
|
||||
// A file opened after typing is what is saved; the typed document stays out of it.
|
||||
let opened = run(&["--template", "blank", "--cmd", r#"text.insert={"text":"Typed"}"#, "--cmd", &open, "--cmd", "caret.docEnd"], &out);
|
||||
let reopened = std::fs::read_to_string(&out).unwrap_or_default();
|
||||
// A run starts with one open document.
|
||||
let mut cli = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli"));
|
||||
let listed = cli.args(["run", "--template", "sample", "--print", "--cmd", "view.switchWindows"]).output().unwrap();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
|
||||
assert!(made.status.success(), "stderr: {}", String::from_utf8_lossy(&made.stderr));
|
||||
assert_eq!(fresh.trim(), "Fresh");
|
||||
assert!(opened.status.success(), "stderr: {}", String::from_utf8_lossy(&opened.stderr));
|
||||
assert_eq!(reopened.trim(), "Opened");
|
||||
assert!(listed.status.success(), "stderr: {}", String::from_utf8_lossy(&listed.stderr));
|
||||
let stdout = String::from_utf8_lossy(&listed.stdout);
|
||||
assert_eq!(stdout.matches("\"id\"").count(), 1, "{stdout}");
|
||||
}
|
||||
@@ -49,9 +49,9 @@ impl PrefsSync {
|
||||
pub fn changed(&mut self, app: &WordApp) -> Option<String> {
|
||||
let mut prefs = app.prefs();
|
||||
if let Some((host, own)) = &self.host_author
|
||||
&& prefs.author == *host
|
||||
&& prefs.author.as_deref() == Some(host.as_str())
|
||||
{
|
||||
prefs.author = own.clone();
|
||||
prefs.author = Some(own.clone());
|
||||
}
|
||||
let json = match serde_json::to_string(&prefs) {
|
||||
Ok(j) => j,
|
||||
|
||||
@@ -146,22 +146,10 @@ fn stored_prefs() -> Option<String> {
|
||||
}
|
||||
|
||||
impl eframe::App for WebShell {
|
||||
/// Runs one browser frame: forwards queued host messages to the status bar, advances the app,
|
||||
/// then writes changed preferences to browser storage immediately, because a reload or closed
|
||||
/// tab never runs the exit path.
|
||||
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 {
|
||||
let inbox = self.inbox.clone();
|
||||
let ctx = ctx.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
|
||||
match f.bytes_async().await {
|
||||
Ok(bytes) => {
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
let msgs = std::mem::take(&mut *self.messages.borrow_mut());
|
||||
for msg in msgs {
|
||||
self.app.status(msg);
|
||||
@@ -180,11 +168,34 @@ impl eframe::App for WebShell {
|
||||
}
|
||||
}
|
||||
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.app.raw_input_hook(raw);
|
||||
/// Forwards this frame's raw browser input to the app before widgets run.
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.app.raw_input_hook(ctx, raw);
|
||||
}
|
||||
|
||||
/// Draws the app. On the first pass of a visible frame it takes the browser's dropped files and
|
||||
/// spawns asynchronous byte reads into the inbox; later passes only draw.
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
// Browser drops are input: take them only in the first real UI pass, never in a hidden
|
||||
// `run_logic` tick that still sees the previous shown frame's RawInput.
|
||||
if ui.ctx().current_pass_index() == 0 {
|
||||
let ctx = ui.ctx().clone();
|
||||
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
|
||||
for f in dropped {
|
||||
let inbox = self.inbox.clone();
|
||||
let ctx = ctx.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
|
||||
match f.bytes_async().await {
|
||||
Ok(bytes) => {
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
self.app.ui(ui);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ wordcraft-fonts = { workspace = true }
|
||||
wordcraft-ui-egui = { workspace = true }
|
||||
# The control key file's location (shared with the MCP bridge).
|
||||
wordcraft-control-key = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links"] }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard", "wayland", "x11", "links"] }
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
@@ -73,13 +73,16 @@ windows = { version = "0.62", features = ["UI_ViewManagement", "System_UserProfi
|
||||
clipboard-win = { version = "5.4", features = ["std"] }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756" }
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
rfd = { workspace = true }
|
||||
# Waits for an `rfd::AsyncFileDialog` on a background thread (the dialogs don't block the UI, #94);
|
||||
# rfd itself already depends on it.
|
||||
pollster = "0.4"
|
||||
pollster = "1"
|
||||
# Pictures and copied files on the system clipboard (#45); egui's paste only carries text. Already
|
||||
# in the build through egui-winit, with the same features.
|
||||
arboard = { workspace = true, features = ["image-data"] }
|
||||
# The control server's random key.
|
||||
getrandom = "0.3"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
craft-update = { workspace = true }
|
||||
|
||||
@@ -35,26 +35,31 @@ impl AppleEvents {
|
||||
}
|
||||
|
||||
/// Open the documents and act on the quit requests that arrived since the last frame. A document
|
||||
/// opens like File › Open (asking about unsaved changes first); quitting closes the window, which
|
||||
/// asks the same.
|
||||
/// opens like File › Open: beside the open ones, in place of an untouched blank one, or, when it
|
||||
/// is already open, by showing it. Quitting closes the window, which asks about every document
|
||||
/// with unsaved changes.
|
||||
pub fn poll(inbox: &Inbox, app: &mut WordApp, ctx: &egui::Context) {
|
||||
for e in inbox.drain() {
|
||||
match e {
|
||||
Event::Open(paths) => {
|
||||
for p in paths.iter().filter_map(|p| p.to_str()) {
|
||||
match app.run("file.open", serde_json::json!({"path": p})) {
|
||||
match app.open_file(p, None) {
|
||||
// Leave Backstage, as opening from the Open dialog does.
|
||||
Ok(_) => app.ui.backstage = false,
|
||||
Err(e) => {
|
||||
log::warn!("{p}: {e}");
|
||||
let e = e.to_string();
|
||||
let msg = wordcraft_ui_egui::tl!("Couldn't open {path}: {error}");
|
||||
app.status(wordcraft_ui_egui::i18n::fmt(msg, &[("path", p), ("error", &e)]));
|
||||
app.report_open_failure(p, &e);
|
||||
// Backstage hides the status bar, so leave it to show the error.
|
||||
app.ui.backstage = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
|
||||
// Quitting asks about every document with unsaved changes, each in its window; the
|
||||
// app closes once that's done (`quit_requested`).
|
||||
Event::Quit => {
|
||||
app.quit();
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,8 @@ const DESKTOP_ID: &str = "ai.storyteller.wordcraft.desktop";
|
||||
|
||||
/// The types the button makes WordCraft the default for on Linux and BSD: Word documents and
|
||||
/// templates (also macro-enabled and legacy), RTF and ODT. The same set the Windows installer
|
||||
/// lists under WordCraft's Capabilities; Markdown and plain text are only offered under Open With.
|
||||
/// lists under WordCraft's Capabilities; Markdown is only offered under Open With, and plain
|
||||
/// text only on Windows and macOS (the `.desktop` file leaves it out, #4137).
|
||||
/// Every one is in the `.desktop` file's `MimeType=` (a test checks).
|
||||
#[cfg(any(all(unix, not(target_os = "macos")), test))]
|
||||
const WORD_MIME_TYPES: &[&str] = &[
|
||||
@@ -164,6 +165,16 @@ mod tests {
|
||||
assert!(entry.lines().any(|l| l == "Exec=wordcraft %F"), "files are passed on the command line");
|
||||
}
|
||||
|
||||
/// Declaring `text/plain` makes WordCraft a handler for every config, script and log on systems with no default set.
|
||||
#[test]
|
||||
fn the_desktop_entry_does_not_declare_plain_text() {
|
||||
let entry = include_str!("../../../packaging/linux/ai.storyteller.wordcraft.desktop");
|
||||
let declared: Vec<&str> =
|
||||
entry.lines().find_map(|l| l.strip_prefix("MimeType=")).map(|v| v.split(';').filter(|s| !s.is_empty()).collect()).unwrap_or_default();
|
||||
assert!(!declared.is_empty(), "MimeType= line not found");
|
||||
assert!(!declared.contains(&"text/plain"), "text/plain must not be declared");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn application_dirs_follow_the_xdg_spec() {
|
||||
|
||||
@@ -208,7 +208,7 @@ fn save_exts(name: &str) -> Vec<String> {
|
||||
let suggested = std::path::Path::new(name)
|
||||
.extension()
|
||||
.and_then(std::ffi::OsStr::to_str)
|
||||
.map(|e| e.to_ascii_lowercase())
|
||||
.map(str::to_ascii_lowercase)
|
||||
.filter(|e| wordcraft_engine::io::SAVE_EXTS.contains(&e.as_str()))
|
||||
.unwrap_or_else(|| "docx".to_string());
|
||||
let mut exts: Vec<String> = Vec::with_capacity(wordcraft_engine::io::SAVE_EXTS.len());
|
||||
|
||||
@@ -346,339 +346,4 @@ pub fn install_panic_hook() {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
static N: AtomicUsize = AtomicUsize::new(0);
|
||||
let d = std::env::temp_dir().join(format!("wordcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
d
|
||||
}
|
||||
|
||||
fn read(path: &Path) -> String {
|
||||
std::fs::read_to_string(path).expect("read log")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_filter_shows_wordcraft_info_and_other_crates_warnings() {
|
||||
let f = Filter::parse(DEFAULT_FILTER);
|
||||
assert_eq!(f.level_for("wordcraft"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("wordcraft::control_server"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("wordcraft_docx::package"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("wordcraft_ui_egui"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn);
|
||||
assert_eq!(f.level_for("naga"), LevelFilter::Warn);
|
||||
assert_eq!(f.max(), LevelFilter::Info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directives_follow_env_logger_and_the_most_specific_one_wins() {
|
||||
let f = Filter::parse("info,wgpu_core=error,wordcraft_pdf=trace,wordcraft_pdf::fonts=off");
|
||||
assert_eq!(f.level_for("eframe"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error);
|
||||
assert_eq!(f.level_for("wordcraft_pdf::images"), LevelFilter::Trace);
|
||||
assert_eq!(f.level_for("wordcraft_pdf::fonts"), LevelFilter::Off);
|
||||
assert_eq!(f.max(), LevelFilter::Trace);
|
||||
// A module name is matched at `::` boundaries, not as a bare prefix.
|
||||
assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info);
|
||||
// A trailing `*` is a prefix.
|
||||
assert_eq!(Filter::parse("wordcraft*=debug").level_for("wordcraft_layout::lines"), LevelFilter::Debug);
|
||||
assert_eq!(Filter::parse("wordcraft*=debug").level_for("eframe"), LevelFilter::Error);
|
||||
// A bare target name sets that target to the most verbose level, as env_logger does.
|
||||
assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace);
|
||||
assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error);
|
||||
// Levels are case-insensitive and surrounding blanks are ignored.
|
||||
assert_eq!(Filter::parse(" WARN , wordcraft = Debug ").level_for("wordcraft"), LevelFilter::Debug);
|
||||
assert_eq!(Filter::parse(" WARN , wordcraft = Debug ").level_for("eframe"), LevelFilter::Warn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_specs_never_panic_and_fall_back_sensibly() {
|
||||
let long = "x".repeat(100_000);
|
||||
let many = "a=info,".repeat(10_000);
|
||||
for spec in [
|
||||
"",
|
||||
",,,",
|
||||
"=",
|
||||
"==",
|
||||
"=debug",
|
||||
"wordcraft=",
|
||||
"nonsense=loud",
|
||||
"🦀=info",
|
||||
"*",
|
||||
"*=",
|
||||
"=*",
|
||||
"a=b=c",
|
||||
"warn,,wordcraft=DEBUG",
|
||||
"\0",
|
||||
"é::ü=trace",
|
||||
&long,
|
||||
&many,
|
||||
] {
|
||||
let f = Filter::parse(spec);
|
||||
for target in ["", "wordcraft", "🦀", "a::b", "*"] {
|
||||
let _ = f.level_for(target);
|
||||
}
|
||||
let _ = f.max();
|
||||
}
|
||||
assert_eq!(Filter::parse("warn,,wordcraft=DEBUG").level_for("wordcraft"), LevelFilter::Debug);
|
||||
// An unknown level is skipped: that target keeps the default.
|
||||
assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error);
|
||||
assert_eq!(Filter::parse("wordcraft=").level_for("wordcraft"), LevelFilter::Error);
|
||||
assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error);
|
||||
assert_eq!(Filter::parse("🦀=info").level_for("🦀::claw"), LevelFilter::Info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamps_are_utc_with_milliseconds() {
|
||||
assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z");
|
||||
// Leap day, and the day after it.
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z");
|
||||
// 2000 is a leap year (divisible by 400), 2100 is not.
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z");
|
||||
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 to the epoch instead of panicking.
|
||||
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
|
||||
// Past chrono's range (a clock set absurdly far ahead) keeps the proleptic Gregorian date
|
||||
// the old formatter gave, rather than falling back to the epoch. FredApps' counterexample:
|
||||
// UNIX_EPOCH + 10_000_000_000_000 s was 318857-05-20T17:46:40.000Z and must stay so.
|
||||
// Windows' SystemTime holds only up to about year 30828, so this is checked where it fits.
|
||||
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");
|
||||
}
|
||||
// Windows' SystemTime can't hold u64::MAX / 2 seconds, so it is only checked 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");
|
||||
}
|
||||
// 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]
|
||||
fn a_line_carries_time_level_thread_target_and_message() {
|
||||
assert_eq!(
|
||||
format_line("2026-10-08T07:59:17.728Z", log::Level::Warn, "main", "wordcraft_docx::package", "skipping unreadable zip entry 3"),
|
||||
"2026-10-08T07:59:17.728Z WARN [main] wordcraft_docx::package: skipping unreadable zip entry 3\n"
|
||||
);
|
||||
// A multi-line message keeps its lines; the record still ends in one newline.
|
||||
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
|
||||
let dir = temp_dir("rotate");
|
||||
for run in 1..=4 {
|
||||
let (path, _file) = rotate(&dir).expect("rotate");
|
||||
assert_eq!(path, dir.join(LOG_FILE));
|
||||
assert_eq!(read(&path), "");
|
||||
std::fs::write(&path, format!("run {run}")).expect("write");
|
||||
}
|
||||
assert_eq!(read(&dir.join(LOG_FILE)), "run 4");
|
||||
assert_eq!(read(&dir.join("wordcraft.1.log")), "run 3");
|
||||
assert_eq!(read(&dir.join("wordcraft.2.log")), "run 2");
|
||||
assert!(!dir.join("wordcraft.3.log").exists(), "only {KEEP} old logs are kept");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() {
|
||||
let dir = temp_dir("blocked");
|
||||
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
|
||||
std::fs::write(&dir, "a file where the directory should be").expect("block");
|
||||
assert!(rotate(&dir).is_err());
|
||||
let _ = std::fs::remove_file(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() {
|
||||
let dir = temp_dir("pending");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
sink.write("early 1\n");
|
||||
sink.write("early 2\n");
|
||||
sink.attach(file);
|
||||
sink.write("late\n");
|
||||
assert_eq!(read(&path), "early 1\nearly 2\nlate\n");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() {
|
||||
let dir = temp_dir("bounded");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
for i in 0..MAX_PENDING + 5 {
|
||||
sink.write(&format!("line {i}\n"));
|
||||
}
|
||||
sink.attach(file);
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING);
|
||||
assert!(text.contains("5 earlier log lines were dropped"), "{text}");
|
||||
assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n")));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exactly_a_full_pending_buffer_drops_nothing() {
|
||||
let dir = temp_dir("full");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
for i in 0..MAX_PENDING {
|
||||
sink.write(&format!("line {i}\n"));
|
||||
}
|
||||
sink.attach(file);
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().count(), MAX_PENDING);
|
||||
assert!(!text.contains("dropped"), "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_file_nothing_is_kept_in_memory() {
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
sink.write("early\n");
|
||||
sink.no_file();
|
||||
sink.write("late\n");
|
||||
assert!(sink.pending.is_empty() && sink.dropped == 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_stops_at_its_size_cap_with_one_note() {
|
||||
let dir = temp_dir("cap");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(100);
|
||||
sink.attach(file);
|
||||
for i in 0..50 {
|
||||
sink.write(&format!("line {i:02} with some padding\n"));
|
||||
}
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}");
|
||||
assert_eq!(text.matches("log file reached").count(), 1, "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_logger_filters_records_and_writes_them_to_the_attached_file() {
|
||||
use log::Log;
|
||||
let dir = temp_dir("logger");
|
||||
let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
|
||||
let record = |level, target: &'static str, msg: &'static str| {
|
||||
logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build());
|
||||
};
|
||||
record(log::Level::Info, "wordcraft", "before the settings directory");
|
||||
record(log::Level::Info, "wgpu_core::device", "too chatty");
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
assert_eq!(path, dir.join(LOG_FILE));
|
||||
record(log::Level::Warn, "wgpu_hal::vulkan", "a real warning");
|
||||
record(log::Level::Error, "wordcraft_render", "PNG encode: bad size 0×0");
|
||||
record(log::Level::Debug, "wordcraft", "below info");
|
||||
let text = read(&path);
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
assert_eq!(lines.len(), 3, "{text}");
|
||||
assert!(lines[0].contains(" INFO [") && lines[0].ends_with("wordcraft: before the settings directory"), "{text}");
|
||||
assert!(lines[1].ends_with("wgpu_hal::vulkan: a real warning"), "{text}");
|
||||
assert!(lines[2].contains(" ERROR [") && lines[2].ends_with("wordcraft_render: PNG encode: bad size 0×0"), "{text}");
|
||||
assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("wordcraft_ui_egui").build()));
|
||||
assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build()));
|
||||
// Attaching again rotates: the first log becomes `.1`.
|
||||
logger.attach_dir(&dir).expect("attach again");
|
||||
assert_eq!(read(&dir.join("wordcraft.1.log")), text);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_from_many_threads_are_whole_lines() {
|
||||
use log::Log;
|
||||
let dir = temp_dir("threads");
|
||||
let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false));
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
let workers: Vec<_> = (0..8)
|
||||
.map(|t| {
|
||||
let l = std::sync::Arc::clone(&logger);
|
||||
std::thread::spawn(move || {
|
||||
for i in 0..100 {
|
||||
l.log(
|
||||
&log::Record::builder().level(log::Level::Info).target("wordcraft").args(format_args!("thread {t} record {i}")).build(),
|
||||
);
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
for w in workers {
|
||||
w.join().expect("worker");
|
||||
}
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().count(), 800);
|
||||
assert!(text.lines().all(|l| l.contains(" wordcraft: thread ")), "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() {
|
||||
use log::Log;
|
||||
let dir = temp_dir("unusable");
|
||||
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
|
||||
std::fs::write(&dir, "not a directory").expect("block");
|
||||
let logger = AppLogger::new(Filter::parse("info"), false);
|
||||
logger.log(&log::Record::builder().level(log::Level::Error).target("x").args(format_args!("early")).build());
|
||||
assert!(logger.attach_dir(&dir).is_err());
|
||||
logger.log(&log::Record::builder().level(log::Level::Error).target("x").args(format_args!("still fine")).build());
|
||||
// Nothing waits in memory for a file that won't come.
|
||||
let sink = logger.sink.lock().expect("sink");
|
||||
assert!(sink.pending.is_empty() && sink.file.is_none());
|
||||
drop(sink);
|
||||
let _ = std::fs::remove_file(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_poisoned_sink_still_logs() {
|
||||
use log::Log;
|
||||
let dir = temp_dir("poison");
|
||||
let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false));
|
||||
let l = std::sync::Arc::clone(&logger);
|
||||
let _ = std::thread::spawn(move || {
|
||||
let _guard = l.sink.lock().expect("lock");
|
||||
panic!("poison the sink");
|
||||
})
|
||||
.join();
|
||||
assert!(logger.sink.is_poisoned());
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
logger.log(&log::Record::builder().level(log::Level::Warn).target("wordcraft").args(format_args!("after the poison")).build());
|
||||
assert!(read(&path).contains("wordcraft: after the poison"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panic_messages_leave_out_quoted_text() {
|
||||
// What std says when a string is sliced inside a character: the string is the user's text.
|
||||
let msg = "byte index 5 is not a char boundary; it is inside 'é' (bytes 4..6) of `Dear Ms Smith, my secret`";
|
||||
assert_eq!(redact_quoted(msg), "byte index 5 is not a char boundary; it is inside 'é' (bytes 4..6) of `…`");
|
||||
assert_eq!(redact_quoted("byte index 99 is out of range of `my diary`[...]"), "byte index 99 is out of range of `…`[...]");
|
||||
assert_eq!(redact_quoted("a `b` c `d` e"), "a `…` c `…` e");
|
||||
// An unpaired backtick hides the rest rather than risk leaking it.
|
||||
assert_eq!(redact_quoted("of `half a sentence"), "of `…");
|
||||
assert_eq!(
|
||||
redact_quoted("no quotes: index out of bounds: the len is 3 but the index is 5"),
|
||||
"no quotes: index out of bounds: the len is 3 but the index is 5"
|
||||
);
|
||||
assert_eq!(redact_quoted(""), "");
|
||||
assert_eq!(redact_quoted("``"), "`…`");
|
||||
assert_eq!(redact_quoted("🦀`🦀`🦀"), "🦀`…`🦀");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panic_report_names_the_place_and_the_redacted_message() {
|
||||
assert_eq!(
|
||||
panic_report(Some("crates/engine/src/cmd/edit.rs:12:5"), Some("of `secret`"), None),
|
||||
"panic at crates/engine/src/cmd/edit.rs:12:5: of `…`"
|
||||
);
|
||||
assert_eq!(panic_report(None, None, None), "panic at an unknown place: (no message)");
|
||||
assert_eq!(panic_report(Some("a.rs:1:1"), Some("boom"), Some(" 0: main\n")), "panic at a.rs:1:1: boom\n 0: main");
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
static N: AtomicUsize = AtomicUsize::new(0);
|
||||
let d = std::env::temp_dir().join(format!("wordcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
d
|
||||
}
|
||||
|
||||
fn read(path: &Path) -> String {
|
||||
std::fs::read_to_string(path).expect("read log")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_filter_shows_wordcraft_info_and_other_crates_warnings() {
|
||||
let f = Filter::parse(DEFAULT_FILTER);
|
||||
assert_eq!(f.level_for("wordcraft"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("wordcraft::control_server"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("wordcraft_docx::package"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("wordcraft_ui_egui"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn);
|
||||
assert_eq!(f.level_for("naga"), LevelFilter::Warn);
|
||||
assert_eq!(f.max(), LevelFilter::Info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directives_follow_env_logger_and_the_most_specific_one_wins() {
|
||||
let f = Filter::parse("info,wgpu_core=error,wordcraft_pdf=trace,wordcraft_pdf::fonts=off");
|
||||
assert_eq!(f.level_for("eframe"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error);
|
||||
assert_eq!(f.level_for("wordcraft_pdf::images"), LevelFilter::Trace);
|
||||
assert_eq!(f.level_for("wordcraft_pdf::fonts"), LevelFilter::Off);
|
||||
assert_eq!(f.max(), LevelFilter::Trace);
|
||||
// A module name is matched at `::` boundaries, not as a bare prefix.
|
||||
assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info);
|
||||
// A trailing `*` is a prefix.
|
||||
assert_eq!(Filter::parse("wordcraft*=debug").level_for("wordcraft_layout::lines"), LevelFilter::Debug);
|
||||
assert_eq!(Filter::parse("wordcraft*=debug").level_for("eframe"), LevelFilter::Error);
|
||||
// A bare target name sets that target to the most verbose level, as env_logger does.
|
||||
assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace);
|
||||
assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error);
|
||||
// Levels are case-insensitive and surrounding blanks are ignored.
|
||||
assert_eq!(Filter::parse(" WARN , wordcraft = Debug ").level_for("wordcraft"), LevelFilter::Debug);
|
||||
assert_eq!(Filter::parse(" WARN , wordcraft = Debug ").level_for("eframe"), LevelFilter::Warn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_specs_never_panic_and_fall_back_sensibly() {
|
||||
let long = "x".repeat(100_000);
|
||||
let many = "a=info,".repeat(10_000);
|
||||
for spec in [
|
||||
"",
|
||||
",,,",
|
||||
"=",
|
||||
"==",
|
||||
"=debug",
|
||||
"wordcraft=",
|
||||
"nonsense=loud",
|
||||
"🦀=info",
|
||||
"*",
|
||||
"*=",
|
||||
"=*",
|
||||
"a=b=c",
|
||||
"warn,,wordcraft=DEBUG",
|
||||
"\0",
|
||||
"é::ü=trace",
|
||||
&long,
|
||||
&many,
|
||||
] {
|
||||
let f = Filter::parse(spec);
|
||||
for target in ["", "wordcraft", "🦀", "a::b", "*"] {
|
||||
let _ = f.level_for(target);
|
||||
}
|
||||
let _ = f.max();
|
||||
}
|
||||
assert_eq!(Filter::parse("warn,,wordcraft=DEBUG").level_for("wordcraft"), LevelFilter::Debug);
|
||||
// An unknown level is skipped: that target keeps the default.
|
||||
assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error);
|
||||
assert_eq!(Filter::parse("wordcraft=").level_for("wordcraft"), LevelFilter::Error);
|
||||
assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error);
|
||||
assert_eq!(Filter::parse("🦀=info").level_for("🦀::claw"), LevelFilter::Info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamps_are_utc_with_milliseconds() {
|
||||
assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z");
|
||||
// Leap day, and the day after it.
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z");
|
||||
// 2000 is a leap year (divisible by 400), 2100 is not.
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z");
|
||||
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 to the epoch instead of panicking.
|
||||
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
|
||||
// Past chrono's range (a clock set absurdly far ahead) keeps the proleptic Gregorian date
|
||||
// the old formatter gave, rather than falling back to the epoch. FredApps' counterexample:
|
||||
// UNIX_EPOCH + 10_000_000_000_000 s was 318857-05-20T17:46:40.000Z and must stay so.
|
||||
// Windows' SystemTime holds only up to about year 30828, so this is checked where it fits.
|
||||
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");
|
||||
}
|
||||
// Windows' SystemTime can't hold u64::MAX / 2 seconds, so it is only checked 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");
|
||||
}
|
||||
// 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]
|
||||
fn a_line_carries_time_level_thread_target_and_message() {
|
||||
assert_eq!(
|
||||
format_line("2026-10-08T07:59:17.728Z", log::Level::Warn, "main", "wordcraft_docx::package", "skipping unreadable zip entry 3"),
|
||||
"2026-10-08T07:59:17.728Z WARN [main] wordcraft_docx::package: skipping unreadable zip entry 3\n"
|
||||
);
|
||||
// A multi-line message keeps its lines; the record still ends in one newline.
|
||||
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
|
||||
let dir = temp_dir("rotate");
|
||||
for run in 1..=4 {
|
||||
let (path, _file) = rotate(&dir).expect("rotate");
|
||||
assert_eq!(path, dir.join(LOG_FILE));
|
||||
assert_eq!(read(&path), "");
|
||||
std::fs::write(&path, format!("run {run}")).expect("write");
|
||||
}
|
||||
assert_eq!(read(&dir.join(LOG_FILE)), "run 4");
|
||||
assert_eq!(read(&dir.join("wordcraft.1.log")), "run 3");
|
||||
assert_eq!(read(&dir.join("wordcraft.2.log")), "run 2");
|
||||
assert!(!dir.join("wordcraft.3.log").exists(), "only {KEEP} old logs are kept");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() {
|
||||
let dir = temp_dir("blocked");
|
||||
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
|
||||
std::fs::write(&dir, "a file where the directory should be").expect("block");
|
||||
assert!(rotate(&dir).is_err());
|
||||
let _ = std::fs::remove_file(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() {
|
||||
let dir = temp_dir("pending");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
sink.write("early 1\n");
|
||||
sink.write("early 2\n");
|
||||
sink.attach(file);
|
||||
sink.write("late\n");
|
||||
assert_eq!(read(&path), "early 1\nearly 2\nlate\n");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() {
|
||||
let dir = temp_dir("bounded");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
for i in 0..MAX_PENDING + 5 {
|
||||
sink.write(&format!("line {i}\n"));
|
||||
}
|
||||
sink.attach(file);
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING);
|
||||
assert!(text.contains("5 earlier log lines were dropped"), "{text}");
|
||||
assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n")));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exactly_a_full_pending_buffer_drops_nothing() {
|
||||
let dir = temp_dir("full");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
for i in 0..MAX_PENDING {
|
||||
sink.write(&format!("line {i}\n"));
|
||||
}
|
||||
sink.attach(file);
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().count(), MAX_PENDING);
|
||||
assert!(!text.contains("dropped"), "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_file_nothing_is_kept_in_memory() {
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
sink.write("early\n");
|
||||
sink.no_file();
|
||||
sink.write("late\n");
|
||||
assert!(sink.pending.is_empty() && sink.dropped == 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_stops_at_its_size_cap_with_one_note() {
|
||||
let dir = temp_dir("cap");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(100);
|
||||
sink.attach(file);
|
||||
for i in 0..50 {
|
||||
sink.write(&format!("line {i:02} with some padding\n"));
|
||||
}
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}");
|
||||
assert_eq!(text.matches("log file reached").count(), 1, "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_logger_filters_records_and_writes_them_to_the_attached_file() {
|
||||
use log::Log;
|
||||
let dir = temp_dir("logger");
|
||||
let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
|
||||
let record = |level, target: &'static str, msg: &'static str| {
|
||||
logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build());
|
||||
};
|
||||
record(log::Level::Info, "wordcraft", "before the settings directory");
|
||||
record(log::Level::Info, "wgpu_core::device", "too chatty");
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
assert_eq!(path, dir.join(LOG_FILE));
|
||||
record(log::Level::Warn, "wgpu_hal::vulkan", "a real warning");
|
||||
record(log::Level::Error, "wordcraft_render", "PNG encode: bad size 0×0");
|
||||
record(log::Level::Debug, "wordcraft", "below info");
|
||||
let text = read(&path);
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
assert_eq!(lines.len(), 3, "{text}");
|
||||
assert!(lines[0].contains(" INFO [") && lines[0].ends_with("wordcraft: before the settings directory"), "{text}");
|
||||
assert!(lines[1].ends_with("wgpu_hal::vulkan: a real warning"), "{text}");
|
||||
assert!(lines[2].contains(" ERROR [") && lines[2].ends_with("wordcraft_render: PNG encode: bad size 0×0"), "{text}");
|
||||
assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("wordcraft_ui_egui").build()));
|
||||
assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build()));
|
||||
// Attaching again rotates: the first log becomes `.1`.
|
||||
logger.attach_dir(&dir).expect("attach again");
|
||||
assert_eq!(read(&dir.join("wordcraft.1.log")), text);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_from_many_threads_are_whole_lines() {
|
||||
use log::Log;
|
||||
let dir = temp_dir("threads");
|
||||
let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false));
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
let workers: Vec<_> = (0..8)
|
||||
.map(|t| {
|
||||
let l = std::sync::Arc::clone(&logger);
|
||||
std::thread::spawn(move || {
|
||||
for i in 0..100 {
|
||||
l.log(&log::Record::builder().level(log::Level::Info).target("wordcraft").args(format_args!("thread {t} record {i}")).build());
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
for w in workers {
|
||||
w.join().expect("worker");
|
||||
}
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().count(), 800);
|
||||
assert!(text.lines().all(|l| l.contains(" wordcraft: thread ")), "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() {
|
||||
use log::Log;
|
||||
let dir = temp_dir("unusable");
|
||||
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
|
||||
std::fs::write(&dir, "not a directory").expect("block");
|
||||
let logger = AppLogger::new(Filter::parse("info"), false);
|
||||
logger.log(&log::Record::builder().level(log::Level::Error).target("x").args(format_args!("early")).build());
|
||||
assert!(logger.attach_dir(&dir).is_err());
|
||||
logger.log(&log::Record::builder().level(log::Level::Error).target("x").args(format_args!("still fine")).build());
|
||||
// Nothing waits in memory for a file that won't come.
|
||||
let sink = logger.sink.lock().expect("sink");
|
||||
assert!(sink.pending.is_empty() && sink.file.is_none());
|
||||
drop(sink);
|
||||
let _ = std::fs::remove_file(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_poisoned_sink_still_logs() {
|
||||
use log::Log;
|
||||
let dir = temp_dir("poison");
|
||||
let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false));
|
||||
let l = std::sync::Arc::clone(&logger);
|
||||
let _ = std::thread::spawn(move || {
|
||||
let _guard = l.sink.lock().expect("lock");
|
||||
panic!("poison the sink");
|
||||
})
|
||||
.join();
|
||||
assert!(logger.sink.is_poisoned());
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
logger.log(&log::Record::builder().level(log::Level::Warn).target("wordcraft").args(format_args!("after the poison")).build());
|
||||
assert!(read(&path).contains("wordcraft: after the poison"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panic_messages_leave_out_quoted_text() {
|
||||
// What std says when a string is sliced inside a character: the string is the user's text.
|
||||
let msg = "byte index 5 is not a char boundary; it is inside 'é' (bytes 4..6) of `Dear Ms Smith, my secret`";
|
||||
assert_eq!(redact_quoted(msg), "byte index 5 is not a char boundary; it is inside 'é' (bytes 4..6) of `…`");
|
||||
assert_eq!(redact_quoted("byte index 99 is out of range of `my diary`[...]"), "byte index 99 is out of range of `…`[...]");
|
||||
assert_eq!(redact_quoted("a `b` c `d` e"), "a `…` c `…` e");
|
||||
// An unpaired backtick hides the rest rather than risk leaking it.
|
||||
assert_eq!(redact_quoted("of `half a sentence"), "of `…");
|
||||
assert_eq!(
|
||||
redact_quoted("no quotes: index out of bounds: the len is 3 but the index is 5"),
|
||||
"no quotes: index out of bounds: the len is 3 but the index is 5"
|
||||
);
|
||||
assert_eq!(redact_quoted(""), "");
|
||||
assert_eq!(redact_quoted("``"), "`…`");
|
||||
assert_eq!(redact_quoted("🦀`🦀`🦀"), "🦀`…`🦀");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panic_report_names_the_place_and_the_redacted_message() {
|
||||
assert_eq!(
|
||||
panic_report(Some("crates/engine/src/cmd/edit.rs:12:5"), Some("of `secret`"), None),
|
||||
"panic at crates/engine/src/cmd/edit.rs:12:5: of `…`"
|
||||
);
|
||||
assert_eq!(panic_report(None, None, None), "panic at an unknown place: (no message)");
|
||||
assert_eq!(panic_report(Some("a.rs:1:1"), Some("boom"), Some(" 0: main\n")), "panic at a.rs:1:1: boom\n 0: main");
|
||||
}
|
||||
+101
-13
@@ -27,47 +27,66 @@ mod traffic_lights;
|
||||
use wordcraft_engine::Session;
|
||||
use wordcraft_ui_egui::{
|
||||
Services, UiState, WordApp,
|
||||
window_geometry::{WindowGeometry, take_rescue},
|
||||
window_geometry::{RawWindowState, WindowGeometry, take_rescue},
|
||||
};
|
||||
|
||||
/// The app, the restored window geometry (or, with none, the default: no position) until the
|
||||
/// first frame that knows the monitor has checked it, the control server's key file (removed on
|
||||
/// exit), the file dialogs the app asked for and, on macOS, the documents opened from Finder.
|
||||
/// exit), the file dialogs the app asked for, this frame's raw host window state and, on macOS,
|
||||
/// the documents opened from Finder.
|
||||
struct App(
|
||||
WordApp,
|
||||
Option<WindowGeometry>,
|
||||
Option<control_server::KeyFile>,
|
||||
file_dialogs::Launcher,
|
||||
// The host's answer for this frame. `InputState` still describes the last painted frame on
|
||||
// a hidden logic tick.
|
||||
RawWindowState,
|
||||
#[cfg(target_os = "macos")] fmv_macos_events::Inbox,
|
||||
);
|
||||
|
||||
impl eframe::App for App {
|
||||
/// Advances one desktop frame: polls Finder events on macOS, runs the app and its file dialogs,
|
||||
/// fits or rescues the window and maximizes it after opening, then gates a close or quit request
|
||||
/// on unsaved changes and active printing.
|
||||
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
|
||||
#[cfg(target_os = "macos")]
|
||||
apple_events::poll(&self.4, &mut self.0, ctx);
|
||||
apple_events::poll(&self.5, &mut self.0, ctx);
|
||||
self.0.logic(ctx);
|
||||
self.3.show(frame);
|
||||
let window = self.4;
|
||||
// A window that doesn't fit on its screen (the default size on a laptop) shrinks to fit
|
||||
// there, so all of the scroll bar can be dragged (#1215). Checked on the frame that first
|
||||
// knows the monitor: the off-screen check below takes the pending geometry then.
|
||||
if let Some((pos, size)) = ctx.input(|i| self.1.and_then(|g| g.fit_on_monitor(i.viewport()))) {
|
||||
if let Some((pos, size)) = self.1.and_then(|g| g.fit_on_monitor(&window)) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(size));
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos));
|
||||
}
|
||||
// The geometry saved for the next start is the active window's.
|
||||
let prev = self.0.ui.window;
|
||||
self.0.ui.window = ctx.input(|i| WindowGeometry::track(prev, i.viewport(), i.viewport_rect().size()));
|
||||
// Another document window's state is the one it last painted with: the host reports only
|
||||
// the root window's for this frame.
|
||||
let active = self.0.active_viewport();
|
||||
let tracked = if active == egui::ViewportId::ROOT {
|
||||
window
|
||||
} else {
|
||||
ctx.input_for(active, |i| RawWindowState::from_viewport(Some(i.viewport()), Some(i.viewport_rect())))
|
||||
};
|
||||
self.0.ui.window = WindowGeometry::track(prev, &tracked);
|
||||
// The first frame, once the monitor is known (as `take_rescue` waits for): maximize a
|
||||
// window that opened at its normal size so it never flashed on screen half-opened.
|
||||
let maximize = self.1.and_then(|g| g.maximize_after_open()).filter(|_| ctx.input(|i| i.viewport().monitor_size.is_some()));
|
||||
if let Some(pos) = ctx.input(|i| take_rescue(&mut self.1, i.viewport())) {
|
||||
let maximize = self.1.and_then(|g| g.maximize_after_open()).filter(|_| window.monitor_size.is_some());
|
||||
if let Some(pos) = take_rescue(&mut self.1, &window) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos));
|
||||
}
|
||||
if let Some(command) = maximize {
|
||||
ctx.send_viewport_cmd(command);
|
||||
}
|
||||
// The window's close button: with unsaved changes, ask first (`WordApp::close_requested`).
|
||||
// The window's close button: with other windows open only this window closes (the app
|
||||
// handles that while drawing it); the last one quits, asking first about unsaved changes
|
||||
// (`WordApp::close_requested`).
|
||||
let closing = ctx.input(|i| i.viewport().close_requested());
|
||||
if closing && !self.0.close_requested() {
|
||||
if closing && (self.0.closes_a_window() || !self.0.close_requested()) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||
} else if (closing || self.0.quit_requested) && !self.0.printing.busy() {
|
||||
// On macOS, letting eframe drop the window inside `-[NSApplication run]` aborts in
|
||||
@@ -85,8 +104,13 @@ impl eframe::App for App {
|
||||
}
|
||||
}
|
||||
}
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.0.raw_input_hook(raw);
|
||||
/// Records this frame's native window state before forwarding the hook, so logic never reads a
|
||||
/// stale inner rectangle from a hidden tick.
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
// Keep this frame's native window state. `logic` must not read the last painted
|
||||
// `InputState` when a hidden Wayland tick reports no fresh inner rect.
|
||||
self.4 = RawWindowState::from_raw(raw);
|
||||
self.0.raw_input_hook(ctx, raw);
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
|
||||
self.0.ui(ui);
|
||||
@@ -305,6 +329,26 @@ fn text_args() -> (Vec<String>, Vec<std::path::PathBuf>) {
|
||||
(args, files)
|
||||
}
|
||||
|
||||
/// The arguments without the ones macOS adds when it launches an app (#1103): `-psn_0_12345`, and
|
||||
/// the `-NS…` / `-Apple…` options (`-AppleLanguages (en)`, `-NSDocumentRevisionsDebugMode YES`) with
|
||||
/// the value after each. They are not files, and opening them as files fails.
|
||||
fn without_launch_args(args: Vec<String>) -> Vec<String> {
|
||||
let mut kept = Vec::with_capacity(args.len());
|
||||
let mut args = args.into_iter().peekable();
|
||||
while let Some(a) = args.next() {
|
||||
if a.starts_with("-psn_") {
|
||||
continue;
|
||||
}
|
||||
if a.starts_with("-NS") || a.starts_with("-Apple") {
|
||||
// Its value, unless the next argument is another flag.
|
||||
args.next_if(|v| !v.starts_with('-'));
|
||||
continue;
|
||||
}
|
||||
kept.push(a);
|
||||
}
|
||||
kept
|
||||
}
|
||||
|
||||
/// Open a file whose name isn't valid Unicode (#1440). `file.open` takes its path as text, so it
|
||||
/// gets the file's bytes under the lossy name, and the document then keeps the real path to save to.
|
||||
/// The bytes are read as `file.open` reads a path: only a regular file, and at most 2 GB.
|
||||
@@ -316,11 +360,21 @@ fn open_non_unicode(app: &mut WordApp, path: &std::path::Path) {
|
||||
// Not when it waits for a password: that open names the document itself.
|
||||
Ok(r) if r.get("pending").is_none() => app.session.path = Some(path.into()),
|
||||
Ok(_) => {}
|
||||
Err(e) => log::warn!("{name}: {e}"),
|
||||
Err(e) => {
|
||||
app.report_open_failure(&name, &e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses the command line, installs logging and the panic hook, restores preferences and window
|
||||
/// geometry, opens requested documents or the Start screen, starts the control server when
|
||||
/// asked, and runs the eframe event loop.
|
||||
fn main() -> eframe::Result {
|
||||
// Windows: the update helper is a copy of this program started with `--finish-update`.
|
||||
#[cfg(windows)]
|
||||
if let Some(result) = craft_update::self_update::finish(&wordcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
|
||||
std::process::exit(i32::from(result.is_err()));
|
||||
}
|
||||
// First, so the panic hook and every start-up warning are recorded (`logging`).
|
||||
let logger = logging::install();
|
||||
logging::install_panic_hook();
|
||||
@@ -328,6 +382,8 @@ fn main() -> eframe::Result {
|
||||
let mut files = Vec::new();
|
||||
let mut sample = false;
|
||||
let (args, other_files) = text_args();
|
||||
// Only macOS (Finder / LaunchServices) adds these; elsewhere `-NS…` could be a real file name.
|
||||
let args = if cfg!(target_os = "macos") { without_launch_args(args) } else { args };
|
||||
let mut bad_control: Option<Option<String>> = None;
|
||||
let mut args = args.into_iter().peekable();
|
||||
while let Some(a) = args.next() {
|
||||
@@ -397,6 +453,8 @@ fn main() -> eframe::Result {
|
||||
#[cfg(target_os = "macos")]
|
||||
graphics::configure_indirect_validation(&mut options, graphics::skips_indirect_validation(true, cfg!(target_arch = "x86_64")));
|
||||
load_user_fonts();
|
||||
// Windows opened for further documents start like the first one, before its saved place.
|
||||
let window_builder = options.viewport.clone();
|
||||
let restored = saved_window();
|
||||
if let Some(window) = restored {
|
||||
options.viewport = window.apply(options.viewport);
|
||||
@@ -429,6 +487,7 @@ fn main() -> eframe::Result {
|
||||
let mut session = Session::new(doc);
|
||||
session.system_region = region;
|
||||
let mut app = WordApp::new(session, services(file_dialog, &cc.egui_ctx));
|
||||
app.set_startup_update_check(true);
|
||||
load_prefs(&mut app);
|
||||
load_user_dictionary(&mut app);
|
||||
// A region chosen in File › Options replaces the system's for the first blank
|
||||
@@ -447,6 +506,7 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
}
|
||||
app.ui.window = restored;
|
||||
app.set_window_builder(window_builder);
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(bar) = traffic_lights::title_bar() {
|
||||
@@ -468,7 +528,7 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
for f in files {
|
||||
if let Err(e) = app.run("file.open", serde_json::json!({"path": f})) {
|
||||
log::warn!("{f}: {e}");
|
||||
app.report_open_failure(&f, &e);
|
||||
}
|
||||
}
|
||||
for f in other_files {
|
||||
@@ -480,6 +540,7 @@ fn main() -> eframe::Result {
|
||||
Some(restored.unwrap_or_default()),
|
||||
key_file,
|
||||
dialogs,
|
||||
RawWindowState::default(),
|
||||
#[cfg(target_os = "macos")]
|
||||
apple_events.connect(&cc.egui_ctx),
|
||||
)))
|
||||
@@ -553,6 +614,33 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod launch_arg_tests {
|
||||
use super::without_launch_args;
|
||||
|
||||
fn strip(args: &[&str]) -> Vec<String> {
|
||||
without_launch_args(args.iter().map(|a| a.to_string()).collect())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_launch_arguments_are_not_files() {
|
||||
assert_eq!(strip(&["-psn_0_12345", "a.docx"]), ["a.docx"]);
|
||||
assert_eq!(strip(&["-AppleLanguages", "(en)", "a.docx"]), ["a.docx"]);
|
||||
assert_eq!(strip(&["a.docx", "-NSDocumentRevisionsDebugMode", "YES", "b.docx"]), ["a.docx", "b.docx"]);
|
||||
// A flag with no value after it, or followed by another flag, takes nothing with it.
|
||||
assert_eq!(strip(&["a.docx", "-AppleLocale"]), ["a.docx"]);
|
||||
assert_eq!(strip(&["-NSFoo", "--sample", "a.docx"]), ["--sample", "a.docx"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_arguments_are_kept() {
|
||||
assert_eq!(strip(&["--control", "7981", "--sample", "-h"]), ["--control", "7981", "--sample", "-h"]);
|
||||
// Files whose names merely start like a launch argument keep their place when they are paths.
|
||||
assert_eq!(strip(&["./-psn_x.docx", "Apple.docx", "-", "-nsfoo"]), ["./-psn_x.docx", "Apple.docx", "-", "-nsfoo"]);
|
||||
assert!(strip(&[]).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod prefs_tests {
|
||||
use super::write_prefs;
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
//! Desktop spooler adapter. Discovery and submission never run on the UI thread.
|
||||
use wordcraft_ui_egui::printing::{Discover, Duplex, Printer, Settings, Submit};
|
||||
|
||||
/// Returns a discovery callback that enumerates spooler printers on a named worker thread and
|
||||
/// sends the list through a channel, keeping spooler calls off the UI thread.
|
||||
pub fn discover() -> Discover {
|
||||
Box::new(|| {
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
let _ = std::thread::Builder::new().name("print-discovery".into()).spawn(move || {
|
||||
let printers = craft_print::printers().into_iter().map(|printer| Printer { name: printer.name, default: printer.default }).collect();
|
||||
let printers = craft_print::printers()
|
||||
.into_iter()
|
||||
.map(|printer| Printer { name: printer.name, label: printer.description, default: printer.default })
|
||||
.collect();
|
||||
let _ = sender.send(printers);
|
||||
});
|
||||
receiver
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -181,3 +181,12 @@ sync_github_name = true
|
||||
|
||||
[people.pakiat]
|
||||
display_name = "OtorO"
|
||||
|
||||
[people.TobyWu666]
|
||||
display_name = "tobywu"
|
||||
|
||||
[people.Bra1nZ]
|
||||
sync_github_name = true
|
||||
|
||||
[people.dylanmtaylor]
|
||||
sync_github_name = true
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
04600cbb1508f1b15927ec574a5e32e5f376fd8b
|
||||
9205108cfdced5177706264038848b68b2422d2b
|
||||
|
||||
@@ -8,9 +8,15 @@ description = "WordCraft document model: stories, paragraphs, runs, tables, sect
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
# Test inputs other crates' tests share (`cfb_guard::repeating_difat_sample`); turned on only as a
|
||||
# dev-dependency, so never built into the app.
|
||||
test-support = []
|
||||
|
||||
[dependencies]
|
||||
wordcraft-geom = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
use super::{Arc, Deserialize, Paragraph, Serialize, Table};
|
||||
|
||||
/// A block in a story. (Blocks are always behind an `Arc`, so the size difference is fine.)
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum Block {
|
||||
Para(Paragraph),
|
||||
Table(Table),
|
||||
}
|
||||
|
||||
impl Block {
|
||||
pub fn as_para(&self) -> Option<&Paragraph> {
|
||||
match self {
|
||||
Block::Para(p) => Some(p),
|
||||
Block::Table(_) => None,
|
||||
}
|
||||
}
|
||||
pub fn as_table(&self) -> Option<&Table> {
|
||||
match self {
|
||||
Block::Table(t) => Some(t),
|
||||
Block::Para(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type Blocks = Vec<Arc<Block>>;
|
||||
|
||||
/// Text boxes inside text boxes count (and show) this many levels deep.
|
||||
pub const MAX_TEXT_BOX_DEPTH: usize = 4;
|
||||
|
||||
/// Bounds the work of expanding text boxes inside text boxes (layout, walks, saving), whatever a
|
||||
/// document says: a box isn't expanded inside itself, nesting stops at [`MAX_TEXT_BOX_DEPTH`], and
|
||||
/// an outermost box expands at most [`BoxBudget::MAX_NESTED`] boxes inside it. (A box whose
|
||||
/// shapes all show that same box, or chains of boxes each showing the next many times, would
|
||||
/// otherwise grow exponentially.) Outermost boxes aren't capped: their number is the document's
|
||||
/// (or its pages', for headers), so the work stays linear and no box is left blank for budget.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct BoxBudget {
|
||||
open: Vec<u32>,
|
||||
nested: usize,
|
||||
total: usize,
|
||||
}
|
||||
|
||||
impl BoxBudget {
|
||||
/// Boxes expanded inside one outermost box.
|
||||
pub const MAX_NESTED: usize = 64;
|
||||
|
||||
/// Start expanding text box story `id`; false if it mustn't be (pair a true with `leave`).
|
||||
pub fn enter(&mut self, id: u32) -> bool {
|
||||
if self.open.contains(&id) || self.open.len() >= MAX_TEXT_BOX_DEPTH {
|
||||
return false;
|
||||
}
|
||||
if self.open.is_empty() {
|
||||
self.nested = 0;
|
||||
} else if self.nested >= Self::MAX_NESTED {
|
||||
return false;
|
||||
} else {
|
||||
self.nested += 1;
|
||||
}
|
||||
self.total += 1;
|
||||
self.open.push(id);
|
||||
true
|
||||
}
|
||||
|
||||
/// Done expanding the box last entered.
|
||||
pub fn leave(&mut self) {
|
||||
self.open.pop();
|
||||
}
|
||||
|
||||
/// Whether a text box is being expanded now.
|
||||
pub fn inside(&self) -> bool {
|
||||
!self.open.is_empty()
|
||||
}
|
||||
|
||||
/// Boxes expanded so far.
|
||||
pub fn total(&self) -> usize {
|
||||
self.total
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
//! A bytes-only check of an OLE compound file ([MS-CFB]) before the `cfb` crate opens it
|
||||
//! (#3642). Every reader of `.doc`, `.xls` and encrypted Office packages calls [`check`] first.
|
||||
|
||||
/// Check a compound file's FAT layout before `cfb` reads it ([MS-CFB] §2.2 header, §2.5
|
||||
/// DIFAT). `cfb` appends a whole sector of FAT entries for every FAT sector the DIFAT lists and
|
||||
/// only validates the FAT once it is built, so a small header naming one sector over and over
|
||||
/// makes it allocate gigabytes. Here the DIFAT is walked the way `cfb` walks it (header entries,
|
||||
/// then the DIFAT chain, at most one step per sector; trailing zero and free entries trimmed
|
||||
/// the same way) and the file is refused when it lists one FAT sector twice, or more FAT sectors
|
||||
/// than its sectors need (one FAT sector covers `sector_len / 4` of them, plus one spare), so the
|
||||
/// FAT `cfb` builds is about the size the file needs. Anything else that is wrong is left for
|
||||
/// `cfb` to report.
|
||||
pub fn check(bytes: &[u8]) -> Result<(), String> {
|
||||
const MAGIC: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
|
||||
const FREE: u32 = 0xFFFF_FFFF;
|
||||
const END_OF_CHAIN: u32 = 0xFFFF_FFFE;
|
||||
const HEADER_ENTRIES: usize = 109;
|
||||
let u16_at = |at: usize| bytes.get(at..at.checked_add(2)?).map(|b| u16::from_le_bytes([b[0], b[1]]));
|
||||
let u32_at = |at: usize| bytes.get(at..at.checked_add(4)?).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]));
|
||||
if bytes.len() < 512 || bytes.get(..8) != Some(MAGIC.as_slice()) {
|
||||
return Ok(());
|
||||
}
|
||||
let sector_len: usize = match u16_at(0x1A) {
|
||||
Some(3) => 512,
|
||||
Some(4) => 4096,
|
||||
_ => return Ok(()),
|
||||
};
|
||||
if bytes.len() < sector_len {
|
||||
return Ok(());
|
||||
}
|
||||
// `cfb` counts the sectors after the header sector, the last one possibly partial.
|
||||
let num_sectors = bytes.len().div_ceil(sector_len) - 1;
|
||||
let num_fat_sectors = u32_at(0x2C).unwrap_or(0) as usize;
|
||||
let mut difat: Vec<u32> = (0..HEADER_ENTRIES).filter_map(|i| u32_at(0x4C + i * 4)).collect();
|
||||
let mut next = u32_at(0x44).unwrap_or(END_OF_CHAIN);
|
||||
let mut steps = 0usize;
|
||||
while next != END_OF_CHAIN && next != FREE {
|
||||
steps += 1;
|
||||
// `cfb` refuses a chain sector past the file or seen before; a chain longer than the
|
||||
// file has sectors must have done one of the two.
|
||||
if steps > num_sectors || next as usize >= num_sectors {
|
||||
return Err(format!("the DIFAT chain is longer than the file or leaves it (sector {next})"));
|
||||
}
|
||||
let at = (next as usize + 1) * sector_len;
|
||||
let entries = sector_len / 4 - 1;
|
||||
difat.extend((0..entries).map(|i| u32_at(at + i * 4).unwrap_or(FREE)));
|
||||
next = u32_at(at + entries * 4).unwrap_or(END_OF_CHAIN);
|
||||
}
|
||||
while difat.len() > HEADER_ENTRIES && difat.len() > num_fat_sectors && difat.last() == Some(&0) {
|
||||
difat.pop();
|
||||
}
|
||||
while difat.last() == Some(&FREE) {
|
||||
difat.pop();
|
||||
}
|
||||
// Each listed FAT sector adds a whole sector of entries. A FAT sector holds the entries of
|
||||
// `sector_len / 4` sectors, so a file needs no more FAT sectors than that covers (one spare),
|
||||
// and none of them twice. A free entry in the middle is left for `cfb` to refuse.
|
||||
let listed: Vec<u32> = difat.into_iter().filter(|&s| s != FREE).collect();
|
||||
let needed = num_sectors.div_ceil(sector_len / 4).saturating_add(1);
|
||||
if listed.len() > needed {
|
||||
return Err(format!("the DIFAT lists {} FAT sectors in a file of {num_sectors} sectors", listed.len()));
|
||||
}
|
||||
let mut seen = std::collections::HashSet::with_capacity(listed.len());
|
||||
if let Some(dup) = listed.iter().find(|&&s| !seen.insert(s)) {
|
||||
return Err(format!("the DIFAT lists FAT sector {dup} twice"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A compound file of about 1 MB whose DIFAT chain lists one FAT sector 2048 × 127 times:
|
||||
/// without [`check`], `cfb` builds a FAT of about 130 MB for it. Regression input for the
|
||||
/// readers' tests: built only for tests, this crate's and (through the `test-support` feature,
|
||||
/// which they turn on as a dev-dependency) the readers', never into the app.
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn repeating_difat_sample() -> Vec<u8> {
|
||||
const S: usize = 512;
|
||||
const CHAIN: u32 = 2048; // DIFAT sectors 1..=CHAIN, sector 0 is the "FAT"
|
||||
let mut f = vec![0u8; S * (CHAIN as usize + 2)];
|
||||
let mut put = |at: usize, v: &[u8]| {
|
||||
if let Some(dst) = f.get_mut(at..at + v.len()) {
|
||||
dst.copy_from_slice(v);
|
||||
}
|
||||
};
|
||||
put(0, &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]);
|
||||
put(0x18, &0x3Eu16.to_le_bytes());
|
||||
put(0x1A, &3u16.to_le_bytes());
|
||||
put(0x1C, &0xFFFEu16.to_le_bytes());
|
||||
put(0x1E, &9u16.to_le_bytes());
|
||||
put(0x20, &6u16.to_le_bytes());
|
||||
put(0x2C, &u32::MAX.to_le_bytes()); // no zero trimming
|
||||
put(0x38, &4096u32.to_le_bytes());
|
||||
put(0x44, &1u32.to_le_bytes());
|
||||
put(0x48, &CHAIN.to_le_bytes());
|
||||
// The 109 header entries are all 0 already; each chain sector's entries too.
|
||||
for d in 1..=CHAIN {
|
||||
let next = if d == CHAIN { 0xFFFF_FFFE } else { d + 1 };
|
||||
put((d as usize + 2) * S - 4, &next.to_le_bytes());
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn a_repeating_difat_is_refused_and_a_small_file_passes() {
|
||||
assert!(super::check(&super::repeating_difat_sample()).is_err_and(|e| e.contains("FAT sectors in a file of")));
|
||||
assert!(super::check(b"not a compound file").is_ok());
|
||||
}
|
||||
|
||||
/// A 512-byte-sector file of `sectors` sectors after the header, whose header DIFAT lists
|
||||
/// `fat` (the rest free).
|
||||
fn header_difat(sectors: usize, fat: &[u32]) -> Vec<u8> {
|
||||
let mut f = vec![0u8; 512 * (sectors + 1)];
|
||||
f[..8].copy_from_slice(&[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]);
|
||||
f[0x1A..0x1C].copy_from_slice(&3u16.to_le_bytes());
|
||||
f[0x2C..0x30].copy_from_slice(&(fat.len() as u32).to_le_bytes());
|
||||
f[0x44..0x48].copy_from_slice(&0xFFFF_FFFEu32.to_le_bytes());
|
||||
for i in 0..109 {
|
||||
let v = fat.get(i).copied().unwrap_or(u32::MAX);
|
||||
f[0x4C + i * 4..0x50 + i * 4].copy_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fat_sector_listed_twice_or_more_than_the_file_needs_is_refused() {
|
||||
// Each list is shorter than the file has sectors, which the guard used to accept.
|
||||
assert!(super::check(&header_difat(10, &[0, 0])).is_err_and(|e| e.contains("FAT sector 0 twice")));
|
||||
assert!(super::check(&header_difat(10, &[0, 1, 2])).is_err_and(|e| e.contains("3 FAT sectors")));
|
||||
assert!(super::check(&header_difat(10, &[0])).is_ok());
|
||||
assert!(super::check(&header_difat(200, &[0, 1])).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -238,7 +238,7 @@ pub(crate) fn ideograph_zodiac_traditional(n: u32) -> String {
|
||||
|
||||
/// The `n`th (1-based) item of a sequence, or decimal past its end.
|
||||
fn nth(set: &[char], n: u32) -> String {
|
||||
n.checked_sub(1).and_then(|i| set.get(i as usize)).map(|c| c.to_string()).unwrap_or_else(|| n.to_string())
|
||||
n.checked_sub(1).and_then(|i| set.get(i as usize)).map(ToString::to_string).unwrap_or_else(|| n.to_string())
|
||||
}
|
||||
|
||||
/// Code points `first..` enclosing 1 to `count`, decimal beyond.
|
||||
|
||||
@@ -64,6 +64,11 @@ pub fn is_cluster_boundary(text: &str, off: usize) -> bool {
|
||||
/// Byte offsets where clusters start (graphemes, joined per [`joins_cluster`]), in order; the
|
||||
/// first is 0 for non-empty text.
|
||||
pub fn cluster_starts(text: &str) -> Vec<usize> {
|
||||
// ASCII: every character starts a grapheme except LF after CR, and none joins (UAX #29).
|
||||
if text.is_ascii() {
|
||||
let b = text.as_bytes();
|
||||
return (0..b.len()).filter(|&i| !(i > 0 && b.get(i - 1) == Some(&b'\r') && b.get(i) == Some(&b'\n'))).collect();
|
||||
}
|
||||
let mut out: Vec<usize> = Vec::new();
|
||||
let mut prev: Option<char> = None;
|
||||
for (i, g) in text.grapheme_indices(true) {
|
||||
@@ -110,12 +115,22 @@ pub fn next_cluster(text: &str, off: usize) -> usize {
|
||||
}
|
||||
}
|
||||
|
||||
/// Scripts whose Backspace removes one character at a time (a vowel sign, a medial, a stacked
|
||||
/// consonant) rather than the whole cluster, as on Windows and macOS: the Brahmic scripts of
|
||||
/// South and South-East Asia.
|
||||
/// Characters Backspace removes individually rather than with their whole cluster: Arabic
|
||||
/// combining marks and characters of the Brahmic scripts of South and South-East Asia.
|
||||
pub fn backspaces_by_char(c: char) -> bool {
|
||||
matches!(u32::from(c),
|
||||
0x0900..=0x0DFF // Devanagari … Sinhala
|
||||
0x0610..=0x061A // Arabic nonspacing marks (Unicode 17.0)
|
||||
| 0x064B..=0x065F
|
||||
| 0x0670
|
||||
| 0x06D6..=0x06DC
|
||||
| 0x06DF..=0x06E4
|
||||
| 0x06E7..=0x06E8
|
||||
| 0x06EA..=0x06ED
|
||||
| 0x0897..=0x089F
|
||||
| 0x08CA..=0x08E1
|
||||
| 0x08E3..=0x08FF
|
||||
| 0x10EFA..=0x10EFF
|
||||
| 0x0900..=0x0DFF // Devanagari … Sinhala
|
||||
| 0x0E00..=0x0EFF // Thai, Lao
|
||||
| 0x0F00..=0x0FFF // Tibetan
|
||||
| 0x1000..=0x109F // Myanmar
|
||||
@@ -188,6 +203,22 @@ mod tests {
|
||||
assert_eq!(split(kinzi, &cluster_starts(kinzi)), vec!["အ", "င်္ဂ", "လိ", "ပ်"]);
|
||||
}
|
||||
|
||||
/// The ASCII shortcut gives the grapheme rules' answer.
|
||||
#[test]
|
||||
fn ascii_cluster_starts_match_the_graphemes() {
|
||||
let graphemes = |t: &str| -> Vec<usize> { t.grapheme_indices(true).map(|(i, _)| i).collect() };
|
||||
let all: String = (0u8..128).map(char::from).collect();
|
||||
for t in ["", "a", "Hello, world.", "\r\n", "a\r\nb\n\rc\r\r\n\n", "\t \u{7f}x\u{0}", all.as_str()] {
|
||||
assert_eq!(cluster_starts(t), graphemes(t), "{t:?}");
|
||||
}
|
||||
for a in 0u8..128 {
|
||||
for b in 0u8..128 {
|
||||
let t = format!("{}{}", char::from(a), char::from(b));
|
||||
assert_eq!(cluster_starts(&t), graphemes(&t), "{t:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn khmer_subscripts_and_indic_conjuncts_are_one_cluster() {
|
||||
// ខ្មែរ: ខ + coeng + ម + ែ, then រ.
|
||||
@@ -229,8 +260,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_by_character_in_brahmic_scripts_only() {
|
||||
fn backspace_by_character_in_brahmic_scripts_and_arabic_marks() {
|
||||
assert!(backspaces_by_char('ေ') && backspaces_by_char('ि') && backspaces_by_char('្'));
|
||||
assert!(backspaces_by_char('\u{064E}') && backspaces_by_char('\u{06E7}') && backspaces_by_char('\u{10EFF}'));
|
||||
assert!(!backspaces_by_char('\u{301}') && !backspaces_by_char('a') && !backspaces_by_char('語'));
|
||||
assert!(!backspaces_by_char('ب') && !backspaces_by_char('\u{06E5}') && !backspaces_by_char('\u{08E2}'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
use super::block::{Block, Blocks};
|
||||
use super::parts::Part;
|
||||
use super::{Arc, BTreeMap, BuildingBlock, Cell, Paragraph, Row, Table};
|
||||
|
||||
pub fn para_block(p: Paragraph) -> Arc<Block> {
|
||||
Arc::new(Block::Para(p))
|
||||
}
|
||||
|
||||
/// The same blocks: the same content. The blocks are compared by address first — the
|
||||
/// document is copy-on-write, so a block that changed was copied and has another address —
|
||||
/// and a block that was copied but holds the same content is compared by value.
|
||||
pub(crate) fn same_blocks(a: &Blocks, b: &Blocks) -> bool {
|
||||
a.len() == b.len() && a.iter().zip(b).all(|(x, y)| Arc::ptr_eq(x, y) || same_block(x, y))
|
||||
}
|
||||
|
||||
/// The same block by value. A table's cells compare their blocks by address first too: an edit in
|
||||
/// one cell copies the whole table, which mustn't mean comparing every cell by value.
|
||||
fn same_block(a: &Block, b: &Block) -> bool {
|
||||
match (a, b) {
|
||||
(Block::Table(x), Block::Table(y)) => same_table(x, y),
|
||||
_ => a == b,
|
||||
}
|
||||
}
|
||||
|
||||
/// The same table. Every field is listed, so a new one does not build until someone decides
|
||||
/// whether a change to it counts.
|
||||
fn same_table(a: &Table, b: &Table) -> bool {
|
||||
let Table { props, grid, rows, controls } = a;
|
||||
let Table { props: other_props, grid: other_grid, rows: other_rows, controls: other_controls } = b;
|
||||
props == other_props
|
||||
&& grid == other_grid
|
||||
&& controls == other_controls
|
||||
&& rows.len() == other_rows.len()
|
||||
&& rows.iter().zip(other_rows).all(|(x, y)| same_row(x, y))
|
||||
}
|
||||
|
||||
fn same_row(a: &Row, b: &Row) -> bool {
|
||||
let Row { props, cells, controls } = a;
|
||||
let Row { props: other_props, cells: other_cells, controls: other_controls } = b;
|
||||
props == other_props
|
||||
&& controls == other_controls
|
||||
&& cells.len() == other_cells.len()
|
||||
&& cells.iter().zip(other_cells).all(|(x, y)| {
|
||||
let Cell { props, blocks, controls } = x;
|
||||
let Cell { props: other_props, blocks: other_blocks, controls: other_controls } = y;
|
||||
props == other_props && controls == other_controls && same_blocks(blocks, other_blocks)
|
||||
})
|
||||
}
|
||||
|
||||
/// The same building blocks, their content compared by address first.
|
||||
pub(crate) fn same_glossary(a: &[BuildingBlock], b: &[BuildingBlock]) -> bool {
|
||||
a.len() == b.len()
|
||||
&& a.iter().zip(b).all(|(x, y)| {
|
||||
let BuildingBlock { name, gallery, category, description, guid, style, types, behaviors, blocks } = x;
|
||||
let BuildingBlock {
|
||||
name: other_name,
|
||||
gallery: other_gallery,
|
||||
category: other_category,
|
||||
description: other_description,
|
||||
guid: other_guid,
|
||||
style: other_style,
|
||||
types: other_types,
|
||||
behaviors: other_behaviors,
|
||||
blocks: other_blocks,
|
||||
} = y;
|
||||
name == other_name
|
||||
&& gallery == other_gallery
|
||||
&& category == other_category
|
||||
&& description == other_description
|
||||
&& guid == other_guid
|
||||
&& style == other_style
|
||||
&& types == other_types
|
||||
&& behaviors == other_behaviors
|
||||
&& same_blocks(blocks, other_blocks)
|
||||
})
|
||||
}
|
||||
|
||||
/// The same parts: the same keys, and each part the same.
|
||||
pub(crate) fn same_parts(a: &BTreeMap<u32, Part>, b: &BTreeMap<u32, Part>) -> bool {
|
||||
a.len() == b.len() && a.iter().zip(b).all(|((k1, p1), (k2, p2))| k1 == k2 && same_part(p1, p2))
|
||||
}
|
||||
|
||||
/// The same part: the same kind, text body and blocks. Every field is listed, so a new one
|
||||
/// does not build until someone decides whether a change to it counts.
|
||||
fn same_part(a: &Part, b: &Part) -> bool {
|
||||
let Part { kind, blocks, body } = a;
|
||||
let Part { kind: other_kind, blocks: other_blocks, body: other_body } = b;
|
||||
kind == other_kind && body == other_body && same_blocks(blocks, other_blocks)
|
||||
}
|
||||
|
||||
/// The same bytes: the same content. The bytes are compared by address first — a changed
|
||||
/// picture was copied, so it has another address — and a picture that was copied but holds
|
||||
/// the same bytes is compared by value.
|
||||
pub(crate) fn same_bytes(a: &BTreeMap<String, Arc<Vec<u8>>>, b: &BTreeMap<String, Arc<Vec<u8>>>) -> bool {
|
||||
a.len() == b.len() && a.iter().zip(b).all(|((k1, v1), (k2, v2))| k1 == k2 && (Arc::ptr_eq(v1, v2) || **v1 == **v2))
|
||||
}
|
||||
+38
-37
@@ -362,6 +362,43 @@ mod tests {
|
||||
assert!(near(corner_radius(Some(10_000), -5.0, 100.0), 0.0));
|
||||
}
|
||||
|
||||
/// One rerouting case: the free end settles and the attached end keeps its site.
|
||||
fn assert_transformed_reroute(kind: ShapeKind, rot: f32, flip_h: bool, flip_v: bool, free: Option<ConnEnd>, free_start: bool) {
|
||||
let attached = Some(ConnEnd { id: 7, site: 3 });
|
||||
let (start, end) = if free_start { (free, attached) } else { (attached, free) };
|
||||
let mut obj = shape(kind, 40.0, 20.0, ShapeExtra { start, end, ..Default::default() });
|
||||
let InlineObject::Shape { float, .. } = &mut obj else { panic!() };
|
||||
float.rot = rot;
|
||||
float.flip_h = flip_h;
|
||||
float.flip_v = flip_v;
|
||||
// Frame (10, 30, 40, 20), centre (30, 40): mirror, then rotate
|
||||
// clockwise in y-down group space. Do not use the routing transform
|
||||
// to compute the expectation.
|
||||
let sign = if free_start { -1.0 } else { 1.0 };
|
||||
let dx = sign * if flip_h { -20.0 } else { 20.0 };
|
||||
let dy = sign * if flip_v { -10.0 } else { 10.0 };
|
||||
let (sin, cos) = rot.to_radians().sin_cos();
|
||||
let expected = (30.0 + dx * cos - dy * sin, 40.0 + dx * sin + dy * cos);
|
||||
let kids = vec![
|
||||
GroupChild { x: 100.0, y: 200.0, obj: shape(ShapeKind::Rectangle, 40.0, 20.0, ShapeExtra { id: 7, ..Default::default() }) },
|
||||
GroupChild { x: 10.0, y: 30.0, obj },
|
||||
];
|
||||
let mut group = group(kids);
|
||||
assert!(reroute_group(&mut group));
|
||||
let InlineObject::Group { children, .. } = &group else { panic!() };
|
||||
let c = &children[1];
|
||||
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() };
|
||||
let (start, end) = ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v);
|
||||
let (free, attached) = if free_start { (start, end) } else { (end, start) };
|
||||
assert!(
|
||||
(free.0 - expected.0).abs() < 0.001 && (free.1 - expected.1).abs() < 0.001,
|
||||
"{kind:?}, {rot}, {flip_h}, {flip_v}, {free_start}: {free:?} != {expected:?}"
|
||||
);
|
||||
assert_eq!(attached, (140.0, 210.0));
|
||||
assert_eq!(float.rot, 0.0);
|
||||
assert!(!reroute_group(&mut group), "rerouting must settle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rerouting_preserves_transformed_free_and_unresolved_ends() {
|
||||
for kind in [ShapeKind::StraightConnector, ShapeKind::ElbowConnector, ShapeKind::CurvedConnector] {
|
||||
@@ -369,43 +406,7 @@ mod tests {
|
||||
for (flip_h, flip_v) in [(false, false), (true, false), (false, true), (true, true)] {
|
||||
for free in [None, Some(ConnEnd { id: 404, site: 0 }), Some(ConnEnd { id: 7, site: 99 })] {
|
||||
for free_start in [false, true] {
|
||||
let attached = Some(ConnEnd { id: 7, site: 3 });
|
||||
let (start, end) = if free_start { (free, attached) } else { (attached, free) };
|
||||
let mut obj = shape(kind, 40.0, 20.0, ShapeExtra { start, end, ..Default::default() });
|
||||
let InlineObject::Shape { float, .. } = &mut obj else { panic!() };
|
||||
float.rot = rot;
|
||||
float.flip_h = flip_h;
|
||||
float.flip_v = flip_v;
|
||||
// Frame (10, 30, 40, 20), centre (30, 40): mirror, then rotate
|
||||
// clockwise in y-down group space. Do not use the routing transform
|
||||
// to compute the expectation.
|
||||
let sign = if free_start { -1.0 } else { 1.0 };
|
||||
let dx = sign * if flip_h { -20.0 } else { 20.0 };
|
||||
let dy = sign * if flip_v { -10.0 } else { 10.0 };
|
||||
let (sin, cos) = rot.to_radians().sin_cos();
|
||||
let expected = (30.0 + dx * cos - dy * sin, 40.0 + dx * sin + dy * cos);
|
||||
let kids = vec![
|
||||
GroupChild {
|
||||
x: 100.0,
|
||||
y: 200.0,
|
||||
obj: shape(ShapeKind::Rectangle, 40.0, 20.0, ShapeExtra { id: 7, ..Default::default() }),
|
||||
},
|
||||
GroupChild { x: 10.0, y: 30.0, obj },
|
||||
];
|
||||
let mut group = group(kids);
|
||||
assert!(reroute_group(&mut group));
|
||||
let InlineObject::Group { children, .. } = &group else { panic!() };
|
||||
let c = &children[1];
|
||||
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() };
|
||||
let (start, end) = ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v);
|
||||
let (free, attached) = if free_start { (start, end) } else { (end, start) };
|
||||
assert!(
|
||||
(free.0 - expected.0).abs() < 0.001 && (free.1 - expected.1).abs() < 0.001,
|
||||
"{kind:?}, {rot}, {flip_h}, {flip_v}, {free_start}: {free:?} != {expected:?}"
|
||||
);
|
||||
assert_eq!(attached, (140.0, 210.0));
|
||||
assert_eq!(float.rot, 0.0);
|
||||
assert!(!reroute_group(&mut group), "rerouting must settle");
|
||||
assert_transformed_reroute(kind, rot, flip_h, flip_v, free, free_start);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+70
-33
@@ -14,6 +14,8 @@
|
||||
//! outside what's deleted (so deleting across a control's edge empties it rather than breaking
|
||||
//! it); writers call [`Document::balance_controls`] so a saved file always has whole controls.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::para::OBJ;
|
||||
@@ -312,10 +314,10 @@ pub fn control_events(bl: &Blocks) -> Vec<ControlEvent<'_>> {
|
||||
if !p.objects.iter().any(|o| matches!(o, InlineObject::ControlStart { .. } | InlineObject::ControlEnd)) {
|
||||
continue;
|
||||
}
|
||||
for off in p.object_offsets() {
|
||||
match p.object_at(off) {
|
||||
Some(InlineObject::ControlStart { control }) => out.push(ControlEvent::Open { block: i, off: Some(off), control }),
|
||||
Some(InlineObject::ControlEnd) => out.push(ControlEvent::Close { block: i, off: Some(off) }),
|
||||
for (off, o) in p.objects_with_offsets() {
|
||||
match o {
|
||||
InlineObject::ControlStart { control } => out.push(ControlEvent::Open { block: i, off: Some(off), control }),
|
||||
InlineObject::ControlEnd => out.push(ControlEvent::Close { block: i, off: Some(off) }),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -420,24 +422,37 @@ fn scan(story: StoryRef, bl: &Blocks, prefix: &mut Vec<u32>, depth: usize, out:
|
||||
}
|
||||
|
||||
/// Positions of the markers without a partner in a block list and the cells inside it.
|
||||
fn orphans(bl: &Blocks, prefix: &mut Vec<u32>, depth: usize, out: &mut Vec<(Path, usize)>, tables: &mut Vec<(Path, usize, bool)>) {
|
||||
pub(crate) fn orphans(bl: &Blocks, prefix: &mut Vec<u32>, depth: usize, out: &mut Vec<(Path, usize)>, tables: &mut Vec<(Path, usize, bool)>) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
let events = control_events(bl);
|
||||
let (_, lone) = pair_events(&events);
|
||||
for k in lone {
|
||||
let Some(e) = events.get(k) else { continue };
|
||||
let mut path = prefix.clone();
|
||||
path.push(u32::try_from(e.block()).unwrap_or(u32::MAX));
|
||||
match (e, e.off()) {
|
||||
(_, Some(off)) => out.push((Path(path), off)),
|
||||
(ControlEvent::Open { .. }, None) => {
|
||||
// Which of the table's opens: count those before it on the same table.
|
||||
let nth = events.iter().take(k).filter(|x| matches!(x, ControlEvent::Open { block, off: None, .. } if *block == e.block())).count();
|
||||
tables.push((Path(path), nth, true));
|
||||
if !lone.is_empty() {
|
||||
let mut table_counts: HashMap<usize, usize> = HashMap::new();
|
||||
let mut open_nth = Vec::with_capacity(events.len());
|
||||
for e in &events {
|
||||
if let ControlEvent::Open { block, off: None, .. } = e {
|
||||
let count = table_counts.entry(*block).or_insert(0);
|
||||
open_nth.push(*count);
|
||||
*count += 1;
|
||||
} else {
|
||||
open_nth.push(0);
|
||||
}
|
||||
}
|
||||
for k in lone {
|
||||
let Some(e) = events.get(k) else { continue };
|
||||
let mut path = prefix.clone();
|
||||
path.push(u32::try_from(e.block()).unwrap_or(u32::MAX));
|
||||
match (e, e.off()) {
|
||||
(_, Some(off)) => out.push((Path(path), off)),
|
||||
(ControlEvent::Open { .. }, None) => {
|
||||
// Which of the table's opens: index among table opens on the same table.
|
||||
let nth = open_nth.get(k).copied().unwrap_or(0);
|
||||
tables.push((Path(path), nth, true));
|
||||
}
|
||||
(ControlEvent::Close { .. }, None) => tables.push((Path(path), 0, false)),
|
||||
}
|
||||
(ControlEvent::Close { .. }, None) => tables.push((Path(path), 0, false)),
|
||||
}
|
||||
}
|
||||
for (i, b) in bl.iter().enumerate() {
|
||||
@@ -639,34 +654,52 @@ impl Document {
|
||||
out.push((r.end.path.clone(), r.end.off));
|
||||
}
|
||||
}
|
||||
// Markers paired with a table wrap: their partner is never deleted with them.
|
||||
// Markers paired with a table wrap: their partner is never deleted with them. Each lookup
|
||||
// is a set probe, and each container is paired once, not once per marker (#3614).
|
||||
let in_ranges: BTreeSet<(&Path, usize)> = ranges.iter().flat_map(|r| [(&r.start.path, r.start.off), (&r.end.path, r.end.off)]).collect();
|
||||
let mut table_paired: BTreeMap<&[u32], BTreeSet<(usize, usize)>> = BTreeMap::new();
|
||||
for path in &paths {
|
||||
let Some(p) = self.para(a.story, path) else { continue };
|
||||
for off in p.object_offsets() {
|
||||
if !inside(path, off) || !matches!(p.object_at(off), Some(InlineObject::ControlStart { .. } | InlineObject::ControlEnd)) {
|
||||
for (off, o) in p.objects_with_offsets() {
|
||||
if !inside(path, off)
|
||||
|| !matches!(o, InlineObject::ControlStart { .. } | InlineObject::ControlEnd)
|
||||
|| in_ranges.contains(&(path, off))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let paired = ranges.iter().any(|r| (r.start.path == *path && r.start.off == off) || (r.end.path == *path && r.end.off == off));
|
||||
if !paired && self.paired_with_table(a.story, path, off) {
|
||||
let paired = table_paired.entry(path.parent()).or_insert_with(|| self.table_paired_markers(a.story, path));
|
||||
if paired.contains(&(path.last(), off)) {
|
||||
out.push((path.clone(), off));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sorted and without repeats: `delete_range` groups this by paragraph and binary-searches
|
||||
// each group (`control_markers_kept_are_sorted` pins it).
|
||||
out.sort();
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether the marker at `path`/`off` is paired with a table wrap's open or close.
|
||||
fn paired_with_table(&self, s: StoryRef, path: &Path, off: usize) -> bool {
|
||||
let Some(bl) = self.container(s, path) else { return false };
|
||||
/// (block, offset) of the paragraph markers in `path`'s container that are paired with a
|
||||
/// table wrap's open or close.
|
||||
fn table_paired_markers(&self, s: StoryRef, path: &Path) -> BTreeSet<(usize, usize)> {
|
||||
let Some(bl) = self.container(s, path) else { return BTreeSet::new() };
|
||||
let events = control_events(bl);
|
||||
let (pairs, _) = pair_events(&events);
|
||||
let me = events.iter().position(|e| e.block() == path.last() && e.off() == Some(off));
|
||||
let Some(me) = me else { return false };
|
||||
pairs.iter().any(|(o, c, _)| {
|
||||
(*o == me && events.get(*c).is_some_and(|e| e.off().is_none())) || (*c == me && events.get(*o).is_some_and(|e| e.off().is_none()))
|
||||
})
|
||||
let mut out = BTreeSet::new();
|
||||
for (o, c, _) in pairs {
|
||||
let (Some(open), Some(close)) = (events.get(o), events.get(c)) else { continue };
|
||||
match (open.off(), close.off()) {
|
||||
(Some(off), None) => {
|
||||
out.insert((open.block(), off));
|
||||
}
|
||||
(None, Some(off)) => {
|
||||
out.insert((close.block(), off));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Where typing at `p` should go: a caret at the start of a paragraph, before the start of
|
||||
@@ -674,8 +707,11 @@ impl Document {
|
||||
/// end, goes inside it too (the control is around the whole paragraph).
|
||||
pub fn typing_pos(&self, p: &Pos) -> Pos {
|
||||
let Some(para) = self.para_at(p) else { return p.clone() };
|
||||
let is_start = |off: usize| matches!(para.object_at(off), Some(InlineObject::ControlStart { control }) if control.block);
|
||||
let is_end = |off: usize| matches!(para.object_at(off), Some(InlineObject::ControlEnd));
|
||||
// The objects once, looked up by offset (object_at per step rescans the text).
|
||||
let objs: Vec<(usize, &InlineObject)> = para.objects_with_offsets().collect();
|
||||
let obj = |off: usize| objs.binary_search_by_key(&off, |(o, _)| *o).ok().and_then(|k| objs.get(k)).map(|(_, o)| *o);
|
||||
let is_start = |off: usize| matches!(obj(off), Some(InlineObject::ControlStart { control }) if control.block);
|
||||
let is_end = |off: usize| matches!(obj(off), Some(InlineObject::ControlEnd));
|
||||
let w = OBJ.len_utf8();
|
||||
let mut off = p.off;
|
||||
// Only block starts before the caret (or none) and block starts right after it.
|
||||
@@ -688,8 +724,9 @@ impl Document {
|
||||
}
|
||||
if p.off == para.len() && off >= w && is_end(off - w) {
|
||||
// Step back over the trailing ends that close block-level controls.
|
||||
let ranges = self.control_ranges(p.story);
|
||||
while off >= w && is_end(off - w) && ranges.iter().any(|r| r.end.path == p.path && r.end.off == off - w && r.control.block) {
|
||||
let block_ends: BTreeSet<usize> =
|
||||
self.control_ranges(p.story).into_iter().filter(|r| r.end.path == p.path && r.control.block).map(|r| r.end.off).collect();
|
||||
while off >= w && is_end(off - w) && block_ends.contains(&(off - w)) {
|
||||
off -= w;
|
||||
}
|
||||
return Pos { off, ..p.clone() };
|
||||
|
||||
@@ -0,0 +1,866 @@
|
||||
use super::block::{Block, Blocks, BoxBudget};
|
||||
use super::compare::{para_block, same_blocks, same_bytes, same_glossary, same_parts};
|
||||
use super::error::{DocError, Result};
|
||||
use super::parts::{MAX_LINKED_BOXES, NoteSeparators, Part, PartKind, PermRange, TextBody, free_id};
|
||||
use super::position::{Path, Pos, StoryRef};
|
||||
use super::settings::{CoreProps, CustomProp, Settings};
|
||||
use super::text_walk::{TextMode, notes_and_boxes, story_word_count_of, text_of, walk};
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::{BTreeMap, CharProps, Document, InlineObject, Numbering, Paragraph, SectionProps, StyleSheet, Table, edit, numbering, para, section};
|
||||
|
||||
/// [`Document::media`] by content, so adding media is linear in the bytes added rather than a
|
||||
/// scan of every earlier image (CWE-407). Rebuilt whenever `media` changed size behind its back;
|
||||
/// a hit is always checked against `media`, so a stale entry can at worst miss a duplicate.
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct MediaIndex {
|
||||
by_bytes: std::collections::HashMap<Arc<Vec<u8>>, String>,
|
||||
/// `media.len()` when the index last matched it.
|
||||
len: usize,
|
||||
/// The number after the last generated `image{n}` key, so taken names are probed once.
|
||||
next: usize,
|
||||
}
|
||||
|
||||
impl MediaIndex {
|
||||
fn rebuild(&mut self, media: &BTreeMap<String, Arc<Vec<u8>>>) {
|
||||
self.by_bytes.clear();
|
||||
// The first key (in key order) of equal bytes wins, as a scan would find it.
|
||||
for (k, v) in media.iter().rev() {
|
||||
self.by_bytes.insert(v.clone(), k.clone());
|
||||
}
|
||||
self.len = media.len();
|
||||
self.next = 0;
|
||||
}
|
||||
|
||||
fn find(&self, media: &BTreeMap<String, Arc<Vec<u8>>>, bytes: &Vec<u8>) -> Option<String> {
|
||||
self.by_bytes.get(bytes).filter(|k| media.get(*k).is_some_and(|v| **v == *bytes)).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for MediaIndex {
|
||||
fn eq(&self, _: &Self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for MediaIndex {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("MediaIndex")
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Document {
|
||||
fn default() -> Self {
|
||||
Document::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// A blank document: one empty Normal paragraph, Letter, built-in styles.
|
||||
pub fn new() -> Self {
|
||||
Document {
|
||||
body: vec![para_block(Paragraph::new())],
|
||||
last_section: SectionProps::default(),
|
||||
parts: BTreeMap::new(),
|
||||
styles: StyleSheet::builtin(),
|
||||
numbering: Numbering::default(),
|
||||
comments: BTreeMap::new(),
|
||||
revisions: Vec::new(),
|
||||
settings: Settings::default(),
|
||||
core: CoreProps::default(),
|
||||
sources: Vec::new(),
|
||||
custom_props: Vec::new(),
|
||||
footnote_separators: NoteSeparators::default(),
|
||||
endnote_separators: NoteSeparators::default(),
|
||||
media: BTreeMap::new(),
|
||||
passthrough: BTreeMap::new(),
|
||||
glossary: Vec::new(),
|
||||
media_index: MediaIndex::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A document from plain text, one paragraph per line.
|
||||
pub fn from_text(text: &str) -> Self {
|
||||
let mut d = Document::new();
|
||||
d.body = text.split('\n').map(|l| para_block(Paragraph::with_text(l.trim_end_matches('\r'), CharProps::default()))).collect();
|
||||
d.ensure_nonempty();
|
||||
d
|
||||
}
|
||||
|
||||
fn fix_cell_blocks(bl: &mut Blocks) {
|
||||
if bl.is_empty() || matches!(bl.last().map(|b| &**b), Some(Block::Table(_))) {
|
||||
bl.push(para_block(Paragraph::new()));
|
||||
}
|
||||
}
|
||||
|
||||
fn fix_story_blocks(bl: &mut Blocks) {
|
||||
if !bl.iter().any(|b| matches!(**b, Block::Para(_))) || matches!(bl.last().map(|b| &**b), Some(Block::Table(_))) {
|
||||
bl.push(para_block(Paragraph::new()));
|
||||
}
|
||||
for b in bl.iter_mut() {
|
||||
if let Block::Table(t) = &**b {
|
||||
let needs = t
|
||||
.rows
|
||||
.iter()
|
||||
.any(|r| r.cells.iter().any(|c| c.blocks.is_empty() || matches!(c.blocks.last().map(|b| &**b), Some(Block::Table(_)))));
|
||||
if needs && let Block::Table(t) = Arc::make_mut(b) {
|
||||
for r in &mut t.rows {
|
||||
for c in &mut r.cells {
|
||||
Self::fix_cell_blocks(&mut c.blocks);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every story must hold at least one paragraph, and every cell too.
|
||||
pub fn ensure_nonempty(&mut self) {
|
||||
Self::fix_story_blocks(&mut self.body);
|
||||
for p in self.parts.values_mut() {
|
||||
if p.blocks.is_empty() {
|
||||
p.blocks.push(para_block(Paragraph::new()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the document holds the same content as `other`: the same blocks, the same
|
||||
/// styles, lists, settings, comments and the rest.
|
||||
///
|
||||
/// The blocks are compared by address first: the document is copy-on-write, so a block
|
||||
/// that changed was copied and has another address. Comparing them by value would walk
|
||||
/// the whole document, and this is asked after every command.
|
||||
///
|
||||
/// Every field is listed, so a new one does not build until someone decides whether a
|
||||
/// change to it counts (the same rule the session's edit snapshot follows).
|
||||
pub fn same_content(&self, other: &Document) -> bool {
|
||||
let Document {
|
||||
body,
|
||||
last_section,
|
||||
parts,
|
||||
styles,
|
||||
numbering,
|
||||
comments,
|
||||
revisions,
|
||||
settings,
|
||||
core,
|
||||
sources,
|
||||
custom_props,
|
||||
footnote_separators,
|
||||
endnote_separators,
|
||||
media,
|
||||
passthrough,
|
||||
glossary,
|
||||
media_index: _,
|
||||
} = self;
|
||||
let Document {
|
||||
body: other_body,
|
||||
last_section: other_section,
|
||||
parts: other_parts,
|
||||
styles: other_styles,
|
||||
numbering: other_numbering,
|
||||
comments: other_comments,
|
||||
revisions: other_revisions,
|
||||
settings: other_settings,
|
||||
core: other_core,
|
||||
sources: other_sources,
|
||||
custom_props: other_props,
|
||||
footnote_separators: other_footnotes,
|
||||
endnote_separators: other_endnotes,
|
||||
media: other_media,
|
||||
passthrough: other_passthrough,
|
||||
glossary: other_glossary,
|
||||
media_index: _,
|
||||
} = other;
|
||||
same_blocks(body, other_body)
|
||||
&& same_parts(parts, other_parts)
|
||||
&& last_section == other_section
|
||||
&& styles == other_styles
|
||||
&& numbering == other_numbering
|
||||
&& comments == other_comments
|
||||
&& revisions == other_revisions
|
||||
&& settings == other_settings
|
||||
&& core == other_core
|
||||
&& sources == other_sources
|
||||
&& custom_props == other_props
|
||||
&& footnote_separators == other_footnotes
|
||||
&& endnote_separators == other_endnotes
|
||||
&& same_bytes(media, other_media)
|
||||
&& same_bytes(passthrough, other_passthrough)
|
||||
&& same_glossary(glossary, other_glossary)
|
||||
}
|
||||
|
||||
pub fn story(&self, s: StoryRef) -> Option<&Blocks> {
|
||||
match s {
|
||||
StoryRef::Body => Some(&self.body),
|
||||
StoryRef::Part(id) => self.parts.get(&id).map(|p| &p.blocks),
|
||||
}
|
||||
}
|
||||
pub fn story_mut(&mut self, s: StoryRef) -> Result<&mut Blocks> {
|
||||
match s {
|
||||
StoryRef::Body => Ok(&mut self.body),
|
||||
StoryRef::Part(id) => self.parts.get_mut(&id).map(|p| &mut p.blocks).ok_or(DocError::BadStory(s)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The block list that contains the block at `path`.
|
||||
pub fn container(&self, s: StoryRef, path: &Path) -> Option<&Blocks> {
|
||||
let mut bl = self.story(s)?;
|
||||
let parent = path.parent();
|
||||
if !parent.len().is_multiple_of(3) {
|
||||
return None;
|
||||
}
|
||||
for ch in parent.chunks(3) {
|
||||
let [b, r, c] = ch else { return None };
|
||||
let Block::Table(t) = &**bl.get(*b as usize)? else { return None };
|
||||
bl = &t.rows.get(*r as usize)?.cells.get(*c as usize)?.blocks;
|
||||
}
|
||||
Some(bl)
|
||||
}
|
||||
|
||||
pub fn container_mut(&mut self, s: StoryRef, path: &Path) -> Result<&mut Blocks> {
|
||||
let bad = || DocError::BadPath(path.to_string());
|
||||
let parent: Vec<u32> = path.parent().to_vec();
|
||||
if !parent.len().is_multiple_of(3) {
|
||||
return Err(bad());
|
||||
}
|
||||
let mut bl = self.story_mut(s)?;
|
||||
for ch in parent.chunks(3) {
|
||||
let [b, r, c] = ch else { return Err(bad()) };
|
||||
let blk = bl.get_mut(*b as usize).ok_or_else(bad)?;
|
||||
let Block::Table(t) = Arc::make_mut(blk) else { return Err(bad()) };
|
||||
bl = &mut t.rows.get_mut(*r as usize).ok_or_else(bad)?.cells.get_mut(*c as usize).ok_or_else(bad)?.blocks;
|
||||
}
|
||||
Ok(bl)
|
||||
}
|
||||
|
||||
pub fn block(&self, s: StoryRef, path: &Path) -> Option<&Block> {
|
||||
self.container(s, path)?.get(path.last()).map(|b| &**b)
|
||||
}
|
||||
|
||||
pub fn para(&self, s: StoryRef, path: &Path) -> Option<&Paragraph> {
|
||||
self.block(s, path)?.as_para()
|
||||
}
|
||||
pub fn para_at(&self, p: &Pos) -> Option<&Paragraph> {
|
||||
self.para(p.story, &p.path)
|
||||
}
|
||||
|
||||
pub fn para_mut(&mut self, s: StoryRef, path: &Path) -> Result<&mut Paragraph> {
|
||||
let i = path.last();
|
||||
let bl = self.container_mut(s, path)?;
|
||||
match bl.get_mut(i).map(Arc::make_mut) {
|
||||
Some(Block::Para(p)) => Ok(p),
|
||||
_ => Err(DocError::BadPath(path.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn table(&self, s: StoryRef, path: &Path) -> Option<&Table> {
|
||||
self.block(s, path)?.as_table()
|
||||
}
|
||||
pub fn table_mut(&mut self, s: StoryRef, path: &Path) -> Result<&mut Table> {
|
||||
let i = path.last();
|
||||
let bl = self.container_mut(s, path)?;
|
||||
match bl.get_mut(i).map(Arc::make_mut) {
|
||||
Some(Block::Table(t)) => Ok(t),
|
||||
_ => Err(DocError::BadPath(path.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Paths of every paragraph in a story, in document order (tables descended, depth-limited).
|
||||
pub fn para_paths(&self, s: StoryRef) -> Vec<Path> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(bl) = self.story(s) {
|
||||
walk(bl, &mut Vec::new(), &mut out, 0);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// First and last positions of a story.
|
||||
pub fn start_of(&self, s: StoryRef) -> Pos {
|
||||
let path = self.para_paths(s).into_iter().next().unwrap_or_else(|| Path::top(0));
|
||||
Pos { story: s, path, off: 0 }
|
||||
}
|
||||
pub fn end_of(&self, s: StoryRef) -> Pos {
|
||||
let path = self.para_paths(s).pop().unwrap_or_else(|| Path::top(0));
|
||||
let off = self.para(s, &path).map(|p| p.len()).unwrap_or(0);
|
||||
Pos { story: s, path, off }
|
||||
}
|
||||
|
||||
/// The position just after the shape that owns text box story `part` (body first, then the
|
||||
/// other parts), for leaving the text box.
|
||||
pub fn text_box_anchor(&self, part: u32) -> Option<Pos> {
|
||||
let stories = std::iter::once(StoryRef::Body).chain(self.parts.keys().filter(|k| **k != part).map(|k| StoryRef::Part(*k)));
|
||||
for story in stories {
|
||||
for path in self.para_paths(story) {
|
||||
let Some(p) = self.para(story, &path) else { continue };
|
||||
for (off, o) in p.objects_with_offsets() {
|
||||
if o.text_boxes().contains(&part) {
|
||||
return Some(Pos { story, path, off: off + para::OBJ.len_utf8() });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The next / previous paragraph path in document order.
|
||||
pub fn next_para(&self, s: StoryRef, path: &Path) -> Option<Path> {
|
||||
let all = self.para_paths(s);
|
||||
let i = all.binary_search(path).ok()?;
|
||||
all.get(i + 1).cloned()
|
||||
}
|
||||
pub fn prev_para(&self, s: StoryRef, path: &Path) -> Option<Path> {
|
||||
let all = self.para_paths(s);
|
||||
let i = all.binary_search(path).ok()?;
|
||||
i.checked_sub(1).and_then(|j| all.get(j).cloned())
|
||||
}
|
||||
|
||||
/// Clamp a position into the document (valid path, char boundary).
|
||||
pub fn clamp(&self, p: &Pos) -> Pos {
|
||||
if let Some(para) = self.para_at(p) {
|
||||
return Pos { story: p.story, path: p.path.clone(), off: para.clamp(p.off) };
|
||||
}
|
||||
let story = if self.story(p.story).is_some() { p.story } else { StoryRef::Body };
|
||||
let all = self.para_paths(story);
|
||||
let path = all.iter().rev().find(|q| **q <= p.path).or(all.first()).cloned().unwrap_or_else(|| Path::top(0));
|
||||
let off = self.para(story, &path).map(|q| q.clamp(p.off)).unwrap_or(0);
|
||||
Pos { story, path, off }
|
||||
}
|
||||
|
||||
/// The whole text of a story, paragraphs separated by `\n` (tables: cells by `\t`).
|
||||
pub fn plain_text(&self, s: StoryRef) -> String {
|
||||
self.story_text(s, TextMode::Plain)
|
||||
}
|
||||
|
||||
/// [`Self::plain_text`] as it reads on screen: symbol-font characters (Wingdings…) as their
|
||||
/// Unicode equivalents ([`Paragraph::shown_text`]).
|
||||
pub fn shown_text(&self, s: StoryRef) -> String {
|
||||
self.story_text(s, TextMode::Shown)
|
||||
}
|
||||
|
||||
/// [`Self::plain_text`] without tracked-deleted runs. A deleted paragraph mark still yields `\n`.
|
||||
pub fn final_text(&self, s: StoryRef) -> String {
|
||||
self.story_text(s, TextMode::Final)
|
||||
}
|
||||
|
||||
fn story_text(&self, s: StoryRef, mode: TextMode) -> String {
|
||||
let mut out = String::new();
|
||||
if let Some(bl) = self.story(s) {
|
||||
text_of(bl, &mut out, 0, mode);
|
||||
}
|
||||
if out.ends_with('\n') {
|
||||
out.pop();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Section properties in effect for each top-level body block (index → section number), and
|
||||
/// the list of sections.
|
||||
pub fn sections(&self) -> Vec<(usize, &SectionProps)> {
|
||||
let mut v = Vec::new();
|
||||
for (i, b) in self.body.iter().enumerate() {
|
||||
if let Block::Para(p) = &**b
|
||||
&& let Some(s) = &p.section
|
||||
{
|
||||
v.push((i, &**s));
|
||||
}
|
||||
}
|
||||
v.push((self.body.len().saturating_sub(1), &self.last_section));
|
||||
v
|
||||
}
|
||||
|
||||
/// The footnote (`endnote` false) or endnote options in effect in section `sect`: its own,
|
||||
/// else the document's, else Word's defaults (footnotes at the page bottom in 1, 2, 3;
|
||||
/// endnotes at the end of the document in i, ii, iii; numbered on through the document).
|
||||
/// Placements that don't apply to the kind fall back to its default.
|
||||
pub fn note_options(&self, sect: &SectionProps, endnote: bool) -> section::NoteOptions {
|
||||
use section::NotePos;
|
||||
let (own, doc, fmt) = if endnote {
|
||||
(§.endnote_pr, &self.settings.endnote_pr, self.settings.endnote_format)
|
||||
} else {
|
||||
(§.footnote_pr, &self.settings.footnote_pr, self.settings.footnote_format)
|
||||
};
|
||||
let pos = own.pos.or(doc.pos);
|
||||
let pos = if endnote {
|
||||
pos.filter(|p| matches!(p, NotePos::SectEnd | NotePos::DocEnd)).unwrap_or(NotePos::DocEnd)
|
||||
} else {
|
||||
pos.filter(|p| matches!(p, NotePos::PageBottom | NotePos::BeneathText)).unwrap_or(NotePos::PageBottom)
|
||||
};
|
||||
section::NoteOptions {
|
||||
pos,
|
||||
num_fmt: own.num_fmt.unwrap_or(fmt),
|
||||
num_start: own.num_start.or(doc.num_start).unwrap_or(1).clamp(1, section::MAX_NOTE_START),
|
||||
num_restart: own.num_restart.or(doc.num_restart).unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The section that contains top-level body block `block` (index into `sections()`).
|
||||
pub fn section_index_of(&self, block: usize) -> usize {
|
||||
self.sections().iter().position(|(end, _)| block <= *end).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Mutable section properties for the section containing body block `block`.
|
||||
pub fn section_mut(&mut self, block: usize) -> &mut SectionProps {
|
||||
let ends: Vec<usize> = self.sections().iter().map(|(e, _)| *e).collect();
|
||||
let idx = ends.iter().position(|e| block <= *e).unwrap_or(ends.len().saturating_sub(1));
|
||||
if idx + 1 < ends.len()
|
||||
&& let Some(e) = ends.get(idx)
|
||||
&& let Some(Block::Para(p)) = self.body.get_mut(*e).map(Arc::make_mut)
|
||||
&& let Some(s) = p.section.as_mut()
|
||||
{
|
||||
return s;
|
||||
}
|
||||
&mut self.last_section
|
||||
}
|
||||
|
||||
/// Remove the section break on the mark of top-level body paragraph `block`: its section
|
||||
/// joins the following one ([`SectionProps::absorb`]). False when there is no break there.
|
||||
pub fn remove_section_break(&mut self, block: usize) -> bool {
|
||||
if !matches!(self.body.get(block).map(|b| &**b), Some(Block::Para(p)) if p.section.is_some()) {
|
||||
return false;
|
||||
}
|
||||
let Some(Block::Para(p)) = self.body.get_mut(block).map(Arc::make_mut) else { return false };
|
||||
let Some(before) = p.section.take() else { return false };
|
||||
self.section_mut(block).absorb(&before);
|
||||
true
|
||||
}
|
||||
|
||||
/// Allocate a new part id and insert the part. Part ids run out only past `u32::MAX` parts, more
|
||||
/// than memory holds; then nothing is added and the id is 0, which [`free_id`] never gives a part.
|
||||
pub fn add_part(&mut self, kind: PartKind, blocks: Blocks) -> u32 {
|
||||
self.try_add_part(kind, blocks).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// [`Document::add_part`], or an error, adding nothing, when every part id is taken.
|
||||
pub fn try_add_part(&mut self, kind: PartKind, blocks: Blocks) -> Result<u32> {
|
||||
let id = free_id(self.parts.keys().next_back().copied(), self.parts.keys().copied(), 1)
|
||||
.ok_or_else(|| DocError::Invalid("no free part id".into()))?;
|
||||
let mut part = Part { kind, blocks, body: TextBody::default() };
|
||||
if part.blocks.is_empty() {
|
||||
part.blocks.push(para_block(Paragraph::new()));
|
||||
}
|
||||
self.parts.insert(id, part);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Count words like Word's status bar (whitespace-separated tokens containing a letter or digit,
|
||||
/// including generated numbered-list labels).
|
||||
pub fn word_count(&self) -> usize {
|
||||
self.story_word_count(StoryRef::Body)
|
||||
}
|
||||
|
||||
/// Word's default count: the body plus its text boxes, footnotes and endnotes.
|
||||
pub fn word_count_including_notes(&self) -> usize {
|
||||
self.counted_stories().iter().map(|s| self.story_word_count(*s)).sum()
|
||||
}
|
||||
|
||||
/// Word count of one story, including generated numbered-list labels.
|
||||
pub fn story_word_count(&self, s: StoryRef) -> usize {
|
||||
let Some(bl) = self.story(s) else { return 0 };
|
||||
let mut counters = numbering::Counters::default();
|
||||
story_word_count_of(bl, &self.numbering, &self.styles, &mut counters, 0)
|
||||
}
|
||||
|
||||
/// The body, then the text boxes, footnotes and endnotes it shows (nested ones too), in
|
||||
/// document order. Headers, footers, comments and stories nothing points at aren't included.
|
||||
pub fn counted_stories(&self) -> Vec<StoryRef> {
|
||||
self.reachable(vec![StoryRef::Body], ¬es_and_boxes)
|
||||
}
|
||||
|
||||
/// The stories a reader sees, in reading order: the body, then the headers and footers in
|
||||
/// use, each followed by the notes and text boxes it shows. Hidden stories (a removed header,
|
||||
/// a first-page or even-page header whose option is off, a linked text box's own text) and
|
||||
/// comment text aren't included.
|
||||
pub fn shown_stories(&self) -> Vec<StoryRef> {
|
||||
let followers: std::collections::BTreeSet<u32> = self.text_box_chains().into_iter().flat_map(|c| c.into_iter().skip(1)).collect();
|
||||
let follow = |o: &InlineObject| {
|
||||
let mut v = notes_and_boxes(o);
|
||||
v.retain(|(id, _)| !followers.contains(id));
|
||||
v
|
||||
};
|
||||
let mut out = self.reachable(vec![StoryRef::Body], &follow);
|
||||
let mut seen: std::collections::BTreeSet<StoryRef> = out.iter().copied().collect();
|
||||
let mut roots = Vec::new();
|
||||
let mut root_seen = std::collections::BTreeSet::new();
|
||||
let (mut headers, mut footers) = (section::HeaderSet::default(), section::HeaderSet::default());
|
||||
for (_, sec) in self.sections() {
|
||||
// Unset versions are inherited from the previous section.
|
||||
for (set, own) in [(&mut headers, &sec.headers), (&mut footers, &sec.footers)] {
|
||||
set.default = own.default.or(set.default);
|
||||
set.first = own.first.or(set.first);
|
||||
set.even = own.even.or(set.even);
|
||||
let first = set.first.filter(|_| sec.title_page);
|
||||
let even = set.even.filter(|_| self.settings.even_odd_headers);
|
||||
// By the first page each one shows on: page 1, then page 2 (even), then page 3.
|
||||
let order = if sec.title_page { [first, even, set.default] } else { [set.default, even, None] };
|
||||
for id in order.into_iter().flatten() {
|
||||
if self.parts.get(&id).is_some_and(|p| matches!(p.kind, PartKind::Header | PartKind::Footer)) && root_seen.insert(id) {
|
||||
roots.push(StoryRef::Part(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for st in self.reachable(roots, &follow) {
|
||||
if seen.insert(st) {
|
||||
out.push(st);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Drop the text box stories no box shows any more (a deleted or cut box's text), nested
|
||||
/// ones too. Undo snapshots and the clipboard keep their own copies. Free for a document
|
||||
/// without text boxes; otherwise one pass over its paragraphs. Returns how many went.
|
||||
pub fn prune_text_boxes(&mut self) -> usize {
|
||||
if !self.parts.values().any(|p| p.kind == PartKind::TextBox) {
|
||||
return 0;
|
||||
}
|
||||
let roots = std::iter::once(StoryRef::Body)
|
||||
.chain(self.parts.iter().filter(|(_, p)| p.kind != PartKind::TextBox).map(|(id, _)| StoryRef::Part(*id)))
|
||||
.collect();
|
||||
let live = self.reachable(roots, &|o| o.text_boxes().into_iter().map(|id| (id, &[PartKind::TextBox][..])).collect());
|
||||
let before = self.parts.len();
|
||||
self.parts.retain(|id, p| p.kind != PartKind::TextBox || live.contains(&StoryRef::Part(*id)));
|
||||
let gone = before - self.parts.len();
|
||||
if gone > 0 {
|
||||
// A link to a box that went ends the chain there (its id may be reused).
|
||||
let ids: std::collections::BTreeSet<u32> = self.parts.keys().copied().collect();
|
||||
for p in self.parts.values_mut() {
|
||||
if p.body.next.is_some_and(|n| !ids.contains(&n)) {
|
||||
p.body.next = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
gone
|
||||
}
|
||||
|
||||
/// The chains of linked text boxes (Create Link), each in order from the box holding the
|
||||
/// text: every chain has two or more boxes. A link to a part that isn't a text box, a box
|
||||
/// already linked from another, or links running in a circle are ignored (those boxes show
|
||||
/// their own text); a chain stops after [`MAX_LINKED_BOXES`].
|
||||
pub fn text_box_chains(&self) -> Vec<Vec<u32>> {
|
||||
let is_box = |id: u32| self.parts.get(&id).is_some_and(|p| p.kind == PartKind::TextBox);
|
||||
// Who links to whom: the first box (by id) to link to a box wins.
|
||||
let mut prev: BTreeMap<u32, u32> = BTreeMap::new();
|
||||
for (id, p) in &self.parts {
|
||||
if p.kind != PartKind::TextBox {
|
||||
continue;
|
||||
}
|
||||
if let Some(n) = p.body.next.filter(|n| *n != *id && is_box(*n)) {
|
||||
prev.entry(n).or_insert(*id);
|
||||
}
|
||||
}
|
||||
let next = |id: u32| self.parts.get(&id).and_then(|p| p.body.next).filter(|n| prev.get(n) == Some(&id));
|
||||
let mut out = Vec::new();
|
||||
for (id, p) in &self.parts {
|
||||
if p.kind != PartKind::TextBox || prev.contains_key(id) || next(*id).is_none() {
|
||||
continue;
|
||||
}
|
||||
let mut chain = vec![*id];
|
||||
let mut at = *id;
|
||||
while let Some(n) = next(at) {
|
||||
if chain.len() >= MAX_LINKED_BOXES || chain.contains(&n) {
|
||||
break;
|
||||
}
|
||||
chain.push(n);
|
||||
at = n;
|
||||
}
|
||||
out.push(chain);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The chain (see [`Document::text_box_chains`]) text box `part` belongs to, if linked.
|
||||
pub fn text_box_chain(&self, part: u32) -> Option<Vec<u32>> {
|
||||
self.text_box_chains().into_iter().find(|c| c.contains(&part))
|
||||
}
|
||||
|
||||
/// The size (width, height) of the shapes showing text boxes `ids`, in any story (a group
|
||||
/// member's as the group stretches it).
|
||||
pub fn text_box_sizes(&self, ids: &std::collections::BTreeSet<u32>) -> BTreeMap<u32, (f32, f32)> {
|
||||
let mut out = BTreeMap::new();
|
||||
let stories = std::iter::once(&self.body).chain(self.parts.values().map(|p| &p.blocks));
|
||||
for blocks in stories {
|
||||
for b in blocks {
|
||||
edit::each_para(b, 0, &mut |p| {
|
||||
for o in &p.objects {
|
||||
match o {
|
||||
InlineObject::Shape { story: Some(id), w, h, .. } if ids.contains(id) => {
|
||||
out.entry(*id).or_insert((*w, *h));
|
||||
}
|
||||
InlineObject::Group { w, h, .. } => {
|
||||
for (r, c) in o.group_rects(0.0, 0.0, *w, *h) {
|
||||
if let Some(id) = c.text_box().filter(|id| ids.contains(id)) {
|
||||
out.entry(id).or_insert((r[2], r[3]));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Every inline object in reading order: the body's, with each text box's objects where the
|
||||
/// box is (nested boxes too, within a [`BoxBudget`]).
|
||||
pub fn objects_in_reading_order(&self, f: &mut dyn FnMut(&InlineObject)) {
|
||||
self.walk_objects(&self.body, &mut BoxBudget::default(), &mut |p, k| {
|
||||
if let Some(o) = p.objects.get(k) {
|
||||
f(o);
|
||||
}
|
||||
true
|
||||
});
|
||||
}
|
||||
|
||||
/// Like [`Document::objects_in_reading_order`], with each object's paragraph and index;
|
||||
/// `f` returns whether to go into the object's text box (false: the box is left out).
|
||||
pub fn objects_in_reading_order_at(&self, f: &mut dyn FnMut(&Paragraph, usize) -> bool) {
|
||||
self.walk_objects(&self.body, &mut BoxBudget::default(), f);
|
||||
}
|
||||
|
||||
/// Like [`Document::objects_in_reading_order_at`], also giving the top-level body block each
|
||||
/// object is in (for objects in text boxes, the block of the box's anchor).
|
||||
pub fn objects_in_reading_order_by_block(&self, f: &mut dyn FnMut(usize, &Paragraph, usize) -> bool) {
|
||||
let mut budget = BoxBudget::default();
|
||||
for (i, b) in self.body.iter().enumerate() {
|
||||
self.walk_objects(std::slice::from_ref(b), &mut budget, &mut |p, k| f(i, p, k));
|
||||
}
|
||||
}
|
||||
|
||||
/// The footnotes and endnotes referenced inside text box story `part` (nested boxes too), in
|
||||
/// reading order.
|
||||
pub fn notes_in_text_box(&self, part: u32) -> Vec<u32> {
|
||||
let mut out = Vec::new();
|
||||
let mut budget = BoxBudget::default();
|
||||
if let Some(p) = self.parts.get(&part).filter(|p| p.kind == PartKind::TextBox)
|
||||
&& budget.enter(part)
|
||||
{
|
||||
self.walk_objects(&p.blocks, &mut budget, &mut |p, k| {
|
||||
if let Some(InlineObject::NoteRef { id, .. }) = p.objects.get(k) {
|
||||
out.push(*id);
|
||||
}
|
||||
true
|
||||
});
|
||||
budget.leave();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn walk_objects(&self, blocks: &[Arc<Block>], budget: &mut BoxBudget, f: &mut dyn FnMut(&Paragraph, usize) -> bool) {
|
||||
for b in blocks {
|
||||
edit::each_para(b, 0, &mut |p| {
|
||||
for (k, o) in p.objects.iter().enumerate() {
|
||||
if !f(p, k) {
|
||||
continue;
|
||||
}
|
||||
for id in o.text_boxes() {
|
||||
if let Some(part) = self.parts.get(&id).filter(|p| p.kind == PartKind::TextBox)
|
||||
&& budget.enter(id)
|
||||
{
|
||||
self.walk_objects(&part.blocks, budget, f);
|
||||
budget.leave();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// `roots`, then every story their objects lead to (`follow`: an object's story id and the
|
||||
/// part kinds that count), transitively, in the order found.
|
||||
fn reachable(&self, roots: Vec<StoryRef>, follow: &dyn Fn(&InlineObject) -> Vec<(u32, &'static [PartKind])>) -> Vec<StoryRef> {
|
||||
let mut out = roots;
|
||||
let mut seen: std::collections::BTreeSet<StoryRef> = out.iter().copied().collect();
|
||||
let mut i = 0;
|
||||
while let Some(s) = out.get(i).copied() {
|
||||
i += 1;
|
||||
let mut found = Vec::new();
|
||||
for b in self.story(s).into_iter().flatten() {
|
||||
edit::each_para(b, 0, &mut |p| {
|
||||
found.extend(p.objects.iter().flat_map(follow));
|
||||
});
|
||||
}
|
||||
for (id, kinds) in found {
|
||||
if self.parts.get(&id).is_some_and(|p| kinds.contains(&p.kind)) && seen.insert(StoryRef::Part(id)) {
|
||||
out.push(StoryRef::Part(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A drawing id no shape uses yet (for gluing a connector to a shape that has none).
|
||||
pub fn next_shape_id(&self) -> u32 {
|
||||
let mut max = 0u32;
|
||||
let mut see = |o: &InlineObject| match o {
|
||||
InlineObject::Shape { extra, .. } => max = max.max(extra.id),
|
||||
InlineObject::Group { children, .. } => {
|
||||
for c in children {
|
||||
if let InlineObject::Shape { extra, .. } = &c.obj {
|
||||
max = max.max(extra.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
for blocks in std::iter::once(&self.body).chain(self.parts.values().map(|p| &p.blocks)) {
|
||||
for b in blocks {
|
||||
edit::each_para(b, 0, &mut |p| p.objects.iter().for_each(&mut see));
|
||||
}
|
||||
}
|
||||
max.saturating_add(1).max(1)
|
||||
}
|
||||
|
||||
/// Change every inline object in every story (paragraphs inside tables too), copying only
|
||||
/// the blocks whose paragraphs `wants` says have objects to change.
|
||||
pub fn objects_mut(&mut self, wants: &dyn Fn(&InlineObject) -> bool, f: &mut dyn FnMut(&mut InlineObject)) {
|
||||
let mut stories: Vec<&mut Blocks> = vec![&mut self.body];
|
||||
stories.extend(self.parts.values_mut().map(|p| &mut p.blocks));
|
||||
for blocks in stories {
|
||||
for b in blocks.iter_mut() {
|
||||
let mut any = false;
|
||||
edit::each_para(b, 0, &mut |p| any |= p.objects.iter().any(wants));
|
||||
if any {
|
||||
edit::each_para_mut(Arc::make_mut(b), 0, &mut |p| {
|
||||
if p.objects.iter().any(wants) {
|
||||
p.objects.iter_mut().filter(|o| wants(o)).for_each(&mut *f);
|
||||
p.touch();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Repair every paragraph's runs (see [`Paragraph::normalize`]); for models from untrusted
|
||||
/// JSON, whose run lengths may overflow, fall short or split a character.
|
||||
pub fn normalize_runs(&mut self) {
|
||||
let mut stories: Vec<&mut Blocks> = vec![&mut self.body];
|
||||
stories.extend(self.parts.values_mut().map(|p| &mut p.blocks));
|
||||
for blocks in stories {
|
||||
for b in blocks.iter_mut() {
|
||||
edit::each_para_mut(Arc::make_mut(b), 0, &mut Paragraph::normalize);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of paragraphs in the body (all depths).
|
||||
pub fn paragraph_count(&self) -> usize {
|
||||
self.para_paths(StoryRef::Body).len()
|
||||
}
|
||||
|
||||
/// Add a media item; returns its key (deduplicated by content).
|
||||
pub fn add_media(&mut self, bytes: Vec<u8>, ext: &str) -> String {
|
||||
let ix = &mut self.media_index;
|
||||
if ix.len != self.media.len() {
|
||||
ix.rebuild(&self.media);
|
||||
}
|
||||
if let Some(k) = ix.find(&self.media, &bytes) {
|
||||
return k;
|
||||
}
|
||||
if ix.by_bytes.contains_key(&bytes) {
|
||||
// Stale (`media` was edited directly without changing its size): look again.
|
||||
ix.rebuild(&self.media);
|
||||
if let Some(k) = ix.find(&self.media, &bytes) {
|
||||
return k;
|
||||
}
|
||||
}
|
||||
let n = self.media.len().saturating_add(1);
|
||||
let mut i = n.max(ix.next);
|
||||
let mut key = format!("image{i}.{ext}");
|
||||
while self.media.contains_key(&key) {
|
||||
i = i.saturating_add(1);
|
||||
key = format!("image{i}.{ext}");
|
||||
}
|
||||
let bytes = Arc::new(bytes);
|
||||
self.media.insert(key.clone(), bytes.clone());
|
||||
ix.by_bytes.entry(bytes).or_insert_with(|| key.clone());
|
||||
ix.len = self.media.len();
|
||||
ix.next = i.saturating_add(1);
|
||||
key
|
||||
}
|
||||
|
||||
/// The value of a custom property (names compare case-insensitively, as in Word).
|
||||
pub fn custom_prop(&self, name: &str) -> Option<&str> {
|
||||
self.custom_props.iter().find(|p| p.name.eq_ignore_ascii_case(name)).map(|p| p.value.as_str())
|
||||
}
|
||||
|
||||
/// Set a custom text property, replacing one of the same name in place.
|
||||
pub fn set_custom_prop(&mut self, name: &str, value: &str) {
|
||||
match self.custom_props.iter_mut().find(|p| p.name.eq_ignore_ascii_case(name)) {
|
||||
Some(p) => {
|
||||
p.kind = "lpwstr".into();
|
||||
p.value = value.to_string();
|
||||
}
|
||||
None => {
|
||||
self.custom_props.push(CustomProp { name: name.to_string(), kind: "lpwstr".into(), value: value.to_string(), ..Default::default() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a custom property. Returns whether it existed.
|
||||
pub fn remove_custom_prop(&mut self, name: &str) -> bool {
|
||||
let n = self.custom_props.len();
|
||||
self.custom_props.retain(|p| !p.name.eq_ignore_ascii_case(name));
|
||||
self.custom_props.len() != n
|
||||
}
|
||||
|
||||
/// Ranges that stay editable while the document is protected (Restrict Editing exceptions),
|
||||
/// in document order across every story: the [`InlineObject::PermStart`]'s id, group and
|
||||
/// editor, its start (just after the marker) and its end (at the matching
|
||||
/// [`InlineObject::PermEnd`], or the end of the story when it has none).
|
||||
pub fn perm_ranges(&self) -> Vec<PermRange> {
|
||||
let mut out: Vec<PermRange> = Vec::new();
|
||||
let stories = std::iter::once(StoryRef::Body).chain(self.parts.keys().map(|k| StoryRef::Part(*k)));
|
||||
for story in stories {
|
||||
// Open ranges by id, latest last (an end closes the latest open range of its id).
|
||||
let mut open: BTreeMap<u32, Vec<usize>> = BTreeMap::new();
|
||||
for path in self.para_paths(story) {
|
||||
let Some(p) = self.para(story, &path) else { continue };
|
||||
for (off, o) in p.objects_with_offsets() {
|
||||
match o {
|
||||
InlineObject::PermStart { id, group, editor } => {
|
||||
let start = Pos { story, path: path.clone(), off: off + para::OBJ.len_utf8() };
|
||||
open.entry(*id).or_default().push(out.len());
|
||||
out.push(PermRange { id: *id, group: group.clone(), editor: editor.clone(), start: start.clone(), end: start });
|
||||
}
|
||||
InlineObject::PermEnd { id } => {
|
||||
if let Some(i) = open.get_mut(id).and_then(Vec::pop)
|
||||
&& let Some(r) = out.get_mut(i)
|
||||
{
|
||||
r.end = Pos { story, path: path.clone(), off };
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
// A range left open runs to the end of its story.
|
||||
let end = self.end_of(story);
|
||||
for i in open.into_values().flatten() {
|
||||
if let Some(r) = out.get_mut(i) {
|
||||
r.end = end.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Bookmark names in the body, in order.
|
||||
pub fn bookmarks(&self) -> Vec<(String, Pos)> {
|
||||
let mut out = Vec::new();
|
||||
for path in self.para_paths(StoryRef::Body) {
|
||||
if let Some(p) = self.para(StoryRef::Body, &path) {
|
||||
for (off, o) in p.objects_with_offsets() {
|
||||
if let InlineObject::BookmarkStart { name } = o {
|
||||
out.push((name.clone(), Pos { story: StoryRef::Body, path: path.clone(), off }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
+251
-558
@@ -7,7 +7,7 @@ use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::para::InlineObject;
|
||||
use crate::para::{InlineObject, Run};
|
||||
use crate::props::{CharProps, ParaProps};
|
||||
use crate::{Block, Blocks, DocError, Document, Paragraph, Part, PartKind, Path, Pos, Result, para_block};
|
||||
use crate::{Table, table::MAX_TABLE_DEPTH};
|
||||
@@ -188,6 +188,111 @@ fn fit_tables(b: Block, room: usize, out: &mut Vec<Block>) {
|
||||
const MAX_BOX_DEPTH: usize = 8;
|
||||
/// Most text box stories one paste may create (hostile documents can fan out).
|
||||
const MAX_PASTED_BOXES: usize = 4096;
|
||||
/// Most text box content (estimated bytes, see [`block_cost`]) one paste may copy: thousands of
|
||||
/// boxes can show the same story, and each needs a story of its own. Blocks without text boxes
|
||||
/// are shared between those stories, not copied, so they are not charged per box.
|
||||
const MAX_PASTED_BOX_BYTES: usize = 32 << 20;
|
||||
|
||||
/// What a paste may still spend on text box stories.
|
||||
struct BoxBudget {
|
||||
stories: usize,
|
||||
bytes: usize,
|
||||
/// The source stories being copied, innermost last: a box inside one of them showing it
|
||||
/// again is a cycle.
|
||||
active: Vec<u32>,
|
||||
/// Each source story's cost ([`block_cost`]), worked out once per paste: a story too big for
|
||||
/// what is left is not walked again for every box that shows it.
|
||||
costs: BTreeMap<u32, usize>,
|
||||
/// Boxes left without a story because the paste's budget couldn't hold theirs.
|
||||
skipped: usize,
|
||||
}
|
||||
|
||||
impl BoxBudget {
|
||||
fn new() -> BoxBudget {
|
||||
BoxBudget { stories: MAX_PASTED_BOXES, bytes: MAX_PASTED_BOX_BYTES, active: Vec::new(), costs: BTreeMap::new(), skipped: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Rough size of a block's content, and whether it holds text boxes (and so must be copied).
|
||||
fn block_cost(b: &Block) -> (usize, bool) {
|
||||
let (mut cost, mut boxes) = (std::mem::size_of::<Block>(), false);
|
||||
each_para(b, 0, &mut |p| {
|
||||
let items = p
|
||||
.runs
|
||||
.len()
|
||||
.saturating_mul(std::mem::size_of::<Run>())
|
||||
.saturating_add(p.objects.len().saturating_mul(std::mem::size_of::<InlineObject>()));
|
||||
let objects = p.objects.iter().fold(0usize, |n, o| n.saturating_add(object_cost(o)));
|
||||
cost = cost.saturating_add(p.text.len()).saturating_add(items).saturating_add(objects);
|
||||
boxes |= box_ids(p).next().is_some();
|
||||
});
|
||||
(cost, boxes)
|
||||
}
|
||||
|
||||
/// Rough heap bytes a clone of `o` copies beyond its own storage: its strings and lists, and its
|
||||
/// group members however deep. What it shares through `Arc` (pictures' embedded objects, charts,
|
||||
/// freeforms, wrap polygons, gradients) is not copied, so not counted.
|
||||
fn object_cost(o: &InlineObject) -> usize {
|
||||
let float = |f: &crate::para::Float| f.alt.text.len().saturating_add(f.alt.title.len());
|
||||
let mut cost = 0usize;
|
||||
let mut todo = vec![o];
|
||||
while let Some(o) = todo.pop() {
|
||||
let add = match o {
|
||||
InlineObject::Image { media, alt, float: f, .. } => media.len().saturating_add(alt.len()).saturating_add(float(f)),
|
||||
InlineObject::Graphic { alt, float: f, .. } => alt.len().saturating_add(float(f)),
|
||||
InlineObject::Shape { float: f, extra, .. } => {
|
||||
let warp = extra.warp.as_ref().map_or(0, |w| {
|
||||
let adj = w.adj.iter().map(|(name, _)| std::mem::size_of::<(String, i64)>().saturating_add(name.len()));
|
||||
adj.fold(w.preset.len(), usize::saturating_add)
|
||||
});
|
||||
float(f).saturating_add(warp)
|
||||
}
|
||||
InlineObject::Group { float: f, children, .. } => {
|
||||
todo.extend(children.iter().map(|c| &c.obj));
|
||||
children.len().saturating_mul(std::mem::size_of::<crate::para::GroupChild>()).saturating_add(float(f))
|
||||
}
|
||||
InlineObject::Field { instr, result, form, .. } => instr.len().saturating_add(result.len()).saturating_add(form.len()),
|
||||
InlineObject::FieldStart { instr, .. } => instr.len(),
|
||||
InlineObject::ControlStart { control } => std::mem::size_of::<crate::control::ContentControl>().saturating_add(serialized_len(control)),
|
||||
InlineObject::NoteRef { custom, .. } => custom.len(),
|
||||
InlineObject::BookmarkStart { name } | InlineObject::BookmarkEnd { name } => name.len(),
|
||||
InlineObject::PermStart { group, editor, .. } => group.len().saturating_add(editor.len()),
|
||||
InlineObject::Equation { linear, math, .. } => linear.len().saturating_add(math.heap_bytes()),
|
||||
InlineObject::Ruby { base, ruby, lang, props, .. } => {
|
||||
[base.len(), ruby.len(), lang.len(), std::mem::size_of::<CharProps>(), serialized_len(props)]
|
||||
.into_iter()
|
||||
.fold(0, usize::saturating_add)
|
||||
}
|
||||
InlineObject::Opaque { format, xml, text } => format.len().saturating_add(xml.len()).saturating_add(text.len()),
|
||||
InlineObject::FieldEnd
|
||||
| InlineObject::ControlEnd
|
||||
| InlineObject::CommentStart { .. }
|
||||
| InlineObject::CommentEnd { .. }
|
||||
| InlineObject::PermEnd { .. }
|
||||
| InlineObject::PositionalTab { .. } => 0,
|
||||
};
|
||||
cost = cost.saturating_add(add);
|
||||
}
|
||||
cost
|
||||
}
|
||||
|
||||
/// Length of `v` written as JSON: at least the bytes of every string and list it owns. For the
|
||||
/// nested formatting and control types, which share nothing through `Arc`.
|
||||
fn serialized_len<T: Serialize>(v: &T) -> usize {
|
||||
struct Count(usize);
|
||||
impl std::io::Write for Count {
|
||||
fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
|
||||
self.0 = self.0.saturating_add(b.len());
|
||||
Ok(b.len())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
let mut n = Count(0);
|
||||
let _ = serde_json::to_writer(&mut n, v);
|
||||
n.0
|
||||
}
|
||||
|
||||
/// Call `f` on every paragraph of a block, in document order, however deep its tables nest. The
|
||||
/// walk keeps its own stack instead of recursing, so it is complete: a text box it doesn't reach
|
||||
@@ -329,10 +434,16 @@ impl Document {
|
||||
at.extend(offs.get(k).copied());
|
||||
}
|
||||
}
|
||||
// Reference offsets and run boundaries are ordered; do not restart at the first
|
||||
// reference for every run, including runs after the last reference.
|
||||
let mut at = at.into_iter().peekable();
|
||||
let mut start = 0usize;
|
||||
for r in &mut p.runs {
|
||||
let end = start.saturating_add(r.len);
|
||||
if at.iter().any(|&o| o >= start && o < end) {
|
||||
while at.peek().is_some_and(|&o| o < start) {
|
||||
at.next();
|
||||
}
|
||||
if at.peek().is_some_and(|&o| o < end) {
|
||||
swap(&mut r.props.style);
|
||||
}
|
||||
start = end;
|
||||
@@ -474,7 +585,13 @@ impl Document {
|
||||
}
|
||||
// Content control markers whose partner is outside the range stay (the control is
|
||||
// emptied, not broken).
|
||||
let kept = self.kept_control_markers(&a, &b);
|
||||
let mut kept = self.kept_control_markers(&a, &b);
|
||||
// The walk below groups `kept` by paragraph (`chunk_by`) and looks offsets up
|
||||
// (`binary_search`), so it must be sorted; `kept_control_markers` returns it so, and this
|
||||
// keeps a change there from quietly losing markers.
|
||||
if !kept.is_sorted() {
|
||||
kept.sort();
|
||||
}
|
||||
let kept_in = |path: &Path| -> Vec<usize> { kept.iter().filter(|(p, _)| p == path).map(|(_, o)| *o).collect() };
|
||||
if a.path == b.path {
|
||||
self.para_mut(a.story, &a.path)?.delete_except(a.off, b.off, &kept_in(&a.path))?;
|
||||
@@ -485,13 +602,21 @@ impl Document {
|
||||
let (ia, ib) = (a.path.last(), b.path.last());
|
||||
// Kept markers of the paragraphs in between move to the join.
|
||||
let mut moved: Vec<(InlineObject, CharProps)> = Vec::new();
|
||||
for (path, off) in &kept {
|
||||
if *path != a.path
|
||||
&& *path != b.path
|
||||
&& let Some(p) = self.para(a.story, path)
|
||||
&& let Some(o) = p.object_at(*off)
|
||||
{
|
||||
moved.push((o.clone(), p.props_of_char(*off).clone()));
|
||||
// `kept` is sorted: one walk of each paragraph's objects and runs (#3614).
|
||||
for group in kept.chunk_by(|x, y| x.0 == y.0) {
|
||||
let Some((path, _)) = group.first() else { continue };
|
||||
if *path == a.path || *path == b.path {
|
||||
continue;
|
||||
}
|
||||
let Some(p) = self.para(a.story, path) else { continue };
|
||||
let mut runs = p.run_ranges().peekable();
|
||||
for (off, o) in p.objects_with_offsets() {
|
||||
if group.binary_search_by_key(&off, |(_, k)| *k).is_err() {
|
||||
continue;
|
||||
}
|
||||
while runs.next_if(|(r, _)| r.end <= off).is_some() {}
|
||||
let props = runs.peek().filter(|(r, _)| r.contains(&off)).map_or(&p.mark, |(_, c)| *c);
|
||||
moved.push((o.clone(), props.clone()));
|
||||
}
|
||||
}
|
||||
{
|
||||
@@ -673,6 +798,63 @@ impl Document {
|
||||
Fragment { blocks, parts }
|
||||
}
|
||||
|
||||
/// A copy of the block at `path` (a whole table, say) as a fragment, with its text boxes.
|
||||
pub fn copy_block(&self, story: crate::StoryRef, path: &Path) -> Option<Fragment> {
|
||||
Some(self.fragment_of(vec![self.block(story, path)?.clone()]))
|
||||
}
|
||||
|
||||
/// A copy of rows `rows` of the table at `tp`, with the cells of each that reach into grid
|
||||
/// columns `cols` (see [`crate::Table::slice`]), as a fragment holding that table.
|
||||
pub fn copy_table_cells(&self, story: crate::StoryRef, tp: &Path, rows: (usize, usize), cols: (usize, usize)) -> Option<Fragment> {
|
||||
let t = self.table(story, tp)?.slice(rows, cols);
|
||||
if t.rows.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(self.fragment_of(vec![Block::Table(t)]))
|
||||
}
|
||||
|
||||
/// `blocks` as a fragment: half content controls dropped, text boxes copied along.
|
||||
fn fragment_of(&self, mut blocks: Vec<Block>) -> Fragment {
|
||||
crate::control::balance_blocks(&mut blocks);
|
||||
let parts = self.text_box_parts(&blocks);
|
||||
Fragment { blocks, parts }
|
||||
}
|
||||
|
||||
/// The table whose whole text `a..b` covers when the range runs across its cells: from the
|
||||
/// start of its first paragraph to the end of its last. Of the tables holding both ends, the
|
||||
/// outermost one covered whole (a table nested in a cell when the range covers just it).
|
||||
/// `None` for a range inside one cell, or one that doesn't cover a whole table.
|
||||
pub fn whole_table(&self, a: &Pos, b: &Pos) -> Option<Path> {
|
||||
let (a, b) = order(a, b);
|
||||
if a.story != b.story || a.off != 0 || a.path.parent() == b.path.parent() {
|
||||
return None;
|
||||
}
|
||||
let mut paths: Option<Vec<Path>> = None;
|
||||
// Table block paths are the prefixes of a cell paragraph's path one block deep, then
|
||||
// every three steps (block, row, cell) deeper.
|
||||
let mut n = 1usize;
|
||||
while n.saturating_add(3) <= a.path.0.len() {
|
||||
let pre = a.path.0.get(..n)?;
|
||||
if !b.path.0.starts_with(pre) {
|
||||
return None;
|
||||
}
|
||||
let tp = Path(pre.to_vec());
|
||||
if self.table(a.story, &tp).is_some() {
|
||||
let paths = paths.get_or_insert_with(|| self.para_paths(a.story));
|
||||
let mut inside = paths.iter().filter(|p| p.0.starts_with(pre));
|
||||
if let Some(first) = inside.next() {
|
||||
let last = inside.next_back().unwrap_or(first);
|
||||
let end = self.para(a.story, last).map_or(0, Paragraph::len);
|
||||
if *first == a.path && *last == b.path && b.off >= end {
|
||||
return Some(tp);
|
||||
}
|
||||
}
|
||||
}
|
||||
n = n.saturating_add(3);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The whole body as a fragment (with its text boxes), to insert into another document.
|
||||
pub fn body_fragment(&self) -> Fragment {
|
||||
let blocks: Vec<Block> = self.body.iter().map(|b| (**b).clone()).collect();
|
||||
@@ -727,6 +909,13 @@ impl Document {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// Across the cells of a whole table: the table itself, not its cells' paragraphs (#1744).
|
||||
if let Some(tp) = self.whole_table(&a, &b)
|
||||
&& let Some(blk) = self.block(a.story, &tp)
|
||||
{
|
||||
out.push(blk.clone());
|
||||
return out;
|
||||
}
|
||||
for p in self.paths_between(&a, &b) {
|
||||
if let Some(para) = self.para(a.story, &p) {
|
||||
let mut q = para.clone();
|
||||
@@ -744,7 +933,7 @@ impl Document {
|
||||
/// Give every text box in `blocks` a new story of its own, copied from `parts` (or, for a
|
||||
/// fragment without them, from this document's text box of that id). A box whose story can't
|
||||
/// be found keeps no story rather than showing an unrelated one.
|
||||
fn adopt_text_boxes(&mut self, blocks: &mut [Block], parts: &BTreeMap<u32, Part>, depth: usize, budget: &mut usize) {
|
||||
fn adopt_text_boxes(&mut self, blocks: &mut [Block], parts: &BTreeMap<u32, Part>, depth: usize, budget: &mut BoxBudget) {
|
||||
let mut any = false;
|
||||
for b in blocks.iter() {
|
||||
each_para(b, 0, &mut |p| any |= box_ids(p).next().is_some());
|
||||
@@ -770,18 +959,53 @@ impl Document {
|
||||
}
|
||||
}
|
||||
|
||||
/// A new text box story copied from `old`; its own boxes get new stories too.
|
||||
fn adopt_text_box(&mut self, old: u32, parts: &BTreeMap<u32, Part>, depth: usize, budget: &mut usize) -> Option<u32> {
|
||||
if depth >= MAX_BOX_DEPTH || *budget == 0 {
|
||||
/// A new text box story copied from `old`; its own boxes get new stories too. Blocks without
|
||||
/// text boxes are shared with the source (copy-on-write), the others copied. A box showing a
|
||||
/// story it is inside, or whose story the paste's budget can't hold, gets none (CWE-400);
|
||||
/// only that box: a later box with a smaller story is still copied while the budget lasts.
|
||||
fn adopt_text_box(&mut self, old: u32, parts: &BTreeMap<u32, Part>, depth: usize, budget: &mut BoxBudget) -> Option<u32> {
|
||||
if depth >= MAX_BOX_DEPTH || budget.active.contains(&old) {
|
||||
return None;
|
||||
}
|
||||
if budget.stories == 0 {
|
||||
budget.skipped = budget.skipped.saturating_add(1);
|
||||
return None;
|
||||
}
|
||||
let src = parts.get(&old).or_else(|| self.parts.get(&old).filter(|p| p.kind == PartKind::TextBox))?;
|
||||
let mut blocks: Vec<Block> = src.blocks.iter().map(|b| (**b).clone()).collect();
|
||||
// Charge what the copy really allocates before making any of it: the block slots, and the
|
||||
// blocks holding text boxes (deep-copied below). The rest are shared `Arc`s, however many
|
||||
// boxes show them, so they cost nothing more. Worked out once per paste.
|
||||
let cost = *budget.costs.entry(old).or_insert_with(|| {
|
||||
let slots = src.blocks.len().saturating_mul(std::mem::size_of::<Arc<Block>>());
|
||||
src.blocks.iter().fold(slots, |a, b| match block_cost(b) {
|
||||
(c, true) => a.saturating_add(c),
|
||||
(_, false) => a,
|
||||
})
|
||||
});
|
||||
let Some(left) = budget.bytes.checked_sub(cost) else {
|
||||
budget.skipped = budget.skipped.saturating_add(1);
|
||||
return None;
|
||||
};
|
||||
budget.bytes = left;
|
||||
budget.stories -= 1;
|
||||
let src_blocks = src.blocks.clone();
|
||||
// The copy keeps its text direction and alignment, not its link (a copy stands alone).
|
||||
let body = crate::TextBody { next: None, ..src.body };
|
||||
*budget -= 1;
|
||||
self.adopt_text_boxes(&mut blocks, parts, depth + 1, budget);
|
||||
let id = self.try_add_part(PartKind::TextBox, blocks.into_iter().map(Arc::new).collect()).ok()?;
|
||||
budget.active.push(old);
|
||||
let mut blocks: Blocks = Vec::with_capacity(src_blocks.len());
|
||||
for b in src_blocks {
|
||||
let mut boxes = false;
|
||||
each_para(&b, 0, &mut |p| boxes |= box_ids(p).next().is_some());
|
||||
if boxes {
|
||||
let mut copy = (*b).clone();
|
||||
self.adopt_text_boxes(std::slice::from_mut(&mut copy), parts, depth + 1, budget);
|
||||
blocks.push(Arc::new(copy));
|
||||
} else {
|
||||
blocks.push(b);
|
||||
}
|
||||
}
|
||||
budget.active.pop();
|
||||
let id = self.try_add_part(PartKind::TextBox, blocks).ok()?;
|
||||
if let Some(p) = self.parts.get_mut(&id) {
|
||||
p.body = body;
|
||||
}
|
||||
@@ -803,8 +1027,15 @@ impl Document {
|
||||
return Err(DocError::BadPath(pos.path.to_string()));
|
||||
}
|
||||
// Pasted text boxes get stories of their own (never shared with the copy's source).
|
||||
let mut budget = MAX_PASTED_BOXES;
|
||||
let mut budget = BoxBudget::new();
|
||||
self.adopt_text_boxes(&mut blocks, &frag.parts, 0, &mut budget);
|
||||
if budget.skipped > 0 {
|
||||
// Once per paste, not once per box (review of #3874).
|
||||
log::warn!(
|
||||
"paste: {} text boxes are left empty; their stories are past what one paste copies ({MAX_PASTED_BOXES} stories, {MAX_PASTED_BOX_BYTES} bytes)",
|
||||
budget.skipped
|
||||
);
|
||||
}
|
||||
// Single paragraph: inline insert keeping runs.
|
||||
if blocks.len() == 1
|
||||
&& let Some(Block::Para(src)) = blocks.first()
|
||||
@@ -879,542 +1110,4 @@ impl Document {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{StoryRef, Table};
|
||||
|
||||
fn doc(lines: &[&str]) -> Document {
|
||||
Document::from_text(&lines.join("\n"))
|
||||
}
|
||||
|
||||
/// Paths between two positions are those of all the story's paragraphs between them.
|
||||
#[test]
|
||||
fn paths_between_matches_every_path_filtered() {
|
||||
let mut d = doc(&["a", "b", "c", "d", "e"]);
|
||||
let mut inner = Table::new(2, 2, 100.0);
|
||||
inner.rows[1].cells[0].blocks.push(crate::para_block(crate::Paragraph::new()));
|
||||
let mut t = Table::new(3, 2, 200.0);
|
||||
t.rows[1].cells[1].blocks.insert(0, Arc::new(Block::Table(inner)));
|
||||
d.insert_block(StoryRef::Body, &Path::top(2), Block::Table(t)).unwrap();
|
||||
d.insert_block(StoryRef::Body, &Path::top(4), Block::Table(Table::new(1, 3, 200.0))).unwrap();
|
||||
let all = d.para_paths(StoryRef::Body);
|
||||
for a in &all {
|
||||
for b in &all {
|
||||
let (pa, pb) = (Pos { story: StoryRef::Body, path: a.clone(), off: 0 }, Pos { story: StoryRef::Body, path: b.clone(), off: 0 });
|
||||
let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
|
||||
let want: Vec<Path> = all.iter().filter(|p| *p >= lo && *p <= hi).cloned().collect();
|
||||
assert_eq!(d.paths_between(&pa, &pb), want, "{a:?} {b:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_and_join() {
|
||||
let mut d = doc(&["Hello world"]);
|
||||
let p = d.split_paragraph(&Pos::body(0, 5)).unwrap();
|
||||
assert_eq!(p, Pos::body(1, 0));
|
||||
assert_eq!(d.plain_text(StoryRef::Body), "Hello\n world");
|
||||
let at = d.delete_range(&Pos::body(0, 5), &Pos::body(1, 0)).unwrap();
|
||||
assert_eq!(at, Pos::body(0, 5));
|
||||
assert_eq!(d.plain_text(StoryRef::Body), "Hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_range_with_bad_end_leaves_document_unchanged() {
|
||||
let mut d = doc(&["one", "two", "three"]);
|
||||
let before = d.plain_text(StoryRef::Body);
|
||||
// Missing paragraph as the second end.
|
||||
assert!(d.delete_range(&Pos::body(0, 1), &Pos::body(9, 0)).is_err());
|
||||
assert_eq!(d.plain_text(StoryRef::Body), before);
|
||||
// Offset past the end of the second paragraph.
|
||||
assert!(d.delete_range(&Pos::body(0, 1), &Pos::body(1, 99)).is_err());
|
||||
assert_eq!(d.plain_text(StoryRef::Body), before);
|
||||
// Offset inside a multi-byte character.
|
||||
let mut d = doc(&["abc", "\u{e9}x"]);
|
||||
assert!(d.delete_range(&Pos::body(0, 1), &Pos::body(1, 1)).is_err());
|
||||
assert_eq!(d.plain_text(StoryRef::Body), "abc\n\u{e9}x");
|
||||
// Range entering a table with a bad offset at its start: the cell text stays.
|
||||
let mut d = doc(&["before", "after"]);
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(Table::new(2, 1, 200.0))).unwrap();
|
||||
d.insert_text(&Pos { story: StoryRef::Body, path: Path(vec![1, 0, 0, 0]), off: 0 }, "x", &CharProps::default()).unwrap();
|
||||
let before = d.plain_text(StoryRef::Body);
|
||||
let cell = Pos { story: StoryRef::Body, path: Path(vec![1, 1, 0, 0]), off: 0 };
|
||||
assert!(d.delete_range(&Pos::body(0, 99), &cell).is_err());
|
||||
assert_eq!(d.plain_text(StoryRef::Body), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_multi() {
|
||||
let mut d = doc(&["one", "two", "three"]);
|
||||
d.delete_range(&Pos::body(2, 2), &Pos::body(0, 1)).unwrap();
|
||||
assert_eq!(d.plain_text(StoryRef::Body), "oree");
|
||||
assert_eq!(d.body.len(), 1);
|
||||
}
|
||||
|
||||
/// #2127: a range over two section breaks joins three sections into the last; the headers
|
||||
/// and footers it showed by linking to earlier ones stay, each from the nearest section that
|
||||
/// named it, as they resolved before the delete.
|
||||
#[test]
|
||||
fn delete_over_two_section_breaks_keeps_the_linked_headers() {
|
||||
let mut d = doc(&["A", "B", "C"]);
|
||||
let set = |default| crate::section::HeaderSet { default: Some(default), ..Default::default() };
|
||||
let s1 = crate::section::SectionProps { headers: set(1), footers: set(5), ..Default::default() };
|
||||
let s2 = crate::section::SectionProps { headers: set(2), ..Default::default() };
|
||||
let s3 = crate::section::SectionProps { margin_left: 99.0, ..Default::default() };
|
||||
for (i, s) in [(0, s1), (1, s2), (2, s3.clone())] {
|
||||
if let Some(Block::Para(p)) = d.body.get_mut(i).map(Arc::make_mut) {
|
||||
p.section = Some(Box::new(s));
|
||||
}
|
||||
}
|
||||
assert_eq!(d.sections().len(), 4);
|
||||
d.delete_range(&Pos::body(0, 1), &Pos::body(2, 0)).unwrap();
|
||||
assert_eq!(d.plain_text(StoryRef::Body), "AC");
|
||||
let sections = d.sections();
|
||||
assert_eq!(sections.len(), 2);
|
||||
assert_eq!(*sections[0].1, crate::section::SectionProps { headers: set(2), footers: set(5), ..s3 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_across() {
|
||||
let mut d = doc(&["abc", "def"]);
|
||||
d.format_range(&Pos::body(0, 1), &Pos::body(1, 2), &|c| c.bold = Some(true)).unwrap();
|
||||
let p0 = d.para(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
assert_eq!(p0.props_of_char(0).bold, None);
|
||||
assert_eq!(p0.props_of_char(1).bold, Some(true));
|
||||
let p1 = d.para(StoryRef::Body, &Path::top(1)).unwrap();
|
||||
assert_eq!(p1.props_of_char(1).bold, Some(true));
|
||||
assert_eq!(p1.props_of_char(2).bold, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_paste_round_trip() {
|
||||
let mut d = doc(&["alpha", "beta", "gamma"]);
|
||||
let f = d.copy_range(&Pos::body(0, 2), &Pos::body(2, 3));
|
||||
assert_eq!(f.plain_text(), "pha\nbeta\ngam");
|
||||
let end = d.insert_fragment(&Pos::body(2, 5), &f).unwrap();
|
||||
assert_eq!(d.plain_text(StoryRef::Body), "alpha\nbeta\ngammapha\nbeta\ngam");
|
||||
assert_eq!(end, Pos::body(4, 3));
|
||||
let one = Fragment::from_text("XY");
|
||||
let e = d.insert_fragment(&Pos::body(0, 0), &one).unwrap();
|
||||
assert_eq!(e, Pos::body(0, 2));
|
||||
assert!(d.plain_text(StoryRef::Body).starts_with("XYalpha"));
|
||||
}
|
||||
|
||||
/// The first paragraph of the deepest cell, following the first table at each level.
|
||||
fn deepest(d: &Document) -> Path {
|
||||
let (mut path, mut blocks) = (Vec::new(), &d.body);
|
||||
while let Some((i, t)) = blocks.iter().enumerate().find_map(|(i, b)| if let Block::Table(t) = &**b { Some((i, t)) } else { None }) {
|
||||
path.extend([i as u32, 0, 0]);
|
||||
blocks = &t.rows[0].cells[0].blocks;
|
||||
}
|
||||
path.push(0);
|
||||
Path(path)
|
||||
}
|
||||
|
||||
fn body_height(d: &Document) -> usize {
|
||||
d.body.iter().filter_map(|b| if let Block::Table(t) = &**b { Some(table_height(t)) } else { None }).max().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// How many times `c` is in `blocks`, the deepest cells too.
|
||||
fn count(blocks: &[Arc<Block>], c: char) -> usize {
|
||||
blocks
|
||||
.iter()
|
||||
.map(|b| match &**b {
|
||||
Block::Para(p) => p.text.matches(c).count(),
|
||||
Block::Table(t) => t.rows.iter().flat_map(|r| &r.cells).map(|cell| count(&cell.blocks, c)).sum(),
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tables_nest_at_most_max_table_depth() {
|
||||
let mut d = doc(&["x", ""]);
|
||||
for _ in 0..MAX_TABLE_DEPTH {
|
||||
let at = deepest(&d);
|
||||
d.insert_block(StoryRef::Body, &at, Block::Table(Table::new(1, 1, 100.0))).unwrap();
|
||||
}
|
||||
assert_eq!(body_height(&d), MAX_TABLE_DEPTH);
|
||||
// One more in the deepest cell is refused and changes nothing; a paragraph is fine.
|
||||
let (at, before) = (deepest(&d), d.clone());
|
||||
assert_eq!(at.depth(), MAX_TABLE_DEPTH);
|
||||
assert!(d.insert_block(StoryRef::Body, &at, Block::Table(Table::new(1, 1, 100.0))).is_err());
|
||||
assert_eq!(d, before);
|
||||
d.insert_block(StoryRef::Body, &at, Block::Para(Paragraph::new())).unwrap();
|
||||
// One level up, a table fits but a table holding a table doesn't.
|
||||
let up = Path(at.0[..at.0.len() - 3].to_vec());
|
||||
let mut two = Table::new(1, 1, 100.0);
|
||||
two.rows[0].cells[0].blocks.insert(0, Arc::new(Block::Table(Table::new(1, 1, 100.0))));
|
||||
assert!(d.insert_block(StoryRef::Body, &up, Block::Table(two)).is_err());
|
||||
d.insert_block(StoryRef::Body, &up, Block::Table(Table::new(1, 1, 100.0))).unwrap();
|
||||
assert_eq!(body_height(&d), MAX_TABLE_DEPTH);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasting_a_table_into_its_own_cell_stops_at_max_table_depth() {
|
||||
// #1596: copy everything and paste it into the deepest cell, again and again: each round
|
||||
// doubled the nesting, until saving, copying or closing the document overflowed the stack.
|
||||
let mut d = doc(&["x", ""]);
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(Table::new(1, 1, 100.0))).unwrap();
|
||||
d.insert_text(&Pos { story: StoryRef::Body, path: Path(vec![1, 0, 0, 0]), off: 0 }, "a", &CharProps::default()).unwrap();
|
||||
for _ in 0..8 {
|
||||
let all = d.copy_range(&Pos::body(0, 0), &Pos::body(d.body.len() - 1, 0));
|
||||
let at = deepest(&d);
|
||||
let off = d.para(StoryRef::Body, &at).map_or(0, Paragraph::len);
|
||||
d.insert_fragment(&Pos { story: StoryRef::Body, path: at, off }, &all).unwrap();
|
||||
}
|
||||
assert_eq!(body_height(&d), MAX_TABLE_DEPTH);
|
||||
// Nothing is lost: every copy's text is there, below the limit as paragraphs.
|
||||
assert_eq!((count(&d.body, 'x'), count(&d.body, 'a')), (256, 256));
|
||||
}
|
||||
|
||||
fn text_box(d: &mut Document, pos: &Pos, text: &str) -> u32 {
|
||||
let id = d.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text(text, CharProps::default()))]);
|
||||
d.insert_object(pos, box_shape(Some(id)), &CharProps::default()).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
fn box_shape(story: Option<u32>) -> InlineObject {
|
||||
InlineObject::Shape {
|
||||
kind: crate::para::ShapeKind::TextBox,
|
||||
w: 144.0,
|
||||
h: 72.0,
|
||||
fill: None,
|
||||
stroke: None,
|
||||
stroke_width: 0.75,
|
||||
float: Default::default(),
|
||||
story,
|
||||
freeform: None,
|
||||
effects: Default::default(),
|
||||
extra: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Story ids of the boxes in a body paragraph, in order.
|
||||
fn boxes_in(d: &Document, path: &Path) -> Vec<Option<u32>> {
|
||||
let p = d.para(StoryRef::Body, path).unwrap();
|
||||
p.objects.iter().filter_map(|o| if let InlineObject::Shape { story, .. } = o { Some(*story) } else { None }).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasted_text_box_gets_its_own_story() {
|
||||
let mut d = doc(&["ab", "cd"]);
|
||||
let id = text_box(&mut d, &Pos::body(0, 1), "Inside");
|
||||
let f = d.copy_range(&Pos::body(0, 0), &Pos::body(0, 5));
|
||||
assert_eq!(f.parts.keys().copied().collect::<Vec<_>>(), vec![id]);
|
||||
// Edit the original after copying: the paste keeps the copied text.
|
||||
d.insert_text(&Pos { story: StoryRef::Part(id), path: Path::top(0), off: 6 }, " later", &CharProps::default()).unwrap();
|
||||
d.insert_fragment(&Pos::body(1, 2), &f).unwrap();
|
||||
let pasted = boxes_in(&d, &Path::top(1));
|
||||
let [Some(new)] = pasted[..] else { panic!("{pasted:?}") };
|
||||
assert_ne!(new, id);
|
||||
assert_eq!(d.parts.get(&new).map(|p| p.kind), Some(PartKind::TextBox));
|
||||
assert_eq!(d.plain_text(StoryRef::Part(new)), "Inside");
|
||||
assert_eq!(d.plain_text(StoryRef::Part(id)), "Inside later");
|
||||
// Editing the copy leaves the original alone; a second paste is a third story.
|
||||
d.insert_text(&Pos { story: StoryRef::Part(new), path: Path::top(0), off: 0 }, "Copy: ", &CharProps::default()).unwrap();
|
||||
assert_eq!(d.plain_text(StoryRef::Part(id)), "Inside later");
|
||||
d.insert_fragment(&Pos::body(1, 0), &f).unwrap();
|
||||
let again = boxes_in(&d, &Path::top(1));
|
||||
assert_eq!(again.len(), 2);
|
||||
assert!(again.iter().all(|s| s.is_some_and(|s| s != id)) && again[0] != again[1], "{again:?}");
|
||||
// Multi-paragraph pastes too.
|
||||
let f2 = d.copy_range(&Pos::body(0, 0), &Pos::body(1, 1));
|
||||
let before = d.parts.len();
|
||||
d.insert_fragment(&Pos::body(1, 0), &f2).unwrap();
|
||||
assert_eq!(d.parts.len(), before + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_box_pasted_into_another_document_brings_its_text() {
|
||||
let mut src = doc(&["x"]);
|
||||
let id = text_box(&mut src, &Pos::body(0, 1), "From the source");
|
||||
let f = src.copy_range(&Pos::body(0, 0), &Pos::body(0, 1 + 3));
|
||||
// The target already has a header with the same part id.
|
||||
let mut dst = doc(&["target"]);
|
||||
let hdr = dst.add_part(PartKind::Header, vec![para_block(Paragraph::with_text("Header text", CharProps::default()))]);
|
||||
assert_eq!(hdr, id);
|
||||
dst.insert_fragment(&Pos::body(0, 6), &f).unwrap();
|
||||
let [Some(new)] = boxes_in(&dst, &Path::top(0))[..] else { panic!() };
|
||||
assert_ne!(new, hdr);
|
||||
assert_eq!(dst.plain_text(StoryRef::Part(new)), "From the source");
|
||||
assert_eq!(dst.plain_text(StoryRef::Part(hdr)), "Header text");
|
||||
// The whole body of a document, as Insert › Text from File uses it.
|
||||
let mut dst2 = doc(&["t"]);
|
||||
dst2.insert_fragment(&Pos::body(0, 1), &src.body_fragment()).unwrap();
|
||||
let [Some(n2)] = boxes_in(&dst2, &Path::top(0))[..] else { panic!() };
|
||||
assert_eq!(dst2.plain_text(StoryRef::Part(n2)), "From the source");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_box_in_a_copied_table_gets_its_own_story() {
|
||||
let mut d = doc(&["before", "after"]);
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(Table::new(1, 1, 200.0))).unwrap();
|
||||
let cell = Pos { story: StoryRef::Body, path: Path(vec![1, 0, 0, 0]), off: 0 };
|
||||
let id = text_box(&mut d, &cell, "In a cell");
|
||||
let f = d.copy_range(&Pos::body(0, 0), &Pos::body(2, 5));
|
||||
assert!(f.parts.contains_key(&id));
|
||||
d.insert_fragment(&Pos::body(2, 5), &f).unwrap();
|
||||
let ids: Vec<u32> = d
|
||||
.para_paths(StoryRef::Body)
|
||||
.iter()
|
||||
.filter_map(|p| d.para(StoryRef::Body, p))
|
||||
.flat_map(|p| p.objects.iter())
|
||||
.filter_map(|o| if let InlineObject::Shape { story, .. } = o { *story } else { None })
|
||||
.collect();
|
||||
assert_eq!(ids.len(), 2, "{ids:?}");
|
||||
assert_ne!(ids[0], ids[1]);
|
||||
assert!(ids.iter().all(|i| d.plain_text(StoryRef::Part(*i)) == "In a cell"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fragment_without_parts_never_shows_an_unrelated_story() {
|
||||
let mut d = doc(&["x"]);
|
||||
let hdr = d.add_part(PartKind::Header, Vec::new());
|
||||
let own = d.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text("Mine", CharProps::default()))]);
|
||||
let mut p = Paragraph::new();
|
||||
p.insert_object(0, box_shape(Some(hdr)), &CharProps::default()).unwrap();
|
||||
p.insert_object(0, box_shape(Some(own)), &CharProps::default()).unwrap();
|
||||
p.insert_object(0, box_shape(Some(999)), &CharProps::default()).unwrap();
|
||||
let f = Fragment { blocks: vec![Block::Para(p)], ..Default::default() };
|
||||
d.insert_fragment(&Pos::body(0, 1), &f).unwrap();
|
||||
let got = boxes_in(&d, &Path::top(0));
|
||||
assert_eq!(got.len(), 3);
|
||||
assert_eq!(got[0], None, "missing story");
|
||||
let Some(copy) = got[1] else { panic!("{got:?}") };
|
||||
assert_ne!(copy, own);
|
||||
assert_eq!(d.plain_text(StoryRef::Part(copy)), "Mine");
|
||||
assert_eq!(got[2], None, "a header isn't a text box");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_nested_text_box_paste_is_bounded() {
|
||||
let mut d = doc(&["x"]);
|
||||
let id = text_box(&mut d, &Pos::body(0, 1), "loop");
|
||||
// The box contains a box showing itself.
|
||||
d.insert_object(&Pos { story: StoryRef::Part(id), path: Path::top(0), off: 0 }, box_shape(Some(id)), &CharProps::default()).unwrap();
|
||||
let f = d.copy_range(&Pos::body(0, 0), &Pos::body(0, 1 + 3));
|
||||
let before = d.parts.len();
|
||||
d.insert_fragment(&Pos::body(0, 0), &f).unwrap();
|
||||
let added = d.parts.len() - before;
|
||||
assert!((1..=MAX_BOX_DEPTH).contains(&added), "{added}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tables_in_paths() {
|
||||
let mut d = doc(&["before", "after"]);
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(Table::new(2, 2, 200.0))).unwrap();
|
||||
let paths = d.para_paths(StoryRef::Body);
|
||||
assert_eq!(paths.len(), 6);
|
||||
assert_eq!(paths[1], Path(vec![1, 0, 0, 0]));
|
||||
let cell = Pos { story: StoryRef::Body, path: Path(vec![1, 0, 1, 0]), off: 0 };
|
||||
let e = d.insert_text(&cell, "x", &CharProps::default()).unwrap();
|
||||
assert_eq!(e.off, 1);
|
||||
assert_eq!(d.next_para(StoryRef::Body, &Path(vec![1, 0, 1, 0])), Some(Path(vec![1, 1, 0, 0])));
|
||||
assert!(d.plain_text(StoryRef::Body).contains("\tx"));
|
||||
// Delete from before the table into a cell: text cleared, table kept (partially covered).
|
||||
d.delete_range(&Pos::body(0, 2), &cell).unwrap();
|
||||
assert_eq!(d.para(StoryRef::Body, &Path::top(0)).unwrap().text, "be");
|
||||
assert!(d.container(StoryRef::Body, &Path(vec![1, 0, 0, 0])).is_some());
|
||||
assert_eq!(Path(vec![1, 0, 1, 0]).cell(), Some((Path::top(1), 0, 1)));
|
||||
}
|
||||
|
||||
fn para(text: &str) -> Block {
|
||||
Block::Para(Paragraph::with_text(text, CharProps::default()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appended_pieces_are_each_followed_by_one_paragraph_mark() {
|
||||
let mut spike = Fragment::default();
|
||||
spike.append_piece(Fragment { blocks: vec![para("a")], ..Default::default() });
|
||||
spike.append_piece(Fragment { blocks: vec![para("b")], ..Default::default() });
|
||||
assert_eq!(spike.plain_text(), "a\nb\n");
|
||||
// A piece that already ends with its paragraph mark gets no second one.
|
||||
let mut spike = Fragment::default();
|
||||
spike.append_piece(Fragment { blocks: vec![para("a"), para("")], ..Default::default() });
|
||||
assert_eq!(spike.plain_text(), "a\n");
|
||||
spike.append_piece(Fragment { blocks: vec![para("b"), para("c")], ..Default::default() });
|
||||
assert_eq!(spike.plain_text(), "a\nb\nc\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appended_pieces_keep_their_own_text_boxes() {
|
||||
// Two documents give their first text box the same id.
|
||||
let mut one = doc(&["x"]);
|
||||
let id1 = text_box(&mut one, &Pos::body(0, 1), "One");
|
||||
let mut two = doc(&["y"]);
|
||||
let id2 = text_box(&mut two, &Pos::body(0, 1), "Two");
|
||||
assert_eq!(id1, id2);
|
||||
let mut spike = Fragment::default();
|
||||
spike.append_piece(one.copy_range(&Pos::body(0, 0), &Pos::body(0, 1 + 3)));
|
||||
spike.append_piece(two.copy_range(&Pos::body(0, 0), &Pos::body(0, 1 + 3)));
|
||||
let mut dst = doc(&[""]);
|
||||
dst.insert_fragment(&Pos::body(0, 0), &spike).unwrap();
|
||||
let texts: Vec<String> =
|
||||
[0, 1].iter().flat_map(|i| boxes_in(&dst, &Path::top(*i))).map(|b| dst.plain_text(StoryRef::Part(b.unwrap()))).collect();
|
||||
assert_eq!(texts, ["One", "Two"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_positions_error() {
|
||||
let mut d = doc(&["é"]);
|
||||
assert!(d.insert_text(&Pos::body(0, 1), "x", &CharProps::default()).is_err());
|
||||
assert!(d.insert_text(&Pos::body(5, 0), "x", &CharProps::default()).is_err());
|
||||
assert!(d.split_paragraph(&Pos::body(0, 99)).is_err());
|
||||
let c = d.clamp(&Pos::body(9, 9));
|
||||
assert_eq!(c, Pos::body(0, 2));
|
||||
assert!(d.delete_range(&Pos { story: StoryRef::Part(3), path: Path::top(0), off: 0 }, &Pos::body(0, 0)).is_err());
|
||||
}
|
||||
|
||||
// delete_range over whole tables (#1844), in the body, a header and a footnote.
|
||||
|
||||
/// A document whose story (the body, or a header or footnote part) holds `lines`.
|
||||
fn story_doc(kind: Option<PartKind>, lines: &[&str]) -> (Document, StoryRef) {
|
||||
match kind {
|
||||
None => (doc(lines), StoryRef::Body),
|
||||
Some(k) => {
|
||||
let mut d = doc(&["body"]);
|
||||
let blocks = lines.iter().map(|l| para_block(Paragraph::with_text(l, CharProps::default()))).collect();
|
||||
let id = d.add_part(k, blocks);
|
||||
(d, StoryRef::Part(id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const STORIES: [Option<PartKind>; 3] = [None, Some(PartKind::Header), Some(PartKind::Footnote)];
|
||||
|
||||
fn at(story: StoryRef, path: &[u32], off: usize) -> Pos {
|
||||
Pos { story, path: Path(path.to_vec()), off }
|
||||
}
|
||||
|
||||
/// Insert an `rows`×`cols` table at `path` whose cells hold `texts`, row by row.
|
||||
fn put_table(d: &mut Document, story: StoryRef, path: &[u32], rows: usize, cols: usize, texts: &[&str]) {
|
||||
d.insert_block(story, &Path(path.to_vec()), Block::Table(Table::new(rows, cols, 200.0))).unwrap();
|
||||
for (k, t) in texts.iter().enumerate() {
|
||||
let mut p = path.to_vec();
|
||||
p.extend([(k / cols) as u32, (k % cols) as u32, 0]);
|
||||
d.insert_text(&at(story, &p, 0), t, &CharProps::default()).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// The story's top-level blocks: a paragraph's text, or `[table]`.
|
||||
fn shape(d: &Document, story: StoryRef) -> Vec<String> {
|
||||
d.story(story).unwrap().iter().map(|b| b.as_para().map_or("[table]".to_string(), |p| p.text.clone())).collect()
|
||||
}
|
||||
|
||||
fn cell_text(d: &Document, story: StoryRef, table: &[u32], r: u32, c: u32) -> String {
|
||||
let mut p = table.to_vec();
|
||||
p.extend([r, c, 0]);
|
||||
d.para(story, &Path(p)).map(|p| p.text.clone()).unwrap_or_else(|| "<none>".into())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_range_ending_at_the_end_of_a_cell_that_isnt_the_last_keeps_the_table() {
|
||||
for kind in STORIES {
|
||||
for (r, c) in [(0, 1), (1, 0)] {
|
||||
let (mut d, st) = story_doc(kind, &["before", "after"]);
|
||||
put_table(&mut d, st, &[1], 2, 2, &["a", "b", "c", "d"]);
|
||||
d.delete_range(&at(st, &[0], 0), &at(st, &[1, r, c, 0], 1)).unwrap();
|
||||
assert_eq!(shape(&d, st), ["", "[table]", "after"], "{kind:?} ({r},{c})");
|
||||
assert_eq!(cell_text(&d, st, &[1], 1, 1), "d", "{kind:?} ({r},{c})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_range_from_the_start_of_one_table_into_the_middle_of_the_next_removes_only_the_first() {
|
||||
for kind in STORIES {
|
||||
let (mut d, st) = story_doc(kind, &["x", "mid", "y"]);
|
||||
put_table(&mut d, st, &[2], 2, 2, &["e", "f", "g", "h"]);
|
||||
put_table(&mut d, st, &[1], 2, 2, &["a", "b", "c", "d"]);
|
||||
assert_eq!(shape(&d, st), ["x", "[table]", "mid", "[table]", "y"]);
|
||||
let to = d.delete_range(&at(st, &[1, 0, 0, 0], 0), &at(st, &[3, 0, 1, 0], 1)).unwrap();
|
||||
assert_eq!(shape(&d, st), ["x", "", "[table]", "y"], "{kind:?}");
|
||||
assert_eq!([cell_text(&d, st, &[2], 0, 0), cell_text(&d, st, &[2], 0, 1), cell_text(&d, st, &[2], 1, 0)], ["", "", "g"], "{kind:?}");
|
||||
assert!(d.para(st, &to.path).is_some(), "{kind:?}: {to:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_range_from_a_cell_paragraph_to_the_end_of_a_table_nested_in_that_cell_removes_only_the_nested_table() {
|
||||
for kind in STORIES {
|
||||
let (mut d, st) = story_doc(kind, &["before", "after"]);
|
||||
put_table(&mut d, st, &[1], 1, 1, &["p"]);
|
||||
put_table(&mut d, st, &[1, 0, 0, 1], 1, 1, &["n"]);
|
||||
d.insert_block(st, &Path(vec![1, 0, 0, 2]), Block::Para(Paragraph::with_text("q", CharProps::default()))).unwrap();
|
||||
let to = d.delete_range(&at(st, &[1, 0, 0, 0], 1), &at(st, &[1, 0, 0, 1, 0, 0, 0], 1)).unwrap();
|
||||
assert_eq!(shape(&d, st), ["before", "[table]", "after"], "{kind:?}");
|
||||
let cell = d.container(st, &Path(vec![1, 0, 0, 0])).unwrap();
|
||||
let cell: Vec<String> = cell.iter().map(|b| b.as_para().map_or("[table]".to_string(), |p| p.text.clone())).collect();
|
||||
assert_eq!(cell, ["pq"], "{kind:?}");
|
||||
assert_eq!(to, at(st, &[1, 0, 0, 0], 1), "{kind:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_whole_tables_at_the_end_of_a_story_leave_one_empty_paragraph() {
|
||||
for kind in STORIES {
|
||||
for trailing in [true, false] {
|
||||
let (mut d, st) = story_doc(kind, &[""]);
|
||||
put_table(&mut d, st, &[0], 1, 2, &["a", "b"]);
|
||||
put_table(&mut d, st, &[1], 2, 1, &["c", "d"]);
|
||||
if !trailing {
|
||||
// A story that ends in a table (a hostile file).
|
||||
d.story_mut(st).unwrap().pop();
|
||||
}
|
||||
let to = d.delete_range(&at(st, &[0, 0, 0, 0], 0), &at(st, &[1, 1, 0, 0], 1)).unwrap();
|
||||
assert_eq!(shape(&d, st), [""], "{kind:?} trailing {trailing}");
|
||||
assert_eq!(to, at(st, &[0], 0), "{kind:?} trailing {trailing}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Content controls pair their markers within one block list (a story, or a cell), so a
|
||||
/// marker in a table never has its partner outside it: a control inside a table removed whole
|
||||
/// goes with it, and one the range only enters keeps both markers (the control is emptied).
|
||||
#[test]
|
||||
fn removing_a_whole_table_keeps_a_control_the_range_only_partly_covers() {
|
||||
use crate::control::ContentControl;
|
||||
let start = || InlineObject::ControlStart { control: Box::new(ContentControl::default()) };
|
||||
for kind in STORIES {
|
||||
let (mut d, st) = story_doc(kind, &["before", "after"]);
|
||||
put_table(&mut d, st, &[1], 1, 1, &["c"]);
|
||||
// One control around "efo" in "before", one around the cell's text.
|
||||
d.insert_object(&at(st, &[0], 4), InlineObject::ControlEnd, &CharProps::default()).unwrap();
|
||||
let in_before = d.insert_object(&at(st, &[0], 1), start(), &CharProps::default()).unwrap();
|
||||
d.insert_object(&at(st, &[1, 0, 0, 0], 1), InlineObject::ControlEnd, &CharProps::default()).unwrap();
|
||||
d.insert_object(&at(st, &[1, 0, 0, 0], 0), start(), &CharProps::default()).unwrap();
|
||||
assert_eq!(d.control_ranges(st).len(), 2, "setup");
|
||||
let end = d.para(st, &Path(vec![1, 0, 0, 0])).unwrap().len();
|
||||
// From inside the first control to the end of the table.
|
||||
d.delete_range(&Pos { off: in_before.off + 1, ..in_before }, &at(st, &[1, 0, 0, 0], end)).unwrap();
|
||||
assert_eq!(shape(&d, st), ["b\u{FFFC}e\u{FFFC}after"], "{kind:?}: the table goes, the paragraphs join");
|
||||
let ranges = d.control_ranges(st);
|
||||
assert_eq!(ranges.len(), 1, "{kind:?}: {ranges:?}");
|
||||
assert!(ranges.iter().all(|r| r.start.path == Path::top(0) && r.end.path == Path::top(0)), "{kind:?}");
|
||||
let objects = d.para(st, &Path::top(0)).map_or(0, |p| p.objects.len());
|
||||
assert_eq!(objects, 2, "{kind:?}: no orphan markers left");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_table_nested_deeper_than_the_paragraph_walk_is_never_removed_whole() {
|
||||
// 20 tables, each in the first cell of the one around it, ahead of that cell's paragraph:
|
||||
// the innermost paragraphs come first in the document but the paragraph walk stops short.
|
||||
let mut t = Table::new(1, 1, 200.0);
|
||||
if let Some(cell) = t.rows.get_mut(0).and_then(|r| r.cells.get_mut(0)) {
|
||||
cell.blocks = vec![para_block(Paragraph::with_text("deep", CharProps::default()))];
|
||||
}
|
||||
for _ in 0..20 {
|
||||
let mut outer = Table::new(1, 1, 200.0);
|
||||
if let Some(cell) = outer.rows.get_mut(0).and_then(|r| r.cells.get_mut(0)) {
|
||||
cell.blocks.insert(0, Arc::new(Block::Table(t)));
|
||||
}
|
||||
t = outer;
|
||||
}
|
||||
let mut d = doc(&["before", "after"]);
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(t)).unwrap();
|
||||
let first = d.para_paths(StoryRef::Body).into_iter().find(|p| p.0.first() == Some(&1)).unwrap();
|
||||
d.delete_range(&Pos { story: StoryRef::Body, path: first, off: 0 }, &Pos::body(2, 0)).unwrap();
|
||||
assert!(d.body.get(1).is_some_and(|b| b.as_table().is_some()), "a table with paragraphs the walk never saw was removed");
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,666 @@
|
||||
use super::*;
|
||||
use crate::{StoryRef, Table};
|
||||
|
||||
fn doc(lines: &[&str]) -> Document {
|
||||
Document::from_text(&lines.join("\n"))
|
||||
}
|
||||
|
||||
/// Paths between two positions are those of all the story's paragraphs between them.
|
||||
#[test]
|
||||
fn paths_between_matches_every_path_filtered() {
|
||||
let mut d = doc(&["a", "b", "c", "d", "e"]);
|
||||
let mut inner = Table::new(2, 2, 100.0);
|
||||
inner.rows[1].cells[0].blocks.push(crate::para_block(crate::Paragraph::new()));
|
||||
let mut t = Table::new(3, 2, 200.0);
|
||||
t.rows[1].cells[1].blocks.insert(0, Arc::new(Block::Table(inner)));
|
||||
d.insert_block(StoryRef::Body, &Path::top(2), Block::Table(t)).unwrap();
|
||||
d.insert_block(StoryRef::Body, &Path::top(4), Block::Table(Table::new(1, 3, 200.0))).unwrap();
|
||||
let all = d.para_paths(StoryRef::Body);
|
||||
for a in &all {
|
||||
for b in &all {
|
||||
let (pa, pb) = (Pos { story: StoryRef::Body, path: a.clone(), off: 0 }, Pos { story: StoryRef::Body, path: b.clone(), off: 0 });
|
||||
let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
|
||||
let want: Vec<Path> = all.iter().filter(|p| *p >= lo && *p <= hi).cloned().collect();
|
||||
assert_eq!(d.paths_between(&pa, &pb), want, "{a:?} {b:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_and_join() {
|
||||
let mut d = doc(&["Hello world"]);
|
||||
let p = d.split_paragraph(&Pos::body(0, 5)).unwrap();
|
||||
assert_eq!(p, Pos::body(1, 0));
|
||||
assert_eq!(d.plain_text(StoryRef::Body), "Hello\n world");
|
||||
let at = d.delete_range(&Pos::body(0, 5), &Pos::body(1, 0)).unwrap();
|
||||
assert_eq!(at, Pos::body(0, 5));
|
||||
assert_eq!(d.plain_text(StoryRef::Body), "Hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_range_with_bad_end_leaves_document_unchanged() {
|
||||
let mut d = doc(&["one", "two", "three"]);
|
||||
let before = d.plain_text(StoryRef::Body);
|
||||
// Missing paragraph as the second end.
|
||||
assert!(d.delete_range(&Pos::body(0, 1), &Pos::body(9, 0)).is_err());
|
||||
assert_eq!(d.plain_text(StoryRef::Body), before);
|
||||
// Offset past the end of the second paragraph.
|
||||
assert!(d.delete_range(&Pos::body(0, 1), &Pos::body(1, 99)).is_err());
|
||||
assert_eq!(d.plain_text(StoryRef::Body), before);
|
||||
// Offset inside a multi-byte character.
|
||||
let mut d = doc(&["abc", "\u{e9}x"]);
|
||||
assert!(d.delete_range(&Pos::body(0, 1), &Pos::body(1, 1)).is_err());
|
||||
assert_eq!(d.plain_text(StoryRef::Body), "abc\n\u{e9}x");
|
||||
// Range entering a table with a bad offset at its start: the cell text stays.
|
||||
let mut d = doc(&["before", "after"]);
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(Table::new(2, 1, 200.0))).unwrap();
|
||||
d.insert_text(&Pos { story: StoryRef::Body, path: Path(vec![1, 0, 0, 0]), off: 0 }, "x", &CharProps::default()).unwrap();
|
||||
let before = d.plain_text(StoryRef::Body);
|
||||
let cell = Pos { story: StoryRef::Body, path: Path(vec![1, 1, 0, 0]), off: 0 };
|
||||
assert!(d.delete_range(&Pos::body(0, 99), &cell).is_err());
|
||||
assert_eq!(d.plain_text(StoryRef::Body), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_multi() {
|
||||
let mut d = doc(&["one", "two", "three"]);
|
||||
d.delete_range(&Pos::body(2, 2), &Pos::body(0, 1)).unwrap();
|
||||
assert_eq!(d.plain_text(StoryRef::Body), "oree");
|
||||
assert_eq!(d.body.len(), 1);
|
||||
}
|
||||
|
||||
/// #2127: a range over two section breaks joins three sections into the last; the headers
|
||||
/// and footers it showed by linking to earlier ones stay, each from the nearest section that
|
||||
/// named it, as they resolved before the delete.
|
||||
#[test]
|
||||
fn delete_over_two_section_breaks_keeps_the_linked_headers() {
|
||||
let mut d = doc(&["A", "B", "C"]);
|
||||
let set = |default| crate::section::HeaderSet { default: Some(default), ..Default::default() };
|
||||
let s1 = crate::section::SectionProps { headers: set(1), footers: set(5), ..Default::default() };
|
||||
let s2 = crate::section::SectionProps { headers: set(2), ..Default::default() };
|
||||
let s3 = crate::section::SectionProps { margin_left: 99.0, ..Default::default() };
|
||||
for (i, s) in [(0, s1), (1, s2), (2, s3.clone())] {
|
||||
if let Some(Block::Para(p)) = d.body.get_mut(i).map(Arc::make_mut) {
|
||||
p.section = Some(Box::new(s));
|
||||
}
|
||||
}
|
||||
assert_eq!(d.sections().len(), 4);
|
||||
d.delete_range(&Pos::body(0, 1), &Pos::body(2, 0)).unwrap();
|
||||
assert_eq!(d.plain_text(StoryRef::Body), "AC");
|
||||
let sections = d.sections();
|
||||
assert_eq!(sections.len(), 2);
|
||||
assert_eq!(*sections[0].1, crate::section::SectionProps { headers: set(2), footers: set(5), ..s3 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_across() {
|
||||
let mut d = doc(&["abc", "def"]);
|
||||
d.format_range(&Pos::body(0, 1), &Pos::body(1, 2), &|c| c.bold = Some(true)).unwrap();
|
||||
let p0 = d.para(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
assert_eq!(p0.props_of_char(0).bold, None);
|
||||
assert_eq!(p0.props_of_char(1).bold, Some(true));
|
||||
let p1 = d.para(StoryRef::Body, &Path::top(1)).unwrap();
|
||||
assert_eq!(p1.props_of_char(1).bold, Some(true));
|
||||
assert_eq!(p1.props_of_char(2).bold, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_paste_round_trip() {
|
||||
let mut d = doc(&["alpha", "beta", "gamma"]);
|
||||
let f = d.copy_range(&Pos::body(0, 2), &Pos::body(2, 3));
|
||||
assert_eq!(f.plain_text(), "pha\nbeta\ngam");
|
||||
let end = d.insert_fragment(&Pos::body(2, 5), &f).unwrap();
|
||||
assert_eq!(d.plain_text(StoryRef::Body), "alpha\nbeta\ngammapha\nbeta\ngam");
|
||||
assert_eq!(end, Pos::body(4, 3));
|
||||
let one = Fragment::from_text("XY");
|
||||
let e = d.insert_fragment(&Pos::body(0, 0), &one).unwrap();
|
||||
assert_eq!(e, Pos::body(0, 2));
|
||||
assert!(d.plain_text(StoryRef::Body).starts_with("XYalpha"));
|
||||
}
|
||||
|
||||
/// The first paragraph of the deepest cell, following the first table at each level.
|
||||
fn deepest(d: &Document) -> Path {
|
||||
let (mut path, mut blocks) = (Vec::new(), &d.body);
|
||||
while let Some((i, t)) = blocks.iter().enumerate().find_map(|(i, b)| if let Block::Table(t) = &**b { Some((i, t)) } else { None }) {
|
||||
path.extend([i as u32, 0, 0]);
|
||||
blocks = &t.rows[0].cells[0].blocks;
|
||||
}
|
||||
path.push(0);
|
||||
Path(path)
|
||||
}
|
||||
|
||||
fn body_height(d: &Document) -> usize {
|
||||
d.body.iter().filter_map(|b| if let Block::Table(t) = &**b { Some(table_height(t)) } else { None }).max().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// How many times `c` is in `blocks`, the deepest cells too.
|
||||
fn count(blocks: &[Arc<Block>], c: char) -> usize {
|
||||
blocks
|
||||
.iter()
|
||||
.map(|b| match &**b {
|
||||
Block::Para(p) => p.text.matches(c).count(),
|
||||
Block::Table(t) => t.rows.iter().flat_map(|r| &r.cells).map(|cell| count(&cell.blocks, c)).sum(),
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tables_nest_at_most_max_table_depth() {
|
||||
let mut d = doc(&["x", ""]);
|
||||
for _ in 0..MAX_TABLE_DEPTH {
|
||||
let at = deepest(&d);
|
||||
d.insert_block(StoryRef::Body, &at, Block::Table(Table::new(1, 1, 100.0))).unwrap();
|
||||
}
|
||||
assert_eq!(body_height(&d), MAX_TABLE_DEPTH);
|
||||
// One more in the deepest cell is refused and changes nothing; a paragraph is fine.
|
||||
let (at, before) = (deepest(&d), d.clone());
|
||||
assert_eq!(at.depth(), MAX_TABLE_DEPTH);
|
||||
assert!(d.insert_block(StoryRef::Body, &at, Block::Table(Table::new(1, 1, 100.0))).is_err());
|
||||
assert_eq!(d, before);
|
||||
d.insert_block(StoryRef::Body, &at, Block::Para(Paragraph::new())).unwrap();
|
||||
// One level up, a table fits but a table holding a table doesn't.
|
||||
let up = Path(at.0[..at.0.len() - 3].to_vec());
|
||||
let mut two = Table::new(1, 1, 100.0);
|
||||
two.rows[0].cells[0].blocks.insert(0, Arc::new(Block::Table(Table::new(1, 1, 100.0))));
|
||||
assert!(d.insert_block(StoryRef::Body, &up, Block::Table(two)).is_err());
|
||||
d.insert_block(StoryRef::Body, &up, Block::Table(Table::new(1, 1, 100.0))).unwrap();
|
||||
assert_eq!(body_height(&d), MAX_TABLE_DEPTH);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasting_a_table_into_its_own_cell_stops_at_max_table_depth() {
|
||||
// #1596: copy everything and paste it into the deepest cell, again and again: each round
|
||||
// doubled the nesting, until saving, copying or closing the document overflowed the stack.
|
||||
let mut d = doc(&["x", ""]);
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(Table::new(1, 1, 100.0))).unwrap();
|
||||
d.insert_text(&Pos { story: StoryRef::Body, path: Path(vec![1, 0, 0, 0]), off: 0 }, "a", &CharProps::default()).unwrap();
|
||||
for _ in 0..8 {
|
||||
let all = d.copy_range(&Pos::body(0, 0), &Pos::body(d.body.len() - 1, 0));
|
||||
let at = deepest(&d);
|
||||
let off = d.para(StoryRef::Body, &at).map_or(0, Paragraph::len);
|
||||
d.insert_fragment(&Pos { story: StoryRef::Body, path: at, off }, &all).unwrap();
|
||||
}
|
||||
assert_eq!(body_height(&d), MAX_TABLE_DEPTH);
|
||||
// Nothing is lost: every copy's text is there, below the limit as paragraphs.
|
||||
assert_eq!((count(&d.body, 'x'), count(&d.body, 'a')), (256, 256));
|
||||
}
|
||||
|
||||
fn text_box(d: &mut Document, pos: &Pos, text: &str) -> u32 {
|
||||
let id = d.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text(text, CharProps::default()))]);
|
||||
d.insert_object(pos, box_shape(Some(id)), &CharProps::default()).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
fn box_shape(story: Option<u32>) -> InlineObject {
|
||||
InlineObject::Shape {
|
||||
kind: crate::para::ShapeKind::TextBox,
|
||||
w: 144.0,
|
||||
h: 72.0,
|
||||
fill: None,
|
||||
stroke: None,
|
||||
stroke_width: 0.75,
|
||||
float: Default::default(),
|
||||
story,
|
||||
freeform: None,
|
||||
effects: Default::default(),
|
||||
extra: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Story ids of the boxes in a body paragraph, in order.
|
||||
fn boxes_in(d: &Document, path: &Path) -> Vec<Option<u32>> {
|
||||
let p = d.para(StoryRef::Body, path).unwrap();
|
||||
p.objects.iter().filter_map(|o| if let InlineObject::Shape { story, .. } = o { Some(*story) } else { None }).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasted_text_box_gets_its_own_story() {
|
||||
let mut d = doc(&["ab", "cd"]);
|
||||
let id = text_box(&mut d, &Pos::body(0, 1), "Inside");
|
||||
let f = d.copy_range(&Pos::body(0, 0), &Pos::body(0, 5));
|
||||
assert_eq!(f.parts.keys().copied().collect::<Vec<_>>(), vec![id]);
|
||||
// Edit the original after copying: the paste keeps the copied text.
|
||||
d.insert_text(&Pos { story: StoryRef::Part(id), path: Path::top(0), off: 6 }, " later", &CharProps::default()).unwrap();
|
||||
d.insert_fragment(&Pos::body(1, 2), &f).unwrap();
|
||||
let pasted = boxes_in(&d, &Path::top(1));
|
||||
let [Some(new)] = pasted[..] else { panic!("{pasted:?}") };
|
||||
assert_ne!(new, id);
|
||||
assert_eq!(d.parts.get(&new).map(|p| p.kind), Some(PartKind::TextBox));
|
||||
assert_eq!(d.plain_text(StoryRef::Part(new)), "Inside");
|
||||
assert_eq!(d.plain_text(StoryRef::Part(id)), "Inside later");
|
||||
// Editing the copy leaves the original alone; a second paste is a third story.
|
||||
d.insert_text(&Pos { story: StoryRef::Part(new), path: Path::top(0), off: 0 }, "Copy: ", &CharProps::default()).unwrap();
|
||||
assert_eq!(d.plain_text(StoryRef::Part(id)), "Inside later");
|
||||
d.insert_fragment(&Pos::body(1, 0), &f).unwrap();
|
||||
let again = boxes_in(&d, &Path::top(1));
|
||||
assert_eq!(again.len(), 2);
|
||||
assert!(again.iter().all(|s| s.is_some_and(|s| s != id)) && again[0] != again[1], "{again:?}");
|
||||
// Multi-paragraph pastes too.
|
||||
let f2 = d.copy_range(&Pos::body(0, 0), &Pos::body(1, 1));
|
||||
let before = d.parts.len();
|
||||
d.insert_fragment(&Pos::body(1, 0), &f2).unwrap();
|
||||
assert_eq!(d.parts.len(), before + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_box_pasted_into_another_document_brings_its_text() {
|
||||
let mut src = doc(&["x"]);
|
||||
let id = text_box(&mut src, &Pos::body(0, 1), "From the source");
|
||||
let f = src.copy_range(&Pos::body(0, 0), &Pos::body(0, 1 + 3));
|
||||
// The target already has a header with the same part id.
|
||||
let mut dst = doc(&["target"]);
|
||||
let hdr = dst.add_part(PartKind::Header, vec![para_block(Paragraph::with_text("Header text", CharProps::default()))]);
|
||||
assert_eq!(hdr, id);
|
||||
dst.insert_fragment(&Pos::body(0, 6), &f).unwrap();
|
||||
let [Some(new)] = boxes_in(&dst, &Path::top(0))[..] else { panic!() };
|
||||
assert_ne!(new, hdr);
|
||||
assert_eq!(dst.plain_text(StoryRef::Part(new)), "From the source");
|
||||
assert_eq!(dst.plain_text(StoryRef::Part(hdr)), "Header text");
|
||||
// The whole body of a document, as Insert › Text from File uses it.
|
||||
let mut dst2 = doc(&["t"]);
|
||||
dst2.insert_fragment(&Pos::body(0, 1), &src.body_fragment()).unwrap();
|
||||
let [Some(n2)] = boxes_in(&dst2, &Path::top(0))[..] else { panic!() };
|
||||
assert_eq!(dst2.plain_text(StoryRef::Part(n2)), "From the source");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_box_in_a_copied_table_gets_its_own_story() {
|
||||
let mut d = doc(&["before", "after"]);
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(Table::new(1, 1, 200.0))).unwrap();
|
||||
let cell = Pos { story: StoryRef::Body, path: Path(vec![1, 0, 0, 0]), off: 0 };
|
||||
let id = text_box(&mut d, &cell, "In a cell");
|
||||
let f = d.copy_range(&Pos::body(0, 0), &Pos::body(2, 5));
|
||||
assert!(f.parts.contains_key(&id));
|
||||
d.insert_fragment(&Pos::body(2, 5), &f).unwrap();
|
||||
let ids: Vec<u32> = d
|
||||
.para_paths(StoryRef::Body)
|
||||
.iter()
|
||||
.filter_map(|p| d.para(StoryRef::Body, p))
|
||||
.flat_map(|p| p.objects.iter())
|
||||
.filter_map(|o| if let InlineObject::Shape { story, .. } = o { *story } else { None })
|
||||
.collect();
|
||||
assert_eq!(ids.len(), 2, "{ids:?}");
|
||||
assert_ne!(ids[0], ids[1]);
|
||||
assert!(ids.iter().all(|i| d.plain_text(StoryRef::Part(*i)) == "In a cell"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fragment_without_parts_never_shows_an_unrelated_story() {
|
||||
let mut d = doc(&["x"]);
|
||||
let hdr = d.add_part(PartKind::Header, Vec::new());
|
||||
let own = d.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text("Mine", CharProps::default()))]);
|
||||
let mut p = Paragraph::new();
|
||||
p.insert_object(0, box_shape(Some(hdr)), &CharProps::default()).unwrap();
|
||||
p.insert_object(0, box_shape(Some(own)), &CharProps::default()).unwrap();
|
||||
p.insert_object(0, box_shape(Some(999)), &CharProps::default()).unwrap();
|
||||
let f = Fragment { blocks: vec![Block::Para(p)], ..Default::default() };
|
||||
d.insert_fragment(&Pos::body(0, 1), &f).unwrap();
|
||||
let got = boxes_in(&d, &Path::top(0));
|
||||
assert_eq!(got.len(), 3);
|
||||
assert_eq!(got[0], None, "missing story");
|
||||
let Some(copy) = got[1] else { panic!("{got:?}") };
|
||||
assert_ne!(copy, own);
|
||||
assert_eq!(d.plain_text(StoryRef::Part(copy)), "Mine");
|
||||
assert_eq!(got[2], None, "a header isn't a text box");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_nested_text_box_paste_is_bounded() {
|
||||
let mut d = doc(&["x"]);
|
||||
let id = text_box(&mut d, &Pos::body(0, 1), "loop");
|
||||
// The box contains a box showing itself.
|
||||
d.insert_object(&Pos { story: StoryRef::Part(id), path: Path::top(0), off: 0 }, box_shape(Some(id)), &CharProps::default()).unwrap();
|
||||
let f = d.copy_range(&Pos::body(0, 0), &Pos::body(0, 1 + 3));
|
||||
let before = d.parts.len();
|
||||
d.insert_fragment(&Pos::body(0, 0), &f).unwrap();
|
||||
let added = d.parts.len() - before;
|
||||
// The copy's inner box would show the story being copied: a cycle, so it gets none.
|
||||
assert_eq!(added, 1);
|
||||
}
|
||||
|
||||
/// A fragment whose body is one paragraph of `refs` text boxes all showing story 1.
|
||||
fn fan_out(story: Vec<Arc<Block>>, refs: usize) -> Fragment {
|
||||
let mut p = Paragraph::new();
|
||||
for _ in 0..refs {
|
||||
p.insert_object(0, box_shape(Some(1)), &CharProps::default()).unwrap();
|
||||
}
|
||||
let mut parts = BTreeMap::new();
|
||||
parts.insert(1, crate::Part { kind: PartKind::TextBox, blocks: story, body: Default::default() });
|
||||
Fragment { blocks: vec![Block::Para(p)], parts }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasted_boxes_share_the_blocks_they_need_not_copy() {
|
||||
let big = para_block(Paragraph::with_text(&"x".repeat(100_000), CharProps::default()));
|
||||
let f = fan_out(vec![big.clone()], 3);
|
||||
let mut d = doc(&["x"]);
|
||||
d.insert_fragment(&Pos::body(0, 1), &f).unwrap();
|
||||
let got = boxes_in(&d, &Path::top(0));
|
||||
assert_eq!(got.len(), 3);
|
||||
let ids: Vec<u32> = got.into_iter().flatten().collect();
|
||||
assert_eq!(ids.len(), 3, "each box has a story");
|
||||
assert!(ids.windows(2).all(|w| w[0] != w[1]), "stories of their own: {ids:?}");
|
||||
for id in ids {
|
||||
let b = d.parts[&id].blocks.first().unwrap();
|
||||
assert!(Arc::ptr_eq(b, &big), "no text box inside: shared, not copied");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_box_over_the_budget_leaves_later_smaller_boxes_their_stories() {
|
||||
// Review of #3874: once one story didn't fit, every later box in the paste was left
|
||||
// empty, however small its story. Only the box that doesn't fit is.
|
||||
// (Story 1 holds a text box, so each box showing it needs a real copy.)
|
||||
let mut bp = Paragraph::with_text(&"x".repeat(100_000), CharProps::default());
|
||||
bp.insert_object(0, box_shape(Some(999)), &CharProps::default()).unwrap();
|
||||
let big = para_block(bp);
|
||||
let small = para_block(Paragraph::with_text("small", CharProps::default()));
|
||||
let mut p = Paragraph::new();
|
||||
for story in [2, 1, 2, 1] {
|
||||
p.insert_object(0, box_shape(Some(story)), &CharProps::default()).unwrap();
|
||||
}
|
||||
let mut parts = BTreeMap::new();
|
||||
parts.insert(1, crate::Part { kind: PartKind::TextBox, blocks: vec![big], body: Default::default() });
|
||||
parts.insert(2, crate::Part { kind: PartKind::TextBox, blocks: vec![small], body: Default::default() });
|
||||
let mut blocks = vec![Block::Para(p)];
|
||||
let mut d = doc(&["x"]);
|
||||
let mut budget = BoxBudget { bytes: 50_000, ..BoxBudget::new() };
|
||||
d.adopt_text_boxes(&mut blocks, &parts, 0, &mut budget);
|
||||
let Some(Block::Para(p)) = blocks.first() else { panic!() };
|
||||
let got: Vec<Option<u32>> = p.objects.iter().map(|o| if let InlineObject::Shape { story, .. } = o { *story } else { None }).collect();
|
||||
assert_eq!(got.iter().map(Option::is_some).collect::<Vec<_>>(), [false, true, false, true], "{got:?}");
|
||||
assert_eq!(budget.skipped, 2);
|
||||
assert_eq!(budget.costs.len(), 2, "each story costed once");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_boxes_sharing_one_story_paste_with_their_text() {
|
||||
// Review of #3874: a document converted from PDF has thousands of boxes showing story
|
||||
// data they share. 64 boxes x a 1 MiB block is 64 MiB counted per box (over the 32 MiB
|
||||
// cap) but the block is shared, not copied, so every box keeps its text.
|
||||
let big = para_block(Paragraph::with_text(&"x".repeat(1 << 20), CharProps::default()));
|
||||
let f = fan_out(vec![big.clone()], 64);
|
||||
let mut d = doc(&["x"]);
|
||||
let (_, warned) = crate::test_log::warnings(|| d.insert_fragment(&Pos::body(0, 1), &f).unwrap());
|
||||
assert!(!warned.iter().any(|w| w.contains("text boxes are left empty")), "{warned:?}");
|
||||
let ids: Vec<u32> = boxes_in(&d, &Path::top(0)).into_iter().flatten().collect();
|
||||
assert_eq!(ids.len(), 64, "every box keeps a story");
|
||||
for id in ids {
|
||||
let b = d.parts[&id].blocks.first().unwrap();
|
||||
assert!(Arc::ptr_eq(b, &big), "shared, so not charged per box");
|
||||
assert_eq!(d.parts[&id].blocks.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasted_box_copies_are_capped_in_bytes() {
|
||||
// Story 1 is a 1 MiB paragraph that holds a text box itself, so each box showing it needs
|
||||
// its own copy; 64 of them used to copy 64 MiB.
|
||||
let mut p = Paragraph::with_text(&"x".repeat(1 << 20), CharProps::default());
|
||||
p.insert_object(0, box_shape(Some(999)), &CharProps::default()).unwrap();
|
||||
let f = fan_out(vec![para_block(p)], 64);
|
||||
let mut d = doc(&["x"]);
|
||||
let before = d.parts.len();
|
||||
let (_, warned) = crate::test_log::warnings(|| d.insert_fragment(&Pos::body(0, 1), &f).unwrap());
|
||||
let added = d.parts.len() - before;
|
||||
// The boxes left empty are said to be, once for the paste (review of #3874).
|
||||
let empty: Vec<_> = warned.iter().filter(|w| w.contains("text boxes are left empty")).collect();
|
||||
assert_eq!(empty.len(), 1, "{warned:?}");
|
||||
assert!(empty[0].starts_with(&format!("paste: {} text boxes", 64 - added)), "{empty:?}");
|
||||
let copied: usize = d.parts.values().flat_map(|p| p.blocks.iter()).map(|b| if let Block::Para(p) = &**b { p.text.len() } else { 0 }).sum();
|
||||
assert!((1..64).contains(&added), "{added}");
|
||||
assert!(copied <= MAX_PASTED_BOX_BYTES, "{copied}");
|
||||
// The boxes past the cap keep no story rather than a shared one.
|
||||
let got = boxes_in(&d, &Path::top(0));
|
||||
assert_eq!(got.iter().flatten().count(), added);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasted_box_copies_count_the_objects_they_copy() {
|
||||
// #3614 review: a story holding a text box and a 1 MiB kept-for-round-trip object costs
|
||||
// the object's XML too, not only its slot: 64 copies used to copy 64 MiB of it.
|
||||
let mut p = Paragraph::with_text("x", CharProps::default());
|
||||
p.insert_object(0, box_shape(Some(999)), &CharProps::default()).unwrap();
|
||||
let opaque = InlineObject::Opaque { format: "docx".into(), xml: "x".repeat(1 << 20), text: String::new() };
|
||||
p.insert_object(0, opaque, &CharProps::default()).unwrap();
|
||||
let f = fan_out(vec![para_block(p)], 64);
|
||||
let mut d = doc(&["x"]);
|
||||
let before = d.parts.len();
|
||||
d.insert_fragment(&Pos::body(0, 1), &f).unwrap();
|
||||
let added = d.parts.len() - before;
|
||||
let xml = |o: &InlineObject| if let InlineObject::Opaque { xml, .. } = o { xml.len() } else { 0 };
|
||||
let copied: usize =
|
||||
d.parts.values().flat_map(|p| p.blocks.iter()).map(|b| if let Block::Para(p) = &**b { p.objects.iter().map(xml).sum() } else { 0 }).sum();
|
||||
assert!((1..64).contains(&added), "{added}");
|
||||
assert!(copied <= MAX_PASTED_BOX_BYTES, "{copied}");
|
||||
// Nested group members and equations count too.
|
||||
let member = |obj| crate::para::GroupChild { x: 0.0, y: 0.0, obj };
|
||||
let deep = InlineObject::Opaque { format: String::new(), xml: "y".repeat(5000), text: String::new() };
|
||||
let inner = InlineObject::Group { w: 1.0, h: 1.0, float: Default::default(), ch_w: 1.0, ch_h: 1.0, children: vec![member(deep)], canvas: None };
|
||||
let outer = InlineObject::Group { w: 1.0, h: 1.0, float: Default::default(), ch_w: 1.0, ch_h: 1.0, children: vec![member(inner)], canvas: None };
|
||||
assert!(object_cost(&outer) >= 5000);
|
||||
let eq = InlineObject::Equation {
|
||||
linear: String::new(),
|
||||
display: false,
|
||||
math: crate::math::Math { nodes: crate::math::parse_linear(&"z".repeat(5000)), ..Default::default() },
|
||||
};
|
||||
assert!(object_cost(&eq) >= 5000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tables_in_paths() {
|
||||
let mut d = doc(&["before", "after"]);
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(Table::new(2, 2, 200.0))).unwrap();
|
||||
let paths = d.para_paths(StoryRef::Body);
|
||||
assert_eq!(paths.len(), 6);
|
||||
assert_eq!(paths[1], Path(vec![1, 0, 0, 0]));
|
||||
let cell = Pos { story: StoryRef::Body, path: Path(vec![1, 0, 1, 0]), off: 0 };
|
||||
let e = d.insert_text(&cell, "x", &CharProps::default()).unwrap();
|
||||
assert_eq!(e.off, 1);
|
||||
assert_eq!(d.next_para(StoryRef::Body, &Path(vec![1, 0, 1, 0])), Some(Path(vec![1, 1, 0, 0])));
|
||||
assert!(d.plain_text(StoryRef::Body).contains("\tx"));
|
||||
// Delete from before the table into a cell: text cleared, table kept (partially covered).
|
||||
d.delete_range(&Pos::body(0, 2), &cell).unwrap();
|
||||
assert_eq!(d.para(StoryRef::Body, &Path::top(0)).unwrap().text, "be");
|
||||
assert!(d.container(StoryRef::Body, &Path(vec![1, 0, 0, 0])).is_some());
|
||||
assert_eq!(Path(vec![1, 0, 1, 0]).cell(), Some((Path::top(1), 0, 1)));
|
||||
}
|
||||
|
||||
fn para(text: &str) -> Block {
|
||||
Block::Para(Paragraph::with_text(text, CharProps::default()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appended_pieces_are_each_followed_by_one_paragraph_mark() {
|
||||
let mut spike = Fragment::default();
|
||||
spike.append_piece(Fragment { blocks: vec![para("a")], ..Default::default() });
|
||||
spike.append_piece(Fragment { blocks: vec![para("b")], ..Default::default() });
|
||||
assert_eq!(spike.plain_text(), "a\nb\n");
|
||||
// A piece that already ends with its paragraph mark gets no second one.
|
||||
let mut spike = Fragment::default();
|
||||
spike.append_piece(Fragment { blocks: vec![para("a"), para("")], ..Default::default() });
|
||||
assert_eq!(spike.plain_text(), "a\n");
|
||||
spike.append_piece(Fragment { blocks: vec![para("b"), para("c")], ..Default::default() });
|
||||
assert_eq!(spike.plain_text(), "a\nb\nc\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appended_pieces_keep_their_own_text_boxes() {
|
||||
// Two documents give their first text box the same id.
|
||||
let mut one = doc(&["x"]);
|
||||
let id1 = text_box(&mut one, &Pos::body(0, 1), "One");
|
||||
let mut two = doc(&["y"]);
|
||||
let id2 = text_box(&mut two, &Pos::body(0, 1), "Two");
|
||||
assert_eq!(id1, id2);
|
||||
let mut spike = Fragment::default();
|
||||
spike.append_piece(one.copy_range(&Pos::body(0, 0), &Pos::body(0, 1 + 3)));
|
||||
spike.append_piece(two.copy_range(&Pos::body(0, 0), &Pos::body(0, 1 + 3)));
|
||||
let mut dst = doc(&[""]);
|
||||
dst.insert_fragment(&Pos::body(0, 0), &spike).unwrap();
|
||||
let texts: Vec<String> = [0, 1].iter().flat_map(|i| boxes_in(&dst, &Path::top(*i))).map(|b| dst.plain_text(StoryRef::Part(b.unwrap()))).collect();
|
||||
assert_eq!(texts, ["One", "Two"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_positions_error() {
|
||||
let mut d = doc(&["é"]);
|
||||
assert!(d.insert_text(&Pos::body(0, 1), "x", &CharProps::default()).is_err());
|
||||
assert!(d.insert_text(&Pos::body(5, 0), "x", &CharProps::default()).is_err());
|
||||
assert!(d.split_paragraph(&Pos::body(0, 99)).is_err());
|
||||
let c = d.clamp(&Pos::body(9, 9));
|
||||
assert_eq!(c, Pos::body(0, 2));
|
||||
assert!(d.delete_range(&Pos { story: StoryRef::Part(3), path: Path::top(0), off: 0 }, &Pos::body(0, 0)).is_err());
|
||||
}
|
||||
|
||||
// delete_range over whole tables (#1844), in the body, a header and a footnote.
|
||||
|
||||
/// A document whose story (the body, or a header or footnote part) holds `lines`.
|
||||
fn story_doc(kind: Option<PartKind>, lines: &[&str]) -> (Document, StoryRef) {
|
||||
match kind {
|
||||
None => (doc(lines), StoryRef::Body),
|
||||
Some(k) => {
|
||||
let mut d = doc(&["body"]);
|
||||
let blocks = lines.iter().map(|l| para_block(Paragraph::with_text(l, CharProps::default()))).collect();
|
||||
let id = d.add_part(k, blocks);
|
||||
(d, StoryRef::Part(id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const STORIES: [Option<PartKind>; 3] = [None, Some(PartKind::Header), Some(PartKind::Footnote)];
|
||||
|
||||
fn at(story: StoryRef, path: &[u32], off: usize) -> Pos {
|
||||
Pos { story, path: Path(path.to_vec()), off }
|
||||
}
|
||||
|
||||
/// Insert an `rows`×`cols` table at `path` whose cells hold `texts`, row by row.
|
||||
fn put_table(d: &mut Document, story: StoryRef, path: &[u32], rows: usize, cols: usize, texts: &[&str]) {
|
||||
d.insert_block(story, &Path(path.to_vec()), Block::Table(Table::new(rows, cols, 200.0))).unwrap();
|
||||
for (k, t) in texts.iter().enumerate() {
|
||||
let mut p = path.to_vec();
|
||||
p.extend([(k / cols) as u32, (k % cols) as u32, 0]);
|
||||
d.insert_text(&at(story, &p, 0), t, &CharProps::default()).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// The story's top-level blocks: a paragraph's text, or `[table]`.
|
||||
fn shape(d: &Document, story: StoryRef) -> Vec<String> {
|
||||
d.story(story).unwrap().iter().map(|b| b.as_para().map_or("[table]".to_string(), |p| p.text.clone())).collect()
|
||||
}
|
||||
|
||||
fn cell_text(d: &Document, story: StoryRef, table: &[u32], r: u32, c: u32) -> String {
|
||||
let mut p = table.to_vec();
|
||||
p.extend([r, c, 0]);
|
||||
d.para(story, &Path(p)).map(|p| p.text.clone()).unwrap_or_else(|| "<none>".into())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_range_ending_at_the_end_of_a_cell_that_isnt_the_last_keeps_the_table() {
|
||||
for kind in STORIES {
|
||||
for (r, c) in [(0, 1), (1, 0)] {
|
||||
let (mut d, st) = story_doc(kind, &["before", "after"]);
|
||||
put_table(&mut d, st, &[1], 2, 2, &["a", "b", "c", "d"]);
|
||||
d.delete_range(&at(st, &[0], 0), &at(st, &[1, r, c, 0], 1)).unwrap();
|
||||
assert_eq!(shape(&d, st), ["", "[table]", "after"], "{kind:?} ({r},{c})");
|
||||
assert_eq!(cell_text(&d, st, &[1], 1, 1), "d", "{kind:?} ({r},{c})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_range_from_the_start_of_one_table_into_the_middle_of_the_next_removes_only_the_first() {
|
||||
for kind in STORIES {
|
||||
let (mut d, st) = story_doc(kind, &["x", "mid", "y"]);
|
||||
put_table(&mut d, st, &[2], 2, 2, &["e", "f", "g", "h"]);
|
||||
put_table(&mut d, st, &[1], 2, 2, &["a", "b", "c", "d"]);
|
||||
assert_eq!(shape(&d, st), ["x", "[table]", "mid", "[table]", "y"]);
|
||||
let to = d.delete_range(&at(st, &[1, 0, 0, 0], 0), &at(st, &[3, 0, 1, 0], 1)).unwrap();
|
||||
assert_eq!(shape(&d, st), ["x", "", "[table]", "y"], "{kind:?}");
|
||||
assert_eq!([cell_text(&d, st, &[2], 0, 0), cell_text(&d, st, &[2], 0, 1), cell_text(&d, st, &[2], 1, 0)], ["", "", "g"], "{kind:?}");
|
||||
assert!(d.para(st, &to.path).is_some(), "{kind:?}: {to:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_range_from_a_cell_paragraph_to_the_end_of_a_table_nested_in_that_cell_removes_only_the_nested_table() {
|
||||
for kind in STORIES {
|
||||
let (mut d, st) = story_doc(kind, &["before", "after"]);
|
||||
put_table(&mut d, st, &[1], 1, 1, &["p"]);
|
||||
put_table(&mut d, st, &[1, 0, 0, 1], 1, 1, &["n"]);
|
||||
d.insert_block(st, &Path(vec![1, 0, 0, 2]), Block::Para(Paragraph::with_text("q", CharProps::default()))).unwrap();
|
||||
let to = d.delete_range(&at(st, &[1, 0, 0, 0], 1), &at(st, &[1, 0, 0, 1, 0, 0, 0], 1)).unwrap();
|
||||
assert_eq!(shape(&d, st), ["before", "[table]", "after"], "{kind:?}");
|
||||
let cell = d.container(st, &Path(vec![1, 0, 0, 0])).unwrap();
|
||||
let cell: Vec<String> = cell.iter().map(|b| b.as_para().map_or("[table]".to_string(), |p| p.text.clone())).collect();
|
||||
assert_eq!(cell, ["pq"], "{kind:?}");
|
||||
assert_eq!(to, at(st, &[1, 0, 0, 0], 1), "{kind:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_whole_tables_at_the_end_of_a_story_leave_one_empty_paragraph() {
|
||||
for kind in STORIES {
|
||||
for trailing in [true, false] {
|
||||
let (mut d, st) = story_doc(kind, &[""]);
|
||||
put_table(&mut d, st, &[0], 1, 2, &["a", "b"]);
|
||||
put_table(&mut d, st, &[1], 2, 1, &["c", "d"]);
|
||||
if !trailing {
|
||||
// A story that ends in a table (a hostile file).
|
||||
d.story_mut(st).unwrap().pop();
|
||||
}
|
||||
let to = d.delete_range(&at(st, &[0, 0, 0, 0], 0), &at(st, &[1, 1, 0, 0], 1)).unwrap();
|
||||
assert_eq!(shape(&d, st), [""], "{kind:?} trailing {trailing}");
|
||||
assert_eq!(to, at(st, &[0], 0), "{kind:?} trailing {trailing}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Content controls pair their markers within one block list (a story, or a cell), so a
|
||||
/// marker in a table never has its partner outside it: a control inside a table removed whole
|
||||
/// goes with it, and one the range only enters keeps both markers (the control is emptied).
|
||||
#[test]
|
||||
fn removing_a_whole_table_keeps_a_control_the_range_only_partly_covers() {
|
||||
use crate::control::ContentControl;
|
||||
let start = || InlineObject::ControlStart { control: Box::new(ContentControl::default()) };
|
||||
for kind in STORIES {
|
||||
let (mut d, st) = story_doc(kind, &["before", "after"]);
|
||||
put_table(&mut d, st, &[1], 1, 1, &["c"]);
|
||||
// One control around "efo" in "before", one around the cell's text.
|
||||
d.insert_object(&at(st, &[0], 4), InlineObject::ControlEnd, &CharProps::default()).unwrap();
|
||||
let in_before = d.insert_object(&at(st, &[0], 1), start(), &CharProps::default()).unwrap();
|
||||
d.insert_object(&at(st, &[1, 0, 0, 0], 1), InlineObject::ControlEnd, &CharProps::default()).unwrap();
|
||||
d.insert_object(&at(st, &[1, 0, 0, 0], 0), start(), &CharProps::default()).unwrap();
|
||||
assert_eq!(d.control_ranges(st).len(), 2, "setup");
|
||||
let end = d.para(st, &Path(vec![1, 0, 0, 0])).unwrap().len();
|
||||
// From inside the first control to the end of the table.
|
||||
d.delete_range(&Pos { off: in_before.off + 1, ..in_before }, &at(st, &[1, 0, 0, 0], end)).unwrap();
|
||||
assert_eq!(shape(&d, st), ["b\u{FFFC}e\u{FFFC}after"], "{kind:?}: the table goes, the paragraphs join");
|
||||
let ranges = d.control_ranges(st);
|
||||
assert_eq!(ranges.len(), 1, "{kind:?}: {ranges:?}");
|
||||
assert!(ranges.iter().all(|r| r.start.path == Path::top(0) && r.end.path == Path::top(0)), "{kind:?}");
|
||||
let objects = d.para(st, &Path::top(0)).map_or(0, |p| p.objects.len());
|
||||
assert_eq!(objects, 2, "{kind:?}: no orphan markers left");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_table_nested_deeper_than_the_paragraph_walk_is_never_removed_whole() {
|
||||
// 20 tables, each in the first cell of the one around it, ahead of that cell's paragraph:
|
||||
// the innermost paragraphs come first in the document but the paragraph walk stops short.
|
||||
let mut t = Table::new(1, 1, 200.0);
|
||||
if let Some(cell) = t.rows.get_mut(0).and_then(|r| r.cells.get_mut(0)) {
|
||||
cell.blocks = vec![para_block(Paragraph::with_text("deep", CharProps::default()))];
|
||||
}
|
||||
for _ in 0..20 {
|
||||
let mut outer = Table::new(1, 1, 200.0);
|
||||
if let Some(cell) = outer.rows.get_mut(0).and_then(|r| r.cells.get_mut(0)) {
|
||||
cell.blocks.insert(0, Arc::new(Block::Table(t)));
|
||||
}
|
||||
t = outer;
|
||||
}
|
||||
let mut d = doc(&["before", "after"]);
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(t)).unwrap();
|
||||
let first = d.para_paths(StoryRef::Body).into_iter().find(|p| p.0.first() == Some(&1)).unwrap();
|
||||
d.delete_range(&Pos { story: StoryRef::Body, path: first, off: 0 }, &Pos::body(2, 0)).unwrap();
|
||||
assert!(d.body.get(1).is_some_and(|b| b.as_table().is_some()), "a table with paragraphs the walk never saw was removed");
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
use super::position::StoryRef;
|
||||
|
||||
#[derive(Debug, thiserror::Error, Clone, PartialEq)]
|
||||
pub enum DocError {
|
||||
#[error("offset {0} is not a character boundary in the paragraph")]
|
||||
BadOffset(usize),
|
||||
#[error("no paragraph at {0}")]
|
||||
BadPath(String),
|
||||
#[error("no story {0:?}")]
|
||||
BadStory(StoryRef),
|
||||
#[error("{0}")]
|
||||
Invalid(String),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, DocError>;
|
||||
@@ -43,12 +43,12 @@ fn scan(story: StoryRef, bl: &Blocks, prefix: &mut Vec<u32>, depth: usize, out:
|
||||
match &**b {
|
||||
Block::Para(p) => {
|
||||
let path = Path(prefix.clone());
|
||||
for off in p.object_offsets() {
|
||||
match p.object_at(off) {
|
||||
Some(InlineObject::FieldStart { instr, locked, .. }) => {
|
||||
for (off, o) in p.objects_with_offsets() {
|
||||
match o {
|
||||
InlineObject::FieldStart { instr, locked, .. } => {
|
||||
open.push((instr.clone(), *locked, Pos::new(story, path.clone(), off)));
|
||||
}
|
||||
Some(InlineObject::FieldEnd) => match open.pop() {
|
||||
InlineObject::FieldEnd => match open.pop() {
|
||||
Some((instr, locked, start)) => {
|
||||
let depth = open.len();
|
||||
out.ranges.push(FieldRange { instr, locked, start, end: Pos::new(story, path.clone(), off), depth });
|
||||
@@ -99,7 +99,10 @@ impl Document {
|
||||
pub fn past_field_ends(&self, p: &Pos) -> Pos {
|
||||
let Some(para) = self.para_at(p) else { return p.clone() };
|
||||
let mut off = p.off;
|
||||
while matches!(para.object_at(off), Some(InlineObject::FieldEnd)) {
|
||||
for (at, o) in para.objects_with_offsets().skip_while(|(at, _)| *at < p.off) {
|
||||
if at != off || !matches!(o, InlineObject::FieldEnd) {
|
||||
break;
|
||||
}
|
||||
off += OBJ.len_utf8();
|
||||
}
|
||||
Pos { off, ..p.clone() }
|
||||
|
||||
@@ -0,0 +1,712 @@
|
||||
//! Visible logical text for IME surrounding-text queries.
|
||||
//!
|
||||
//! The platform input method works on what the reader sees, not on the stored run text: tracked
|
||||
//! deletions are hidden, and an inline object stands for its plain-text result. The helpers here
|
||||
//! build a bounded window around a selection without cloning the paragraph or the document, and
|
||||
//! map both ends in both directions so a query and a delete use exactly the same text.
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use crate::para::{OBJ, Paragraph};
|
||||
use crate::{Document, Pos};
|
||||
|
||||
/// A bounded visible-logical window around a selection.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ImeWindow {
|
||||
/// At most [`MAX_IME_TEXT_BYTES`] bytes of visible committed text.
|
||||
pub text: String,
|
||||
/// UTF-8 byte offset of the primary end in [`Self::text`].
|
||||
pub cursor: usize,
|
||||
/// UTF-8 byte offset of the other end in [`Self::text`].
|
||||
pub anchor: usize,
|
||||
}
|
||||
|
||||
/// The largest visible window an IME query copies.
|
||||
pub const MAX_IME_TEXT_BYTES: usize = 4000;
|
||||
|
||||
/// Which unit an input method's delete-surrounding counts use.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum DeleteUnit {
|
||||
/// Unicode scalar values of the visible text.
|
||||
Chars,
|
||||
/// UTF-8 bytes of the visible text.
|
||||
Bytes,
|
||||
}
|
||||
|
||||
/// A deletion request in the paragraph's stored byte offsets.
|
||||
///
|
||||
/// The selection itself is never part of `before` or `after`; both ranges are outside it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ImeDelete {
|
||||
/// Stored byte range to erase before the selection.
|
||||
pub before: Option<Range<usize>>,
|
||||
/// Stored byte range to erase after the selection.
|
||||
pub after: Option<Range<usize>>,
|
||||
/// Stored byte offset of the selection's anchor **before** the deletions.
|
||||
///
|
||||
/// The ranges are returned in the paragraph's stored text as it is now; the caller applies
|
||||
/// them (and shifts these offsets past `before`) itself.
|
||||
pub anchor: usize,
|
||||
/// Stored byte offset of the selection's focus **before** the deletions.
|
||||
pub focus: usize,
|
||||
}
|
||||
|
||||
/// One paragraph's visible logical text.
|
||||
///
|
||||
/// Deleted runs are skipped and inline objects contribute their plain-text result. Iteration is
|
||||
/// lazy: no full visible copy is made.
|
||||
pub struct VisibleParagraph<'a> {
|
||||
para: &'a Paragraph,
|
||||
deleted: Vec<Range<usize>>,
|
||||
}
|
||||
|
||||
impl<'a> VisibleParagraph<'a> {
|
||||
/// A view of `para` with tracked deletions hidden.
|
||||
pub fn new(para: &'a Paragraph) -> Self {
|
||||
let mut deleted = para.deleted_ranges();
|
||||
deleted.sort_unstable_by_key(|r| r.start);
|
||||
Self { para, deleted }
|
||||
}
|
||||
|
||||
/// Whether `off` falls inside a tracked deletion, advancing `next` past the runs that end
|
||||
/// before it.
|
||||
///
|
||||
/// The ranges are sorted, so one monotone cursor makes a whole scan O(text + ranges) instead
|
||||
/// of O(text × ranges): alternating tracked revisions used to make every character scan every
|
||||
/// range.
|
||||
fn is_deleted_at(&self, next: &mut usize, off: usize) -> bool {
|
||||
while let Some(r) = self.deleted.get(*next) {
|
||||
if r.end > off {
|
||||
break;
|
||||
}
|
||||
*next = next.saturating_add(1);
|
||||
}
|
||||
self.deleted.get(*next).is_some_and(|r| r.start <= off && off < r.end)
|
||||
}
|
||||
|
||||
/// Call `f` for every visible piece: `(stored byte start, piece, stored byte len)`.
|
||||
fn for_each_piece(&self, mut f: impl FnMut(usize, &str, usize)) {
|
||||
self.for_each_piece_while(|start, piece, stored| {
|
||||
f(start, piece, stored);
|
||||
true
|
||||
});
|
||||
}
|
||||
|
||||
/// Call `f` for visible pieces until it returns `false`.
|
||||
fn for_each_piece_while(&self, mut f: impl FnMut(usize, &str, usize) -> bool) {
|
||||
let mut object = 0usize;
|
||||
let mut deleted = 0usize;
|
||||
for (i, c) in self.para.text.char_indices() {
|
||||
let is_object = c == OBJ;
|
||||
if is_object {
|
||||
object += 1;
|
||||
}
|
||||
if self.is_deleted_at(&mut deleted, i) {
|
||||
continue;
|
||||
}
|
||||
let keep_going = if is_object {
|
||||
let piece = self.para.objects.get(object - 1).map_or("", |o| o.plain_text());
|
||||
f(i, piece, OBJ.len_utf8())
|
||||
} else {
|
||||
let mut buf = [0u8; 4];
|
||||
f(i, c.encode_utf8(&mut buf), c.len_utf8())
|
||||
};
|
||||
if !keep_going {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Total visible UTF-8 bytes.
|
||||
pub fn visible_len(&self) -> usize {
|
||||
let mut len = 0usize;
|
||||
self.for_each_piece(|_, piece, _| len = len.saturating_add(piece.len()));
|
||||
len
|
||||
}
|
||||
|
||||
/// Visible offset of a stored paragraph byte offset.
|
||||
///
|
||||
/// `None` when the stored offset is not a character boundary, points inside a tracked
|
||||
/// deletion, or would split an inline object's plain-text result.
|
||||
pub fn visible_offset(&self, stored: usize) -> Option<usize> {
|
||||
if stored > self.para.text.len() || !self.para.text.is_char_boundary(stored) {
|
||||
return None;
|
||||
}
|
||||
let mut visible = 0usize;
|
||||
let mut found = false;
|
||||
let mut object = 0usize;
|
||||
let mut deleted = 0usize;
|
||||
for (i, c) in self.para.text.char_indices() {
|
||||
if i == stored {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
if i > stored {
|
||||
break;
|
||||
}
|
||||
let is_object = c == OBJ;
|
||||
if is_object {
|
||||
object += 1;
|
||||
}
|
||||
if self.is_deleted_at(&mut deleted, i) {
|
||||
// A position at the start of a deleted run is still a visible position; one
|
||||
// inside it is not.
|
||||
continue;
|
||||
}
|
||||
if is_object {
|
||||
let piece = self.para.objects.get(object - 1).map_or("", |o| o.plain_text());
|
||||
visible = visible.saturating_add(piece.len());
|
||||
} else {
|
||||
visible = visible.saturating_add(c.len_utf8());
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
// The end of the paragraph, or a deleted run at its end.
|
||||
let mut next = 0usize;
|
||||
return (!self.is_deleted_at(&mut next, stored)).then_some(visible);
|
||||
}
|
||||
if stored < self.para.text.len() && self.is_deleted_at(&mut deleted, stored) {
|
||||
// The start of a deleted run is still a visible caret position; only a position
|
||||
// strictly inside one is unrepresentable.
|
||||
let at_start = self.deleted.iter().any(|r| r.start == stored);
|
||||
return at_start.then_some(visible);
|
||||
}
|
||||
Some(visible)
|
||||
}
|
||||
|
||||
/// Stored paragraph byte offset for a visible offset.
|
||||
///
|
||||
/// `None` when the visible offset falls inside an inline object's plain-text result or is
|
||||
/// not a boundary of the visible representation.
|
||||
pub fn stored_offset(&self, visible: usize) -> Option<usize> {
|
||||
let mut acc = 0usize;
|
||||
let mut answer = None;
|
||||
let mut object = 0usize;
|
||||
let mut deleted = 0usize;
|
||||
for (i, c) in self.para.text.char_indices() {
|
||||
let is_object = c == OBJ;
|
||||
if is_object {
|
||||
object += 1;
|
||||
}
|
||||
if self.is_deleted_at(&mut deleted, i) {
|
||||
continue;
|
||||
}
|
||||
let piece_len = if is_object { self.para.objects.get(object - 1).map_or(0, |o| o.plain_text().len()) } else { c.len_utf8() };
|
||||
if visible == acc {
|
||||
answer = Some(i);
|
||||
break;
|
||||
}
|
||||
if visible < acc.saturating_add(piece_len) {
|
||||
// Inside a character (or an object's expansion): no stored boundary there.
|
||||
return None;
|
||||
}
|
||||
acc = acc.saturating_add(piece_len);
|
||||
}
|
||||
answer.or_else(|| (visible == acc).then_some(self.para.text.len()))
|
||||
}
|
||||
|
||||
/// Stored offset for the **start** of a visible range, skipping zero-length objects at
|
||||
/// the same boundary.
|
||||
///
|
||||
/// A content control's markers are inline objects with no plain text, so they share a
|
||||
/// visible offset with the content just inside (or after) them. A range start must map
|
||||
/// after a control-start marker and after a control-end marker, never onto the marker
|
||||
/// itself: an IME deletion must not break the control. Non-empty object expansions still
|
||||
/// map to the object's own stored offset, so a range that reaches one deletes it whole.
|
||||
pub fn stored_offset_after(&self, visible: usize) -> Option<usize> {
|
||||
let mut acc = 0usize;
|
||||
let mut object = 0usize;
|
||||
let mut deleted = 0usize;
|
||||
for (i, c) in self.para.text.char_indices() {
|
||||
let is_object = c == OBJ;
|
||||
if is_object {
|
||||
object += 1;
|
||||
}
|
||||
if self.is_deleted_at(&mut deleted, i) {
|
||||
continue;
|
||||
}
|
||||
let piece_len = if is_object { self.para.objects.get(object - 1).map_or(0, |o| o.plain_text().len()) } else { c.len_utf8() };
|
||||
if visible == acc && piece_len > 0 {
|
||||
return Some(i);
|
||||
}
|
||||
if visible < acc.saturating_add(piece_len) {
|
||||
return None;
|
||||
}
|
||||
acc = acc.saturating_add(piece_len);
|
||||
}
|
||||
(visible == acc).then_some(self.para.text.len())
|
||||
}
|
||||
|
||||
/// Stored offset for a visible offset, preferring the position **before** a tracked
|
||||
/// deletion that starts there. Use this for the end of a range so a deletion run just
|
||||
/// after the caret is never drawn into it.
|
||||
pub fn stored_offset_before(&self, visible: usize) -> Option<usize> {
|
||||
let mut acc = 0usize;
|
||||
let mut object = 0usize;
|
||||
let mut deleted = 0usize;
|
||||
for (i, c) in self.para.text.char_indices() {
|
||||
if visible == acc {
|
||||
return Some(i);
|
||||
}
|
||||
let is_object = c == OBJ;
|
||||
if is_object {
|
||||
object += 1;
|
||||
}
|
||||
if self.is_deleted_at(&mut deleted, i) {
|
||||
continue;
|
||||
}
|
||||
let piece_len = if is_object { self.para.objects.get(object - 1).map_or(0, |o| o.plain_text().len()) } else { c.len_utf8() };
|
||||
if visible < acc.saturating_add(piece_len) {
|
||||
return None;
|
||||
}
|
||||
acc = acc.saturating_add(piece_len);
|
||||
}
|
||||
(visible == acc).then_some(self.para.text.len())
|
||||
}
|
||||
|
||||
/// Round a visible byte offset forward to the next visible boundary (inward for a start).
|
||||
pub fn ceil_boundary(&self, visible: usize) -> usize {
|
||||
let mut acc = 0usize;
|
||||
let mut out = None;
|
||||
self.for_each_piece(|_, piece, _| {
|
||||
if out.is_some() {
|
||||
return;
|
||||
}
|
||||
if acc >= visible {
|
||||
out = Some(acc);
|
||||
return;
|
||||
}
|
||||
acc = acc.saturating_add(piece.len());
|
||||
if acc >= visible {
|
||||
out = Some(acc);
|
||||
}
|
||||
});
|
||||
out.unwrap_or(acc)
|
||||
}
|
||||
|
||||
/// Round a visible byte offset backward to the previous visible boundary (inward for an end).
|
||||
pub fn floor_boundary(&self, visible: usize) -> usize {
|
||||
let mut acc = 0usize;
|
||||
let mut out = 0usize;
|
||||
self.for_each_piece(|_, piece, _| {
|
||||
let next = acc.saturating_add(piece.len());
|
||||
if next > visible {
|
||||
return;
|
||||
}
|
||||
acc = next;
|
||||
out = acc;
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// A bounded window with `cursor` and `anchor` as visible byte offsets.
|
||||
///
|
||||
/// The selection is never split and the window never exceeds [`MAX_IME_TEXT_BYTES`]. A
|
||||
/// selection wider than that, an invalid offset, or an offset that cannot be represented as
|
||||
/// visible text returns `None` (unavailable, not fabricated).
|
||||
pub fn window(&self, cursor: usize, anchor: usize, max_bytes: usize) -> Option<ImeWindow> {
|
||||
// The public API caps its own budget: a caller cannot ask for an unbounded copy.
|
||||
let max_bytes = max_bytes.min(MAX_IME_TEXT_BYTES);
|
||||
let (sel_start, sel_end) = (cursor.min(anchor), cursor.max(anchor));
|
||||
let selection_len = sel_end - sel_start;
|
||||
if selection_len > max_bytes {
|
||||
return None;
|
||||
}
|
||||
// Spend the budget around the selection and stop walking at its end: the paragraph is
|
||||
// never copied and the work is bounded by the caret's distance from the paragraph start.
|
||||
let before = (max_bytes - selection_len) / 2;
|
||||
let window_start = sel_start.saturating_sub(before);
|
||||
let window_end = window_start.saturating_add(max_bytes);
|
||||
let mut text = String::with_capacity(window_end.saturating_sub(window_start).min(max_bytes));
|
||||
let mut first_kept = None;
|
||||
let mut acc = 0usize;
|
||||
self.for_each_piece_while(|_, piece, _| {
|
||||
for (k, c) in piece.char_indices() {
|
||||
let start = acc.saturating_add(k);
|
||||
let end = start.saturating_add(c.len_utf8());
|
||||
if end > window_end {
|
||||
return false;
|
||||
}
|
||||
if start >= window_start {
|
||||
if first_kept.is_none() {
|
||||
first_kept = Some(start);
|
||||
}
|
||||
text.push(c);
|
||||
}
|
||||
}
|
||||
acc = acc.saturating_add(piece.len());
|
||||
acc < window_end
|
||||
});
|
||||
let Some(first) = first_kept else {
|
||||
// An empty document is an available empty context, never unavailable.
|
||||
return (cursor == 0 && anchor == 0).then_some(ImeWindow { text: String::new(), cursor: 0, anchor: 0 });
|
||||
};
|
||||
Some(ImeWindow { text, cursor: cursor.saturating_sub(first), anchor: anchor.saturating_sub(first) })
|
||||
}
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Bounded visible logical text around `cursor`/`anchor` in one paragraph.
|
||||
///
|
||||
/// A selection across paragraphs or blocks is unrepresentable and returns `None`.
|
||||
pub fn ime_surrounding(&self, cursor: &Pos, anchor: &Pos, max_bytes: usize) -> Option<ImeWindow> {
|
||||
if cursor.story != anchor.story || cursor.path != anchor.path {
|
||||
return None;
|
||||
}
|
||||
let para = self.para_at(cursor)?;
|
||||
let view = VisibleParagraph::new(para);
|
||||
let c = view.visible_offset(cursor.off)?;
|
||||
let a = view.visible_offset(anchor.off)?;
|
||||
view.window(c, a, max_bytes.min(MAX_IME_TEXT_BYTES))
|
||||
}
|
||||
|
||||
/// Delete-surrounding ranges in the stored paragraph text.
|
||||
///
|
||||
/// Counts exclude the selection, tracked deletions and any pre-edit (which never lives in
|
||||
/// the model here). Byte counts that end inside a scalar leave it whole: a start rounds
|
||||
/// forward and an end rounds backward; a byte count beyond the visible text is refused.
|
||||
/// Character counts clamp at the paragraph's ends, as the web text agent's do. A range that
|
||||
/// cannot be represented in stored text returns `None` instead of guessing; so does a
|
||||
/// request with nothing to erase (`0` on both sides).
|
||||
pub fn ime_delete_surrounding(&self, cursor: &Pos, anchor: &Pos, before: usize, after: usize, unit: DeleteUnit) -> Option<ImeDelete> {
|
||||
if cursor.story != anchor.story || cursor.path != anchor.path {
|
||||
return None;
|
||||
}
|
||||
if before == 0 && after == 0 {
|
||||
// Nothing to erase: don't build the boundary table (or walk a long paragraph) to
|
||||
// discover it.
|
||||
return None;
|
||||
}
|
||||
let para = self.para_at(cursor)?;
|
||||
let view = VisibleParagraph::new(para);
|
||||
let c = view.visible_offset(cursor.off)?;
|
||||
let a = view.visible_offset(anchor.off)?;
|
||||
let (sel_start, sel_end) = (c.min(a), c.max(a));
|
||||
|
||||
let (before_start, after_end) = match unit {
|
||||
DeleteUnit::Bytes => {
|
||||
let total = view.visible_len();
|
||||
if before > sel_start || after > total - sel_end {
|
||||
return None;
|
||||
}
|
||||
(view.ceil_boundary(sel_start.saturating_sub(before)), view.floor_boundary(sel_end.saturating_add(after)))
|
||||
}
|
||||
DeleteUnit::Chars => char_boundaries(&view, sel_start, sel_end, before, after)?,
|
||||
};
|
||||
|
||||
// Start a range after a deletion run and end it before one, so tracked deletions are
|
||||
// never part of the erased stored range.
|
||||
let before_start = view.stored_offset_after(before_start)?;
|
||||
let before_end = view.stored_offset_before(sel_start)?;
|
||||
let after_start = view.stored_offset_after(sel_end)?;
|
||||
let after_end = view.stored_offset_before(after_end)?;
|
||||
let before_range = (before_start < before_end).then_some(before_start..before_end);
|
||||
let after_range = (after_start < after_end).then_some(after_start..after_end);
|
||||
Some(ImeDelete { before: before_range, after: after_range, anchor: anchor.off, focus: cursor.off })
|
||||
}
|
||||
}
|
||||
|
||||
/// The visible byte boundaries `before` characters back from `sel_start` and `after`
|
||||
/// characters on from `sel_end`.
|
||||
///
|
||||
/// Two walks and constant memory replace the old per-character `Vec` that held every boundary
|
||||
/// (a hostile count could make it allocate the whole paragraph). Character counts clamp at the
|
||||
/// paragraph's ends; `None` when either selection end is not a boundary of the visible text.
|
||||
fn char_boundaries(view: &VisibleParagraph<'_>, sel_start: usize, sel_end: usize, before: usize, after: usize) -> Option<(usize, usize)> {
|
||||
let mut acc = 0usize;
|
||||
let mut chars = 0usize;
|
||||
let mut start_index = None;
|
||||
let mut end_index = None;
|
||||
view.for_each_piece(|_, piece, _| {
|
||||
for (k, _) in piece.char_indices() {
|
||||
let at = acc.saturating_add(k);
|
||||
if at == sel_start {
|
||||
start_index = Some(chars);
|
||||
}
|
||||
if at == sel_end {
|
||||
end_index = Some(chars);
|
||||
}
|
||||
chars = chars.saturating_add(1);
|
||||
}
|
||||
acc = acc.saturating_add(piece.len());
|
||||
});
|
||||
if sel_start == acc {
|
||||
start_index = Some(chars);
|
||||
}
|
||||
if sel_end == acc {
|
||||
end_index = Some(chars);
|
||||
}
|
||||
let start_index = start_index?;
|
||||
let end_index = end_index?;
|
||||
let target_before = start_index.saturating_sub(before);
|
||||
let target_after = end_index.saturating_add(after).min(chars);
|
||||
|
||||
let mut acc = 0usize;
|
||||
let mut index = 0usize;
|
||||
let mut before_start = None;
|
||||
let mut after_end = (target_after == 0).then_some(0);
|
||||
view.for_each_piece(|_, piece, _| {
|
||||
for (k, _) in piece.char_indices() {
|
||||
let at = acc.saturating_add(k);
|
||||
if index == target_before && before_start.is_none() {
|
||||
before_start = Some(at);
|
||||
}
|
||||
if index == target_after && after_end.is_none() {
|
||||
after_end = Some(at);
|
||||
}
|
||||
index = index.saturating_add(1);
|
||||
}
|
||||
acc = acc.saturating_add(piece.len());
|
||||
});
|
||||
if index == target_before && before_start.is_none() {
|
||||
before_start = Some(acc);
|
||||
}
|
||||
if index == target_after && after_end.is_none() {
|
||||
after_end = Some(acc);
|
||||
}
|
||||
Some((before_start?, after_end?))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::para::Run;
|
||||
use crate::props::CharProps;
|
||||
use crate::{InlineObject, StoryRef};
|
||||
|
||||
/// Builds a paragraph with one run per `(text, deleted)` part; deleted parts carry revision 0.
|
||||
fn para_with_runs(parts: &[(&str, bool)]) -> Paragraph {
|
||||
let mut p = Paragraph::new();
|
||||
for (text, deleted) in parts {
|
||||
let mut props = CharProps::default();
|
||||
if *deleted {
|
||||
props.del = Some(0);
|
||||
}
|
||||
p.text.push_str(text);
|
||||
p.runs.push(Run { len: text.len(), props });
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// A tracked deletion is hidden from the IME view but still maps stored offsets: its start is a
|
||||
/// visible caret position, its interior is not, and a window concatenates only visible text.
|
||||
#[test]
|
||||
fn tracked_deletions_are_hidden_and_offsets_still_map() {
|
||||
let p = para_with_runs(&[("ab", false), ("DEL", true), ("cd", false)]);
|
||||
let view = VisibleParagraph::new(&p);
|
||||
assert_eq!(view.visible_len(), 4);
|
||||
assert_eq!(view.visible_offset(0), Some(0));
|
||||
assert_eq!(view.visible_offset(2), Some(2), "the start of a deleted run is a visible caret position");
|
||||
assert_eq!(view.visible_offset(3), None, "inside a tracked deletion");
|
||||
assert_eq!(view.visible_offset(5), Some(2));
|
||||
assert_eq!(view.visible_offset(7), Some(4));
|
||||
assert_eq!(view.stored_offset(2), Some(5));
|
||||
assert_eq!(view.stored_offset(4), Some(7));
|
||||
assert_eq!(view.window(2, 2, 4000).map(|w| w.text), Some("abcd".into()));
|
||||
}
|
||||
|
||||
/// An inline object contributes its plain text to visible length and boundary mapping; offsets
|
||||
/// strictly inside that expansion have no stored boundary.
|
||||
#[test]
|
||||
fn objects_contribute_their_plain_text_and_split_mapping_stops() {
|
||||
let mut p = Paragraph::new();
|
||||
p.text = format!("a{OBJ}b");
|
||||
p.runs = vec![Run { len: p.text.len(), props: CharProps::default() }];
|
||||
p.objects.push(InlineObject::Equation { linear: "x+y".into(), display: false, math: Default::default() });
|
||||
let view = VisibleParagraph::new(&p);
|
||||
assert_eq!(view.visible_len(), 5);
|
||||
assert_eq!(view.visible_offset(1), Some(1));
|
||||
assert_eq!(view.visible_offset(4), Some(4));
|
||||
assert_eq!(view.stored_offset(1), Some(1));
|
||||
assert_eq!(view.stored_offset(4), Some(4));
|
||||
assert_eq!(view.stored_offset(2), None, "inside the object's text");
|
||||
}
|
||||
|
||||
/// An empty paragraph yields an available empty IME context at offset 0; an empty document
|
||||
/// yields an available empty context too, though this test asserts only its text, not its offsets.
|
||||
#[test]
|
||||
fn an_empty_document_is_available_and_empty() {
|
||||
let p = Paragraph::new();
|
||||
let view = VisibleParagraph::new(&p);
|
||||
let w = view.window(0, 0, MAX_IME_TEXT_BYTES).expect("an available empty context");
|
||||
assert_eq!((w.text.as_str(), w.cursor, w.anchor), ("", 0, 0));
|
||||
let doc = Document::new();
|
||||
let w = doc.ime_surrounding(&Pos::body(0, 0), &Pos::body(0, 0), MAX_IME_TEXT_BYTES).expect("available");
|
||||
assert_eq!(w.text, "");
|
||||
}
|
||||
|
||||
/// A window is capped at the IME byte budget while keeping cursor and anchor inside its text; a
|
||||
/// selection wider than the budget is unavailable.
|
||||
#[test]
|
||||
fn bounded_window_keeps_the_selection_and_rejects_too_wide_ones() {
|
||||
let p = Paragraph::with_text(&"a".repeat(10_000), CharProps::default());
|
||||
let view = VisibleParagraph::new(&p);
|
||||
let w = view.window(5000, 5000, MAX_IME_TEXT_BYTES).expect("a bounded window");
|
||||
assert!(w.text.len() <= MAX_IME_TEXT_BYTES);
|
||||
assert_eq!(w.cursor, w.anchor);
|
||||
assert!(w.cursor <= w.text.len());
|
||||
assert!(view.window(0, 4001, MAX_IME_TEXT_BYTES).is_none());
|
||||
}
|
||||
|
||||
/// Byte deletion rounds its start to a visible UTF-8 boundary, leaves partially covered scalar
|
||||
/// values whole, and refuses counts that run past the paragraph.
|
||||
#[test]
|
||||
fn byte_deletion_rounds_inward_and_leaves_partial_scalars_whole() {
|
||||
let doc = Document::from_text("aé👍b");
|
||||
let caret = Pos::body(0, 8);
|
||||
let one = doc.ime_delete_surrounding(&caret, &caret, 1, 0, DeleteUnit::Bytes).expect("b");
|
||||
assert_eq!(one.before, Some(7..8));
|
||||
// Two bytes start inside the emoji: it is left whole, only `b` goes.
|
||||
let two = doc.ime_delete_surrounding(&caret, &caret, 2, 0, DeleteUnit::Bytes).expect("b");
|
||||
assert_eq!(two.before, Some(7..8));
|
||||
// Six bytes start inside `é`: it stays whole, the emoji and `b` go.
|
||||
let six = doc.ime_delete_surrounding(&caret, &caret, 6, 0, DeleteUnit::Bytes).expect("emoji and b");
|
||||
assert_eq!(six.before, Some(3..8));
|
||||
// A count beyond the paragraph is refused, not clipped to delete a whole side.
|
||||
assert!(doc.ime_delete_surrounding(&caret, &caret, 0, 1, DeleteUnit::Bytes).is_none());
|
||||
assert!(doc.ime_delete_surrounding(&caret, &caret, 99, 0, DeleteUnit::Bytes).is_none());
|
||||
}
|
||||
|
||||
/// Surrounding deletion skips the selected range on both sides and returns the selection's
|
||||
/// original reversed anchor and focus.
|
||||
#[test]
|
||||
fn byte_deletion_excludes_a_reversed_selection() {
|
||||
let doc = Document::from_text("abcd");
|
||||
let cursor = Pos::body(0, 1);
|
||||
let anchor = Pos::body(0, 3);
|
||||
let del = doc.ime_delete_surrounding(&cursor, &anchor, 1, 1, DeleteUnit::Bytes).expect("a deletion");
|
||||
assert_eq!(del.before, Some(0..1), "only the character before the selection");
|
||||
assert_eq!(del.after, Some(3..4), "only the character after the selection");
|
||||
assert_eq!((del.anchor, del.focus), (3, 1), "the reversed selection is preserved");
|
||||
}
|
||||
|
||||
/// Tracked deletions are absent from windows and skipped when deleting around a caret, so an IME
|
||||
/// edit cannot remove them.
|
||||
#[test]
|
||||
fn tracked_deletions_are_not_counted_for_deletion_or_query() {
|
||||
let mut doc = Document::from_text("abDELcd");
|
||||
let p = para_with_runs(&[("ab", false), ("DEL", true), ("cd", false)]);
|
||||
doc.body = vec![std::sync::Arc::new(crate::Block::Para(p))];
|
||||
let view = VisibleParagraph::new(doc.para_at(&Pos::body(0, 0)).expect("paragraph"));
|
||||
assert_eq!(view.window(2, 2, 100).map(|w| w.text), Some("abcd".into()));
|
||||
let caret = Pos::body(0, 7);
|
||||
let del = doc.ime_delete_surrounding(&caret, &caret, 1, 0, DeleteUnit::Bytes).expect("c");
|
||||
assert_eq!(del.before, Some(6..7), "the stored deletion run is skipped");
|
||||
}
|
||||
|
||||
/// A selection spanning paragraphs has no single surrounding context: both the query and
|
||||
/// surrounding deletion return `None`.
|
||||
#[test]
|
||||
fn cross_paragraph_selection_is_unavailable() {
|
||||
let doc = Document::from_text("one\ntwo");
|
||||
let cursor = Pos::body(0, 1);
|
||||
let anchor = Pos::body(1, 1);
|
||||
assert!(doc.ime_surrounding(&cursor, &anchor, MAX_IME_TEXT_BYTES).is_none());
|
||||
assert!(doc.ime_delete_surrounding(&cursor, &anchor, 1, 0, DeleteUnit::Chars).is_none());
|
||||
}
|
||||
|
||||
/// An object's plain-text result stands for the object in the visible text; a deletion
|
||||
/// that starts inside that expansion leaves the object whole (byte) or is refused (chars).
|
||||
#[test]
|
||||
fn object_expansion_is_never_partly_deleted() {
|
||||
let mut doc = Document::from_text("");
|
||||
let mut p = Paragraph::new();
|
||||
p.text = format!("a{OBJ}b");
|
||||
p.runs = vec![Run { len: p.text.len(), props: CharProps::default() }];
|
||||
p.objects.push(InlineObject::Equation { linear: "x+y".into(), display: false, math: Default::default() });
|
||||
doc.body = vec![std::sync::Arc::new(crate::Block::Para(p))];
|
||||
let caret = Pos::body(0, 5);
|
||||
// Two bytes before the caret end inside `x+y` (`y` is the last byte): the start rounds
|
||||
// forward past the object, so only `b` goes.
|
||||
let two = doc.ime_delete_surrounding(&caret, &caret, 2, 0, DeleteUnit::Bytes).expect("b only");
|
||||
assert_eq!(two.before, Some(4..5), "stored UTF-8 offsets keep the object whole");
|
||||
let mut remaining = format!("a{OBJ}b");
|
||||
remaining.replace_range(two.before.clone().unwrap(), "");
|
||||
assert_eq!(remaining, format!("a{OBJ}"));
|
||||
// A character that lands inside the expansion has no stored boundary: refused.
|
||||
assert!(doc.ime_delete_surrounding(&caret, &caret, 2, 0, DeleteUnit::Chars).is_none());
|
||||
// A count reaching before the object erases the object as a whole.
|
||||
let five = doc.ime_delete_surrounding(&caret, &caret, 5, 0, DeleteUnit::Bytes).expect("object and b");
|
||||
assert_eq!(five.before, Some(0..5));
|
||||
}
|
||||
|
||||
/// Character counts on the right side clamp at the paragraph's end and never cross into the
|
||||
/// next paragraph.
|
||||
#[test]
|
||||
fn trailing_character_deletion_clamps_at_the_paragraph_end() {
|
||||
let doc = Document::from_text("ab\ncd");
|
||||
let caret = Pos::body(0, 1);
|
||||
let del = doc.ime_delete_surrounding(&caret, &caret, 0, 99, DeleteUnit::Chars).expect("b only");
|
||||
assert_eq!(del.after, Some(1..2));
|
||||
let empty = doc.ime_delete_surrounding(&caret, &caret, 0, 0, DeleteUnit::Chars);
|
||||
assert!(empty.is_none(), "nothing to erase");
|
||||
}
|
||||
|
||||
/// Sorted tracked deletions are scanned with one cursor: alternating revisions stay correct
|
||||
/// (and linear) instead of each character scanning every range.
|
||||
#[test]
|
||||
fn alternating_tracked_revisions_map_correctly() {
|
||||
// Equal-length visible/deleted pieces, so the expected visible byte count is exact.
|
||||
let parts: Vec<(String, bool)> =
|
||||
(0..2000).map(|i| (if i % 2 == 0 { format!("v{i:04};") } else { format!("D{i:04};") }, i % 2 == 1)).collect();
|
||||
let parts: Vec<(&str, bool)> = parts.iter().map(|(t, d)| (t.as_str(), *d)).collect();
|
||||
let p = para_with_runs(&parts);
|
||||
let view = VisibleParagraph::new(&p);
|
||||
assert_eq!(view.visible_len(), p.text.len() - 6 * 1000);
|
||||
assert_eq!(view.visible_offset(p.text.len()), Some(view.visible_len()));
|
||||
let w = view.window(0, 0, 100).expect("a window");
|
||||
assert!(w.text.len() <= 100);
|
||||
assert!(!w.text.contains('D'), "tracked deletions never show");
|
||||
}
|
||||
|
||||
/// The public window never copies more than the IME cap, whatever a caller asks for.
|
||||
#[test]
|
||||
fn window_budget_is_capped() {
|
||||
let p = Paragraph::with_text(&"a".repeat(10_000), CharProps::default());
|
||||
let view = VisibleParagraph::new(&p);
|
||||
let w = view.window(0, 0, usize::MAX).expect("a bounded window");
|
||||
assert_eq!(w.text.len(), MAX_IME_TEXT_BYTES);
|
||||
let doc = Document::from_text(&"a".repeat(10_000));
|
||||
let w = doc.ime_surrounding(&Pos::body(0, 0), &Pos::body(0, 0), usize::MAX).expect("bounded");
|
||||
assert_eq!(w.text.len(), MAX_IME_TEXT_BYTES);
|
||||
}
|
||||
|
||||
/// A caret in a table-cell paragraph maps against that cell's text, not the body around the table.
|
||||
#[test]
|
||||
fn table_cell_paragraph_maps_inside_its_cell() {
|
||||
let mut doc = Document::from_text("outside");
|
||||
let mut table = crate::Table::new(1, 1, 100.0);
|
||||
table.rows[0].cells[0].blocks = vec![crate::para_block(Paragraph::with_text("cell", CharProps::default()))];
|
||||
doc.body.push(std::sync::Arc::new(crate::Block::Table(table)));
|
||||
let path = crate::Path(vec![1, 0, 0, 0]);
|
||||
let cursor = Pos { story: StoryRef::Body, path: path.clone(), off: 4 };
|
||||
let anchor = Pos { story: StoryRef::Body, path, off: 2 };
|
||||
let w = doc.ime_surrounding(&cursor, &anchor, MAX_IME_TEXT_BYTES).expect("the cell text");
|
||||
assert_eq!(w.text, "cell");
|
||||
assert_eq!(w.cursor, 4);
|
||||
assert_eq!(w.anchor, 2);
|
||||
}
|
||||
|
||||
/// Content-control markers are zero-length visible objects. A deletion range starts after
|
||||
/// the marker and ends before the next one, so an ordinary IME edit never removes a marker
|
||||
/// or leaves a half-paired control.
|
||||
#[test]
|
||||
fn control_markers_are_not_included_in_delete_ranges() {
|
||||
use crate::control::{ContentControl, ControlKind};
|
||||
let mut p = Paragraph::new();
|
||||
p.text = format!("pre{OBJ}Ada{OBJ}X");
|
||||
p.runs = vec![Run { len: p.text.len(), props: CharProps::default() }];
|
||||
let mut ctl = ContentControl::new(ControlKind::RichText);
|
||||
ctl.placeholder_text = "Enter name".into();
|
||||
p.objects.push(InlineObject::ControlStart { control: Box::new(ctl) });
|
||||
p.objects.push(InlineObject::ControlEnd);
|
||||
let mut doc = Document::new();
|
||||
doc.body = vec![std::sync::Arc::new(crate::Block::Para(p))];
|
||||
// Stored positions are UTF-8 byte offsets; each OBJ marker occupies three bytes.
|
||||
let cursor = Pos::body(0, 9);
|
||||
let del = doc.ime_delete_surrounding(&cursor, &cursor, 3, 1, DeleteUnit::Bytes).expect("a deletion");
|
||||
assert_eq!(del.before, Some(6..9), "the control-start marker and `pre` stay outside");
|
||||
assert_eq!(del.after, Some(12..13), "the control-end marker stays outside; only `X` goes");
|
||||
assert_eq!((del.anchor, del.focus), (9, 9));
|
||||
}
|
||||
}
|
||||
+30
-1722
File diff suppressed because it is too large
Load Diff
@@ -41,6 +41,24 @@ impl Math {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.nodes.is_empty()
|
||||
}
|
||||
|
||||
/// Rough heap bytes a clone copies: every node, its argument lists and its run text.
|
||||
/// Iterative, so it costs at most the size of the structure.
|
||||
pub(crate) fn heap_bytes(&self) -> usize {
|
||||
let mut n = self.omml.len();
|
||||
let mut todo: Vec<&MNode> = self.nodes.iter().collect();
|
||||
while let Some(node) = todo.pop() {
|
||||
n = n.saturating_add(std::mem::size_of::<MNode>());
|
||||
if let MNode::Run(r) = node {
|
||||
n = n.saturating_add(r.text.len()).saturating_add(r.font.as_ref().map_or(0, String::len));
|
||||
}
|
||||
each_arg(node, &mut |a| {
|
||||
n = n.saturating_add(std::mem::size_of::<Arg>());
|
||||
todo.extend(a);
|
||||
});
|
||||
}
|
||||
n
|
||||
}
|
||||
}
|
||||
|
||||
/// Display equation justification (`m:oMathParaPr/m:jc`).
|
||||
@@ -1095,4 +1113,20 @@ mod tests {
|
||||
let deep = "√".repeat(3000);
|
||||
let _ = parse_linear(&deep);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_run_of_letters_parses_in_linear_time() {
|
||||
// Each letter that doesn't start a function name used to rescan (and copy) the rest of
|
||||
// the run, so a 20,000-letter equation did 200 million steps. (No wall-clock assert: a
|
||||
// loaded runner would fail it; a regression shows as a slow test.)
|
||||
for _ in 0..5 {
|
||||
let e = parse_linear(&"x".repeat(30_000));
|
||||
assert!(matches!(e.as_slice(), [MNode::Run(r)] if r.text.len() == 20_000), "{:?}", e.len());
|
||||
}
|
||||
// Function names are still found at the start of a run, and only as whole runs.
|
||||
assert!(matches!(parse_linear("sin x").as_slice(), [MNode::Func { .. }]));
|
||||
assert!(matches!(parse_linear("arcsin x").as_slice(), [MNode::Func { .. }]));
|
||||
assert!(matches!(parse_linear("sinx").as_slice(), [MNode::Run(_)]));
|
||||
assert!(matches!(parse_linear("arcsinhx").as_slice(), [MNode::Run(_)]));
|
||||
}
|
||||
}
|
||||
|
||||
+62
-57
@@ -1061,63 +1061,8 @@ pub fn apply_structure(root: &mut Arg, pos: &MathPos, level: usize, action: &str
|
||||
*kind = ScriptKind::SubSup;
|
||||
here(if action == "script.addSup" { 2 } else { 1 })
|
||||
}
|
||||
(MNode::Matrix { rows, .. }, a) if a.starts_with("matrix.") => {
|
||||
let ncols = rows.iter().map(|r| r.len()).max().unwrap_or(1).max(1);
|
||||
// Row and column of the caret's cell.
|
||||
let mut k = 0;
|
||||
let mut rc = (0, 0);
|
||||
for (r, row) in rows.iter().enumerate() {
|
||||
if c < k + row.len() {
|
||||
rc = (r, c - k);
|
||||
break;
|
||||
}
|
||||
k += row.len();
|
||||
}
|
||||
let (r, col) = rc;
|
||||
let (mut tr, mut tc) = (r, col);
|
||||
match a {
|
||||
"matrix.rowAbove" | "matrix.rowBelow" if rows.len() < 1000 => {
|
||||
let at = if a == "matrix.rowAbove" { r } else { r + 1 };
|
||||
rows.insert(at.min(rows.len()), vec![Vec::new(); ncols]);
|
||||
tr = at;
|
||||
}
|
||||
"matrix.colBefore" | "matrix.colAfter" if ncols < 64 => {
|
||||
let at = if a == "matrix.colBefore" { col } else { col + 1 };
|
||||
for row in rows.iter_mut() {
|
||||
row.insert(at.min(row.len()), Vec::new());
|
||||
}
|
||||
tc = at;
|
||||
}
|
||||
"matrix.deleteRow" if rows.len() > 1 => {
|
||||
rows.remove(r.min(rows.len() - 1));
|
||||
tr = r.min(rows.len() - 1);
|
||||
}
|
||||
"matrix.deleteCol" if ncols > 1 => {
|
||||
for row in rows.iter_mut() {
|
||||
if col < row.len() {
|
||||
row.remove(col);
|
||||
}
|
||||
}
|
||||
tc = col.min(ncols - 2);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let flat: usize = rows.iter().take(tr).map(|x| x.len()).sum::<usize>() + tc;
|
||||
here(flat)
|
||||
}
|
||||
(MNode::EqArr { rows }, a) if a.starts_with("eqarr.") => match a {
|
||||
"eqarr.rowAbove" | "eqarr.rowBelow" if rows.len() < 1000 => {
|
||||
let at = if a == "eqarr.rowAbove" { c } else { c + 1 }.min(rows.len());
|
||||
rows.insert(at, Vec::new());
|
||||
here(at)
|
||||
}
|
||||
"eqarr.deleteRow" if rows.len() > 1 => {
|
||||
let k = c.min(rows.len() - 1);
|
||||
rows.remove(k);
|
||||
here(k.min(rows.len() - 1))
|
||||
}
|
||||
_ => inner,
|
||||
},
|
||||
(MNode::Matrix { rows, .. }, a) if a.starts_with("matrix.") => apply_matrix_action(rows, c, a, &here),
|
||||
(MNode::EqArr { rows }, a) if a.starts_with("eqarr.") => apply_equation_array_action(rows, c, a, &here, inner),
|
||||
(MNode::GroupChr { top, chr, .. }, "group.flip") => {
|
||||
*top = !*top;
|
||||
*chr = match *chr {
|
||||
@@ -1141,6 +1086,66 @@ pub fn apply_structure(root: &mut Arg, pos: &MathPos, level: usize, action: &str
|
||||
Some(clamp(root, &out))
|
||||
}
|
||||
|
||||
fn apply_matrix_action(rows: &mut Vec<Vec<Arg>>, caret: usize, action: &str, here: &impl Fn(usize) -> MathPos) -> MathPos {
|
||||
let columns = rows.iter().map(Vec::len).max().unwrap_or(1).max(1);
|
||||
let (mut row_index, mut column) = (0, 0);
|
||||
let mut offset = 0;
|
||||
for (index, row) in rows.iter().enumerate() {
|
||||
if caret < offset + row.len() {
|
||||
row_index = index;
|
||||
column = caret - offset;
|
||||
break;
|
||||
}
|
||||
offset += row.len();
|
||||
}
|
||||
let (mut target_row, mut target_column) = (row_index, column);
|
||||
match action {
|
||||
"matrix.rowAbove" | "matrix.rowBelow" if rows.len() < 1000 => {
|
||||
let at = if action == "matrix.rowAbove" { row_index } else { row_index + 1 };
|
||||
rows.insert(at.min(rows.len()), vec![Vec::new(); columns]);
|
||||
target_row = at;
|
||||
}
|
||||
"matrix.colBefore" | "matrix.colAfter" if columns < 64 => {
|
||||
let at = if action == "matrix.colBefore" { column } else { column + 1 };
|
||||
for row in rows.iter_mut() {
|
||||
row.insert(at.min(row.len()), Vec::new());
|
||||
}
|
||||
target_column = at;
|
||||
}
|
||||
"matrix.deleteRow" if rows.len() > 1 => {
|
||||
rows.remove(row_index.min(rows.len() - 1));
|
||||
target_row = row_index.min(rows.len() - 1);
|
||||
}
|
||||
"matrix.deleteCol" if columns > 1 => {
|
||||
for row in rows.iter_mut() {
|
||||
if column < row.len() {
|
||||
row.remove(column);
|
||||
}
|
||||
}
|
||||
target_column = column.min(columns - 2);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let flat = rows.iter().take(target_row).map(Vec::len).sum::<usize>() + target_column;
|
||||
here(flat)
|
||||
}
|
||||
|
||||
fn apply_equation_array_action(rows: &mut Vec<Arg>, caret: usize, action: &str, here: &impl Fn(usize) -> MathPos, inner: MathPos) -> MathPos {
|
||||
match action {
|
||||
"eqarr.rowAbove" | "eqarr.rowBelow" if rows.len() < 1000 => {
|
||||
let at = (if action == "eqarr.rowAbove" { caret } else { caret + 1 }).min(rows.len());
|
||||
rows.insert(at, Vec::new());
|
||||
here(at)
|
||||
}
|
||||
"eqarr.deleteRow" if rows.len() > 1 => {
|
||||
let index = caret.min(rows.len() - 1);
|
||||
rows.remove(index);
|
||||
here(index.min(rows.len() - 1))
|
||||
}
|
||||
_ => inner,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the limit placement of n-ary operators (`None`: Word's default by operator).
|
||||
pub fn set_limits(nodes: &mut Arg, loc: Option<LimLoc>) {
|
||||
for n in nodes.iter_mut() {
|
||||
|
||||
+301
-214
@@ -324,6 +324,21 @@ impl Lp {
|
||||
let den = self.arg();
|
||||
one(MNode::Frac { kind: FracKind::Bar, num, den })
|
||||
}
|
||||
"genfrac" => {
|
||||
let beg = delim_arg(&self.arg());
|
||||
let end = delim_arg(&self.arg());
|
||||
let thickness = self.arg();
|
||||
let _style = self.arg();
|
||||
let num = self.arg();
|
||||
let den = self.arg();
|
||||
let kind = if is_zero_length(&thickness) { FracKind::NoBar } else { FracKind::Bar };
|
||||
let frac = MNode::Frac { kind, num, den };
|
||||
if beg.is_none() && end.is_none() {
|
||||
one(frac)
|
||||
} else {
|
||||
one(MNode::Delim { beg, end, sep: None, grow: true, shp_match: false, elems: vec![vec![frac]] })
|
||||
}
|
||||
}
|
||||
"binom" | "dbinom" | "tbinom" => {
|
||||
let n = self.arg();
|
||||
let k = self.arg();
|
||||
@@ -801,6 +816,31 @@ fn text_chars(text: &str, s: &mut String) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The delimiter of a `\genfrac` argument: one character, or none for `{}` and anything else.
|
||||
fn delim_arg(a: &[MNode]) -> Option<char> {
|
||||
let [MNode::Run(r)] = a else { return None };
|
||||
let mut cs = r.text.chars();
|
||||
match (cs.next(), cs.next()) {
|
||||
(Some(c), None) if c != '.' => Some(c),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a `\genfrac` thickness is a zero length such as `0pt`, `0.0pt` or `0 em`. An empty
|
||||
/// thickness means the default rule and anything that is not a plain number and unit is not zero.
|
||||
fn is_zero_length(a: &[MNode]) -> bool {
|
||||
let mut text = String::new();
|
||||
for n in a {
|
||||
let MNode::Run(r) = n else { return false };
|
||||
text.extend(r.text.chars().filter(|c| !c.is_whitespace() && !matches!(c, '\u{2009}' | '\u{205F}' | '\u{2005}')));
|
||||
}
|
||||
let number = text.trim_end_matches(|c: char| c.is_ascii_alphabetic());
|
||||
let unit = &text[number.len()..];
|
||||
!number.is_empty()
|
||||
&& matches!(unit, "pt" | "em" | "ex" | "mm" | "cm" | "in" | "bp" | "pc" | "sp" | "mu")
|
||||
&& number.parse::<f64>().is_ok_and(|v| v == 0.0)
|
||||
}
|
||||
|
||||
fn delim_tex(c: Option<char>) -> String {
|
||||
match c {
|
||||
None => ".".into(),
|
||||
@@ -823,233 +863,252 @@ fn group_chr_tex(chr: char, top: bool) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn node(n: &MNode, s: &mut String, depth: usize) {
|
||||
match n {
|
||||
MNode::Run(r) => {
|
||||
let (open, close) = if r.nor {
|
||||
("\\text{", "}")
|
||||
} else {
|
||||
match (r.sty, r.scr) {
|
||||
(_, MScr::DoubleStruck) => ("\\mathbb{", "}"),
|
||||
(_, MScr::Script) => ("\\mathcal{", "}"),
|
||||
(_, MScr::Fraktur) => ("\\mathfrak{", "}"),
|
||||
(_, MScr::SansSerif) => ("\\mathsf{", "}"),
|
||||
(_, MScr::Monospace) => ("\\mathtt{", "}"),
|
||||
(Some(MSty::Bold), _) => ("\\mathbf{", "}"),
|
||||
(Some(MSty::BoldItalic), _) => ("\\boldsymbol{", "}"),
|
||||
(Some(MSty::Plain), _) if r.text.chars().any(|c| c.is_alphabetic()) => {
|
||||
if FUNCTION_NAMES.contains(&r.text.as_str()) {
|
||||
s.push('\\');
|
||||
s.push_str(&r.text);
|
||||
s.push(' ');
|
||||
return;
|
||||
}
|
||||
("\\mathrm{", "}")
|
||||
}
|
||||
_ => ("", ""),
|
||||
}
|
||||
};
|
||||
s.push_str(open);
|
||||
if r.nor {
|
||||
text_chars(&r.text, s);
|
||||
} else {
|
||||
chars(&r.text, s);
|
||||
}
|
||||
s.push_str(close);
|
||||
}
|
||||
MNode::Frac { kind, num, den } => match kind {
|
||||
FracKind::NoBar => {
|
||||
s.push_str("\\genfrac{}{}{0pt}{}");
|
||||
braced(num, s, depth);
|
||||
braced(den, s, depth);
|
||||
}
|
||||
FracKind::Linear | FracKind::Skewed => {
|
||||
braced(num, s, depth);
|
||||
s.push('/');
|
||||
braced(den, s, depth);
|
||||
}
|
||||
FracKind::Bar => {
|
||||
s.push_str("\\frac");
|
||||
braced(num, s, depth);
|
||||
braced(den, s, depth);
|
||||
}
|
||||
},
|
||||
MNode::Script { kind, base, sub, sup } => {
|
||||
if *kind == ScriptKind::Pre {
|
||||
s.push_str("{}_");
|
||||
braced(sub, s, depth);
|
||||
s.push('^');
|
||||
braced(sup, s, depth);
|
||||
braced(base, s, depth);
|
||||
return;
|
||||
}
|
||||
braced(base, s, depth);
|
||||
if matches!(kind, ScriptKind::Sub | ScriptKind::SubSup) {
|
||||
s.push('_');
|
||||
braced(sub, s, depth);
|
||||
}
|
||||
if matches!(kind, ScriptKind::Sup | ScriptKind::SubSup) {
|
||||
s.push('^');
|
||||
braced(sup, s, depth);
|
||||
}
|
||||
}
|
||||
MNode::Rad { deg, deg_hide, e } => {
|
||||
s.push_str("\\sqrt");
|
||||
if !deg_hide && !deg.is_empty() {
|
||||
s.push('[');
|
||||
arg(deg, s, depth);
|
||||
s.push(']');
|
||||
}
|
||||
braced(e, s, depth);
|
||||
}
|
||||
MNode::Nary { chr, lim_loc, sub_hide, sup_hide, sub, sup, e, .. } => {
|
||||
match name_of(*chr) {
|
||||
Some(n) => {
|
||||
s.push('\\');
|
||||
s.push_str(n);
|
||||
}
|
||||
None => s.push(*chr),
|
||||
}
|
||||
match lim_loc {
|
||||
Some(crate::math::LimLoc::UndOvr) => s.push_str("\\limits"),
|
||||
Some(crate::math::LimLoc::SubSup) if !crate::math::is_integral(*chr) => s.push_str("\\nolimits"),
|
||||
_ => {}
|
||||
}
|
||||
if !sub_hide && !sub.is_empty() {
|
||||
s.push('_');
|
||||
braced(sub, s, depth);
|
||||
}
|
||||
if !sup_hide && !sup.is_empty() {
|
||||
s.push('^');
|
||||
braced(sup, s, depth);
|
||||
}
|
||||
s.push(' ');
|
||||
braced(e, s, depth);
|
||||
}
|
||||
MNode::Delim { beg, end, sep, elems, .. } => {
|
||||
s.push_str("\\left");
|
||||
s.push_str(&delim_tex(*beg));
|
||||
let mid = match sep {
|
||||
Some(c) => format!("\\middle{}", delim_tex(Some(*c))),
|
||||
None => "\\middle|".to_string(),
|
||||
};
|
||||
for (i, e) in elems.iter().enumerate() {
|
||||
if i > 0 {
|
||||
s.push_str(&mid);
|
||||
}
|
||||
arg(e, s, depth);
|
||||
}
|
||||
s.push_str("\\right");
|
||||
s.push_str(&delim_tex(*end));
|
||||
}
|
||||
fn node(node: &MNode, output: &mut String, depth: usize) {
|
||||
match node {
|
||||
MNode::Run(run) => run_node(run, output),
|
||||
MNode::Frac { kind, num, den } => fraction_node(*kind, num, den, output, depth),
|
||||
MNode::Script { kind, base, sub, sup } => script_node(*kind, base, sub, sup, output, depth),
|
||||
MNode::Rad { deg, deg_hide, e } => radical_node(deg, *deg_hide, e, output, depth),
|
||||
MNode::Nary { .. } => nary_node(node, output, depth),
|
||||
MNode::Delim { beg, end, sep, elems, .. } => delimiter_node(*beg, *end, *sep, elems, output, depth),
|
||||
MNode::Func { name, e } => {
|
||||
arg(name, s, depth);
|
||||
braced(e, s, depth);
|
||||
}
|
||||
MNode::Lim { upper, e, lim } => {
|
||||
// `\lim_{…}` keeps its name; other bases stack.
|
||||
let simple = matches!(e.as_slice(), [MNode::Run(r)] if r.sty == Some(MSty::Plain) && FUNCTION_NAMES.contains(&r.text.as_str()));
|
||||
if simple && !upper {
|
||||
arg(e, s, depth);
|
||||
s.push('_');
|
||||
braced(lim, s, depth);
|
||||
} else if let [MNode::GroupChr { chr, top, e: inner }] = e.as_slice() {
|
||||
s.push_str(group_chr_tex(*chr, *top));
|
||||
braced(inner, s, depth);
|
||||
s.push(if *upper { '^' } else { '_' });
|
||||
braced(lim, s, depth);
|
||||
} else {
|
||||
s.push_str(if *upper { "\\overset" } else { "\\underset" });
|
||||
braced(lim, s, depth);
|
||||
braced(e, s, depth);
|
||||
}
|
||||
}
|
||||
MNode::Acc { chr, e } => {
|
||||
let cmd = match chr {
|
||||
'\u{302}' => "hat",
|
||||
'\u{303}' => "tilde",
|
||||
'\u{307}' => "dot",
|
||||
'\u{308}' => "ddot",
|
||||
'\u{20DB}' => "dddot",
|
||||
'\u{30C}' => "check",
|
||||
'\u{306}' => "breve",
|
||||
'\u{301}' => "acute",
|
||||
'\u{300}' => "grave",
|
||||
'\u{305}' | '\u{AF}' => "bar",
|
||||
'\u{20D6}' => "overleftarrow",
|
||||
'\u{20E1}' => "overleftrightarrow",
|
||||
_ => "vec",
|
||||
};
|
||||
s.push('\\');
|
||||
s.push_str(cmd);
|
||||
braced(e, s, depth);
|
||||
arg(name, output, depth);
|
||||
braced(e, output, depth);
|
||||
}
|
||||
MNode::Lim { upper, e, lim } => limit_node(*upper, e, lim, output, depth),
|
||||
MNode::Acc { chr, e } => accent_node(*chr, e, output, depth),
|
||||
MNode::Bar { top, e } => {
|
||||
s.push_str(if *top { "\\overline" } else { "\\underline" });
|
||||
braced(e, s, depth);
|
||||
output.push_str(if *top { "\\overline" } else { "\\underline" });
|
||||
braced(e, output, depth);
|
||||
}
|
||||
MNode::BorderBox { e, .. } => {
|
||||
s.push_str("\\boxed");
|
||||
braced(e, s, depth);
|
||||
output.push_str("\\boxed");
|
||||
braced(e, output, depth);
|
||||
}
|
||||
MNode::Boxed { e } => braced(e, s, depth),
|
||||
MNode::Boxed { e } => braced(e, output, depth),
|
||||
MNode::GroupChr { chr, top, e } => {
|
||||
s.push_str(group_chr_tex(*chr, *top));
|
||||
braced(e, s, depth);
|
||||
}
|
||||
MNode::EqArr { rows } => {
|
||||
if rows.len() == 1
|
||||
&& let Some(r) = rows.first()
|
||||
&& crate::math::has_number_mark(r)
|
||||
{
|
||||
arg(r, s, depth);
|
||||
return;
|
||||
}
|
||||
s.push_str("\\begin{aligned}");
|
||||
for (i, r) in rows.iter().enumerate() {
|
||||
if i > 0 {
|
||||
s.push_str("\\\\");
|
||||
}
|
||||
for n in r {
|
||||
match n {
|
||||
MNode::Run(run) if !run.lit && run.text.contains('&') => {
|
||||
for (k, part) in run.text.split('&').enumerate() {
|
||||
if k > 0 {
|
||||
s.push('&');
|
||||
}
|
||||
let mut p = run.clone();
|
||||
p.text = part.to_string();
|
||||
node(&MNode::Run(p), s, depth + 1);
|
||||
}
|
||||
}
|
||||
other => node(other, s, depth + 1),
|
||||
}
|
||||
}
|
||||
}
|
||||
s.push_str("\\end{aligned}");
|
||||
}
|
||||
MNode::Matrix { rows, .. } => {
|
||||
s.push_str("\\begin{matrix}");
|
||||
for (i, r) in rows.iter().enumerate() {
|
||||
if i > 0 {
|
||||
s.push_str("\\\\");
|
||||
}
|
||||
for (j, c) in r.iter().enumerate() {
|
||||
if j > 0 {
|
||||
s.push('&');
|
||||
}
|
||||
arg(c, s, depth);
|
||||
}
|
||||
}
|
||||
s.push_str("\\end{matrix}");
|
||||
output.push_str(group_chr_tex(*chr, *top));
|
||||
braced(e, output, depth);
|
||||
}
|
||||
MNode::EqArr { rows } => equation_array_node(rows, output, depth),
|
||||
MNode::Matrix { rows, .. } => matrix_node(rows, output, depth),
|
||||
MNode::Phant { e, .. } => {
|
||||
s.push_str("\\phantom");
|
||||
braced(e, s, depth);
|
||||
output.push_str("\\phantom");
|
||||
braced(e, output, depth);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_node(run: &MRun, output: &mut String) {
|
||||
let (open, close) = if run.nor {
|
||||
("\\text{", "}")
|
||||
} else {
|
||||
match (run.sty, run.scr) {
|
||||
(_, MScr::DoubleStruck) => ("\\mathbb{", "}"),
|
||||
(_, MScr::Script) => ("\\mathcal{", "}"),
|
||||
(_, MScr::Fraktur) => ("\\mathfrak{", "}"),
|
||||
(_, MScr::SansSerif) => ("\\mathsf{", "}"),
|
||||
(_, MScr::Monospace) => ("\\mathtt{", "}"),
|
||||
(Some(MSty::Bold), _) => ("\\mathbf{", "}"),
|
||||
(Some(MSty::BoldItalic), _) => ("\\boldsymbol{", "}"),
|
||||
(Some(MSty::Plain), _) if run.text.chars().any(char::is_alphabetic) => {
|
||||
if FUNCTION_NAMES.contains(&run.text.as_str()) {
|
||||
output.push('\\');
|
||||
output.push_str(&run.text);
|
||||
output.push(' ');
|
||||
return;
|
||||
}
|
||||
("\\mathrm{", "}")
|
||||
}
|
||||
_ => ("", ""),
|
||||
}
|
||||
};
|
||||
output.push_str(open);
|
||||
if run.nor {
|
||||
text_chars(&run.text, output)
|
||||
} else {
|
||||
chars(&run.text, output)
|
||||
}
|
||||
output.push_str(close);
|
||||
}
|
||||
|
||||
fn fraction_node(kind: FracKind, numerator: &[MNode], denominator: &[MNode], output: &mut String, depth: usize) {
|
||||
match kind {
|
||||
FracKind::NoBar => {
|
||||
output.push_str("\\genfrac{}{}{0pt}{}");
|
||||
braced(numerator, output, depth);
|
||||
braced(denominator, output, depth);
|
||||
}
|
||||
FracKind::Linear | FracKind::Skewed => {
|
||||
braced(numerator, output, depth);
|
||||
output.push('/');
|
||||
braced(denominator, output, depth);
|
||||
}
|
||||
FracKind::Bar => {
|
||||
output.push_str("\\frac");
|
||||
braced(numerator, output, depth);
|
||||
braced(denominator, output, depth);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn script_node(kind: ScriptKind, base: &[MNode], sub: &[MNode], sup: &[MNode], output: &mut String, depth: usize) {
|
||||
if kind == ScriptKind::Pre {
|
||||
output.push_str("{}_");
|
||||
braced(sub, output, depth);
|
||||
output.push('^');
|
||||
braced(sup, output, depth);
|
||||
braced(base, output, depth);
|
||||
return;
|
||||
}
|
||||
braced(base, output, depth);
|
||||
if matches!(kind, ScriptKind::Sub | ScriptKind::SubSup) {
|
||||
output.push('_');
|
||||
braced(sub, output, depth);
|
||||
}
|
||||
if matches!(kind, ScriptKind::Sup | ScriptKind::SubSup) {
|
||||
output.push('^');
|
||||
braced(sup, output, depth);
|
||||
}
|
||||
}
|
||||
|
||||
fn radical_node(degree: &[MNode], degree_hidden: bool, expression: &[MNode], output: &mut String, depth: usize) {
|
||||
output.push_str("\\sqrt");
|
||||
if !degree_hidden && !degree.is_empty() {
|
||||
output.push('[');
|
||||
arg(degree, output, depth);
|
||||
output.push(']');
|
||||
}
|
||||
braced(expression, output, depth);
|
||||
}
|
||||
|
||||
fn nary_node(node: &MNode, output: &mut String, depth: usize) {
|
||||
let MNode::Nary { chr, lim_loc, sub_hide, sup_hide, sub, sup, e, .. } = node else { return };
|
||||
match name_of(*chr) {
|
||||
Some(name) => {
|
||||
output.push('\\');
|
||||
output.push_str(name);
|
||||
}
|
||||
None => output.push(*chr),
|
||||
}
|
||||
match lim_loc {
|
||||
Some(crate::math::LimLoc::UndOvr) => output.push_str("\\limits"),
|
||||
Some(crate::math::LimLoc::SubSup) if !crate::math::is_integral(*chr) => output.push_str("\\nolimits"),
|
||||
_ => {}
|
||||
}
|
||||
if !sub_hide && !sub.is_empty() {
|
||||
output.push('_');
|
||||
braced(sub, output, depth);
|
||||
}
|
||||
if !sup_hide && !sup.is_empty() {
|
||||
output.push('^');
|
||||
braced(sup, output, depth);
|
||||
}
|
||||
output.push(' ');
|
||||
braced(e, output, depth);
|
||||
}
|
||||
|
||||
fn delimiter_node(begin: Option<char>, end: Option<char>, separator: Option<char>, elements: &[Arg], output: &mut String, depth: usize) {
|
||||
output.push_str("\\left");
|
||||
output.push_str(&delim_tex(begin));
|
||||
let middle = separator.map(|c| format!("\\middle{}", delim_tex(Some(c)))).unwrap_or_else(|| "\\middle|".to_string());
|
||||
for (index, element) in elements.iter().enumerate() {
|
||||
if index > 0 {
|
||||
output.push_str(&middle);
|
||||
}
|
||||
arg(element, output, depth);
|
||||
}
|
||||
output.push_str("\\right");
|
||||
output.push_str(&delim_tex(end));
|
||||
}
|
||||
|
||||
fn limit_node(upper: bool, expression: &[MNode], limit: &[MNode], output: &mut String, depth: usize) {
|
||||
let simple = matches!(expression, [MNode::Run(run)] if run.sty == Some(MSty::Plain) && FUNCTION_NAMES.contains(&run.text.as_str()));
|
||||
if simple && !upper {
|
||||
arg(expression, output, depth);
|
||||
output.push('_');
|
||||
braced(limit, output, depth);
|
||||
} else if let [MNode::GroupChr { chr, top, e: inner }] = expression {
|
||||
output.push_str(group_chr_tex(*chr, *top));
|
||||
braced(inner, output, depth);
|
||||
output.push(if upper { '^' } else { '_' });
|
||||
braced(limit, output, depth);
|
||||
} else {
|
||||
output.push_str(if upper { "\\overset" } else { "\\underset" });
|
||||
braced(limit, output, depth);
|
||||
braced(expression, output, depth);
|
||||
}
|
||||
}
|
||||
|
||||
fn accent_node(character: char, expression: &[MNode], output: &mut String, depth: usize) {
|
||||
let command = match character {
|
||||
'\u{302}' => "hat",
|
||||
'\u{303}' => "tilde",
|
||||
'\u{307}' => "dot",
|
||||
'\u{308}' => "ddot",
|
||||
'\u{20DB}' => "dddot",
|
||||
'\u{30C}' => "check",
|
||||
'\u{306}' => "breve",
|
||||
'\u{301}' => "acute",
|
||||
'\u{300}' => "grave",
|
||||
'\u{305}' | '\u{AF}' => "bar",
|
||||
'\u{20D6}' => "overleftarrow",
|
||||
'\u{20E1}' => "overleftrightarrow",
|
||||
_ => "vec",
|
||||
};
|
||||
output.push('\\');
|
||||
output.push_str(command);
|
||||
braced(expression, output, depth);
|
||||
}
|
||||
|
||||
fn equation_array_node(rows: &[Arg], output: &mut String, depth: usize) {
|
||||
if rows.len() == 1
|
||||
&& let Some(row) = rows.first()
|
||||
&& crate::math::has_number_mark(row)
|
||||
{
|
||||
arg(row, output, depth);
|
||||
return;
|
||||
}
|
||||
output.push_str("\\begin{aligned}");
|
||||
for (index, row) in rows.iter().enumerate() {
|
||||
if index > 0 {
|
||||
output.push_str("\\\\");
|
||||
}
|
||||
for child_node in row {
|
||||
match child_node {
|
||||
MNode::Run(run) if !run.lit && run.text.contains('&') => {
|
||||
for (index, part) in run.text.split('&').enumerate() {
|
||||
if index > 0 {
|
||||
output.push('&');
|
||||
}
|
||||
let mut piece = run.clone();
|
||||
piece.text = part.to_string();
|
||||
run_node(&piece, output);
|
||||
}
|
||||
}
|
||||
other => node(other, output, depth + 1),
|
||||
}
|
||||
}
|
||||
}
|
||||
output.push_str("\\end{aligned}");
|
||||
}
|
||||
|
||||
fn matrix_node(rows: &[Vec<Arg>], output: &mut String, depth: usize) {
|
||||
output.push_str("\\begin{matrix}");
|
||||
for (row_index, row) in rows.iter().enumerate() {
|
||||
if row_index > 0 {
|
||||
output.push_str("\\\\");
|
||||
}
|
||||
for (column, cell) in row.iter().enumerate() {
|
||||
if column > 0 {
|
||||
output.push('&');
|
||||
}
|
||||
arg(cell, output, depth);
|
||||
}
|
||||
}
|
||||
output.push_str("\\end{matrix}");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1076,6 +1135,34 @@ mod tests {
|
||||
let _ = (LimLoc::UndOvr, MathJc::Center);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latex_binom_round_trip() {
|
||||
let a = parse_latex(r"\binom{n}{k}");
|
||||
let l = to_latex(&a);
|
||||
assert_eq!(parse_latex(&l), a, "exported as {l}");
|
||||
let nobar = |n: &str, k: &str| MNode::Frac { kind: FracKind::NoBar, num: parse_latex(n), den: parse_latex(k) };
|
||||
let b = parse_latex(r"\genfrac{}{}{0pt}{}{n}{k}");
|
||||
assert_eq!(b, vec![nobar("n", "k")]);
|
||||
assert_eq!(parse_latex(&to_latex(&b)), b);
|
||||
// Other spellings of a zero thickness.
|
||||
for z in ["0 pt", "0.0pt", r"0\,pt", "0em"] {
|
||||
let c = parse_latex(&format!(r"\genfrac{{}}{{}}{{{z}}}{{}}{{n}}{{k}}"));
|
||||
assert_eq!(c, vec![nobar("n", "k")], "thickness {z}");
|
||||
}
|
||||
// A non-zero or default thickness keeps the bar.
|
||||
for t in ["1pt", "0.4pt", ""] {
|
||||
let c = parse_latex(&format!(r"\genfrac{{}}{{}}{{{t}}}{{}}{{n}}{{k}}"));
|
||||
let bar = MNode::Frac { kind: FracKind::Bar, num: parse_latex("n"), den: parse_latex("k") };
|
||||
assert_eq!(c, vec![bar], "thickness {t:?}");
|
||||
}
|
||||
// Delimiters are kept.
|
||||
let d = parse_latex(r"\genfrac(){0pt}{}{n}{k}");
|
||||
assert_eq!(d, a);
|
||||
let e = parse_latex(r"\genfrac[]{0pt}{}{n}{k}");
|
||||
assert!(matches!(&e[..], [MNode::Delim { beg: Some('['), end: Some(']'), .. }]), "{e:#?}");
|
||||
assert_eq!(parse_latex(&to_latex(&e)), e);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latex_round_trip() {
|
||||
for lin in ["x=(-b±√(b^2-4ac))/2a", "∑_(i=1)^n▒i^2", "√(3&x)", "a⃗+x̂", "■(a&b@c&d)", "sinx+cosy", "e^(-x^2)"] {
|
||||
|
||||
@@ -18,6 +18,18 @@ pub const FUNCTION_NAMES: &[&str] = &[
|
||||
"tanh",
|
||||
];
|
||||
|
||||
/// Length of the longest of [`FUNCTION_NAMES`].
|
||||
const MAX_NAME: usize = {
|
||||
let (mut max, mut i) = (0, 0);
|
||||
while i < FUNCTION_NAMES.len() {
|
||||
if FUNCTION_NAMES[i].len() > max {
|
||||
max = FUNCTION_NAMES[i].len();
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
max
|
||||
};
|
||||
|
||||
/// Function names whose subscripts go underneath (`lim_(n→∞)`).
|
||||
const LIMIT_NAMES: &[&str] = &["lim", "liminf", "limsup", "max", "min", "sup", "inf", "det", "gcd", "Pr"];
|
||||
|
||||
@@ -410,10 +422,12 @@ impl P {
|
||||
fn word(&mut self, stops: &[char]) -> Arg {
|
||||
let start = self.i;
|
||||
let mut end = start;
|
||||
while self.c.get(end).is_some_and(char::is_ascii_alphabetic) {
|
||||
// Look no further than the longest name (and one past it, to see the run is longer): a
|
||||
// scan to the end of every run made a long run of letters quadratic (CWE-407).
|
||||
while end - start <= MAX_NAME && self.c.get(end).is_some_and(char::is_ascii_alphabetic) {
|
||||
end += 1;
|
||||
}
|
||||
let word: String = self.c.get(start..end).unwrap_or(&[]).iter().collect();
|
||||
let word: String = if end - start > MAX_NAME { String::new() } else { self.c.get(start..end).unwrap_or(&[]).iter().collect() };
|
||||
if !FUNCTION_NAMES.contains(&word.as_str()) {
|
||||
self.i = start + 1;
|
||||
return vec![text(self.c.get(start).copied().unwrap_or(' '))];
|
||||
@@ -556,7 +570,7 @@ impl P {
|
||||
}
|
||||
out.pop()
|
||||
};
|
||||
if !matches!(c, '^' | '_') && !(matches!(c, '┬' | '┴') && self.no_limits == 0) && !is_combining(c) {
|
||||
if !(matches!(c, '^' | '_') || matches!(c, '┬' | '┴') && self.no_limits == 0 || is_combining(c)) {
|
||||
break;
|
||||
}
|
||||
// The base is already deep: the operator is text (see `too_deep`).
|
||||
|
||||
@@ -357,7 +357,8 @@ impl Counters {
|
||||
return Some((def.text.clone(), def));
|
||||
}
|
||||
let mut label = String::new();
|
||||
let mut chars = def.text.chars().peekable();
|
||||
// Word's level text is short; .docx and the lists command already cap it at 64.
|
||||
let mut chars = def.text.chars().take(MAX_LEVEL_TEXT).peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '%'
|
||||
&& let Some(d) = chars.peek().and_then(|d| d.to_digit(10))
|
||||
@@ -375,6 +376,9 @@ impl Counters {
|
||||
f => f,
|
||||
};
|
||||
label.push_str(&fmt.format(val));
|
||||
if label.len() > MAX_LABEL_BYTES {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
label.push(c);
|
||||
}
|
||||
@@ -383,10 +387,29 @@ impl Counters {
|
||||
}
|
||||
}
|
||||
|
||||
/// Longest level text (in characters) expanded into a list label.
|
||||
const MAX_LEVEL_TEXT: usize = 64;
|
||||
/// Labels stop growing once they pass this many bytes.
|
||||
const MAX_LABEL_BYTES: usize = 256;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_huge_level_text_gives_a_bounded_label() {
|
||||
let mut n = Numbering::default();
|
||||
let id = n.add_list(ListKind::Numbered);
|
||||
let huge = Level { format: NumFormat::UpperRoman, text: "%1".repeat(30_000), start: u32::MAX, ..Level::default() };
|
||||
if let Some(num) = n.nums.iter_mut().find(|x| x.id == id) {
|
||||
num.level_overrides = vec![(0, huge)];
|
||||
}
|
||||
let mut c = Counters::default();
|
||||
let label = c.next_label(&n, id, 0).unwrap().0;
|
||||
assert!(label.len() <= 512, "label is {} bytes", label.len());
|
||||
assert!(label.starts_with("MMMM"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_list_s_own_level_definitions_win() {
|
||||
// A specification's article numbers: level 1 counts parts with no label of its own, level 2
|
||||
|
||||
+21
-848
@@ -13,852 +13,15 @@ use crate::props::{CharProps, ParaProps};
|
||||
use crate::section::SectionProps;
|
||||
use crate::{DocError, Result};
|
||||
|
||||
/// Object replacement character: marks an inline object in paragraph text.
|
||||
pub const OBJ: char = '\u{FFFC}';
|
||||
/// Manual line break (Shift+Enter).
|
||||
pub const LINE_BREAK: char = '\n';
|
||||
/// Page break (Ctrl+Enter).
|
||||
pub const PAGE_BREAK: char = '\u{000C}';
|
||||
/// Column break (Ctrl+Shift+Enter).
|
||||
pub const COLUMN_BREAK: char = '\u{000E}';
|
||||
/// Optional hyphen (Ctrl+-).
|
||||
pub const SOFT_HYPHEN: char = '\u{00AD}';
|
||||
/// Nonbreaking hyphen (Ctrl+Shift+-).
|
||||
pub const NB_HYPHEN: char = '\u{2011}';
|
||||
/// Nonbreaking space (Ctrl+Shift+Space).
|
||||
pub const NBSP: char = '\u{00A0}';
|
||||
mod float;
|
||||
mod inline_kinds;
|
||||
mod inline_object;
|
||||
mod run;
|
||||
|
||||
static REV: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
/// A fresh revision number (process-unique), used by layout caches to detect edits.
|
||||
pub fn next_rev() -> u64 {
|
||||
REV.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// A formatting run: `len` bytes of text sharing `props`.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Run {
|
||||
pub len: usize,
|
||||
#[serde(default, skip_serializing_if = "CharProps::is_empty")]
|
||||
pub props: CharProps,
|
||||
}
|
||||
|
||||
/// How a floating object is placed.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Wrap {
|
||||
/// In line with text (not floating).
|
||||
#[default]
|
||||
Inline,
|
||||
Square,
|
||||
Tight,
|
||||
Through,
|
||||
TopAndBottom,
|
||||
BehindText,
|
||||
InFrontOfText,
|
||||
}
|
||||
|
||||
/// Which sides of a Square, Tight or Through object text may flow on (`@wrapText`, ECMA-376
|
||||
/// Part 1, §20.4.3.7 `ST_WrapText`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum WrapText {
|
||||
/// Both sides of the object.
|
||||
#[default]
|
||||
BothSides,
|
||||
/// Only the left side: the band to the object's right stays empty.
|
||||
Left,
|
||||
/// Only the right side.
|
||||
Right,
|
||||
/// Only whichever side has more room, decided per line.
|
||||
Largest,
|
||||
}
|
||||
|
||||
impl WrapText {
|
||||
/// From the OOXML value (`bothSides`, `left`, `right`, `largest`).
|
||||
pub fn from_ooxml(v: &str) -> Option<WrapText> {
|
||||
let v = v.trim();
|
||||
if v.eq_ignore_ascii_case("bothSides") || v.eq_ignore_ascii_case("both") {
|
||||
Some(WrapText::BothSides)
|
||||
} else if v.eq_ignore_ascii_case("left") {
|
||||
Some(WrapText::Left)
|
||||
} else if v.eq_ignore_ascii_case("right") {
|
||||
Some(WrapText::Right)
|
||||
} else if v.eq_ignore_ascii_case("largest") {
|
||||
Some(WrapText::Largest)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The OOXML value.
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
WrapText::BothSides => "bothSides",
|
||||
WrapText::Left => "left",
|
||||
WrapText::Right => "right",
|
||||
WrapText::Largest => "largest",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_both(&self) -> bool {
|
||||
*self == WrapText::BothSides
|
||||
}
|
||||
}
|
||||
|
||||
/// What a floating object's offset is relative to.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Anchor {
|
||||
#[default]
|
||||
Column,
|
||||
Margin,
|
||||
Page,
|
||||
Paragraph,
|
||||
/// The page's left margin area (from the page's left edge to the text).
|
||||
LeftMargin,
|
||||
/// The page's right margin area (from the text to the page's right edge).
|
||||
RightMargin,
|
||||
/// The page's top margin area (from the page's top edge to the text).
|
||||
TopMargin,
|
||||
/// The page's bottom margin area (from the text to the page's bottom edge).
|
||||
BottomMargin,
|
||||
/// The inside margin (left on odd pages, right on even pages when mirrored).
|
||||
InsideMargin,
|
||||
/// The outside margin.
|
||||
OutsideMargin,
|
||||
/// The anchor character (horizontal only).
|
||||
Character,
|
||||
/// The anchor line (vertical only).
|
||||
Line,
|
||||
}
|
||||
|
||||
/// Alignment of a floating object within its [`Anchor`] area, instead of an offset.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum FloatAlign {
|
||||
/// Left or top.
|
||||
Start,
|
||||
Center,
|
||||
/// Right or bottom.
|
||||
End,
|
||||
Inside,
|
||||
Outside,
|
||||
}
|
||||
|
||||
impl FloatAlign {
|
||||
/// From an OOXML alignment (`wp:align`, `w:tblpXSpec`, `w:tblpYSpec`).
|
||||
pub fn from_ooxml(v: &str) -> Option<FloatAlign> {
|
||||
match v {
|
||||
"left" | "top" => Some(FloatAlign::Start),
|
||||
"center" => Some(FloatAlign::Center),
|
||||
"right" | "bottom" => Some(FloatAlign::End),
|
||||
"inside" => Some(FloatAlign::Inside),
|
||||
"outside" => Some(FloatAlign::Outside),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The OOXML name on the horizontal (`left`, `right`) or vertical (`top`, `bottom`) axis.
|
||||
pub fn ooxml(self, horizontal: bool) -> &'static str {
|
||||
match (self, horizontal) {
|
||||
(FloatAlign::Start, true) => "left",
|
||||
(FloatAlign::Start, false) => "top",
|
||||
(FloatAlign::Center, _) => "center",
|
||||
(FloatAlign::End, true) => "right",
|
||||
(FloatAlign::End, false) => "bottom",
|
||||
(FloatAlign::Inside, _) => "inside",
|
||||
(FloatAlign::Outside, _) => "outside",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Floating placement (ignored when `wrap` is `Inline`).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Float {
|
||||
pub wrap: Wrap,
|
||||
/// The sides text wraps on (Square, Tight and Through).
|
||||
#[serde(skip_serializing_if = "WrapText::is_both")]
|
||||
pub wrap_text: WrapText,
|
||||
pub h_rel: Anchor,
|
||||
pub v_rel: Anchor,
|
||||
/// Offsets, points (used when the matching alignment is `None`).
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
/// Alignments within the anchor area; they take precedence over the offsets.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub h_align: Option<FloatAlign>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub v_align: Option<FloatAlign>,
|
||||
/// Distance from surrounding text at the left and right (points).
|
||||
pub dist: f32,
|
||||
/// Distance from surrounding text above and below (points).
|
||||
pub dist_top: f32,
|
||||
pub dist_bottom: f32,
|
||||
/// Room around the object for effects such as shadows (left, top, right, bottom, points),
|
||||
/// inline or floating: lines and surrounding text keep clear of it. Rotation's own overhang
|
||||
/// is not in here: it follows from [`Float::rot`] (see [`Float::spin_pad`]).
|
||||
pub effect: [f32; 4],
|
||||
/// Rotation clockwise about the object's centre, degrees (DrawingML `a:xfrm/@rot`). Offsets
|
||||
/// and the size stay those of the unrotated frame, as in Word; see [`Float::spin`].
|
||||
#[serde(skip_serializing_if = "is_zero")]
|
||||
pub rot: f32,
|
||||
/// Mirrored left to right / top to bottom (`a:xfrm/@flipH`, `@flipV`).
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
pub flip_h: bool,
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
pub flip_v: bool,
|
||||
/// The outline Tight and Through wrapping follow (`wp:wrapPolygon`), in the object's own
|
||||
/// (unrotated) frame; `None` = derived from the object (see [`crate::wrap`]).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub wrap_polygon: Option<std::sync::Arc<crate::wrap::WrapPolygon>>,
|
||||
/// What the drawing is, for screen readers and exports (`wp:docPr` `@descr`, `@title` and the
|
||||
/// decorative mark). See [`InlineObject::alt_text`].
|
||||
#[serde(skip_serializing_if = "AltText::is_empty")]
|
||||
pub alt: AltText,
|
||||
/// Hidden (`wp:docPr/@hidden`, VML `visibility:hidden`): kept and saved, but neither drawn nor
|
||||
/// laid out, as Word for Windows does (Word for Mac draws hidden VML shapes).
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
pub hidden: bool,
|
||||
/// Anchored in a table cell but placed on the page, not in the cell (`wp:anchor/@layoutInCell`
|
||||
/// off, any VML shape in a header or footer): a picture watermark in a header that starts with a table.
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
pub outside_cell: bool,
|
||||
/// Stacking order among floating objects (`wp:anchor/@relativeHeight`): higher is drawn on
|
||||
/// top. 0 = unknown, drawn in document order (objects inserted here, or from other formats).
|
||||
#[serde(skip_serializing_if = "is_zero_u32")]
|
||||
pub z: u32,
|
||||
}
|
||||
|
||||
/// Longest alternative text kept (characters): hostile files and commands are capped.
|
||||
pub const MAX_ALT_TEXT: usize = 10_000;
|
||||
|
||||
/// A drawing's alternative text (Picture/Shape Format › Accessibility › Alt Text).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct AltText {
|
||||
/// The description of a shape, text box or group. Pictures and charts keep theirs in their
|
||||
/// own `alt`; read it with [`InlineObject::alt_text`], which knows where it is.
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub text: String,
|
||||
/// The short title (`wp:docPr/@title`).
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub title: String,
|
||||
/// Decorative: it adds nothing to the content, so screen readers skip it and the
|
||||
/// accessibility checker doesn't ask for a description.
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
pub decorative: bool,
|
||||
}
|
||||
|
||||
impl AltText {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.text.is_empty() && self.title.is_empty() && !self.decorative
|
||||
}
|
||||
}
|
||||
|
||||
/// `s` cut to [`MAX_ALT_TEXT`] characters.
|
||||
pub fn cap_alt(s: &str) -> String {
|
||||
s.chars().take(MAX_ALT_TEXT).collect()
|
||||
}
|
||||
|
||||
fn is_zero(v: &f32) -> bool {
|
||||
*v == 0.0
|
||||
}
|
||||
fn is_false(v: &bool) -> bool {
|
||||
!*v
|
||||
}
|
||||
fn is_zero_u32(v: &u32) -> bool {
|
||||
*v == 0
|
||||
}
|
||||
|
||||
impl Float {
|
||||
/// The rotation and flips, the angle normalised to `0..360` (a NaN angle is no rotation).
|
||||
pub fn spin(&self) -> wordcraft_geom::Spin {
|
||||
wordcraft_geom::Spin::new(self.rot, self.flip_h, self.flip_v)
|
||||
}
|
||||
/// Set the rotation and flips (the angle normalised).
|
||||
pub fn set_spin(&mut self, s: wordcraft_geom::Spin) {
|
||||
self.rot = wordcraft_geom::normalize_degrees(s.deg);
|
||||
self.flip_h = s.flip_h;
|
||||
self.flip_v = s.flip_v;
|
||||
}
|
||||
/// How far a `w` × `h` frame's rotated bounds reach past it on each side (x, y): the room a
|
||||
/// rotated object takes beyond its frame (zero unrotated; negative where the turned object
|
||||
/// is narrower than its frame, as a wide picture turned 90° is).
|
||||
pub fn spin_pad(&self, w: f32, h: f32) -> (f32, f32) {
|
||||
let (w, h) = (wordcraft_geom::finite(w).clamp(0.0, 4000.0), wordcraft_geom::finite(h).clamp(0.0, 4000.0));
|
||||
let (bw, bh) = self.spin().extent(w, h);
|
||||
((bw - w) / 2.0, (bh - h) / 2.0)
|
||||
}
|
||||
/// The effect extents, finite and clamped (left, top, right, bottom).
|
||||
pub fn effect_extent(&self) -> [f32; 4] {
|
||||
self.effect.map(|v| if v.is_finite() { v.clamp(0.0, 1584.0) } else { 0.0 })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ShapeKind {
|
||||
#[default]
|
||||
Rectangle,
|
||||
RoundedRectangle,
|
||||
Ellipse,
|
||||
/// Flowchart OR preset: an ellipse with horizontal and vertical spokes.
|
||||
FlowchartOr,
|
||||
Triangle,
|
||||
/// A right triangle (DrawingML's `rtTriangle`): the right angle at the bottom left.
|
||||
RightTriangle,
|
||||
Diamond,
|
||||
Line,
|
||||
/// A block arrow pointing right (`rightArrow`; also stands for the double-headed arrows).
|
||||
Arrow,
|
||||
/// A block arrow pointing left (`leftArrow`).
|
||||
ArrowLeft,
|
||||
/// A block arrow pointing up (`upArrow`).
|
||||
ArrowUp,
|
||||
/// A block arrow pointing down (`downArrow`).
|
||||
ArrowDown,
|
||||
Star,
|
||||
Heart,
|
||||
/// A text box (rectangle with a text story).
|
||||
TextBox,
|
||||
/// A shape drawn point by point, such as ink (its geometry is the shape's `freeform`).
|
||||
Freeform,
|
||||
/// Connectors: lines whose ends can be glued to other shapes (see [`crate::connector`]).
|
||||
StraightConnector,
|
||||
ElbowConnector,
|
||||
CurvedConnector,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum NoteKind {
|
||||
#[default]
|
||||
Footnote,
|
||||
Endnote,
|
||||
}
|
||||
|
||||
/// Where a positional tab ([`InlineObject::PositionalTab`]) lands on its line, and how the text
|
||||
/// after it lines up there (ECMA-376 `ST_PTabAlignment`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PTabAlign {
|
||||
#[default]
|
||||
Left,
|
||||
Center,
|
||||
Right,
|
||||
}
|
||||
|
||||
impl PTabAlign {
|
||||
pub const ALL: [PTabAlign; 3] = [PTabAlign::Left, PTabAlign::Center, PTabAlign::Right];
|
||||
|
||||
/// The OOXML name (`left`, `center`, `right`).
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
PTabAlign::Left => "left",
|
||||
PTabAlign::Center => "center",
|
||||
PTabAlign::Right => "right",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_ooxml(v: &str) -> Option<PTabAlign> {
|
||||
PTabAlign::ALL.into_iter().find(|a| a.ooxml() == v)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a positional tab's place is measured against (ECMA-376 `ST_PTabRelativeTo`): the text
|
||||
/// margins, or the paragraph's indents.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PTabBase {
|
||||
#[default]
|
||||
Margin,
|
||||
Indent,
|
||||
}
|
||||
|
||||
impl PTabBase {
|
||||
pub const ALL: [PTabBase; 2] = [PTabBase::Margin, PTabBase::Indent];
|
||||
|
||||
/// The OOXML name (`margin`, `indent`).
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
PTabBase::Margin => "margin",
|
||||
PTabBase::Indent => "indent",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_ooxml(v: &str) -> Option<PTabBase> {
|
||||
PTabBase::ALL.into_iter().find(|a| a.ooxml() == v)
|
||||
}
|
||||
}
|
||||
|
||||
/// How ruby (phonetic guide) text lines up over its base text (ECMA-376 `ST_RubyAlign`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum RubyAlign {
|
||||
#[default]
|
||||
Center,
|
||||
/// Letters spread evenly from edge to edge.
|
||||
DistributeLetter,
|
||||
/// Letters spread evenly with half a gap at each edge.
|
||||
DistributeSpace,
|
||||
Left,
|
||||
Right,
|
||||
/// Right-aligned beside vertical text (laid out like `Right` in horizontal text).
|
||||
RightVertical,
|
||||
}
|
||||
|
||||
impl RubyAlign {
|
||||
pub const ALL: [RubyAlign; 6] =
|
||||
[RubyAlign::Center, RubyAlign::DistributeLetter, RubyAlign::DistributeSpace, RubyAlign::Left, RubyAlign::Right, RubyAlign::RightVertical];
|
||||
|
||||
/// The OOXML name (`center`, `distributeLetter`…), also used by commands.
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
RubyAlign::Center => "center",
|
||||
RubyAlign::DistributeLetter => "distributeLetter",
|
||||
RubyAlign::DistributeSpace => "distributeSpace",
|
||||
RubyAlign::Left => "left",
|
||||
RubyAlign::Right => "right",
|
||||
RubyAlign::RightVertical => "rightVertical",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_ooxml(v: &str) -> Option<RubyAlign> {
|
||||
RubyAlign::ALL.into_iter().find(|a| a.ooxml() == v)
|
||||
}
|
||||
}
|
||||
|
||||
/// Phonetic Guide's Offset for a ruby `raise` (baseline to baseline, points): the gap from the top
|
||||
/// of the base text to the bottom of the ruby text, taking letters as rising 0.8 em above their
|
||||
/// baseline and dropping 0.2 em below it.
|
||||
pub fn ruby_offset(raise: f32, base_size: f32, size: f32) -> f32 {
|
||||
raise - 0.8 * base_size - 0.2 * size
|
||||
}
|
||||
|
||||
/// The ruby raise (baseline to baseline, points) for a Phonetic Guide Offset (see [`ruby_offset`]).
|
||||
pub fn ruby_raise(offset: f32, base_size: f32, size: f32) -> f32 {
|
||||
offset + 0.8 * base_size + 0.2 * size
|
||||
}
|
||||
|
||||
/// An inline object anchored at a U+FFFC in the paragraph text.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum InlineObject {
|
||||
Image {
|
||||
/// Key in `Document::media`.
|
||||
media: String,
|
||||
/// Display size, points.
|
||||
w: f32,
|
||||
h: f32,
|
||||
#[serde(default)]
|
||||
alt: String,
|
||||
#[serde(default)]
|
||||
float: Float,
|
||||
/// Crop (left, top, right, bottom) fractions 0..1.
|
||||
#[serde(default)]
|
||||
crop: [f32; 4],
|
||||
/// The OLE object (embedded or linked file) or Word signature line this picture shows, as
|
||||
/// read from a file: saving writes the object back, not only its picture. See
|
||||
/// [`crate::graphic::Embedded`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
ole: Option<std::sync::Arc<crate::graphic::Embedded>>,
|
||||
},
|
||||
/// A chart or SmartArt diagram (see [`crate::graphic`]).
|
||||
Graphic {
|
||||
w: f32,
|
||||
h: f32,
|
||||
#[serde(default)]
|
||||
alt: String,
|
||||
#[serde(default)]
|
||||
float: Float,
|
||||
graphic: std::sync::Arc<crate::graphic::Graphic>,
|
||||
},
|
||||
Shape {
|
||||
kind: ShapeKind,
|
||||
w: f32,
|
||||
h: f32,
|
||||
fill: Option<crate::fill::Fill>,
|
||||
stroke: Option<crate::props::Rgb>,
|
||||
#[serde(default)]
|
||||
stroke_width: f32,
|
||||
#[serde(default)]
|
||||
float: Float,
|
||||
/// Text box content: `Document::parts` id.
|
||||
#[serde(default)]
|
||||
story: Option<u32>,
|
||||
/// A freeform's geometry (`ShapeKind::Freeform`): its paths, or an ink stroke.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
freeform: Option<std::sync::Arc<crate::freeform::Freeform>>,
|
||||
/// Shadow, glow and soft edges.
|
||||
#[serde(default, skip_serializing_if = "crate::effects::ShapeEffects::is_empty")]
|
||||
effects: crate::effects::ShapeEffects,
|
||||
/// Drawing id, text warp, connector ends and arrowheads.
|
||||
#[serde(default, skip_serializing_if = "crate::connector::ShapeExtra::is_empty")]
|
||||
extra: crate::connector::ShapeExtra,
|
||||
},
|
||||
/// Pictures, shapes and text boxes grouped into one object (Layout › Arrange › Group): it
|
||||
/// moves, wraps and resizes as one. Its members are laid out in the group's own coordinate
|
||||
/// space, `ch_w` × `ch_h`, which is stretched over the group's `w` × `h` (DrawingML's
|
||||
/// `a:chExt` and `a:ext`), so resizing the group scales them.
|
||||
Group {
|
||||
w: f32,
|
||||
h: f32,
|
||||
#[serde(default)]
|
||||
float: Float,
|
||||
/// Size of the members' coordinate space (their offsets and sizes are in it).
|
||||
ch_w: f32,
|
||||
ch_h: f32,
|
||||
children: Vec<GroupChild>,
|
||||
/// A Drawing Canvas (Insert › Shapes › New Drawing Canvas, DrawingML's `wpc:wpc`) rather
|
||||
/// than a group: it has its own background and outline, and its frame is independent of
|
||||
/// its members: resizing it doesn't scale them (`ch_w` × `ch_h` follow `w` × `h`), and
|
||||
/// what lies outside the frame is clipped.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
canvas: Option<CanvasStyle>,
|
||||
},
|
||||
/// A field: `instr` is the field code (`PAGE`, `NUMPAGES`, `DATE \@ "M/d/yyyy"`, `TOC \o "1-3"`…);
|
||||
/// `result` the cached display text. A `locked` field keeps its result when fields update.
|
||||
Field {
|
||||
instr: String,
|
||||
#[serde(default)]
|
||||
result: String,
|
||||
#[serde(default)]
|
||||
locked: bool,
|
||||
/// `w:dirty` (ECMA-376 §17.16.5.35): the cached result is stale — another program that
|
||||
/// couldn't compute it set the flag so Word refreshes the field on open. Kept through
|
||||
/// saves; cleared when WordCraft computes a result, like Word's F9.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
dirty: bool,
|
||||
/// Shows its code (`{ PAGE }`) instead of its result, the other way round from the
|
||||
/// document-wide View › Field Codes setting (`fields.toggleCode`, Shift+F9). A view
|
||||
/// toggle: never written to files.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
code: bool,
|
||||
/// A legacy form field's definition (`FORMCHECKBOX`, `FORMTEXT`, `FORMDROPDOWN`): the
|
||||
/// source's `w:ffData`, raw OOXML kept for round trip. Empty for other fields.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
form: String,
|
||||
},
|
||||
/// The start of a field whose result is ordinary content — formatted, possibly spanning
|
||||
/// paragraphs — up to the matching [`InlineObject::FieldEnd`]. Imported TOCs and citation
|
||||
/// managers' `ADDIN` fields are kept this way; `instr` is the field code. See
|
||||
/// [`crate::fields`].
|
||||
FieldStart {
|
||||
instr: String,
|
||||
#[serde(default)]
|
||||
locked: bool,
|
||||
/// `w:dirty` on the begin marker, as [`InlineObject::Field::dirty`].
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
dirty: bool,
|
||||
},
|
||||
/// The end of the innermost open [`InlineObject::FieldStart`].
|
||||
FieldEnd,
|
||||
/// The start of a content control whose content is what follows, up to the matching
|
||||
/// [`InlineObject::ControlEnd`]. See [`crate::control`].
|
||||
ControlStart {
|
||||
control: Box<crate::control::ContentControl>,
|
||||
},
|
||||
/// The end of the innermost open [`InlineObject::ControlStart`].
|
||||
ControlEnd,
|
||||
NoteRef {
|
||||
kind: NoteKind,
|
||||
/// `Document::parts` id of the note's story.
|
||||
id: u32,
|
||||
/// Custom mark (empty = automatic number).
|
||||
#[serde(default)]
|
||||
custom: String,
|
||||
},
|
||||
BookmarkStart {
|
||||
name: String,
|
||||
},
|
||||
BookmarkEnd {
|
||||
name: String,
|
||||
},
|
||||
CommentStart {
|
||||
id: u32,
|
||||
},
|
||||
CommentEnd {
|
||||
id: u32,
|
||||
},
|
||||
/// The start of a range that stays editable while the document is protected (Restrict
|
||||
/// Editing exceptions, `w:permStart`): for everyone (`group` = `everyone`), another editing
|
||||
/// group, or one editor (`editor`, a user name or address).
|
||||
PermStart {
|
||||
id: u32,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
group: String,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
editor: String,
|
||||
},
|
||||
/// The end of the editable range [`InlineObject::PermStart`] with the same `id` opened.
|
||||
PermEnd {
|
||||
id: u32,
|
||||
},
|
||||
/// An equation: its linear format (`x=(-b±√(b^2-4ac))/2a`, for plain text) and structure.
|
||||
/// A display equation sits on a line of its own.
|
||||
Equation {
|
||||
linear: String,
|
||||
#[serde(default)]
|
||||
display: bool,
|
||||
/// The structure; empty = parse `linear`.
|
||||
#[serde(default, skip_serializing_if = "crate::math::Math::is_empty")]
|
||||
math: crate::math::Math,
|
||||
},
|
||||
/// Ruby (Phonetic Guide): small `ruby` text (furigana, pinyin…) set over `base` text. The base
|
||||
/// text takes the formatting of the object's run; it reads as the paragraph's text.
|
||||
Ruby {
|
||||
base: String,
|
||||
ruby: String,
|
||||
#[serde(default)]
|
||||
align: RubyAlign,
|
||||
/// Ruby text size, points (`w:hps`).
|
||||
size: f32,
|
||||
/// From the base text's baseline up to the ruby text's baseline, points (`w:hpsRaise`).
|
||||
raise: f32,
|
||||
/// The base text size the ruby was set for, points (`w:hpsBaseText`).
|
||||
#[serde(default)]
|
||||
base_size: f32,
|
||||
/// Language of the ruby (`w:lid`, e.g. `ja-JP`); empty = unspecified.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
lang: String,
|
||||
/// Formatting of the ruby text over the run's (font, colour…); its size is `size`.
|
||||
#[serde(default, skip_serializing_if = "CharProps::is_empty")]
|
||||
props: Box<CharProps>,
|
||||
},
|
||||
/// A positional tab (Word's alignment tab, `w:ptab`): it goes to the left edge, centre or
|
||||
/// right edge of the margins or of the indents, whatever the paragraph's tab stops, and the
|
||||
/// text after it lines up there. Text already past that place sends it to the next line.
|
||||
PositionalTab {
|
||||
#[serde(default)]
|
||||
align: PTabAlign,
|
||||
#[serde(default)]
|
||||
relative_to: PTabBase,
|
||||
#[serde(default)]
|
||||
leader: crate::props::TabLeader,
|
||||
},
|
||||
/// Something we don't model, kept for round-trip (raw XML of the source format).
|
||||
Opaque {
|
||||
format: String,
|
||||
xml: String,
|
||||
#[serde(default)]
|
||||
text: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// One member of an [`InlineObject::Group`]: a picture or shape (its own `w`, `h` and story), at
|
||||
/// `x`, `y` in the group's coordinate space. Its `float` is unused.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GroupChild {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub obj: InlineObject,
|
||||
}
|
||||
|
||||
/// Most members a group keeps (hostile files).
|
||||
pub const MAX_GROUP_CHILDREN: usize = 1000;
|
||||
|
||||
/// The background fill and outline of a Drawing Canvas (an [`InlineObject::Group`] with `canvas`).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct CanvasStyle {
|
||||
pub fill: Option<crate::fill::Fill>,
|
||||
pub stroke: Option<crate::props::Rgb>,
|
||||
/// Outline width, points.
|
||||
pub stroke_width: f32,
|
||||
}
|
||||
|
||||
impl InlineObject {
|
||||
/// A picture, chart or diagram, shape, text box or group: something drawn with a frame, sized and wrapped.
|
||||
pub fn is_drawing(&self) -> bool {
|
||||
matches!(self, InlineObject::Image { .. } | InlineObject::Graphic { .. } | InlineObject::Shape { .. } | InlineObject::Group { .. })
|
||||
}
|
||||
/// A drawing's size and placement.
|
||||
pub fn frame(&self) -> Option<(f32, f32, &Float)> {
|
||||
match self {
|
||||
InlineObject::Image { w, h, float, .. }
|
||||
| InlineObject::Graphic { w, h, float, .. }
|
||||
| InlineObject::Shape { w, h, float, .. }
|
||||
| InlineObject::Group { w, h, float, .. } => Some((*w, *h, float)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// A drawing's placement, to change.
|
||||
pub fn float_mut(&mut self) -> Option<&mut Float> {
|
||||
match self {
|
||||
InlineObject::Image { float, .. }
|
||||
| InlineObject::Graphic { float, .. }
|
||||
| InlineObject::Shape { float, .. }
|
||||
| InlineObject::Group { float, .. } => Some(float),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Resize a drawing (a group's members scale with it; a canvas's keep their size and place).
|
||||
pub fn set_size(&mut self, nw: f32, nh: f32) {
|
||||
if let InlineObject::Image { w, h, .. }
|
||||
| InlineObject::Graphic { w, h, .. }
|
||||
| InlineObject::Shape { w, h, .. }
|
||||
| InlineObject::Group { w, h, .. } = self
|
||||
{
|
||||
*w = nw;
|
||||
*h = nh;
|
||||
}
|
||||
if let InlineObject::Group { ch_w, ch_h, canvas: Some(_), .. } = self {
|
||||
*ch_w = nw;
|
||||
*ch_h = nh;
|
||||
}
|
||||
}
|
||||
/// A Drawing Canvas's background and outline (`None` for anything else).
|
||||
pub fn canvas_style(&self) -> Option<&CanvasStyle> {
|
||||
match self {
|
||||
InlineObject::Group { canvas, .. } => canvas.as_ref(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Whether this is a Drawing Canvas.
|
||||
pub fn is_canvas(&self) -> bool {
|
||||
self.canvas_style().is_some()
|
||||
}
|
||||
/// The text box story this object shows, if it is a text box.
|
||||
pub fn text_box(&self) -> Option<u32> {
|
||||
match self {
|
||||
InlineObject::Shape { story, .. } => *story,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The text box stories this object shows: a text box's, or its group members'.
|
||||
pub fn text_boxes(&self) -> Vec<u32> {
|
||||
match self {
|
||||
InlineObject::Shape { story: Some(id), .. } => vec![*id],
|
||||
InlineObject::Group { children, .. } => children.iter().filter_map(|c| c.obj.text_box()).collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
/// The text box story slots of this object and its group members, to repoint.
|
||||
pub fn text_box_slots(&mut self) -> Vec<&mut Option<u32>> {
|
||||
match self {
|
||||
InlineObject::Shape { story, .. } => vec![story],
|
||||
InlineObject::Group { children, .. } => children
|
||||
.iter_mut()
|
||||
.filter_map(|c| match &mut c.obj {
|
||||
InlineObject::Shape { story, .. } => Some(story),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
/// A group's members placed in a `w` × `h` box at (`x`, `y`): each member's rectangle
|
||||
/// (x, y, w, h) and the member. Empty for anything else.
|
||||
pub fn group_rects(&self, x: f32, y: f32, w: f32, h: f32) -> Vec<([f32; 4], &InlineObject)> {
|
||||
let InlineObject::Group { ch_w, ch_h, children, .. } = self else { return Vec::new() };
|
||||
let fin = |v: f32| if v.is_finite() { v } else { 0.0 };
|
||||
let sx = if fin(*ch_w) > 0.0 { fin(w) / ch_w } else { 1.0 };
|
||||
let sy = if fin(*ch_h) > 0.0 { fin(h) / ch_h } else { 1.0 };
|
||||
children
|
||||
.iter()
|
||||
.take(MAX_GROUP_CHILDREN)
|
||||
.filter_map(|c| {
|
||||
let (cw, chh, _) = c.obj.frame()?;
|
||||
let r = [x + fin(c.x) * sx, y + fin(c.y) * sy, (fin(cw) * sx).clamp(0.0, 4000.0), (fin(chh) * sy).clamp(0.0, 4000.0)];
|
||||
(!matches!(c.obj, InlineObject::Group { .. })).then_some((r, &c.obj))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// A legacy form field's definition (`FORMCHECKBOX`, `FORMTEXT`, `FORMDROPDOWN`), read from
|
||||
/// the `w:ffData` it keeps; `None` for anything else.
|
||||
pub fn legacy_form(&self) -> Option<crate::legacy_form::LegacyForm> {
|
||||
match self {
|
||||
InlineObject::Field { form, .. } if !form.is_empty() => crate::legacy_form::LegacyForm::parse(form),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Zero-width markers don't take part in layout.
|
||||
pub fn is_marker(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
InlineObject::BookmarkStart { .. }
|
||||
| InlineObject::BookmarkEnd { .. }
|
||||
| InlineObject::CommentStart { .. }
|
||||
| InlineObject::CommentEnd { .. }
|
||||
| InlineObject::PermStart { .. }
|
||||
| InlineObject::PermEnd { .. }
|
||||
| InlineObject::FieldStart { .. }
|
||||
| InlineObject::FieldEnd
|
||||
| InlineObject::ControlStart { .. }
|
||||
| InlineObject::ControlEnd
|
||||
)
|
||||
}
|
||||
/// A drawing's description (alternative text), wherever it is kept.
|
||||
pub fn alt_text(&self) -> &str {
|
||||
match self {
|
||||
InlineObject::Image { alt, .. } | InlineObject::Graphic { alt, .. } => alt,
|
||||
InlineObject::Shape { float, .. } | InlineObject::Group { float, .. } => &float.alt.text,
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
/// Set a drawing's description (capped at [`MAX_ALT_TEXT`] characters); `false` if this is no drawing.
|
||||
pub fn set_alt_text(&mut self, text: &str) -> bool {
|
||||
let text = cap_alt(text);
|
||||
match self {
|
||||
InlineObject::Image { alt, .. } | InlineObject::Graphic { alt, .. } => *alt = text,
|
||||
InlineObject::Shape { float, .. } | InlineObject::Group { float, .. } => float.alt.text = text,
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
/// A drawing marked decorative.
|
||||
pub fn is_decorative(&self) -> bool {
|
||||
self.frame().is_some_and(|(_, _, f)| f.alt.decorative)
|
||||
}
|
||||
pub fn is_floating(&self) -> bool {
|
||||
match self {
|
||||
InlineObject::Image { float, .. }
|
||||
| InlineObject::Graphic { float, .. }
|
||||
| InlineObject::Shape { float, .. }
|
||||
| InlineObject::Group { float, .. } => float.wrap != Wrap::Inline,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
/// The geometry of an ink stroke (a freeform shape drawn with a pen), if this is one.
|
||||
pub fn ink(&self) -> Option<&crate::freeform::Freeform> {
|
||||
match self {
|
||||
InlineObject::Shape { freeform: Some(f), .. } if f.is_ink() => Some(f),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The line a footnote or endnote separator draws (ECMA-376 §17.11: the `w:separator` and
|
||||
/// `w:continuationSeparator` run elements): a short line, or with `continuation` one across
|
||||
/// the column. Kept as the run element itself.
|
||||
pub fn note_separator(continuation: bool) -> InlineObject {
|
||||
let el = if continuation { "w:continuationSeparator" } else { "w:separator" };
|
||||
InlineObject::Opaque { format: "docx".into(), xml: format!("<w:r><{el}/></w:r>"), text: String::new() }
|
||||
}
|
||||
/// For a [`InlineObject::note_separator`]: whether it is the continuation separator.
|
||||
pub fn as_note_separator(&self) -> Option<bool> {
|
||||
match self {
|
||||
InlineObject::Opaque { format, xml, .. } if format == "docx" => match xml.as_str() {
|
||||
"<w:r><w:separator/></w:r>" => Some(false),
|
||||
"<w:r><w:continuationSeparator/></w:r>" => Some(true),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The text this object contributes to plain-text extraction.
|
||||
pub fn plain_text(&self) -> &str {
|
||||
match self {
|
||||
InlineObject::Field { result, .. } => result,
|
||||
InlineObject::Equation { linear, .. } => linear,
|
||||
InlineObject::Ruby { base, .. } => base,
|
||||
InlineObject::PositionalTab { .. } => "\t",
|
||||
InlineObject::Opaque { text, .. } => text,
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
}
|
||||
pub use float::{AltText, Anchor, Float, FloatAlign, MAX_ALT_TEXT, Wrap, WrapText, cap_alt};
|
||||
pub use inline_kinds::{NoteKind, PTabAlign, PTabBase, RubyAlign, ShapeKind, ruby_offset, ruby_raise};
|
||||
pub use inline_object::{CanvasStyle, GroupChild, InlineObject, MAX_GROUP_CHILDREN};
|
||||
pub use run::{COLUMN_BREAK, LINE_BREAK, NB_HYPHEN, NBSP, OBJ, PAGE_BREAK, Run, SOFT_HYPHEN, next_rev};
|
||||
|
||||
/// A paragraph.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
@@ -1089,6 +252,12 @@ impl Paragraph {
|
||||
pub fn object_offsets(&self) -> Vec<usize> {
|
||||
self.text.char_indices().filter(|(_, c)| *c == OBJ).map(|(i, _)| i).collect()
|
||||
}
|
||||
/// (byte offset, object) for every object, in text order, in one pass. Prefer it to
|
||||
/// [`Paragraph::object_offsets`] + [`Paragraph::object_at`], which counts the objects before
|
||||
/// each offset again, so a loop over a paragraph's objects is quadratic (#3614).
|
||||
pub fn objects_with_offsets(&self) -> impl Iterator<Item = (usize, &InlineObject)> + '_ {
|
||||
self.text.char_indices().filter(|(_, c)| *c == OBJ).map(|(i, _)| i).zip(self.objects.iter())
|
||||
}
|
||||
|
||||
/// Insert plain text (U+FFFC is dropped) at `off` with `props`. Returns the inserted length.
|
||||
pub fn insert_text(&mut self, off: usize, s: &str, props: &CharProps) -> Result<usize> {
|
||||
@@ -1332,9 +501,8 @@ impl Paragraph {
|
||||
pub fn next_boundary(&self, off: usize) -> usize {
|
||||
crate::cluster::next_cluster(&self.text, self.clamp(off))
|
||||
}
|
||||
/// Where Backspace at `off` deletes from: the previous character in the Brahmic scripts
|
||||
/// (Myanmar, Khmer, Devanagari, Thai…), where it removes one vowel sign or stacked consonant
|
||||
/// at a time, otherwise the previous caret stop.
|
||||
/// Where Backspace at `off` deletes from: one Arabic combining mark or one character in the
|
||||
/// Brahmic scripts (Myanmar, Khmer, Devanagari, Thai…), otherwise the previous caret stop.
|
||||
pub fn backspace_from(&self, off: usize) -> usize {
|
||||
let off = self.clamp(off);
|
||||
match self.text.get(..off).and_then(|t| t.char_indices().next_back()) {
|
||||
@@ -1559,6 +727,11 @@ mod tests {
|
||||
assert!(matches!(p.object_at(0), Some(InlineObject::BookmarkStart { .. })));
|
||||
let off = p.object_offsets()[1];
|
||||
assert!(matches!(p.object_at(off), Some(InlineObject::Field { .. })));
|
||||
// The one-pass walk agrees with offsets + object_at.
|
||||
let one_pass: Vec<(usize, &InlineObject)> = p.objects_with_offsets().collect();
|
||||
let each: Vec<(usize, &InlineObject)> = p.object_offsets().into_iter().filter_map(|o| Some((o, p.object_at(o)?))).collect();
|
||||
assert_eq!(one_pass, each);
|
||||
assert_eq!(one_pass.len(), 2);
|
||||
assert_eq!(p.plain_text(), "a1b");
|
||||
p.delete(0, 3).unwrap();
|
||||
assert_eq!(p.objects.len(), 1);
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
use super::{Deserialize, Serialize};
|
||||
|
||||
/// How a floating object is placed.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Wrap {
|
||||
/// In line with text (not floating).
|
||||
#[default]
|
||||
Inline,
|
||||
Square,
|
||||
Tight,
|
||||
Through,
|
||||
TopAndBottom,
|
||||
BehindText,
|
||||
InFrontOfText,
|
||||
}
|
||||
|
||||
/// Which sides of a Square, Tight or Through object text may flow on (`@wrapText`, ECMA-376
|
||||
/// Part 1, §20.4.3.7 `ST_WrapText`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum WrapText {
|
||||
/// Both sides of the object.
|
||||
#[default]
|
||||
BothSides,
|
||||
/// Only the left side: the band to the object's right stays empty.
|
||||
Left,
|
||||
/// Only the right side.
|
||||
Right,
|
||||
/// Only whichever side has more room, decided per line.
|
||||
Largest,
|
||||
}
|
||||
|
||||
impl WrapText {
|
||||
/// From the OOXML value (`bothSides`, `left`, `right`, `largest`).
|
||||
pub fn from_ooxml(v: &str) -> Option<WrapText> {
|
||||
let v = v.trim();
|
||||
if v.eq_ignore_ascii_case("bothSides") || v.eq_ignore_ascii_case("both") {
|
||||
Some(WrapText::BothSides)
|
||||
} else if v.eq_ignore_ascii_case("left") {
|
||||
Some(WrapText::Left)
|
||||
} else if v.eq_ignore_ascii_case("right") {
|
||||
Some(WrapText::Right)
|
||||
} else if v.eq_ignore_ascii_case("largest") {
|
||||
Some(WrapText::Largest)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The OOXML value.
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
WrapText::BothSides => "bothSides",
|
||||
WrapText::Left => "left",
|
||||
WrapText::Right => "right",
|
||||
WrapText::Largest => "largest",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_both(&self) -> bool {
|
||||
*self == WrapText::BothSides
|
||||
}
|
||||
}
|
||||
|
||||
/// What a floating object's offset is relative to.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Anchor {
|
||||
#[default]
|
||||
Column,
|
||||
Margin,
|
||||
Page,
|
||||
Paragraph,
|
||||
/// The page's left margin area (from the page's left edge to the text).
|
||||
LeftMargin,
|
||||
/// The page's right margin area (from the text to the page's right edge).
|
||||
RightMargin,
|
||||
/// The page's top margin area (from the page's top edge to the text).
|
||||
TopMargin,
|
||||
/// The page's bottom margin area (from the text to the page's bottom edge).
|
||||
BottomMargin,
|
||||
/// The inside margin (left on odd pages, right on even pages when mirrored).
|
||||
InsideMargin,
|
||||
/// The outside margin.
|
||||
OutsideMargin,
|
||||
/// The anchor character (horizontal only).
|
||||
Character,
|
||||
/// The anchor line (vertical only).
|
||||
Line,
|
||||
}
|
||||
|
||||
/// Alignment of a floating object within its [`Anchor`] area, instead of an offset.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum FloatAlign {
|
||||
/// Left or top.
|
||||
Start,
|
||||
Center,
|
||||
/// Right or bottom.
|
||||
End,
|
||||
Inside,
|
||||
Outside,
|
||||
}
|
||||
|
||||
impl FloatAlign {
|
||||
/// From an OOXML alignment (`wp:align`, `w:tblpXSpec`, `w:tblpYSpec`).
|
||||
pub fn from_ooxml(v: &str) -> Option<FloatAlign> {
|
||||
match v {
|
||||
"left" | "top" => Some(FloatAlign::Start),
|
||||
"center" => Some(FloatAlign::Center),
|
||||
"right" | "bottom" => Some(FloatAlign::End),
|
||||
"inside" => Some(FloatAlign::Inside),
|
||||
"outside" => Some(FloatAlign::Outside),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The OOXML name on the horizontal (`left`, `right`) or vertical (`top`, `bottom`) axis.
|
||||
pub fn ooxml(self, horizontal: bool) -> &'static str {
|
||||
match (self, horizontal) {
|
||||
(FloatAlign::Start, true) => "left",
|
||||
(FloatAlign::Start, false) => "top",
|
||||
(FloatAlign::Center, _) => "center",
|
||||
(FloatAlign::End, true) => "right",
|
||||
(FloatAlign::End, false) => "bottom",
|
||||
(FloatAlign::Inside, _) => "inside",
|
||||
(FloatAlign::Outside, _) => "outside",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Floating placement (ignored when `wrap` is `Inline`).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Float {
|
||||
pub wrap: Wrap,
|
||||
/// The sides text wraps on (Square, Tight and Through).
|
||||
#[serde(skip_serializing_if = "WrapText::is_both")]
|
||||
pub wrap_text: WrapText,
|
||||
pub h_rel: Anchor,
|
||||
pub v_rel: Anchor,
|
||||
/// Offsets, points (used when the matching alignment is `None`).
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
/// Alignments within the anchor area; they take precedence over the offsets.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub h_align: Option<FloatAlign>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub v_align: Option<FloatAlign>,
|
||||
/// Distance from surrounding text at the left and right (points).
|
||||
pub dist: f32,
|
||||
/// Distance from surrounding text above and below (points).
|
||||
pub dist_top: f32,
|
||||
pub dist_bottom: f32,
|
||||
/// Room around the object for effects such as shadows (left, top, right, bottom, points),
|
||||
/// inline or floating: lines and surrounding text keep clear of it. Rotation's own overhang
|
||||
/// is not in here: it follows from [`Float::rot`] (see [`Float::spin_pad`]).
|
||||
pub effect: [f32; 4],
|
||||
/// Rotation clockwise about the object's centre, degrees (DrawingML `a:xfrm/@rot`). Offsets
|
||||
/// and the size stay those of the unrotated frame, as in Word; see [`Float::spin`].
|
||||
#[serde(skip_serializing_if = "is_zero")]
|
||||
pub rot: f32,
|
||||
/// Mirrored left to right / top to bottom (`a:xfrm/@flipH`, `@flipV`).
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
pub flip_h: bool,
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
pub flip_v: bool,
|
||||
/// The outline Tight and Through wrapping follow (`wp:wrapPolygon`), in the object's own
|
||||
/// (unrotated) frame; `None` = derived from the object (see [`crate::wrap`]).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub wrap_polygon: Option<std::sync::Arc<crate::wrap::WrapPolygon>>,
|
||||
/// What the drawing is, for screen readers and exports (`wp:docPr` `@descr`, `@title` and the
|
||||
/// decorative mark). See [`InlineObject::alt_text`].
|
||||
#[serde(skip_serializing_if = "AltText::is_empty")]
|
||||
pub alt: AltText,
|
||||
/// Hidden (`wp:docPr/@hidden`, VML `visibility:hidden`): kept and saved, but neither drawn nor
|
||||
/// laid out, as Word for Windows does (Word for Mac draws hidden VML shapes).
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
pub hidden: bool,
|
||||
/// Anchored in a table cell but placed on the page, not in the cell (`wp:anchor/@layoutInCell`
|
||||
/// off, any VML shape in a header or footer): a picture watermark in a header that starts with a table.
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
pub outside_cell: bool,
|
||||
/// Stacking order among floating objects (`wp:anchor/@relativeHeight`): higher is drawn on
|
||||
/// top. 0 = unknown, drawn in document order (objects inserted here, or from other formats).
|
||||
#[serde(skip_serializing_if = "is_zero_u32")]
|
||||
pub z: u32,
|
||||
}
|
||||
|
||||
/// Longest alternative text kept (characters): hostile files and commands are capped.
|
||||
pub const MAX_ALT_TEXT: usize = 10_000;
|
||||
|
||||
/// A drawing's alternative text (Picture/Shape Format › Accessibility › Alt Text).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct AltText {
|
||||
/// The description of a shape, text box or group. Pictures and charts keep theirs in their
|
||||
/// own `alt`; read it with [`InlineObject::alt_text`], which knows where it is.
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub text: String,
|
||||
/// The short title (`wp:docPr/@title`).
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub title: String,
|
||||
/// Decorative: it adds nothing to the content, so screen readers skip it and the
|
||||
/// accessibility checker doesn't ask for a description.
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
pub decorative: bool,
|
||||
}
|
||||
|
||||
impl AltText {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.text.is_empty() && self.title.is_empty() && !self.decorative
|
||||
}
|
||||
}
|
||||
|
||||
/// `s` cut to [`MAX_ALT_TEXT`] characters.
|
||||
pub fn cap_alt(s: &str) -> String {
|
||||
s.chars().take(MAX_ALT_TEXT).collect()
|
||||
}
|
||||
|
||||
fn is_zero(v: &f32) -> bool {
|
||||
*v == 0.0
|
||||
}
|
||||
|
||||
fn is_false(v: &bool) -> bool {
|
||||
!*v
|
||||
}
|
||||
|
||||
fn is_zero_u32(v: &u32) -> bool {
|
||||
*v == 0
|
||||
}
|
||||
|
||||
impl Float {
|
||||
/// The rotation and flips, the angle normalised to `0..360` (a NaN angle is no rotation).
|
||||
pub fn spin(&self) -> wordcraft_geom::Spin {
|
||||
wordcraft_geom::Spin::new(self.rot, self.flip_h, self.flip_v)
|
||||
}
|
||||
/// Set the rotation and flips (the angle normalised).
|
||||
pub fn set_spin(&mut self, s: wordcraft_geom::Spin) {
|
||||
self.rot = wordcraft_geom::normalize_degrees(s.deg);
|
||||
self.flip_h = s.flip_h;
|
||||
self.flip_v = s.flip_v;
|
||||
}
|
||||
/// How far a `w` × `h` frame's rotated bounds reach past it on each side (x, y): the room a
|
||||
/// rotated object takes beyond its frame (zero unrotated; negative where the turned object
|
||||
/// is narrower than its frame, as a wide picture turned 90° is).
|
||||
pub fn spin_pad(&self, w: f32, h: f32) -> (f32, f32) {
|
||||
let (w, h) = (wordcraft_geom::finite(w).clamp(0.0, 4000.0), wordcraft_geom::finite(h).clamp(0.0, 4000.0));
|
||||
let (bw, bh) = self.spin().extent(w, h);
|
||||
((bw - w) / 2.0, (bh - h) / 2.0)
|
||||
}
|
||||
/// The effect extents, finite and clamped (left, top, right, bottom).
|
||||
pub fn effect_extent(&self) -> [f32; 4] {
|
||||
self.effect.map(|v| if v.is_finite() { v.clamp(0.0, 1584.0) } else { 0.0 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
use super::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ShapeKind {
|
||||
#[default]
|
||||
Rectangle,
|
||||
RoundedRectangle,
|
||||
Ellipse,
|
||||
/// Flowchart OR preset: an ellipse with horizontal and vertical spokes.
|
||||
FlowchartOr,
|
||||
Triangle,
|
||||
/// A right triangle (DrawingML's `rtTriangle`): the right angle at the bottom left.
|
||||
RightTriangle,
|
||||
Diamond,
|
||||
Line,
|
||||
/// A block arrow pointing right (`rightArrow`; also stands for the double-headed arrows).
|
||||
Arrow,
|
||||
/// A block arrow pointing left (`leftArrow`).
|
||||
ArrowLeft,
|
||||
/// A block arrow pointing up (`upArrow`).
|
||||
ArrowUp,
|
||||
/// A block arrow pointing down (`downArrow`).
|
||||
ArrowDown,
|
||||
Star,
|
||||
Heart,
|
||||
/// A text box (rectangle with a text story).
|
||||
TextBox,
|
||||
/// A shape drawn point by point, such as ink (its geometry is the shape's `freeform`).
|
||||
Freeform,
|
||||
/// Connectors: lines whose ends can be glued to other shapes (see [`crate::connector`]).
|
||||
StraightConnector,
|
||||
ElbowConnector,
|
||||
CurvedConnector,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum NoteKind {
|
||||
#[default]
|
||||
Footnote,
|
||||
Endnote,
|
||||
}
|
||||
|
||||
/// Where a positional tab ([`InlineObject::PositionalTab`]) lands on its line, and how the text
|
||||
/// after it lines up there (ECMA-376 `ST_PTabAlignment`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PTabAlign {
|
||||
#[default]
|
||||
Left,
|
||||
Center,
|
||||
Right,
|
||||
}
|
||||
|
||||
impl PTabAlign {
|
||||
pub const ALL: [PTabAlign; 3] = [PTabAlign::Left, PTabAlign::Center, PTabAlign::Right];
|
||||
|
||||
/// The OOXML name (`left`, `center`, `right`).
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
PTabAlign::Left => "left",
|
||||
PTabAlign::Center => "center",
|
||||
PTabAlign::Right => "right",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_ooxml(v: &str) -> Option<PTabAlign> {
|
||||
PTabAlign::ALL.into_iter().find(|a| a.ooxml() == v)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a positional tab's place is measured against (ECMA-376 `ST_PTabRelativeTo`): the text
|
||||
/// margins, or the paragraph's indents.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PTabBase {
|
||||
#[default]
|
||||
Margin,
|
||||
Indent,
|
||||
}
|
||||
|
||||
impl PTabBase {
|
||||
pub const ALL: [PTabBase; 2] = [PTabBase::Margin, PTabBase::Indent];
|
||||
|
||||
/// The OOXML name (`margin`, `indent`).
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
PTabBase::Margin => "margin",
|
||||
PTabBase::Indent => "indent",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_ooxml(v: &str) -> Option<PTabBase> {
|
||||
PTabBase::ALL.into_iter().find(|a| a.ooxml() == v)
|
||||
}
|
||||
}
|
||||
|
||||
/// How ruby (phonetic guide) text lines up over its base text (ECMA-376 `ST_RubyAlign`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum RubyAlign {
|
||||
#[default]
|
||||
Center,
|
||||
/// Letters spread evenly from edge to edge.
|
||||
DistributeLetter,
|
||||
/// Letters spread evenly with half a gap at each edge.
|
||||
DistributeSpace,
|
||||
Left,
|
||||
Right,
|
||||
/// Right-aligned beside vertical text (laid out like `Right` in horizontal text).
|
||||
RightVertical,
|
||||
}
|
||||
|
||||
impl RubyAlign {
|
||||
pub const ALL: [RubyAlign; 6] =
|
||||
[RubyAlign::Center, RubyAlign::DistributeLetter, RubyAlign::DistributeSpace, RubyAlign::Left, RubyAlign::Right, RubyAlign::RightVertical];
|
||||
|
||||
/// The OOXML name (`center`, `distributeLetter`…), also used by commands.
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
RubyAlign::Center => "center",
|
||||
RubyAlign::DistributeLetter => "distributeLetter",
|
||||
RubyAlign::DistributeSpace => "distributeSpace",
|
||||
RubyAlign::Left => "left",
|
||||
RubyAlign::Right => "right",
|
||||
RubyAlign::RightVertical => "rightVertical",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_ooxml(v: &str) -> Option<RubyAlign> {
|
||||
RubyAlign::ALL.into_iter().find(|a| a.ooxml() == v)
|
||||
}
|
||||
}
|
||||
|
||||
/// Phonetic Guide's Offset for a ruby `raise` (baseline to baseline, points): the gap from the top
|
||||
/// of the base text to the bottom of the ruby text, taking letters as rising 0.8 em above their
|
||||
/// baseline and dropping 0.2 em below it.
|
||||
pub fn ruby_offset(raise: f32, base_size: f32, size: f32) -> f32 {
|
||||
raise - 0.8 * base_size - 0.2 * size
|
||||
}
|
||||
|
||||
/// The ruby raise (baseline to baseline, points) for a Phonetic Guide Offset (see [`ruby_offset`]).
|
||||
pub fn ruby_raise(offset: f32, base_size: f32, size: f32) -> f32 {
|
||||
offset + 0.8 * base_size + 0.2 * size
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
use super::float::{Float, Wrap, cap_alt};
|
||||
use super::inline_kinds::{NoteKind, PTabAlign, PTabBase, RubyAlign, ShapeKind};
|
||||
use super::{CharProps, Deserialize, Serialize};
|
||||
|
||||
/// An inline object anchored at a U+FFFC in the paragraph text.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum InlineObject {
|
||||
Image {
|
||||
/// Key in `Document::media`.
|
||||
media: String,
|
||||
/// Display size, points.
|
||||
w: f32,
|
||||
h: f32,
|
||||
#[serde(default)]
|
||||
alt: String,
|
||||
#[serde(default)]
|
||||
float: Float,
|
||||
/// Crop (left, top, right, bottom) fractions 0..1.
|
||||
#[serde(default)]
|
||||
crop: [f32; 4],
|
||||
/// The OLE object (embedded or linked file) or Word signature line this picture shows, as
|
||||
/// read from a file: saving writes the object back, not only its picture. See
|
||||
/// [`crate::graphic::Embedded`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
ole: Option<std::sync::Arc<crate::graphic::Embedded>>,
|
||||
},
|
||||
/// A chart or SmartArt diagram (see [`crate::graphic`]).
|
||||
Graphic {
|
||||
w: f32,
|
||||
h: f32,
|
||||
#[serde(default)]
|
||||
alt: String,
|
||||
#[serde(default)]
|
||||
float: Float,
|
||||
graphic: std::sync::Arc<crate::graphic::Graphic>,
|
||||
},
|
||||
Shape {
|
||||
kind: ShapeKind,
|
||||
w: f32,
|
||||
h: f32,
|
||||
fill: Option<crate::fill::Fill>,
|
||||
stroke: Option<crate::props::Rgb>,
|
||||
#[serde(default)]
|
||||
stroke_width: f32,
|
||||
#[serde(default)]
|
||||
float: Float,
|
||||
/// Text box content: `Document::parts` id.
|
||||
#[serde(default)]
|
||||
story: Option<u32>,
|
||||
/// A freeform's geometry (`ShapeKind::Freeform`): its paths, or an ink stroke.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
freeform: Option<std::sync::Arc<crate::freeform::Freeform>>,
|
||||
/// Shadow, glow and soft edges.
|
||||
#[serde(default, skip_serializing_if = "crate::effects::ShapeEffects::is_empty")]
|
||||
effects: crate::effects::ShapeEffects,
|
||||
/// Drawing id, text warp, connector ends and arrowheads.
|
||||
#[serde(default, skip_serializing_if = "crate::connector::ShapeExtra::is_empty")]
|
||||
extra: crate::connector::ShapeExtra,
|
||||
},
|
||||
/// Pictures, shapes and text boxes grouped into one object (Layout › Arrange › Group): it
|
||||
/// moves, wraps and resizes as one. Its members are laid out in the group's own coordinate
|
||||
/// space, `ch_w` × `ch_h`, which is stretched over the group's `w` × `h` (DrawingML's
|
||||
/// `a:chExt` and `a:ext`), so resizing the group scales them.
|
||||
Group {
|
||||
w: f32,
|
||||
h: f32,
|
||||
#[serde(default)]
|
||||
float: Float,
|
||||
/// Size of the members' coordinate space (their offsets and sizes are in it).
|
||||
ch_w: f32,
|
||||
ch_h: f32,
|
||||
children: Vec<GroupChild>,
|
||||
/// A Drawing Canvas (Insert › Shapes › New Drawing Canvas, DrawingML's `wpc:wpc`) rather
|
||||
/// than a group: it has its own background and outline, and its frame is independent of
|
||||
/// its members: resizing it doesn't scale them (`ch_w` × `ch_h` follow `w` × `h`), and
|
||||
/// what lies outside the frame is clipped.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
canvas: Option<CanvasStyle>,
|
||||
},
|
||||
/// A field: `instr` is the field code (`PAGE`, `NUMPAGES`, `DATE \@ "M/d/yyyy"`, `TOC \o "1-3"`…);
|
||||
/// `result` the cached display text. A `locked` field keeps its result when fields update.
|
||||
Field {
|
||||
instr: String,
|
||||
#[serde(default)]
|
||||
result: String,
|
||||
#[serde(default)]
|
||||
locked: bool,
|
||||
/// `w:dirty` (ECMA-376 §17.16.5.35): the cached result is stale — another program that
|
||||
/// couldn't compute it set the flag so Word refreshes the field on open. Kept through
|
||||
/// saves; cleared when WordCraft computes a result, like Word's F9.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
dirty: bool,
|
||||
/// Shows its code (`{ PAGE }`) instead of its result, the other way round from the
|
||||
/// document-wide View › Field Codes setting (`fields.toggleCode`, Shift+F9). A view
|
||||
/// toggle: never written to files.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
code: bool,
|
||||
/// A legacy form field's definition (`FORMCHECKBOX`, `FORMTEXT`, `FORMDROPDOWN`): the
|
||||
/// source's `w:ffData`, raw OOXML kept for round trip. Empty for other fields.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
form: String,
|
||||
},
|
||||
/// The start of a field whose result is ordinary content — formatted, possibly spanning
|
||||
/// paragraphs — up to the matching [`InlineObject::FieldEnd`]. Imported TOCs and citation
|
||||
/// managers' `ADDIN` fields are kept this way; `instr` is the field code. See
|
||||
/// [`crate::fields`].
|
||||
FieldStart {
|
||||
instr: String,
|
||||
#[serde(default)]
|
||||
locked: bool,
|
||||
/// `w:dirty` on the begin marker, as [`InlineObject::Field::dirty`].
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
dirty: bool,
|
||||
},
|
||||
/// The end of the innermost open [`InlineObject::FieldStart`].
|
||||
FieldEnd,
|
||||
/// The start of a content control whose content is what follows, up to the matching
|
||||
/// [`InlineObject::ControlEnd`]. See [`crate::control`].
|
||||
ControlStart {
|
||||
control: Box<crate::control::ContentControl>,
|
||||
},
|
||||
/// The end of the innermost open [`InlineObject::ControlStart`].
|
||||
ControlEnd,
|
||||
NoteRef {
|
||||
kind: NoteKind,
|
||||
/// `Document::parts` id of the note's story.
|
||||
id: u32,
|
||||
/// Custom mark (empty = automatic number).
|
||||
#[serde(default)]
|
||||
custom: String,
|
||||
},
|
||||
BookmarkStart {
|
||||
name: String,
|
||||
},
|
||||
BookmarkEnd {
|
||||
name: String,
|
||||
},
|
||||
CommentStart {
|
||||
id: u32,
|
||||
},
|
||||
CommentEnd {
|
||||
id: u32,
|
||||
},
|
||||
/// The start of a range that stays editable while the document is protected (Restrict
|
||||
/// Editing exceptions, `w:permStart`): for everyone (`group` = `everyone`), another editing
|
||||
/// group, or one editor (`editor`, a user name or address).
|
||||
PermStart {
|
||||
id: u32,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
group: String,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
editor: String,
|
||||
},
|
||||
/// The end of the editable range [`InlineObject::PermStart`] with the same `id` opened.
|
||||
PermEnd {
|
||||
id: u32,
|
||||
},
|
||||
/// An equation: its linear format (`x=(-b±√(b^2-4ac))/2a`, for plain text) and structure.
|
||||
/// A display equation sits on a line of its own.
|
||||
Equation {
|
||||
linear: String,
|
||||
#[serde(default)]
|
||||
display: bool,
|
||||
/// The structure; empty = parse `linear`.
|
||||
#[serde(default, skip_serializing_if = "crate::math::Math::is_empty")]
|
||||
math: crate::math::Math,
|
||||
},
|
||||
/// Ruby (Phonetic Guide): small `ruby` text (furigana, pinyin…) set over `base` text. The base
|
||||
/// text takes the formatting of the object's run; it reads as the paragraph's text.
|
||||
Ruby {
|
||||
base: String,
|
||||
ruby: String,
|
||||
#[serde(default)]
|
||||
align: RubyAlign,
|
||||
/// Ruby text size, points (`w:hps`).
|
||||
size: f32,
|
||||
/// From the base text's baseline up to the ruby text's baseline, points (`w:hpsRaise`).
|
||||
raise: f32,
|
||||
/// The base text size the ruby was set for, points (`w:hpsBaseText`).
|
||||
#[serde(default)]
|
||||
base_size: f32,
|
||||
/// Language of the ruby (`w:lid`, e.g. `ja-JP`); empty = unspecified.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
lang: String,
|
||||
/// Formatting of the ruby text over the run's (font, colour…); its size is `size`.
|
||||
#[serde(default, skip_serializing_if = "CharProps::is_empty")]
|
||||
props: Box<CharProps>,
|
||||
},
|
||||
/// A positional tab (Word's alignment tab, `w:ptab`): it goes to the left edge, centre or
|
||||
/// right edge of the margins or of the indents, whatever the paragraph's tab stops, and the
|
||||
/// text after it lines up there. Text already past that place sends it to the next line.
|
||||
PositionalTab {
|
||||
#[serde(default)]
|
||||
align: PTabAlign,
|
||||
#[serde(default)]
|
||||
relative_to: PTabBase,
|
||||
#[serde(default)]
|
||||
leader: crate::props::TabLeader,
|
||||
},
|
||||
/// Something we don't model, kept for round-trip (raw XML of the source format).
|
||||
Opaque {
|
||||
format: String,
|
||||
xml: String,
|
||||
#[serde(default)]
|
||||
text: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// One member of an [`InlineObject::Group`]: a picture or shape (its own `w`, `h` and story), at
|
||||
/// `x`, `y` in the group's coordinate space. Its `float` is unused.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GroupChild {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub obj: InlineObject,
|
||||
}
|
||||
|
||||
/// Most members a group keeps (hostile files).
|
||||
pub const MAX_GROUP_CHILDREN: usize = 1000;
|
||||
|
||||
/// The background fill and outline of a Drawing Canvas (an [`InlineObject::Group`] with `canvas`).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct CanvasStyle {
|
||||
pub fill: Option<crate::fill::Fill>,
|
||||
pub stroke: Option<crate::props::Rgb>,
|
||||
/// Outline width, points.
|
||||
pub stroke_width: f32,
|
||||
}
|
||||
|
||||
impl InlineObject {
|
||||
/// A picture, chart or diagram, shape, text box or group: something drawn with a frame, sized and wrapped.
|
||||
pub fn is_drawing(&self) -> bool {
|
||||
matches!(self, InlineObject::Image { .. } | InlineObject::Graphic { .. } | InlineObject::Shape { .. } | InlineObject::Group { .. })
|
||||
}
|
||||
/// A drawing's size and placement.
|
||||
pub fn frame(&self) -> Option<(f32, f32, &Float)> {
|
||||
match self {
|
||||
InlineObject::Image { w, h, float, .. }
|
||||
| InlineObject::Graphic { w, h, float, .. }
|
||||
| InlineObject::Shape { w, h, float, .. }
|
||||
| InlineObject::Group { w, h, float, .. } => Some((*w, *h, float)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// A drawing's placement, to change.
|
||||
pub fn float_mut(&mut self) -> Option<&mut Float> {
|
||||
match self {
|
||||
InlineObject::Image { float, .. }
|
||||
| InlineObject::Graphic { float, .. }
|
||||
| InlineObject::Shape { float, .. }
|
||||
| InlineObject::Group { float, .. } => Some(float),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Resize a drawing (a group's members scale with it; a canvas's keep their size and place).
|
||||
pub fn set_size(&mut self, nw: f32, nh: f32) {
|
||||
if let InlineObject::Image { w, h, .. }
|
||||
| InlineObject::Graphic { w, h, .. }
|
||||
| InlineObject::Shape { w, h, .. }
|
||||
| InlineObject::Group { w, h, .. } = self
|
||||
{
|
||||
*w = nw;
|
||||
*h = nh;
|
||||
}
|
||||
if let InlineObject::Group { ch_w, ch_h, canvas: Some(_), .. } = self {
|
||||
*ch_w = nw;
|
||||
*ch_h = nh;
|
||||
}
|
||||
}
|
||||
/// A Drawing Canvas's background and outline (`None` for anything else).
|
||||
pub fn canvas_style(&self) -> Option<&CanvasStyle> {
|
||||
match self {
|
||||
InlineObject::Group { canvas, .. } => canvas.as_ref(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Whether this is a Drawing Canvas.
|
||||
pub fn is_canvas(&self) -> bool {
|
||||
self.canvas_style().is_some()
|
||||
}
|
||||
/// The text box story this object shows, if it is a text box.
|
||||
pub fn text_box(&self) -> Option<u32> {
|
||||
match self {
|
||||
InlineObject::Shape { story, .. } => *story,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The text box stories this object shows: a text box's, or its group members'.
|
||||
pub fn text_boxes(&self) -> Vec<u32> {
|
||||
match self {
|
||||
InlineObject::Shape { story: Some(id), .. } => vec![*id],
|
||||
InlineObject::Group { children, .. } => children.iter().filter_map(|c| c.obj.text_box()).collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
/// The text box story slots of this object and its group members, to repoint.
|
||||
pub fn text_box_slots(&mut self) -> Vec<&mut Option<u32>> {
|
||||
match self {
|
||||
InlineObject::Shape { story, .. } => vec![story],
|
||||
InlineObject::Group { children, .. } => children
|
||||
.iter_mut()
|
||||
.filter_map(|c| match &mut c.obj {
|
||||
InlineObject::Shape { story, .. } => Some(story),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
/// A group's members placed in a `w` × `h` box at (`x`, `y`): each member's rectangle
|
||||
/// (x, y, w, h) and the member. Empty for anything else.
|
||||
pub fn group_rects(&self, x: f32, y: f32, w: f32, h: f32) -> Vec<([f32; 4], &InlineObject)> {
|
||||
let InlineObject::Group { ch_w, ch_h, children, .. } = self else { return Vec::new() };
|
||||
let fin = |v: f32| if v.is_finite() { v } else { 0.0 };
|
||||
let sx = if fin(*ch_w) > 0.0 { fin(w) / ch_w } else { 1.0 };
|
||||
let sy = if fin(*ch_h) > 0.0 { fin(h) / ch_h } else { 1.0 };
|
||||
children
|
||||
.iter()
|
||||
.take(MAX_GROUP_CHILDREN)
|
||||
.filter_map(|c| {
|
||||
let (cw, chh, _) = c.obj.frame()?;
|
||||
let r = [x + fin(c.x) * sx, y + fin(c.y) * sy, (fin(cw) * sx).clamp(0.0, 4000.0), (fin(chh) * sy).clamp(0.0, 4000.0)];
|
||||
(!matches!(c.obj, InlineObject::Group { .. })).then_some((r, &c.obj))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// A legacy form field's definition (`FORMCHECKBOX`, `FORMTEXT`, `FORMDROPDOWN`), read from
|
||||
/// the `w:ffData` it keeps; `None` for anything else.
|
||||
pub fn legacy_form(&self) -> Option<crate::legacy_form::LegacyForm> {
|
||||
match self {
|
||||
InlineObject::Field { form, .. } if !form.is_empty() => crate::legacy_form::LegacyForm::parse(form),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Zero-width markers don't take part in layout.
|
||||
pub fn is_marker(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
InlineObject::BookmarkStart { .. }
|
||||
| InlineObject::BookmarkEnd { .. }
|
||||
| InlineObject::CommentStart { .. }
|
||||
| InlineObject::CommentEnd { .. }
|
||||
| InlineObject::PermStart { .. }
|
||||
| InlineObject::PermEnd { .. }
|
||||
| InlineObject::FieldStart { .. }
|
||||
| InlineObject::FieldEnd
|
||||
| InlineObject::ControlStart { .. }
|
||||
| InlineObject::ControlEnd
|
||||
)
|
||||
}
|
||||
/// A drawing's description (alternative text), wherever it is kept.
|
||||
pub fn alt_text(&self) -> &str {
|
||||
match self {
|
||||
InlineObject::Image { alt, .. } | InlineObject::Graphic { alt, .. } => alt,
|
||||
InlineObject::Shape { float, .. } | InlineObject::Group { float, .. } => &float.alt.text,
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
/// Set a drawing's description (capped at [`MAX_ALT_TEXT`] characters); `false` if this is no drawing.
|
||||
pub fn set_alt_text(&mut self, text: &str) -> bool {
|
||||
let text = cap_alt(text);
|
||||
match self {
|
||||
InlineObject::Image { alt, .. } | InlineObject::Graphic { alt, .. } => *alt = text,
|
||||
InlineObject::Shape { float, .. } | InlineObject::Group { float, .. } => float.alt.text = text,
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
/// A drawing marked decorative.
|
||||
pub fn is_decorative(&self) -> bool {
|
||||
self.frame().is_some_and(|(_, _, f)| f.alt.decorative)
|
||||
}
|
||||
pub fn is_floating(&self) -> bool {
|
||||
match self {
|
||||
InlineObject::Image { float, .. }
|
||||
| InlineObject::Graphic { float, .. }
|
||||
| InlineObject::Shape { float, .. }
|
||||
| InlineObject::Group { float, .. } => float.wrap != Wrap::Inline,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
/// The geometry of an ink stroke (a freeform shape drawn with a pen), if this is one.
|
||||
pub fn ink(&self) -> Option<&crate::freeform::Freeform> {
|
||||
match self {
|
||||
InlineObject::Shape { freeform: Some(f), .. } if f.is_ink() => Some(f),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The line a footnote or endnote separator draws (ECMA-376 §17.11: the `w:separator` and
|
||||
/// `w:continuationSeparator` run elements): a short line, or with `continuation` one across
|
||||
/// the column. Kept as the run element itself.
|
||||
pub fn note_separator(continuation: bool) -> InlineObject {
|
||||
let el = if continuation { "w:continuationSeparator" } else { "w:separator" };
|
||||
InlineObject::Opaque { format: "docx".into(), xml: format!("<w:r><{el}/></w:r>"), text: String::new() }
|
||||
}
|
||||
/// For a [`InlineObject::note_separator`]: whether it is the continuation separator.
|
||||
pub fn as_note_separator(&self) -> Option<bool> {
|
||||
match self {
|
||||
InlineObject::Opaque { format, xml, .. } if format == "docx" => match xml.as_str() {
|
||||
"<w:r><w:separator/></w:r>" => Some(false),
|
||||
"<w:r><w:continuationSeparator/></w:r>" => Some(true),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The text this object contributes to plain-text extraction.
|
||||
pub fn plain_text(&self) -> &str {
|
||||
match self {
|
||||
InlineObject::Field { result, .. } => result,
|
||||
InlineObject::Equation { linear, .. } => linear,
|
||||
InlineObject::Ruby { base, .. } => base,
|
||||
InlineObject::PositionalTab { .. } => "\t",
|
||||
InlineObject::Opaque { text, .. } => text,
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
use super::{AtomicU64, CharProps, Deserialize, Ordering, Serialize};
|
||||
|
||||
/// Object replacement character: marks an inline object in paragraph text.
|
||||
pub const OBJ: char = '\u{FFFC}';
|
||||
|
||||
/// Manual line break (Shift+Enter).
|
||||
pub const LINE_BREAK: char = '\n';
|
||||
|
||||
/// Page break (Ctrl+Enter).
|
||||
pub const PAGE_BREAK: char = '\u{000C}';
|
||||
|
||||
/// Column break (Ctrl+Shift+Enter).
|
||||
pub const COLUMN_BREAK: char = '\u{000E}';
|
||||
|
||||
/// Optional hyphen (Ctrl+-).
|
||||
pub const SOFT_HYPHEN: char = '\u{00AD}';
|
||||
|
||||
/// Nonbreaking hyphen (Ctrl+Shift+-).
|
||||
pub const NB_HYPHEN: char = '\u{2011}';
|
||||
|
||||
/// Nonbreaking space (Ctrl+Shift+Space).
|
||||
pub const NBSP: char = '\u{00A0}';
|
||||
|
||||
static REV: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
/// A fresh revision number (process-unique), used by layout caches to detect edits.
|
||||
pub fn next_rev() -> u64 {
|
||||
REV.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// A formatting run: `len` bytes of text sharing `props`.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Run {
|
||||
pub len: usize,
|
||||
#[serde(default, skip_serializing_if = "CharProps::is_empty")]
|
||||
pub props: CharProps,
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
use super::block::Blocks;
|
||||
use super::position::Pos;
|
||||
use super::{Deserialize, Serialize, props};
|
||||
|
||||
/// A free id, `min` or more, for something new (a part, a list, a comment): one past `largest`, the
|
||||
/// largest id in use, so ids keep growing; once that would pass `u32::MAX` (a file can use any id),
|
||||
/// the lowest one `used` (every id in use) doesn't hold. `None` when every id from `min` up is taken.
|
||||
pub fn free_id(largest: Option<u32>, used: impl Iterator<Item = u32>, min: u32) -> Option<u32> {
|
||||
let Some(largest) = largest else { return Some(min) };
|
||||
if let Some(next) = largest.checked_add(1) {
|
||||
return Some(next.max(min));
|
||||
}
|
||||
let mut ids: Vec<u32> = used.filter(|&i| i >= min).collect();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
let mut want = min;
|
||||
for i in ids {
|
||||
if i > want {
|
||||
break;
|
||||
}
|
||||
want = want.checked_add(1)?;
|
||||
}
|
||||
Some(want)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PartKind {
|
||||
#[default]
|
||||
Header,
|
||||
Footer,
|
||||
Footnote,
|
||||
Endnote,
|
||||
Comment,
|
||||
TextBox,
|
||||
}
|
||||
|
||||
/// A document's own separators and continuation notice for its footnotes or its endnotes
|
||||
/// (ECMA-376 §17.11: the `w:footnote`/`w:endnote` entries of type `separator`,
|
||||
/// `continuationSeparator` and `continuationNotice`). `None` = the default: a short line above
|
||||
/// the notes, a line across the column above notes continued from the page before, no notice.
|
||||
/// The lines themselves are [`InlineObject::note_separator`] objects in the paragraphs.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct NoteSeparators {
|
||||
/// Above the notes on a page.
|
||||
pub separator: Option<Blocks>,
|
||||
/// Above the rest of a note continued from the page before.
|
||||
pub continuation_separator: Option<Blocks>,
|
||||
/// Below the part of a note that continues on the next page.
|
||||
pub continuation_notice: Option<Blocks>,
|
||||
}
|
||||
|
||||
impl NoteSeparators {
|
||||
/// No separator of the document's own: the defaults apply.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.separator.is_none() && self.continuation_separator.is_none() && self.continuation_notice.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// A secondary story.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
pub struct Part {
|
||||
pub kind: PartKind,
|
||||
pub blocks: Blocks,
|
||||
/// A text box's text direction, alignment and link (unused by other parts).
|
||||
#[serde(default, skip_serializing_if = "TextBody::is_default")]
|
||||
pub body: TextBody,
|
||||
}
|
||||
|
||||
/// How a text box shows its text (Shape Format › Text): which way it runs, where it sits between
|
||||
/// the box's top and bottom, and the box its overflow continues in.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct TextBody {
|
||||
pub vert: props::TextVert,
|
||||
pub anchor: props::VAlign,
|
||||
/// Create Link: the text box (another `TextBox` part, shown by its own shape) the text
|
||||
/// continues in when it doesn't fit this one. The linked box shows the rest of this box's
|
||||
/// story; its own blocks are unused. See [`Document::text_box_chains`].
|
||||
pub next: Option<u32>,
|
||||
/// Internal margins (left, top, right, bottom), EMU, when the file gives its own
|
||||
/// (`wps:bodyPr` `lIns`… ECMA-376 Part 1 §21.1.2.1.1, or VML `v:textbox` `inset`);
|
||||
/// `None` is Word's defaults.
|
||||
pub inset: Option<[u32; 4]>,
|
||||
/// Resize shape to fit text (`a:spAutoFit`, VML `mso-fit-shape-to-text`): Word grows the
|
||||
/// box to its text, so none of the text is cut.
|
||||
pub auto_fit: bool,
|
||||
/// The colour the shape's style gives its text (`wps:style/a:fontRef`): what automatic text
|
||||
/// colour means inside this box, instead of black. Text whose colour comes from its runs,
|
||||
/// styles or the document defaults keeps it (Word, observed with synthetic documents).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub auto_color: Option<props::Rgb>,
|
||||
}
|
||||
|
||||
/// Word's default text box margins (left, top, right, bottom), EMU: 0.1" and 0.05".
|
||||
pub const TEXT_BOX_INSET: [u32; 4] = [91_440, 45_720, 91_440, 45_720];
|
||||
|
||||
/// Widest text box margin kept, EMU (22", the largest page side).
|
||||
pub const MAX_TEXT_BOX_INSET: u32 = 20_116_800;
|
||||
|
||||
impl TextBody {
|
||||
pub fn is_default(&self) -> bool {
|
||||
*self == TextBody::default()
|
||||
}
|
||||
|
||||
/// The internal margins (left, top, right, bottom) in points.
|
||||
pub fn insets_pt(&self) -> [f32; 4] {
|
||||
self.inset.unwrap_or(TEXT_BOX_INSET).map(|v| v.min(MAX_TEXT_BOX_INSET) as f32 / 12_700.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Most boxes one chain of linked text boxes runs through (hostile files).
|
||||
pub const MAX_LINKED_BOXES: usize = 256;
|
||||
|
||||
/// A range that stays editable while the document is protected; see [`Document::perm_ranges`].
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PermRange {
|
||||
pub id: u32,
|
||||
/// Editing group (`everyone`, `editors`…); empty when the range names an editor.
|
||||
pub group: String,
|
||||
/// The one editor the range is for; empty for a group.
|
||||
pub editor: String,
|
||||
pub start: Pos,
|
||||
pub end: Pos,
|
||||
}
|
||||
|
||||
impl PermRange {
|
||||
/// Can everyone edit it (rather than one person or group)?
|
||||
pub fn everyone(&self) -> bool {
|
||||
self.group.eq_ignore_ascii_case("everyone")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
use super::{Deserialize, Serialize};
|
||||
|
||||
/// Which story a position is in.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum StoryRef {
|
||||
#[default]
|
||||
Body,
|
||||
Part(u32),
|
||||
}
|
||||
|
||||
/// Path to a paragraph: `[block]` in a story, `[block, row, cell, block]` inside a table cell,
|
||||
/// three more indexes per nesting level. Lexicographic order = document order.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default)]
|
||||
pub struct Path(pub Vec<u32>);
|
||||
|
||||
impl Path {
|
||||
pub fn top(i: usize) -> Path {
|
||||
Path(vec![i as u32])
|
||||
}
|
||||
/// The block index within its container.
|
||||
pub fn last(&self) -> usize {
|
||||
self.0.last().copied().unwrap_or(0) as usize
|
||||
}
|
||||
pub fn with_last(&self, i: usize) -> Path {
|
||||
let mut p = self.clone();
|
||||
if let Some(l) = p.0.last_mut() {
|
||||
*l = i as u32;
|
||||
}
|
||||
p
|
||||
}
|
||||
/// The container prefix (everything but the last index).
|
||||
pub fn parent(&self) -> &[u32] {
|
||||
self.0.split_last().map(|(_, r)| r).unwrap_or(&[])
|
||||
}
|
||||
/// Nesting depth (0 = top-level story block).
|
||||
pub fn depth(&self) -> usize {
|
||||
self.0.len().saturating_sub(1) / 3
|
||||
}
|
||||
/// The enclosing table cell (table block path, row, cell) if any.
|
||||
pub fn cell(&self) -> Option<(Path, usize, usize)> {
|
||||
if self.0.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
let n = self.0.len();
|
||||
let t = Path(self.0.get(..n - 3)?.to_vec());
|
||||
Some((t, *self.0.get(n - 3)? as usize, *self.0.get(n - 2)? as usize))
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Path {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let s: Vec<String> = self.0.iter().map(ToString::to_string).collect();
|
||||
write!(f, "{}", s.join("/"))
|
||||
}
|
||||
}
|
||||
|
||||
/// A position in a document.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default)]
|
||||
pub struct Pos {
|
||||
pub story: StoryRef,
|
||||
pub path: Path,
|
||||
pub off: usize,
|
||||
}
|
||||
|
||||
impl Pos {
|
||||
pub fn new(story: StoryRef, path: Path, off: usize) -> Pos {
|
||||
Pos { story, path, off }
|
||||
}
|
||||
pub fn body(block: usize, off: usize) -> Pos {
|
||||
Pos { story: StoryRef::Body, path: Path::top(block), off }
|
||||
}
|
||||
}
|
||||
+12
-1364
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,545 @@
|
||||
use super::color::{Rgb, TextColor};
|
||||
use super::para_props::Border;
|
||||
use super::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Underline {
|
||||
#[default]
|
||||
None,
|
||||
Single,
|
||||
Words,
|
||||
Double,
|
||||
Thick,
|
||||
Dotted,
|
||||
Dash,
|
||||
DotDash,
|
||||
DotDotDash,
|
||||
Wave,
|
||||
DoubleWave,
|
||||
}
|
||||
|
||||
impl Underline {
|
||||
pub const ALL: [Underline; 11] = [
|
||||
Underline::None,
|
||||
Underline::Single,
|
||||
Underline::Words,
|
||||
Underline::Double,
|
||||
Underline::Thick,
|
||||
Underline::Dotted,
|
||||
Underline::Dash,
|
||||
Underline::DotDash,
|
||||
Underline::DotDotDash,
|
||||
Underline::Wave,
|
||||
Underline::DoubleWave,
|
||||
];
|
||||
/// The OOXML `w:u/@w:val` name.
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
Underline::None => "none",
|
||||
Underline::Single => "single",
|
||||
Underline::Words => "words",
|
||||
Underline::Double => "double",
|
||||
Underline::Thick => "thick",
|
||||
Underline::Dotted => "dotted",
|
||||
Underline::Dash => "dash",
|
||||
Underline::DotDash => "dotDash",
|
||||
Underline::DotDotDash => "dotDotDash",
|
||||
Underline::Wave => "wave",
|
||||
Underline::DoubleWave => "wavyDouble",
|
||||
}
|
||||
}
|
||||
pub fn from_ooxml(s: &str) -> Underline {
|
||||
Underline::ALL.iter().copied().find(|u| u.ooxml() == s).unwrap_or(match s {
|
||||
"thickDash" | "dashLong" | "dashedHeavy" | "dashLongHeavy" => Underline::Dash,
|
||||
"dottedHeavy" => Underline::Dotted,
|
||||
"wavyHeavy" => Underline::Wave,
|
||||
"dashDotHeavy" => Underline::DotDash,
|
||||
"dashDotDotHeavy" => Underline::DotDotDash,
|
||||
_ => Underline::Single,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The OpenType ligatures a run asks for ([MS-DOCX] `w14:ligatures`): standard (`liga`),
|
||||
/// contextual (`clig`), historical (`hlig`) and discretional (`dlig`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Ligatures {
|
||||
pub standard: bool,
|
||||
pub contextual: bool,
|
||||
pub historical: bool,
|
||||
pub discretional: bool,
|
||||
}
|
||||
|
||||
impl Ligatures {
|
||||
/// Every combination of the four flags.
|
||||
pub fn all() -> impl Iterator<Item = Ligatures> {
|
||||
(0u8..16).map(|b| Ligatures { standard: b & 1 != 0, contextual: b & 2 != 0, historical: b & 4 != 0, discretional: b & 8 != 0 })
|
||||
}
|
||||
/// The `w14:ligatures/@w14:val` name: `none`, `all`, or the set flags joined in camelCase
|
||||
/// (`standardContextual`).
|
||||
pub fn ooxml(self) -> String {
|
||||
let on: Vec<&str> =
|
||||
[(self.standard, "standard"), (self.contextual, "contextual"), (self.historical, "historical"), (self.discretional, "discretional")]
|
||||
.into_iter()
|
||||
.filter_map(|(on, n)| on.then_some(n))
|
||||
.collect();
|
||||
match on.len() {
|
||||
0 => "none".into(),
|
||||
4 => "all".into(),
|
||||
_ => on
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(i, n)| n.chars().enumerate().map(move |(j, c)| if i > 0 && j == 0 { c.to_ascii_uppercase() } else { c }))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
/// `None` for a name [MS-DOCX] doesn't define.
|
||||
pub fn from_ooxml(s: &str) -> Option<Ligatures> {
|
||||
Ligatures::all().find(|l| l.ooxml() == s)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum VertAlign {
|
||||
#[default]
|
||||
Baseline,
|
||||
Superscript,
|
||||
Subscript,
|
||||
}
|
||||
|
||||
/// Word's 16 highlighter colours (plus none).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Highlight {
|
||||
#[default]
|
||||
None,
|
||||
Yellow,
|
||||
BrightGreen,
|
||||
Turquoise,
|
||||
Pink,
|
||||
Blue,
|
||||
Red,
|
||||
DarkBlue,
|
||||
Teal,
|
||||
Green,
|
||||
Violet,
|
||||
DarkRed,
|
||||
DarkYellow,
|
||||
Gray50,
|
||||
Gray25,
|
||||
Black,
|
||||
White,
|
||||
}
|
||||
|
||||
impl Highlight {
|
||||
pub const ALL: [Highlight; 17] = [
|
||||
Highlight::None,
|
||||
Highlight::Yellow,
|
||||
Highlight::BrightGreen,
|
||||
Highlight::Turquoise,
|
||||
Highlight::Pink,
|
||||
Highlight::Blue,
|
||||
Highlight::Red,
|
||||
Highlight::DarkBlue,
|
||||
Highlight::Teal,
|
||||
Highlight::Green,
|
||||
Highlight::Violet,
|
||||
Highlight::DarkRed,
|
||||
Highlight::DarkYellow,
|
||||
Highlight::Gray50,
|
||||
Highlight::Gray25,
|
||||
Highlight::Black,
|
||||
Highlight::White,
|
||||
];
|
||||
pub fn rgb(self) -> Option<Rgb> {
|
||||
Some(match self {
|
||||
Highlight::None => return None,
|
||||
Highlight::Yellow => Rgb(255, 255, 0),
|
||||
Highlight::BrightGreen => Rgb(0, 255, 0),
|
||||
Highlight::Turquoise => Rgb(0, 255, 255),
|
||||
Highlight::Pink => Rgb(255, 0, 255),
|
||||
Highlight::Blue => Rgb(0, 0, 255),
|
||||
Highlight::Red => Rgb(255, 0, 0),
|
||||
Highlight::DarkBlue => Rgb(0, 0, 128),
|
||||
Highlight::Teal => Rgb(0, 128, 128),
|
||||
Highlight::Green => Rgb(0, 128, 0),
|
||||
Highlight::Violet => Rgb(128, 0, 128),
|
||||
Highlight::DarkRed => Rgb(128, 0, 0),
|
||||
Highlight::DarkYellow => Rgb(128, 128, 0),
|
||||
Highlight::Gray50 => Rgb(128, 128, 128),
|
||||
Highlight::Gray25 => Rgb(192, 192, 192),
|
||||
Highlight::Black => Rgb(0, 0, 0),
|
||||
Highlight::White => Rgb(255, 255, 255),
|
||||
})
|
||||
}
|
||||
/// OOXML `w:highlight/@w:val`.
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
Highlight::None => "none",
|
||||
Highlight::Yellow => "yellow",
|
||||
Highlight::BrightGreen => "green",
|
||||
Highlight::Turquoise => "cyan",
|
||||
Highlight::Pink => "magenta",
|
||||
Highlight::Blue => "blue",
|
||||
Highlight::Red => "red",
|
||||
Highlight::DarkBlue => "darkBlue",
|
||||
Highlight::Teal => "darkCyan",
|
||||
Highlight::Green => "darkGreen",
|
||||
Highlight::Violet => "darkMagenta",
|
||||
Highlight::DarkRed => "darkRed",
|
||||
Highlight::DarkYellow => "darkYellow",
|
||||
Highlight::Gray50 => "darkGray",
|
||||
Highlight::Gray25 => "lightGray",
|
||||
Highlight::Black => "black",
|
||||
Highlight::White => "white",
|
||||
}
|
||||
}
|
||||
pub fn from_ooxml(s: &str) -> Highlight {
|
||||
Highlight::ALL.iter().copied().find(|h| h.ooxml() == s).unwrap_or(Highlight::None)
|
||||
}
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Highlight::None => "No Color",
|
||||
Highlight::Yellow => "Yellow",
|
||||
Highlight::BrightGreen => "Bright Green",
|
||||
Highlight::Turquoise => "Turquoise",
|
||||
Highlight::Pink => "Pink",
|
||||
Highlight::Blue => "Blue",
|
||||
Highlight::Red => "Red",
|
||||
Highlight::DarkBlue => "Dark Blue",
|
||||
Highlight::Teal => "Teal",
|
||||
Highlight::Green => "Green",
|
||||
Highlight::Violet => "Violet",
|
||||
Highlight::DarkRed => "Dark Red",
|
||||
Highlight::DarkYellow => "Dark Yellow",
|
||||
Highlight::Gray50 => "Gray-50%",
|
||||
Highlight::Gray25 => "Gray-25%",
|
||||
Highlight::Black => "Black",
|
||||
Highlight::White => "White",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The text size, in points, of a run whose size no style or default sets.
|
||||
pub const DEFAULT_FONT_SIZE: f32 = 11.0;
|
||||
|
||||
/// Character formatting (direct or in a style). `None` = inherit.
|
||||
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct CharProps {
|
||||
/// Character style id.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub style: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub font: Option<String>,
|
||||
/// The symbol's own font (OOXML `w:sym/@w:font`, ECMA-376 §17.3.3.30), set on a symbol
|
||||
/// character from a `w:sym` or Insert › Symbol: it is shown in this font whatever `font` is,
|
||||
/// so changing the text's font keeps the symbol. `None` = the run's font.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sym_font: Option<String>,
|
||||
/// Points.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub size: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bold: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub italic: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub underline: Option<Underline>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub underline_color: Option<Rgb>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub strike: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub double_strike: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub color: Option<TextColor>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub highlight: Option<Highlight>,
|
||||
/// Character shading (background fill).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub shading: Option<Rgb>,
|
||||
/// Character border (`w:bdr`); `style: None` = explicitly no border.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub border: Option<Border>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub vert_align: Option<VertAlign>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub caps: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub small_caps: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hidden: Option<bool>,
|
||||
/// Hidden in Web Layout (`w:webHidden`); distinct from `w:vanish` (`hidden`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub web_hidden: Option<bool>,
|
||||
/// Snap characters to the document grid (`w:snapToGrid`). Unset inherits; Word's default is on.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub snap_to_grid: Option<bool>,
|
||||
/// Character spacing (expanded/condensed), points.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub spacing: Option<f32>,
|
||||
/// Horizontal scale, percent (100 = normal).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub scale: Option<f32>,
|
||||
/// Raised (+) / lowered (−) position, points.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub position: Option<f32>,
|
||||
/// Kerning for fonts at or above this size (points); 0 = off.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub kern: Option<f32>,
|
||||
/// OpenType ligatures (`w14:ligatures`). `None` leaves WordCraft's default: standard and
|
||||
/// contextual ligatures.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ligatures: Option<Ligatures>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub outline: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub shadow: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub emboss: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub engrave: Option<bool>,
|
||||
/// WordArt text effects: fill, outline, shadow, glow, reflection ([MS-DOCX] `w14:` run
|
||||
/// properties). Overlaid effect by effect.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub text_effects: Option<Box<crate::wordart::TextEffects>>,
|
||||
/// BCP 47 language tag (proofing).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lang: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub no_proof: Option<bool>,
|
||||
/// Right-to-left run (OOXML `w:rtl`): its characters are complex script and read right to left.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rtl: Option<bool>,
|
||||
/// Format the whole run with the complex-script properties below (OOXML `w:cs`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cs: Option<bool>,
|
||||
/// Complex-script font (Arabic, Persian, Hebrew…; OOXML `w:rFonts/@w:cs`). `None` = `font`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub font_cs: Option<String>,
|
||||
/// East Asian font (Chinese, Japanese and Korean text; OOXML `w:rFonts/@w:eastAsia`). `None` =
|
||||
/// inherit, then `font`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub font_ea: Option<String>,
|
||||
/// Complex-script size, points (`w:szCs`). `None` leaves the inherited value unchanged.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub size_cs: Option<f32>,
|
||||
/// Complex-script bold / italic (`w:bCs`, `w:iCs`). `None` leaves the inherited value unchanged.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bold_cs: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub italic_cs: Option<bool>,
|
||||
/// BCP 47 language of complex-script text (`w:lang/@w:bidi`, e.g. `fa-IR`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lang_bidi: Option<String>,
|
||||
/// BCP 47 language of East Asian text (`w:lang/@w:eastAsia`, e.g. `zh-CN`, `ja-JP`, `ko-KR`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lang_ea: Option<String>,
|
||||
/// East Asian typography: two lines in one and horizontal in vertical (`w:eastAsianLayout`).
|
||||
/// Kept for round trip; layout doesn't apply it yet.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub east_asian_layout: Option<EastAsianLayout>,
|
||||
/// Hyperlink target: a URL, or `#bookmark` for an internal link.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub link: Option<String>,
|
||||
/// Tracked insertion: index into `Document::revisions`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ins: Option<u32>,
|
||||
/// Tracked deletion: index into `Document::revisions`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub del: Option<u32>,
|
||||
/// Tracked formatting change (`w:rPrChange`): the formatting before it.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fmt_change: Option<Box<PropChange<CharProps>>>,
|
||||
}
|
||||
|
||||
/// East Asian typography settings of a run (ISO/IEC 29500-1 §17.3.2.10, `w:eastAsianLayout`):
|
||||
/// *two lines in one* sets the run's text on two sub-lines inside one line (warichu), *horizontal
|
||||
/// in vertical* turns it 90° to read across a vertical line (tate-chu-yoko). Each attribute is
|
||||
/// `None` when the file doesn't say.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct EastAsianLayout {
|
||||
/// East Asian typography run id (`w:id`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<i32>,
|
||||
/// Two lines in one (`w:combine`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub combine: Option<bool>,
|
||||
/// Brackets around two lines in one (`w:combineBrackets`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub combine_brackets: Option<CombineBrackets>,
|
||||
/// Horizontal in vertical (`w:vert`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub vert: Option<bool>,
|
||||
/// Compress horizontal-in-vertical text to the line height (`w:vertCompress`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub vert_compress: Option<bool>,
|
||||
}
|
||||
|
||||
/// Brackets around two lines in one (`ST_CombineBrackets`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum CombineBrackets {
|
||||
None,
|
||||
Round,
|
||||
Square,
|
||||
Angle,
|
||||
Curly,
|
||||
}
|
||||
|
||||
impl CombineBrackets {
|
||||
pub const ALL: [CombineBrackets; 5] =
|
||||
[CombineBrackets::None, CombineBrackets::Round, CombineBrackets::Square, CombineBrackets::Angle, CombineBrackets::Curly];
|
||||
/// OOXML `w:combineBrackets` value.
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
CombineBrackets::None => "none",
|
||||
CombineBrackets::Round => "round",
|
||||
CombineBrackets::Square => "square",
|
||||
CombineBrackets::Angle => "angle",
|
||||
CombineBrackets::Curly => "curly",
|
||||
}
|
||||
}
|
||||
/// The value for `s`, `None` for one the schema doesn't have.
|
||||
pub fn from_ooxml(s: &str) -> Option<CombineBrackets> {
|
||||
CombineBrackets::ALL.into_iter().find(|b| b.ooxml() == s)
|
||||
}
|
||||
}
|
||||
|
||||
/// A tracked formatting change (ECMA-376 §17.13.5: `w:rPrChange`, `w:pPrChange`,
|
||||
/// `w:tblPrChange`, `w:trPrChange`, `w:tcPrChange`, `w:sectPrChange`): the properties as they
|
||||
/// were before the change, and the revision (author, date) that made it.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PropChange<T> {
|
||||
/// Index into `Document::revisions`.
|
||||
pub rev: u32,
|
||||
/// The properties before the change (formatting only: no revision or link marks).
|
||||
pub old: T,
|
||||
}
|
||||
|
||||
impl<T> PropChange<T> {
|
||||
pub fn boxed(rev: u32, old: T) -> Option<Box<PropChange<T>>> {
|
||||
Some(Box::new(PropChange { rev, old }))
|
||||
}
|
||||
}
|
||||
|
||||
/// A tracked change of a paragraph's list numbering (`w:numberingChange`, kept for round trip):
|
||||
/// `original` is the number as it was shown before the change.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct NumChange {
|
||||
/// Index into `Document::revisions`.
|
||||
pub rev: u32,
|
||||
pub original: String,
|
||||
}
|
||||
|
||||
impl CharProps {
|
||||
/// The same formatting outside a hyperlink: no link target, and no "Hyperlink" character
|
||||
/// style that came with it. What a paragraph mark or text typed next to a link gets.
|
||||
pub fn unlinked(&self) -> CharProps {
|
||||
let mut c = self.clone();
|
||||
if c.link.take().is_some() && c.style.as_deref() == Some("Hyperlink") {
|
||||
c.style = None;
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
/// The fields of `self` that differ from `base` (equal ones unset): what `self` adds when
|
||||
/// overlaid on `base`.
|
||||
pub fn minus(mut self, base: &CharProps) -> CharProps {
|
||||
self.minus_text_style(base);
|
||||
self.minus_run_visibility(base);
|
||||
self.minus_script_style(base);
|
||||
self.minus_document_marks(base);
|
||||
self
|
||||
}
|
||||
|
||||
fn minus_text_style(&mut self, base: &CharProps) {
|
||||
drop_same_fields!(self, base; style, font, sym_font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight,
|
||||
shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl);
|
||||
}
|
||||
|
||||
fn minus_run_visibility(&mut self, base: &CharProps) {
|
||||
drop_same_fields!(self, base; web_hidden, snap_to_grid);
|
||||
}
|
||||
|
||||
fn minus_script_style(&mut self, base: &CharProps) {
|
||||
drop_same_fields!(self, base; cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, lang_ea, east_asian_layout);
|
||||
}
|
||||
|
||||
fn minus_document_marks(&mut self, base: &CharProps) {
|
||||
drop_same_fields!(self, base; link, ins, del, ligatures);
|
||||
}
|
||||
|
||||
/// Apply every `Some` field of `patch` on top of `self`.
|
||||
///
|
||||
/// Plain and complex-script properties inherit independently. Commands that intend to
|
||||
/// format both scripts must set both values explicitly.
|
||||
pub fn overlay(&mut self, patch: &CharProps) {
|
||||
self.overlay_text_style(patch);
|
||||
self.overlay_run_visibility(patch);
|
||||
self.overlay_script_style(patch);
|
||||
self.overlay_document_marks(patch);
|
||||
if let Some(p) = &patch.text_effects {
|
||||
self.text_effects.get_or_insert_with(Default::default).overlay(p);
|
||||
}
|
||||
}
|
||||
|
||||
fn overlay_text_style(&mut self, patch: &CharProps) {
|
||||
overlay_fields!(self, patch; style, font, sym_font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight,
|
||||
shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl);
|
||||
}
|
||||
|
||||
fn overlay_run_visibility(&mut self, patch: &CharProps) {
|
||||
overlay_fields!(self, patch; web_hidden, snap_to_grid);
|
||||
}
|
||||
|
||||
fn overlay_script_style(&mut self, patch: &CharProps) {
|
||||
overlay_fields!(self, patch; cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, lang_ea, east_asian_layout);
|
||||
}
|
||||
|
||||
fn overlay_document_marks(&mut self, patch: &CharProps) {
|
||||
overlay_fields!(self, patch; link, ins, del, fmt_change, ligatures);
|
||||
}
|
||||
pub fn overlaid(mut self, patch: &CharProps) -> CharProps {
|
||||
self.overlay(patch);
|
||||
self
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
*self == CharProps::default()
|
||||
}
|
||||
/// The font a character of this run is shown in: the symbol's own font when it has one
|
||||
/// ([`Self::sym_font`]), else the run's font.
|
||||
pub fn shown_font(&self) -> Option<&str> {
|
||||
self.sym_font.as_deref().filter(|f| !f.trim().is_empty()).or(self.font.as_deref())
|
||||
}
|
||||
/// The same formatting for text typed or painted next to a symbol: without the symbol's own
|
||||
/// font.
|
||||
pub fn without_symbol(mut self) -> CharProps {
|
||||
self.sym_font = None;
|
||||
self
|
||||
}
|
||||
/// Clear direct formatting but keep what isn't "formatting" (links, revisions, character style
|
||||
/// is cleared too, as Word's Clear Formatting does).
|
||||
pub fn cleared(&self) -> CharProps {
|
||||
CharProps {
|
||||
link: self.link.clone(),
|
||||
ins: self.ins,
|
||||
del: self.del,
|
||||
fmt_change: self.fmt_change.clone(),
|
||||
// A symbol stays the symbol it is.
|
||||
sym_font: self.sym_font.clone(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
/// Just the formatting: without the link, revision marks and tracked formatting change.
|
||||
pub fn formatting(&self) -> CharProps {
|
||||
CharProps { link: None, ins: None, del: None, fmt_change: None, ..self.clone() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
use super::{Deserialize, Serialize};
|
||||
|
||||
/// An sRGB colour.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Default)]
|
||||
pub struct Rgb(pub u8, pub u8, pub u8);
|
||||
|
||||
impl<'de> Deserialize<'de> for Rgb {
|
||||
/// Reads what it writes (`[r, g, b]`) and also a string [`Rgb::parse`] reads, so the colours in
|
||||
/// `CharProps` / `ParaProps` take the `RRGGBB` that `para.borders` and `table.shading` take (#865).
|
||||
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
use serde::de::{Error, SeqAccess, Unexpected, Visitor};
|
||||
struct V;
|
||||
impl<'de> Visitor<'de> for V {
|
||||
type Value = Rgb;
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
f.write_str("`[r, g, b]`, `RRGGBB` or `#RRGGBB`")
|
||||
}
|
||||
fn visit_str<E: Error>(self, s: &str) -> Result<Rgb, E> {
|
||||
Rgb::parse(s).ok_or_else(|| E::invalid_value(Unexpected::Str(s), &self))
|
||||
}
|
||||
fn visit_seq<A: SeqAccess<'de>>(self, mut a: A) -> Result<Rgb, A::Error> {
|
||||
let mut c = [0u8; 3];
|
||||
for (i, x) in c.iter_mut().enumerate() {
|
||||
*x = a.next_element()?.ok_or_else(|| A::Error::invalid_length(i, &self))?;
|
||||
}
|
||||
if a.next_element::<serde::de::IgnoredAny>()?.is_some() {
|
||||
return Err(A::Error::invalid_length(4, &self));
|
||||
}
|
||||
Ok(Rgb(c[0], c[1], c[2]))
|
||||
}
|
||||
}
|
||||
d.deserialize_any(V)
|
||||
}
|
||||
}
|
||||
|
||||
impl Rgb {
|
||||
pub const BLACK: Rgb = Rgb(0, 0, 0);
|
||||
pub const WHITE: Rgb = Rgb(255, 255, 255);
|
||||
/// `RRGGBB` (no `#`).
|
||||
pub fn hex(self) -> String {
|
||||
format!("{:02X}{:02X}{:02X}", self.0, self.1, self.2)
|
||||
}
|
||||
/// Parse `RRGGBB` or `#RRGGBB`.
|
||||
pub fn parse(s: &str) -> Option<Rgb> {
|
||||
let s = s.trim();
|
||||
let s = s.strip_prefix('#').unwrap_or(s);
|
||||
if s.len() != 6 || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
let p = |i: usize| s.get(i..i + 2).and_then(|h| u8::from_str_radix(h, 16).ok());
|
||||
Some(Rgb(p(0)?, p(2)?, p(4)?))
|
||||
}
|
||||
/// This colour with the DrawingML colour modifier `name` (`lumMod`, `lumOff`, `tint`,
|
||||
/// `shade`, `satMod`, ECMA-376 §20.1.2.3) at `v` (1.0 = 100%); other names leave it
|
||||
/// unchanged.
|
||||
pub fn modified(self, name: &str, v: f64) -> Rgb {
|
||||
match name {
|
||||
"lumMod" => adjust_lum(self, |l| l * v),
|
||||
"lumOff" => adjust_lum(self, |l| l + v),
|
||||
"tint" => mix(self, 255.0, v),
|
||||
"shade" => mix(self, 0.0, v),
|
||||
"satMod" => {
|
||||
let (h, sat, l) = to_hsl(self);
|
||||
from_hsl(h, (sat * v).clamp(0.0, 1.0), l)
|
||||
}
|
||||
_ => self,
|
||||
}
|
||||
}
|
||||
/// The colour with hue `h` (0..1 of a turn), saturation `s` and lightness `l` (0..1).
|
||||
pub fn from_hsl(h: f64, s: f64, l: f64) -> Rgb {
|
||||
from_hsl(h, s, l)
|
||||
}
|
||||
/// This colour with its HSL luminance changed by `f`.
|
||||
pub fn with_lum(self, f: impl Fn(f64) -> f64) -> Rgb {
|
||||
adjust_lum(self, f)
|
||||
}
|
||||
pub fn luma(self) -> f32 {
|
||||
0.299 * self.0 as f32 + 0.587 * self.1 as f32 + 0.114 * self.2 as f32
|
||||
}
|
||||
}
|
||||
|
||||
/// A text colour: automatic (black on light backgrounds, white on dark) or a fixed colour.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Default)]
|
||||
pub enum TextColor {
|
||||
#[default]
|
||||
Auto,
|
||||
Rgb(Rgb),
|
||||
}
|
||||
|
||||
impl TextColor {
|
||||
/// `auto` or `automatic` (any case), or a colour [`Rgb::parse`] reads.
|
||||
pub fn parse(s: &str) -> Option<TextColor> {
|
||||
if s.eq_ignore_ascii_case("auto") || s.eq_ignore_ascii_case("automatic") { Some(TextColor::Auto) } else { Rgb::parse(s).map(TextColor::Rgb) }
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for TextColor {
|
||||
/// Reads what it writes (`"Auto"`, `{"Rgb": [r, g, b]}`) and also a string [`TextColor::parse`]
|
||||
/// reads, so `styles.modify` and `format.set` take the colours `format.color` takes.
|
||||
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
use serde::de::{Error, Unexpected};
|
||||
#[derive(Deserialize)]
|
||||
enum Written {
|
||||
Auto,
|
||||
Rgb(Rgb),
|
||||
}
|
||||
match serde_json::Value::deserialize(d)? {
|
||||
serde_json::Value::String(s) => {
|
||||
TextColor::parse(&s).ok_or_else(|| D::Error::invalid_value(Unexpected::Str(&s), &"`auto`, `RRGGBB` or `#RRGGBB`"))
|
||||
}
|
||||
v => Ok(match serde_json::from_value(v).map_err(D::Error::custom)? {
|
||||
Written::Auto => TextColor::Auto,
|
||||
Written::Rgb(c) => TextColor::Rgb(c),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `tint` (towards `to` = 255) and `shade` (towards 0): `v` is the share of the original colour kept.
|
||||
fn mix(c: Rgb, to: f64, v: f64) -> Rgb {
|
||||
let f = |x: u8| (x as f64 * v + to * (1.0 - v)).round().clamp(0.0, 255.0) as u8;
|
||||
Rgb(f(c.0), f(c.1), f(c.2))
|
||||
}
|
||||
|
||||
/// `c` with its HSL luminance changed by `f`.
|
||||
fn adjust_lum(c: Rgb, f: impl Fn(f64) -> f64) -> Rgb {
|
||||
let (h, s, l) = to_hsl(c);
|
||||
from_hsl(h, s, f(l).clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
fn to_hsl(c: Rgb) -> (f64, f64, f64) {
|
||||
let (r, g, b) = (c.0 as f64 / 255.0, c.1 as f64 / 255.0, c.2 as f64 / 255.0);
|
||||
let (max, min) = (r.max(g).max(b), r.min(g).min(b));
|
||||
let l = (max + min) / 2.0;
|
||||
if max == min {
|
||||
return (0.0, 0.0, l);
|
||||
}
|
||||
let d = max - min;
|
||||
let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) };
|
||||
let h = if max == r {
|
||||
(g - b) / d + if g < b { 6.0 } else { 0.0 }
|
||||
} else if max == g {
|
||||
(b - r) / d + 2.0
|
||||
} else {
|
||||
(r - g) / d + 4.0
|
||||
};
|
||||
(h / 6.0, s, l)
|
||||
}
|
||||
|
||||
fn from_hsl(h: f64, s: f64, l: f64) -> Rgb {
|
||||
let byte = |v: f64| (v * 255.0).round().clamp(0.0, 255.0) as u8;
|
||||
if s == 0.0 {
|
||||
return Rgb(byte(l), byte(l), byte(l));
|
||||
}
|
||||
let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s };
|
||||
let p = 2.0 * l - q;
|
||||
let channel = |t: f64| {
|
||||
let t = t.rem_euclid(1.0);
|
||||
let v = if t < 1.0 / 6.0 {
|
||||
p + (q - p) * 6.0 * t
|
||||
} else if t < 0.5 {
|
||||
q
|
||||
} else if t < 2.0 / 3.0 {
|
||||
p + (q - p) * (2.0 / 3.0 - t) * 6.0
|
||||
} else {
|
||||
p
|
||||
};
|
||||
byte(v)
|
||||
};
|
||||
Rgb(channel(h + 1.0 / 3.0), channel(h), channel(h - 1.0 / 3.0))
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
use super::char_props::{NumChange, PropChange};
|
||||
use super::color::Rgb;
|
||||
use super::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Align {
|
||||
#[default]
|
||||
Left,
|
||||
Center,
|
||||
Right,
|
||||
Justify,
|
||||
Distribute,
|
||||
}
|
||||
|
||||
impl Align {
|
||||
/// Alignment is logical (`Left` is the start edge, ISO 29500 `start`): the alignment as seen
|
||||
/// on the page, or the logical one for a visual choice, in a paragraph that reads right to
|
||||
/// left when `rtl` (Left and Right swap; the swap is its own inverse).
|
||||
pub fn visual(self, rtl: bool) -> Align {
|
||||
match self {
|
||||
Align::Left if rtl => Align::Right,
|
||||
Align::Right if rtl => Align::Left,
|
||||
a => a,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Line spacing rule.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "rule", content = "value")]
|
||||
pub enum LineSpacing {
|
||||
/// Multiple of single spacing (1.0 = single, 1.15, 1.5, 2.0 = double).
|
||||
Multiple(f32),
|
||||
/// At least this many points.
|
||||
AtLeast(f32),
|
||||
/// Exactly this many points.
|
||||
Exactly(f32),
|
||||
}
|
||||
|
||||
impl Default for LineSpacing {
|
||||
fn default() -> Self {
|
||||
LineSpacing::Multiple(1.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum TabAlign {
|
||||
#[default]
|
||||
Left,
|
||||
Center,
|
||||
Right,
|
||||
Decimal,
|
||||
Bar,
|
||||
/// Clears an inherited stop at this position.
|
||||
Clear,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum TabLeader {
|
||||
#[default]
|
||||
None,
|
||||
Dot,
|
||||
Hyphen,
|
||||
Underscore,
|
||||
MiddleDot,
|
||||
}
|
||||
|
||||
impl TabLeader {
|
||||
pub fn char(self) -> Option<char> {
|
||||
match self {
|
||||
TabLeader::None => None,
|
||||
TabLeader::Dot => Some('.'),
|
||||
TabLeader::Hyphen => Some('-'),
|
||||
TabLeader::Underscore => Some('_'),
|
||||
TabLeader::MiddleDot => Some('·'),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Two tab positions closer than this (points) are the same stop: `para.tabs` replaces at this
|
||||
/// tolerance, and the Tabs dialog and the ruler merge and detect no-ops with it.
|
||||
pub const TAB_POS_EPS: f32 = 0.5;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct TabStop {
|
||||
/// Points from the paragraph's left indent origin (the text column's left edge).
|
||||
pub pos: f32,
|
||||
#[serde(default)]
|
||||
pub align: TabAlign,
|
||||
#[serde(default)]
|
||||
pub leader: TabLeader,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum BorderStyle {
|
||||
#[default]
|
||||
None,
|
||||
Single,
|
||||
Double,
|
||||
Dotted,
|
||||
Dashed,
|
||||
Thick,
|
||||
Triple,
|
||||
DotDash,
|
||||
Wave,
|
||||
}
|
||||
|
||||
impl BorderStyle {
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
BorderStyle::None => "nil",
|
||||
BorderStyle::Single => "single",
|
||||
BorderStyle::Double => "double",
|
||||
BorderStyle::Dotted => "dotted",
|
||||
BorderStyle::Dashed => "dashed",
|
||||
BorderStyle::Thick => "thick",
|
||||
BorderStyle::Triple => "triple",
|
||||
BorderStyle::DotDash => "dotDash",
|
||||
BorderStyle::Wave => "wave",
|
||||
}
|
||||
}
|
||||
pub fn from_ooxml(s: &str) -> BorderStyle {
|
||||
match s {
|
||||
"nil" | "none" => BorderStyle::None,
|
||||
"double" => BorderStyle::Double,
|
||||
"dotted" => BorderStyle::Dotted,
|
||||
"dashed" | "dashSmallGap" => BorderStyle::Dashed,
|
||||
"thick" => BorderStyle::Thick,
|
||||
"triple" => BorderStyle::Triple,
|
||||
"dotDash" | "dotDotDash" => BorderStyle::DotDash,
|
||||
"wave" | "doubleWave" => BorderStyle::Wave,
|
||||
_ => BorderStyle::Single,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One border line.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct Border {
|
||||
pub style: BorderStyle,
|
||||
/// Line width, points.
|
||||
pub width: f32,
|
||||
pub color: Option<Rgb>,
|
||||
/// Distance from the text, points.
|
||||
pub space: f32,
|
||||
}
|
||||
|
||||
impl Border {
|
||||
pub fn single(width: f32) -> Border {
|
||||
Border { style: BorderStyle::Single, width, color: None, space: 0.0 }
|
||||
}
|
||||
pub fn is_visible(&self) -> bool {
|
||||
self.style != BorderStyle::None && self.width > 0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Borders on the four sides (plus inner borders for tables and runs of bordered paragraphs).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct Borders {
|
||||
pub top: Option<Border>,
|
||||
pub left: Option<Border>,
|
||||
pub bottom: Option<Border>,
|
||||
pub right: Option<Border>,
|
||||
/// Between paragraphs / inside horizontal (tables).
|
||||
pub between: Option<Border>,
|
||||
/// Inside vertical (tables).
|
||||
pub inside_v: Option<Border>,
|
||||
}
|
||||
|
||||
impl Borders {
|
||||
pub fn all(b: Border) -> Borders {
|
||||
Borders { top: Some(b), left: Some(b), bottom: Some(b), right: Some(b), between: Some(b), inside_v: Some(b) }
|
||||
}
|
||||
pub fn box_(b: Border) -> Borders {
|
||||
Borders { top: Some(b), left: Some(b), bottom: Some(b), right: Some(b), between: None, inside_v: None }
|
||||
}
|
||||
pub fn any_visible(&self) -> bool {
|
||||
[self.top, self.left, self.bottom, self.right, self.between, self.inside_v].iter().flatten().any(Border::is_visible)
|
||||
}
|
||||
/// Apply every `Some` side of `patch` on top of `self`.
|
||||
pub fn overlay(&mut self, patch: &Borders) {
|
||||
if patch.top.is_some() {
|
||||
self.top = patch.top;
|
||||
}
|
||||
if patch.left.is_some() {
|
||||
self.left = patch.left;
|
||||
}
|
||||
if patch.bottom.is_some() {
|
||||
self.bottom = patch.bottom;
|
||||
}
|
||||
if patch.right.is_some() {
|
||||
self.right = patch.right;
|
||||
}
|
||||
if patch.between.is_some() {
|
||||
self.between = patch.between;
|
||||
}
|
||||
if patch.inside_v.is_some() {
|
||||
self.inside_v = patch.inside_v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A reference to a numbering definition and level.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct NumRef {
|
||||
/// `Numbering::nums` id; 0 removes numbering inherited from a style.
|
||||
pub num: u32,
|
||||
pub level: u8,
|
||||
}
|
||||
|
||||
/// A text frame's placement (`w:framePr`, ECMA-376 §17.3.1.11), in points. Offsets apply when
|
||||
/// the matching alignment is unset; anchors and the wrap keep OOXML's names.
|
||||
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct TextFrame {
|
||||
/// Width; `None` fits the text.
|
||||
pub w: Option<f32>,
|
||||
/// Height, with `h_exact` exact rather than at least; `None` fits the text.
|
||||
pub h: Option<f32>,
|
||||
pub h_exact: bool,
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
/// `left`, `center`, `right`, `inside`, `outside`.
|
||||
pub x_align: Option<String>,
|
||||
/// `top`, `center`, `bottom`, `inside`, `outside` (`inline` is no alignment).
|
||||
pub y_align: Option<String>,
|
||||
/// `text` (the column), `margin` or `page`.
|
||||
pub h_anchor: String,
|
||||
/// `text` (the paragraph), `margin` or `page`.
|
||||
pub v_anchor: String,
|
||||
/// `around`, `auto`, `notBeside`, `none`, `tight` or `through`.
|
||||
pub wrap: String,
|
||||
/// Distance from the text left and right, and above and below.
|
||||
pub h_space: f32,
|
||||
pub v_space: f32,
|
||||
}
|
||||
|
||||
/// Paragraph formatting. `None` = inherit.
|
||||
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ParaProps {
|
||||
/// Paragraph style id.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub style: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub align: Option<Align>,
|
||||
/// Left indent, points.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub indent_left: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub indent_right: Option<f32>,
|
||||
/// First-line indent, points; negative = hanging.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub indent_first: Option<f32>,
|
||||
/// Left (start) indent in characters (`w:leftChars` / `w:startChars`, stored by Word in
|
||||
/// hundredths). A non-zero value wins over `indent_left`; one character is the size of the
|
||||
/// paragraph mark's font. `Some(0.0)` cancels an inherited value. Common in East Asian documents.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub indent_left_chars: Option<f32>,
|
||||
/// Right (end) indent in characters (`w:rightChars` / `w:endChars`), like `indent_left_chars`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub indent_right_chars: Option<f32>,
|
||||
/// First-line indent in characters (`w:firstLineChars`; negative = `w:hangingChars`), like
|
||||
/// `indent_left_chars`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub indent_first_chars: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub space_before: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub space_after: Option<f32>,
|
||||
/// HTML-style automatic space before (`w:beforeAutospacing`): 14 pt, none at the top of the
|
||||
/// document or of a table cell, and collapsing with the space after the paragraph above.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub space_before_auto: Option<bool>,
|
||||
/// HTML-style automatic space after (`w:afterAutospacing`): 14 pt, none at the end of a cell.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub space_after_auto: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub line_spacing: Option<LineSpacing>,
|
||||
/// Ignore space before/after between paragraphs of the same style.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub contextual_spacing: Option<bool>,
|
||||
/// `w:mirrorIndents` (ECMA-376 §17.3.1.18): the left and right indents are the inside and
|
||||
/// outside ones, so they swap sides on even (left-hand) pages.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mirror_indents: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub keep_next: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub keep_lines: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub page_break_before: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub widow_control: Option<bool>,
|
||||
/// 0..=8 = Level 1..9; 9 = body text.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub outline_level: Option<u8>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub numbering: Option<NumRef>,
|
||||
/// Tab stops (replaces the inherited list; `TabAlign::Clear` stops remove inherited ones).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tabs: Option<Vec<TabStop>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub shading: Option<Rgb>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub borders: Option<Borders>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub suppress_hyphens: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub suppress_line_numbers: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bidi: Option<bool>,
|
||||
/// Drop cap: lines to drop (0 = none) — applies to the first character(s).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub drop_cap: Option<u8>,
|
||||
/// Drop cap "In margin" (`w:dropCap="margin"`): the letter hangs in the left margin and the
|
||||
/// text keeps its indent. Unset = dropped into the text.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub drop_cap_margin: Option<bool>,
|
||||
/// The drop cap's distance from the text, in points (`w:hSpace`). Unset = 0.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub drop_cap_distance: Option<f32>,
|
||||
/// The drop cap letter's font family (the cap run's `w:rFonts`). Unset = the text's font.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub drop_cap_font: Option<String>,
|
||||
/// A text frame (`w:framePr` other than a drop cap): the paragraph is placed apart from the
|
||||
/// text, which may wrap around it — a footer's page number in a frame, for instance.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub frame: Option<TextFrame>,
|
||||
/// Asian typography (`w:kinsoku`): East Asian line-breaking rules — no line starts with
|
||||
/// closing punctuation such as 、。」 or ends with opening punctuation such as 「(.
|
||||
/// Unset = on.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub kinsoku: Option<bool>,
|
||||
/// `w:wordWrap`: Latin words wrap whole; `false` lets them break at any character (Word's
|
||||
/// "Allow Latin text to wrap in the middle of a word"). Unset = on.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub word_wrap: Option<bool>,
|
||||
/// `w:overflowPunct`: punctuation may hang past the line end. Unset = on.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub overflow_punct: Option<bool>,
|
||||
/// `w:topLinePunct`: compress punctuation at the start of a line. Unset = off.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub top_line_punct: Option<bool>,
|
||||
/// `w:autoSpaceDE`: automatic space between Asian and Latin text. Unset = on.
|
||||
#[serde(rename = "autoSpaceDE", skip_serializing_if = "Option::is_none")]
|
||||
pub auto_space_de: Option<bool>,
|
||||
/// `w:autoSpaceDN`: automatic space between Asian text and numbers. Unset = on.
|
||||
#[serde(rename = "autoSpaceDN", skip_serializing_if = "Option::is_none")]
|
||||
pub auto_space_dn: Option<bool>,
|
||||
/// Tracked change of the list numbering (`w:numberingChange`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub num_change: Option<Box<NumChange>>,
|
||||
/// Tracked formatting change (`w:pPrChange`): the paragraph properties before it.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fmt_change: Option<Box<PropChange<ParaProps>>>,
|
||||
/// `w:snapToGrid`: lines snap to the section's document grid. Unset = on.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub snap_to_grid: Option<bool>,
|
||||
}
|
||||
|
||||
impl ParaProps {
|
||||
pub fn overlay(&mut self, patch: &ParaProps) {
|
||||
self.overlay_layout(patch);
|
||||
self.overlay_pagination(patch);
|
||||
self.overlay_script_layout(patch);
|
||||
}
|
||||
|
||||
fn overlay_layout(&mut self, patch: &ParaProps) {
|
||||
overlay_fields!(self, patch; style, align, indent_left, indent_right, indent_first, indent_left_chars, indent_right_chars, indent_first_chars,
|
||||
space_before, space_after, space_before_auto, space_after_auto, line_spacing, contextual_spacing, mirror_indents, tabs, shading, borders);
|
||||
}
|
||||
|
||||
fn overlay_pagination(&mut self, patch: &ParaProps) {
|
||||
overlay_fields!(self, patch; keep_next, keep_lines, page_break_before, widow_control, outline_level, numbering, suppress_hyphens, suppress_line_numbers,
|
||||
drop_cap, drop_cap_margin, drop_cap_distance, drop_cap_font, frame, snap_to_grid);
|
||||
}
|
||||
|
||||
fn overlay_script_layout(&mut self, patch: &ParaProps) {
|
||||
overlay_fields!(self, patch; bidi, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn);
|
||||
}
|
||||
pub fn overlaid(mut self, patch: &ParaProps) -> ParaProps {
|
||||
self.overlay(patch);
|
||||
self
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
*self == ParaProps::default()
|
||||
}
|
||||
/// After setting indents in points: keep the indents in characters (left, right, first line)
|
||||
/// an inherited style may set from winning over them, as Word does (`w:firstLineChars="0"`).
|
||||
pub fn cancel_char_indents(&mut self, left: bool, right: bool, first: bool) {
|
||||
for (on, chars) in [(left, &mut self.indent_left_chars), (right, &mut self.indent_right_chars), (first, &mut self.indent_first_chars)] {
|
||||
if on {
|
||||
*chars = Some(0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Just the formatting: without the tracked changes.
|
||||
pub fn formatting(&self) -> ParaProps {
|
||||
ParaProps { num_change: None, fmt_change: None, ..self.clone() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
use super::char_props::PropChange;
|
||||
use super::color::Rgb;
|
||||
use super::para_props::{Align, Borders};
|
||||
use super::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum VAlign {
|
||||
#[default]
|
||||
Top,
|
||||
Center,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
/// Which way a table cell's text runs (`w:textDirection`, ECMA-376 §17.4.72).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum TextDirection {
|
||||
/// Left to right, lines stacking downwards.
|
||||
#[default]
|
||||
Horizontal,
|
||||
/// Turned 90° clockwise: lines read top to bottom and stack from right to left (`tbRl`).
|
||||
Down,
|
||||
/// Turned 90° counter-clockwise: lines read bottom to top and stack from left to right (`btLr`).
|
||||
Up,
|
||||
}
|
||||
|
||||
impl TextDirection {
|
||||
/// The direction an OOXML `ST_TextDirection` value names (transitional and strict names).
|
||||
/// The East Asian vertical layouts turn like `Down`; unknown values are horizontal.
|
||||
pub fn from_ooxml(v: &str) -> TextDirection {
|
||||
match v {
|
||||
"tbRl" | "tbRlV" | "tbLrV" | "rl" | "rlV" | "lrV" => TextDirection::Down,
|
||||
"btLr" | "lr" => TextDirection::Up,
|
||||
_ => TextDirection::Horizontal,
|
||||
}
|
||||
}
|
||||
/// The transitional OOXML name.
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
TextDirection::Horizontal => "lrTb",
|
||||
TextDirection::Down => "tbRl",
|
||||
TextDirection::Up => "btLr",
|
||||
}
|
||||
}
|
||||
/// Word's Text Direction button: horizontal → down → up → horizontal.
|
||||
pub fn next(self) -> TextDirection {
|
||||
match self {
|
||||
TextDirection::Horizontal => TextDirection::Down,
|
||||
TextDirection::Down => TextDirection::Up,
|
||||
TextDirection::Up => TextDirection::Horizontal,
|
||||
}
|
||||
}
|
||||
pub fn is_turned(self) -> bool {
|
||||
self != TextDirection::Horizontal
|
||||
}
|
||||
}
|
||||
|
||||
/// Which way a text box's text runs (Shape Format › Text › Text Direction; DrawingML
|
||||
/// `a:bodyPr/@vert`, ECMA-376 §21.1.10.83 `ST_TextVerticalType`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum TextVert {
|
||||
/// Upright, lines stacking downwards (`horz`).
|
||||
#[default]
|
||||
Horz,
|
||||
/// Turned 90° clockwise (`vert`).
|
||||
Vert,
|
||||
/// Turned 270° (`vert270`).
|
||||
Vert270,
|
||||
/// Upright letters stacked one above the other (`wordArtVert`).
|
||||
Stacked,
|
||||
}
|
||||
|
||||
impl TextVert {
|
||||
/// The direction an `ST_TextVerticalType` value names; the East Asian and Mongolian vertical
|
||||
/// layouts turn like `vert`, unknown values are horizontal.
|
||||
pub fn from_ooxml(v: &str) -> TextVert {
|
||||
match v {
|
||||
"vert" | "eaVert" | "mongolianVert" => TextVert::Vert,
|
||||
"vert270" => TextVert::Vert270,
|
||||
"wordArtVert" | "wordArtVertRtl" => TextVert::Stacked,
|
||||
_ => TextVert::Horz,
|
||||
}
|
||||
}
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
TextVert::Horz => "horz",
|
||||
TextVert::Vert => "vert",
|
||||
TextVert::Vert270 => "vert270",
|
||||
TextVert::Stacked => "wordArtVert",
|
||||
}
|
||||
}
|
||||
/// How its lines turn on the page (stacked text stays upright).
|
||||
pub fn turn(self) -> TextDirection {
|
||||
match self {
|
||||
TextVert::Vert => TextDirection::Down,
|
||||
TextVert::Vert270 => TextDirection::Up,
|
||||
TextVert::Horz | TextVert::Stacked => TextDirection::Horizontal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a text box's text sits between its top and bottom (Align Text; `a:bodyPr/@anchor`,
|
||||
/// `ST_TextAnchoringType`): reuses [`VAlign`]. Justified and distributed anchoring read as top
|
||||
/// and middle.
|
||||
pub fn valign_from_anchor(v: &str) -> VAlign {
|
||||
match v {
|
||||
"ctr" | "dist" => VAlign::Center,
|
||||
"b" => VAlign::Bottom,
|
||||
_ => VAlign::Top,
|
||||
}
|
||||
}
|
||||
|
||||
/// The `ST_TextAnchoringType` value for `a`.
|
||||
pub fn anchor_ooxml(a: VAlign) -> &'static str {
|
||||
match a {
|
||||
VAlign::Top => "t",
|
||||
VAlign::Center => "ctr",
|
||||
VAlign::Bottom => "b",
|
||||
}
|
||||
}
|
||||
|
||||
/// Table-wide properties.
|
||||
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct TableProps {
|
||||
pub style: Option<String>,
|
||||
/// Preferred width: points (`Some`) or automatic (`None`).
|
||||
pub width: Option<f32>,
|
||||
/// Preferred width as a percentage of the text column (overrides `width`).
|
||||
pub width_pct: Option<f32>,
|
||||
pub align: Option<Align>,
|
||||
/// Indent from the left margin, points.
|
||||
pub indent: Option<f32>,
|
||||
pub borders: Option<Borders>,
|
||||
/// Default cell margins (top, left, bottom, right), points.
|
||||
pub cell_margins: Option<[f32; 4]>,
|
||||
/// Fixed column widths (no auto-fit).
|
||||
pub fixed: bool,
|
||||
/// Table style options ("look"): header row, total row, banded rows, first column, last
|
||||
/// column, banded columns.
|
||||
pub look: TableLook,
|
||||
pub shading: Option<Rgb>,
|
||||
/// Alternative text: the title (`w:tblCaption`).
|
||||
pub caption: Option<String>,
|
||||
/// Alternative text: the description (`w:tblDescription`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
/// Floating placement (`w:tblpPr`); `None` for a table in the text flow.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub float: Option<TableFloat>,
|
||||
/// Tracked formatting change (`w:tblPrChange`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fmt_change: Option<Box<PropChange<TableProps>>>,
|
||||
/// Right-to-left table (`w:bidiVisual`): the first logical column stands at the right edge,
|
||||
/// cell margins and left/right borders mirror, and the alignment and indent are measured from
|
||||
/// the right margin.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub bidi_visual: bool,
|
||||
}
|
||||
|
||||
/// Where a floating table sits; the text after it wraps around it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Default, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct TableFloat {
|
||||
/// What `x` is measured from: the column (`Column`), the margin or the page.
|
||||
pub h_rel: crate::para::Anchor,
|
||||
/// What `y` is measured from: the text where the table stands (`Paragraph`), the margin or
|
||||
/// the page.
|
||||
pub v_rel: crate::para::Anchor,
|
||||
/// Offsets, points (used when the matching alignment is `None`).
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub h_align: Option<crate::para::FloatAlign>,
|
||||
pub v_align: Option<crate::para::FloatAlign>,
|
||||
/// Distance from surrounding text: left, top, right, bottom (points).
|
||||
pub dist: [f32; 4],
|
||||
/// Whether it may overlap other floating tables (`w:tblOverlap`); Word's default is yes.
|
||||
pub overlap: bool,
|
||||
}
|
||||
|
||||
impl TableFloat {
|
||||
/// The distances from text, finite and clamped (left, top, right, bottom).
|
||||
pub fn dist_from_text(&self) -> [f32; 4] {
|
||||
self.dist.map(|v| if v.is_finite() { v.clamp(0.0, 1584.0) } else { 0.0 })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct TableLook {
|
||||
pub header_row: bool,
|
||||
pub total_row: bool,
|
||||
pub banded_rows: bool,
|
||||
pub first_column: bool,
|
||||
pub last_column: bool,
|
||||
pub banded_columns: bool,
|
||||
}
|
||||
|
||||
impl Default for TableLook {
|
||||
fn default() -> Self {
|
||||
TableLook { header_row: true, total_row: false, banded_rows: true, first_column: true, last_column: false, banded_columns: false }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum HeightRule {
|
||||
#[default]
|
||||
Auto,
|
||||
AtLeast,
|
||||
Exact,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct RowProps {
|
||||
/// Points (meaning depends on `height_rule`).
|
||||
pub height: Option<f32>,
|
||||
pub height_rule: HeightRule,
|
||||
/// Repeat as a header row at the top of each page.
|
||||
pub header: bool,
|
||||
/// Don't let the row break across pages.
|
||||
pub cant_split: bool,
|
||||
/// Table properties this row overrides (`w:tblPrEx`, ECMA-376 §17.4.61): width, alignment,
|
||||
/// indent, borders, shading, layout and cell margins. `Some` when the file has the element.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub exceptions: Option<Box<TableProps>>,
|
||||
/// Grid columns skipped before the row's first cell (`w:gridBefore`), and the preferred
|
||||
/// width of that gap in points (`w:wBefore`).
|
||||
#[serde(skip_serializing_if = "is_zero")]
|
||||
pub grid_before: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub w_before: Option<f32>,
|
||||
/// Grid columns left after the row's last cell (`w:gridAfter`), and their width in points
|
||||
/// (`w:wAfter`).
|
||||
#[serde(skip_serializing_if = "is_zero")]
|
||||
pub grid_after: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub w_after: Option<f32>,
|
||||
/// Tracked formatting change (`w:trPrChange`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fmt_change: Option<Box<PropChange<RowProps>>>,
|
||||
}
|
||||
|
||||
fn is_zero(v: &u32) -> bool {
|
||||
*v == 0
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum VMerge {
|
||||
#[default]
|
||||
None,
|
||||
/// First cell of a vertically merged range.
|
||||
Restart,
|
||||
/// Continues the merge from the cell above.
|
||||
Continue,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct CellProps {
|
||||
/// Preferred width, points.
|
||||
pub width: Option<f32>,
|
||||
/// Preferred width as a percentage of the table's (`w:tcW w:type="pct"`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub width_pct: Option<f32>,
|
||||
/// Number of grid columns spanned (1 = no horizontal merge).
|
||||
pub span: u32,
|
||||
pub vmerge: VMerge,
|
||||
pub shading: Option<Rgb>,
|
||||
pub borders: Option<Borders>,
|
||||
/// Vertical alignment (`w:vAlign`); `None` follows the table style, else top. An explicit
|
||||
/// `Top` beats the style.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub valign: Option<VAlign>,
|
||||
/// Cell margins override (top, left, bottom, right), points.
|
||||
pub margins: Option<[f32; 4]>,
|
||||
/// Per-side overrides when `margins` is absent; omitted sides inherit table defaults.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub partial_margins: Option<[Option<f32>; 4]>,
|
||||
/// Which way the cell's text runs.
|
||||
pub text_direction: TextDirection,
|
||||
/// The file states the horizontal direction (`lrTb`) outright; saving writes it back.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub text_direction_set: bool,
|
||||
pub no_wrap: bool,
|
||||
/// The cell's last paragraph takes no room when it is empty (`w:hideMark`): a row of such
|
||||
/// empty cells shrinks to its borders.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub hide_mark: bool,
|
||||
/// Tracked formatting change (`w:tcPrChange`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fmt_change: Option<Box<PropChange<CellProps>>>,
|
||||
}
|
||||
|
||||
impl CellProps {
|
||||
/// Resolve top, left, bottom and right margins against the current table defaults.
|
||||
/// A complete override takes precedence over per-side overrides.
|
||||
pub fn resolved_margins(&self, defaults: [f32; 4]) -> [f32; 4] {
|
||||
if let Some(margins) = self.margins {
|
||||
return margins;
|
||||
}
|
||||
self.partial_margins.map_or(defaults, |sides| std::array::from_fn(|i| sides[i].unwrap_or(defaults[i])))
|
||||
}
|
||||
}
|
||||
@@ -270,7 +270,12 @@ impl ResolvedPara {
|
||||
drop_cap_margin: p.drop_cap_margin.unwrap_or(false),
|
||||
drop_cap_distance: p.drop_cap_distance.filter(|d| d.is_finite()).unwrap_or(0.0).clamp(0.0, MAX_DROP_CAP_DISTANCE),
|
||||
drop_cap_font: p.drop_cap_font.clone().filter(|f| !f.trim().is_empty()),
|
||||
frame: p.frame.clone().filter(|_| p.drop_cap.unwrap_or(0) == 0),
|
||||
// Explicit zero dimensions cancel a frame; omitted dimensions still fit its text.
|
||||
frame: p.frame.clone().filter(|f| p.drop_cap.unwrap_or(0) == 0 && (f.w != Some(0.0) || f.h != Some(0.0))).map(|mut f| {
|
||||
f.w = f.w.filter(|v| *v != 0.0);
|
||||
f.h = f.h.filter(|v| *v != 0.0);
|
||||
f
|
||||
}),
|
||||
suppress_hyphens: p.suppress_hyphens.unwrap_or(false),
|
||||
suppress_line_numbers: p.suppress_line_numbers.unwrap_or(false),
|
||||
kinsoku: p.kinsoku.unwrap_or(true),
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
use super::{Blocks, Deserialize, Document, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Comment {
|
||||
pub author: String,
|
||||
pub initials: String,
|
||||
/// ISO 8601.
|
||||
pub date: String,
|
||||
/// Reply to this comment id.
|
||||
pub parent: Option<u32>,
|
||||
pub resolved: bool,
|
||||
/// `Document::parts` id of the comment text.
|
||||
pub part: u32,
|
||||
/// Word's durable id for the comment (`w16cid:durableId`, hex), kept across saves so Word
|
||||
/// keeps tracking the same comment. `None` for comments made here: the writer assigns one.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub durable_id: Option<String>,
|
||||
/// The comment's UTC timestamp: from the file (`w16cex:dateUtc`, where `date` is local time),
|
||||
/// or the time WordCraft stamped the comment with.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub date_utc: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum RevisionKind {
|
||||
#[default]
|
||||
Insert,
|
||||
Delete,
|
||||
Format,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct Revision {
|
||||
pub kind: RevisionKind,
|
||||
pub author: String,
|
||||
pub date: String,
|
||||
/// A move (ECMA-376 §17.13.5.21–28): the name linking the source (an `Delete` revision,
|
||||
/// `w:moveFrom`) with the destination (an `Insert` revision, `w:moveTo`). `None` = a plain
|
||||
/// insertion or deletion.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub move_name: Option<String>,
|
||||
}
|
||||
|
||||
impl Revision {
|
||||
pub fn new(kind: RevisionKind, author: impl Into<String>, date: impl Into<String>) -> Revision {
|
||||
Revision { kind, author: author.into(), date: date.into(), move_name: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// The move name of revision `idx`, when it is one end of a move.
|
||||
pub fn move_name(&self, idx: Option<u32>) -> Option<&str> {
|
||||
idx.and_then(|i| self.revisions.get(i as usize)).and_then(|r| r.move_name.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
/// A building block (Insert › Quick Parts, AutoText…) kept in the document's glossary
|
||||
/// (ECMA-376 §17.12: `w:docPart`).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct BuildingBlock {
|
||||
/// The entry's name (`w:docPartPr/w:name`).
|
||||
pub name: String,
|
||||
/// Its gallery (`w:category/w:gallery`: `docParts` for Quick Parts, `autoTxt` for AutoText…).
|
||||
pub gallery: String,
|
||||
/// Its category within the gallery (`w:category/w:name`, e.g. "General").
|
||||
pub category: String,
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub description: String,
|
||||
/// The entry's identity (`w:guid`), kept as read.
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub guid: String,
|
||||
/// The paragraph style it's associated with (`w:style`), kept as read.
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub style: String,
|
||||
/// Its types (`w:types/w:type`: `normal`, `bbPlcHdr` for a content control's placeholder…).
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub types: Vec<String>,
|
||||
/// How it's inserted (`w:behaviors/w:behavior`: `content`, `p`, `pg`).
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub behaviors: Vec<String>,
|
||||
/// The content (`w:docPartBody`).
|
||||
pub blocks: Blocks,
|
||||
}
|
||||
|
||||
/// The gallery Quick Parts saves to (`w:gallery w:val="docParts"`).
|
||||
pub const QUICK_PARTS_GALLERY: &str = "docParts";
|
||||
|
||||
/// The AutoText gallery (`w:gallery w:val="autoTxt"`).
|
||||
pub const AUTO_TEXT_GALLERY: &str = "autoTxt";
|
||||
@@ -255,7 +255,7 @@ impl NumFormat {
|
||||
pub fn from_ooxml(s: &str) -> NumFormat {
|
||||
NumFormat::ALL.into_iter().find(|f| f.ooxml() == s).unwrap_or(NumFormat::Decimal)
|
||||
}
|
||||
/// Format `n` (1-based). Formats WordCraft doesn't draw yet (Hebrew, Arabic, Hindi, Thai,
|
||||
/// Format `n` (1-based). Formats WordCraft doesn't draw yet (Hebrew, Hindi, Thai,
|
||||
/// Vietnamese, text amounts) show decimal, as do values past a sequence's end
|
||||
/// (the 21st item of ①…⑳).
|
||||
pub fn format(self, n: u32) -> String {
|
||||
@@ -268,6 +268,8 @@ impl NumFormat {
|
||||
NumFormat::LowerLetter => letters(n),
|
||||
NumFormat::RussianUpper => russian_letters(n).to_uppercase(),
|
||||
NumFormat::RussianLower => russian_letters(n),
|
||||
NumFormat::ArabicAlpha => repeated_letter(&ARABIC_ALPHA, n),
|
||||
NumFormat::ArabicAbjad => repeated_letter(&ARABIC_ABJAD, n),
|
||||
NumFormat::Ordinal => format!("{n}{}", ordinal_suffix(n)),
|
||||
NumFormat::CardinalText => cardinal_text(n),
|
||||
NumFormat::OrdinalText => ordinal_text(n),
|
||||
@@ -308,8 +310,6 @@ impl NumFormat {
|
||||
| NumFormat::VietnameseCounting
|
||||
| NumFormat::Hebrew1
|
||||
| NumFormat::Hebrew2
|
||||
| NumFormat::ArabicAlpha
|
||||
| NumFormat::ArabicAbjad
|
||||
| NumFormat::HindiVowels
|
||||
| NumFormat::HindiConsonants
|
||||
| NumFormat::HindiNumbers
|
||||
@@ -399,6 +399,27 @@ pub fn russian_letters(n: u32) -> String {
|
||||
std::iter::repeat_n(c, k as usize).collect()
|
||||
}
|
||||
|
||||
/// `arabicAlpha`: the 28 letters in alphabetical (hijā'ī) order.
|
||||
const ARABIC_ALPHA: [char; 28] =
|
||||
['أ', 'ب', 'ت', 'ث', 'ج', 'ح', 'خ', 'د', 'ذ', 'ر', 'ز', 'س', 'ش', 'ص', 'ض', 'ط', 'ظ', 'ع', 'غ', 'ف', 'ق', 'ك', 'ل', 'م', 'ن', 'ه', 'و', 'ي'];
|
||||
|
||||
/// `arabicAbjad`: the 28 letters in abjad order (أبجد هوز حطي كلمن سعفص قرشت ثخذ ضظغ).
|
||||
const ARABIC_ABJAD: [char; 28] =
|
||||
['أ', 'ب', 'ج', 'د', 'ه', 'و', 'ز', 'ح', 'ط', 'ي', 'ك', 'ل', 'م', 'ن', 'س', 'ع', 'ف', 'ص', 'ق', 'ر', 'ش', 'ت', 'ث', 'خ', 'ذ', 'ض', 'ظ', 'غ'];
|
||||
|
||||
/// One letter of `alphabet`, repeated once per pass through it (as [`letters`]: a..z, aa..zz).
|
||||
fn repeated_letter(alphabet: &[char], n: u32) -> String {
|
||||
let Some(&first) = alphabet.first() else { return n.to_string() };
|
||||
if n == 0 {
|
||||
return String::new();
|
||||
}
|
||||
let len = alphabet.len() as u32;
|
||||
let n = n.min(len * 30);
|
||||
let k = (n - 1) / len + 1;
|
||||
let c = alphabet.get(((n - 1) % len) as usize).copied().unwrap_or(first);
|
||||
std::iter::repeat_n(c, k as usize).collect()
|
||||
}
|
||||
|
||||
const ONES: [&str; 20] = [
|
||||
"zero",
|
||||
"one",
|
||||
@@ -920,6 +941,19 @@ mod tests {
|
||||
assert_eq!(NumFormat::RussianUpper.format(36), "ЖЖ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_letters_count_in_both_orders() {
|
||||
let alpha: Vec<String> = (1..=5).map(|n| NumFormat::ArabicAlpha.format(n)).collect();
|
||||
assert_eq!(alpha.join(" "), "أ ب ت ث ج");
|
||||
let abjad: Vec<String> = (1..=5).map(|n| NumFormat::ArabicAbjad.format(n)).collect();
|
||||
assert_eq!(abjad.join(" "), "أ ب ج د ه");
|
||||
assert_eq!(NumFormat::ArabicAlpha.format(28), "ي");
|
||||
assert_eq!(NumFormat::ArabicAbjad.format(28), "غ");
|
||||
// Past the 28th letter they double, as Latin and Russian letters do.
|
||||
assert_eq!(NumFormat::ArabicAlpha.format(29), "أأ");
|
||||
assert_eq!(NumFormat::ArabicAbjad.format(30), "بب");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn landscape_swaps() {
|
||||
let mut s = SectionProps { margin_left: 50.0, ..Default::default() };
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
use super::{Deserialize, Rgb, Serialize, math, section, styles};
|
||||
|
||||
/// A bibliography source.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Source {
|
||||
/// Unique tag cited by CITATION fields.
|
||||
pub tag: String,
|
||||
/// book, bookSection, article (journal), periodical, conference, report, website, film, other
|
||||
pub kind: String,
|
||||
/// "Last, First; Last, First"
|
||||
pub author: String,
|
||||
/// A corporate author ("World Health Organization"): written as it is, never split into
|
||||
/// names, and in place of `author` when set (Create Source › Corporate Author, `b:Corporate`,
|
||||
/// #4848).
|
||||
pub corporate: String,
|
||||
pub title: String,
|
||||
pub year: String,
|
||||
pub publisher: String,
|
||||
pub city: String,
|
||||
pub journal: String,
|
||||
pub volume: String,
|
||||
pub pages: String,
|
||||
pub url: String,
|
||||
/// More bibliography fields (Create Source › Show All Bibliography Fields).
|
||||
pub editor: String,
|
||||
pub edition: String,
|
||||
pub issue: String,
|
||||
pub month: String,
|
||||
pub day: String,
|
||||
/// Date accessed (websites).
|
||||
pub accessed: String,
|
||||
pub doi: String,
|
||||
}
|
||||
|
||||
/// A custom document property (File › Info › Properties › Custom). Citation managers keep
|
||||
/// their per-document preferences here (Zotero: `ZOTERO_PREF_1`, `ZOTERO_PREF_2`…).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct CustomProp {
|
||||
pub name: String,
|
||||
/// OOXML variant type: `lpwstr`, `i4`, `r8`, `bool`, `filetime`…, or `raw` when `value` is
|
||||
/// the property's XML content kept verbatim (vectors, blobs).
|
||||
pub kind: String,
|
||||
pub value: String,
|
||||
/// Bookmark associated with this property (`linkTarget`), retained without evaluating it.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub link_target: Option<String>,
|
||||
}
|
||||
|
||||
/// Document properties (File › Info).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct CoreProps {
|
||||
pub title: String,
|
||||
pub subject: String,
|
||||
pub creator: String,
|
||||
pub keywords: String,
|
||||
pub description: String,
|
||||
pub category: String,
|
||||
pub last_modified_by: String,
|
||||
pub created: String,
|
||||
pub modified: String,
|
||||
pub revision: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Settings {
|
||||
pub track_changes: bool,
|
||||
/// Record a tracked cut and paste of a sentence or paragraph as a move (Track moves); off
|
||||
/// when the file has `w:doNotTrackMoves` (ECMA-376 §17.15.1.29), and then it is an
|
||||
/// ordinary deletion and insertion.
|
||||
pub track_moves: bool,
|
||||
/// Default tab stop interval, points.
|
||||
pub default_tab: f32,
|
||||
pub even_odd_headers: bool,
|
||||
pub mirror_margins: bool,
|
||||
pub auto_hyphenation: bool,
|
||||
/// Hyphenation zone (`w:hyphenationZone`), points: a line is hyphenated only when breaking
|
||||
/// it at a word boundary would leave more than this empty at its end.
|
||||
pub hyphenation_zone: f32,
|
||||
/// Words in capitals may be hyphenated (`w:doNotHyphenateCaps` turns it off).
|
||||
pub hyphenate_caps: bool,
|
||||
/// Most consecutive lines that may end with a hyphen (`w:consecutiveHyphenLimit`); 0 = no limit.
|
||||
pub consecutive_hyphen_limit: u32,
|
||||
pub page_color: Option<Rgb>,
|
||||
/// Picture or texture page background (Design › Page Color › Fill Effects), drawn over the colour.
|
||||
pub page_picture: Option<PagePicture>,
|
||||
pub watermark: Option<Watermark>,
|
||||
/// Theme fonts (major = headings, minor = body).
|
||||
pub major_font: String,
|
||||
pub minor_font: String,
|
||||
/// Theme colours: dk1 lt1 dk2 lt2 accent1..6 hlink folHlink.
|
||||
pub theme_colors: Vec<Rgb>,
|
||||
pub theme_name: String,
|
||||
pub footnote_format: section::NumFormat,
|
||||
pub endnote_format: section::NumFormat,
|
||||
/// The document's footnote and endnote placement, start and restart (`w:footnotePr` /
|
||||
/// `w:endnotePr` in the settings); sections may override them.
|
||||
#[serde(skip_serializing_if = "section::NoteProps::is_empty")]
|
||||
pub footnote_pr: section::NoteProps,
|
||||
#[serde(skip_serializing_if = "section::NoteProps::is_empty")]
|
||||
pub endnote_pr: section::NoteProps,
|
||||
/// Restrict Editing, when enforced: `readOnly`, `comments`, `trackedChanges`, `forms`, or
|
||||
/// `none` when only formatting is restricted (`w:documentProtection w:edit`).
|
||||
pub protection: Option<String>,
|
||||
/// Restrict Editing limits formatting to the styles that aren't locked, and forbids direct
|
||||
/// formatting (`w:formatting="1"`).
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub protect_formatting: bool,
|
||||
/// The protection password's hash, as the file's `w:documentProtection` attributes
|
||||
/// (`w:algorithmName`, `w:hashValue`, `w:saltValue`, `w:spinCount`, or the older
|
||||
/// `w:cryptAlgorithmSid`, `w:hash`, `w:salt`…). Empty: no password.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub protect_hash: Vec<(String, String)>,
|
||||
/// Word compatibility mode the document is laid out in (`compatibilityMode`): 15 for Word
|
||||
/// 2013 and later, which places a table's border at the margin rather than its text and lets
|
||||
/// justified lines shrink their spaces to fit more text.
|
||||
pub compat_mode: u32,
|
||||
/// Fonts the document's font table classes as roman (serif, `w:family w:val="roman"`): Word
|
||||
/// draws one that isn't installed in a serif face.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub roman_fonts: Vec<String>,
|
||||
/// `w:doNotUseHTMLParagraphAutoSpacing`: the space between two paragraphs is the first's space
|
||||
/// after plus the second's space before, not the larger of the two.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub sum_paragraph_spacing: bool,
|
||||
/// `w:doNotExpandShiftReturn`: a justified line that ends in a manual line break keeps its
|
||||
/// natural spacing; by default Word stretches it to the margin like a wrapped line.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub do_not_expand_shift_return: bool,
|
||||
/// Keep contextual paragraph spacing inside legacy table cells (`allowSpaceOfSameStyleInTable`).
|
||||
/// Word ignores the layout override in compatibility mode 15, but the flag still round-trips.
|
||||
pub allow_space_of_same_style_in_table: bool,
|
||||
/// Drawing grid spacing in points, across and down (View › Gridlines); ECMA-376's default is 1/8 inch.
|
||||
pub grid_h: f32,
|
||||
pub grid_v: f32,
|
||||
/// Equation options (`m:mathPr`), when the document has them.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub math: Option<math::MathProps>,
|
||||
/// Citation and bibliography style (References › Style) by name, when the document
|
||||
/// records one (OOXML's `b:Sources/@StyleName`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bib_style: Option<String>,
|
||||
/// The template the document is attached to (Tools › Templates and Add-ins): the target of
|
||||
/// `w:attachedTemplate`'s relationship, a path or `file:` URL as the file gives it.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub attached_template: Option<String>,
|
||||
/// Update the document's styles from the attached template when it is opened (`w:linkStyles`).
|
||||
pub link_styles: bool,
|
||||
/// The zoom the document was saved at (`w:zoom`), restored when it is opened; `None` for 100%
|
||||
/// without a fit mode.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub zoom: Option<Zoom>,
|
||||
/// Remove personal information when saving (`w:removePersonalInformation`, ECMA-376
|
||||
/// §17.15.1.70): the saved file names comment and revision authors "Author" and leaves out
|
||||
/// the document's creator and last-modified-by. The open document keeps its real values.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub remove_personal_info: bool,
|
||||
/// Remove date and time from annotations when saving (`w:removeDateAndTime`, §17.15.1.69):
|
||||
/// comments and tracked changes are saved without `w:date`.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub remove_date_time: bool,
|
||||
}
|
||||
|
||||
/// A saved zoom: a percentage, or a fit mode (the percentage it last came to).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Zoom {
|
||||
/// 10–500.
|
||||
pub percent: u32,
|
||||
pub fit: Option<ZoomFit>,
|
||||
}
|
||||
|
||||
/// View › Zoom's fit modes a document can be saved in.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ZoomFit {
|
||||
OnePage,
|
||||
PageWidth,
|
||||
TextWidth,
|
||||
}
|
||||
|
||||
/// Word's compatibility mode for documents that don't state one.
|
||||
pub const LEGACY_COMPAT_MODE: u32 = 12;
|
||||
|
||||
/// The compatibility mode of documents created by Word 2013 and later, and by WordCraft.
|
||||
pub const COMPAT_MODE_CURRENT: u32 = 15;
|
||||
|
||||
/// Word's hyphenation zone when a document doesn't set one (0.25"), points.
|
||||
pub const DEFAULT_HYPHENATION_ZONE: f32 = 18.0;
|
||||
|
||||
/// ECMA-376 §17.15.1.44/45: 180 twentieths of a point when a document doesn't say.
|
||||
pub const DEFAULT_GRID: f32 = 9.0;
|
||||
|
||||
/// A picture fill for the page background (`v:background/v:fill` with an image).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct PagePicture {
|
||||
/// Key into [`Document::media`].
|
||||
pub media: String,
|
||||
/// `v:fill/@type` (`tile`, `frame`, `tile`...); tiled when `tile`, stretched to the page otherwise.
|
||||
pub fill_type: String,
|
||||
/// `o:title` of the fill (texture name), kept for round trip.
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Watermark {
|
||||
pub text: String,
|
||||
pub font: String,
|
||||
pub color: Rgb,
|
||||
pub diagonal: bool,
|
||||
pub semitransparent: bool,
|
||||
/// The shape's box (its `style` width and height, points), which the text is stretched to
|
||||
/// fill (`v:textpath fitshape`); `None`: the box Word's Design › Watermark gives
|
||||
/// ([`Watermark::frame`]).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub size: Option<[f32; 2]>,
|
||||
}
|
||||
|
||||
impl Default for Watermark {
|
||||
fn default() -> Self {
|
||||
Watermark {
|
||||
text: "DRAFT".into(),
|
||||
font: styles::BODY_FONT.into(),
|
||||
color: Rgb(0xC0, 0xC0, 0xC0),
|
||||
diagonal: true,
|
||||
semitransparent: true,
|
||||
size: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Watermark {
|
||||
/// The box the text fills on a page `page_w` × `page_h` (points): its own size, else half the
|
||||
/// page diagonal across (half the page width when not diagonal), as high as the text's length
|
||||
/// makes it. Centred on the margins and, when diagonal, turned 45° counterclockwise (Word's
|
||||
/// `rotation:315`).
|
||||
pub fn frame(&self, page_w: f32, page_h: f32) -> [f32; 2] {
|
||||
if let Some([w, h]) = self.size.filter(|[w, h]| w.is_finite() && h.is_finite() && *w > 0.0 && *h > 0.0) {
|
||||
return [w.min(4000.0), h.min(4000.0)];
|
||||
}
|
||||
let (pw, ph) = (wordcraft_geom::finite(page_w).clamp(72.0, 4000.0), wordcraft_geom::finite(page_h).clamp(72.0, 4000.0));
|
||||
let across = if self.diagonal { (pw * pw + ph * ph).sqrt() } else { pw };
|
||||
let width = (across * 0.5).clamp(36.0, 2000.0);
|
||||
let chars = self.text.chars().take(200).count().max(1) as f32;
|
||||
[width, (width / (0.55 * chars)).clamp(12.0, 400.0)]
|
||||
}
|
||||
}
|
||||
|
||||
/// Our default theme colours.
|
||||
pub const THEME_COLORS: [Rgb; 12] = [
|
||||
Rgb(0x00, 0x00, 0x00),
|
||||
Rgb(0xFF, 0xFF, 0xFF),
|
||||
Rgb(0x0E, 0x28, 0x41),
|
||||
Rgb(0xE8, 0xE8, 0xE8),
|
||||
Rgb(0x15, 0x60, 0x82),
|
||||
Rgb(0xE9, 0x71, 0x32),
|
||||
Rgb(0x19, 0x6B, 0x24),
|
||||
Rgb(0x0F, 0x9E, 0xD5),
|
||||
Rgb(0xA0, 0x2B, 0x93),
|
||||
Rgb(0x4E, 0xA7, 0x2E),
|
||||
Rgb(0x46, 0x78, 0x86),
|
||||
Rgb(0x96, 0x60, 0x7D),
|
||||
];
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Settings {
|
||||
track_changes: false,
|
||||
track_moves: true,
|
||||
default_tab: 36.0,
|
||||
even_odd_headers: false,
|
||||
mirror_margins: false,
|
||||
auto_hyphenation: false,
|
||||
hyphenation_zone: DEFAULT_HYPHENATION_ZONE,
|
||||
hyphenate_caps: true,
|
||||
consecutive_hyphen_limit: 0,
|
||||
page_color: None,
|
||||
page_picture: None,
|
||||
watermark: None,
|
||||
major_font: styles::HEADING_FONT.into(),
|
||||
minor_font: styles::BODY_FONT.into(),
|
||||
theme_colors: THEME_COLORS.to_vec(),
|
||||
theme_name: "Craft".into(),
|
||||
footnote_format: section::NumFormat::Decimal,
|
||||
endnote_format: section::NumFormat::LowerRoman,
|
||||
footnote_pr: section::NoteProps::default(),
|
||||
endnote_pr: section::NoteProps::default(),
|
||||
protection: None,
|
||||
protect_formatting: false,
|
||||
protect_hash: Vec::new(),
|
||||
compat_mode: COMPAT_MODE_CURRENT,
|
||||
roman_fonts: Vec::new(),
|
||||
sum_paragraph_spacing: false,
|
||||
do_not_expand_shift_return: false,
|
||||
allow_space_of_same_style_in_table: false,
|
||||
grid_h: DEFAULT_GRID,
|
||||
grid_v: DEFAULT_GRID,
|
||||
math: None,
|
||||
bib_style: None,
|
||||
attached_template: None,
|
||||
link_styles: false,
|
||||
zoom: None,
|
||||
remove_personal_info: false,
|
||||
remove_date_time: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
+145
-1
@@ -48,6 +48,12 @@ impl Row {
|
||||
pub fn first_grid(&self) -> usize {
|
||||
(self.props.grid_before as usize).min(MAX_COLS)
|
||||
}
|
||||
|
||||
/// Grid columns `w:gridAfter` leaves empty after the row's last cell, bounded as the DOCX
|
||||
/// reader bounds them (a native document can say anything).
|
||||
pub fn grid_after(&self) -> usize {
|
||||
(self.props.grid_after as usize).min(MAX_COLS)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
@@ -71,6 +77,80 @@ pub const MAX_COLS: usize = 63;
|
||||
pub const MAX_TABLE_DEPTH: usize = 24;
|
||||
|
||||
impl Table {
|
||||
/// Grid columns `lo..hi` that [`Table::slice`] copies for `rows`/`cols` (widened over merged cells),
|
||||
/// and whether any cell touches them (Cut clears the same cells it copies).
|
||||
pub fn slice_bounds(&self, rows: (usize, usize), cols: (usize, usize)) -> (usize, usize, bool) {
|
||||
let ((r0, r1), (g0, g1)) = (rows, cols);
|
||||
let kept = || self.rows.iter().take(r1.saturating_add(1)).skip(r0);
|
||||
// Grid columns `lo..hi` the cells touching them cover: a cell merged across that is partly
|
||||
// in them widens them, for every row, so the copy isn't ragged. Each round only widens
|
||||
// (at most once per grid column), so a bound of the grid's width plus one round is enough.
|
||||
let (mut lo, mut hi) = (g0, g1.saturating_add(1));
|
||||
let mut any = false;
|
||||
for _ in 0..=MAX_COLS.saturating_add(1) {
|
||||
let (mut nlo, mut nhi, mut found) = (lo, hi, false);
|
||||
for row in kept() {
|
||||
let mut g = 0usize;
|
||||
for cell in &row.cells {
|
||||
let end = g.saturating_add(cell.span());
|
||||
if g < hi && end > lo {
|
||||
(nlo, nhi, found) = (nlo.min(g), nhi.max(end), true);
|
||||
}
|
||||
g = end;
|
||||
}
|
||||
}
|
||||
any = found;
|
||||
if (nlo, nhi) == (lo, hi) {
|
||||
break;
|
||||
}
|
||||
(lo, hi) = (nlo, nhi);
|
||||
}
|
||||
(lo, hi, any)
|
||||
}
|
||||
|
||||
/// A copy of rows `rows` of this table holding, of each row, the cells that reach into grid
|
||||
/// columns `cols` (both inclusive): what Copy takes from selected rows or cells. Content
|
||||
/// controls around rows and the table aren't copied (they'd lose their other end); a cell
|
||||
/// that continues a vertical merge from a row left out starts the merge instead.
|
||||
pub fn slice(&self, rows: (usize, usize), cols: (usize, usize)) -> Table {
|
||||
let (r0, r1) = rows;
|
||||
let kept = || self.rows.iter().take(r1.saturating_add(1)).skip(r0);
|
||||
let (lo, hi, any) = self.slice_bounds(rows, cols);
|
||||
let mut out_rows = Vec::new();
|
||||
for row in kept() {
|
||||
let mut g = 0usize;
|
||||
let mut cells = Vec::new();
|
||||
for cell in &row.cells {
|
||||
let end = g.saturating_add(cell.span());
|
||||
if any && g < hi && end > lo {
|
||||
cells.push(cell.clone());
|
||||
}
|
||||
g = end;
|
||||
}
|
||||
if !cells.is_empty() {
|
||||
out_rows.push(Row { props: row.props.clone(), cells, controls: ControlWrap::default() });
|
||||
}
|
||||
}
|
||||
if let Some(first) = out_rows.first_mut() {
|
||||
for c in &mut first.cells {
|
||||
if c.props.vmerge == VMerge::Continue {
|
||||
c.props.vmerge = VMerge::Restart;
|
||||
}
|
||||
}
|
||||
}
|
||||
let whole = lo == 0 && hi >= self.grid.len();
|
||||
let grid = if whole { self.grid.clone() } else { self.grid.get(lo..hi.min(self.grid.len())).map(<[f32]>::to_vec).unwrap_or_default() };
|
||||
let mut props = self.props.clone();
|
||||
if !whole {
|
||||
// Part of the columns: as wide as they are, not the whole table's width.
|
||||
if props.width.is_some() || props.width_pct.is_some() {
|
||||
props.width = Some(grid.iter().sum());
|
||||
}
|
||||
props.width_pct = None;
|
||||
}
|
||||
Table { props, grid, rows: out_rows, controls: ControlWrap::default() }
|
||||
}
|
||||
|
||||
/// A `rows × cols` table spanning `width` points, "Table Grid" style.
|
||||
pub fn new(rows: usize, cols: usize, width: f32) -> Table {
|
||||
let rows = rows.clamp(1, MAX_ROWS);
|
||||
@@ -97,7 +177,8 @@ impl Table {
|
||||
}
|
||||
|
||||
pub fn cols(&self) -> usize {
|
||||
let row_cols = |r: &Row| r.first_grid() + r.cells.iter().map(Cell::span).sum::<usize>() + r.props.grid_after as usize;
|
||||
let row_cols =
|
||||
|r: &Row| r.first_grid().saturating_add(r.cells.iter().map(Cell::span).fold(0, usize::saturating_add)).saturating_add(r.grid_after());
|
||||
self.grid.len().max(self.rows.iter().map(row_cols).max().unwrap_or(0))
|
||||
}
|
||||
|
||||
@@ -559,6 +640,69 @@ impl Table {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A 3×3 table, 100 pt columns: (0,0)–(0,1) merged across, (1,2)–(2,2) merged down.
|
||||
fn merged() -> Table {
|
||||
let mut t = Table::new(3, 3, 300.0);
|
||||
t.merge(0, 0, 0, 1);
|
||||
t.merge(1, 2, 2, 2);
|
||||
assert_eq!(t.rows[0].cells.iter().map(Cell::span).collect::<Vec<_>>(), [2, 1]);
|
||||
assert_eq!((t.rows[1].cells[2].props.vmerge, t.rows[2].cells[2].props.vmerge), (VMerge::Restart, VMerge::Continue));
|
||||
t
|
||||
}
|
||||
|
||||
fn spans(t: &Table) -> Vec<Vec<usize>> {
|
||||
t.rows.iter().map(|r| r.cells.iter().map(Cell::span).collect()).collect()
|
||||
}
|
||||
|
||||
/// #1744 review: a cell merged across that is partly selected takes its whole width, and the
|
||||
/// other rows widen with it, so the copy isn't ragged.
|
||||
#[test]
|
||||
fn slice_widens_to_a_merged_cell_in_every_row() {
|
||||
let t = merged().slice((0, 1), (1, 2));
|
||||
assert_eq!(t.grid, [100.0, 100.0, 100.0]);
|
||||
assert_eq!(spans(&t), [vec![2, 1], vec![1, 1, 1]]);
|
||||
// Rows without the merged cell: just the selected columns.
|
||||
let t = merged().slice((1, 2), (1, 2));
|
||||
assert_eq!(t.grid, [100.0, 100.0]);
|
||||
assert_eq!(spans(&t), [vec![1, 1], vec![1, 1]]);
|
||||
}
|
||||
|
||||
/// #1744 review: a cell continuing a vertical merge from a row left out starts it instead.
|
||||
#[test]
|
||||
fn slice_starts_a_vertical_merge_whose_first_row_is_left_out() {
|
||||
let t = merged().slice((2, 2), (0, 2));
|
||||
assert_eq!(t.rows.len(), 1);
|
||||
assert_eq!(t.rows[0].cells[2].props.vmerge, VMerge::Restart);
|
||||
// Both rows kept: the merge is kept as it is.
|
||||
let t = merged().slice((1, 2), (2, 2));
|
||||
assert_eq!((t.rows[0].cells[0].props.vmerge, t.rows[1].cells[0].props.vmerge), (VMerge::Restart, VMerge::Continue));
|
||||
}
|
||||
|
||||
/// #1744 review: part of the columns is as wide as they are; content controls around rows
|
||||
/// and the table aren't copied.
|
||||
#[test]
|
||||
fn slice_width_and_controls() {
|
||||
let mut t = Table::new(3, 3, 300.0);
|
||||
(t.props.width, t.props.width_pct) = (Some(300.0), Some(50.0));
|
||||
let wrap = ControlWrap { open: vec![crate::control::ContentControl::default()], close: 1 };
|
||||
t.controls = wrap.clone();
|
||||
t.rows[1].controls = wrap;
|
||||
let part = t.slice((0, 2), (2, 2));
|
||||
assert_eq!((part.grid.len(), part.props.width, part.props.width_pct), (1, Some(100.0), None));
|
||||
let whole = t.slice((0, 2), (0, 2));
|
||||
assert_eq!((whole.props.width, whole.props.width_pct), (Some(300.0), Some(50.0)), "all columns keep the table's width");
|
||||
let row = t.slice((1, 1), (0, 2));
|
||||
assert!(row.controls.is_empty() && row.rows[0].controls.is_empty());
|
||||
// A table sized only by a percentage keeps a width for the part.
|
||||
let mut pct = Table::new(3, 3, 300.0);
|
||||
(pct.props.width, pct.props.width_pct) = (None, Some(50.0));
|
||||
let part = pct.slice((0, 2), (2, 2));
|
||||
assert_eq!((part.props.width, part.props.width_pct), (Some(100.0), None));
|
||||
// Nothing in range: no rows.
|
||||
assert!(t.slice((5, 6), (0, 2)).rows.is_empty());
|
||||
assert!(t.slice((0, 2), (9, 9)).rows.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_before_shifts_the_row() {
|
||||
let mut t = Table::new(2, 3, 300.0);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
//! Test helper: the warnings this crate logs, captured per test thread, so a test can check that
|
||||
//! a limit which leaves something out says so.
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
||||
struct Capture;
|
||||
|
||||
thread_local! {
|
||||
static LINES: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
impl log::Log for Capture {
|
||||
fn enabled(&self, m: &log::Metadata) -> bool {
|
||||
m.level() <= log::Level::Warn
|
||||
}
|
||||
|
||||
fn log(&self, r: &log::Record) {
|
||||
if self.enabled(r.metadata()) {
|
||||
LINES.with(|l| l.borrow_mut().push(r.args().to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
static CAPTURE: Capture = Capture;
|
||||
|
||||
/// `f`'s result and the warnings logged on this thread while it ran.
|
||||
pub(crate) fn warnings<T>(f: impl FnOnce() -> T) -> (T, Vec<String>) {
|
||||
// Only this logger is ever installed in a test binary; a second call keeps it.
|
||||
let _ = log::set_logger(&CAPTURE);
|
||||
log::set_max_level(log::LevelFilter::Warn);
|
||||
LINES.with(|l| l.borrow_mut().clear());
|
||||
let out = f();
|
||||
(out, LINES.with(|l| std::mem::take(&mut *l.borrow_mut())))
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
use super::*;
|
||||
|
||||
mod note_conversion;
|
||||
|
||||
#[test]
|
||||
fn new_document_is_valid() {
|
||||
let d = Document::new();
|
||||
@@ -90,6 +92,45 @@ fn parts_and_media() {
|
||||
assert_ne!(k1, k3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_distinct_media_add_in_linear_time() {
|
||||
// An import with tens of thousands of small distinct images (same length, differing only at
|
||||
// the end) used to compare each against every earlier one.
|
||||
let mut d = Document::new();
|
||||
let t = std::time::Instant::now();
|
||||
let n = 40_000u32;
|
||||
for i in 0..n {
|
||||
let mut bytes = vec![7u8; 256];
|
||||
bytes.extend(i.to_le_bytes());
|
||||
d.add_media(bytes, "png");
|
||||
}
|
||||
assert_eq!(d.media.len(), n as usize);
|
||||
// Duplicates still find their first copy, including media inserted directly.
|
||||
let mut first = vec![7u8; 256];
|
||||
first.extend(0u32.to_le_bytes());
|
||||
assert_eq!(d.add_media(first, "png"), "image1.png");
|
||||
d.media.insert("direct.png".into(), Arc::new(vec![9, 9]));
|
||||
assert_eq!(d.add_media(vec![9, 9], "png"), "direct.png");
|
||||
// About half a second on a loaded machine; the old scan took over two minutes.
|
||||
assert!(t.elapsed().as_secs_f32() < 10.0, "{:?}", t.elapsed());
|
||||
// The lookup cache isn't part of the document.
|
||||
let mut e = d.clone();
|
||||
e.media_index = MediaIndex::default();
|
||||
assert_eq!(d, e);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_keys_skip_taken_names_once() {
|
||||
let mut d = Document::new();
|
||||
// A file whose media already use the names add_media would pick next.
|
||||
for i in 2..2002u32 {
|
||||
d.media.insert(format!("image{i}.png"), Arc::new(i.to_le_bytes().to_vec()));
|
||||
}
|
||||
assert_eq!(d.add_media(vec![0], "png"), "image2002.png");
|
||||
assert_eq!(d.add_media(vec![1], "png"), "image2003.png");
|
||||
assert_eq!(d.add_media(vec![2], "png"), "image2004.png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_ids_skip_taken_ones_instead_of_overflowing() {
|
||||
assert_eq!(free_id(None, std::iter::empty(), 1), Some(1));
|
||||
@@ -523,3 +564,155 @@ fn custom_props_set_get_remove() {
|
||||
assert!(!d.remove_custom_prop("other"));
|
||||
assert_eq!(d.custom_props.len(), 1);
|
||||
}
|
||||
|
||||
/// A paragraph that is nothing but `objs` (built directly: inserting one at a time is slow too).
|
||||
fn para_of_objects(objs: Vec<InlineObject>) -> Paragraph {
|
||||
let mut p = Paragraph::new();
|
||||
p.text = para::OBJ.to_string().repeat(objs.len());
|
||||
p.runs = vec![Run { len: p.text.len(), props: CharProps::default() }];
|
||||
p.objects = objs;
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_walks_are_linear_in_the_paragraph() {
|
||||
// #3614: loops of object_offsets + object_at recounted the objects before each one, so a
|
||||
// paragraph of 40,000 markers took seconds per walk.
|
||||
let n = 40_000u32;
|
||||
let w = para::OBJ.len_utf8();
|
||||
let mut objs: Vec<InlineObject> = Vec::new();
|
||||
for i in 0..n {
|
||||
objs.push(InlineObject::BookmarkStart { name: format!("b{i}") });
|
||||
objs.push(fs("ADDIN x"));
|
||||
objs.push(InlineObject::PermStart { id: i, group: "everyone".into(), editor: String::new() });
|
||||
}
|
||||
// Ranges close in the order they opened (the worst case for a backwards search).
|
||||
for i in 0..n {
|
||||
objs.push(InlineObject::FieldEnd);
|
||||
objs.push(InlineObject::PermEnd { id: i });
|
||||
}
|
||||
let mut d = Document::new();
|
||||
d.body = vec![Arc::new(Block::Para(para_of_objects(objs)))];
|
||||
let t = std::time::Instant::now();
|
||||
assert_eq!(d.bookmarks().len(), n as usize);
|
||||
assert_eq!(d.field_ranges(StoryRef::Body).len(), n as usize);
|
||||
let perms = d.perm_ranges();
|
||||
assert_eq!(perms.len(), n as usize);
|
||||
assert!(perms.iter().all(|r| r.end.off > r.start.off), "every range closed by its own end");
|
||||
assert_eq!(d.text_box_anchor(7), None);
|
||||
// A caret before a long row of field ends moves past all of them.
|
||||
let first_end = 3 * n as usize * w;
|
||||
let p = d.past_field_ends(&Pos::body(0, first_end));
|
||||
assert_eq!(p.off, first_end + w);
|
||||
// Under 0.3 s on a loaded machine; the old walks took minutes (360 s for the first test).
|
||||
assert!(t.elapsed().as_secs_f32() < 10.0, "{:?}", t.elapsed());
|
||||
|
||||
let ends: Vec<InlineObject> = (0..n).map(|_| InlineObject::FieldEnd).collect();
|
||||
d.body = vec![Arc::new(Block::Para(para_of_objects(ends)))];
|
||||
let t = std::time::Instant::now();
|
||||
assert_eq!(d.past_field_ends(&Pos::body(0, 0)).off, n as usize * w);
|
||||
// Typing at the end of a row of block-level control starts.
|
||||
let block = ContentControl { block: true, ..Default::default() };
|
||||
let starts: Vec<InlineObject> = (0..n).map(|_| InlineObject::ControlStart { control: Box::new(block.clone()) }).collect();
|
||||
d.body = vec![Arc::new(Block::Para(para_of_objects(starts)))];
|
||||
let end = Pos::body(0, n as usize * w);
|
||||
assert_eq!(d.typing_pos(&end), end);
|
||||
assert!(t.elapsed().as_secs_f32() < 10.0, "{:?}", t.elapsed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_across_many_lone_control_markers_is_not_quadratic() {
|
||||
// A millisecond now; the old delete took 18 s at 1,500 markers and 114 s at 3,000.
|
||||
let n = 3_000;
|
||||
let starts: Vec<InlineObject> = (0..n).map(|_| InlineObject::ControlStart { control: Box::default() }).collect();
|
||||
let mut d = Document::from_text("before\n\nafter");
|
||||
d.body[1] = Arc::new(Block::Para(para_of_objects(starts)));
|
||||
let t = std::time::Instant::now();
|
||||
let at = d.delete_range(&Pos::body(0, 3), &Pos::body(2, 2)).unwrap();
|
||||
assert_eq!(at, Pos::body(0, 3));
|
||||
assert_eq!(d.plain_text(StoryRef::Body), "befter");
|
||||
assert!(t.elapsed().as_secs_f32() < 10.0, "{:?}", t.elapsed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_markers_kept_are_sorted() {
|
||||
// `delete_range` groups the kept markers by paragraph and binary-searches each group, so
|
||||
// `kept_control_markers` must hand them over sorted and without repeats (review of #3874).
|
||||
let start = || InlineObject::ControlStart { control: Box::default() };
|
||||
let mut d = Document::from_text("before");
|
||||
// Controls open and close across paragraphs: 1 closes the one 0 opens, 3 the one 2 opens…
|
||||
for i in 0..12 {
|
||||
let objs = if i % 2 == 0 { vec![start(), start()] } else { vec![InlineObject::ControlEnd, InlineObject::ControlEnd] };
|
||||
d.body.push(Arc::new(Block::Para(para_of_objects(objs))));
|
||||
}
|
||||
d.body.push(Arc::new(Block::Para(Paragraph::with_text("after", CharProps::default()))));
|
||||
let w = para::OBJ.len_utf8();
|
||||
for (a, b) in [(Pos::body(2, w), Pos::body(11, w)), (Pos::body(1, w), Pos::body(12, w)), (Pos::body(3, w), Pos::body(8, 0))] {
|
||||
let kept = d.kept_control_markers(&a, &b);
|
||||
assert!(!kept.is_empty(), "{a:?}..{b:?}");
|
||||
assert!(kept.windows(2).all(|p| p[0] < p[1]), "sorted, no repeats: {kept:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lone_table_open_markers_linear_time() {
|
||||
let mut d = Document::new();
|
||||
let mut t = Table::new(1, 1, 100.0);
|
||||
const COUNT: usize = 2_000;
|
||||
t.controls.open = (0..COUNT).map(|i| ContentControl { tag: format!("ctrl_{i}"), ..Default::default() }).collect();
|
||||
d.body.insert(0, Arc::new(Block::Table(t)));
|
||||
|
||||
let mut paras = Vec::new();
|
||||
let mut tables = Vec::new();
|
||||
let timer = std::time::Instant::now();
|
||||
crate::control::orphans(&d.body, &mut Vec::new(), 0, &mut paras, &mut tables);
|
||||
|
||||
assert_eq!(paras.len(), 0);
|
||||
assert_eq!(tables.len(), COUNT);
|
||||
for (i, (path, nth, is_open)) in tables.iter().enumerate() {
|
||||
assert_eq!(*path, Path::top(0));
|
||||
assert_eq!(*nth, i);
|
||||
assert!(*is_open);
|
||||
}
|
||||
|
||||
assert!(d.has_unbalanced_controls());
|
||||
let removed = d.balance_controls();
|
||||
assert_eq!(removed, COUNT);
|
||||
assert!(!d.has_unbalanced_controls());
|
||||
assert!(timer.elapsed().as_secs_f32() < 10.0, "{:?}", timer.elapsed());
|
||||
let Block::Table(tbl) = &*d.body[0] else { panic!("expected table") };
|
||||
assert!(tbl.controls.open.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lone_table_open_markers_mixed_tables_and_paired() {
|
||||
let mut d = Document::new();
|
||||
let mut t0 = Table::new(1, 1, 100.0);
|
||||
t0.controls.open = (0..3).map(|i| ContentControl { tag: format!("t0_{i}"), ..Default::default() }).collect();
|
||||
t0.controls.close = 1;
|
||||
|
||||
let mut t1 = Table::new(1, 1, 100.0);
|
||||
t1.controls.open = (0..2).map(|i| ContentControl { tag: format!("t1_{i}"), ..Default::default() }).collect();
|
||||
|
||||
d.body = vec![Arc::new(Block::Table(t0)), Arc::new(Block::Table(t1))];
|
||||
|
||||
let mut paras = Vec::new();
|
||||
let mut tables = Vec::new();
|
||||
crate::control::orphans(&d.body, &mut Vec::new(), 0, &mut paras, &mut tables);
|
||||
|
||||
assert_eq!(paras.len(), 0);
|
||||
assert_eq!(tables, vec![(Path::top(0), 0, true), (Path::top(0), 1, true), (Path::top(1), 0, true), (Path::top(1), 1, true),]);
|
||||
|
||||
let removed = d.balance_controls();
|
||||
assert_eq!(removed, 4);
|
||||
assert!(!d.has_unbalanced_controls());
|
||||
|
||||
let Block::Table(res0) = &*d.body[0] else { panic!("expected table") };
|
||||
assert_eq!(res0.controls.open.len(), 1);
|
||||
assert_eq!(res0.controls.open[0].tag, "t0_2");
|
||||
assert_eq!(res0.controls.close, 1);
|
||||
|
||||
let Block::Table(res1) = &*d.body[1] else { panic!("expected table") };
|
||||
assert!(res1.controls.open.is_empty());
|
||||
assert_eq!(res1.controls.close, 0);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
use super::*;
|
||||
use crate::para::{InlineObject, NoteKind, OBJ};
|
||||
|
||||
fn reference(id: u32, kind: NoteKind) -> InlineObject {
|
||||
InlineObject::NoteRef { kind, id, custom: "*".into() }
|
||||
}
|
||||
|
||||
fn styled(len: usize, style: &str) -> Run {
|
||||
Run { len, props: CharProps { style: Some(style.into()), italic: Some(true), ..Default::default() } }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversion_preserves_reference_run_boundaries_and_unmatched_storage() {
|
||||
for (foot, end, expected) in [
|
||||
(true, false, ["FootnoteReference", "EndnoteReference", "FootnoteReference", "EndnoteReference", "EndnoteReference", "FootnoteReference"]),
|
||||
(false, true, ["FootnoteReference", "FootnoteReference", "FootnoteReference", "EndnoteReference", "FootnoteReference", "FootnoteReference"]),
|
||||
(true, true, ["FootnoteReference", "EndnoteReference", "FootnoteReference", "EndnoteReference", "FootnoteReference", "FootnoteReference"]),
|
||||
] {
|
||||
let mut d = Document::new();
|
||||
let note = |style: &str| {
|
||||
let mut p = Paragraph::with_text("Original note 漢", CharProps::default());
|
||||
p.props.style = Some(style.into());
|
||||
p.mark.style = Some(style.into());
|
||||
vec![para_block(p)]
|
||||
};
|
||||
let f = d.add_part(PartKind::Footnote, note("FootnoteText"));
|
||||
let e = d.add_part(PartKind::Endnote, note("EndnoteText"));
|
||||
let p = Paragraph {
|
||||
text: format!("é{OBJ}{OBJ}漢{OBJ}{OBJ}"),
|
||||
objects: vec![
|
||||
reference(f, NoteKind::Footnote),
|
||||
InlineObject::BookmarkStart { name: "kept".into() },
|
||||
reference(e, NoteKind::Endnote),
|
||||
reference(999, NoteKind::Footnote),
|
||||
reference(f, NoteKind::Footnote),
|
||||
],
|
||||
runs: vec![
|
||||
styled(2, "FootnoteReference"),
|
||||
styled(3, "FootnoteReference"),
|
||||
styled(3, "FootnoteReference"),
|
||||
styled(3, "EndnoteReference"),
|
||||
styled(3, "EndnoteReference"),
|
||||
styled(3, "FootnoteReference"),
|
||||
],
|
||||
..Paragraph::new()
|
||||
};
|
||||
let mut table = Table::new(1, 1, 200.0);
|
||||
table.rows[0].cells[0].blocks = vec![para_block(p.clone())];
|
||||
d.body = vec![para_block(p.clone()), Arc::new(Block::Table(table))];
|
||||
let h = d.add_part(PartKind::Header, vec![para_block(p.clone())]);
|
||||
let t = d.add_part(PartKind::TextBox, vec![para_block(p.clone())]);
|
||||
let before = d.clone();
|
||||
assert_eq!(d.convert_notes(foot, end), usize::from(foot) + usize::from(end));
|
||||
for (story, path) in [
|
||||
(StoryRef::Body, Path::top(0)),
|
||||
(StoryRef::Body, Path(vec![1, 0, 0, 0])),
|
||||
(StoryRef::Part(h), Path::top(0)),
|
||||
(StoryRef::Part(t), Path::top(0)),
|
||||
] {
|
||||
let got = d.para(story, &path).unwrap();
|
||||
assert_eq!(got.text, p.text);
|
||||
assert_eq!(got.runs.iter().map(|r| r.props.style.as_deref().unwrap()).collect::<Vec<_>>(), expected);
|
||||
assert!(got.runs.iter().all(|r| r.props.italic == Some(true)));
|
||||
assert_eq!(got.objects[1], p.objects[1]);
|
||||
assert_eq!(got.objects[3], p.objects[3], "a missing note stays unchanged");
|
||||
assert_eq!(got.objects[0], reference(f, if foot { NoteKind::Endnote } else { NoteKind::Footnote }));
|
||||
assert_eq!(got.objects[2], reference(e, if end { NoteKind::Footnote } else { NoteKind::Endnote }));
|
||||
assert_eq!(got.objects[4], got.objects[0], "surplus object storage retains the existing conversion");
|
||||
}
|
||||
for (id, flipped, from, to) in [(f, foot, "FootnoteText", "EndnoteText"), (e, end, "EndnoteText", "FootnoteText")] {
|
||||
let got = d.para(StoryRef::Part(id), &Path::top(0)).unwrap();
|
||||
assert_eq!(got.text, before.plain_text(StoryRef::Part(id)));
|
||||
assert_eq!(got.props.style.as_deref(), Some(if flipped { to } else { from }));
|
||||
assert_eq!(got.mark.style, got.props.style);
|
||||
}
|
||||
let mut missing = Paragraph {
|
||||
text: format!("{OBJ}{OBJ}"),
|
||||
objects: vec![reference(f, if foot { NoteKind::Endnote } else { NoteKind::Footnote })],
|
||||
runs: vec![styled(0, "FootnoteReference"), styled(3, "FootnoteReference"), styled(3, "FootnoteReference")],
|
||||
..Paragraph::new()
|
||||
};
|
||||
missing.runs.push(styled(usize::MAX, "FootnoteReference"));
|
||||
d.body = vec![para_block(missing)];
|
||||
d.convert_notes(true, true);
|
||||
let got = d.para(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
assert_eq!(
|
||||
got.runs.iter().map(|r| r.props.style.as_deref().unwrap()).collect::<Vec<_>>(),
|
||||
["FootnoteReference", "EndnoteReference", "FootnoteReference", "FootnoteReference"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn dense(n: usize) -> Document {
|
||||
let mut d = Document::new();
|
||||
let id = d.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("Shared original note", CharProps::default()))]);
|
||||
d.body = vec![para_block(Paragraph {
|
||||
text: format!("{}{}", OBJ.to_string().repeat(n), "x".repeat(n)),
|
||||
objects: (0..n).map(|_| reference(id, NoteKind::Footnote)).collect(),
|
||||
runs: (0..n).map(|_| styled(OBJ.len_utf8(), "FootnoteReference")).chain((0..n).map(|_| styled(1, "FootnoteReference"))).collect(),
|
||||
..Paragraph::new()
|
||||
})];
|
||||
d
|
||||
}
|
||||
|
||||
// Thread CPU excludes scheduler delays. Missing or nonadvancing counters provide no timing
|
||||
// evidence; the semantic assertions still run on those platforms.
|
||||
fn cpu_cost(f: impl FnOnce()) -> Option<f64> {
|
||||
let ran = || std::fs::read_to_string("/proc/thread-self/schedstat").ok()?.split_whitespace().next()?.parse::<u64>().ok();
|
||||
let before = ran();
|
||||
f();
|
||||
match (before, ran()) {
|
||||
(Some(a), Some(b)) if b > a => Some((b - a) as f64 * 1e-9),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversion_work_scales_with_reference_and_formatting_runs() {
|
||||
let (small, big) = (dense(2_000), dense(16_000));
|
||||
let convert = |input: &Document| {
|
||||
let mut d = input.clone();
|
||||
assert_eq!(d.convert_notes(true, false), 1);
|
||||
let p = d.para(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
let n = p.objects.len();
|
||||
assert!(p.runs[..n].iter().all(|r| r.props.style.as_deref() == Some("EndnoteReference")));
|
||||
assert!(p.runs[n..].iter().all(|r| r.props.style.as_deref() == Some("FootnoteReference")));
|
||||
std::hint::black_box(d);
|
||||
};
|
||||
convert(&small);
|
||||
convert(&big);
|
||||
let (mut t1, mut t8) = (f64::INFINITY, f64::INFINITY);
|
||||
for _ in 0..3 {
|
||||
let a = cpu_cost(|| (0..8).for_each(|_| convert(&small)));
|
||||
let b = cpu_cost(|| convert(&big));
|
||||
if let (Some(a), Some(b)) = (a, b) {
|
||||
t1 = t1.min(a / 8.0);
|
||||
t8 = t8.min(b);
|
||||
}
|
||||
}
|
||||
if t1.is_finite() && t8.is_finite() {
|
||||
let ratio = t8 / t1;
|
||||
eprintln!("note conversion CPU: small={t1:.6}s big={t8:.6}s ratio={ratio:.2}");
|
||||
assert!(ratio < 24.0, "8x the references and runs cost {ratio:.2}x CPU");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
use super::block::{Block, Blocks};
|
||||
use super::parts::PartKind;
|
||||
use super::position::Path;
|
||||
use super::{InlineObject, Numbering, numbering, para, section, styles};
|
||||
|
||||
/// Words in `s` (tokens separated by whitespace that contain at least one alphanumeric char).
|
||||
pub fn count_words(s: &str) -> usize {
|
||||
s.split(|c: char| c.is_whitespace() || c == para::OBJ).filter(|w| w.chars().any(char::is_alphanumeric)).count()
|
||||
}
|
||||
|
||||
/// How deep in nested tables [`Document::para_paths`] goes: cells below this are skipped.
|
||||
pub(crate) const PARA_WALK_DEPTH: usize = 16;
|
||||
|
||||
/// The note and text box stories an object shows, for `Document::reachable`.
|
||||
pub(crate) fn notes_and_boxes(o: &InlineObject) -> Vec<(u32, &'static [PartKind])> {
|
||||
match o {
|
||||
InlineObject::NoteRef { id, .. } => vec![(*id, &[PartKind::Footnote, PartKind::Endnote][..])],
|
||||
o => o.text_boxes().into_iter().map(|id| (id, &[PartKind::TextBox][..])).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn walk(bl: &Blocks, prefix: &mut Vec<u32>, out: &mut Vec<Path>, depth: usize) {
|
||||
if depth > PARA_WALK_DEPTH {
|
||||
return;
|
||||
}
|
||||
for (i, b) in bl.iter().enumerate() {
|
||||
prefix.push(i as u32);
|
||||
match &**b {
|
||||
Block::Para(_) => out.push(Path(prefix.clone())),
|
||||
Block::Table(t) => {
|
||||
for (r, row) in t.rows.iter().enumerate() {
|
||||
for (c, cell) in row.cells.iter().enumerate() {
|
||||
prefix.push(r as u32);
|
||||
prefix.push(c as u32);
|
||||
walk(&cell.blocks, prefix, out, depth + 1);
|
||||
prefix.pop();
|
||||
prefix.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
prefix.pop();
|
||||
}
|
||||
}
|
||||
|
||||
/// [`walk`] for the paragraphs from path `a` to `b` inclusive, skipping the blocks outside.
|
||||
pub(crate) fn walk_between(bl: &Blocks, prefix: &mut Vec<u32>, out: &mut Vec<Path>, depth: usize, (a, b): (&[u32], &[u32])) {
|
||||
use std::cmp::Ordering::{Greater, Less};
|
||||
if depth > PARA_WALK_DEPTH {
|
||||
return;
|
||||
}
|
||||
// Where the paths starting with `q` lie against `x`: all before, all after, or either.
|
||||
let side = |q: &[u32], x: &[u32]| {
|
||||
let m = q.len().min(x.len());
|
||||
q[..m].cmp(&x[..m])
|
||||
};
|
||||
for (i, blk) in bl.iter().enumerate() {
|
||||
prefix.push(i as u32);
|
||||
if side(prefix, b) == Greater {
|
||||
prefix.pop();
|
||||
break;
|
||||
}
|
||||
if side(prefix, a) != Less {
|
||||
match &**blk {
|
||||
Block::Para(_) => {
|
||||
if prefix.as_slice() >= a && prefix.as_slice() <= b {
|
||||
out.push(Path(prefix.clone()));
|
||||
}
|
||||
}
|
||||
Block::Table(t) => {
|
||||
for (r, row) in t.rows.iter().enumerate() {
|
||||
for (c, cell) in row.cells.iter().enumerate() {
|
||||
prefix.push(r as u32);
|
||||
prefix.push(c as u32);
|
||||
if side(prefix, a) != Less && side(prefix, b) != Greater {
|
||||
walk_between(&cell.blocks, prefix, out, depth + 1, (a, b));
|
||||
}
|
||||
prefix.pop();
|
||||
prefix.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
prefix.pop();
|
||||
}
|
||||
}
|
||||
|
||||
/// Which text of a paragraph [`text_of`] collects.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) enum TextMode {
|
||||
/// Every run, tracked deletions included ([`Paragraph::plain_text`]).
|
||||
Plain,
|
||||
/// As shown on screen ([`Paragraph::shown_text`]).
|
||||
Shown,
|
||||
/// Without tracked deletions ([`Paragraph::final_text`]).
|
||||
Final,
|
||||
}
|
||||
|
||||
pub(crate) fn text_of(bl: &Blocks, out: &mut String, depth: usize, mode: TextMode) {
|
||||
if depth > 16 {
|
||||
return;
|
||||
}
|
||||
for b in bl {
|
||||
match &**b {
|
||||
Block::Para(p) => {
|
||||
out.push_str(&match mode {
|
||||
TextMode::Plain => p.plain_text(),
|
||||
TextMode::Shown => p.shown_text(),
|
||||
TextMode::Final => p.final_text(),
|
||||
});
|
||||
out.push('\n');
|
||||
}
|
||||
Block::Table(t) => {
|
||||
for row in &t.rows {
|
||||
let mut cells = Vec::new();
|
||||
for c in &row.cells {
|
||||
let mut s = String::new();
|
||||
text_of(&c.blocks, &mut s, depth + 1, mode);
|
||||
cells.push(s.trim_end_matches('\n').replace('\n', " "));
|
||||
}
|
||||
out.push_str(&cells.join("\t"));
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn story_word_count_of(
|
||||
bl: &Blocks,
|
||||
numbering: &Numbering,
|
||||
styles: &styles::StyleSheet,
|
||||
counters: &mut numbering::Counters,
|
||||
depth: usize,
|
||||
) -> usize {
|
||||
if depth > 16 {
|
||||
return 0;
|
||||
}
|
||||
let mut total = 0usize;
|
||||
for b in bl {
|
||||
match &**b {
|
||||
Block::Para(p) => {
|
||||
let n = p.props.numbering.or_else(|| styles.resolve_para(&p.props).numbering).filter(|n| n.num != 0);
|
||||
if let Some(n) = n
|
||||
&& let Some((label, def)) = counters.next_label(numbering, n.num, n.level)
|
||||
&& !matches!(def.format, section::NumFormat::Bullet | section::NumFormat::None)
|
||||
{
|
||||
total = total.saturating_add(count_words(&label));
|
||||
}
|
||||
total = total.saturating_add(count_words(&p.plain_text()));
|
||||
}
|
||||
Block::Table(t) => {
|
||||
for row in &t.rows {
|
||||
for c in &row.cells {
|
||||
total = total.saturating_add(story_word_count_of(&c.blocks, numbering, styles, counters, depth + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
@@ -20,5 +20,6 @@ log = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
wordcraft-doc = { workspace = true, features = ["test-support"] }
|
||||
proptest = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -295,8 +295,13 @@ impl Cipher {
|
||||
if hflags & H_EXTERNAL != 0 {
|
||||
return Err(unsupported("the document uses an external encryption provider"));
|
||||
}
|
||||
// [MS-OFFCRYPTO] defines AES only for the encrypted packages of Word 2007 and later
|
||||
// (which open through the .docx reader whatever their extension); a Word 97-2003 file
|
||||
// may only use XOR, RC4 or RC4 CryptoAPI, so there is no public scheme to follow here.
|
||||
if hflags & H_AES != 0 {
|
||||
return Err(unsupported("AES-encrypted .doc files are not supported"));
|
||||
return Err(unsupported(
|
||||
"this Word 97-2003 file claims AES encryption, which is only defined for the newer encrypted-package format; it can't be opened",
|
||||
));
|
||||
}
|
||||
if hflags & H_CRYPTO_API == 0 {
|
||||
return Err(unsupported("unknown encryption header"));
|
||||
|
||||
@@ -81,12 +81,18 @@ pub(crate) fn anchors(
|
||||
) -> Vec<(u32, InlineObject)> {
|
||||
let Some(pair) = pair else { return Vec::new() };
|
||||
let mut out = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for (cp, spa) in plc(table, pair, CB_SPA) {
|
||||
let i32_at = |o: usize| spa.get(o..o + 4).and_then(|b| b.try_into().ok()).map(i32::from_le_bytes).unwrap_or(0);
|
||||
let spid = i32_at(0) as u32;
|
||||
let [xl, yt, xr, yb] = [4, 8, 12, 16].map(i32_at);
|
||||
let flags = spa.get(20..22).map(|b| u16::from_le_bytes([b[0], b[1]])).unwrap_or(0);
|
||||
let Some(node) = shapes.get(&spid) else { continue };
|
||||
// Word anchors each shape once. A repeated spid would build another copy of the shape,
|
||||
// and of every member of a group, per anchor (#3710).
|
||||
if !seen.insert(spid) {
|
||||
continue;
|
||||
}
|
||||
let tw = |v: i64| (v as f32 / 20.0).clamp(-2.0 * MAX_PT, 2.0 * MAX_PT);
|
||||
let (mut x, mut y) = (tw(xl as i64), tw(yt as i64));
|
||||
let (mut w, mut h) = (tw(xr as i64 - xl as i64).clamp(0.0, MAX_PT), tw(yb as i64 - yt as i64).clamp(0.0, MAX_PT));
|
||||
|
||||
+191
-29
@@ -49,6 +49,91 @@ const MAX_PARTS: usize = 50_000;
|
||||
/// headers, notes, text boxes), whatever the file's size. The working budget is tied to the
|
||||
/// WordDocument stream (see [`read`]); this caps even a maximal stream.
|
||||
const MAX_TOTAL_CHARS: u64 = 64 << 20;
|
||||
/// Bytes of repeated work per document beyond the text itself: piece Prm grpprls (Prcs, up
|
||||
/// to 64 KB each) interpreted (#3692), hyperlink targets (#3708), picture alt text (#3709)
|
||||
/// and floating objects (#3710) copied into every run or anchor. Past it Prms are skipped,
|
||||
/// copied strings left empty and floating objects dropped, each kind logged once a document
|
||||
/// ([`Work::log`]).
|
||||
const MAX_WORK: u64 = 64 << 20;
|
||||
|
||||
/// What the [`MAX_WORK`] budget pays for, and leaves out once it is spent.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Cost {
|
||||
/// A piece's Prm grpprl: past the budget the piece keeps its CHPX formatting only.
|
||||
Formatting,
|
||||
/// A hyperlink target copied into a run of its result: past it the text is kept, unlinked.
|
||||
LinkTarget,
|
||||
/// A picture's alt text copied into an anchor: past it the picture has none.
|
||||
AltText,
|
||||
/// A floating object copied into its anchor: past it the object is left out.
|
||||
FloatingObject,
|
||||
}
|
||||
|
||||
impl Cost {
|
||||
const ALL: [Cost; 4] = [Cost::Formatting, Cost::LinkTarget, Cost::AltText, Cost::FloatingObject];
|
||||
|
||||
/// What is left out, for the log.
|
||||
fn left_out(self) -> &'static str {
|
||||
match self {
|
||||
Cost::Formatting => "some text keeps only part of its formatting",
|
||||
Cost::LinkTarget => "some hyperlinks' text is kept without its link",
|
||||
Cost::AltText => "some pictures are left without their alt text",
|
||||
Cost::FloatingObject => "some floating shapes and pictures are left out",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One document's [`MAX_WORK`] budget, and what it has left out (each kind logged once when
|
||||
/// the document has been read, [`Work::log`]).
|
||||
struct Work {
|
||||
left: std::cell::Cell<u64>,
|
||||
dropped: std::cell::Cell<[bool; 4]>,
|
||||
}
|
||||
|
||||
impl Work {
|
||||
fn new(budget: u64) -> Self {
|
||||
Work { left: std::cell::Cell::new(budget), dropped: std::cell::Cell::new([false; 4]) }
|
||||
}
|
||||
|
||||
/// Spends `cost` bytes on `what`: false (and `what` noted as left out) when they aren't left.
|
||||
fn charge(&self, cost: u64, what: Cost) -> bool {
|
||||
match self.left.get().checked_sub(cost) {
|
||||
Some(left) => {
|
||||
self.left.set(left);
|
||||
true
|
||||
}
|
||||
None => {
|
||||
let mut dropped = self.dropped.get();
|
||||
if let Some(d) = Cost::ALL.iter().position(|c| *c == what).and_then(|i| dropped.get_mut(i)) {
|
||||
*d = true;
|
||||
}
|
||||
self.dropped.set(dropped);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The kinds of work left out so far, in [`Cost::ALL`] order.
|
||||
fn dropped(&self) -> Vec<Cost> {
|
||||
Cost::ALL.iter().zip(self.dropped.get()).filter(|(_, d)| *d).map(|(c, _)| *c).collect()
|
||||
}
|
||||
|
||||
/// Logs one warning for each kind left out.
|
||||
fn log(&self) {
|
||||
let dropped = self.dropped();
|
||||
for c in &dropped {
|
||||
log::warn!(".doc: the document repeats more than WordCraft copies ({MAX_WORK} bytes): {}", c.left_out());
|
||||
}
|
||||
#[cfg(test)]
|
||||
LAST_DROPPED.with(|d| *d.borrow_mut() = dropped);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// What the last document read on this thread left out for the work budget ([`Work::log`]).
|
||||
static LAST_DROPPED: std::cell::RefCell<Vec<Cost>> = const { std::cell::RefCell::new(Vec::new()) };
|
||||
}
|
||||
/// `sprmCPicLocation`: the fc of a picture in the Data stream.
|
||||
const C_PIC_LOCATION: u16 = 0x6A03;
|
||||
/// `sprmCSymbol`: the record font and character of a symbol character ([MS-DOC] §2.6.1); the
|
||||
@@ -88,10 +173,16 @@ pub enum DocbinError {
|
||||
/// Is this `.doc` password-protected (XOR obfuscation, RC4 or RC4 CryptoAPI)? False for
|
||||
/// anything that is not a readable Word 97-2003 file.
|
||||
pub fn is_encrypted(bytes: &[u8]) -> bool {
|
||||
let Ok(mut comp) = cfb::CompoundFile::open(std::io::Cursor::new(bytes)) else { return false };
|
||||
let Ok(mut comp) = open_container(bytes) else { return false };
|
||||
stream(&mut comp, "WordDocument").is_some_and(|w| fib::peek_encryption(&w).is_some())
|
||||
}
|
||||
|
||||
/// Open the compound file around a `.doc`, after [`wordcraft_doc::cfb_guard::check`].
|
||||
fn open_container(bytes: &[u8]) -> Result<cfb::CompoundFile<std::io::Cursor<&[u8]>>, DocbinError> {
|
||||
wordcraft_doc::cfb_guard::check(bytes).map_err(DocbinError::Container)?;
|
||||
cfb::CompoundFile::open(std::io::Cursor::new(bytes)).map_err(|e| DocbinError::Container(e.to_string()))
|
||||
}
|
||||
|
||||
/// Read a `.doc` (Word 97-2003 binary) file into a [`Document`]. A password-protected file
|
||||
/// gives [`DocbinError::PasswordRequired`]; see [`read_with_password`].
|
||||
pub fn read(bytes: &[u8]) -> Result<Document, DocbinError> {
|
||||
@@ -102,7 +193,7 @@ pub fn read(bytes: &[u8]) -> Result<Document, DocbinError> {
|
||||
/// verifier ([`DocbinError::WrongPassword`] when it does not match) and then decrypted. The
|
||||
/// password is ignored for an unprotected file.
|
||||
pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Document, DocbinError> {
|
||||
let mut comp = cfb::CompoundFile::open(std::io::Cursor::new(bytes)).map_err(|e| DocbinError::Container(e.to_string()))?;
|
||||
let mut comp = open_container(bytes)?;
|
||||
let mut word = stream(&mut comp, "WordDocument").ok_or_else(|| DocbinError::NotWord("no WordDocument stream".into()))?;
|
||||
if word.len() as u64 > MAX_STREAM {
|
||||
return Err(DocbinError::Limit(format!("WordDocument stream is {} bytes", word.len())));
|
||||
@@ -168,13 +259,14 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
|
||||
let ftn_base = fib.ccp.text;
|
||||
let hdd_base = fib.ccp.text.saturating_add(fib.ccp.ftn);
|
||||
let edn_base = hdd_base.saturating_add(fib.ccp.hdd).saturating_add(fib.ccp.atn);
|
||||
let mut pics = media::Pictures::default();
|
||||
let mut pics = media::Pictures::for_input(word.len().saturating_add(data.len()));
|
||||
// Every materialised character comes from at least one byte of the WordDocument stream, so a
|
||||
// legitimate document's total text (body plus every header, footnote and text box, each read
|
||||
// once) cannot exceed its stream. A hostile piece or story table that aliases the same bytes
|
||||
// many times can, so the walk is held to twice the stream's size — generous for real files,
|
||||
// tight against aliasing — and never past the absolute ceiling.
|
||||
let budget = std::cell::Cell::new((word.len() as u64).saturating_mul(2).min(MAX_TOTAL_CHARS));
|
||||
let work = Work::new(MAX_WORK);
|
||||
|
||||
let ctx = WalkCtx {
|
||||
word: &word,
|
||||
@@ -190,6 +282,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
|
||||
drawn: &[],
|
||||
sect_ends: &[],
|
||||
budget: &budget,
|
||||
work: &work,
|
||||
};
|
||||
|
||||
// Drawings: text box stories become parts first, then the floating shapes and pictures
|
||||
@@ -298,6 +391,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
|
||||
doc.media.extend(pics.media);
|
||||
doc.body = table::assemble(paras);
|
||||
doc.ensure_nonempty();
|
||||
work.log();
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
@@ -334,6 +428,8 @@ struct WalkCtx<'a> {
|
||||
/// footnotes, text boxes) are hostile and can each span a whole subdocument, so thousands of
|
||||
/// them could otherwise materialise the same text thousands of times; this bounds the total.
|
||||
budget: &'a std::cell::Cell<u64>,
|
||||
/// Repeated-work bytes left ([`MAX_WORK`]), and what they left out.
|
||||
work: &'a Work,
|
||||
}
|
||||
|
||||
/// State while assembling one paragraph from per-CP formatting.
|
||||
@@ -365,6 +461,14 @@ impl ParaBuild {
|
||||
}
|
||||
self.text.push(c);
|
||||
}
|
||||
|
||||
/// Append `c` to the current run, whose properties the caller knows it matches.
|
||||
fn extend(&mut self, c: char) {
|
||||
if let Some(r) = self.runs.last_mut() {
|
||||
r.len += c.len_utf8();
|
||||
}
|
||||
self.text.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk a CP range, splitting paragraphs at 0x0D/0x07 marks, applying the direct character
|
||||
@@ -376,7 +480,7 @@ impl ParaBuild {
|
||||
/// The range is clamped to the last piece's CP, and unreadable stretches (gaps, truncated
|
||||
/// pieces) are skipped a whole piece at a time, so the work is bounded by the text that
|
||||
/// really exists, not by the CP numbers a file claims.
|
||||
fn walk(ctx: &WalkCtx, cp_start: u32, cp_end: u32, pics: &mut media::Pictures) -> Vec<ParaOut> {
|
||||
fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pictures) -> Vec<ParaOut> {
|
||||
let cp_end = cp_end.min(ctx.pieces.cp_end());
|
||||
let mut out = Vec::new();
|
||||
let mut pb = ParaBuild::default();
|
||||
@@ -385,6 +489,7 @@ fn walk(ctx: &WalkCtx, cp_start: u32, cp_end: u32, pics: &mut media::Pictures) -
|
||||
// Field assembly state: instruction characters are swallowed, the result is kept until
|
||||
// the end character, then emitted as one object (hyperlinks keep their result text).
|
||||
let mut field: Option<FieldBuild> = None;
|
||||
let mut memo: Option<FmtMemo<'a>> = None;
|
||||
while cp < cp_end {
|
||||
// Bookmark boundaries at this CP (main-document walks only; subdocument walks get
|
||||
// an empty table and `cp` never matches the main-document CPs).
|
||||
@@ -490,7 +595,7 @@ fn walk(ctx: &WalkCtx, cp_start: u32, cp_end: u32, pics: &mut media::Pictures) -
|
||||
f.depth -= 1;
|
||||
field = Some(f);
|
||||
} else {
|
||||
finish_field(&mut pb, f);
|
||||
finish_field(&mut pb, f, ctx.work);
|
||||
}
|
||||
}
|
||||
cp = cp.saturating_add(1);
|
||||
@@ -502,12 +607,15 @@ fn walk(ctx: &WalkCtx, cp_start: u32, cp_end: u32, pics: &mut media::Pictures) -
|
||||
let fc_pic = pic_location(ctx.chpx_bins.chpx(ctx.word, fc));
|
||||
match fc_pic.and_then(|at| pics.get(ctx.data, at)) {
|
||||
Some(p) => {
|
||||
// Every anchor of a picture gets its own copy of the alt text; the
|
||||
// copies are charged to the work budget (#3709).
|
||||
let alt = if ctx.work.charge(p.alt.len() as u64, Cost::AltText) { p.alt.clone() } else { String::new() };
|
||||
pb.push('\u{FFFC}', CharProps::default());
|
||||
pb.objects.push(InlineObject::Image {
|
||||
media: p.key,
|
||||
media: p.key.clone(),
|
||||
w: p.w,
|
||||
h: p.h,
|
||||
alt: p.alt,
|
||||
alt,
|
||||
float: Default::default(),
|
||||
crop: p.crop,
|
||||
ole: None,
|
||||
@@ -519,7 +627,12 @@ fn walk(ctx: &WalkCtx, cp_start: u32, cp_end: u32, pics: &mut media::Pictures) -
|
||||
'\u{8}' => {
|
||||
// Drawn object anchor: the floating shape or picture anchored at this CP.
|
||||
let at = ctx.drawn.partition_point(|(c, _)| *c < cp);
|
||||
// Each copy (a story can be walked more than once) is charged to the work budget
|
||||
// by what a clone copies: a picture's alt text, a group with all its members (#3710).
|
||||
for (_, obj) in ctx.drawn.get(at..).unwrap_or_default().iter().take_while(|(c, _)| *c == cp) {
|
||||
if !ctx.work.charge(object_cost(obj), Cost::FloatingObject) {
|
||||
continue;
|
||||
}
|
||||
pb.push('\u{FFFC}', CharProps::default());
|
||||
pb.objects.push(obj.clone());
|
||||
}
|
||||
@@ -532,16 +645,16 @@ fn walk(ctx: &WalkCtx, cp_start: u32, cp_end: u32, pics: &mut media::Pictures) -
|
||||
pb.objects.push(InlineObject::NoteRef { kind: *kind, id: *id, custom: String::new() });
|
||||
}
|
||||
}
|
||||
'\t' => push_formatted(&mut pb, cp, '\t', ctx, field.as_mut()),
|
||||
'\u{B}' => push_formatted(&mut pb, cp, '\n', ctx, field.as_mut()),
|
||||
'\u{C}' => push_formatted(&mut pb, cp, '\u{C}', ctx, field.as_mut()),
|
||||
'\u{E}' => push_formatted(&mut pb, cp, '\u{E}', ctx, field.as_mut()),
|
||||
'\u{1E}' => push_formatted(&mut pb, cp, '\u{2011}', ctx, field.as_mut()),
|
||||
'\u{1F}' => push_formatted(&mut pb, cp, '\u{AD}', ctx, field.as_mut()),
|
||||
'\t' => push_formatted(&mut pb, cp, '\t', ctx, field.as_mut(), &mut memo),
|
||||
'\u{B}' => push_formatted(&mut pb, cp, '\n', ctx, field.as_mut(), &mut memo),
|
||||
'\u{C}' => push_formatted(&mut pb, cp, '\u{C}', ctx, field.as_mut(), &mut memo),
|
||||
'\u{E}' => push_formatted(&mut pb, cp, '\u{E}', ctx, field.as_mut(), &mut memo),
|
||||
'\u{1E}' => push_formatted(&mut pb, cp, '\u{2011}', ctx, field.as_mut(), &mut memo),
|
||||
'\u{1F}' => push_formatted(&mut pb, cp, '\u{AD}', ctx, field.as_mut(), &mut memo),
|
||||
// Object anchors, comment references and other special characters are dropped
|
||||
// (or handled by later passes).
|
||||
c if (c as u32) < 0x20 => {}
|
||||
c => push_formatted(&mut pb, cp, c, ctx, field.as_mut()),
|
||||
c => push_formatted(&mut pb, cp, c, ctx, field.as_mut(), &mut memo),
|
||||
}
|
||||
cp = cp.saturating_add(units);
|
||||
}
|
||||
@@ -564,19 +677,44 @@ struct FieldBuild {
|
||||
sep: Option<Vec<(char, CharProps)>>,
|
||||
}
|
||||
|
||||
/// Bytes a clone of `obj` copies: the object itself plus a picture's alt text, a group with all
|
||||
/// its members (#3710).
|
||||
fn object_cost(obj: &InlineObject) -> u64 {
|
||||
let shallow = std::mem::size_of::<InlineObject>() as u64;
|
||||
match obj {
|
||||
InlineObject::Image { alt, .. } => shallow.saturating_add(alt.len() as u64),
|
||||
InlineObject::Group { children, .. } => children.iter().fold(shallow, |n, c| n.saturating_add(object_cost(&c.obj))),
|
||||
_ => shallow,
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit a finished field: HYPERLINK keeps its result text as a linked run; every other
|
||||
/// field becomes one inline object with its cached result.
|
||||
fn finish_field(pb: &mut ParaBuild, f: FieldBuild) {
|
||||
///
|
||||
/// The target is copied once per run of equally formatted result text, not per character,
|
||||
/// and each copy is charged to `work` ([`MAX_WORK`]); once that is spent the rest of the
|
||||
/// result is kept as plain text (#3708).
|
||||
fn finish_field(pb: &mut ParaBuild, f: FieldBuild, work: &Work) {
|
||||
let instr = f.instr.trim().to_string();
|
||||
let result = f.sep.unwrap_or_default();
|
||||
let result_text: String = result.iter().map(|(c, _)| *c).collect();
|
||||
if let Some(target) = hyperlink_target(&instr) {
|
||||
for (c, mut props) in result {
|
||||
props.link = Some(target.clone());
|
||||
pb.push(c, props);
|
||||
let cost = target.len() as u64;
|
||||
let mut run: Option<CharProps> = None;
|
||||
for (c, props) in result {
|
||||
if run.as_ref() == Some(&props) {
|
||||
pb.extend(c);
|
||||
continue;
|
||||
}
|
||||
let mut linked = props.clone();
|
||||
if work.charge(cost, Cost::LinkTarget) {
|
||||
linked.link = Some(target.clone());
|
||||
}
|
||||
pb.push(c, linked);
|
||||
run = Some(props);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let result_text: String = result.iter().map(|(c, _)| *c).collect();
|
||||
pb.push('\u{FFFC}', CharProps::default());
|
||||
pb.objects.push(InlineObject::Field { instr, result: result_text, locked: false, dirty: false, code: false, form: String::new() });
|
||||
}
|
||||
@@ -702,19 +840,28 @@ fn num_of(ilfo: i32) -> Option<u32> {
|
||||
/// plus the piece's Prm, over the paragraph style's base character props. Inside a field,
|
||||
/// the character is recorded into the instruction or the cached result instead of (or as
|
||||
/// well as) the visible text.
|
||||
fn push_formatted(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx, field: Option<&mut FieldBuild>) {
|
||||
fn push_formatted<'a>(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx<'a>, field: Option<&mut FieldBuild>, memo: &mut Option<FmtMemo<'a>>) {
|
||||
let fc = ctx.pieces.fc_of_cp(cp).unwrap_or(0);
|
||||
let grpprl = ctx.chpx_bins.chpx(ctx.word, fc);
|
||||
let default;
|
||||
let base = match pb.props.style.as_deref().and_then(|id| ctx.sheet.get(id)) {
|
||||
Some(st) => &st.chr,
|
||||
let prm = ctx.pieces.prm_of(cp);
|
||||
let style = pb.props.style.as_deref();
|
||||
let mut props = match memo.as_ref().filter(|m| std::ptr::eq(m.chpx, grpprl) && m.prm == prm && m.style.as_deref() == style) {
|
||||
Some(m) => m.props.clone(),
|
||||
None => {
|
||||
default = CharProps::default();
|
||||
&default
|
||||
let default;
|
||||
let base = match style.and_then(|id| ctx.sheet.get(id)) {
|
||||
Some(st) => &st.chr,
|
||||
None => {
|
||||
default = CharProps::default();
|
||||
&default
|
||||
}
|
||||
};
|
||||
let mut props = char_props(grpprl, ctx.fonts, base);
|
||||
apply_prm(&mut props, ctx.pieces.piece_prm(cp), ctx.fonts, base, ctx.work);
|
||||
*memo = Some(FmtMemo { chpx: grpprl, prm, style: style.map(str::to_owned), props: props.clone() });
|
||||
props
|
||||
}
|
||||
};
|
||||
let mut props = char_props(grpprl, ctx.fonts, base);
|
||||
apply_prm(&mut props, ctx.pieces.piece_prm(cp), ctx.fonts, base);
|
||||
// A symbol character (Insert › Symbol) is stored as the placeholder U+0028 with
|
||||
// sprmCSymbol carrying the font and the character code ([MS-DOC] §2.6.1).
|
||||
let mut c = c;
|
||||
@@ -739,8 +886,20 @@ fn push_formatted(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx, field: Op
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a piece's Prm (Prm0 short sprm or Prm1 grpprl) to character properties.
|
||||
fn apply_prm(props: &mut CharProps, prm: PrmRef<'_>, fonts: &[String], base: &CharProps) {
|
||||
/// The last character formatting resolved, reused while the CHPX, the piece's Prm and the
|
||||
/// paragraph style stay the same, so a Prm grpprl (up to 64 KB) is interpreted once per
|
||||
/// formatting change, not once per character (#3692).
|
||||
struct FmtMemo<'a> {
|
||||
/// The CHPX grpprl, compared by address: it is a slice of the WordDocument stream.
|
||||
chpx: &'a [u8],
|
||||
prm: u16,
|
||||
style: Option<String>,
|
||||
props: CharProps,
|
||||
}
|
||||
|
||||
/// Apply a piece's Prm (Prm0 short sprm or Prm1 grpprl) to character properties. A Prm1
|
||||
/// grpprl is charged to `work`; once that is spent the grpprl is skipped.
|
||||
fn apply_prm(props: &mut CharProps, prm: PrmRef<'_>, fonts: &[String], base: &CharProps, work: &Work) {
|
||||
match prm {
|
||||
PrmRef::None => {}
|
||||
PrmRef::Sprm0 { isprm, val } => {
|
||||
@@ -753,6 +912,9 @@ fn apply_prm(props: &mut CharProps, prm: PrmRef<'_>, fonts: &[String], base: &Ch
|
||||
}
|
||||
}
|
||||
PrmRef::Grpprl(Some(g)) => {
|
||||
if !work.charge(g.len() as u64, Cost::Formatting) {
|
||||
return;
|
||||
}
|
||||
for prl in sprm::iter(g) {
|
||||
if prl.sgc() == 2 {
|
||||
fmt::apply_char(props, &prl, base, fonts);
|
||||
|
||||
+68
-26
@@ -17,6 +17,24 @@ const OFF_PICMID: usize = 28;
|
||||
const MAX_PICTURES: usize = 5_000;
|
||||
/// Most picture bytes one document copies out of the Data stream, all pictures together.
|
||||
pub(crate) const MAX_MEDIA_BYTES: usize = 256 << 20;
|
||||
/// Most bytes one document decodes pictures into (metafiles inflated, DIB pixels), charged
|
||||
/// per attempt whether it succeeds or not: retained media is capped separately, by
|
||||
/// `MAX_MEDIA_BYTES` (#3711, #3712).
|
||||
const MAX_DECODE_WORK: usize = 512 << 20;
|
||||
/// The decode budget grows with the input: up to this many bytes per byte of the WordDocument
|
||||
/// and Data streams. An uncompressed DIB declares at most 32 bytes of pixels per file byte (1
|
||||
/// bit per pixel, 4 bytes charged each), so 64 passes any such picture. RLE8/RLE4 bitmaps
|
||||
/// reach about 510 times that and rely on the `MAX_DECODE_WORK` floor instead; a decompression
|
||||
/// bomb (ratios around 1e6) still hits the cap.
|
||||
const DECODE_WORK_PER_INPUT_BYTE: usize = 64;
|
||||
/// The most the decode budget grows to, whatever the input.
|
||||
const MAX_DECODE_WORK_CEILING: usize = 2 << 30;
|
||||
|
||||
/// The decode budget for a document whose pictures live in `input` stream bytes, never less
|
||||
/// than `floor`.
|
||||
pub(crate) fn decode_work(input: usize, floor: usize) -> usize {
|
||||
input.saturating_mul(DECODE_WORK_PER_INPUT_BYTE).clamp(floor, floor.max(MAX_DECODE_WORK_CEILING))
|
||||
}
|
||||
|
||||
/// The document's pictures, keyed by their location in the Data stream: anchors that share a
|
||||
/// `fc_pic` share one media entry (or one failure), and the total bytes copied are capped, so a
|
||||
@@ -28,11 +46,17 @@ pub(crate) struct Pictures {
|
||||
by_fc: HashMap<u32, Option<Inline>>,
|
||||
/// BLIP store index → media key (`None`: unreadable, not retried).
|
||||
by_bse: HashMap<u32, Option<String>>,
|
||||
/// (stream, foDelay, record length) of a BLIP record outside the store → media key: store
|
||||
/// entries and inline pictures that alias one record share one decode (`None`: failed, not
|
||||
/// retried). The stream is told apart by its address.
|
||||
by_delay: HashMap<(usize, usize, usize), Option<String>>,
|
||||
/// The media entries, merged into the document at the end.
|
||||
pub(crate) media: BTreeMap<String, Arc<Vec<u8>>>,
|
||||
total: usize,
|
||||
/// The byte cap (`MAX_MEDIA_BYTES`; smaller in tests).
|
||||
pub(crate) limit: usize,
|
||||
/// Decode work left (`MAX_DECODE_WORK`; smaller in tests).
|
||||
pub(crate) work: officeart::Decode,
|
||||
/// Anchors whose picture was dropped (only the first few are logged).
|
||||
dropped: usize,
|
||||
/// Pictures kept as placeholders (formats we can't draw).
|
||||
@@ -44,9 +68,11 @@ impl Default for Pictures {
|
||||
Pictures {
|
||||
by_fc: HashMap::new(),
|
||||
by_bse: HashMap::new(),
|
||||
by_delay: HashMap::new(),
|
||||
media: BTreeMap::new(),
|
||||
total: 0,
|
||||
limit: MAX_MEDIA_BYTES,
|
||||
work: officeart::Decode::new(MAX_DECODE_WORK),
|
||||
dropped: 0,
|
||||
placeholders: 0,
|
||||
}
|
||||
@@ -64,19 +90,24 @@ pub(crate) struct Inline {
|
||||
}
|
||||
|
||||
impl Pictures {
|
||||
/// An empty store whose decode budget scales with the `input` stream bytes (WordDocument
|
||||
/// plus Data) the pictures can come from.
|
||||
pub(crate) fn for_input(input: usize) -> Self {
|
||||
Pictures { work: officeart::Decode::new(decode_work(input, MAX_DECODE_WORK)), ..Pictures::default() }
|
||||
}
|
||||
|
||||
/// The inline picture at `fc_pic` in the Data stream, copying its bytes the first time.
|
||||
/// `None` when the header is malformed, the picture can't be read, or a picture
|
||||
/// count/byte cap is reached.
|
||||
pub(crate) fn get(&mut self, data: &[u8], fc_pic: u32) -> Option<Inline> {
|
||||
if let Some(hit) = self.by_fc.get(&fc_pic) {
|
||||
return hit.clone();
|
||||
pub(crate) fn get(&mut self, data: &[u8], fc_pic: u32) -> Option<&Inline> {
|
||||
if !self.by_fc.contains_key(&fc_pic) {
|
||||
if self.by_fc.len().saturating_add(self.by_bse.len()) >= MAX_PICTURES {
|
||||
return None;
|
||||
}
|
||||
let pic = self.read(data, fc_pic);
|
||||
self.by_fc.insert(fc_pic, pic);
|
||||
}
|
||||
if self.by_fc.len().saturating_add(self.by_bse.len()) >= MAX_PICTURES {
|
||||
return None;
|
||||
}
|
||||
let pic = self.read(data, fc_pic);
|
||||
self.by_fc.insert(fc_pic, pic.clone());
|
||||
pic
|
||||
self.by_fc.get(&fc_pic)?.as_ref()
|
||||
}
|
||||
|
||||
/// Read the inline picture at `fc_pic`: nothing is decoded or copied beyond what is left of
|
||||
@@ -86,16 +117,13 @@ impl Pictures {
|
||||
if room == 0 {
|
||||
return None;
|
||||
}
|
||||
match locate(data, fc_pic, room)? {
|
||||
match locate(data, fc_pic, self)? {
|
||||
Found::Range(pic) => {
|
||||
let bytes = data.get(pic.range.clone()).filter(|b| b.len() <= room)?.to_vec();
|
||||
let key = self.insert(bytes, pic.ext, false)?;
|
||||
Some(Inline { key, w: pic.w, h: pic.h, crop: [0.0; 4], alt: String::new() })
|
||||
}
|
||||
Found::Shape { w, h, blip, props } => {
|
||||
let key = self.insert(blip.bytes, blip.ext, blip.placeholder)?;
|
||||
Some(Inline { key, w, h, crop: props.crop, alt: props.alt })
|
||||
}
|
||||
Found::Shape { w, h, key, props } => Some(Inline { key: key?, w, h, crop: props.crop, alt: props.alt }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,12 +137,26 @@ impl Pictures {
|
||||
return None;
|
||||
}
|
||||
let fbse = bstore.get((pib as usize).checked_sub(1)?)?;
|
||||
let room = self.limit.saturating_sub(self.total);
|
||||
let key = officeart::fbse_blip(fbse, delay, room).and_then(|b| self.insert(b.bytes, b.ext, b.placeholder));
|
||||
let key = self.decode_bse(fbse, delay);
|
||||
self.by_bse.insert(pib, key.clone());
|
||||
key
|
||||
}
|
||||
|
||||
/// Decode a store entry's BLIP, once per delay-stream record however many entries name it.
|
||||
fn decode_bse(&mut self, fbse: &[u8], delay: &[u8]) -> Option<String> {
|
||||
let (rec, at) = officeart::fbse_record(fbse, delay)?;
|
||||
let at = at.map(|(fo, used)| (delay.as_ptr() as usize, fo, used));
|
||||
if let Some(hit) = at.and_then(|at| self.by_delay.get(&at)) {
|
||||
return hit.clone();
|
||||
}
|
||||
let room = self.limit.saturating_sub(self.total);
|
||||
let key = officeart::blip(&rec, room, &mut self.work).and_then(|b| self.insert(b.bytes, b.ext, b.placeholder));
|
||||
if let Some(at) = at {
|
||||
self.by_delay.insert(at, key.clone());
|
||||
}
|
||||
key
|
||||
}
|
||||
|
||||
/// Add picture bytes as a new media entry, within the byte cap.
|
||||
fn insert(&mut self, bytes: Vec<u8>, ext: &str, placeholder: bool) -> Option<String> {
|
||||
let total = self.total.checked_add(bytes.len()).filter(|&t| t <= self.limit)?;
|
||||
@@ -151,7 +193,7 @@ struct Located {
|
||||
/// `OfficeArtInlineSpContainer` (Word 97 and later) whose BLIP is decoded.
|
||||
enum Found {
|
||||
Range(Located),
|
||||
Shape { w: f32, h: f32, blip: officeart::Blip, props: officeart::Props },
|
||||
Shape { w: f32, h: f32, key: Option<String>, props: officeart::Props },
|
||||
}
|
||||
|
||||
/// `PICF.mfpf.mm` values of pictures stored as a shape (`MM_SHAPE`) or a shape with a file
|
||||
@@ -160,8 +202,8 @@ const MM_SHAPE: u16 = 0x0064;
|
||||
const MM_SHAPEFILE: u16 = 0x0066;
|
||||
|
||||
/// Locate the picture at `fc_pic` in the Data stream ([MS-DOC] §2.9.192 PICFAndOfficeArtData);
|
||||
/// a shape's picture is decoded only up to `room` bytes.
|
||||
fn locate(data: &[u8], fc_pic: u32, room: usize) -> Option<Found> {
|
||||
/// the BLIP is decoded once per record and charged to the decode budget.
|
||||
fn locate(data: &[u8], fc_pic: u32, pics: &mut Pictures) -> Option<Found> {
|
||||
let base = fc_pic as usize;
|
||||
let pic = data.get(base..)?;
|
||||
let lcb = u32::from_le_bytes(pic.get(0..4)?.try_into().ok()?) as usize;
|
||||
@@ -180,12 +222,12 @@ fn locate(data: &[u8], fc_pic: u32, room: usize) -> Option<Found> {
|
||||
if mm == MM_SHAPEFILE {
|
||||
at = at.checked_add(1 + *pic.get(at)? as usize)?;
|
||||
}
|
||||
let (blip, props) = inline_shape(pic.get(at..end)?, data, room)?;
|
||||
let (key, props) = inline_shape(pic.get(at..end)?, data, pics)?;
|
||||
// The goal size is before cropping; the crop fractions take their share off it.
|
||||
let [cl, ct, cr, cb] = props.crop;
|
||||
let w = (dxa_goal / 20.0 * (1.0 - cl - cr).max(0.01) * scale(mx)).clamp(1.0, 1584.0);
|
||||
let h = (dya_goal / 20.0 * (1.0 - ct - cb).max(0.01) * scale(my)).clamp(1.0, 1584.0);
|
||||
return Some(Found::Shape { w, h, blip, props });
|
||||
return Some(Found::Shape { w, h, key, props });
|
||||
}
|
||||
let w = (dxa_goal / 20.0 * scale(mx)).clamp(1.0, 1584.0);
|
||||
let h = (dya_goal / 20.0 * scale(my)).clamp(1.0, 1584.0);
|
||||
@@ -197,16 +239,16 @@ fn locate(data: &[u8], fc_pic: u32, room: usize) -> Option<Found> {
|
||||
}
|
||||
|
||||
/// An `OfficeArtInlineSpContainer`: the picture shape, then the `OfficeArtFBSE` records its
|
||||
/// `pib` indexes (BLIPs embedded, or at `foDelay` in the Data stream). `room` caps the bytes
|
||||
/// the BLIP decodes to.
|
||||
fn inline_shape(buf: &[u8], data: &[u8], room: usize) -> Option<(officeart::Blip, officeart::Props)> {
|
||||
/// `pib` indexes (BLIPs embedded, or at `foDelay` in the Data stream). The BLIP is decoded
|
||||
/// once per record, however many pictures (and FBSE sizes) name it.
|
||||
fn inline_shape(buf: &[u8], data: &[u8], pics: &mut Pictures) -> Option<(Option<String>, officeart::Props)> {
|
||||
let mut budget = 10_000usize;
|
||||
let recs = officeart::children(buf, &mut budget);
|
||||
let sp = recs.iter().find(|r| r.typ == officeart::rt::SP_CONTAINER).map(|r| officeart::shape(r.body, &mut budget))?;
|
||||
let fbses: Vec<&[u8]> = recs.iter().filter(|r| r.typ == officeart::rt::FBSE).map(|r| r.body).collect();
|
||||
let fbse = sp.props.pib.and_then(|p| fbses.get((p as usize).checked_sub(1)?)).or(fbses.first())?;
|
||||
let blip = officeart::fbse_blip(fbse, data, room)?;
|
||||
Some((blip, sp.props))
|
||||
let key = pics.decode_bse(fbse, data);
|
||||
Some((key, sp.props))
|
||||
}
|
||||
|
||||
/// Offset of the first embedded raster image inside the payload, skipping the small
|
||||
|
||||
@@ -112,23 +112,65 @@ pub(crate) struct Blip {
|
||||
/// The picture of an `OfficeArtFBSE` body: its embedded BLIP, or the BLIP stored at `foDelay`
|
||||
/// in `delay` (the WordDocument stream for the document's BLIP store, the Data stream for
|
||||
/// inline pictures). `limit` caps the bytes produced.
|
||||
pub(crate) fn fbse_blip(fbse: &[u8], delay: &[u8], limit: usize) -> Option<Blip> {
|
||||
#[cfg(test)]
|
||||
pub(crate) fn fbse_blip(fbse: &[u8], delay: &[u8], limit: usize, work: &mut Decode) -> Option<Blip> {
|
||||
let (rec, _) = fbse_record(fbse, delay)?;
|
||||
blip(&rec, limit, work)
|
||||
}
|
||||
|
||||
/// The document's decode budget: bytes of inflating and pixel decoding left, and how many
|
||||
/// attempts it has refused (only the first few are logged).
|
||||
pub(crate) struct Decode {
|
||||
pub(crate) left: usize,
|
||||
refused: usize,
|
||||
}
|
||||
|
||||
impl Decode {
|
||||
pub(crate) fn new(left: usize) -> Self {
|
||||
Decode { left, refused: 0 }
|
||||
}
|
||||
|
||||
/// Take `cost` off the budget; `false` (and a log line) when it doesn't fit.
|
||||
fn charge(&mut self, cost: usize, what: &str) -> bool {
|
||||
match self.left.checked_sub(cost) {
|
||||
Some(left) if cost > 0 => {
|
||||
self.left = left;
|
||||
true
|
||||
}
|
||||
_ => {
|
||||
if self.refused < 10 {
|
||||
log::warn!(
|
||||
"docbin: a {what} needing {cost} bytes of decode work is over the decode budget ({} left) and was not decoded",
|
||||
self.left
|
||||
);
|
||||
}
|
||||
self.refused = self.refused.saturating_add(1);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The BLIP record of an `OfficeArtFBSE`: embedded after it, or in the delay stream, where
|
||||
/// its (`foDelay`, record length) location is returned too (entries can share one record,
|
||||
/// whatever sizes their FBSEs claim: the record's own header is what counts).
|
||||
pub(crate) fn fbse_record<'a>(fbse: &'a [u8], delay: &'a [u8]) -> Option<(Rec<'a>, Option<(usize, usize)>)> {
|
||||
// btWin32, btMacOS, rgbUid[16], tag, size, cRef, foDelay, unused, cbName, unused×2.
|
||||
let size = u32_at(fbse, 20)? as usize;
|
||||
let fo_delay = u32_at(fbse, 28)? as usize;
|
||||
let cb_name = *fbse.get(33)? as usize;
|
||||
let at = 36usize.checked_add(cb_name)?;
|
||||
if let Some((rec, _)) = fbse.get(at..).and_then(record) {
|
||||
return blip(&rec, limit);
|
||||
return Some((rec, None));
|
||||
}
|
||||
// Not embedded: the record sits in the delay stream.
|
||||
let end = fo_delay.checked_add(size.max(8))?.min(delay.len());
|
||||
let (rec, _) = record(delay.get(fo_delay..end)?)?;
|
||||
blip(&rec, limit)
|
||||
let (rec, used) = record(delay.get(fo_delay..)?)?;
|
||||
Some((rec, Some((fo_delay, used))))
|
||||
}
|
||||
|
||||
/// Decode one `OfficeArtBlip*` record ([MS-ODRAW] §2.2.23-§2.2.31).
|
||||
pub(crate) fn blip(rec: &Rec, limit: usize) -> Option<Blip> {
|
||||
/// Decode one `OfficeArtBlip*` record ([MS-ODRAW] §2.2.23-§2.2.31). `work` is the
|
||||
/// document's decode budget: inflating a metafile or decoding a DIB is charged to it before
|
||||
/// it runs, whether it succeeds or not, and nothing is decoded once it is spent.
|
||||
pub(crate) fn blip(rec: &Rec, limit: usize, work: &mut Decode) -> Option<Blip> {
|
||||
// Each type has two instance values; the odd one carries a second 16-byte UID.
|
||||
let uids = if rec.inst & 1 == 1 { 32 } else { 16 };
|
||||
match rec.typ {
|
||||
@@ -141,7 +183,7 @@ pub(crate) fn blip(rec: &Rec, limit: usize) -> Option<Blip> {
|
||||
rt::BLIP_PNG => Some(Blip { bytes: data.to_vec(), ext: "png", placeholder: false }),
|
||||
rt::BLIP_JPEG | rt::BLIP_JPEG_CMYK => Some(Blip { bytes: data.to_vec(), ext: "jpeg", placeholder: false }),
|
||||
rt::BLIP_TIFF => Some(Blip { bytes: data.to_vec(), ext: "tiff", placeholder: false }),
|
||||
_ => dib_to_png(data, limit),
|
||||
_ => dib_to_png(data, limit, work),
|
||||
}
|
||||
}
|
||||
rt::BLIP_EMF | rt::BLIP_WMF | rt::BLIP_PICT => {
|
||||
@@ -155,6 +197,11 @@ pub(crate) fn blip(rec: &Rec, limit: usize) -> Option<Blip> {
|
||||
let cap = limit.min(MAX_METAFILE);
|
||||
let bytes = if compression == 0 {
|
||||
let want = if cb_size > 0 { cb_size.min(cap) } else { cap };
|
||||
// The compressed stream is read whole whatever it inflates to: a record that
|
||||
// claims to inflate to nothing still costs its input (#3871).
|
||||
if !work.charge(want.max(raw.len()), "metafile") {
|
||||
return None;
|
||||
}
|
||||
miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(raw, want).ok()?
|
||||
} else {
|
||||
if raw.len() > cap {
|
||||
@@ -177,8 +224,9 @@ pub(crate) fn blip(rec: &Rec, limit: usize) -> Option<Blip> {
|
||||
|
||||
/// A device-independent bitmap (BITMAPINFOHEADER + palette + pixels) as a PNG: the DIB gets
|
||||
/// the 14-byte BMP file header it lacks, is decoded with size limits and re-encoded. When the
|
||||
/// bitmap can't be decoded the BMP bytes are kept (and render as a grey placeholder).
|
||||
fn dib_to_png(dib: &[u8], limit: usize) -> Option<Blip> {
|
||||
/// bitmap can't be decoded, or its declared pixels (4 bytes each) don't fit in `work`, the
|
||||
/// BMP bytes are kept (and render as a grey placeholder).
|
||||
fn dib_to_png(dib: &[u8], limit: usize, work: &mut Decode) -> Option<Blip> {
|
||||
let header = u32_at(dib, 0)? as usize;
|
||||
if !(12..=124).contains(&header) {
|
||||
return None;
|
||||
@@ -197,12 +245,30 @@ fn dib_to_png(dib: &[u8], limit: usize) -> Option<Blip> {
|
||||
let masks = if header == 40 && (compression == 3 || compression == 6) { if compression == 3 { 12 } else { 16 } } else { 0 };
|
||||
let off_bits = 14usize.checked_add(header)?.checked_add(colors * entry)?.checked_add(masks)?;
|
||||
let file_size = 14usize.checked_add(dib.len()).filter(|&size| size <= limit)?;
|
||||
// The declared size is what decoding allocates, however small the (RLE) data is.
|
||||
let (w, h) = if header == 12 {
|
||||
(u64::from(u16_at(dib, 4)?), u64::from(u16_at(dib, 6)?))
|
||||
} else {
|
||||
(u64::from(u32_at(dib, 4)?), u64::from((u32_at(dib, 8)? as i32).unsigned_abs()))
|
||||
};
|
||||
let cost = w.saturating_mul(h).saturating_mul(4).try_into().unwrap_or(usize::MAX);
|
||||
// Copying the DIB into a BMP is work too, however few pixels it declares: it is charged
|
||||
// before the copy is made (#3871). A bitmap whose pixels don't fit still keeps its BMP
|
||||
// bytes as a placeholder, if the copy fits.
|
||||
let decode = work.charge(cost.max(dib.len()), "bitmap");
|
||||
if !decode && !work.charge(dib.len(), "bitmap") {
|
||||
return None;
|
||||
}
|
||||
let mut bmp = Vec::with_capacity(file_size);
|
||||
bmp.extend_from_slice(b"BM");
|
||||
bmp.extend_from_slice(&u32::try_from(file_size).ok()?.to_le_bytes());
|
||||
bmp.extend_from_slice(&[0, 0, 0, 0]);
|
||||
bmp.extend_from_slice(&u32::try_from(off_bits).ok()?.to_le_bytes());
|
||||
bmp.extend_from_slice(dib);
|
||||
let placeholder = |bmp| Some(Blip { bytes: bmp, ext: "bmp", placeholder: true });
|
||||
if !decode {
|
||||
return placeholder(bmp);
|
||||
}
|
||||
let mut reader = image::ImageReader::new(std::io::Cursor::new(&bmp));
|
||||
reader.set_format(image::ImageFormat::Bmp);
|
||||
let mut limits = image::Limits::default();
|
||||
@@ -217,7 +283,7 @@ fn dib_to_png(dib: &[u8], limit: usize) -> Option<Blip> {
|
||||
{
|
||||
Some(Blip { bytes: png.bytes, ext: "png", placeholder: false })
|
||||
}
|
||||
_ => Some(Blip { bytes: bmp, ext: "bmp", placeholder: true }),
|
||||
_ => placeholder(bmp),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,21 +377,15 @@ impl Props {
|
||||
}
|
||||
0x0104 => self.pib = Some(op),
|
||||
0x0181 => self.fill_color = color(op),
|
||||
0x01BF => {
|
||||
// fFilled (bit 4) is valid when fUsefFilled (bit 20) is set.
|
||||
if op & (1 << 20) != 0 {
|
||||
self.filled = Some(op & (1 << 4) != 0);
|
||||
}
|
||||
}
|
||||
// fFilled (bit 4) is valid when fUsefFilled (bit 20) is set.
|
||||
0x01BF if op & (1 << 20) != 0 => self.filled = Some(op & (1 << 4) != 0),
|
||||
0x01BF => {}
|
||||
0x01C0 => self.line_color = color(op),
|
||||
0x01CB => self.line_width = Some(op),
|
||||
0x01D0 | 0x01D1 => self.arrowhead |= op != 0,
|
||||
0x01FF => {
|
||||
// fLine (bit 3) is valid when fUsefLine (bit 19) is set.
|
||||
if op & (1 << 19) != 0 {
|
||||
self.line = Some(op & (1 << 3) != 0);
|
||||
}
|
||||
}
|
||||
// fLine (bit 3) is valid when fUsefLine (bit 19) is set.
|
||||
0x01FF if op & (1 << 19) != 0 => self.line = Some(op & (1 << 3) != 0),
|
||||
0x01FF => {}
|
||||
0x0384..=0x0387 => self.wrap_dist[(pid - 0x0384) as usize] = Some(op),
|
||||
0x038F => self.posh = Some(op),
|
||||
0x0390 => self.posrelh = Some(op),
|
||||
|
||||
@@ -113,10 +113,16 @@ impl PieceTable {
|
||||
}
|
||||
PrmRef::Sprm0 { isprm, val: (p.prm >> 8) as u8 }
|
||||
} else {
|
||||
PrmRef::Grpprl(self.prc_grpprls.get((p.prm >> 1) as usize).map(|v| v.as_slice()))
|
||||
PrmRef::Grpprl(self.prc_grpprls.get((p.prm >> 1) as usize).map(Vec::as_slice))
|
||||
}
|
||||
}
|
||||
|
||||
/// The raw Prm of the piece holding `cp` (0, no properties, when there is none): two CPs
|
||||
/// with the same value get the same [`PrmRef`].
|
||||
pub(crate) fn prm_of(&self, cp: u32) -> u16 {
|
||||
self.piece_of(cp).map_or(0, |p| p.prm)
|
||||
}
|
||||
|
||||
fn piece_of(&self, cp: u32) -> Option<&Pcd> {
|
||||
let i = self.cps.partition_point(|&c| c <= cp).checked_sub(1)?;
|
||||
if i >= self.pieces.len() {
|
||||
|
||||
+179
-4
@@ -1732,9 +1732,9 @@ fn picture_bytes_are_capped_per_document() {
|
||||
let data: Vec<u8> = [a.clone(), b].concat();
|
||||
let mut pics = crate::media::Pictures::default();
|
||||
pics.limit = 1000;
|
||||
let first = pics.get(&data, 0).expect("first fits");
|
||||
let first = pics.get(&data, 0).cloned().expect("first fits");
|
||||
// Same location again: no new bytes.
|
||||
assert_eq!(pics.get(&data, 0).map(|p| p.key), Some(first.key.clone()));
|
||||
assert_eq!(pics.get(&data, 0).map(|p| p.key.clone()), Some(first.key.clone()));
|
||||
// A second distinct picture would exceed the cap.
|
||||
assert!(pics.get(&data, a.len() as u32).is_none());
|
||||
assert_eq!(pics.media.len(), 1);
|
||||
@@ -1754,8 +1754,8 @@ fn rejected_inline_pictures_are_not_read_again() {
|
||||
assert!(pics.get(&small, 0).is_none());
|
||||
// The rejected picture took nothing off the cap, and anchors sharing a picture share it.
|
||||
let at = big.len() as u32;
|
||||
let fits = pics.get(&data, at).expect("fits");
|
||||
assert_eq!(pics.get(&data, at).map(|p| p.key), Some(fits.key));
|
||||
let fits = pics.get(&data, at).cloned().expect("fits");
|
||||
assert_eq!(pics.get(&data, at).map(|p| p.key.clone()), Some(fits.key));
|
||||
assert_eq!(pics.media.len(), 1);
|
||||
// Filling the cap exactly works; after that, a place never tried before isn't read.
|
||||
let rest = picf_with_png(700);
|
||||
@@ -2392,3 +2392,178 @@ fn paragraph_borders_read_from_both_forms() {
|
||||
assert_eq!((top.style, top.width, top.space), (wordcraft_doc::props::BorderStyle::Single, 1.0, 2.0));
|
||||
assert_eq!(top.color, Some(wordcraft_doc::Rgb(0xFF, 0, 0)));
|
||||
}
|
||||
|
||||
/// #3642: a small compound file whose DIFAT names one FAT sector thousands of times made `cfb`
|
||||
/// build a FAT of hundreds of megabytes before checking it. The precheck refuses it first.
|
||||
#[test]
|
||||
fn a_difat_repeating_one_fat_sector_is_refused_before_cfb_builds_the_fat() {
|
||||
let f = wordcraft_doc::cfb_guard::repeating_difat_sample();
|
||||
// The precheck's own error is the proof that it ran before cfb (no clock to skew).
|
||||
assert!(matches!(read(&f), Err(super::DocbinError::Container(e)) if e.contains("FAT sectors in a file of")));
|
||||
assert!(!super::is_encrypted(&f));
|
||||
// A file cfb wrote itself passes.
|
||||
assert!(wordcraft_doc::cfb_guard::check(&cfb_file(&[("WordDocument", &[0u8; 4096])])).is_ok());
|
||||
}
|
||||
|
||||
/// A compound file `cfb` writes in `version`: `streams` at its root, then `extra` streams under
|
||||
/// an `ObjectPool` storage (where Word keeps embedded objects).
|
||||
fn cfb_file_in(version: cfb::Version, streams: &[(&str, &[u8])], extra: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
let mut comp = cfb::CompoundFile::create_with_version(version, std::io::Cursor::new(Vec::new())).expect("cfb create");
|
||||
for (name, data) in streams {
|
||||
comp.create_stream(format!("/{name}")).expect("cfb stream").write_all(data).expect("cfb write");
|
||||
}
|
||||
comp.create_storage("/ObjectPool").expect("cfb storage");
|
||||
for (name, data) in extra {
|
||||
comp.create_stream(format!("/ObjectPool/{name}")).expect("cfb stream").write_all(data).expect("cfb write");
|
||||
}
|
||||
comp.into_inner().into_inner()
|
||||
}
|
||||
|
||||
/// #3642 review: valid compound files pass the guard and open, so it refuses only the hostile
|
||||
/// ones. The repository keeps no files Word made, so these are written by the `cfb` crate in
|
||||
/// the shape of Word 97–2003 files: the WordDocument and table streams of a .doc these tests
|
||||
/// build, with the summary, Data and embedded-object streams Word adds, in 512-byte sectors
|
||||
/// (version 3, Word's own) and 4096-byte ones (version 4); and one with a Data stream of 8 MB,
|
||||
/// past what the header's 109 FAT sector slots map in 512-byte sectors (109 × 128 sectors,
|
||||
/// about 7 MB), so its FAT sectors are listed in a DIFAT chain too, which the guard walks.
|
||||
#[test]
|
||||
fn valid_doc_shaped_compound_files_pass_the_guard_and_open() {
|
||||
let doc = struct_doc(&structured_spec("Hello world\r", 12));
|
||||
let mut comp = cfb::CompoundFile::open(std::io::Cursor::new(doc.as_slice())).expect("cfb open");
|
||||
// The table stream is 1Table or 0Table, as the FIB's fWhichTblStm says: kept under its name.
|
||||
let table_name = if comp.is_stream("/1Table") { "1Table" } else { "0Table" };
|
||||
let mut stream = |name: &str| {
|
||||
let mut v = Vec::new();
|
||||
std::io::Read::read_to_end(&mut comp.open_stream(name).expect("stream"), &mut v).expect("read");
|
||||
v
|
||||
};
|
||||
let (word, table) = (stream("/WordDocument"), stream(&format!("/{table_name}")));
|
||||
let summary = vec![0xFEu8; 4096];
|
||||
let data = |n: usize| (0..n).map(|i| (i % 251) as u8).collect::<Vec<u8>>();
|
||||
let small = data(200_000);
|
||||
let big = data(8 << 20);
|
||||
let ole = data(70_000);
|
||||
let files = [
|
||||
("as built", doc.clone()),
|
||||
(
|
||||
"version 3",
|
||||
cfb_file_in(
|
||||
cfb::Version::V3,
|
||||
&[("WordDocument", &word), (table_name, &table), ("Data", &small), ("\u{5}SummaryInformation", &summary)],
|
||||
&[("_1234567890", &ole)],
|
||||
),
|
||||
),
|
||||
("version 4", cfb_file_in(cfb::Version::V4, &[("WordDocument", &word), (table_name, &table), ("Data", &small)], &[("_1", &ole)])),
|
||||
("with a DIFAT chain", cfb_file_in(cfb::Version::V3, &[("WordDocument", &word), (table_name, &table), ("Data", &big)], &[])),
|
||||
];
|
||||
let difat_sectors = |f: &[u8]| f.get(0x48..0x4C).map_or(0, |b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]));
|
||||
assert!(difat_sectors(&files[3].1) > 0, "the 8 MB file lists FAT sectors past the header's");
|
||||
for (what, f) in &files {
|
||||
assert_eq!(wordcraft_doc::cfb_guard::check(f), Ok(()), "{what}");
|
||||
let doc = read(f).unwrap_or_else(|e| panic!("{what}: {e}"));
|
||||
assert_eq!(body_texts(&doc).first().map(String::as_str), Some("Hello world"), "{what}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #3708: a long HYPERLINK target over a long result was copied (and compared) once per result
|
||||
/// character. It is now copied once per run.
|
||||
#[test]
|
||||
fn a_long_hyperlink_over_a_long_result_is_one_linked_run() {
|
||||
let n = 250_000;
|
||||
let target = "x".repeat(n);
|
||||
let text: &'static str = Box::leak(format!("\u{13}HYPERLINK \"http://{target}\"\u{14}{}\u{15}\r", "y".repeat(n)).into_boxed_str());
|
||||
let spec = structured_spec(text, text.chars().count() as u32);
|
||||
let doc = read(&struct_doc(&spec)).expect("opens");
|
||||
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
|
||||
let linked: Vec<_> = p0.runs.iter().filter(|r| r.props.link.is_some()).collect();
|
||||
assert_eq!(linked.len(), 1);
|
||||
assert_eq!(linked.first().map(|r| r.len), Some(n));
|
||||
// One copy of the target for the one run, not one per character (~2.5 s here): counted by
|
||||
// what the copies charge to the work budget, which a busy machine can't skew.
|
||||
let result = vec![('y', wordcraft_doc::props::CharProps::default()); n];
|
||||
let f = super::FieldBuild { depth: 1, instr: format!("HYPERLINK \"http://{target}\""), sep: Some(result) };
|
||||
let work = super::Work::new(u64::MAX);
|
||||
super::finish_field(&mut super::ParaBuild::default(), f, &work);
|
||||
assert_eq!(u64::MAX - work.left.get(), ("http://".len() + n) as u64, "one copy of the target");
|
||||
}
|
||||
|
||||
/// #3708: a result whose formatting changes every character pays for a target copy per run
|
||||
/// out of the work budget; past it the text stays, unlinked.
|
||||
#[test]
|
||||
fn hyperlink_target_copies_are_charged_to_the_work_budget() {
|
||||
use wordcraft_doc::props::CharProps;
|
||||
let bold = CharProps { bold: Some(true), ..Default::default() };
|
||||
let result: Vec<(char, CharProps)> = (0..1000).map(|i| ('a', if i % 2 == 0 { bold.clone() } else { CharProps::default() })).collect();
|
||||
let f = super::FieldBuild { depth: 1, instr: format!("HYPERLINK \"{}\"", "u".repeat(100)), sep: Some(result) };
|
||||
let mut pb = super::ParaBuild::default();
|
||||
let budget = super::Work::new(10_000);
|
||||
super::finish_field(&mut pb, f, &budget);
|
||||
assert_eq!(pb.text.len(), 1000, "all text kept");
|
||||
let linked: usize = pb.runs.iter().filter_map(|r| r.props.link.as_ref()).map(|l| l.len()).sum();
|
||||
assert!(linked <= 10_000, "linked bytes {linked}");
|
||||
assert_eq!(pb.runs.iter().filter(|r| r.props.link.is_some()).count(), 100);
|
||||
assert_eq!(budget.left.get(), 0);
|
||||
assert_eq!(budget.dropped(), [super::Cost::LinkTarget], "the unlinked text is noted, to be logged");
|
||||
}
|
||||
|
||||
/// #3871 review: what the work budget leaves out is noted once per kind, in a fixed order, and
|
||||
/// logged once per kind when the document has been read; nothing is noted while it lasts.
|
||||
#[test]
|
||||
fn the_work_budget_notes_each_kind_it_leaves_out_once() {
|
||||
use super::Cost::*;
|
||||
let work = super::Work::new(10);
|
||||
assert!(work.charge(10, AltText));
|
||||
assert!(work.dropped().is_empty(), "spent to the last byte, nothing left out");
|
||||
assert!(work.charge(0, LinkTarget), "a free copy is never refused");
|
||||
for _ in 0..3 {
|
||||
assert!(!work.charge(1, FloatingObject));
|
||||
}
|
||||
assert!(!work.charge(1, AltText));
|
||||
assert_eq!(work.dropped(), [AltText, FloatingObject]);
|
||||
work.log();
|
||||
assert_eq!(super::LAST_DROPPED.with(|d| d.borrow().clone()), [AltText, FloatingObject]);
|
||||
// A document within the budget leaves nothing out.
|
||||
read(&struct_doc(&structured_spec("Hello\r", 6))).expect("opens");
|
||||
assert!(super::LAST_DROPPED.with(|d| d.borrow().is_empty()));
|
||||
}
|
||||
|
||||
/// #3692: a piece's Prm grpprl (a Prc of up to 64 KB) was re-interpreted for every character.
|
||||
/// It is now resolved once per formatting change.
|
||||
#[test]
|
||||
fn a_large_prc_is_interpreted_once_per_formatting_change() {
|
||||
let prc = grpprl(&vec![(0x0835u16, &[0x01u8][..]); 21_000]);
|
||||
let text: &'static str = Box::leak(format!("{}\r", "a".repeat(100_000)).into_boxed_str());
|
||||
let spec = RichSpec {
|
||||
text,
|
||||
styles: vec![(1, 0x0FFF, "Normal", Vec::new(), Vec::new())],
|
||||
fonts: vec!["Times New Roman"],
|
||||
chpx_runs: vec![(0, Vec::new())],
|
||||
papx_entries: vec![(0, 0, Vec::new())],
|
||||
};
|
||||
let bytes = rich_doc_with(&spec, &RichExtra { prcs: vec![prc], prm: 0x0001, ..Default::default() });
|
||||
let t = std::time::Instant::now();
|
||||
let doc = read(&bytes).expect("opens");
|
||||
// About 60 ms on a loaded machine; interpreting the Prc per character took 88 s.
|
||||
assert!(t.elapsed().as_secs() < 10, "opened in {:?}", t.elapsed());
|
||||
let p = para0(&doc);
|
||||
assert_eq!(p.runs.len(), 1);
|
||||
assert_eq!(p.runs.first().and_then(|r| r.props.bold), Some(true));
|
||||
}
|
||||
|
||||
/// #3692: Prm grpprls are charged to a document-wide work budget; once it is spent the Prm
|
||||
/// is skipped (the text keeps its CHPX formatting).
|
||||
#[test]
|
||||
fn prm_grpprls_past_the_work_budget_are_skipped() {
|
||||
use wordcraft_doc::props::CharProps;
|
||||
let g = grpprl(&[(0x0835, &[0x01])]);
|
||||
let work = super::Work::new(g.len() as u64 + 1);
|
||||
let base = CharProps::default();
|
||||
let mut first = CharProps::default();
|
||||
super::apply_prm(&mut first, crate::piece::PrmRef::Grpprl(Some(&g)), &[], &base, &work);
|
||||
assert_eq!(first.bold, Some(true));
|
||||
let mut second = CharProps::default();
|
||||
super::apply_prm(&mut second, crate::piece::PrmRef::Grpprl(Some(&g)), &[], &base, &work);
|
||||
assert_eq!(second.bold, None);
|
||||
assert_eq!(work.left.get(), 1);
|
||||
assert_eq!(work.dropped(), [super::Cost::Formatting]);
|
||||
}
|
||||
|
||||
@@ -247,7 +247,7 @@ fn dib_becomes_png_and_compressed_metafiles_inflate() {
|
||||
dib.extend_from_slice(&[0, 0, 255, 0, 255, 0, 0, 0]);
|
||||
let r = rec(0, 0x7A8, 0xF01F, &[[0u8; 16].as_slice(), &[0xFF], &dib].concat());
|
||||
let (dib_rec, _) = officeart::record(&r).unwrap();
|
||||
let blip = officeart::blip(&dib_rec, 1 << 20).expect("DIB decodes");
|
||||
let blip = officeart::blip(&dib_rec, 1 << 20, &mut officeart::Decode::new(usize::MAX)).expect("DIB decodes");
|
||||
assert_eq!(blip.ext, "png");
|
||||
let img = image::load_from_memory(&blip.bytes).unwrap().to_rgb8();
|
||||
assert_eq!((img.width(), img.height(), img.get_pixel(0, 0).0), (2, 1, [255, 0, 0]));
|
||||
@@ -262,7 +262,7 @@ fn dib_becomes_png_and_compressed_metafiles_inflate() {
|
||||
hdr.extend_from_slice(&[0x00, 0xFE]); // compression = deflate, filter
|
||||
let r = rec(0, 0x216, 0xF01B, &[[0u8; 16].as_slice(), &hdr, &packed].concat());
|
||||
let (wmf_rec, _) = officeart::record(&r).unwrap();
|
||||
let blip = officeart::blip(&wmf_rec, 1 << 20).expect("WMF inflates");
|
||||
let blip = officeart::blip(&wmf_rec, 1 << 20, &mut officeart::Decode::new(usize::MAX)).expect("WMF inflates");
|
||||
assert_eq!((blip.ext, blip.bytes), ("wmf", wmf));
|
||||
}
|
||||
|
||||
@@ -281,10 +281,10 @@ fn deeply_nested_groups_and_lying_lengths_are_bounded() {
|
||||
hdr.extend_from_slice(&[0, 0]);
|
||||
let r = rec(0, 0x3D4, 0xF01A, &[[0u8; 16].as_slice(), &hdr, &[0x78, 0x9C, 1, 2, 3]].concat());
|
||||
let (emf, _) = officeart::record(&r).unwrap();
|
||||
assert!(officeart::blip(&emf, 1 << 20).is_none());
|
||||
assert!(officeart::blip(&emf, 1 << 20, &mut officeart::Decode::new(usize::MAX)).is_none());
|
||||
let mut fbse = vec![0u8; 36];
|
||||
fbse[28..32].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
assert!(officeart::fbse_blip(&fbse, &[0u8; 64], 1 << 20).is_none());
|
||||
assert!(officeart::fbse_blip(&fbse, &[0u8; 64], 1 << 20, &mut officeart::Decode::new(usize::MAX)).is_none());
|
||||
}
|
||||
|
||||
proptest! {
|
||||
@@ -322,13 +322,296 @@ fn dib_conversion_caps_header_and_encoded_bytes() {
|
||||
let record = rec(0, 0x7A8, officeart::rt::BLIP_DIB, &[[0u8; 16].as_slice(), &[0xFF], &dib].concat());
|
||||
let (record, _) = officeart::record(&record).unwrap();
|
||||
let bmp_size = 14 + dib.len();
|
||||
assert!(officeart::blip(&record, bmp_size - 1).is_none(), "the BMP header also needs room");
|
||||
let fallback = officeart::blip(&record, bmp_size).expect("the BMP fallback fits exactly");
|
||||
assert!(officeart::blip(&record, bmp_size - 1, &mut officeart::Decode::new(usize::MAX)).is_none(), "the BMP header also needs room");
|
||||
let fallback = officeart::blip(&record, bmp_size, &mut officeart::Decode::new(usize::MAX)).expect("the BMP fallback fits exactly");
|
||||
assert_eq!((fallback.ext, fallback.placeholder, fallback.bytes.len()), ("bmp", true, bmp_size));
|
||||
let img = image::load_from_memory(&fallback.bytes).unwrap().to_rgb8();
|
||||
assert_eq!((img.width(), img.height(), img.get_pixel(0, 0).0), (1, 1, [90, 60, 30]));
|
||||
let normal = officeart::blip(&record, 512).expect("normal conversion fits");
|
||||
let normal = officeart::blip(&record, 512, &mut officeart::Decode::new(usize::MAX)).expect("normal conversion fits");
|
||||
assert_eq!((normal.ext, normal.placeholder), ("png", false));
|
||||
assert!(normal.bytes.len() > bmp_size && normal.bytes.len() <= 512);
|
||||
assert_eq!(image::load_from_memory(&normal.bytes).unwrap().width(), 1);
|
||||
}
|
||||
|
||||
/// #3709: every anchor of one inline picture got its own copy of the alt text (up to 4096
|
||||
/// UTF-16 units). Copies are charged to the document's work budget; past it alt is empty.
|
||||
#[test]
|
||||
fn alt_text_copies_across_many_anchors_are_bounded() {
|
||||
let alt: String = "€".repeat(4096); // 12 KB as UTF-8
|
||||
let shape = sp(&[fsp(75, 1025, 0xA00), fopt(&[(0x0104, 1)], &[(0x0381, wz(&alt))])]);
|
||||
let data = picf_shape(&[shape, fbse_png(&tiny_png())].concat());
|
||||
let text: &'static str = Box::leak(format!("{}\r", "\u{1}".repeat(8000)).into_boxed_str());
|
||||
let ccp = text.chars().count() as u32;
|
||||
let spec = StructSpec { text, data_stream: data, chpx_runs: vec![(0, grpprl(&[(0x6A03, &0u32.to_le_bytes())]))], ..structured_spec(text, ccp) };
|
||||
let doc = read(&struct_doc(&spec)).expect("opens");
|
||||
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
|
||||
let alts: Vec<&str> = p0.objects.iter().filter_map(|o| if let InlineObject::Image { alt, .. } = o { Some(alt.as_str()) } else { None }).collect();
|
||||
assert_eq!(alts.len(), 8000, "every anchor keeps its picture");
|
||||
assert_eq!(alts.first().copied(), Some(alt.as_str()));
|
||||
let total: usize = alts.iter().map(|a| a.len()).sum();
|
||||
assert!(total <= 64 << 20, "alt bytes {total}");
|
||||
assert_eq!(alts.last().copied(), Some(""));
|
||||
// Leaving it out is logged, once (#3871 review).
|
||||
assert_eq!(crate::LAST_DROPPED.with(|d| d.borrow().clone()), [crate::Cost::AltText]);
|
||||
}
|
||||
|
||||
/// #3710: every PlcSpa entry naming a group built another copy of all its members. A shape is
|
||||
/// anchored once; repeated spids are skipped.
|
||||
#[test]
|
||||
fn a_group_named_by_many_anchors_is_built_once() {
|
||||
let rect = |l: i32| rec(0, 0, 0xF00F, &[l, 0, l + 10, 10].iter().flat_map(|v| v.to_le_bytes()).collect::<Vec<_>>());
|
||||
let head = sp(&[fsp(0, 3000, 0x201), rec(1, 0, 0xF009, &[0i32, 0, 10_000, 10].iter().flat_map(|v| v.to_le_bytes()).collect::<Vec<_>>())]);
|
||||
let members: Vec<Vec<u8>> = (0..1000).map(|i| sp(&[fsp(1, 4000 + i as u32, 0xA02), rect(i * 10)])).collect();
|
||||
let group = container(0xF003, &[vec![head], members].concat());
|
||||
let art = art_content(&[], &[group]);
|
||||
let n = 5000u32;
|
||||
let cps: Vec<u32> = (1..=n + 1).collect();
|
||||
let spas: Vec<u8> = (0..n).flat_map(|_| spa(3000, [0, 0, 1440, 1440], spa_flags(0, 0, 3, false))).collect();
|
||||
let text = "A\u{8}\u{8}\u{8}B\r";
|
||||
let ccp = text.chars().count() as u32;
|
||||
let spec = StructSpec { text, extra_pairs: vec![(40, plc(&cps, &spas)), (50, art)], ..structured_spec(text, ccp) };
|
||||
let t = std::time::Instant::now();
|
||||
let doc = read(&struct_doc(&spec)).expect("opens");
|
||||
// About 10 ms on a loaded machine, so 10 s is a backstop; the old code (a copy per entry,
|
||||
// 6-8 s here) is caught by the group count below, one group per anchor ([1000; 3]).
|
||||
assert!(t.elapsed().as_secs() < 10, "opened in {:?}", t.elapsed());
|
||||
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
|
||||
let groups: Vec<usize> =
|
||||
p0.objects.iter().filter_map(|o| if let InlineObject::Group { children, .. } = o { Some(children.len()) } else { None }).collect();
|
||||
assert_eq!(groups, [1000]);
|
||||
}
|
||||
|
||||
/// #3710: a floating picture's copy is charged its alt text too, and a group every member's.
|
||||
#[test]
|
||||
fn a_floating_copy_is_charged_its_alt_text() {
|
||||
let image = |alt: String| InlineObject::Image {
|
||||
media: String::new(),
|
||||
w: 1.0,
|
||||
h: 1.0,
|
||||
alt,
|
||||
float: Default::default(),
|
||||
crop: Default::default(),
|
||||
ole: None,
|
||||
};
|
||||
let bare = crate::object_cost(&image(String::new()));
|
||||
assert_eq!(crate::object_cost(&image("€".repeat(4096))), bare + 3 * 4096);
|
||||
let member = |obj| wordcraft_doc::para::GroupChild { x: 0.0, y: 0.0, obj };
|
||||
let group = InlineObject::Group {
|
||||
w: 1.0,
|
||||
h: 1.0,
|
||||
float: Default::default(),
|
||||
ch_w: 1.0,
|
||||
ch_h: 1.0,
|
||||
children: vec![member(image("x".repeat(100))), member(image(String::new()))],
|
||||
canvas: None,
|
||||
};
|
||||
assert_eq!(crate::object_cost(&group), 3 * bare + 100);
|
||||
}
|
||||
|
||||
/// A deflated WMF BLIP record declaring `size` uncompressed bytes.
|
||||
fn packed_wmf(size: u32, packed: &[u8]) -> Vec<u8> {
|
||||
let mut hdr = size.to_le_bytes().to_vec();
|
||||
hdr.extend_from_slice(&[0u8; 24]); // rcBounds, ptSize
|
||||
hdr.extend_from_slice(&(packed.len() as u32).to_le_bytes());
|
||||
hdr.extend_from_slice(&[0x00, 0xFE]); // compression = deflate, filter
|
||||
rec(0, 0x216, officeart::rt::BLIP_WMF, &[[0u8; 16].as_slice(), &hdr, packed].concat())
|
||||
}
|
||||
|
||||
/// An `OfficeArtFBSE` body whose BLIP (of `size` bytes) sits at `fo_delay` in the delay stream.
|
||||
fn delayed_fbse(size: usize, fo_delay: usize) -> Vec<u8> {
|
||||
let mut fbse = vec![0u8; 36];
|
||||
fbse[20..24].copy_from_slice(&(size as u32).to_le_bytes());
|
||||
fbse[28..32].copy_from_slice(&(fo_delay as u32).to_le_bytes());
|
||||
fbse
|
||||
}
|
||||
|
||||
/// #3711: store entries aliasing one delay-stream metafile each inflated (and copied) it again.
|
||||
/// Now the record is decoded once and every entry shares its media key.
|
||||
#[test]
|
||||
fn store_entries_aliasing_one_delayed_metafile_decode_it_once() {
|
||||
let wmf = vec![0u8; 1 << 20];
|
||||
let delay = packed_wmf(wmf.len() as u32, &miniz_oxide::deflate::compress_to_vec_zlib(&wmf, 6));
|
||||
let fbse = delayed_fbse(delay.len(), 0);
|
||||
let store: Vec<&[u8]> = vec![&fbse; 5_000];
|
||||
let mut pics = crate::media::Pictures::default();
|
||||
pics.work = officeart::Decode::new(8 << 20);
|
||||
let keys: Vec<_> = (1..=store.len() as u32).map(|pib| pics.bse(pib, &store, &delay)).collect();
|
||||
assert!(keys.iter().all(|k| k.is_some() && *k == keys[0]), "every entry shares one picture");
|
||||
assert_eq!(pics.work.left, (8 << 20) - wmf.len(), "inflated once");
|
||||
}
|
||||
|
||||
/// #3711: distinct metafiles that fail to inflate are charged their declared size anyway, so
|
||||
/// a document of them stops decoding once the work budget is spent.
|
||||
#[test]
|
||||
fn failing_metafile_inflations_spend_the_decode_budget() {
|
||||
let records: Vec<Vec<u8>> = (0..10u8).map(|i| packed_wmf(1 << 20, &[0x78, 0x9C, i, 1, 2, 3])).collect();
|
||||
let delay = records.concat();
|
||||
let mut at = 0;
|
||||
let fbses: Vec<Vec<u8>> = records
|
||||
.iter()
|
||||
.map(|r| {
|
||||
at += r.len();
|
||||
delayed_fbse(r.len(), at - r.len())
|
||||
})
|
||||
.collect();
|
||||
let store: Vec<&[u8]> = fbses.iter().map(Vec::as_slice).collect();
|
||||
let mut pics = crate::media::Pictures::default();
|
||||
pics.work = officeart::Decode::new(3 << 20);
|
||||
for pib in 1..=store.len() as u32 {
|
||||
assert!(pics.bse(pib, &store, &delay).is_none());
|
||||
}
|
||||
assert_eq!(pics.work.left, 0, "three attempts spent the budget; the rest weren't tried");
|
||||
}
|
||||
|
||||
/// #3712: a DIB's declared pixels are charged before decoding (an RLE bitmap is tiny on disk but
|
||||
/// decodes to w × h × 4 bytes); past the budget it stays a BMP placeholder, undecoded.
|
||||
#[test]
|
||||
fn dib_decoding_is_charged_its_declared_pixels() {
|
||||
let blip_of = |dib: &[u8]| rec(0, 0x7A8, officeart::rt::BLIP_DIB, &[[0u8; 16].as_slice(), &[0xFF], dib].concat());
|
||||
let one = blip_of(&one_pixel_dib());
|
||||
let (one, _) = officeart::record(&one).unwrap();
|
||||
let mut work = officeart::Decode::new(3);
|
||||
assert!(officeart::blip(&one, 512, &mut work).is_none(), "not even the copy fits: nothing is copied");
|
||||
assert_eq!(work.left, 3, "nothing decoded, nothing charged");
|
||||
let dib_len = one.body.len() - 17;
|
||||
let mut work = officeart::Decode::new(dib_len);
|
||||
assert_eq!(officeart::blip(&one, 512, &mut work).map(|b| (b.ext, b.placeholder)), Some(("png", false)));
|
||||
assert_eq!(work.left, 0, "the copy is charged, not just 4 bytes a pixel");
|
||||
|
||||
// 8-bit RLE declaring 16384 × 16384 (1 GiB decoded) in a few bytes.
|
||||
let mut big = [40u32, 16_384, 16_384].iter().flat_map(|v| v.to_le_bytes()).collect::<Vec<_>>();
|
||||
big.extend_from_slice(&1u16.to_le_bytes());
|
||||
big.extend_from_slice(&8u16.to_le_bytes());
|
||||
big.extend_from_slice(&1u32.to_le_bytes()); // BI_RLE8
|
||||
big.extend_from_slice(&[0u8; 12]);
|
||||
big.extend_from_slice(&1u32.to_le_bytes()); // one palette colour
|
||||
big.extend_from_slice(&[0u8; 4]); // clrImportant
|
||||
big.extend_from_slice(&[0u8; 4]); // the palette colour
|
||||
big.extend_from_slice(&[0, 0, 0, 1]); // end of line, end of bitmap
|
||||
let big = blip_of(&big);
|
||||
let (big, _) = officeart::record(&big).unwrap();
|
||||
let mut work = officeart::Decode::new(512 << 20);
|
||||
assert_eq!(officeart::blip(&big, 1 << 20, &mut work).map(|b| (b.ext, b.placeholder)), Some(("bmp", true)));
|
||||
assert_eq!(work.left, (512 << 20) - (big.body.len() - 17), "only the copy of the DIB was charged, not its declared pixels");
|
||||
}
|
||||
|
||||
/// An EMF `OfficeArtBlip` record (compressed) with `raw` as its stream, claiming `cb_size`.
|
||||
fn emf_blip(cb_size: u32, raw: &[u8]) -> Vec<u8> {
|
||||
let mut hdr = vec![0u8; 34];
|
||||
hdr[0..4].copy_from_slice(&cb_size.to_le_bytes());
|
||||
hdr[28..32].copy_from_slice(&(raw.len() as u32).to_le_bytes());
|
||||
hdr[32] = 0; // compressed
|
||||
hdr[33] = 0xFE;
|
||||
rec(0, 0x3D4, 0xF01A, &[[0u8; 16].as_slice(), &hdr, raw].concat())
|
||||
}
|
||||
|
||||
/// #3871 review: the zlib input is charged to the decode budget, not just what it inflates to.
|
||||
#[test]
|
||||
fn compressed_metafile_input_is_charged_to_the_decode_budget() {
|
||||
let raw = vec![0x5Au8; 1 << 20]; // not even zlib: costs its input all the same
|
||||
let r = emf_blip(1, &raw);
|
||||
let (emf, _) = officeart::record(&r).unwrap();
|
||||
let mut work = officeart::Decode::new(10 << 20);
|
||||
assert!(officeart::blip(&emf, 64 << 20, &mut work).is_none());
|
||||
assert_eq!(work.left, (10 << 20) - raw.len(), "1 MB of input charged, however little it claims to inflate to");
|
||||
// Ten more of them exhaust the budget; none is inflated past it.
|
||||
let mut work = officeart::Decode::new(3 << 20);
|
||||
for _ in 0..10 {
|
||||
let _ = officeart::blip(&emf, 64 << 20, &mut work);
|
||||
}
|
||||
assert_eq!(work.left, 0, "three attempts fit, the rest are refused");
|
||||
}
|
||||
|
||||
/// An inline `PICFAndOfficeArtData` (shape, FBSE) whose BLIP sits at `fo_delay` in Data; the
|
||||
/// FBSE claims `size` bytes.
|
||||
fn inline_pic(fo_delay: u32, size: u32) -> Vec<u8> {
|
||||
let mut fbse = vec![0u8; 36];
|
||||
fbse[20..24].copy_from_slice(&size.to_le_bytes());
|
||||
fbse[28..32].copy_from_slice(&fo_delay.to_le_bytes());
|
||||
let body = [container(0xF004, &[]), rec(2, 6, 0xF007, &fbse)].concat();
|
||||
let mut picf = vec![0u8; 0x44];
|
||||
picf[0..4].copy_from_slice(&((0x44 + body.len()) as u32).to_le_bytes());
|
||||
picf[4..6].copy_from_slice(&0x44u16.to_le_bytes());
|
||||
picf[6..8].copy_from_slice(&0x64u16.to_le_bytes()); // MM_SHAPE
|
||||
picf[28..30].copy_from_slice(&1440i16.to_le_bytes());
|
||||
picf[30..32].copy_from_slice(&1440i16.to_le_bytes());
|
||||
[picf, body].concat()
|
||||
}
|
||||
|
||||
/// #3871 review: inline pictures that alias one BLIP record share one decode, whatever sizes
|
||||
/// their FBSEs claim and wherever their PICFs sit.
|
||||
#[test]
|
||||
fn inline_pictures_aliasing_one_blip_record_are_decoded_once() {
|
||||
let blip = emf_blip(1, &vec![0x5Au8; 64 << 10]);
|
||||
let a = inline_pic(0, 70_000);
|
||||
let b = inline_pic(0, 80_000);
|
||||
let c = inline_pic(0, 90_000);
|
||||
let blip_at = (a.len() + b.len() + c.len()) as u32;
|
||||
let (a, b, c) = (inline_pic(blip_at, 70_000), inline_pic(blip_at, 80_000), inline_pic(blip_at, 90_000));
|
||||
let data = [a.clone(), b.clone(), c, blip].concat();
|
||||
let mut pics = crate::media::Pictures::default();
|
||||
let start = pics.work.left;
|
||||
let _ = pics.get(&data, 0);
|
||||
let _ = pics.get(&data, a.len() as u32);
|
||||
let _ = pics.get(&data, (a.len() + b.len()) as u32);
|
||||
assert_eq!(start - pics.work.left, 64 << 10, "one record, one charge");
|
||||
}
|
||||
|
||||
/// A 1-bit-per-pixel DIB of `side`×`side` pixels with `bytes` of pixel data.
|
||||
fn dib_1bpp(side: u32, bytes: usize) -> Vec<u8> {
|
||||
let mut d = vec![0u8; 40];
|
||||
d[0..4].copy_from_slice(&40u32.to_le_bytes());
|
||||
d[4..8].copy_from_slice(&side.to_le_bytes());
|
||||
d[8..12].copy_from_slice(&side.to_le_bytes());
|
||||
d[12..14].copy_from_slice(&1u16.to_le_bytes());
|
||||
d[14..16].copy_from_slice(&1u16.to_le_bytes());
|
||||
d.extend_from_slice(&[0, 0, 0, 0, 255, 255, 255, 0]); // palette
|
||||
d.extend(std::iter::repeat_n(0u8, bytes));
|
||||
d
|
||||
}
|
||||
|
||||
fn dib_record(dib: &[u8]) -> Vec<u8> {
|
||||
rec(0, 0x7A8, 0xF01F, &[[0u8; 16].as_slice(), &[0xFF], dib].concat())
|
||||
}
|
||||
|
||||
/// #3871 review: the decode budget scales with the input, so a document of many legitimate
|
||||
/// large scans decodes them all; a high-ratio set is still capped.
|
||||
#[test]
|
||||
fn the_decode_budget_scales_with_the_input_but_still_caps_bombs() {
|
||||
let floor = 1 << 20;
|
||||
// 20 legitimate 1024² 1-bit scans: 4 MB charged each, 128 KB of file each.
|
||||
let real = dib_record(&dib_1bpp(1024, 1024 * 1024 / 8));
|
||||
let (rec_real, _) = officeart::record(&real).unwrap();
|
||||
let input = 20 * real.len();
|
||||
let mut work = officeart::Decode::new(crate::media::decode_work(input, floor));
|
||||
for i in 0..20 {
|
||||
let b = officeart::blip(&rec_real, 64 << 20, &mut work).unwrap();
|
||||
assert!(!b.placeholder, "scan {i} decodes");
|
||||
}
|
||||
// Declared 20000² (1.6 GB each) in a few dozen bytes each: a placeholder, never decoded.
|
||||
let bomb = dib_record(&dib_1bpp(20_000, 16));
|
||||
let (rec_bomb, _) = officeart::record(&bomb).unwrap();
|
||||
let mut work = officeart::Decode::new(crate::media::decode_work(20 * bomb.len(), floor));
|
||||
for _ in 0..20 {
|
||||
assert!(officeart::blip(&rec_bomb, 64 << 20, &mut work).unwrap().placeholder);
|
||||
}
|
||||
assert!(work.left <= crate::media::decode_work(20 * bomb.len(), floor), "nothing was refunded or invented");
|
||||
}
|
||||
|
||||
/// #3871 review: the copy of a DIB into a BMP is charged before it is made, however few pixels
|
||||
/// the header declares; with the budget spent nothing is copied at all.
|
||||
#[test]
|
||||
fn a_dib_with_a_large_tail_is_charged_for_its_copy() {
|
||||
let big = dib_record(&dib_1bpp(1, 1 << 20)); // 1 pixel declared, 1 MB of trailing bytes
|
||||
let (big, _) = officeart::record(&big).unwrap();
|
||||
let dib_len = big.body.len() - 17;
|
||||
let mut work = officeart::Decode::new(10 << 20);
|
||||
assert!(!officeart::blip(&big, 64 << 20, &mut work).unwrap().placeholder);
|
||||
assert_eq!(10 << 20, work.left + dib_len, "charged for the bytes, not just 4 per pixel");
|
||||
// Room for the copy: decoded. Not even that: nothing is copied.
|
||||
let mut work = officeart::Decode::new(dib_len + 1);
|
||||
assert!(officeart::blip(&big, 64 << 20, &mut work).is_some());
|
||||
let mut work = officeart::Decode::new(dib_len - 1);
|
||||
assert!(officeart::blip(&big, 64 << 20, &mut work).is_none());
|
||||
assert_eq!(work.left, dib_len - 1);
|
||||
}
|
||||
|
||||
@@ -199,6 +199,9 @@ fn unsupported_variants_say_so() {
|
||||
let (h, k) = capi_header("pw", 128, hflags);
|
||||
let e = read_with_password(&rc4_file_with(h, &*k), Some("pw")).unwrap_err();
|
||||
assert!(matches!(e, DocbinError::Encryption(_)), "{e:?}");
|
||||
if hflags & 0x20 != 0 {
|
||||
assert!(e.to_string().contains("only defined for the newer encrypted-package format"), "{e}");
|
||||
}
|
||||
}
|
||||
let (mut h, k) = rc4_header("pw");
|
||||
h[0] = 9;
|
||||
|
||||
@@ -31,6 +31,7 @@ zeroize = { workspace = true }
|
||||
getrandom = { workspace = true, features = ["wasm_js"] }
|
||||
|
||||
[dev-dependencies]
|
||||
wordcraft-doc = { workspace = true, features = ["test-support"] }
|
||||
proptest = { workspace = true }
|
||||
image = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
|
||||
@@ -90,13 +90,16 @@ pub(crate) fn parse(root: &El, seen: &mut Seen, room: usize) -> Sources {
|
||||
let tag = child_text(src_el, "Tag");
|
||||
let raw_kind = child_text(src_el, "SourceType");
|
||||
let kind = crate::write::from_source_type(&raw_kind);
|
||||
let (author, editor) = match src_el.els().find(|e| e.local() == "Author") {
|
||||
let (author, corporate, editor) = match src_el.els().find(|e| e.local() == "Author") {
|
||||
Some(a) => {
|
||||
let auth = a.els().find(|e| e.local() == "Author").map(parse_persons).filter(|s| !s.is_empty()).unwrap_or_else(|| parse_persons(a));
|
||||
let role = a.els().find(|e| e.local() == "Author");
|
||||
let auth = role.map(parse_persons).filter(|s| !s.is_empty()).unwrap_or_else(|| parse_persons(a));
|
||||
// Word's Corporate Author: one name, kept whole (`b:Corporate`, #4848).
|
||||
let corporate = role.map(|r| child_text(r, "Corporate").trim().to_string()).unwrap_or_default();
|
||||
let ed = a.els().find(|e| e.local() == "Editor").map(parse_persons).unwrap_or_default();
|
||||
(auth, ed)
|
||||
(auth, corporate, ed)
|
||||
}
|
||||
None => (String::new(), String::new()),
|
||||
None => (String::new(), String::new(), String::new()),
|
||||
};
|
||||
let editor = if editor.is_empty() { src_el.els().find(|e| e.local() == "Editor").map(parse_persons).unwrap_or_default() } else { editor };
|
||||
let title = child_text(src_el, "Title");
|
||||
@@ -132,11 +135,12 @@ pub(crate) fn parse(root: &El, seen: &mut Seen, room: usize) -> Sources {
|
||||
};
|
||||
|
||||
// Keep sources with some content, once each (by tag, or title and author untagged).
|
||||
if tag.is_empty() && title.is_empty() && author.is_empty() && editor.is_empty() {
|
||||
if tag.is_empty() && title.is_empty() && author.is_empty() && corporate.is_empty() && editor.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let fresh = if tag.is_empty() {
|
||||
seen.untagged.insert((title.clone(), if author.is_empty() { editor.clone() } else { author.clone() }))
|
||||
let who = [&author, &corporate, &editor].into_iter().find(|w| !w.is_empty()).cloned().unwrap_or_default();
|
||||
seen.untagged.insert((title.clone(), who))
|
||||
} else {
|
||||
seen.tags.insert(tag.clone())
|
||||
};
|
||||
@@ -147,6 +151,7 @@ pub(crate) fn parse(root: &El, seen: &mut Seen, room: usize) -> Sources {
|
||||
tag,
|
||||
kind,
|
||||
author,
|
||||
corporate,
|
||||
title,
|
||||
year,
|
||||
city,
|
||||
@@ -265,6 +270,25 @@ mod tests {
|
||||
assert!(read_sources(br#"<o:Sources xmlns:o="urn:example:other"><o:Source/></o:Sources>"#).is_err(), "an unrelated namespace");
|
||||
}
|
||||
|
||||
/// #4848: a corporate author (Word's Corporate Author box, `b:Corporate`) is read whole, and
|
||||
/// an untagged source with only a corporate author is still kept, once.
|
||||
#[test]
|
||||
fn a_corporate_author_is_read_whole() {
|
||||
let xml = br#"<b:Sources xmlns:b="http://schemas.openxmlformats.org/officeDocument/2006/bibliography">
|
||||
<b:Source><b:Tag>Wor20</b:Tag><b:SourceType>Report</b:SourceType>
|
||||
<b:Author><b:Author><b:Corporate> World Health Organization </b:Corporate></b:Author></b:Author>
|
||||
<b:Title>World report on hearing</b:Title><b:Year>2020</b:Year></b:Source>
|
||||
<b:Source><b:SourceType>Book</b:SourceType>
|
||||
<b:Author><b:Author><b:Corporate>Harbor Guild, Inc.</b:Corporate></b:Author></b:Author></b:Source>
|
||||
<b:Source><b:SourceType>Book</b:SourceType>
|
||||
<b:Author><b:Author><b:Corporate>Harbor Guild, Inc.</b:Corporate></b:Author></b:Author></b:Source>
|
||||
</b:Sources>"#;
|
||||
let f = read_sources(xml).unwrap();
|
||||
assert_eq!(f.sources.len(), 2, "{:?}", f.sources);
|
||||
assert_eq!((f.sources[0].corporate.as_str(), f.sources[0].author.as_str()), ("World Health Organization", ""));
|
||||
assert_eq!(f.sources[1].corporate, "Harbor Guild, Inc.", "a comma doesn't split a corporate name");
|
||||
}
|
||||
|
||||
/// Word's own 2004/10 bibliography namespace (`Sources.xml` written by scripts and older
|
||||
/// builds, the VBA `Source.XML` format) reads as well as OOXML's.
|
||||
#[test]
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! [`spec_of`] turns a chart part back into the model only when writing that model again gives
|
||||
//! the same markup, so a chart with anything the model leaves out stays as it was read.
|
||||
|
||||
use wordcraft_doc::chart::{ChartSeries, ChartSpec, ChartType, LegendPos};
|
||||
use wordcraft_doc::chart::{ChartSeries, ChartSpec, ChartType, LegendPos, MAX_POINTS, MAX_SERIES};
|
||||
use wordcraft_doc::graphic::GraphicItem;
|
||||
use wordcraft_doc::props::Rgb;
|
||||
|
||||
@@ -23,9 +23,21 @@ pub(crate) const CHART_URI: &str = "http://schemas.openxmlformats.org/drawingml/
|
||||
const CAT_AX: &str = "500000001";
|
||||
const VAL_AX: &str = "500000002";
|
||||
|
||||
/// The chart part for `spec`: a complete XML document. `spec` should be sanitized
|
||||
/// ([`ChartSpec::sanitize`]); a value that isn't finite is written as a gap either way.
|
||||
/// The chart part for `spec`: a complete XML document, written as [`ChartSpec::sanitize`] leaves
|
||||
/// it (a value that isn't finite is a gap).
|
||||
pub fn chart_xml(spec: &ChartSpec) -> String {
|
||||
chart_part(spec).0
|
||||
}
|
||||
|
||||
/// [`chart_xml`], and whether the chart limits left series or points of `spec` out of it.
|
||||
pub(crate) fn chart_part(spec: &ChartSpec) -> (String, bool) {
|
||||
let kept = bounded(spec);
|
||||
let points = spec.series.iter().map(|s| s.values.len()).max().unwrap_or(0).max(spec.categories.len());
|
||||
let cut = kept.series.len() < spec.series.len() || kept.points() < points;
|
||||
(write_chart(&kept), cut)
|
||||
}
|
||||
|
||||
fn write_chart(spec: &ChartSpec) -> String {
|
||||
let mut w = W::new();
|
||||
w.open("c:chartSpace", &[("xmlns:c", NS_C), ("xmlns:a", NS_A), ("xmlns:r", NS_R)]);
|
||||
w.empty("c:roundedCorners", &[("val", "0")]);
|
||||
@@ -79,6 +91,26 @@ pub fn chart_xml(spec: &ChartSpec) -> String {
|
||||
w.s
|
||||
}
|
||||
|
||||
/// `spec` held to the chart limits. A model deserialised as is (a `.json` document) never went
|
||||
/// through [`ChartSpec::sanitize`], and every series repeats every category, so its size would
|
||||
/// multiply (#3648). Only what the limits keep is copied before sanitizing.
|
||||
fn bounded(spec: &ChartSpec) -> ChartSpec {
|
||||
ChartSpec {
|
||||
kind: spec.kind,
|
||||
categories: spec.categories.iter().take(MAX_POINTS).cloned().collect(),
|
||||
series: spec
|
||||
.series
|
||||
.iter()
|
||||
.take(MAX_SERIES)
|
||||
.map(|s| ChartSeries { name: s.name.clone(), values: s.values.iter().take(MAX_POINTS).copied().collect() })
|
||||
.collect(),
|
||||
title: spec.title.clone(),
|
||||
legend: spec.legend,
|
||||
data_labels: spec.data_labels,
|
||||
}
|
||||
.sanitized()
|
||||
}
|
||||
|
||||
/// What `spec` draws as, `w` × `h` points, in the colours of `theme` (series take the accents in
|
||||
/// turn). Empty when the chart has no series.
|
||||
pub fn chart_items(spec: &ChartSpec, theme: &[Rgb], w: f32, h: f32) -> Vec<GraphicItem> {
|
||||
@@ -421,6 +453,27 @@ mod tests {
|
||||
e.els().map(|c| c.name.as_str()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chart_past_the_limits_is_written_within_them() {
|
||||
// A model deserialised as is (a `.json` document) never went through `sanitize`, and every
|
||||
// series repeats every category: 300 series of 4,500 points wrote 2.7 million points (#3648).
|
||||
use wordcraft_doc::chart::MAX_CELLS;
|
||||
let spec = ChartSpec {
|
||||
kind: ChartType::Column,
|
||||
categories: (0..4500).map(|i| i.to_string()).collect(),
|
||||
series: (0..300).map(|i| ChartSeries { name: format!("S{i}"), values: vec![Some(1.0); 4500] }).collect(),
|
||||
title: None,
|
||||
legend: None,
|
||||
data_labels: false,
|
||||
};
|
||||
let x = chart_xml(&spec);
|
||||
let series = x.matches("<c:ser>").count();
|
||||
let points = x.matches("<c:pt ").count();
|
||||
assert!((1..=MAX_SERIES).contains(&series), "{series} series");
|
||||
assert!(points <= 2 * MAX_CELLS, "{points} points");
|
||||
assert!(!x.contains(&format!("<c:pt idx=\"{MAX_POINTS}\"")), "past the point limit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_type_reads_back_as_itself() {
|
||||
for kind in ChartType::ALL {
|
||||
|
||||
+190
-198
@@ -9,6 +9,10 @@
|
||||
//! a verifier that tells a wrong password apart; an HMAC over the encrypted stream checks that it
|
||||
//! hasn't been damaged or tampered with.
|
||||
//!
|
||||
//! Older packages (Word 2007) use the *Standard* method (§2.3.4.5–§2.3.4.9): a binary
|
||||
//! `EncryptionInfo`, one AES key derived from the password with SHA-1 and 50,000 spins, and the
|
||||
//! whole package AES-ECB encrypted. [`decrypt`] reads both; [`encrypt`] writes agile only.
|
||||
//!
|
||||
//! Files are hostile: every stream read is capped, every length is checked before slicing, and the
|
||||
//! spin count is bounded by the specification's maximum. Passwords and keys are wiped from memory
|
||||
//! when dropped ([`Zeroizing`]) and never logged.
|
||||
@@ -47,6 +51,20 @@ const BK_KEY_VALUE: [u8; 8] = [0x14, 0x6e, 0x0b, 0xe7, 0xab, 0xac, 0xd0, 0xd6];
|
||||
const BK_HMAC_KEY: [u8; 8] = [0x5f, 0xb2, 0xad, 0x01, 0x0c, 0xb9, 0xe1, 0xf6];
|
||||
const BK_HMAC_VALUE: [u8; 8] = [0xa0, 0x67, 0x7f, 0x02, 0xb2, 0x2c, 0x84, 0x33];
|
||||
|
||||
/// Standard encryption (§2.3.2, §2.3.4.5–§2.3.4.7): `EncryptionHeader.Flags` bits, the AES and
|
||||
/// SHA-1 algorithm ids, the provider type, the fixed spin count and the SHA-1 size.
|
||||
const F_CRYPTO_API: u32 = 0x04;
|
||||
const F_EXTERNAL: u32 = 0x10;
|
||||
const F_AES: u32 = 0x20;
|
||||
const STANDARD_FLAGS: u32 = F_CRYPTO_API | F_AES;
|
||||
const ALG_AES_128: u32 = 0x660E;
|
||||
const ALG_AES_192: u32 = 0x660F;
|
||||
const ALG_AES_256: u32 = 0x6610;
|
||||
const ALG_SHA1: u32 = 0x8004;
|
||||
const PROV_RSA_AES: u32 = 0x18;
|
||||
const STANDARD_SPIN_COUNT: u32 = 50_000;
|
||||
const SHA1_SIZE: usize = 20;
|
||||
|
||||
/// An OLE compound file starts with these bytes ([MS-CFB] §2.2).
|
||||
const CFB_MAGIC: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
|
||||
|
||||
@@ -56,7 +74,7 @@ const NS_PASSWORD: &str = "http://schemas.microsoft.com/office/2006/keyEncryptor
|
||||
/// Is this an encrypted Office package (a compound file with `EncryptionInfo` and
|
||||
/// `EncryptedPackage` streams) rather than a zip?
|
||||
pub fn is_encrypted(bytes: &[u8]) -> bool {
|
||||
if bytes.get(..8) != Some(&CFB_MAGIC[..]) {
|
||||
if bytes.get(..8) != Some(&CFB_MAGIC[..]) || wordcraft_doc::cfb_guard::check(bytes).is_err() {
|
||||
return false;
|
||||
}
|
||||
match cfb::CompoundFile::open(Cursor::new(bytes)) {
|
||||
@@ -69,9 +87,13 @@ pub fn is_encrypted(bytes: &[u8]) -> bool {
|
||||
/// [`DocxError::PasswordRequired`] once it knows the file is one it can decrypt; a password that
|
||||
/// doesn't fit gives [`DocxError::WrongPassword`].
|
||||
pub fn decrypt(bytes: &[u8], password: Option<&str>) -> Result<Vec<u8>, DocxError> {
|
||||
wordcraft_doc::cfb_guard::check(bytes).map_err(|e| bad(format!("not a readable compound file ({e})")))?;
|
||||
let mut comp = cfb::CompoundFile::open(Cursor::new(bytes)).map_err(|e| bad(format!("not a readable compound file ({e})")))?;
|
||||
let info = stream(&mut comp, "/EncryptionInfo", MAX_INFO)?;
|
||||
let package = stream(&mut comp, "/EncryptedPackage", bytes.len() as u64)?;
|
||||
if matches!(info.get(..4), Some([2..=4, 0, 2, 0])) {
|
||||
return decrypt_standard(&info, &package, password);
|
||||
}
|
||||
let info = parse_info(&info)?;
|
||||
let Some(password) = password else { return Err(DocxError::PasswordRequired) };
|
||||
let pe = &info.password;
|
||||
@@ -125,6 +147,92 @@ pub fn decrypt(bytes: &[u8], password: Option<&str>) -> Result<Vec<u8>, DocxErro
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// [`decrypt`] for Standard encryption (§2.3.4.5–§2.3.4.9): one AES key from the password,
|
||||
/// checked with the verifier, and the whole package AES-ECB encrypted after its 8-byte size.
|
||||
fn decrypt_standard(info: &[u8], package: &[u8], password: Option<&str>) -> Result<Vec<u8>, DocxError> {
|
||||
let s = parse_standard(info)?;
|
||||
let Some(password) = password else { return Err(DocxError::PasswordRequired) };
|
||||
let key = standard_key(s.salt, password, s.key_bytes);
|
||||
// The verifier (§2.3.4.9): a random value and its SHA-1, both encrypted with the key.
|
||||
let verifier = Zeroizing::new(ecb(&key, s.verifier, false)?);
|
||||
let hash = Zeroizing::new(ecb(&key, s.verifier_hash, false)?);
|
||||
let hash = hash.get(..SHA1_SIZE).ok_or_else(|| bad("the password verifier hash is too short"))?;
|
||||
if !same(&Hash::Sha1.hash(&[&verifier]), hash) {
|
||||
return Err(DocxError::WrongPassword);
|
||||
}
|
||||
// The package (§2.3.4.4): StreamSize, then the data; bytes past the last block are ignored.
|
||||
let size = package
|
||||
.get(..8)
|
||||
.and_then(|b| <[u8; 8]>::try_from(b).ok())
|
||||
.map(u64::from_le_bytes)
|
||||
.ok_or_else(|| bad("the encrypted package is truncated"))?;
|
||||
let data = package.get(8..).unwrap_or(&[]);
|
||||
let size = usize::try_from(size).ok().filter(|s| *s <= data.len()).ok_or_else(|| bad("the encrypted package is truncated"))?;
|
||||
let needed = size.div_ceil(BLOCK).saturating_mul(BLOCK);
|
||||
let data = data.get(..needed).ok_or_else(|| bad("the encrypted package is truncated"))?;
|
||||
let mut out = ecb(&key, data, false)?;
|
||||
out.truncate(size);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// What a Standard `EncryptionInfo` stream says (§2.3.4.5): the key size, and the verifier's salt
|
||||
/// and encrypted values.
|
||||
struct Standard<'a> {
|
||||
key_bytes: usize,
|
||||
salt: &'a [u8],
|
||||
verifier: &'a [u8],
|
||||
verifier_hash: &'a [u8],
|
||||
}
|
||||
|
||||
fn parse_standard(info: &[u8]) -> Result<Standard<'_>, DocxError> {
|
||||
let short = || bad("EncryptionInfo is truncated");
|
||||
let u32_at = |at: usize| -> Result<u32, DocxError> {
|
||||
let b = at.checked_add(4).and_then(|end| info.get(at..end)).ok_or_else(short)?;
|
||||
<[u8; 4]>::try_from(b).map(u32::from_le_bytes).map_err(|_| short())
|
||||
};
|
||||
let flags = u32_at(4)?;
|
||||
if flags & F_EXTERNAL != 0 {
|
||||
return Err(bad("this file uses extensible encryption, which WordCraft can't open"));
|
||||
}
|
||||
if flags & STANDARD_FLAGS != STANDARD_FLAGS {
|
||||
return Err(bad("this file's encryption isn't AES, which WordCraft can't open"));
|
||||
}
|
||||
// EncryptionHeader (§2.3.2): Flags, SizeExtra, AlgID, AlgIDHash, KeySize, ProviderType, two
|
||||
// reserved words, then the CSP name, which isn't needed.
|
||||
let header_size = usize::try_from(u32_at(8)?).map_err(|_| short())?;
|
||||
if header_size < 32 {
|
||||
return Err(bad("the encryption header is too short"));
|
||||
}
|
||||
let verifier_at = header_size.checked_add(12).filter(|&v| v <= info.len()).ok_or_else(short)?;
|
||||
// AlgID 0 and AlgIDHash 0 mean "set by the flags": AES-128 and SHA-1.
|
||||
let bits = match u32_at(20)? {
|
||||
0 | ALG_AES_128 => 128,
|
||||
ALG_AES_192 => 192,
|
||||
ALG_AES_256 => 256,
|
||||
id => return Err(bad(format!("encryption algorithm 0x{id:X} isn't supported"))),
|
||||
};
|
||||
let hash_id = u32_at(24)?;
|
||||
if !matches!(hash_id, 0 | ALG_SHA1) {
|
||||
return Err(bad(format!("hash algorithm 0x{hash_id:X} isn't supported")));
|
||||
}
|
||||
if u32_at(28)? != bits {
|
||||
return Err(bad("the key size doesn't match the encryption algorithm"));
|
||||
}
|
||||
// EncryptionVerifier (§2.3.3): SaltSize (16), Salt, EncryptedVerifier, VerifierHashSize (20
|
||||
// for SHA-1), EncryptedVerifierHash (32 bytes for AES).
|
||||
if u32_at(verifier_at)? != SALT as u32 {
|
||||
return Err(bad("the salt isn't 16 bytes"));
|
||||
}
|
||||
let field = |at: usize, len: usize| verifier_at.checked_add(at).and_then(|a| info.get(a..a.checked_add(len)?)).ok_or_else(short);
|
||||
let salt = field(4, SALT)?;
|
||||
let verifier = field(4 + SALT, BLOCK)?;
|
||||
if u32_at(verifier_at.saturating_add(4 + SALT + BLOCK))? != SHA1_SIZE as u32 {
|
||||
return Err(bad("the password verifier hash isn't SHA-1"));
|
||||
}
|
||||
let verifier_hash = field(8 + SALT + BLOCK, 2 * BLOCK)?;
|
||||
Ok(Standard { key_bytes: bits as usize / 8, salt, verifier, verifier_hash })
|
||||
}
|
||||
|
||||
/// Encrypt a zip package with `password` (agile encryption: AES-256-CBC, SHA-512, Word's spin
|
||||
/// count), as Word's "Encrypt with Password" does.
|
||||
pub fn encrypt(package: &[u8], password: &str) -> Result<Vec<u8>, DocxError> {
|
||||
@@ -213,6 +321,47 @@ pub fn encrypt_with_spin_count(package: &[u8], password: &str, spin_count: u32)
|
||||
Ok(comp.into_inner().into_inner())
|
||||
}
|
||||
|
||||
/// A package encrypted with the *Standard* method (§2.3.4.5–§2.3.4.9: AES-ECB, SHA-1, 50,000
|
||||
/// spins), as Word 2007 wrote it, for tests only: WordCraft saves with the agile method
|
||||
/// ([`encrypt`]). `key_bits` is 128, 192 or 256. The compound file holds just the two streams
|
||||
/// [`decrypt`] reads.
|
||||
#[doc(hidden)]
|
||||
pub fn encrypt_standard_for_tests(package: &[u8], password: &str, key_bits: u32) -> Result<Vec<u8>, DocxError> {
|
||||
check_password(password)?;
|
||||
let alg_id = match key_bits {
|
||||
128 => ALG_AES_128,
|
||||
192 => ALG_AES_192,
|
||||
256 => ALG_AES_256,
|
||||
_ => return Err(bad(format!("unsupported AES key size {key_bits}"))),
|
||||
};
|
||||
let salt = random(SALT)?;
|
||||
let verifier = random(SALT)?;
|
||||
let key = standard_key(&salt, password, key_bits as usize / 8);
|
||||
let mut header = Vec::new();
|
||||
for v in [STANDARD_FLAGS, 0, alg_id, ALG_SHA1, key_bits, PROV_RSA_AES, 0, 0] {
|
||||
header.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
// CSPName: an empty null-terminated UTF-16 string.
|
||||
header.extend_from_slice(&[0, 0]);
|
||||
let mut info = vec![3, 0, 2, 0];
|
||||
info.extend_from_slice(&STANDARD_FLAGS.to_le_bytes());
|
||||
info.extend_from_slice(&(header.len() as u32).to_le_bytes());
|
||||
info.extend_from_slice(&header);
|
||||
info.extend_from_slice(&(SALT as u32).to_le_bytes());
|
||||
info.extend_from_slice(&salt);
|
||||
info.extend_from_slice(&ecb(&key, &verifier, true)?);
|
||||
info.extend_from_slice(&(SHA1_SIZE as u32).to_le_bytes());
|
||||
info.extend_from_slice(&ecb(&key, &padded(&Hash::Sha1.hash(&[&verifier])), true)?);
|
||||
let mut stream = (package.len() as u64).to_le_bytes().to_vec();
|
||||
stream.extend_from_slice(&ecb(&key, &padded(package), true)?);
|
||||
let io = |e: std::io::Error| bad(format!("couldn't write the compound file ({e})"));
|
||||
let mut comp = cfb::CompoundFile::create_with_version(cfb::Version::V3, Cursor::new(Vec::new())).map_err(io)?;
|
||||
comp.create_stream("/EncryptionInfo").map_err(io)?.write_all(&info).map_err(io)?;
|
||||
comp.create_stream("/EncryptedPackage").map_err(io)?.write_all(&stream).map_err(io)?;
|
||||
comp.flush().map_err(io)?;
|
||||
Ok(comp.into_inner().into_inner())
|
||||
}
|
||||
|
||||
/// Refuse a password Word wouldn't accept: empty, or longer than [`MAX_PASSWORD_CHARS`].
|
||||
pub fn check_password(password: &str) -> Result<(), DocxError> {
|
||||
if password.is_empty() {
|
||||
@@ -497,6 +646,45 @@ fn derive_key(hash: Hash, hn: &[u8], block_key: &[u8], len: usize) -> Zeroizing<
|
||||
k
|
||||
}
|
||||
|
||||
/// Standard encryption's key (§2.3.4.7): the SHA-1 password hash after 50,000 iterations, hashed
|
||||
/// with block number 0; then X1 = SHA-1 of that hash XOR-ed into 64 bytes of 0x36, X2 the same
|
||||
/// with 0x5C, and the key is the first `len` bytes of X1 + X2.
|
||||
fn standard_key(salt: &[u8], password: &str, len: usize) -> Zeroizing<Vec<u8>> {
|
||||
let hn = iterate(Hash::Sha1, salt, password, STANDARD_SPIN_COUNT);
|
||||
let hfinal = Zeroizing::new(Hash::Sha1.hash(&[&hn, &0u32.to_le_bytes()]));
|
||||
let x = |fill: u8| {
|
||||
let mut buf = Zeroizing::new([fill; 64]);
|
||||
for (b, h) in buf.iter_mut().zip(hfinal.iter()) {
|
||||
*b ^= h;
|
||||
}
|
||||
Zeroizing::new(Hash::Sha1.hash(&[&buf[..]]))
|
||||
};
|
||||
let mut key = Zeroizing::new(x(0x36).to_vec());
|
||||
key.extend_from_slice(&x(0x5C));
|
||||
key.truncate(len);
|
||||
key
|
||||
}
|
||||
|
||||
/// AES-ECB without padding (Standard encryption, §2.3.4.7–§2.3.4.9): `data` must be whole blocks.
|
||||
fn ecb(key: &[u8], data: &[u8], encrypt: bool) -> Result<Vec<u8>, DocxError> {
|
||||
let aes = Aes::new(key)?;
|
||||
let (blocks, rest) = data.as_chunks::<BLOCK>();
|
||||
if !rest.is_empty() {
|
||||
return Err(bad("encrypted data isn't a whole number of blocks"));
|
||||
}
|
||||
let mut out = Vec::with_capacity(data.len());
|
||||
for b in blocks {
|
||||
let mut x = aes::Block::from(*b);
|
||||
if encrypt {
|
||||
aes.encrypt(&mut x);
|
||||
} else {
|
||||
aes.decrypt(&mut x);
|
||||
}
|
||||
out.extend_from_slice(&x);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// An initialization vector (§2.3.4.12): H(salt + blockKey), or the salt itself, truncated to
|
||||
/// the block size or padded with 0x36.
|
||||
fn make_iv(hash: Hash, salt: &[u8], block_key: Option<&[u8]>, block_size: usize) -> Vec<u8> {
|
||||
@@ -643,7 +831,6 @@ fn parse_info(info: &[u8]) -> Result<Info, DocxError> {
|
||||
};
|
||||
match (major, minor) {
|
||||
(4, 4) => {}
|
||||
(2..=4, 2) => return Err(bad("this file uses Standard encryption (Office 2007), which WordCraft can't open yet")),
|
||||
(3 | 4, 3) => return Err(bad("this file uses extensible encryption, which WordCraft can't open")),
|
||||
_ => return Err(bad(format!("unknown encryption version {major}.{minor}"))),
|
||||
}
|
||||
@@ -797,199 +984,4 @@ fn primary_transform() -> Vec<u8> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn protection_password_round_trip() {
|
||||
use super::PasswordCheck::*;
|
||||
let attrs = super::protection_hash("s3cret", 1000).unwrap();
|
||||
assert_eq!(super::check_protection_password(&attrs, "s3cret"), Correct);
|
||||
assert_eq!(super::check_protection_password(&attrs, "S3cret"), Incorrect);
|
||||
// Damaged attributes are refused, never accepted.
|
||||
let mut bad = attrs.clone();
|
||||
bad.retain(|(k, _)| k != "w:hashValue");
|
||||
bad.push(("w:hashValue".into(), "not base64!".into()));
|
||||
assert_eq!(super::check_protection_password(&bad, "s3cret"), Unsupported);
|
||||
let mut odd = attrs.clone();
|
||||
odd.retain(|(k, _)| k != "w:algorithmName");
|
||||
odd.push(("w:algorithmName".into(), "WHIRLPOOL".into()));
|
||||
assert_eq!(super::check_protection_password(&odd, "s3cret"), Unsupported);
|
||||
assert_eq!(super::check_protection_password(&[("w:cryptProviderType".into(), "rsaAES".into())], "x"), NoPassword);
|
||||
}
|
||||
|
||||
/// The worked example of ECMA-376 Part 4 §14.8.1: "Example" gives the key 0x64CEED7E, hashed
|
||||
/// as "7EEDCE64".
|
||||
#[test]
|
||||
fn legacy_key_matches_the_specification_example() {
|
||||
assert_eq!(super::legacy_key("Example"), 0x64CE_ED7E);
|
||||
assert_eq!(super::legacy_key_hex("Example").as_str(), "7EEDCE64");
|
||||
assert_eq!(super::legacy_key(""), 0);
|
||||
// A leading BOM is dropped; only the first 15 characters count.
|
||||
assert_eq!(super::legacy_key("\u{FEFF}Example"), 0x64CE_ED7E);
|
||||
assert_eq!(super::legacy_key("abcdefghijklmnopq"), super::legacy_key("abcdefghijklmno"));
|
||||
assert_ne!(super::legacy_key("abcdefghijklmno"), super::legacy_key("abcdefghijklmn"));
|
||||
}
|
||||
|
||||
/// The legacy form (`w:cryptAlgorithmSid`, `w:hash`, `w:salt`, `w:cryptSpinCount`), as Word
|
||||
/// writes it. The expected hash was computed independently from the specification's stages.
|
||||
#[test]
|
||||
fn legacy_protection_hash_is_verified() {
|
||||
use super::PasswordCheck::*;
|
||||
let legacy = |sid: &str, hash: &str, spin: &str| -> Vec<(String, String)> {
|
||||
vec![
|
||||
("w:cryptProviderType".into(), "rsaFull".into()),
|
||||
("w:cryptAlgorithmClass".into(), "hash".into()),
|
||||
("w:cryptAlgorithmType".into(), "typeAny".into()),
|
||||
("w:cryptAlgorithmSid".into(), sid.into()),
|
||||
("w:cryptSpinCount".into(), spin.into()),
|
||||
("w:hash".into(), hash.into()),
|
||||
("w:salt".into(), "AAECAwQFBgcICQoLDA0ODw==".into()),
|
||||
]
|
||||
};
|
||||
let attrs = legacy("4", KNOWN_LEGACY_SHA1, "100000");
|
||||
assert_eq!(super::check_protection_password(&attrs, "Example"), Correct);
|
||||
assert_eq!(super::check_protection_password(&attrs, "example"), Incorrect);
|
||||
assert_eq!(super::check_protection_password(&attrs, ""), Incorrect);
|
||||
// MD5 (and the other algorithms WordCraft lacks), hostile spin counts: refused.
|
||||
assert_eq!(super::check_protection_password(&legacy("3", KNOWN_LEGACY_SHA1, "100000"), "Example"), Unsupported);
|
||||
assert_eq!(super::check_protection_password(&legacy("4", KNOWN_LEGACY_SHA1, "4294967295"), "Example"), Unsupported);
|
||||
assert_eq!(super::check_protection_password(&legacy("4", KNOWN_LEGACY_SHA1, "-1"), "Example"), Unsupported);
|
||||
let mut ext = attrs.clone();
|
||||
ext.push(("w:algIdExt".into(), "0000000A".into()));
|
||||
assert_eq!(super::check_protection_password(&ext, "Example"), Unsupported);
|
||||
// No algorithm named: the very old bare-hash form isn't defined, so it's refused.
|
||||
let bare = vec![("w:hash".to_string(), KNOWN_LEGACY_SHA1.to_string())];
|
||||
assert_eq!(super::check_protection_password(&bare, "Example"), Unsupported);
|
||||
}
|
||||
|
||||
/// SHA-1 of the salt 00 01 … 0F and "7EEDCE64" as UTF-16LE, iterated 100,000 times.
|
||||
const KNOWN_LEGACY_SHA1: &str = "pEWelKoH6SVpvMmyPAJ8bHA/g/s=";
|
||||
|
||||
use super::*;
|
||||
|
||||
fn zip_package() -> Vec<u8> {
|
||||
let mut doc = wordcraft_doc::Document::from_text("Top secret plans\nSecond paragraph");
|
||||
doc.core.title = "Secret".into();
|
||||
crate::write(&doc).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_with_a_password() {
|
||||
let pkg = zip_package();
|
||||
let enc = encrypt_with_spin_count(&pkg, "pässwörd 🔑", 16).unwrap();
|
||||
assert!(is_encrypted(&enc));
|
||||
assert!(!is_encrypted(&pkg));
|
||||
// The compound file carries the data spaces Word expects.
|
||||
let comp = cfb::CompoundFile::open(Cursor::new(&enc)).unwrap();
|
||||
assert!(comp.is_stream("/\u{6}DataSpaces/TransformInfo/StrongEncryptionTransform/\u{6}Primary"));
|
||||
assert_eq!(decrypt(&enc, Some("pässwörd 🔑")).unwrap(), pkg);
|
||||
let doc = crate::read_with_password(&enc, Some("pässwörd 🔑")).unwrap();
|
||||
assert!(doc.plain_text(wordcraft_doc::StoryRef::Body).contains("Top secret plans"));
|
||||
assert_eq!(doc.core.title, "Secret");
|
||||
// Without a password, or with the wrong one, it says so instead of opening blank.
|
||||
assert_eq!(crate::read(&enc), Err(DocxError::PasswordRequired));
|
||||
assert_eq!(crate::read_with_password(&enc, None), Err(DocxError::PasswordRequired));
|
||||
assert_eq!(decrypt(&enc, Some("Pässwörd 🔑")), Err(DocxError::WrongPassword));
|
||||
// A package that is a whole number of segments, and an empty one.
|
||||
for n in [0usize, SEGMENT, SEGMENT * 2 + 1] {
|
||||
let data: Vec<u8> = (0..n).map(|i| (i * 7) as u8).collect();
|
||||
let enc = encrypt_with_spin_count(&data, "x", 1).unwrap();
|
||||
assert_eq!(decrypt(&enc, Some("x")).unwrap(), data);
|
||||
}
|
||||
assert!(encrypt(&pkg, "").is_err());
|
||||
assert!(encrypt(&pkg, &"x".repeat(MAX_PASSWORD_CHARS + 1)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_without_data_integrity_is_refused() {
|
||||
let enc = encrypt_with_spin_count(&zip_package(), "pw", 4).unwrap();
|
||||
let mut comp = cfb::CompoundFile::open(Cursor::new(enc)).unwrap();
|
||||
let mut info = Vec::new();
|
||||
comp.open_stream("/EncryptionInfo").unwrap().read_to_end(&mut info).unwrap();
|
||||
let xml = String::from_utf8(info[8..].to_vec()).unwrap();
|
||||
let mut stripped = info[..8].to_vec();
|
||||
stripped.extend_from_slice(xml.replacen("<dataIntegrity", "<nothing", 1).as_bytes());
|
||||
comp.create_stream("/EncryptionInfo").unwrap().write_all(&stripped).unwrap();
|
||||
comp.flush().unwrap();
|
||||
let enc = comp.into_inner().into_inner();
|
||||
assert!(matches!(decrypt(&enc, Some("pw")), Err(DocxError::Encryption(m)) if m.contains("dataIntegrity")));
|
||||
assert!(crate::read_with_password(&enc, Some("pw")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampered_package_fails_the_integrity_check() {
|
||||
let pkg = zip_package();
|
||||
let enc = encrypt_with_spin_count(&pkg, "pw", 4).unwrap();
|
||||
let mut comp = cfb::CompoundFile::open(Cursor::new(enc)).unwrap();
|
||||
let mut data = Vec::new();
|
||||
comp.open_stream("/EncryptedPackage").unwrap().read_to_end(&mut data).unwrap();
|
||||
let last = data.len() - 1;
|
||||
data[last] ^= 1;
|
||||
comp.create_stream("/EncryptedPackage").unwrap().write_all(&data).unwrap();
|
||||
comp.flush().unwrap();
|
||||
let enc = comp.into_inner().into_inner();
|
||||
assert!(matches!(decrypt(&enc, Some("pw")), Err(DocxError::Encryption(m)) if m.contains("integrity")));
|
||||
}
|
||||
|
||||
/// A compound file with the given EncryptionInfo and EncryptedPackage streams.
|
||||
fn cfb_with(info: &[u8], package: &[u8]) -> Vec<u8> {
|
||||
let mut comp = cfb::CompoundFile::create(Cursor::new(Vec::new())).unwrap();
|
||||
comp.create_stream("/EncryptionInfo").unwrap().write_all(info).unwrap();
|
||||
comp.create_stream("/EncryptedPackage").unwrap().write_all(package).unwrap();
|
||||
comp.flush().unwrap();
|
||||
comp.into_inner().into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_encryption_info_errors_without_panicking() {
|
||||
let good = encrypt_with_spin_count(&zip_package(), "pw", 2).unwrap();
|
||||
let mut comp = cfb::CompoundFile::open(Cursor::new(&good)).unwrap();
|
||||
let mut info = Vec::new();
|
||||
comp.open_stream("/EncryptionInfo").unwrap().read_to_end(&mut info).unwrap();
|
||||
let mut package = Vec::new();
|
||||
comp.open_stream("/EncryptedPackage").unwrap().read_to_end(&mut package).unwrap();
|
||||
let xml = String::from_utf8(info[8..].to_vec()).unwrap();
|
||||
// (EncryptionInfo, EncryptedPackage, must fail). Truncations only must not panic: the
|
||||
// XML reader is lenient, so cutting the closing tags still reads.
|
||||
let mut cases: Vec<(Vec<u8>, Vec<u8>, bool)> = Vec::new();
|
||||
for n in (0..info.len()).step_by(7) {
|
||||
cases.push((info[..n].to_vec(), package.clone(), n < info.len() / 2));
|
||||
}
|
||||
for n in [0, 4, 8, 9, 100, package.len() - 1] {
|
||||
cases.push((info.clone(), package[..n].to_vec(), true));
|
||||
}
|
||||
// Standard and unknown versions, absurd numbers, bad base64, a huge StreamSize.
|
||||
let with = |from: &str, to: &str| {
|
||||
let mut v = info[..8].to_vec();
|
||||
v.extend_from_slice(xml.replacen(from, to, 2).as_bytes());
|
||||
v
|
||||
};
|
||||
cases.push(([3u8, 0, 2, 0, 0x24, 0, 0, 0].to_vec(), package.clone(), true));
|
||||
cases.push(([9u8, 0, 9, 0].to_vec(), package.clone(), true));
|
||||
cases.push((with("spinCount=\"2\"", "spinCount=\"4294967295\""), package.clone(), true));
|
||||
cases.push((with("keyBits=\"256\"", "keyBits=\"8\""), package.clone(), true));
|
||||
cases.push((with("blockSize=\"16\"", "blockSize=\"4096\""), package.clone(), true));
|
||||
cases.push((with("hashSize=\"64\"", "hashSize=\"0\""), package.clone(), true));
|
||||
cases.push((with("saltSize=\"16\"", "saltSize=\"99999999999999999999\""), package.clone(), true));
|
||||
cases.push((with("SHA512", "MD5"), package.clone(), true));
|
||||
cases.push((with("ChainingModeCBC", "ChainingModeCFB"), package.clone(), true));
|
||||
cases.push((with("saltValue=\"", "saltValue=\"!!"), package.clone(), true));
|
||||
cases.push((with("encryptedKeyValue=\"", "encryptedKeyValue=\"AAAA"), package.clone(), true));
|
||||
// Data integrity is mandatory: a file without it is refused, never opened unchecked.
|
||||
cases.push((with("<dataIntegrity", "<nothing"), package.clone(), true));
|
||||
let mut huge = package.clone();
|
||||
huge[..8].copy_from_slice(&u64::MAX.to_le_bytes());
|
||||
cases.push((with("<dataIntegrity", "<nothing"), huge, true));
|
||||
for (i, p, must_fail) in cases {
|
||||
let file = cfb_with(&i, &p);
|
||||
let r = decrypt(&file, Some("pw"));
|
||||
assert!(!must_fail || r.is_err());
|
||||
let _ = crate::read_with_password(&file, Some("pw"));
|
||||
assert!(crate::read(&file).is_err());
|
||||
}
|
||||
// A file that only looks like a compound file.
|
||||
let mut fake = CFB_MAGIC.to_vec();
|
||||
fake.extend_from_slice(&[0u8; 600]);
|
||||
assert!(!is_encrypted(&fake));
|
||||
assert!(decrypt(&fake, Some("pw")).is_err());
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
#[test]
|
||||
fn protection_password_round_trip() {
|
||||
use super::PasswordCheck::*;
|
||||
let attrs = super::protection_hash("s3cret", 1000).unwrap();
|
||||
assert_eq!(super::check_protection_password(&attrs, "s3cret"), Correct);
|
||||
assert_eq!(super::check_protection_password(&attrs, "S3cret"), Incorrect);
|
||||
// Damaged attributes are refused, never accepted.
|
||||
let mut bad = attrs.clone();
|
||||
bad.retain(|(k, _)| k != "w:hashValue");
|
||||
bad.push(("w:hashValue".into(), "not base64!".into()));
|
||||
assert_eq!(super::check_protection_password(&bad, "s3cret"), Unsupported);
|
||||
let mut odd = attrs.clone();
|
||||
odd.retain(|(k, _)| k != "w:algorithmName");
|
||||
odd.push(("w:algorithmName".into(), "WHIRLPOOL".into()));
|
||||
assert_eq!(super::check_protection_password(&odd, "s3cret"), Unsupported);
|
||||
assert_eq!(super::check_protection_password(&[("w:cryptProviderType".into(), "rsaAES".into())], "x"), NoPassword);
|
||||
}
|
||||
|
||||
/// The worked example of ECMA-376 Part 4 §14.8.1: "Example" gives the key 0x64CEED7E, hashed
|
||||
/// as "7EEDCE64".
|
||||
#[test]
|
||||
fn legacy_key_matches_the_specification_example() {
|
||||
assert_eq!(super::legacy_key("Example"), 0x64CE_ED7E);
|
||||
assert_eq!(super::legacy_key_hex("Example").as_str(), "7EEDCE64");
|
||||
assert_eq!(super::legacy_key(""), 0);
|
||||
// A leading BOM is dropped; only the first 15 characters count.
|
||||
assert_eq!(super::legacy_key("\u{FEFF}Example"), 0x64CE_ED7E);
|
||||
assert_eq!(super::legacy_key("abcdefghijklmnopq"), super::legacy_key("abcdefghijklmno"));
|
||||
assert_ne!(super::legacy_key("abcdefghijklmno"), super::legacy_key("abcdefghijklmn"));
|
||||
}
|
||||
|
||||
/// The legacy form (`w:cryptAlgorithmSid`, `w:hash`, `w:salt`, `w:cryptSpinCount`), as Word
|
||||
/// writes it. The expected hash was computed independently from the specification's stages.
|
||||
#[test]
|
||||
fn legacy_protection_hash_is_verified() {
|
||||
use super::PasswordCheck::*;
|
||||
let legacy = |sid: &str, hash: &str, spin: &str| -> Vec<(String, String)> {
|
||||
vec![
|
||||
("w:cryptProviderType".into(), "rsaFull".into()),
|
||||
("w:cryptAlgorithmClass".into(), "hash".into()),
|
||||
("w:cryptAlgorithmType".into(), "typeAny".into()),
|
||||
("w:cryptAlgorithmSid".into(), sid.into()),
|
||||
("w:cryptSpinCount".into(), spin.into()),
|
||||
("w:hash".into(), hash.into()),
|
||||
("w:salt".into(), "AAECAwQFBgcICQoLDA0ODw==".into()),
|
||||
]
|
||||
};
|
||||
let attrs = legacy("4", KNOWN_LEGACY_SHA1, "100000");
|
||||
assert_eq!(super::check_protection_password(&attrs, "Example"), Correct);
|
||||
assert_eq!(super::check_protection_password(&attrs, "example"), Incorrect);
|
||||
assert_eq!(super::check_protection_password(&attrs, ""), Incorrect);
|
||||
// MD5 (and the other algorithms WordCraft lacks), hostile spin counts: refused.
|
||||
assert_eq!(super::check_protection_password(&legacy("3", KNOWN_LEGACY_SHA1, "100000"), "Example"), Unsupported);
|
||||
assert_eq!(super::check_protection_password(&legacy("4", KNOWN_LEGACY_SHA1, "4294967295"), "Example"), Unsupported);
|
||||
assert_eq!(super::check_protection_password(&legacy("4", KNOWN_LEGACY_SHA1, "-1"), "Example"), Unsupported);
|
||||
let mut ext = attrs.clone();
|
||||
ext.push(("w:algIdExt".into(), "0000000A".into()));
|
||||
assert_eq!(super::check_protection_password(&ext, "Example"), Unsupported);
|
||||
// No algorithm named: the very old bare-hash form isn't defined, so it's refused.
|
||||
let bare = vec![("w:hash".to_string(), KNOWN_LEGACY_SHA1.to_string())];
|
||||
assert_eq!(super::check_protection_password(&bare, "Example"), Unsupported);
|
||||
}
|
||||
|
||||
/// SHA-1 of the salt 00 01 … 0F and "7EEDCE64" as UTF-16LE, iterated 100,000 times.
|
||||
const KNOWN_LEGACY_SHA1: &str = "pEWelKoH6SVpvMmyPAJ8bHA/g/s=";
|
||||
|
||||
use super::*;
|
||||
|
||||
fn zip_package() -> Vec<u8> {
|
||||
let mut doc = wordcraft_doc::Document::from_text("Top secret plans\nSecond paragraph");
|
||||
doc.core.title = "Secret".into();
|
||||
crate::write(&doc).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_with_a_password() {
|
||||
let pkg = zip_package();
|
||||
let enc = encrypt_with_spin_count(&pkg, "pässwörd 🔑", 16).unwrap();
|
||||
assert!(is_encrypted(&enc));
|
||||
assert!(!is_encrypted(&pkg));
|
||||
// The compound file carries the data spaces Word expects.
|
||||
let comp = cfb::CompoundFile::open(Cursor::new(&enc)).unwrap();
|
||||
assert!(comp.is_stream("/\u{6}DataSpaces/TransformInfo/StrongEncryptionTransform/\u{6}Primary"));
|
||||
assert_eq!(decrypt(&enc, Some("pässwörd 🔑")).unwrap(), pkg);
|
||||
let doc = crate::read_with_password(&enc, Some("pässwörd 🔑")).unwrap();
|
||||
assert!(doc.plain_text(wordcraft_doc::StoryRef::Body).contains("Top secret plans"));
|
||||
assert_eq!(doc.core.title, "Secret");
|
||||
// Without a password, or with the wrong one, it says so instead of opening blank.
|
||||
assert_eq!(crate::read(&enc), Err(DocxError::PasswordRequired));
|
||||
assert_eq!(crate::read_with_password(&enc, None), Err(DocxError::PasswordRequired));
|
||||
assert_eq!(decrypt(&enc, Some("Pässwörd 🔑")), Err(DocxError::WrongPassword));
|
||||
// A package that is a whole number of segments, and an empty one.
|
||||
for n in [0usize, SEGMENT, SEGMENT * 2 + 1] {
|
||||
let data: Vec<u8> = (0..n).map(|i| (i * 7) as u8).collect();
|
||||
let enc = encrypt_with_spin_count(&data, "x", 1).unwrap();
|
||||
assert_eq!(decrypt(&enc, Some("x")).unwrap(), data);
|
||||
}
|
||||
assert!(encrypt(&pkg, "").is_err());
|
||||
assert!(encrypt(&pkg, &"x".repeat(MAX_PASSWORD_CHARS + 1)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_without_data_integrity_is_refused() {
|
||||
let enc = encrypt_with_spin_count(&zip_package(), "pw", 4).unwrap();
|
||||
let mut comp = cfb::CompoundFile::open(Cursor::new(enc)).unwrap();
|
||||
let mut info = Vec::new();
|
||||
comp.open_stream("/EncryptionInfo").unwrap().read_to_end(&mut info).unwrap();
|
||||
let xml = String::from_utf8(info[8..].to_vec()).unwrap();
|
||||
let mut stripped = info[..8].to_vec();
|
||||
stripped.extend_from_slice(xml.replacen("<dataIntegrity", "<nothing", 1).as_bytes());
|
||||
comp.create_stream("/EncryptionInfo").unwrap().write_all(&stripped).unwrap();
|
||||
comp.flush().unwrap();
|
||||
let enc = comp.into_inner().into_inner();
|
||||
assert!(matches!(decrypt(&enc, Some("pw")), Err(DocxError::Encryption(m)) if m.contains("dataIntegrity")));
|
||||
assert!(crate::read_with_password(&enc, Some("pw")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampered_package_fails_the_integrity_check() {
|
||||
let pkg = zip_package();
|
||||
let enc = encrypt_with_spin_count(&pkg, "pw", 4).unwrap();
|
||||
let mut comp = cfb::CompoundFile::open(Cursor::new(enc)).unwrap();
|
||||
let mut data = Vec::new();
|
||||
comp.open_stream("/EncryptedPackage").unwrap().read_to_end(&mut data).unwrap();
|
||||
let last = data.len() - 1;
|
||||
data[last] ^= 1;
|
||||
comp.create_stream("/EncryptedPackage").unwrap().write_all(&data).unwrap();
|
||||
comp.flush().unwrap();
|
||||
let enc = comp.into_inner().into_inner();
|
||||
assert!(matches!(decrypt(&enc, Some("pw")), Err(DocxError::Encryption(m)) if m.contains("integrity")));
|
||||
}
|
||||
|
||||
/// A compound file with the given EncryptionInfo and EncryptedPackage streams.
|
||||
fn cfb_with(info: &[u8], package: &[u8]) -> Vec<u8> {
|
||||
let mut comp = cfb::CompoundFile::create(Cursor::new(Vec::new())).unwrap();
|
||||
comp.create_stream("/EncryptionInfo").unwrap().write_all(info).unwrap();
|
||||
comp.create_stream("/EncryptedPackage").unwrap().write_all(package).unwrap();
|
||||
comp.flush().unwrap();
|
||||
comp.into_inner().into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_encryption_info_errors_without_panicking() {
|
||||
let good = encrypt_with_spin_count(&zip_package(), "pw", 2).unwrap();
|
||||
let mut comp = cfb::CompoundFile::open(Cursor::new(&good)).unwrap();
|
||||
let mut info = Vec::new();
|
||||
comp.open_stream("/EncryptionInfo").unwrap().read_to_end(&mut info).unwrap();
|
||||
let mut package = Vec::new();
|
||||
comp.open_stream("/EncryptedPackage").unwrap().read_to_end(&mut package).unwrap();
|
||||
let xml = String::from_utf8(info[8..].to_vec()).unwrap();
|
||||
// (EncryptionInfo, EncryptedPackage, must fail). Truncations only must not panic: the
|
||||
// XML reader is lenient, so cutting the closing tags still reads.
|
||||
let mut cases: Vec<(Vec<u8>, Vec<u8>, bool)> = Vec::new();
|
||||
for n in (0..info.len()).step_by(7) {
|
||||
cases.push((info[..n].to_vec(), package.clone(), n < info.len() / 2));
|
||||
}
|
||||
for n in [0, 4, 8, 9, 100, package.len() - 1] {
|
||||
cases.push((info.clone(), package[..n].to_vec(), true));
|
||||
}
|
||||
// Standard and unknown versions, absurd numbers, bad base64, a huge StreamSize.
|
||||
let with = |from: &str, to: &str| {
|
||||
let mut v = info[..8].to_vec();
|
||||
v.extend_from_slice(xml.replacen(from, to, 2).as_bytes());
|
||||
v
|
||||
};
|
||||
cases.push(([3u8, 0, 2, 0, 0x24, 0, 0, 0].to_vec(), package.clone(), true));
|
||||
cases.push(([9u8, 0, 9, 0].to_vec(), package.clone(), true));
|
||||
cases.push((with("spinCount=\"2\"", "spinCount=\"4294967295\""), package.clone(), true));
|
||||
cases.push((with("keyBits=\"256\"", "keyBits=\"8\""), package.clone(), true));
|
||||
cases.push((with("blockSize=\"16\"", "blockSize=\"4096\""), package.clone(), true));
|
||||
cases.push((with("hashSize=\"64\"", "hashSize=\"0\""), package.clone(), true));
|
||||
cases.push((with("saltSize=\"16\"", "saltSize=\"99999999999999999999\""), package.clone(), true));
|
||||
cases.push((with("SHA512", "MD5"), package.clone(), true));
|
||||
cases.push((with("ChainingModeCBC", "ChainingModeCFB"), package.clone(), true));
|
||||
cases.push((with("saltValue=\"", "saltValue=\"!!"), package.clone(), true));
|
||||
cases.push((with("encryptedKeyValue=\"", "encryptedKeyValue=\"AAAA"), package.clone(), true));
|
||||
// Data integrity is mandatory: a file without it is refused, never opened unchecked.
|
||||
cases.push((with("<dataIntegrity", "<nothing"), package.clone(), true));
|
||||
let mut huge = package.clone();
|
||||
huge[..8].copy_from_slice(&u64::MAX.to_le_bytes());
|
||||
cases.push((with("<dataIntegrity", "<nothing"), huge, true));
|
||||
for (i, p, must_fail) in cases {
|
||||
let file = cfb_with(&i, &p);
|
||||
let r = decrypt(&file, Some("pw"));
|
||||
assert!(!must_fail || r.is_err());
|
||||
let _ = crate::read_with_password(&file, Some("pw"));
|
||||
assert!(crate::read(&file).is_err());
|
||||
}
|
||||
// A file that only looks like a compound file.
|
||||
let mut fake = CFB_MAGIC.to_vec();
|
||||
fake.extend_from_slice(&[0u8; 600]);
|
||||
assert!(!is_encrypted(&fake));
|
||||
assert!(decrypt(&fake, Some("pw")).is_err());
|
||||
}
|
||||
|
||||
/// The key a real-world Standard-encrypted package derives: Apache POI's test file
|
||||
/// `protected_passtika.xlsx` (Apache-2.0 test data), password "tika", written by Excel
|
||||
/// itself. The value was computed from [MS-OFFCRYPTO] §2.3.4.7 independently of this code —
|
||||
/// the file decrypts to a valid package under it and nothing else opens that file — so real
|
||||
/// Word 2007 documents stay pinned, not just spec-derived ones.
|
||||
#[test]
|
||||
fn standard_key_matches_a_real_world_package() {
|
||||
let salt: [u8; 16] = [0x19, 0xd0, 0xc7, 0xf3, 0x95, 0x28, 0x78, 0x43, 0x6a, 0x49, 0x49, 0xd7, 0xf0, 0xa2, 0xcf, 0x1e];
|
||||
const KEY: &str = "8d4d00143a5f2e4918af7e6e7410d46c";
|
||||
let hex = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::<String>();
|
||||
assert_eq!(hex(&standard_key(&salt, "tika", 16)), KEY);
|
||||
// A different password or salt gives a different key; the key is exactly the length
|
||||
// asked for, for each AES width.
|
||||
assert_ne!(hex(&standard_key(&salt, "tika2", 16)), KEY);
|
||||
assert_ne!(hex(&standard_key(&[0; 16], "tika", 16)), KEY);
|
||||
assert_eq!(standard_key(&salt, "tika", 24).len(), 24);
|
||||
assert_eq!(standard_key(&salt, "tika", 32).len(), 32);
|
||||
}
|
||||
|
||||
/// Standard encryption's key derivation (§2.3.4.7) for the password "Password1234_" and the
|
||||
/// salt 00 01 … 0F. The expected keys were computed outside WordCraft (Python's hashlib)
|
||||
/// following the specification's steps, so the encryptor and decryptor can't share a mistake.
|
||||
#[test]
|
||||
fn standard_key_matches_an_independent_derivation() {
|
||||
let salt: Vec<u8> = (0..16).collect();
|
||||
let hex = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::<String>();
|
||||
assert_eq!(hex(&standard_key(&salt, "Password1234_", 16)), "340fc93a8e2154db8304d055df81cebc");
|
||||
assert_eq!(hex(&standard_key(&salt, "Password1234_", 32)), "340fc93a8e2154db8304d055df81cebcef5e89fb8e5eb520efdca76f3330e032");
|
||||
assert_eq!(standard_key(&salt, "Password1234_", 24).len(), 24);
|
||||
// AES-ECB against FIPS-197 Appendix C.1 and C.3.
|
||||
let pt: Vec<u8> = (0..16u8).map(|i| i * 0x11).collect();
|
||||
let k128: Vec<u8> = (0..16).collect();
|
||||
let k256: Vec<u8> = (0..32).collect();
|
||||
assert_eq!(hex(&ecb(&k128, &pt, true).unwrap()), "69c4e0d86a7b0430d8cdb78070b4c55a");
|
||||
assert_eq!(hex(&ecb(&k256, &pt, true).unwrap()), "8ea2b7ca516745bfeafc49904b496089");
|
||||
assert_eq!(ecb(&k256, &ecb(&k256, &pt, true).unwrap(), false).unwrap(), pt);
|
||||
assert!(ecb(&k128, &pt[..15], false).is_err());
|
||||
}
|
||||
|
||||
/// Standard encryption (Word 2007's AES-ECB method) opens with the right password, for each
|
||||
/// AES key size, and says when the password is missing or wrong — as agile files do.
|
||||
#[test]
|
||||
fn standard_encryption_round_trips() {
|
||||
let pkg = zip_package();
|
||||
for bits in [128, 192, 256] {
|
||||
let enc = encrypt_standard_for_tests(&pkg, "pässwörd 🔑", bits).unwrap();
|
||||
assert!(is_encrypted(&enc));
|
||||
assert_eq!(decrypt(&enc, Some("pässwörd 🔑")), Ok(pkg.clone()), "AES-{bits}");
|
||||
assert_eq!(decrypt(&enc, None), Err(DocxError::PasswordRequired));
|
||||
assert_eq!(decrypt(&enc, Some("Pässwörd 🔑")), Err(DocxError::WrongPassword));
|
||||
assert_eq!(crate::read(&enc), Err(DocxError::PasswordRequired));
|
||||
}
|
||||
let enc = encrypt_standard_for_tests(&pkg, "pw", 128).unwrap();
|
||||
let doc = crate::read_with_password(&enc, Some("pw")).unwrap();
|
||||
assert!(doc.plain_text(wordcraft_doc::StoryRef::Body).contains("Top secret plans"));
|
||||
// Sizes on and off the block boundary, and empty.
|
||||
for n in [0usize, 1, 15, 16, 17, SEGMENT + 3] {
|
||||
let data: Vec<u8> = (0..n).map(|i| (i * 7) as u8).collect();
|
||||
assert_eq!(decrypt(&encrypt_standard_for_tests(&data, "x", 256).unwrap(), Some("x")).unwrap(), data);
|
||||
}
|
||||
assert!(encrypt_standard_for_tests(&pkg, "pw", 64).is_err());
|
||||
assert!(encrypt_standard_for_tests(&pkg, "", 128).is_err());
|
||||
}
|
||||
|
||||
/// A Standard-encrypted file assembled byte by byte from [MS-OFFCRYPTO] §2.3.4.5, §2.3.2, §2.3.3
|
||||
/// and §2.3.4.4 with literal field values, without the test encryptor, so a layout mistake
|
||||
/// shared by the encryptor and the reader can't pass. Only the key derivation and AES-ECB
|
||||
/// (pinned by `standard_key_matches_an_independent_derivation`) are reused.
|
||||
fn hand_built_standard(payload: &[u8], password: &str) -> Vec<u8> {
|
||||
let salt: [u8; 16] = [0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD, 0xAE, 0xAF];
|
||||
let verifier: [u8; 16] = *b"verifier-16bytes";
|
||||
let key = standard_key(&salt, password, 16);
|
||||
let mut hash = sha1::Sha1::digest(verifier).to_vec();
|
||||
hash.resize(32, 0); // the hash, zero-padded to two AES blocks
|
||||
let csp: Vec<u8> = "WordCraft test provider".encode_utf16().chain([0]).flat_map(u16::to_le_bytes).collect();
|
||||
|
||||
let mut header = Vec::new();
|
||||
header.extend_from_slice(&0x24u32.to_le_bytes()); // Flags: fCryptoAPI | fAES
|
||||
header.extend_from_slice(&0u32.to_le_bytes()); // SizeExtra
|
||||
header.extend_from_slice(&0x660Eu32.to_le_bytes()); // AlgID: AES-128
|
||||
header.extend_from_slice(&0x8004u32.to_le_bytes()); // AlgIDHash: SHA-1
|
||||
header.extend_from_slice(&128u32.to_le_bytes()); // KeySize, in bits
|
||||
header.extend_from_slice(&0x18u32.to_le_bytes()); // ProviderType: AES
|
||||
header.extend_from_slice(&0u32.to_le_bytes()); // Reserved1
|
||||
header.extend_from_slice(&0u32.to_le_bytes()); // Reserved2
|
||||
header.extend_from_slice(&csp); // CSPName: UTF-16LE, NUL-terminated
|
||||
|
||||
let mut info = Vec::new();
|
||||
info.extend_from_slice(&3u16.to_le_bytes()); // Version major
|
||||
info.extend_from_slice(&2u16.to_le_bytes()); // Version minor
|
||||
info.extend_from_slice(&0x24u32.to_le_bytes()); // Flags: fCryptoAPI | fAES
|
||||
info.extend_from_slice(&(header.len() as u32).to_le_bytes()); // HeaderSize
|
||||
info.extend_from_slice(&header);
|
||||
info.extend_from_slice(&16u32.to_le_bytes()); // SaltSize
|
||||
info.extend_from_slice(&salt); // Salt
|
||||
info.extend_from_slice(&ecb(&key, &verifier, true).unwrap()); // EncryptedVerifier
|
||||
info.extend_from_slice(&20u32.to_le_bytes()); // VerifierHashSize (SHA-1)
|
||||
info.extend_from_slice(&ecb(&key, &hash, true).unwrap()); // EncryptedVerifierHash
|
||||
|
||||
let mut padded = payload.to_vec();
|
||||
padded.resize(payload.len().next_multiple_of(16), 0);
|
||||
let mut package = (payload.len() as u64).to_le_bytes().to_vec(); // StreamSize
|
||||
package.extend_from_slice(&ecb(&key, &padded, true).unwrap());
|
||||
package.extend_from_slice(&[0xEE, 0xEE, 0xEE]); // trailing padding the spec allows
|
||||
cfb_with(&info, &package)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_package_built_by_hand_from_the_spec_opens() {
|
||||
let payload: Vec<u8> = (0..37u8).map(|i| i.wrapping_mul(5)).collect();
|
||||
let file = hand_built_standard(&payload, "pw");
|
||||
assert!(is_encrypted(&file));
|
||||
assert_eq!(decrypt(&file, Some("pw")), Ok(payload.clone()));
|
||||
assert_eq!(decrypt(&file, Some("PW")), Err(DocxError::WrongPassword));
|
||||
assert_eq!(decrypt(&file, None), Err(DocxError::PasswordRequired));
|
||||
// The test encryptor writes the same fixed fields: version and flags (bytes 0..8) and the
|
||||
// header's eight words (12..44); HeaderSize differs with the CSP name, the rest is random.
|
||||
let (hand, _) = streams(&file);
|
||||
let (made, _) = streams(&encrypt_standard_for_tests(&payload, "pw", 128).unwrap());
|
||||
assert_eq!(hand[..8], made[..8]);
|
||||
assert_eq!(hand[12..44], made[12..44]);
|
||||
}
|
||||
|
||||
/// The two streams of an encrypted package.
|
||||
fn streams(file: &[u8]) -> (Vec<u8>, Vec<u8>) {
|
||||
let mut comp = cfb::CompoundFile::open(Cursor::new(file)).unwrap();
|
||||
let mut info = Vec::new();
|
||||
comp.open_stream("/EncryptionInfo").unwrap().read_to_end(&mut info).unwrap();
|
||||
let mut package = Vec::new();
|
||||
comp.open_stream("/EncryptedPackage").unwrap().read_to_end(&mut package).unwrap();
|
||||
(info, package)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_standard_encryption_info_errors_without_panicking() {
|
||||
let data = b"a small package".to_vec();
|
||||
let (info, package) = streams(&encrypt_standard_for_tests(&data, "pw", 128).unwrap());
|
||||
// Layout: version 0, flags 4, header size 8, header 12 (flags, sizeExtra, algId 20,
|
||||
// algIdHash 24, keySize 28, provider, 2 reserved, an empty CSP name), then the verifier at
|
||||
// 46: saltSize, salt 50, encrypted verifier 66, verifierHashSize 82, its hash 86..118.
|
||||
assert_eq!(info.len(), 118);
|
||||
let put = |at: usize, v: u32| {
|
||||
let mut i = info.clone();
|
||||
i[at..at + 4].copy_from_slice(&v.to_le_bytes());
|
||||
i
|
||||
};
|
||||
let mut cases: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
|
||||
for n in 0..info.len() {
|
||||
cases.push((info[..n].to_vec(), package.clone()));
|
||||
}
|
||||
for v in [0, 4, 31, 0x1000, u32::MAX] {
|
||||
cases.push((put(8, v), package.clone()));
|
||||
}
|
||||
// No AES, no CryptoAPI, an external (extensible) provider.
|
||||
for v in [F_CRYPTO_API, F_AES, STANDARD_FLAGS | F_EXTERNAL, 0] {
|
||||
cases.push((put(4, v), package.clone()));
|
||||
}
|
||||
// RC4, MD5, key sizes that don't fit the algorithm.
|
||||
cases.push((put(20, 0x6801), package.clone()));
|
||||
cases.push((put(24, 0x8003), package.clone()));
|
||||
for v in [0, 64, 192, 256, u32::MAX] {
|
||||
cases.push((put(28, v), package.clone()));
|
||||
}
|
||||
for v in [0, 15, 17, u32::MAX] {
|
||||
cases.push((put(46, v), package.clone()));
|
||||
}
|
||||
for v in [0, 16, 32, u32::MAX] {
|
||||
cases.push((put(82, v), package.clone()));
|
||||
}
|
||||
// The package: truncated, an absurd StreamSize, a StreamSize past the data, data that
|
||||
// isn't whole blocks.
|
||||
for n in [0, 4, 8, 9, package.len() - 1] {
|
||||
cases.push((info.clone(), package[..n].to_vec()));
|
||||
}
|
||||
for size in [u64::MAX, 1 << 40, package.len() as u64] {
|
||||
let mut p = package.clone();
|
||||
p[..8].copy_from_slice(&size.to_le_bytes());
|
||||
cases.push((info.clone(), p));
|
||||
}
|
||||
for (i, p) in cases {
|
||||
let file = cfb_with(&i, &p);
|
||||
assert!(decrypt(&file, Some("pw")).is_err());
|
||||
let _ = crate::read_with_password(&file, Some("pw"));
|
||||
assert!(crate::read(&file).is_err());
|
||||
}
|
||||
// Version 2.2 and 4.2 are Standard too; trailing bytes after the last block are ignored.
|
||||
for v in [2u8, 4] {
|
||||
let mut i = info.clone();
|
||||
i[0] = v;
|
||||
assert_eq!(decrypt(&cfb_with(&i, &package), Some("pw")).unwrap(), data);
|
||||
}
|
||||
let mut p = package.clone();
|
||||
p.extend_from_slice(&[1, 2, 3]);
|
||||
assert_eq!(decrypt(&cfb_with(&info, &p), Some("pw")).unwrap(), data);
|
||||
// AlgID and AlgIDHash 0 mean "from the flags": AES-128 and SHA-1.
|
||||
assert_eq!(decrypt(&cfb_with(&put(20, 0), &package), Some("pw")).unwrap(), data);
|
||||
assert_eq!(decrypt(&cfb_with(&put(24, 0), &package), Some("pw")).unwrap(), data);
|
||||
}
|
||||
@@ -25,11 +25,11 @@ pub fn read(root: &El) -> Vec<CustomProp> {
|
||||
let Some(v) = p.els().next() else { continue };
|
||||
let kind = v.local();
|
||||
let prop = if SIMPLE.contains(&kind) {
|
||||
CustomProp { name: name.to_string(), kind: kind.to_string(), value: v.text() }
|
||||
CustomProp { name: name.to_string(), kind: kind.to_string(), value: v.text(), link_target: p.attr("linkTarget").map(str::to_string) }
|
||||
} else {
|
||||
let mut xml = String::new();
|
||||
el_xml(v, &mut xml, 0);
|
||||
CustomProp { name: name.to_string(), kind: "raw".into(), value: xml }
|
||||
CustomProp { name: name.to_string(), kind: "raw".into(), value: xml, link_target: p.attr("linkTarget").map(str::to_string) }
|
||||
};
|
||||
out.push(prop);
|
||||
}
|
||||
@@ -51,7 +51,12 @@ pub fn write(props: &[CustomProp]) -> Vec<u8> {
|
||||
if p.kind == "raw" && !raw_is_wellformed(&p.value) {
|
||||
continue;
|
||||
}
|
||||
w.open("property", &[("fmtid", FMTID), ("pid", &pid.to_string()), ("name", &p.name)]);
|
||||
let pid = pid.to_string();
|
||||
let mut attrs = vec![("fmtid", FMTID), ("pid", &pid), ("name", &p.name)];
|
||||
if let Some(target) = &p.link_target {
|
||||
attrs.push(("linkTarget", target));
|
||||
}
|
||||
w.open("property", &attrs);
|
||||
if p.kind == "raw" {
|
||||
w.raw(&p.value);
|
||||
} else {
|
||||
|
||||
+99
-1
@@ -437,7 +437,47 @@ fn xml_fragment<'a>(ct: &str, bytes: &'a [u8]) -> Option<&'a str> {
|
||||
Some(rest) => rest.split_once("?>").map(|(_, r)| r)?.trim_start(),
|
||||
None => s,
|
||||
};
|
||||
(s.starts_with('<') && !s.contains("<!DOCTYPE")).then_some(s)
|
||||
(s.starts_with('<') && !s.contains("<!DOCTYPE") && one_element(s)).then_some(s)
|
||||
}
|
||||
|
||||
/// Whether `s` is exactly one well-formed element, with only whitespace, comments and processing
|
||||
/// instructions around it. Kept bytes are inlined into `pkg:xmlData` only then; anything else could
|
||||
/// close the wrapper and add parts of its own, so it goes out as `pkg:binaryData` (#3742).
|
||||
fn one_element(s: &str) -> bool {
|
||||
let mut r = quick_xml::Reader::from_str(s);
|
||||
r.config_mut().check_comments = true;
|
||||
let (mut depth, mut roots) = (0usize, 0usize);
|
||||
loop {
|
||||
let attrs_ok = |e: &BytesStart| e.attributes().all(|a| a.is_ok());
|
||||
match r.read_event() {
|
||||
Ok(Event::Start(e)) => {
|
||||
if !attrs_ok(&e) || (depth == 0 && roots > 0) {
|
||||
return false;
|
||||
}
|
||||
roots += usize::from(depth == 0);
|
||||
depth += 1;
|
||||
}
|
||||
Ok(Event::Empty(e)) => {
|
||||
if !attrs_ok(&e) || (depth == 0 && roots > 0) {
|
||||
return false;
|
||||
}
|
||||
roots += usize::from(depth == 0);
|
||||
}
|
||||
Ok(Event::End(_)) => match depth.checked_sub(1) {
|
||||
Some(d) => depth = d,
|
||||
None => return false,
|
||||
},
|
||||
Ok(Event::Text(t)) if depth == 0 => {
|
||||
if !t.iter().all(u8::is_ascii_whitespace) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Ok(Event::CData(_) | Event::GeneralRef(_)) if depth == 0 => return false,
|
||||
Ok(Event::Decl(_) | Event::DocType(_)) | Err(_) => return false,
|
||||
Ok(Event::Eof) => return depth == 0 && roots == 1,
|
||||
Ok(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -494,6 +534,64 @@ mod tests {
|
||||
assert!(matches!(unpack(package(xml::MAX_DEPTH + 1).as_bytes()), Err(DocxError::Limit(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kept_xml_that_would_break_out_of_its_part_is_written_in_base64() {
|
||||
// A part kept from a file is written back as it was read; inlined as is, markup that
|
||||
// closes the wrapper adds parts of its own (here a second core properties part, read
|
||||
// before the real one) (#3742).
|
||||
let evil = format!(
|
||||
"<a/></pkg:xmlData></pkg:part><pkg:part pkg:name=\"/docProps/core.xml\" pkg:contentType=\"application/vnd.openxmlformats-package.core-properties+xml\"><pkg:xmlData><cp:coreProperties xmlns:cp=\"{}\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\"><dc:title>Injected</dc:title></cp:coreProperties></pkg:xmlData></pkg:part><pkg:part pkg:name=\"/x.xml\"><pkg:xmlData><b>",
|
||||
"http://schemas.openxmlformats.org/package/2006/metadata/core-properties"
|
||||
);
|
||||
let w_ns = "xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"";
|
||||
let rel = |id: &str, kind: &str, target: &str| format!("<Relationship Id=\"{id}\" Type=\"{kind}\" Target=\"{target}\"/>");
|
||||
let rels = format!(
|
||||
"<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">{}{}</Relationships>",
|
||||
rel("rId1", crate::package::rt::OFFICE_DOC, "word/document.xml"),
|
||||
rel("rId2", crate::package::rt::CUSTOM_XML, "customXml/item1.xml")
|
||||
);
|
||||
let types = "<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\"><Default Extension=\"xml\" ContentType=\"application/xml\"/></Types>";
|
||||
let z = crate::package::zip_entries(&[
|
||||
("[Content_Types].xml".into(), types.as_bytes().to_vec()),
|
||||
("_rels/.rels".into(), rels.into_bytes()),
|
||||
("word/document.xml".into(), format!("<w:document {w_ns}><w:body><w:p/></w:body></w:document>").into_bytes()),
|
||||
("customXml/item1.xml".into(), evil.clone().into_bytes()),
|
||||
])
|
||||
.unwrap();
|
||||
let mut doc = crate::read(&z).unwrap();
|
||||
assert!(doc.passthrough.values().any(|b| b.as_slice() == evil.as_bytes()), "the part is kept as read");
|
||||
doc.core.title = "Real".into();
|
||||
let flat = write(&doc).unwrap();
|
||||
let text = String::from_utf8_lossy(&flat);
|
||||
assert_eq!(text.matches("pkg:name=\"/docProps/core.xml\"").count(), 1, "{text}");
|
||||
let back = read(&flat).unwrap();
|
||||
assert_eq!(back.core.title, "Real");
|
||||
assert!(back.passthrough.values().any(|b| b.as_slice() == evil.as_bytes()), "the part survives, in base64");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_one_well_formed_element_is_inlined() {
|
||||
let ct = "application/xml";
|
||||
for ok in ["<a/>", "<?xml version=\"1.0\"?>\n<a x=\"1\"><b>t & <![CDATA[c]]></b></a>\n<!-- end -->", "<!-- c --><a/><?pi x?>"] {
|
||||
assert!(xml_fragment(ct, ok.as_bytes()).is_some(), "{ok}");
|
||||
}
|
||||
for bad in [
|
||||
"<a/><b/>",
|
||||
"<a></b>",
|
||||
"<a>",
|
||||
"<a/></x>",
|
||||
"<a/>text",
|
||||
"<a/>&",
|
||||
"<a x=1/>",
|
||||
"<a/><?xml version=\"1.0\"?>",
|
||||
"<a></a></pkg:xmlData>",
|
||||
"<a><!-- x -- y --></a>",
|
||||
"<a/><!-- x--->",
|
||||
] {
|
||||
assert!(xml_fragment(ct, bad.as_bytes()).is_none(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_and_nameless_parts_count_against_the_size_limits() {
|
||||
let (mut files, mut types, mut total) = (BTreeMap::new(), Vec::new(), 0u64);
|
||||
|
||||
@@ -4,13 +4,20 @@
|
||||
//! back. [`embedded_fonts`] hands their font files to whoever lays the document out, undoing the
|
||||
//! obfuscation Word applies to them (ECMA-376 Part 1, 17.8.1).
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use wordcraft_doc::Document;
|
||||
|
||||
use crate::package::{EMBEDDED_PARTS, EmbeddedManifest, FONT_TABLE_PART};
|
||||
use crate::package::{EMBEDDED_PARTS, EmbeddedManifest};
|
||||
|
||||
/// Most font files taken from one document.
|
||||
const MAX_FONTS: usize = 64;
|
||||
|
||||
/// Most bytes of font files copied out of one document: one part's limit, however many
|
||||
/// references the font table makes (each reference is a copy; 64 of them to one large part
|
||||
/// were 64 copies, #3692).
|
||||
const MAX_FONT_BYTES: u64 = crate::package::MAX_PART;
|
||||
|
||||
/// The font table's references to embedded font files, one per style.
|
||||
const EMBEDS: [&str; 4] = ["w:embedRegular", "w:embedBold", "w:embedItalic", "w:embedBoldItalic"];
|
||||
|
||||
@@ -28,14 +35,27 @@ pub struct EmbeddedFont {
|
||||
/// fonts: that is up to the font database. Empty for a document read from anything but a package
|
||||
/// with a font table.
|
||||
pub fn embedded_fonts(doc: &Document) -> Vec<EmbeddedFont> {
|
||||
let Some(table) = doc.passthrough.get(FONT_TABLE_PART).and_then(|b| crate::xml::parse(b).ok()) else {
|
||||
fonts_within(doc, MAX_FONT_BYTES)
|
||||
}
|
||||
|
||||
/// [`embedded_fonts`], copying at most `budget` bytes of font files.
|
||||
fn fonts_within(doc: &Document, mut budget: u64) -> Vec<EmbeddedFont> {
|
||||
let budget_total = budget;
|
||||
let manifest = doc.passthrough.get(EMBEDDED_PARTS).map(|m| EmbeddedManifest::parse(m)).unwrap_or_default();
|
||||
// The table is kept under the spelling the package stores it as (#3872).
|
||||
let Some(key) = crate::package::font_table_key(&doc.passthrough) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let manifest = doc.passthrough.get(EMBEDDED_PARTS).map(|m| EmbeddedManifest::parse(m)).unwrap_or_default();
|
||||
let Some(part) = manifest.parts.get(FONT_TABLE_PART) else {
|
||||
let Some(table) = doc.passthrough.get(key).and_then(|b| crate::xml::parse(b).ok()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(part) = manifest.parts.get(key) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
// A reference repeating one already taken (same file, key and name) gives the same font.
|
||||
let mut taken: HashSet<(&str, [u8; 16], &str)> = HashSet::new();
|
||||
let mut over_budget = 0usize;
|
||||
let embeds = table
|
||||
.children("w:font")
|
||||
.flat_map(|f| f.els().filter(|e| EMBEDS.contains(&e.name.as_str())).map(move |e| (f.attr("w:name").unwrap_or(""), e)));
|
||||
@@ -61,12 +81,25 @@ pub fn embedded_fonts(doc: &Document) -> Vec<EmbeddedFont> {
|
||||
},
|
||||
None => [0; 16],
|
||||
};
|
||||
if !taken.insert((rel.target.as_str(), key, name)) {
|
||||
continue;
|
||||
}
|
||||
// Checked before the copy is made.
|
||||
let Some(left) = budget.checked_sub(bytes.len() as u64) else {
|
||||
over_budget += 1;
|
||||
continue;
|
||||
};
|
||||
budget = left;
|
||||
let mut data = bytes.to_vec();
|
||||
for (i, b) in data.iter_mut().take(32).enumerate() {
|
||||
*b ^= key.get(i % 16).copied().unwrap_or(0);
|
||||
}
|
||||
out.push(EmbeddedFont { name: name.to_string(), data });
|
||||
}
|
||||
if over_budget > 0 {
|
||||
// Once for the document, not once per font.
|
||||
log::warn!("docx: embedded fonts past the {budget_total} byte budget; {over_budget} of them are not loaded");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -83,3 +116,54 @@ fn font_key(guid: &str) -> Option<[u8; 16]> {
|
||||
}
|
||||
Some(key)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::*;
|
||||
use crate::package::FONT_TABLE_PART;
|
||||
|
||||
/// A document whose font table makes `refs` references (`(font name, part)`) to font parts
|
||||
/// `parts` (`(name, size)`).
|
||||
fn doc_with(refs: &[(&str, &str)], parts: &[(&str, usize)]) -> Document {
|
||||
let mut table = String::from(
|
||||
r#"<w:fonts xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">"#,
|
||||
);
|
||||
let mut manifest = format!("P\t{FONT_TABLE_PART}\tapplication/xml\n");
|
||||
for (i, (font, part)) in refs.iter().enumerate() {
|
||||
table.push_str(&format!(r#"<w:font w:name="{font}"><w:embedRegular r:id="rId{i}"/></w:font>"#));
|
||||
manifest.push_str(&format!("R\t{FONT_TABLE_PART}\trId{i}\tfont\t{part}\tI\n"));
|
||||
}
|
||||
table.push_str("</w:fonts>");
|
||||
let mut doc = Document::default();
|
||||
doc.passthrough.insert(FONT_TABLE_PART.into(), Arc::new(table.into_bytes()));
|
||||
doc.passthrough.insert(EMBEDDED_PARTS.into(), Arc::new(manifest.into_bytes()));
|
||||
for (part, size) in parts {
|
||||
doc.passthrough.insert((*part).into(), Arc::new(vec![7u8; *size]));
|
||||
}
|
||||
doc
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_font_referenced_again_is_copied_once() {
|
||||
// Each reference was its own copy: the 64 references allowed to one 256 MB part were
|
||||
// 16 GB of copies (#3692).
|
||||
let refs = vec![("Body", "word/fonts/a.odttf"); MAX_FONTS];
|
||||
let fonts = embedded_fonts(&doc_with(&refs, &[("word/fonts/a.odttf", 1000)]));
|
||||
assert_eq!(fonts.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_copies_stop_at_the_byte_budget() {
|
||||
let refs: Vec<(String, &str)> = (0..MAX_FONTS).map(|i| (format!("Font {i}"), "word/fonts/a.odttf")).collect();
|
||||
let refs: Vec<(&str, &str)> = refs.iter().map(|(n, p)| (n.as_str(), *p)).collect();
|
||||
let doc = doc_with(&refs, &[("word/fonts/a.odttf", 1000)]);
|
||||
let (fonts, warned) = crate::test_log::warnings(|| fonts_within(&doc, 3500));
|
||||
assert_eq!(fonts.len(), 3, "three copies fit");
|
||||
let budget: Vec<_> = warned.iter().filter(|w| w.contains("byte budget")).collect();
|
||||
assert_eq!(budget.len(), 1, "one warning for the document, not one per font: {warned:?}");
|
||||
assert!(budget.iter().all(|w| w.contains(&format!("{} of them", MAX_FONTS - 3))), "{budget:?}");
|
||||
assert_eq!(embedded_fonts(&doc).len(), MAX_FONTS, "well within the real budget");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ mod fonts;
|
||||
mod package;
|
||||
mod read;
|
||||
mod smart_art;
|
||||
#[cfg(test)]
|
||||
mod test_log;
|
||||
mod units;
|
||||
mod wordml2003;
|
||||
mod wordml2003_write;
|
||||
@@ -25,8 +27,8 @@ mod xml;
|
||||
pub use bibliography::{MAX_SOURCES, Sources, read_sources};
|
||||
pub use chart_spec::{chart_items, chart_xml};
|
||||
pub use crypt::{
|
||||
DEFAULT_SPIN_COUNT, MAX_PASSWORD_CHARS, PasswordCheck, check_password, check_protection_password, decrypt, encrypt, encrypt_with_spin_count,
|
||||
is_encrypted, protection_hash,
|
||||
DEFAULT_SPIN_COUNT, MAX_PASSWORD_CHARS, PasswordCheck, check_password, check_protection_password, decrypt, encrypt, encrypt_standard_for_tests,
|
||||
encrypt_with_spin_count, is_encrypted, protection_hash,
|
||||
};
|
||||
pub use flat::{XmlKind, sniff_xml, write as write_flat};
|
||||
pub use fonts::{EmbeddedFont, embedded_fonts};
|
||||
|
||||
+137
-13
@@ -1,6 +1,7 @@
|
||||
//! OPC package access: bounded zip reading, relationships, part names.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::collections::hash_map::Entry;
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::io::{Cursor, Read, Write};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -95,6 +96,15 @@ pub const MAX_VBA_RELATED: usize = 64;
|
||||
pub const EMBEDDED_PARTS: &str = "wordcraft:embedded.parts";
|
||||
/// The font table kept from a file (read by `Reader::keep_part_tree`), with the fonts it embeds.
|
||||
pub const FONT_TABLE_PART: &str = "word/fontTable.xml";
|
||||
|
||||
/// The spelling the font table is stored under in `parts` (part names are case-insensitive, so a
|
||||
/// package may spell it `WORD/FONTTABLE.XML`), preferring the exact [`FONT_TABLE_PART`].
|
||||
pub fn font_table_key<V>(parts: &BTreeMap<String, V>) -> Option<&str> {
|
||||
if let Some((k, _)) = parts.get_key_value(FONT_TABLE_PART) {
|
||||
return Some(k.as_str());
|
||||
}
|
||||
parts.keys().find(|k| k.eq_ignore_ascii_case(FONT_TABLE_PART)).map(String::as_str)
|
||||
}
|
||||
/// Custom XML roots kept by the package (P) or main document (D): `owner\ttype\tpart`.
|
||||
/// Their opaque bytes and related parts use the same bounded manifest as embedded objects.
|
||||
pub const CUSTOM_XML_ROOTS: &str = "wordcraft:customXml.roots";
|
||||
@@ -191,6 +201,9 @@ pub fn rel_is(t: &str, full: &str) -> bool {
|
||||
/// picture, an embedded object) is handed over rather than copied.
|
||||
pub struct Package {
|
||||
files: BTreeMap<String, Arc<Vec<u8>>>,
|
||||
/// Lowercased part name → its stored spelling (the first in name order): part names are
|
||||
/// case-insensitive (ECMA-376 Part 2 §6.2.2.3), so every spelling is one part.
|
||||
lower: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl Package {
|
||||
@@ -223,24 +236,60 @@ impl Package {
|
||||
let room = max_part.min(max_total.saturating_sub(total));
|
||||
let mut buf = Vec::with_capacity(f.size().min(16 * 1024 * 1024) as usize);
|
||||
let read = (&mut f).take(room + 1).read_to_end(&mut buf);
|
||||
// Charged whether or not the read succeeds: an entry inflates in full before its CRC
|
||||
// is checked, so failed entries would otherwise inflate without limit (#3723).
|
||||
total = total.saturating_add(buf.len() as u64);
|
||||
if buf.len() as u64 > room {
|
||||
return Err(DocxError::Limit(format!("part {name} decompresses past the size limit")));
|
||||
}
|
||||
if let Err(e) = read {
|
||||
// A corrupt entry: keep going unless it's a part we can't do without.
|
||||
log::warn!("docx: zip entry {name} unreadable: {e}");
|
||||
continue;
|
||||
}
|
||||
if buf.len() as u64 > room {
|
||||
return Err(DocxError::Limit(format!("part {name} decompresses past the size limit")));
|
||||
}
|
||||
total = total.saturating_add(buf.len() as u64);
|
||||
files.insert(name, Arc::new(buf));
|
||||
}
|
||||
Ok(Package { files })
|
||||
Ok(Package::new(files))
|
||||
}
|
||||
|
||||
/// A package from parts already in memory (a Flat OPC file, a converted document), by part
|
||||
/// name without the leading `/`.
|
||||
pub fn from_parts(files: BTreeMap<String, Vec<u8>>) -> Package {
|
||||
Package { files: files.into_iter().map(|(name, bytes)| (name, Arc::new(bytes))).collect() }
|
||||
Package::new(files.into_iter().map(|(name, bytes)| (name, Arc::new(bytes))).collect())
|
||||
}
|
||||
|
||||
fn new(mut files: BTreeMap<String, Arc<Vec<u8>>>) -> Package {
|
||||
let mut lower: HashMap<String, String> = HashMap::new();
|
||||
let mut collides = false;
|
||||
for name in files.keys() {
|
||||
match lower.entry(name.to_ascii_lowercase()) {
|
||||
Entry::Occupied(_) => collides = true,
|
||||
Entry::Vacant(v) => {
|
||||
v.insert(name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
if !collides {
|
||||
return Package { files, lower };
|
||||
}
|
||||
// Entries whose names differ only in case are one part, so every spelling of the name
|
||||
// reaches the same stored entry (#3704). The spelling kept is the one a relationship
|
||||
// names, so the part the document points at is the one read; the first in byte order only
|
||||
// when no relationship names any of them.
|
||||
let named = rel_targets(&files);
|
||||
for name in files.keys().rev().filter(|n| named.contains(n.as_str())) {
|
||||
lower.insert(name.to_ascii_lowercase(), name.clone());
|
||||
}
|
||||
let before = files.len();
|
||||
files.retain(|name, _| lower.get(&name.to_ascii_lowercase()) == Some(name));
|
||||
let dropped = before.saturating_sub(files.len());
|
||||
log::warn!("docx: {dropped} zip entries are other spellings of parts already read; they are left out");
|
||||
Package { files, lower }
|
||||
}
|
||||
|
||||
/// The stored spelling of part `name` (leading `/` ignored; case-insensitive).
|
||||
pub fn canonical(&self, name: &str) -> Option<&str> {
|
||||
self.lower.get(&name.trim_start_matches('/').to_ascii_lowercase()).map(String::as_str)
|
||||
}
|
||||
|
||||
/// Part bytes by name (leading `/` ignored; case-insensitive fallback).
|
||||
@@ -251,11 +300,7 @@ impl Package {
|
||||
/// [`Package::get`], as the shared bytes themselves: for a part the document keeps whole, which
|
||||
/// then costs no copy (a picture document's pictures used to be held twice while it opened).
|
||||
pub fn get_shared(&self, name: &str) -> Option<&Arc<Vec<u8>>> {
|
||||
let n = name.trim_start_matches('/');
|
||||
if let Some(v) = self.files.get(n) {
|
||||
return Some(v);
|
||||
}
|
||||
self.files.iter().find(|(k, _)| k.eq_ignore_ascii_case(n)).map(|(_, v)| v)
|
||||
self.canonical(name).and_then(|n| self.files.get(n))
|
||||
}
|
||||
|
||||
/// [`Package::xml`] for the main document part: malformed XML is an error, not a truncated read.
|
||||
@@ -289,7 +334,14 @@ impl Package {
|
||||
for r in root.els().filter(|e| e.local() == "Relationship") {
|
||||
let (Some(id), Some(target)) = (r.attr("Id"), r.attr("Target")) else { continue };
|
||||
let external = r.attr("TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("External"));
|
||||
let resolved = if external { target.to_string() } else { resolve(dir, target) };
|
||||
// Internal targets name the part as stored, so a part reached under several spellings
|
||||
// is kept, cached and written once (#3704).
|
||||
let resolved = if external {
|
||||
target.to_string()
|
||||
} else {
|
||||
let path = resolve(dir, target);
|
||||
self.canonical(&path).map_or(path, str::to_string)
|
||||
};
|
||||
rels.list.push(Rel { id: id.to_string(), kind: r.attr("Type").unwrap_or("").to_string(), target: resolved, external });
|
||||
}
|
||||
rels
|
||||
@@ -364,6 +416,25 @@ fn split_dir(part: &str) -> (&str, &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every internal part name the relationship parts in `files` target, spelled as they spell it.
|
||||
fn rel_targets(files: &BTreeMap<String, Arc<Vec<u8>>>) -> HashSet<String> {
|
||||
let mut named = HashSet::new();
|
||||
for (name, bytes) in files {
|
||||
// `dir/_rels/file.rels` relates `dir/file`, so its targets resolve against `dir`.
|
||||
let lower = name.to_ascii_lowercase();
|
||||
let Some(at) = lower.rfind("_rels/").filter(|_| lower.ends_with(".rels")) else { continue };
|
||||
let dir = name.get(..at).unwrap_or("").trim_end_matches('/');
|
||||
let Ok(root) = xml::parse(bytes) else { continue };
|
||||
for r in root.els().filter(|e| e.local() == "Relationship") {
|
||||
let external = r.attr("TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("External"));
|
||||
if let (Some(target), false) = (r.attr("Target"), external) {
|
||||
named.insert(resolve(dir, target));
|
||||
}
|
||||
}
|
||||
}
|
||||
named
|
||||
}
|
||||
|
||||
/// Resolve a relative target against a source directory into a package path.
|
||||
pub fn resolve(dir: &str, target: &str) -> String {
|
||||
let target = target.split('#').next().unwrap_or(target);
|
||||
@@ -420,6 +491,39 @@ mod tests {
|
||||
zip_entries(&e).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_colliding_entries_are_one_part() {
|
||||
let z = zip_of(&[("word/media/Image.png", b"upper".to_vec()), ("word/media/image.png", b"lower".to_vec())]);
|
||||
let p = Package::open(&z).unwrap();
|
||||
for spelling in ["word/media/Image.png", "word/media/image.png", "/WORD/MEDIA/IMAGE.PNG"] {
|
||||
assert_eq!(p.canonical(spelling), Some("word/media/Image.png"), "{spelling}");
|
||||
assert_eq!(p.get(spelling), Some(&b"upper"[..]), "{spelling}");
|
||||
}
|
||||
assert_eq!(p.files.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_spelling_a_relationship_names_is_kept() {
|
||||
// The first spelling in byte order ("Image.png") was kept even when the document's
|
||||
// relationship named the other one, so the picture shown was not the one it pointed at.
|
||||
let rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="image" Target="media/image.png"/></Relationships>"#;
|
||||
let z = zip_of(&[
|
||||
("word/_rels/document.xml.rels", rels.as_bytes().to_vec()),
|
||||
("word/media/Image.png", b"upper".to_vec()),
|
||||
("word/media/image.png", b"lower".to_vec()),
|
||||
("word/media/Other.png", b"first".to_vec()),
|
||||
("word/media/other.png", b"second".to_vec()),
|
||||
]);
|
||||
let (p, warned) = crate::test_log::warnings(|| Package::open(&z).unwrap());
|
||||
assert_eq!(p.canonical("word/media/IMAGE.png"), Some("word/media/image.png"));
|
||||
assert_eq!(p.get("word/media/Image.png"), Some(&b"lower"[..]));
|
||||
assert_eq!(p.get("word/media/OTHER.PNG"), Some(&b"first"[..]), "no relationship names it: first in byte order");
|
||||
assert_eq!(p.files.len(), 3);
|
||||
let folded: Vec<_> = warned.iter().filter(|w| w.contains("other spellings")).collect();
|
||||
assert_eq!(folded.len(), 1, "one warning for the package: {warned:?}");
|
||||
assert!(folded.iter().all(|w| w.contains("2 zip entries")), "{folded:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decompression_bomb_is_rejected() {
|
||||
let z = zip_of(&[("word/document.xml", vec![b' '; 2_000_000])]);
|
||||
@@ -429,6 +533,26 @@ mod tests {
|
||||
assert!(Package::open_limited(&z, 10_000_000, 10_000_000).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_that_fail_their_check_still_count_against_the_total() {
|
||||
// Each entry inflates in full before its CRC is checked; a failed one was dropped without
|
||||
// being charged, so any number of them inflated (#3723).
|
||||
let names: Vec<String> = (0..20).map(|i| format!("p{i}.bin")).collect();
|
||||
let entries: Vec<(&str, Vec<u8>)> = names.iter().map(|n| (n.as_str(), vec![0u8; 1_000_000])).collect();
|
||||
let mut z = zip_of(&entries);
|
||||
let mut patched = 0;
|
||||
for sig in [[0x50u8, 0x4B, 0x01, 0x02], [0x50, 0x4B, 0x03, 0x04]] {
|
||||
let crc_at = if sig[2] == 1 { 16 } else { 14 };
|
||||
let found: Vec<usize> = z.windows(4).enumerate().filter(|(_, w)| *w == sig).map(|(i, _)| i).collect();
|
||||
for pos in found {
|
||||
z[pos + crc_at] ^= 0xFF;
|
||||
patched += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(patched, 40);
|
||||
assert!(matches!(Package::open_limited(&z, 2_000_000, 4_000_000), Err(DocxError::Limit(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lying_declared_size_is_rejected_or_bounded() {
|
||||
let mut z = zip_of(&[("a.xml", b"<a/>".to_vec())]);
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
//! DOCX → [`Document`].
|
||||
|
||||
pub(crate) mod chart;
|
||||
pub(crate) mod diagram;
|
||||
mod drawing_color;
|
||||
mod embed;
|
||||
mod freeform;
|
||||
mod glossary;
|
||||
pub(crate) mod math;
|
||||
mod props;
|
||||
mod sdt;
|
||||
mod shape_style;
|
||||
mod story;
|
||||
mod wordart;
|
||||
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::control::{ControlEvent, ControlKind, control_events, pair_events};
|
||||
use wordcraft_doc::graphic::{Graphic, GraphicKind};
|
||||
use wordcraft_doc::numbering::{AbstractNum, Level, LevelSuffix, Num};
|
||||
use wordcraft_doc::para::NoteKind;
|
||||
use wordcraft_doc::para::OBJ;
|
||||
use wordcraft_doc::props::Rgb;
|
||||
use wordcraft_doc::props::VAlign;
|
||||
use wordcraft_doc::section::NumFormat;
|
||||
use wordcraft_doc::styles::{PartFormat, Style, StyleKind, StyleSheet, TablePart, TableStyleParts};
|
||||
use wordcraft_doc::{
|
||||
Block, Blocks, Comment, Document, InlineObject, PagePicture, Paragraph, PartKind, Revision, RevisionKind, Zoom, ZoomFit, para_block,
|
||||
};
|
||||
|
||||
use crate::DocxError;
|
||||
use crate::bibliography::{MAX_SOURCES, Seen};
|
||||
use crate::package::{ContentTypes, EMBEDDED_PARTS, EmbeddedManifest, MAX_VBA_RELATED, Package, Rels, VBA_PROJECT_PART, VBA_RELATED, rel_is, rt};
|
||||
use crate::units::{flag, int, on_off, tw, u32_of};
|
||||
use crate::xml::El;
|
||||
pub(crate) use props::PropCtx;
|
||||
use story::StoryCtx;
|
||||
|
||||
mod meta;
|
||||
mod parts;
|
||||
mod styles;
|
||||
mod toc;
|
||||
|
||||
pub(crate) use meta::PROTECTION_HASH_ATTRS;
|
||||
pub(crate) use styles::LOWER_NAMES;
|
||||
use toc::{is_toc, toc_control_to_toc, toc_field_to_heading, unstyle_toc_links};
|
||||
|
||||
/// Most fonts read from a font table, and the longest name (hostile files).
|
||||
const MAX_TABLE_FONTS: usize = 2000;
|
||||
const MAX_FONT_NAME: usize = 256;
|
||||
|
||||
/// Most parts (headers, notes, comments, text boxes) we create from one file.
|
||||
const MAX_PARTS: usize = 50_000;
|
||||
/// Deepest chain of headers/footers whose own sections reference further headers/footers.
|
||||
const MAX_HF_DEPTH: usize = 4;
|
||||
/// Most graphic work (items plus path segments, see `story::graphic_work`) built for one file's
|
||||
/// charts and diagrams; later ones are left empty.
|
||||
const MAX_GRAPHIC_WORK: usize = 2_000_000;
|
||||
/// Custom XML parts looked at for bibliography sources.
|
||||
const MAX_BIBLIOGRAPHY_PARTS: usize = 64;
|
||||
|
||||
/// Longest attached-template path we keep (bytes); a longer one is not a real path.
|
||||
const MAX_TEMPLATE_PATH: usize = 32 * 1024;
|
||||
|
||||
pub(crate) struct Reader<'p> {
|
||||
pkg: &'p Package,
|
||||
pub doc: Document,
|
||||
pub pc: PropCtx,
|
||||
revs: HashMap<(u8, String, String, Option<String>), u32>,
|
||||
/// Move ranges (`w:moveFromRangeStart` …) seen so far.
|
||||
pub moves: MoveCtx,
|
||||
media_by_path: HashMap<String, String>,
|
||||
hf_by_path: HashMap<String, u32>,
|
||||
/// Header/footer parts being read right now (see [`MAX_HF_DEPTH`]).
|
||||
hf_depth: usize,
|
||||
pub footnotes: HashMap<i64, u32>,
|
||||
pub endnotes: HashMap<i64, u32>,
|
||||
/// The note being read (kind, part id): its `w:footnoteRef` / `w:endnoteRef` mark is a
|
||||
/// reference to itself.
|
||||
current_note: Option<(NoteKind, u32)>,
|
||||
/// Reading a footnote or endnote separator or continuation notice (where the `w:separator`
|
||||
/// and `w:continuationSeparator` run elements belong).
|
||||
in_note_sep: bool,
|
||||
pub comment_map: HashMap<String, u32>,
|
||||
pub comments_ended: HashSet<u32>,
|
||||
pub bookmarks: HashMap<String, String>,
|
||||
/// Restrict Editing exception ranges (`w:permStart`): the file's id → ours.
|
||||
pub perms: HashMap<String, u32>,
|
||||
/// Chart and diagram parts by path, parsed once.
|
||||
graphic_parts: HashMap<String, Option<Arc<El>>>,
|
||||
/// Charts and diagrams by (kind, part path, width bits, height bits), built once.
|
||||
graphics: HashMap<(GraphicKind, String, u32, u32), Arc<Graphic>>,
|
||||
/// Graphic work left before charts and diagrams are left empty (see [`MAX_GRAPHIC_WORK`]).
|
||||
graphic_budget: usize,
|
||||
/// Drawing ids connectors are glued to (`a:stCxn`, `a:endCxn`), and whether any shape's id
|
||||
/// was kept while reading (those not glued to are dropped at the end).
|
||||
pub glued: HashSet<u32>,
|
||||
pub shape_ids: bool,
|
||||
/// The main document part's name.
|
||||
main: String,
|
||||
/// The package's content types, read when first needed.
|
||||
content_types: Option<ContentTypes>,
|
||||
/// Parts kept for charts, diagrams and OLE objects (see `embed`).
|
||||
embedded: EmbeddedManifest,
|
||||
/// Linked text boxes as read: (`wps:txbx/@id` or `wps:linkedTxbx/@id`, `seq` (0 for the box
|
||||
/// holding the text), text box part). See [`link_text_boxes`].
|
||||
pub txbx_links: Vec<(u32, u32, u32)>,
|
||||
/// The theme's fill styles, which shapes' style references point to.
|
||||
pub theme_fills: shape_style::ThemeFills,
|
||||
/// The fill of the group being read (what its members' `a:grpFill` stands for).
|
||||
pub group_fill: Option<wordcraft_doc::fill::Fill>,
|
||||
}
|
||||
|
||||
/// The package path that internal relationship `id` points at, if it has type `kind` (an `rt`
|
||||
/// constant): a crafted id can't make a chart of some other, possibly huge, part.
|
||||
fn part_of(rels: &Rels, id: &str, kind: &str) -> Option<String> {
|
||||
rels.by_id(id).filter(|r| !r.external && rel_is(&r.kind, kind)).map(|r| r.target.clone())
|
||||
}
|
||||
|
||||
/// Read a `.docx` package.
|
||||
pub fn read(bytes: &[u8]) -> Result<Document, DocxError> {
|
||||
// A password-protected document is a compound file, not a zip: say so rather than failing
|
||||
// as a broken zip (`read_with_password` opens it).
|
||||
if crate::is_encrypted(bytes) {
|
||||
return crate::read_with_password(bytes, None);
|
||||
}
|
||||
read_package(bytes)
|
||||
}
|
||||
|
||||
/// [`read`] for bytes known not to be an encrypted package.
|
||||
pub(crate) fn read_package(bytes: &[u8]) -> Result<Document, DocxError> {
|
||||
read_opened(&Package::open(bytes)?)
|
||||
}
|
||||
|
||||
/// Read a package whose parts are already unpacked (from a zip, a Flat OPC file or a converted
|
||||
/// Word 2003 XML document).
|
||||
pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
|
||||
let root_rels = pkg.rels("");
|
||||
let main = root_rels.by_type(rt::OFFICE_DOC).filter(|r| !r.external).map(|r| r.target.clone()).unwrap_or_else(|| "word/document.xml".to_string());
|
||||
let Some(root) = pkg.xml_strict(&main)? else {
|
||||
return Err(DocxError::MissingPart(main));
|
||||
};
|
||||
if root.name != "w:document" {
|
||||
return Err(DocxError::NotWord(format!("root element is {}", root.name)));
|
||||
}
|
||||
let rels = pkg.rels(&main);
|
||||
let mut doc = Document::new();
|
||||
doc.body.clear();
|
||||
let mut r = Reader {
|
||||
pkg,
|
||||
doc,
|
||||
pc: PropCtx::default(),
|
||||
revs: HashMap::new(),
|
||||
moves: MoveCtx::default(),
|
||||
media_by_path: HashMap::new(),
|
||||
hf_by_path: HashMap::new(),
|
||||
hf_depth: 0,
|
||||
footnotes: HashMap::new(),
|
||||
endnotes: HashMap::new(),
|
||||
current_note: None,
|
||||
in_note_sep: false,
|
||||
comment_map: HashMap::new(),
|
||||
comments_ended: HashSet::new(),
|
||||
bookmarks: HashMap::new(),
|
||||
perms: HashMap::new(),
|
||||
graphic_parts: HashMap::new(),
|
||||
graphics: HashMap::new(),
|
||||
graphic_budget: MAX_GRAPHIC_WORK,
|
||||
glued: HashSet::new(),
|
||||
shape_ids: false,
|
||||
main: main.clone(),
|
||||
content_types: None,
|
||||
embedded: EmbeddedManifest::default(),
|
||||
txbx_links: Vec::new(),
|
||||
theme_fills: Default::default(),
|
||||
group_fill: None,
|
||||
};
|
||||
r.pc.major_font = r.doc.settings.major_font.clone();
|
||||
r.pc.minor_font = r.doc.settings.minor_font.clone();
|
||||
let part = |t: &str| rels.by_type(t).filter(|x| !x.external).map(|x| x.target.clone());
|
||||
|
||||
// The font table and the fonts embedded through it ride along as opaque parts.
|
||||
if let Some(p) = part(rt::FONT_TABLE) {
|
||||
r.keep_part_tree(&p);
|
||||
r.read_font_families(&p);
|
||||
}
|
||||
if let Some(p) = part(rt::THEME) {
|
||||
r.read_theme(&p);
|
||||
}
|
||||
// Secondary parts are optional: a broken one is skipped (logged), not fatal.
|
||||
let lenient = |what: &str, res: Result<(), DocxError>| {
|
||||
if let Err(e) = res {
|
||||
log::warn!("docx: ignoring unreadable {what}: {e}");
|
||||
}
|
||||
};
|
||||
// Styles come before settings, but their theme colours already follow its colour mapping, and
|
||||
// their `…Bidi` theme fonts its theme font language.
|
||||
if let Some(p) = part(rt::SETTINGS) {
|
||||
r.read_color_mapping(&p);
|
||||
}
|
||||
if let Some(p) = part(rt::STYLES) {
|
||||
lenient("styles", r.read_styles(&p));
|
||||
}
|
||||
if let Some(p) = part(rt::NUMBERING) {
|
||||
lenient("numbering", r.read_numbering(&p));
|
||||
}
|
||||
// Without a stated mode (or settings at all) Word lays a document out as Word 2007 did.
|
||||
r.doc.settings.compat_mode = wordcraft_doc::LEGACY_COMPAT_MODE;
|
||||
if let Some(p) = part(rt::SETTINGS) {
|
||||
lenient("settings", r.read_settings(&p));
|
||||
}
|
||||
// Comments before notes: a comment range inside a footnote or endnote resolves its id against
|
||||
// the comments already read, and was dropped when the notes came first.
|
||||
if let Some(p) = part(rt::COMMENTS) {
|
||||
let ex = part(rt::COMMENTS_EX);
|
||||
let ids = part(rt::COMMENTS_IDS);
|
||||
let cex = part(rt::COMMENTS_EXTENSIBLE);
|
||||
lenient("comments", r.read_comments(&p, ex.as_deref(), ids.as_deref(), cex.as_deref()));
|
||||
}
|
||||
if let Some(p) = part(rt::FOOTNOTES) {
|
||||
lenient("footnotes", r.read_notes(&p, true));
|
||||
}
|
||||
if let Some(p) = part(rt::ENDNOTES) {
|
||||
lenient("endnotes", r.read_notes(&p, false));
|
||||
}
|
||||
// Bibliography sources live in a custom XML data store (`b:Sources`), related from the
|
||||
// package root or the main part; Word's own files sometimes leave the relationship out, so
|
||||
// the conventional `customXml/itemN.xml` names are tried too. Each part is read at most once.
|
||||
let mut bib_parts: Vec<String> = Vec::new();
|
||||
for rel in root_rels.list.iter().chain(rels.list.iter()) {
|
||||
if rel_is(&rel.kind, rt::CUSTOM_XML) && !rel.external && !bib_parts.contains(&rel.target) {
|
||||
bib_parts.push(rel.target.clone());
|
||||
if bib_parts.len() >= MAX_BIBLIOGRAPHY_PARTS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
for i in 1..=16 {
|
||||
let path = format!("customXml/item{i}.xml");
|
||||
if bib_parts.len() < MAX_BIBLIOGRAPHY_PARTS && pkg.get(&path).is_some() && !bib_parts.contains(&path) {
|
||||
bib_parts.push(path);
|
||||
}
|
||||
}
|
||||
let mut seen_sources = Seen::default();
|
||||
for path in &bib_parts {
|
||||
if r.doc.sources.len() >= MAX_SOURCES {
|
||||
break;
|
||||
}
|
||||
lenient("customXml bibliography", r.read_bibliography(path, &mut seen_sources));
|
||||
}
|
||||
|
||||
if let Some(bg) = root.child("w:background").and_then(|b| b.attr("w:color")).and_then(Rgb::parse) {
|
||||
r.doc.settings.page_color = Some(bg);
|
||||
}
|
||||
// A picture or texture fill: `w:background/v:background/v:fill` with a relationship id.
|
||||
if let Some(fill) = root.child("w:background").and_then(|b| b.child("v:background")).and_then(|b| b.child("v:fill")) {
|
||||
let rid = fill.attr("r:id").or_else(|| fill.attr("r:embed")).or_else(|| fill.attr("o:relid"));
|
||||
if let Some(media) = rid.and_then(|id| r.media_for(&rels, id)) {
|
||||
r.doc.settings.page_picture = Some(PagePicture {
|
||||
media,
|
||||
fill_type: fill.attr("type").unwrap_or("").chars().take(32).collect(),
|
||||
title: fill.attr("o:title").unwrap_or("").chars().take(255).collect(),
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(body) = root.child("w:body") {
|
||||
let mut sc = StoryCtx::default();
|
||||
let mut blocks = Blocks::new();
|
||||
r.read_blocks(&mut sc, body, &rels, &mut blocks, 0);
|
||||
r.flush_pending(&mut sc, &mut blocks);
|
||||
r.doc.body = blocks;
|
||||
unstyle_toc_links(&mut r.doc);
|
||||
toc_control_to_toc(&mut r.doc.body);
|
||||
toc_field_to_heading(&mut r.doc.body);
|
||||
if let Some(s) = body.child("w:sectPr") {
|
||||
r.doc.last_section = r.read_section(s, &rels);
|
||||
}
|
||||
}
|
||||
// A watermark in the box Word's Design › Watermark gives it for the page keeps no box of its
|
||||
// own: it follows the page, as when it was made.
|
||||
let (pw, ph) = (r.doc.last_section.page_w, r.doc.last_section.page_h);
|
||||
if let Some(wm) = r.doc.settings.watermark.as_mut()
|
||||
&& let Some([w, h]) = wm.size
|
||||
{
|
||||
let [dw, dh] = wordcraft_doc::Watermark { size: None, ..wm.clone() }.frame(pw, ph);
|
||||
if (w - dw).abs() < 0.06 && (h - dh).abs() < 0.06 {
|
||||
wm.size = None;
|
||||
}
|
||||
}
|
||||
|
||||
let core = root_rels.by_type(rt::CORE).map(|x| x.target.clone()).unwrap_or_else(|| "docProps/core.xml".into());
|
||||
r.read_core(&core);
|
||||
if let Some(app) = root_rels.by_type(rt::APP).map(|x| x.target.clone()) {
|
||||
r.read_app(&app);
|
||||
}
|
||||
if let Some(c) = root_rels.by_type(rt::CUSTOM)
|
||||
&& let Ok(Some(x)) = r.xml(&c.target)
|
||||
{
|
||||
r.doc.custom_props = crate::custom::read(&x);
|
||||
}
|
||||
// A macro project and every part it relates to (VBA data, signatures…) ride along as opaque
|
||||
// bytes, never parsed or run, so a .docm/.dotm saved again carries exactly what the file had.
|
||||
// Only the relationships and content types are read.
|
||||
if let Some(v) = part(rt::VBA_PROJECT)
|
||||
&& let Some(b) = pkg.get_shared(&v).filter(|b| !b.is_empty())
|
||||
{
|
||||
r.doc.passthrough.insert(VBA_PROJECT_PART.into(), b.clone());
|
||||
r.read_vba_related(&v);
|
||||
}
|
||||
r.read_glossary(&rels);
|
||||
r.keep_custom_xml(&root_rels, &rels);
|
||||
r.keep_unmodelled(&root, &root_rels, &rels);
|
||||
if !r.embedded.parts.is_empty() {
|
||||
let manifest = r.embedded.to_bytes();
|
||||
r.doc.passthrough.insert(EMBEDDED_PARTS.into(), Arc::new(manifest));
|
||||
}
|
||||
r.finish_moves();
|
||||
let links = std::mem::take(&mut r.txbx_links);
|
||||
link_text_boxes(&mut r.doc, links);
|
||||
if r.shape_ids {
|
||||
keep_glued_ids(&mut r.doc, &r.glued);
|
||||
}
|
||||
let mut doc = r.doc;
|
||||
doc.ensure_nonempty();
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
/// Most move ranges open at once, and most move names tracked, per file.
|
||||
const MAX_OPEN_MOVES: usize = 64;
|
||||
const MAX_MOVES: usize = 100_000;
|
||||
|
||||
/// The move ranges of a file (ECMA-376 §17.13.5.22–27): a `w:moveFrom` / `w:moveTo` is one end
|
||||
/// of a move when it sits inside a range of that kind; the range's name links both ends.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct MoveCtx {
|
||||
/// Open source and destination ranges: (`w:id`, `w:name`).
|
||||
open: [Vec<(String, String)>; 2],
|
||||
/// Per name: a source range started / ended, a destination range started / ended (bits 0–3).
|
||||
seen: HashMap<String, u8>,
|
||||
/// The range of each kind that ended last in the paragraph being read (for its mark).
|
||||
pub closed_here: [Option<String>; 2],
|
||||
}
|
||||
|
||||
impl Reader<'_> {
|
||||
/// A `w:moveFromRangeStart/End` or `w:moveToRangeStart/End`.
|
||||
pub fn move_range(&mut self, e: &El) {
|
||||
let (side, start) = match e.name.as_str() {
|
||||
"w:moveFromRangeStart" => (0, true),
|
||||
"w:moveFromRangeEnd" => (0, false),
|
||||
"w:moveToRangeStart" => (1, true),
|
||||
"w:moveToRangeEnd" => (1, false),
|
||||
_ => return,
|
||||
};
|
||||
let id = e.attr("w:id").unwrap_or("").chars().take(64).collect::<String>();
|
||||
let m = &mut self.moves;
|
||||
let Some(open) = m.open.get_mut(side) else { return };
|
||||
if start {
|
||||
let name = e.attr("w:name").unwrap_or("").chars().take(256).collect::<String>();
|
||||
if name.is_empty() || open.len() >= MAX_OPEN_MOVES || (m.seen.len() >= MAX_MOVES && !m.seen.contains_key(&name)) {
|
||||
return;
|
||||
}
|
||||
*m.seen.entry(name.clone()).or_default() |= 1 << (side * 2);
|
||||
open.push((id, name));
|
||||
} else if let Some(i) = open.iter().rposition(|(x, _)| *x == id) {
|
||||
let (_, name) = open.remove(i);
|
||||
*m.seen.entry(name.clone()).or_default() |= 2 << (side * 2);
|
||||
if let Some(c) = m.closed_here.get_mut(side) {
|
||||
*c = Some(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The revision of a `w:moveFrom` (`to` false) or `w:moveTo`: one end of the innermost open
|
||||
/// range of its kind, else (no range) a plain deletion or insertion.
|
||||
pub fn move_revision(&mut self, to: bool, e: &El, mark: bool) -> u32 {
|
||||
let side = usize::from(to);
|
||||
let open = self.moves.open.get(side).and_then(|o| o.last()).map(|(_, n)| n.clone());
|
||||
// A paragraph mark comes after its paragraph's text: a range that ended in it counts.
|
||||
let name = if mark { open.or_else(|| self.moves.closed_here.get(side).cloned().flatten()) } else { open };
|
||||
let kind = if to { RevisionKind::Insert } else { RevisionKind::Delete };
|
||||
self.revision_named(kind, e, name)
|
||||
}
|
||||
|
||||
/// Moves missing an end (a source or destination range, or its text) are plain deletions
|
||||
/// and insertions.
|
||||
fn finish_moves(&mut self) {
|
||||
let mut ends: HashMap<&str, u8> = HashMap::new();
|
||||
for r in &self.doc.revisions {
|
||||
if let Some(n) = r.move_name.as_deref() {
|
||||
*ends.entry(n).or_default() |= if r.kind == RevisionKind::Delete { 1 } else { 2 };
|
||||
}
|
||||
}
|
||||
let ok: HashSet<String> =
|
||||
ends.into_iter().filter(|(n, e)| *e == 3 && self.moves.seen.get(*n) == Some(&0b1111)).map(|(n, _)| n.to_string()).collect();
|
||||
for r in &mut self.doc.revisions {
|
||||
if r.move_name.as_ref().is_some_and(|n| !ok.contains(n)) {
|
||||
r.move_name = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Link the text boxes read as (id, seq, part) into chains (see [`Reader::txbx_links`]): each
|
||||
/// id's boxes in `seq` order after the box holding the text. A repeated seq, a chain without its
|
||||
/// first box or more than [`wordcraft_doc::MAX_LINKED_BOXES`] boxes: the extra boxes stay apart.
|
||||
fn link_text_boxes(doc: &mut Document, mut links: Vec<(u32, u32, u32)>) {
|
||||
links.sort_by_key(|l| (l.0, l.1));
|
||||
links.dedup_by_key(|l| (l.0, l.1));
|
||||
for group in links.chunk_by(|a, b| a.0 == b.0) {
|
||||
if group.first().is_none_or(|l| l.1 != 0) {
|
||||
continue;
|
||||
}
|
||||
let chain: Vec<u32> = group.iter().take(wordcraft_doc::MAX_LINKED_BOXES).map(|l| l.2).collect();
|
||||
for pair in chain.windows(2) {
|
||||
if let [a, b] = pair
|
||||
&& let Some(p) = doc.parts.get_mut(a)
|
||||
{
|
||||
p.body.next = Some(*b);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop the drawing ids read with shapes, except those connectors are glued to (so a shape
|
||||
/// without connectors reads back as it was written).
|
||||
fn keep_glued_ids(doc: &mut Document, glued: &HashSet<u32>) {
|
||||
let has_id = |o: &InlineObject| match o {
|
||||
InlineObject::Shape { extra, .. } => extra.id != 0,
|
||||
InlineObject::Group { children, .. } => children.iter().any(|c| matches!(&c.obj, InlineObject::Shape { extra, .. } if extra.id != 0)),
|
||||
_ => false,
|
||||
};
|
||||
let clear = |o: &mut InlineObject| {
|
||||
if let InlineObject::Shape { extra, .. } = o
|
||||
&& !glued.contains(&extra.id)
|
||||
{
|
||||
extra.id = 0;
|
||||
}
|
||||
};
|
||||
doc.objects_mut(&has_id, &mut |o| match o {
|
||||
InlineObject::Group { children, .. } => children.iter_mut().for_each(|c| clear(&mut c.obj)),
|
||||
o => clear(o),
|
||||
});
|
||||
}
|
||||
+23
-2193
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,210 @@
|
||||
use super::model::Pos;
|
||||
use super::{CHAR_W, GAP, GraphicItem, LEGEND_SHARE, LINE, MAX_LEGEND_ROWS, PAD, PathSeg, Rgb, ShapeKind, TEXT, TextAlign};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(super) struct Rect {
|
||||
pub(super) x: f32,
|
||||
pub(super) y: f32,
|
||||
pub(super) w: f32,
|
||||
pub(super) h: f32,
|
||||
}
|
||||
|
||||
impl Rect {
|
||||
pub(super) fn right(self) -> f32 {
|
||||
self.x + self.w
|
||||
}
|
||||
pub(super) fn bottom(self) -> f32 {
|
||||
self.y + self.h
|
||||
}
|
||||
pub(super) fn arr(self) -> [f32; 4] {
|
||||
[self.x, self.y, self.w, self.h]
|
||||
}
|
||||
}
|
||||
|
||||
/// Estimated text width, points: text is placed before it's shaped, at [`CHAR_W`] a character.
|
||||
pub(in super::super) fn text_w(s: &str, size: f32) -> f32 {
|
||||
s.chars().count() as f32 * size * CHAR_W
|
||||
}
|
||||
|
||||
/// `s`, cut short with an ellipsis to fit `width` points by the [`text_w`] estimate.
|
||||
pub(super) fn clip_text(s: &str, width: f32, size: f32) -> String {
|
||||
let fit = (width / (size * CHAR_W)).floor().max(0.0) as usize;
|
||||
if s.chars().count() <= fit {
|
||||
return s.to_string();
|
||||
}
|
||||
let mut out: String = s.chars().take(fit.saturating_sub(1)).collect();
|
||||
out.push('…');
|
||||
out
|
||||
}
|
||||
|
||||
pub(super) fn widest(items: &[String], size: f32) -> f32 {
|
||||
items.iter().map(|s| text_w(s, size)).fold(0.0, f32::max)
|
||||
}
|
||||
|
||||
pub(super) fn text(out: &mut Vec<GraphicItem>, rect: Rect, s: &str, size: f32, align: TextAlign, bold: bool) {
|
||||
out.push(GraphicItem::Text { rect: rect.arr(), text: s.to_string(), size, color: TEXT, bold, align, font: None });
|
||||
}
|
||||
|
||||
/// Text centred on `cx` with its top at `y`.
|
||||
pub(super) fn text_at(out: &mut Vec<GraphicItem>, cx: f32, y: f32, s: &str, size: f32) {
|
||||
let w = text_w(s, size) + 4.0;
|
||||
text(out, Rect { x: cx - w / 2.0, y, w, h: size * LINE }, s, size, TextAlign::Center, false);
|
||||
}
|
||||
|
||||
pub(super) fn fill(out: &mut Vec<GraphicItem>, segs: Vec<PathSeg>, color: Rgb) {
|
||||
if !segs.is_empty() {
|
||||
out.push(GraphicItem::Path { segs, fill: Some(color), stroke: None, stroke_width: 0.0 });
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn stroke(out: &mut Vec<GraphicItem>, segs: Vec<PathSeg>, color: Rgb, width: f32) {
|
||||
if !segs.is_empty() {
|
||||
out.push(GraphicItem::Path { segs, fill: None, stroke: Some(color), stroke_width: width });
|
||||
}
|
||||
}
|
||||
|
||||
fn swatch(out: &mut Vec<GraphicItem>, x: f32, y: f32, color: Rgb) {
|
||||
out.push(GraphicItem::Shape { rect: [x, y, SWATCH, SWATCH], kind: ShapeKind::Rectangle, fill: Some(color), stroke: None, stroke_width: 0.0 });
|
||||
}
|
||||
|
||||
pub(super) fn seg(x0: f32, y0: f32, x1: f32, y1: f32) -> Vec<PathSeg> {
|
||||
vec![PathSeg::Move(x0, y0), PathSeg::Line(x1, y1)]
|
||||
}
|
||||
|
||||
pub(super) fn rect_segs(x0: f32, y0: f32, x1: f32, y1: f32) -> Vec<PathSeg> {
|
||||
vec![PathSeg::Move(x0, y0), PathSeg::Line(x1, y0), PathSeg::Line(x1, y1), PathSeg::Line(x0, y1), PathSeg::Close]
|
||||
}
|
||||
|
||||
/// Polyline pieces: a gap (`None`) starts a new sub-path.
|
||||
pub(super) fn runs(pts: impl Iterator<Item = Option<(f32, f32)>>) -> Vec<PathSeg> {
|
||||
let mut segs = Vec::new();
|
||||
let mut open = false;
|
||||
for p in pts {
|
||||
match p {
|
||||
Some((x, y)) => {
|
||||
segs.push(if open { PathSeg::Line(x, y) } else { PathSeg::Move(x, y) });
|
||||
open = true;
|
||||
}
|
||||
None => open = false,
|
||||
}
|
||||
}
|
||||
segs
|
||||
}
|
||||
|
||||
/// Words of `s` in lines of at most `width` points (a word wider than that keeps its own line).
|
||||
pub(super) fn wrap(s: &str, width: f32, size: f32) -> Vec<String> {
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
let mut cur = String::new();
|
||||
for word in s.split_whitespace() {
|
||||
let joined = if cur.is_empty() { word.to_string() } else { format!("{cur} {word}") };
|
||||
if cur.is_empty() || text_w(&joined, size) <= width {
|
||||
cur = joined;
|
||||
} else {
|
||||
lines.push(std::mem::take(&mut cur));
|
||||
cur = word.to_string();
|
||||
}
|
||||
}
|
||||
if !cur.is_empty() {
|
||||
lines.push(cur);
|
||||
}
|
||||
if lines.is_empty() {
|
||||
lines.push(String::new());
|
||||
}
|
||||
lines.truncate(3);
|
||||
lines
|
||||
}
|
||||
|
||||
pub(super) const SWATCH: f32 = 7.0;
|
||||
|
||||
/// Space between legend entries in a row, points.
|
||||
const SEP: f32 = 10.0;
|
||||
|
||||
/// Legend in the given side of `area`; returns the area left for the plot.
|
||||
pub(super) fn legend(out: &mut Vec<GraphicItem>, entries: &[(String, Rgb)], pos: Pos, area: Rect, fs: f32) -> Rect {
|
||||
if entries.is_empty() {
|
||||
return area;
|
||||
}
|
||||
// Entry texts are cut to a share of the chart, so a long name can't push the legend out.
|
||||
let entries: Vec<(String, Rgb)> = entries.iter().map(|(s, c)| (clip_text(s, area.w * LEGEND_SHARE, fs), *c)).collect();
|
||||
match pos {
|
||||
Pos::Right | Pos::Left => side_legend(out, &entries, pos == Pos::Left, area, fs),
|
||||
Pos::Top | Pos::Bottom => row_legend(out, &entries, pos == Pos::Top, area, fs),
|
||||
}
|
||||
}
|
||||
|
||||
/// A legend in a column at the left or right of `area`, keeping only the entries that fit above
|
||||
/// its bottom; returns the area left for the plot.
|
||||
fn side_legend(out: &mut Vec<GraphicItem>, entries: &[(String, Rgb)], left: bool, area: Rect, fs: f32) -> Rect {
|
||||
let lh = fs * LINE;
|
||||
let entries = entries.get(..((area.h / lh).floor().max(0.0) as usize).min(entries.len())).unwrap_or(&[]);
|
||||
if entries.is_empty() {
|
||||
return area;
|
||||
}
|
||||
let tw = entries.iter().map(|(s, _)| text_w(s, fs)).fold(0.0, f32::max);
|
||||
let block_w = SWATCH + GAP + tw;
|
||||
let block_h = lh * entries.len() as f32;
|
||||
let x = if left { area.x } else { area.right() - block_w };
|
||||
let y0 = area.y + ((area.h - block_h) / 2.0).max(0.0);
|
||||
for (k, (s, c)) in entries.iter().enumerate() {
|
||||
let y = y0 + lh * k as f32;
|
||||
swatch(out, x, y + (lh - SWATCH) / 2.0, *c);
|
||||
text(out, Rect { x: x + SWATCH + GAP, y, w: tw + 2.0, h: lh }, s, fs, TextAlign::Left, false);
|
||||
}
|
||||
let used = block_w + PAD;
|
||||
let mut area = area;
|
||||
area.w = (area.w - used).max(1.0);
|
||||
if left {
|
||||
area.x += used;
|
||||
}
|
||||
area
|
||||
}
|
||||
|
||||
/// A legend in centred rows the width of `area` above or below it (at most
|
||||
/// [`MAX_LEGEND_ROWS`], and half its height); returns the area left for the plot.
|
||||
fn row_legend(out: &mut Vec<GraphicItem>, entries: &[(String, Rgb)], top: bool, area: Rect, fs: f32) -> Rect {
|
||||
let lh = fs * LINE;
|
||||
let max_rows = MAX_LEGEND_ROWS.min((area.h / 2.0 / lh).floor().max(1.0) as usize);
|
||||
let rows = legend_rows(entries, area.w, fs, max_rows);
|
||||
let y0 = if top { area.y } else { area.bottom() - lh * rows.len() as f32 };
|
||||
for (r, row) in rows.iter().enumerate() {
|
||||
let total = row.iter().map(|(_, _, w)| SWATCH + GAP + w).sum::<f32>() + SEP * row.len().saturating_sub(1) as f32;
|
||||
let mut x = area.x + ((area.w - total) / 2.0).max(0.0);
|
||||
let y = y0 + lh * r as f32;
|
||||
for (s, c, w) in row {
|
||||
swatch(out, x, y + (lh - SWATCH) / 2.0, *c);
|
||||
text(out, Rect { x: x + SWATCH + GAP, y, w: w + 2.0, h: lh }, s, fs, TextAlign::Left, false);
|
||||
x += SWATCH + GAP + w + SEP;
|
||||
}
|
||||
}
|
||||
let used = lh * rows.len() as f32 + PAD;
|
||||
let mut area = area;
|
||||
area.h = (area.h - used).max(1.0);
|
||||
if top {
|
||||
area.y += used;
|
||||
}
|
||||
area
|
||||
}
|
||||
|
||||
/// Legend entries (text, colour, text width) packed into at most `max_rows` rows of `width`
|
||||
/// points; the entries past the last row are left out.
|
||||
fn legend_rows(entries: &[(String, Rgb)], width: f32, fs: f32, max_rows: usize) -> Vec<Vec<(String, Rgb, f32)>> {
|
||||
let mut rows: Vec<Vec<(String, Rgb, f32)>> = Vec::new();
|
||||
let mut row_w = 0.0;
|
||||
for (s, c) in entries {
|
||||
let w = text_w(s, fs);
|
||||
let entry_w = SWATCH + GAP + w;
|
||||
let fits = !rows.is_empty() && row_w + SEP + entry_w <= width;
|
||||
if fits {
|
||||
row_w += SEP + entry_w;
|
||||
} else if rows.len() < max_rows {
|
||||
rows.push(Vec::new());
|
||||
row_w = entry_w;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
if let Some(row) = rows.last_mut() {
|
||||
row.push((s.clone(), *c, w));
|
||||
}
|
||||
}
|
||||
rows
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
/// A piece of a format section: literal text, a number character (`#`, `0`, `,`, `.`), or the
|
||||
/// exponent of a scientific format (`E+` when true, `E-` when false).
|
||||
enum Tok {
|
||||
Lit(String),
|
||||
Num(char),
|
||||
Exp(bool),
|
||||
}
|
||||
|
||||
/// The sections of a format code (positive; negative; zero; text), split outside quotes.
|
||||
fn sections(code: &str) -> Vec<&str> {
|
||||
let mut out = Vec::new();
|
||||
let (mut start, mut quoted, mut escaped) = (0, false, false);
|
||||
for (i, c) in code.char_indices() {
|
||||
match c {
|
||||
_ if escaped => escaped = false,
|
||||
'\\' if !quoted => escaped = true,
|
||||
'"' => quoted = !quoted,
|
||||
';' if !quoted => {
|
||||
out.push(code.get(start..i).unwrap_or(""));
|
||||
start = i + 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out.push(code.get(start..).unwrap_or(""));
|
||||
out
|
||||
}
|
||||
|
||||
/// The first section of a format code.
|
||||
fn first_section(code: &str) -> &str {
|
||||
sections(code).first().copied().unwrap_or("")
|
||||
}
|
||||
|
||||
/// The pieces of one format section. Quoted text, escapes and `[$€-407]` currency symbols are
|
||||
/// literal; other brackets (`[Red]`) are dropped; `_x` (room for `x`) is a space; `*x` is dropped.
|
||||
fn format_toks(section: &str) -> Vec<Tok> {
|
||||
let mut toks = Vec::new();
|
||||
let mut chars = section.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'"' => toks.push(Tok::Lit(chars.by_ref().take_while(|q| *q != '"').collect())),
|
||||
'[' => {
|
||||
let inner: String = chars.by_ref().take_while(|q| *q != ']').collect();
|
||||
if let Some(sym) = currency(&inner) {
|
||||
toks.push(Tok::Lit(sym.to_string()));
|
||||
}
|
||||
}
|
||||
'\\' => toks.extend(chars.next().map(|n| Tok::Lit(n.to_string()))),
|
||||
'_' => {
|
||||
chars.next();
|
||||
toks.push(Tok::Lit(" ".to_string()));
|
||||
}
|
||||
'*' => {
|
||||
chars.next();
|
||||
}
|
||||
'E' | 'e' if matches!(chars.peek(), Some('+' | '-')) => toks.push(Tok::Exp(chars.next() == Some('+'))),
|
||||
'#' | '0' | ',' | '.' => toks.push(Tok::Num(c)),
|
||||
c => toks.push(Tok::Lit(c.to_string())),
|
||||
}
|
||||
}
|
||||
toks
|
||||
}
|
||||
|
||||
/// The symbol of a `$€-407` bracket content (`[$€-407]`), if it is one.
|
||||
fn currency(inner: &str) -> Option<&str> {
|
||||
inner.strip_prefix('$').map(|rest| rest.split('-').next().unwrap_or(""))
|
||||
}
|
||||
|
||||
/// Tick text for `v` in a number format code (`#,##0`, `0.0`, `0%`, `0.00E+00`, and literal text
|
||||
/// such as `"€"0.00` or `#,##0.00 [$€-407]`). A negative number takes the second section when
|
||||
/// there is one, which shows it without a minus (`#,##0;(#,##0)`). `General` shows as many
|
||||
/// decimals as the step needs.
|
||||
pub(super) fn format_value(v: f64, code: &str, step: f64) -> String {
|
||||
let v = if v.abs() < step * 1e-9 { 0.0 } else { v };
|
||||
let secs = sections(code);
|
||||
match secs.get(1) {
|
||||
Some(neg) if v < 0.0 => format_section(-v, neg, step),
|
||||
_ => format_section(v, secs.first().copied().unwrap_or(""), step),
|
||||
}
|
||||
}
|
||||
|
||||
/// `v` in one section of a format code.
|
||||
fn format_section(v: f64, section: &str, step: f64) -> String {
|
||||
let toks = format_toks(section);
|
||||
let is_num = |t: &Tok| matches!(t, Tok::Num(_) | Tok::Exp(_));
|
||||
let lit = |r: &[Tok]| -> String {
|
||||
r.iter()
|
||||
.filter_map(|t| match t {
|
||||
Tok::Lit(l) => Some(l.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let (Some(s), Some(e)) = (toks.iter().position(is_num), toks.iter().rposition(is_num)) else { return general(v, step) };
|
||||
let body = toks.get(s..=e).unwrap_or(&[]);
|
||||
let (pre, suf) = (lit(toks.get(..s).unwrap_or(&[])), lit(toks.get(e + 1..).unwrap_or(&[])));
|
||||
let digits = |r: &[Tok]| -> String { r.iter().filter_map(|t| if let Tok::Num(c) = t { Some(*c) } else { None }).collect() };
|
||||
if let Some(k) = body.iter().position(|t| matches!(t, Tok::Exp(_))) {
|
||||
let plus = matches!(body.get(k), Some(Tok::Exp(true)));
|
||||
let mantissa = digits(body.get(..k).unwrap_or(&[]));
|
||||
let exp_digits = digits(body.get(k + 1..).unwrap_or(&[])).len();
|
||||
return format!("{pre}{}{suf}", scientific(v, decimals(&mantissa), exp_digits, plus));
|
||||
}
|
||||
let body = digits(body);
|
||||
let pct = pre.contains('%') || suf.contains('%');
|
||||
let v = if pct { v * 100.0 } else { v };
|
||||
let digits = format!("{:.*}", decimals(&body), v.abs());
|
||||
let (ip, fp) = match digits.split_once('.') {
|
||||
Some((a, b)) => (a.to_string(), format!(".{b}")),
|
||||
None => (digits.clone(), String::new()),
|
||||
};
|
||||
let mut grouped = String::new();
|
||||
for (k, ch) in ip.chars().enumerate() {
|
||||
if body.contains(',') && k > 0 && (ip.len() - k) % 3 == 0 {
|
||||
grouped.push(',');
|
||||
}
|
||||
grouped.push(ch);
|
||||
}
|
||||
let sign = if v < 0.0 && digits.chars().any(|c| c != '0' && c != '.') { "-" } else { "" };
|
||||
format!("{sign}{pre}{grouped}{fp}{suf}")
|
||||
}
|
||||
|
||||
/// Decimal places of a number body (`#,##0.00` has 2).
|
||||
fn decimals(body: &str) -> usize {
|
||||
body.split_once('.').map_or(0, |(_, d)| d.chars().filter(|c| matches!(c, '0' | '#')).count()).min(12)
|
||||
}
|
||||
|
||||
/// `v` with no number format (`General`): as many decimals as the tick step needs.
|
||||
fn general(v: f64, step: f64) -> String {
|
||||
let mut d = 0;
|
||||
while d < 6 && (step * 10f64.powi(d)).fract().abs() > 1e-9 {
|
||||
d += 1;
|
||||
}
|
||||
let s = format!("{:.*}", d as usize, v);
|
||||
if s.trim_start_matches(['-', '0', '.']).is_empty() { s.trim_start_matches('-').to_string() } else { s }
|
||||
}
|
||||
|
||||
/// `v` in scientific notation: `decimals` mantissa decimals, at least `exp_digits` exponent
|
||||
/// digits, and a `+` on positive exponents when `plus` (`0.00E+00`: 1.23E+04).
|
||||
fn scientific(v: f64, decimals: usize, exp_digits: usize, plus: bool) -> String {
|
||||
let s = format!("{:.*e}", decimals, v.abs());
|
||||
let (mantissa, exp) = s.split_once('e').unwrap_or((s.as_str(), "0"));
|
||||
let exp: i32 = if v == 0.0 { 0 } else { exp.parse().unwrap_or(0) };
|
||||
let sign = if exp < 0 {
|
||||
"-"
|
||||
} else if plus {
|
||||
"+"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let minus = if v < 0.0 { "-" } else { "" };
|
||||
format!("{minus}{mantissa}E{sign}{:0w$}", exp.unsigned_abs(), w = exp_digits.clamp(1, 4))
|
||||
}
|
||||
|
||||
/// A moment as Excel shows a serial day number.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(super) struct Stamp {
|
||||
pub(super) y: i64,
|
||||
pub(super) m: u32,
|
||||
pub(super) d: u32,
|
||||
/// Day of the week, 0 = Sunday.
|
||||
weekday: u32,
|
||||
/// Seconds into the day.
|
||||
secs: u32,
|
||||
}
|
||||
|
||||
/// The moment of Excel serial day `serial` as Excel shows it, from 0 ("January 0, 1900") to
|
||||
/// 31 December 9999. In the 1900 system serial 60 is Excel's phantom 29 February 1900, and
|
||||
/// serials before it count from 31 December 1899; in the 1904 system (`date1904`) serial 0 is
|
||||
/// 1 January 1904. `None` outside that range.
|
||||
pub(super) fn excel_stamp(serial: f64, date1904: bool) -> Option<Stamp> {
|
||||
let serial = if date1904 { serial + 1462.0 } else { serial };
|
||||
if !(0.0..2_958_466.0).contains(&serial) {
|
||||
return None;
|
||||
}
|
||||
let whole = serial.floor();
|
||||
let secs = (((serial - whole) * 86_400.0).round() as u32).min(86_399);
|
||||
let day = whole as i64;
|
||||
let (y, m, d) = match day {
|
||||
0 => (1900, 1, 0),
|
||||
60 => (1900, 2, 29),
|
||||
1..=59 => civil(day + 1),
|
||||
_ => civil(day),
|
||||
};
|
||||
// Excel's weekdays count serial 1 as a Sunday.
|
||||
Some(Stamp { y, m, d, weekday: (day - 1).rem_euclid(7) as u32, secs })
|
||||
}
|
||||
|
||||
/// Calendar date (year, month, day) of day `n` counted from 30 December 1899 (an Excel serial
|
||||
/// day number). Serial `n` is `n - 25_569` days from 1970-01-01.
|
||||
///
|
||||
/// The caller's serial guard keeps `n` inside chrono's range, but the Gregorian calendar repeats
|
||||
/// every 400 years (146_097 days), so this stays exact for any `n` without hand-rolled calendar
|
||||
/// math: chrono converts the day within its cycle and whole cycles go back into the year (an
|
||||
/// i128 so the extremes can't overflow). 1970-01-01 is `num_days_from_ce` 719_163, so serial `n`
|
||||
/// is CE day `n - 25_569 + 719_163`.
|
||||
pub(super) fn civil(n: i64) -> (i64, u32, u32) {
|
||||
use chrono::Datelike;
|
||||
// CE day of serial `n`, in i128 so no `n` overflows.
|
||||
let from_ce = i128::from(n) - 25_569 + 719_163;
|
||||
let (cycles, rest) = (from_ce.div_euclid(146_097), from_ce.rem_euclid(146_097));
|
||||
match i32::try_from(rest).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
|
||||
Some(date) => {
|
||||
let y = i128::from(date.year()) + cycles * 400;
|
||||
(i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), date.month(), date.day())
|
||||
}
|
||||
// Unreachable: every `rest` in 0..146_097 is a valid CE day.
|
||||
None => (1900, 1, 1),
|
||||
}
|
||||
}
|
||||
|
||||
const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
|
||||
|
||||
const WEEKDAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
|
||||
/// Whether a format code (outside its quoted and bracketed parts) has date or time fields.
|
||||
pub(super) fn is_date_code(code: &str) -> bool {
|
||||
format_toks_raw(code).any(|c| matches!(c.to_ascii_lowercase(), 'y' | 'd' | 'm' | 'h' | 's'))
|
||||
}
|
||||
|
||||
/// The characters of a format code's first section outside quotes and brackets.
|
||||
fn format_toks_raw(code: &str) -> impl Iterator<Item = char> + '_ {
|
||||
let mut quoted = false;
|
||||
let mut bracket = false;
|
||||
first_section(code).chars().filter(move |c| match c {
|
||||
'"' => {
|
||||
quoted = !quoted;
|
||||
false
|
||||
}
|
||||
'[' if !quoted => {
|
||||
bracket = true;
|
||||
false
|
||||
}
|
||||
']' if bracket => {
|
||||
bracket = false;
|
||||
false
|
||||
}
|
||||
_ => !quoted && !bracket,
|
||||
})
|
||||
}
|
||||
|
||||
/// A piece of a date format: literal text, a field (`y`, `m`, `d`, `h`, `s`, or `n` for minutes)
|
||||
/// with its length, or AM/PM (`AM/PM` when true, `A/P` when false).
|
||||
enum DTok {
|
||||
Lit(String),
|
||||
Field(char, usize),
|
||||
AmPm(bool),
|
||||
}
|
||||
|
||||
/// The pieces of a date format's first section. `m` or `mm` right after an hour or right before
|
||||
/// a seconds field is minutes.
|
||||
fn date_toks(code: &str) -> Vec<DTok> {
|
||||
let section: Vec<char> = first_section(code).chars().collect();
|
||||
let mut toks = Vec::new();
|
||||
let mut i = 0;
|
||||
while let Some(&c) = section.get(i) {
|
||||
let at = i;
|
||||
i += 1;
|
||||
match c {
|
||||
'"' => {
|
||||
let lit: String = section.get(i..).unwrap_or(&[]).iter().take_while(|q| **q != '"').collect();
|
||||
i += lit.chars().count() + 1;
|
||||
toks.push(DTok::Lit(lit));
|
||||
}
|
||||
'[' => {
|
||||
let inner: String = section.get(i..).unwrap_or(&[]).iter().take_while(|q| **q != ']').collect();
|
||||
i += inner.chars().count() + 1;
|
||||
toks.extend(currency(&inner).map(|s| DTok::Lit(s.to_string())));
|
||||
}
|
||||
'\\' | '_' | '*' => {
|
||||
let next = section.get(i).copied();
|
||||
i += 1;
|
||||
match (c, next) {
|
||||
('\\', Some(n)) => toks.push(DTok::Lit(n.to_string())),
|
||||
('_', _) => toks.push(DTok::Lit(" ".to_string())),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
'a' | 'A' if starts_ci(§ion, at, "AM/PM") => {
|
||||
i += 4;
|
||||
toks.push(DTok::AmPm(true));
|
||||
}
|
||||
'a' | 'A' if starts_ci(§ion, at, "A/P") => {
|
||||
i += 2;
|
||||
toks.push(DTok::AmPm(false));
|
||||
}
|
||||
_ if matches!(c.to_ascii_lowercase(), 'y' | 'm' | 'd' | 'h' | 's') => {
|
||||
let mut n = 1;
|
||||
while section.get(i).is_some_and(|q| q.eq_ignore_ascii_case(&c)) {
|
||||
i += 1;
|
||||
n += 1;
|
||||
}
|
||||
toks.push(DTok::Field(c.to_ascii_lowercase(), n));
|
||||
}
|
||||
c => toks.push(DTok::Lit(c.to_string())),
|
||||
}
|
||||
}
|
||||
mark_minutes(&mut toks);
|
||||
toks
|
||||
}
|
||||
|
||||
/// Whether `chars` from `at` on start with `pat`, ignoring ASCII case.
|
||||
fn starts_ci(chars: &[char], at: usize, pat: &str) -> bool {
|
||||
pat.chars().enumerate().all(|(k, p)| chars.get(at + k).is_some_and(|c| c.eq_ignore_ascii_case(&p)))
|
||||
}
|
||||
|
||||
/// Turns the `m`/`mm` fields that mean minutes (after an hour, or before seconds) into `n`.
|
||||
fn mark_minutes(toks: &mut [DTok]) {
|
||||
let fields: Vec<usize> = toks.iter().enumerate().filter(|(_, t)| matches!(t, DTok::Field(..))).map(|(k, _)| k).collect();
|
||||
let kind = |k: Option<&usize>, toks: &[DTok]| match k.and_then(|k| toks.get(*k)) {
|
||||
Some(DTok::Field(c, _)) => Some(*c),
|
||||
_ => None,
|
||||
};
|
||||
for (j, k) in fields.iter().enumerate() {
|
||||
let after_hour = j.checked_sub(1).and_then(|p| kind(fields.get(p), toks)) == Some('h');
|
||||
let before_secs = kind(fields.get(j + 1), toks) == Some('s');
|
||||
if let Some(DTok::Field(c @ 'm', n)) = toks.get_mut(*k)
|
||||
&& *n <= 2
|
||||
&& (after_hour || before_secs)
|
||||
{
|
||||
*c = 'n';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A moment in a date format: day, month, year, weekday (`ddd`, `dddd`), hour, minute and
|
||||
/// second fields replaced (12-hour with AM/PM); quoted text and currency kept, other brackets
|
||||
/// dropped.
|
||||
pub(super) fn date_text(t: Stamp, code: &str) -> String {
|
||||
let toks = date_toks(code);
|
||||
let twelve = toks.iter().any(|k| matches!(k, DTok::AmPm(_)));
|
||||
let hour = t.secs / 3600;
|
||||
let mut out = String::new();
|
||||
for tok in &toks {
|
||||
match tok {
|
||||
DTok::Lit(s) => out.push_str(s),
|
||||
DTok::AmPm(long) => out.push_str(match (hour < 12, long) {
|
||||
(true, true) => "AM",
|
||||
(false, true) => "PM",
|
||||
(true, false) => "A",
|
||||
(false, false) => "P",
|
||||
}),
|
||||
DTok::Field(c, n) => out.push_str(&field_text(t, *c, *n, twelve)),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// One date or time field (`c` as in [`DTok::Field`], `n` letters long).
|
||||
fn field_text(t: Stamp, c: char, n: usize, twelve: bool) -> String {
|
||||
let month = MONTHS.get((t.m as usize).clamp(1, 12) - 1).copied().unwrap_or("");
|
||||
let weekday = WEEKDAYS.get(t.weekday as usize).copied().unwrap_or("");
|
||||
let pad = |v: u32| if n >= 2 { format!("{v:02}") } else { v.to_string() };
|
||||
let hour = t.secs / 3600;
|
||||
match c {
|
||||
'y' if n <= 2 => format!("{:02}", t.y.rem_euclid(100)),
|
||||
'y' => format!("{:04}", t.y),
|
||||
'd' if n <= 2 => pad(t.d),
|
||||
'd' if n == 3 => weekday.get(..3).unwrap_or(weekday).to_string(),
|
||||
'd' => weekday.to_string(),
|
||||
'm' if n <= 2 => pad(t.m),
|
||||
'm' if n == 3 => month.get(..3).unwrap_or(month).to_string(),
|
||||
'm' if n == 4 => month.to_string(),
|
||||
'm' => month.get(..1).unwrap_or(month).to_string(),
|
||||
'h' if twelve => pad(if hour.is_multiple_of(12) { 12 } else { hour % 12 }),
|
||||
'h' => pad(hour),
|
||||
'n' => pad(t.secs / 60 % 60),
|
||||
_ => pad(t.secs % 60),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
use super::Rgb;
|
||||
|
||||
/// A data label to draw once the chart is: the value at (`x`, `y`), centred there (`inside`, in
|
||||
/// white on the fill) or just beside it (above, or right of a horizontal bar's end).
|
||||
pub(super) struct DataLabel {
|
||||
pub(super) x: f32,
|
||||
pub(super) y: f32,
|
||||
pub(super) text: String,
|
||||
pub(super) inside: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum Kind {
|
||||
Bar,
|
||||
Line,
|
||||
Area,
|
||||
Pie,
|
||||
Doughnut,
|
||||
Scatter,
|
||||
Radar,
|
||||
}
|
||||
|
||||
impl Kind {
|
||||
pub(super) fn of(name: &str) -> Option<Kind> {
|
||||
Some(match name {
|
||||
"c:barChart" => Kind::Bar,
|
||||
"c:lineChart" => Kind::Line,
|
||||
"c:areaChart" => Kind::Area,
|
||||
"c:pieChart" => Kind::Pie,
|
||||
"c:doughnutChart" => Kind::Doughnut,
|
||||
"c:scatterChart" => Kind::Scatter,
|
||||
"c:radarChart" => Kind::Radar,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum Grouping {
|
||||
Clustered,
|
||||
Stacked,
|
||||
Percent,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum Pos {
|
||||
Right,
|
||||
Left,
|
||||
Top,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
/// One value axis (or category axis) as the file states it.
|
||||
#[derive(Default)]
|
||||
pub(super) struct Axis {
|
||||
pub(super) deleted: bool,
|
||||
pub(super) grid: bool,
|
||||
pub(super) line: bool,
|
||||
/// `c:crossBetween val="midCat"`: categories sit on the tick marks, not between them.
|
||||
pub(super) mid: bool,
|
||||
pub(super) min: Option<f64>,
|
||||
pub(super) max: Option<f64>,
|
||||
/// Number format of the tick labels, when not linked to the source data.
|
||||
pub(super) fmt: Option<String>,
|
||||
}
|
||||
|
||||
pub(super) struct Series {
|
||||
pub(super) name: String,
|
||||
pub(super) color: Option<Rgb>,
|
||||
pub(super) line_w: f32,
|
||||
/// Whether the series is drawn as a line (scatter series without one get markers).
|
||||
pub(super) line: bool,
|
||||
pub(super) values: Vec<Option<f64>>,
|
||||
/// Scatter x values (index + 1 when the file has none).
|
||||
pub(super) xs: Vec<Option<f64>>,
|
||||
/// Per-point colours from `c:dPt`.
|
||||
pub(super) points: Vec<Option<Rgb>>,
|
||||
pub(super) fmt: Option<String>,
|
||||
/// Format code of the scatter x values' cache.
|
||||
pub(super) x_fmt: Option<String>,
|
||||
/// Data labels on or off for this series (`c:dLbls` of its own), when it says.
|
||||
pub(super) labels: Option<bool>,
|
||||
/// The values as the file has them, for data labels (`values` become shares in a 100%
|
||||
/// stacked chart); empty when the series shows no labels.
|
||||
pub(super) raw: Vec<Option<f64>>,
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
use super::format::{date_text, excel_stamp, format_value, is_date_code};
|
||||
use super::model::{Axis, Grouping, Kind, Pos, Series};
|
||||
use super::plot::label;
|
||||
use super::{Chart, El, FONT, LINE_W, MAX_CELLS, MAX_POINTS, MAX_RADAR_SERIES, MAX_SERIES, MAX_SPOKES, Rgb, fill_colour, int, measure, slot};
|
||||
|
||||
impl Chart {
|
||||
pub(super) fn parse(space: &El, theme: &[Rgb]) -> Option<Chart> {
|
||||
let chart = space.child("c:chart")?;
|
||||
let plot = chart.child("c:plotArea")?;
|
||||
let ty = plot.els().find(|e| Kind::of(&e.name).is_some())?;
|
||||
let kind = Kind::of(&ty.name)?;
|
||||
let scatter = kind == Kind::Scatter;
|
||||
let date1904 = space.child("c:date1904").is_some_and(|d| d.attr("val").is_none_or(truthy));
|
||||
let mut series = read_series(ty, scatter, theme);
|
||||
if series.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let cats = ty.children("c:ser").find_map(|s| s.child("c:cat")).map(|c| labels(Some(c), MAX_POINTS, date1904)).unwrap_or_default();
|
||||
let n = if kind == Kind::Radar {
|
||||
series.iter().map(|s| s.values.len()).max().unwrap_or(0).max(cats.len()).min(MAX_SPOKES)
|
||||
} else if matches!(kind, Kind::Pie | Kind::Doughnut) {
|
||||
series.first().map_or(0, |s| s.values.len()).max(cats.len())
|
||||
} else {
|
||||
series.iter().map(|s| s.values.len()).max().unwrap_or(0).max(cats.len())
|
||||
};
|
||||
// Every series is padded to `n` points, so keep only as many as the cell budget holds.
|
||||
if let Some(keep) = MAX_CELLS.checked_div(n) {
|
||||
series.truncate(keep.max(1));
|
||||
}
|
||||
if kind == Kind::Radar {
|
||||
series.truncate(MAX_RADAR_SERIES);
|
||||
}
|
||||
let grouping = match ty.child("c:grouping").and_then(|g| g.attr("val")) {
|
||||
Some("stacked") if matches!(kind, Kind::Bar | Kind::Area | Kind::Line) => Grouping::Stacked,
|
||||
Some("percentStacked") if matches!(kind, Kind::Bar | Kind::Area | Kind::Line) => Grouping::Percent,
|
||||
_ => Grouping::Clustered,
|
||||
};
|
||||
let mut cats = cats;
|
||||
cats.resize(n, String::new());
|
||||
let radar_style = ty.child("c:radarStyle").and_then(|r| r.attr("val"));
|
||||
let type_labels = show_values(ty.child("c:dLbls"));
|
||||
for s in &mut series {
|
||||
s.values.resize(n, None);
|
||||
s.xs.resize(n, None);
|
||||
s.labels = Some(s.labels.or(type_labels).unwrap_or(false));
|
||||
if s.labels == Some(true) {
|
||||
s.raw = s.values.clone();
|
||||
}
|
||||
}
|
||||
if grouping == Grouping::Percent {
|
||||
to_percent(&mut series, n);
|
||||
}
|
||||
let (cat, val) = chart_axes(plot, scatter);
|
||||
let val_fmt = if grouping == Grouping::Percent {
|
||||
"0%".to_string()
|
||||
} else {
|
||||
val.fmt.clone().or_else(|| series.iter().find_map(|s| s.fmt.clone())).unwrap_or_else(|| "General".to_string())
|
||||
};
|
||||
let num = |n: &str| ty.child(n).and_then(|g| g.attr("val")).and_then(int);
|
||||
Some(Chart {
|
||||
kind,
|
||||
horizontal: kind == Kind::Bar && ty.child("c:barDir").and_then(|d| d.attr("val")) == Some("bar"),
|
||||
grouping,
|
||||
gap: num("c:gapWidth").unwrap_or(150).clamp(0, 500) as f32 / 100.0,
|
||||
overlap: num("c:overlap").unwrap_or(0).clamp(-100, 100) as f32 / 100.0,
|
||||
hole: num("c:holeSize").unwrap_or(50).clamp(10, 90) as f32 / 100.0,
|
||||
first_angle: num("c:firstSliceAng").unwrap_or(0).clamp(0, 360) as f64,
|
||||
radar_fill: radar_style == Some("filled"),
|
||||
radar_marks: radar_style == Some("marker"),
|
||||
vary: matches!(kind, Kind::Pie | Kind::Doughnut) && ty.child("c:varyColors").and_then(|v| v.attr("val")).is_none_or(truthy),
|
||||
n,
|
||||
cats,
|
||||
title: title_of(chart, &series),
|
||||
series,
|
||||
accents: (4..10).map(|i| slot(theme, i)).collect(),
|
||||
legend: legend_pos(chart),
|
||||
cat,
|
||||
val,
|
||||
text_size: text_size(space),
|
||||
val_fmt,
|
||||
date1904,
|
||||
plot_border: plot.child("c:spPr").and_then(|p| p.child("a:ln")).and_then(|l| fill_colour(l, theme)),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn stacked(&self) -> bool {
|
||||
self.grouping != Grouping::Clustered
|
||||
}
|
||||
|
||||
fn accent(&self, i: usize) -> Rgb {
|
||||
self.accents.get(i % 6).copied().unwrap_or(Rgb::BLACK)
|
||||
}
|
||||
|
||||
pub(super) fn series_colour(&self, j: usize) -> Rgb {
|
||||
self.series.get(j).and_then(|s| s.color).unwrap_or_else(|| self.accent(j))
|
||||
}
|
||||
|
||||
/// Colour of point `i` of series `j`: its `c:dPt` override, else the varied accent (pie) or
|
||||
/// the series colour.
|
||||
pub(super) fn point_colour(&self, j: usize, i: usize) -> Rgb {
|
||||
if let Some(Some(c)) = self.series.get(j).and_then(|s| s.points.get(i)) {
|
||||
return *c;
|
||||
}
|
||||
if self.vary { self.accent(i) } else { self.series_colour(j) }
|
||||
}
|
||||
|
||||
pub(super) fn legend_entries(&self) -> Vec<(String, Rgb)> {
|
||||
if matches!(self.kind, Kind::Pie | Kind::Doughnut) {
|
||||
return self
|
||||
.cats
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, c)| (if c.is_empty() { (i + 1).to_string() } else { c.clone() }, self.point_colour(0, i)))
|
||||
.collect();
|
||||
}
|
||||
self.series
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(j, s)| (if s.name.is_empty() { format!("Series {}", j + 1) } else { s.name.clone() }, self.series_colour(j)))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// The series of a chart type element, within the chart's cell budget.
|
||||
pub(super) fn read_series(ty: &El, scatter: bool, theme: &[Rgb]) -> Vec<Series> {
|
||||
let mut budget = MAX_CELLS;
|
||||
let mut series = Vec::new();
|
||||
for ser in ty.children("c:ser").take(MAX_SERIES) {
|
||||
let s = Series::parse(ser, scatter, theme, budget.min(MAX_POINTS));
|
||||
budget = budget.saturating_sub(s.values.len().max(s.xs.len()));
|
||||
series.push(s);
|
||||
}
|
||||
series
|
||||
}
|
||||
|
||||
/// Values as their share of each category's total (100% stacked charts).
|
||||
fn to_percent(series: &mut [Series], n: usize) {
|
||||
for i in 0..n {
|
||||
let total: f64 = series.iter().map(|s| s.values.get(i).copied().flatten().map_or(0.0, f64::abs)).sum();
|
||||
for s in series.iter_mut() {
|
||||
if let Some(Some(v)) = s.values.get_mut(i) {
|
||||
*v = if total > 0.0 { *v / total } else { 0.0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The category axis (scatter: the horizontal value axis) and the value axis of a plot area.
|
||||
fn chart_axes(plot: &El, scatter: bool) -> (Axis, Axis) {
|
||||
let vals_ax: Vec<&El> = plot.children("c:valAx").collect();
|
||||
let horiz_pos = |a: &&El| matches!(a.child("c:axPos").and_then(|p| p.attr("val")), Some("b" | "t"));
|
||||
let (val_el, x_el) = if scatter {
|
||||
let x = vals_ax.iter().copied().find(horiz_pos).or_else(|| vals_ax.first().copied());
|
||||
let y = vals_ax.iter().copied().find(|a| !horiz_pos(a)).or_else(|| vals_ax.iter().copied().find(|a| Some(*a) != x));
|
||||
(y, x)
|
||||
} else {
|
||||
(vals_ax.first().copied(), None)
|
||||
};
|
||||
let cat_el = plot.els().find(|e| matches!(e.name.as_str(), "c:catAx" | "c:dateAx"));
|
||||
(axis(if scatter { x_el } else { cat_el }), axis(val_el))
|
||||
}
|
||||
|
||||
/// The chart's title. An empty `c:title` shows the series name (Word's automatic title of a
|
||||
/// one-series chart) unless the automatic title is deleted.
|
||||
pub(super) fn title_of(chart: &El, series: &[Series]) -> Option<String> {
|
||||
let auto_deleted = chart.child("c:autoTitleDeleted").and_then(|a| a.attr("val")).is_some_and(truthy);
|
||||
let auto = if !auto_deleted && series.len() == 1 { series.first().map(|s| s.name.clone()).filter(|s| !s.is_empty()) } else { None };
|
||||
chart.child("c:title").and_then(|t| title_text(t).or(auto))
|
||||
}
|
||||
|
||||
/// Whether a `c:dLbls` shows the values (`None` when there's none, or it doesn't say).
|
||||
fn show_values(d: Option<&El>) -> Option<bool> {
|
||||
let d = d?;
|
||||
if d.child("c:delete").and_then(|e| e.attr("val")).is_some_and(truthy) {
|
||||
return Some(false);
|
||||
}
|
||||
d.child("c:showVal").map(|v| v.attr("val").is_none_or(truthy))
|
||||
}
|
||||
|
||||
fn legend_pos(chart: &El) -> Option<Pos> {
|
||||
chart.child("c:legend").map(|l| match l.child("c:legendPos").and_then(|p| p.attr("val")).unwrap_or("r") {
|
||||
"l" => Pos::Left,
|
||||
"t" | "tr" => Pos::Top,
|
||||
"b" => Pos::Bottom,
|
||||
_ => Pos::Right,
|
||||
})
|
||||
}
|
||||
|
||||
/// The chart's text size (its `c:txPr` default run size), points.
|
||||
pub(super) fn text_size(space: &El) -> f32 {
|
||||
space
|
||||
.child("c:txPr")
|
||||
.and_then(|t| t.find("a:defRPr"))
|
||||
.and_then(|d| d.attr("sz"))
|
||||
.and_then(int)
|
||||
.map_or(FONT, |v| (v as f32 / 100.0).clamp(6.0, 24.0))
|
||||
}
|
||||
|
||||
impl Series {
|
||||
fn parse(ser: &El, scatter: bool, theme: &[Rgb], cap: usize) -> Series {
|
||||
let name = ser.child("c:tx").and_then(|t| t.find("c:v")).map(|v| label(v.text().trim())).unwrap_or_default();
|
||||
let sp = ser.child("c:spPr");
|
||||
let ln = sp.and_then(|p| p.child("a:ln"));
|
||||
let marker = ser.child("c:marker").and_then(|m| m.child("c:spPr")).and_then(|p| fill_colour(p, theme));
|
||||
let color = sp.and_then(|p| fill_colour(p, theme)).or_else(|| ln.and_then(|l| fill_colour(l, theme))).or(marker);
|
||||
let line_w = ln.and_then(|l| l.attr("w")).and_then(|v| measure(v, 12_700.0)).map_or(LINE_W, |w| w.clamp(0.0, 20.0));
|
||||
let line = ln.is_none_or(|l| l.child("a:noFill").is_none());
|
||||
let (values, fmt) = numbers(ser.child(if scatter { "c:yVal" } else { "c:val" }), cap);
|
||||
let (mut xs, x_fmt) = if scatter { numbers(ser.child("c:xVal"), cap) } else { (Vec::new(), None) };
|
||||
if scatter && xs.is_empty() {
|
||||
xs = (1..=values.len()).map(|i| Some(i as f64)).collect();
|
||||
}
|
||||
let mut points = vec![None; values.len()];
|
||||
for dpt in ser.children("c:dPt") {
|
||||
let i = dpt.child("c:idx").and_then(|e| e.attr("val")).and_then(int).and_then(|i| usize::try_from(i).ok());
|
||||
let c = dpt.child("c:spPr").and_then(|p| fill_colour(p, theme));
|
||||
if let Some(slot) = i.and_then(|i| points.get_mut(i)) {
|
||||
*slot = c;
|
||||
}
|
||||
}
|
||||
let labels = show_values(ser.child("c:dLbls"));
|
||||
Series { name, color, line_w, line, values, xs, points, fmt, x_fmt, labels, raw: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Longest number-format code honoured (the limit spreadsheet apps use); longer ones are ignored.
|
||||
const MAX_FORMAT_CODE: usize = 255;
|
||||
|
||||
/// Category labels of a `c:cat` (string or number cache), capped. Numbers show in the cache's
|
||||
/// format code (dates included); text and `General` numbers as they are stored.
|
||||
pub(super) fn labels(el: Option<&El>, cap: usize, date1904: bool) -> Vec<String> {
|
||||
let Some(el) = el else { return Vec::new() };
|
||||
let Some(cache) = el.find("c:strCache").or_else(|| el.find("c:numCache")).or_else(|| el.find("c:strLit")).or_else(|| el.find("c:numLit")) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let code =
|
||||
cache.child("c:formatCode").map(|f| f.text()).filter(|f| !f.is_empty() && f.len() <= MAX_FORMAT_CODE && !f.eq_ignore_ascii_case("General"));
|
||||
pts(cache, cap)
|
||||
.into_iter()
|
||||
.map(|p| {
|
||||
let text = p.unwrap_or_default();
|
||||
match (code.as_deref(), text.trim().parse::<f64>()) {
|
||||
(Some(code), Ok(v)) if v.is_finite() => label(&category_text(v, code, 1.0, date1904)),
|
||||
_ => label(&text),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A number in its format code: a date or time for date codes (serial days, from 1904 when
|
||||
/// `date1904`), else as [`format_value`] with tick step `step`.
|
||||
pub(super) fn category_text(v: f64, code: &str, step: f64, date1904: bool) -> String {
|
||||
match excel_stamp(v, date1904) {
|
||||
Some(t) if is_date_code(code) => date_text(t, code),
|
||||
_ => format_value(v, code, step),
|
||||
}
|
||||
}
|
||||
|
||||
/// Values of a `c:val`-style element (number cache or literal), capped; with its format code.
|
||||
pub(super) fn numbers(el: Option<&El>, cap: usize) -> (Vec<Option<f64>>, Option<String>) {
|
||||
let Some(cache) = el.and_then(|e| e.find("c:numCache").or_else(|| e.find("c:numLit"))) else {
|
||||
return (Vec::new(), None);
|
||||
};
|
||||
let fmt = cache.child("c:formatCode").map(|f| f.text()).filter(|f| !f.is_empty() && f.len() <= MAX_FORMAT_CODE);
|
||||
let vals = pts(cache, cap).into_iter().map(|p| p.and_then(|t| t.trim().parse::<f64>().ok()).filter(|v| v.is_finite())).collect();
|
||||
(vals, fmt)
|
||||
}
|
||||
|
||||
/// The `c:pt` texts of a cache by index. Indexes and counts from the file are capped.
|
||||
fn pts(cache: &El, cap: usize) -> Vec<Option<String>> {
|
||||
let declared = cache.child("c:ptCount").and_then(|c| c.attr("val")).and_then(int).unwrap_or(0).clamp(0, cap as i64) as usize;
|
||||
let mut out: Vec<Option<String>> = vec![None; declared];
|
||||
for p in cache.children("c:pt") {
|
||||
let Some(i) = p.attr("idx").and_then(int).and_then(|i| usize::try_from(i).ok()) else { continue };
|
||||
if i >= cap {
|
||||
continue;
|
||||
}
|
||||
if out.len() <= i {
|
||||
out.resize(i + 1, None);
|
||||
}
|
||||
if let Some(slot) = out.get_mut(i) {
|
||||
*slot = p.child("c:v").map(|v| v.text());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub(super) fn axis(a: Option<&El>) -> Axis {
|
||||
let Some(a) = a else { return Axis { deleted: true, ..Default::default() } };
|
||||
let scaling = a.child("c:scaling");
|
||||
let num =
|
||||
|n: &str| scaling.and_then(|s| s.child(n)).and_then(|e| e.attr("val")).and_then(|v| v.trim().parse::<f64>().ok()).filter(|v| v.is_finite());
|
||||
Axis {
|
||||
deleted: a.child("c:delete").and_then(|d| d.attr("val")).is_some_and(truthy),
|
||||
grid: a.child("c:majorGridlines").is_some(),
|
||||
line: a.child("c:spPr").and_then(|p| p.child("a:ln")).and_then(|l| l.child("a:noFill")).is_none(),
|
||||
mid: a.child("c:crossBetween").and_then(|c| c.attr("val")) == Some("midCat"),
|
||||
min: num("c:min"),
|
||||
max: num("c:max"),
|
||||
fmt: a
|
||||
.child("c:numFmt")
|
||||
.filter(|f| f.attr("sourceLinked") != Some("1"))
|
||||
.and_then(|f| f.attr("formatCode"))
|
||||
.filter(|f| f.len() <= MAX_FORMAT_CODE)
|
||||
.map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
fn title_text(t: &El) -> Option<String> {
|
||||
let tx = t.child("c:tx")?;
|
||||
let s = match tx.child("c:rich") {
|
||||
Some(rich) => {
|
||||
let mut s = String::new();
|
||||
rich_into(rich, &mut s);
|
||||
s
|
||||
}
|
||||
None => tx.find("c:v").map(|v| v.text()).unwrap_or_default(),
|
||||
};
|
||||
let s = label(s.trim());
|
||||
(!s.is_empty()).then_some(s)
|
||||
}
|
||||
|
||||
/// The `a:t` runs under an element, in order.
|
||||
fn rich_into(e: &El, s: &mut String) {
|
||||
for c in e.els() {
|
||||
if c.name == "a:t" {
|
||||
s.push_str(&c.text());
|
||||
} else {
|
||||
rich_into(c, s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn truthy(v: &str) -> bool {
|
||||
matches!(v.trim(), "1" | "true")
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
use super::canvas::{Rect, fill, rect_segs, runs, seg, stroke, text, text_at, text_w, widest, wrap};
|
||||
use super::format::format_value;
|
||||
use super::model::{DataLabel, Grouping, Kind, Series};
|
||||
use super::parse::category_text;
|
||||
use super::polar::{draw_data_labels, label_of};
|
||||
use super::{AXIS, Chart, El, GAP, GRID, GraphicItem, LINE, MAX_LABEL, MAX_TICKS, PathSeg, Rgb, ShapeKind, TextAlign, fill_colour, measure};
|
||||
|
||||
pub(super) fn label(s: &str) -> String {
|
||||
s.chars().take(MAX_LABEL).collect()
|
||||
}
|
||||
|
||||
/// Chart area: the chart space's fill and border (white and borderless by default).
|
||||
pub(super) fn backdrop(out: &mut Vec<GraphicItem>, space: &El, theme: &[Rgb], w: f32, h: f32) {
|
||||
let sp = space.child("c:spPr");
|
||||
let fill = match sp {
|
||||
Some(p) if p.child("a:noFill").is_some() => None,
|
||||
Some(p) => fill_colour(p, theme).or(Some(Rgb::WHITE)),
|
||||
None => Some(Rgb::WHITE),
|
||||
};
|
||||
let ln = sp.and_then(|p| p.child("a:ln"));
|
||||
let stroke = ln.and_then(|l| fill_colour(l, theme));
|
||||
let stroke_width = ln.and_then(|l| l.attr("w")).and_then(|v| measure(v, 12_700.0)).unwrap_or(0.75).clamp(0.0, 20.0);
|
||||
if fill.is_some() || stroke.is_some() {
|
||||
out.push(GraphicItem::Shape { rect: [0.0, 0.0, w, h], kind: ShapeKind::Rectangle, fill, stroke, stroke_width });
|
||||
}
|
||||
}
|
||||
|
||||
/// An axis scale: `lo` to `hi` in steps of `step`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(super) struct Scale {
|
||||
pub(super) lo: f64,
|
||||
pub(super) hi: f64,
|
||||
pub(super) step: f64,
|
||||
}
|
||||
|
||||
impl Scale {
|
||||
pub(super) fn ticks(&self) -> Vec<f64> {
|
||||
let n = (((self.hi - self.lo) / self.step + 1e-9).floor().max(0.0) as usize).min(MAX_TICKS);
|
||||
(0..=n).map(|k| self.lo + k as f64 * self.step).filter(|t| *t <= self.hi + 1e-9).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A scale for data `dlo..dhi` (zero included when `zero`), as Excel and Word pick it: an end
|
||||
/// away from zero sits 5% of the data span beyond the data (an end at zero stays there), rounded
|
||||
/// out to a step of 1, 2, 2.5, 5 or 10 times a power of ten (about seven steps). `min`/`max`
|
||||
/// from the file replace the ends.
|
||||
pub(super) fn nice_scale(dlo: f64, dhi: f64, zero: bool, min: Option<f64>, max: Option<f64>) -> Scale {
|
||||
let (dlo, dhi) = if dhi > dlo { (dlo, dhi) } else { (dlo, dlo + 1.0) };
|
||||
let span = dhi - dlo;
|
||||
// With zero, the ends reach it: all-negative data still starts its bars at zero.
|
||||
let (ends_lo, ends_hi) = if zero { (dlo.min(0.0), dhi.max(0.0)) } else { (dlo, dhi) };
|
||||
let lo = min.unwrap_or(if ends_lo < 0.0 { ends_lo - 0.05 * span } else { ends_lo });
|
||||
let hi = max.unwrap_or(if ends_hi > 0.0 { ends_hi + 0.05 * span } else { ends_hi });
|
||||
if !(hi > lo && hi.is_finite() && lo.is_finite()) {
|
||||
return Scale { lo: 0.0, hi: 1.0, step: 0.2 };
|
||||
}
|
||||
let raw = (hi - lo) / 7.5;
|
||||
let mag = 10f64.powf(raw.log10().floor());
|
||||
let step = [1.0, 2.0, 2.5, 5.0, 10.0].iter().map(|m| m * mag).find(|s| *s >= raw).unwrap_or(10.0 * mag);
|
||||
let lo_r = if min.is_some() { lo } else { ((lo / step) + 1e-9).floor() * step };
|
||||
let hi_r = if max.is_some() { hi } else { ((hi / step) - 1e-9).ceil() * step };
|
||||
if !(step.is_finite() && step > 0.0 && lo_r.is_finite() && hi_r.is_finite() && hi_r > lo_r) {
|
||||
return Scale { lo: 0.0, hi: 1.0, step: 0.2 };
|
||||
}
|
||||
Scale { lo: lo_r, hi: hi_r, step }
|
||||
}
|
||||
|
||||
/// The plot rectangle and the scales that map values and categories onto it.
|
||||
struct Frame {
|
||||
plot: Rect,
|
||||
horiz: bool,
|
||||
n: usize,
|
||||
vs: Scale,
|
||||
xs: Option<Scale>,
|
||||
mid: bool,
|
||||
}
|
||||
|
||||
impl Frame {
|
||||
/// Width of one category band.
|
||||
fn band(&self) -> f32 {
|
||||
let len = if self.horiz { self.plot.h } else { self.plot.w };
|
||||
len / self.n.max(1) as f32
|
||||
}
|
||||
|
||||
/// Start of category `i`'s band: its left edge, or for bars its top edge (category 0 at the
|
||||
/// bottom).
|
||||
fn band_start(&self, i: usize) -> f32 {
|
||||
let k = i as f32;
|
||||
if self.horiz { self.plot.bottom() - (k + 1.0) * self.band() } else { self.plot.x + k * self.band() }
|
||||
}
|
||||
|
||||
/// A value's position along the value direction: y for columns, x for bars.
|
||||
fn val(&self, v: f64) -> f32 {
|
||||
let t = ((v - self.vs.lo) / (self.vs.hi - self.vs.lo)).clamp(0.0, 1.0) as f32;
|
||||
if self.horiz { self.plot.x + t * self.plot.w } else { self.plot.bottom() - t * self.plot.h }
|
||||
}
|
||||
|
||||
/// x of category `i` for line and area points.
|
||||
fn cat_x(&self, i: usize) -> f32 {
|
||||
if !self.mid {
|
||||
return self.plot.x + (i as f32 + 0.5) * self.band();
|
||||
}
|
||||
if self.n <= 1 { self.plot.x + self.plot.w / 2.0 } else { self.plot.x + self.plot.w * i as f32 / (self.n - 1) as f32 }
|
||||
}
|
||||
|
||||
/// x of a scatter point.
|
||||
fn scat_x(&self, x: f64) -> f32 {
|
||||
match self.xs {
|
||||
Some(s) => self.plot.x + ((x - s.lo) / (s.hi - s.lo)).clamp(0.0, 1.0) as f32 * self.plot.w,
|
||||
None => self.plot.x,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The value scale and, for scatter charts, the x scale, with their ticks and tick texts.
|
||||
pub(super) struct Axes {
|
||||
pub(super) vs: Scale,
|
||||
vticks: Vec<(f64, String)>,
|
||||
xs: Option<Scale>,
|
||||
xticks: Vec<(f64, String)>,
|
||||
}
|
||||
|
||||
impl Axes {
|
||||
pub(super) fn of(c: &Chart) -> Axes {
|
||||
let (dlo, dhi) = extent(c);
|
||||
let (vmin, vmax) = if c.grouping == Grouping::Percent { (Some(0.0), Some(1.0)) } else { (c.val.min, c.val.max) };
|
||||
// Lines and scatters don't need zero for a narrow range (1000..1050 stays on its own scale).
|
||||
let zero = !matches!(c.kind, Kind::Line | Kind::Scatter) || !narrow(dlo, dhi);
|
||||
let vs = nice_scale(dlo, dhi, zero, vmin, vmax);
|
||||
let vticks = vs.ticks().into_iter().map(|t| (t, format_value(t, &c.val_fmt, vs.step))).collect();
|
||||
let xs = (c.kind == Kind::Scatter).then(|| {
|
||||
let (xl, xh) = x_extent(c);
|
||||
nice_scale(xl, xh, false, c.cat.min, c.cat.max)
|
||||
});
|
||||
// Scatter x values show in the axis's own format, else their cache's (dates included).
|
||||
let xfmt = c.cat.fmt.clone().or_else(|| c.series.iter().find_map(|s| s.x_fmt.clone())).unwrap_or_else(|| "General".to_string());
|
||||
let xticks = xs.map(|s| s.ticks().into_iter().map(|t| (t, category_text(t, &xfmt, s.step, c.date1904))).collect()).unwrap_or_default();
|
||||
Axes { vs, vticks, xs, xticks }
|
||||
}
|
||||
}
|
||||
|
||||
/// The room around the plot for axis labels, and the category labels as they'll be drawn.
|
||||
struct Margins {
|
||||
left: f32,
|
||||
top: f32,
|
||||
bottom: f32,
|
||||
/// Category labels in lines (none for scatter charts or a deleted category axis).
|
||||
wrapped: Vec<Vec<String>>,
|
||||
/// Every `skip`-th category label is shown under a column chart.
|
||||
skip: usize,
|
||||
}
|
||||
|
||||
/// The margins the axis labels need around the plot in `area`. Category labels under a column
|
||||
/// chart wrap to the band; labels that still don't fit show every k-th category.
|
||||
fn axis_margins(c: &Chart, ax: &Axes, area: Rect) -> Margins {
|
||||
let fs = c.text_size;
|
||||
let lh = fs * LINE;
|
||||
let scatter = c.kind == Kind::Scatter;
|
||||
let (cat_on, val_on) = (!c.cat.deleted, !c.val.deleted);
|
||||
let left = if c.horizontal {
|
||||
if cat_on { widest(&c.cats, fs).min(area.w * 0.4) + GAP } else { 0.0 }
|
||||
} else if val_on {
|
||||
ax.vticks.iter().map(|(_, s)| text_w(s, fs)).fold(0.0, f32::max) + GAP
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let band_w = (area.w - left).max(1.0) / c.n as f32;
|
||||
let wrapped: Vec<Vec<String>> = if cat_on && !scatter {
|
||||
let width = if c.horizontal { left - GAP } else { band_w };
|
||||
c.cats.iter().map(|s| wrap(s, width, fs)).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let widest_line = wrapped.iter().flatten().map(|s| text_w(s, fs)).fold(0.0, f32::max);
|
||||
let skip = ((widest_line / band_w).ceil() as usize).max(1);
|
||||
let label_lines = wrapped.iter().map(Vec::len).max().unwrap_or(1) as f32;
|
||||
let bottom = if cat_on && !c.horizontal {
|
||||
if scatter { lh + GAP } else { label_lines * lh + GAP }
|
||||
} else if c.horizontal && val_on {
|
||||
lh + GAP
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let top = if !c.horizontal && val_on { lh / 2.0 } else { 0.0 };
|
||||
Margins { left, top, bottom, wrapped, skip }
|
||||
}
|
||||
|
||||
/// A bar, column, line, area or scatter chart in `area`: gridlines, series, axes, then labels.
|
||||
pub(super) fn cartesian(out: &mut Vec<GraphicItem>, c: &Chart, area: Rect) {
|
||||
if c.n == 0 {
|
||||
return;
|
||||
}
|
||||
let ax = Axes::of(c);
|
||||
let m = axis_margins(c, &ax, area);
|
||||
let plot = Rect { x: area.x + m.left, y: area.y + m.top, w: area.w - m.left, h: area.h - m.top - m.bottom };
|
||||
if plot.w < 1.0 || plot.h < 1.0 {
|
||||
return;
|
||||
}
|
||||
let g = Frame { plot, horiz: c.horizontal, n: c.n, vs: ax.vs, xs: ax.xs, mid: c.val.mid };
|
||||
if c.val.grid {
|
||||
draw_grid(out, &g, &ax.vticks);
|
||||
}
|
||||
let mut labels = Vec::new();
|
||||
match c.kind {
|
||||
Kind::Bar => bars(out, c, &g, &mut labels),
|
||||
Kind::Line => lines(out, c, &g, &mut labels),
|
||||
Kind::Area => areas(out, c, &g, &mut labels),
|
||||
Kind::Scatter => draw_scatter(out, c, &g, &mut labels),
|
||||
Kind::Pie | Kind::Doughnut | Kind::Radar => {}
|
||||
}
|
||||
draw_axes(out, c, &g);
|
||||
draw_data_labels(out, &labels, c.text_size, g.horiz && c.kind == Kind::Bar);
|
||||
if !c.val.deleted {
|
||||
draw_value_labels(out, c, &g, &ax.vticks, Rect { w: m.left, ..area });
|
||||
}
|
||||
if !c.cat.deleted {
|
||||
draw_category_labels(out, c, &g, &ax.xticks, &m, area);
|
||||
}
|
||||
}
|
||||
|
||||
/// Major gridlines at the value ticks.
|
||||
fn draw_grid(out: &mut Vec<GraphicItem>, g: &Frame, ticks: &[(f64, String)]) {
|
||||
let plot = g.plot;
|
||||
for (t, _) in ticks {
|
||||
let p = g.val(*t);
|
||||
let segs = if g.horiz { seg(p, plot.y, p, plot.bottom()) } else { seg(plot.x, p, plot.right(), p) };
|
||||
stroke(out, segs, GRID, 0.75);
|
||||
}
|
||||
}
|
||||
|
||||
/// The category axis line, the plot area's outline and the value axis line, as the file shows them.
|
||||
fn draw_axes(out: &mut Vec<GraphicItem>, c: &Chart, g: &Frame) {
|
||||
let plot = g.plot;
|
||||
if !c.cat.deleted && c.cat.line {
|
||||
let segs = if g.horiz { seg(plot.x, plot.y, plot.x, plot.bottom()) } else { seg(plot.x, plot.bottom(), plot.right(), plot.bottom()) };
|
||||
stroke(out, segs, AXIS, 0.75);
|
||||
}
|
||||
if let Some(color) = c.plot_border {
|
||||
stroke(out, rect_segs(plot.x, plot.y, plot.right(), plot.bottom()), color, 0.75);
|
||||
}
|
||||
if !c.val.deleted && c.val.line {
|
||||
let segs = if g.horiz { seg(plot.x, plot.bottom(), plot.right(), plot.bottom()) } else { seg(plot.x, plot.y, plot.x, plot.bottom()) };
|
||||
stroke(out, segs, AXIS, 0.75);
|
||||
}
|
||||
}
|
||||
|
||||
/// Value tick labels: under the plot for bars, else right-aligned in `margin` (the area's left
|
||||
/// margin).
|
||||
fn draw_value_labels(out: &mut Vec<GraphicItem>, c: &Chart, g: &Frame, ticks: &[(f64, String)], margin: Rect) {
|
||||
let (fs, lh) = (c.text_size, c.text_size * LINE);
|
||||
for (t, s) in ticks {
|
||||
let p = g.val(*t);
|
||||
if g.horiz {
|
||||
text_at(out, p, g.plot.bottom() + GAP / 2.0, s, fs);
|
||||
} else {
|
||||
text(out, Rect { x: margin.x, y: p - lh / 2.0, w: margin.w - GAP, h: lh }, s, fs, TextAlign::Right, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Category labels: scatter x ticks under the plot, bar labels in the left margin, column
|
||||
/// labels under their bands (every `m.skip`-th).
|
||||
fn draw_category_labels(out: &mut Vec<GraphicItem>, c: &Chart, g: &Frame, xticks: &[(f64, String)], m: &Margins, area: Rect) {
|
||||
let (fs, lh) = (c.text_size, c.text_size * LINE);
|
||||
let plot = g.plot;
|
||||
if c.kind == Kind::Scatter {
|
||||
for (t, s) in xticks {
|
||||
text_at(out, g.scat_x(*t), plot.bottom() + GAP / 2.0, s, fs);
|
||||
}
|
||||
} else if g.horiz {
|
||||
for (i, lines) in m.wrapped.iter().enumerate() {
|
||||
let top = g.band_start(i) + g.band() / 2.0 - lines.len() as f32 * lh / 2.0;
|
||||
for (k, s) in lines.iter().enumerate() {
|
||||
text(out, Rect { x: area.x, y: top + k as f32 * lh, w: m.left - GAP, h: lh }, s, fs, TextAlign::Right, false);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let band = g.band();
|
||||
for (i, lines) in m.wrapped.iter().enumerate().filter(|(i, _)| i % m.skip == 0) {
|
||||
for (k, s) in lines.iter().enumerate() {
|
||||
let y = plot.bottom() + GAP / 2.0 + k as f32 * lh;
|
||||
text(out, Rect { x: g.cat_x(i) - band / 2.0, y, w: band, h: lh }, s, fs, TextAlign::Center, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether data `lo..hi` is a narrow band away from zero: its span is under a sixth of the end
|
||||
/// farther from zero (the mirror image for negative data).
|
||||
pub(super) fn narrow(lo: f64, hi: f64) -> bool {
|
||||
if lo > 0.0 {
|
||||
(hi - lo) / hi < 1.0 / 6.0
|
||||
} else if hi < 0.0 {
|
||||
(hi - lo) / lo.abs() < 1.0 / 6.0
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Lowest and highest value of the plot; stacked series add up per category.
|
||||
fn extent(c: &Chart) -> (f64, f64) {
|
||||
let mut pts: Vec<f64> = Vec::new();
|
||||
if c.stacked() {
|
||||
for i in 0..c.n {
|
||||
let (mut p, mut q) = (0.0, 0.0);
|
||||
for s in &c.series {
|
||||
match s.values.get(i).copied().flatten() {
|
||||
Some(v) if v >= 0.0 => p += v,
|
||||
Some(v) => q += v,
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
pts.push(p);
|
||||
pts.push(q);
|
||||
}
|
||||
} else {
|
||||
pts.extend(c.series.iter().flat_map(|s| s.values.iter().flatten().copied()));
|
||||
}
|
||||
let lo = pts.iter().copied().fold(f64::INFINITY, f64::min);
|
||||
let hi = pts.iter().copied().fold(f64::NEG_INFINITY, f64::max);
|
||||
if lo.is_finite() && hi.is_finite() { (lo, hi) } else { (0.0, 0.0) }
|
||||
}
|
||||
|
||||
/// Range of scatter x values (index + 1 where missing).
|
||||
fn x_extent(c: &Chart) -> (f64, f64) {
|
||||
let xs: Vec<f64> = c.series.iter().flat_map(|s| s.xs.iter().flatten().copied()).collect();
|
||||
let lo = xs.iter().copied().fold(f64::INFINITY, f64::min);
|
||||
let hi = xs.iter().copied().fold(f64::NEG_INFINITY, f64::max);
|
||||
if lo.is_finite() && hi.is_finite() { (lo, hi) } else { (0.0, 1.0) }
|
||||
}
|
||||
|
||||
fn bars(out: &mut Vec<GraphicItem>, c: &Chart, g: &Frame, labels: &mut Vec<DataLabel>) {
|
||||
let stacked = c.stacked();
|
||||
let slots = if stacked { 1 } else { c.series.len().max(1) };
|
||||
let ov = if stacked { 0.0 } else { c.overlap };
|
||||
let ns = slots as f32;
|
||||
let band = g.band();
|
||||
let thick = band / (ns - (ns - 1.0) * ov + c.gap);
|
||||
let group = thick * (ns - (ns - 1.0) * ov);
|
||||
let mut pos = vec![0.0f64; g.n];
|
||||
let mut neg = vec![0.0f64; g.n];
|
||||
for (j, s) in c.series.iter().enumerate().filter(|(_, s)| has_values(s)) {
|
||||
let slot = if stacked { 0 } else { j };
|
||||
let slot = (if g.horiz { slots.saturating_sub(1 + slot) } else { slot }) as f32;
|
||||
for i in 0..g.n {
|
||||
let Some(v) = s.values.get(i).copied().flatten() else { continue };
|
||||
let (v0, v1) = if !stacked {
|
||||
(0.0, v)
|
||||
} else if v >= 0.0 {
|
||||
let Some(p) = pos.get_mut(i) else { continue };
|
||||
let b = *p;
|
||||
*p += v;
|
||||
(b, *p)
|
||||
} else {
|
||||
let Some(q) = neg.get_mut(i) else { continue };
|
||||
let b = *q;
|
||||
*q += v;
|
||||
(b, *q)
|
||||
};
|
||||
let a = g.band_start(i) + (band - group) / 2.0 + slot * thick * (1.0 - ov);
|
||||
let (p0, p1) = (g.val(v0), g.val(v1));
|
||||
let (p0, p1) = (p0.min(p1), p0.max(p1));
|
||||
let segs = if g.horiz { rect_segs(p0, a, p1, a + thick) } else { rect_segs(a, p0, a + thick, p1) };
|
||||
fill(out, segs, c.point_colour(j, i));
|
||||
if let Some(text) = label_of(s, i) {
|
||||
let mid = a + thick / 2.0;
|
||||
let (x, y) = match (g.horiz, stacked) {
|
||||
(true, true) => ((p0 + p1) / 2.0, mid),
|
||||
(false, true) => (mid, (p0 + p1) / 2.0),
|
||||
// Beyond the bar's end: right of a positive bar, above a positive column.
|
||||
(true, false) => (if v >= 0.0 { p1 } else { p0 }, mid),
|
||||
(false, false) => (mid, if v >= 0.0 { p0 } else { p1 }),
|
||||
};
|
||||
labels.push(DataLabel { x, y, text, inside: stacked });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a series has any point to draw.
|
||||
pub(super) fn has_values(s: &Series) -> bool {
|
||||
s.values.iter().any(Option::is_some)
|
||||
}
|
||||
|
||||
/// Line series; stacked ones are drawn at the running total of the series up to them.
|
||||
fn lines(out: &mut Vec<GraphicItem>, c: &Chart, g: &Frame, labels: &mut Vec<DataLabel>) {
|
||||
let mut total = vec![0.0f64; g.n];
|
||||
for (j, s) in c.series.iter().enumerate().filter(|(_, s)| has_values(s)) {
|
||||
let pts: Vec<Option<(f32, f32)>> = (0..g.n)
|
||||
.map(|i| {
|
||||
let v = s.values.get(i).copied().flatten()?;
|
||||
let y = match total.get_mut(i) {
|
||||
Some(t) if c.stacked() => {
|
||||
*t += v;
|
||||
*t
|
||||
}
|
||||
_ => v,
|
||||
};
|
||||
Some((g.cat_x(i), g.val(y)))
|
||||
})
|
||||
.collect();
|
||||
for (i, p) in pts.iter().enumerate() {
|
||||
if let (Some((x, y)), Some(text)) = (p, label_of(s, i)) {
|
||||
labels.push(DataLabel { x: *x, y: *y, text, inside: false });
|
||||
}
|
||||
}
|
||||
stroke(out, runs(pts.into_iter()), c.series_colour(j), s.line_w);
|
||||
}
|
||||
}
|
||||
|
||||
fn areas(out: &mut Vec<GraphicItem>, c: &Chart, g: &Frame, labels: &mut Vec<DataLabel>) {
|
||||
let mut base = vec![0.0f64; g.n];
|
||||
for (j, s) in c.series.iter().enumerate().filter(|(_, s)| has_values(s)) {
|
||||
let vals = (0..g.n).map(|i| s.values.get(i).copied().flatten().unwrap_or(0.0));
|
||||
let top: Vec<f64> = base.iter().zip(vals).map(|(b, v)| b + v).collect();
|
||||
let mut segs = Vec::with_capacity(2 * g.n + 1);
|
||||
for (i, t) in top.iter().enumerate() {
|
||||
let (x, y) = (g.cat_x(i), g.val(*t));
|
||||
segs.push(if i == 0 { PathSeg::Move(x, y) } else { PathSeg::Line(x, y) });
|
||||
}
|
||||
for (i, b) in base.iter().enumerate().rev() {
|
||||
segs.push(PathSeg::Line(g.cat_x(i), g.val(*b)));
|
||||
}
|
||||
segs.push(PathSeg::Close);
|
||||
fill(out, segs, c.series_colour(j));
|
||||
for (i, (t, b)) in top.iter().zip(&base).enumerate() {
|
||||
if let Some(text) = label_of(s, i) {
|
||||
labels.push(DataLabel { x: g.cat_x(i), y: g.val((t + b) / 2.0), text, inside: true });
|
||||
}
|
||||
}
|
||||
if c.stacked() {
|
||||
base = top;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Scatter series as lines, or as small markers when the series has no line.
|
||||
fn draw_scatter(out: &mut Vec<GraphicItem>, c: &Chart, g: &Frame, labels: &mut Vec<DataLabel>) {
|
||||
const MARKER: f32 = 6.0;
|
||||
for (j, s) in c.series.iter().enumerate() {
|
||||
let pts: Vec<Option<(f32, f32)>> =
|
||||
s.xs.iter()
|
||||
.zip(&s.values)
|
||||
.map(|(x, y)| match (x, y) {
|
||||
(Some(x), Some(y)) => Some((g.scat_x(*x), g.val(*y))),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
for (i, p) in pts.iter().enumerate() {
|
||||
if let (Some((x, y)), Some(text)) = (p, label_of(s, i)) {
|
||||
labels.push(DataLabel { x: *x, y: *y - MARKER / 2.0, text, inside: false });
|
||||
}
|
||||
}
|
||||
if s.line {
|
||||
stroke(out, runs(pts.iter().copied()), c.series_colour(j), s.line_w);
|
||||
continue;
|
||||
}
|
||||
for (x, y) in pts.iter().flatten() {
|
||||
let rect = [x - MARKER / 2.0, y - MARKER / 2.0, MARKER, MARKER];
|
||||
out.push(GraphicItem::Shape { rect, kind: ShapeKind::Ellipse, fill: Some(c.series_colour(j)), stroke: None, stroke_width: 0.0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user