mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-03 08:50:02 -06:00
Compare commits
507
Commits
fix/ci-main-nbsp
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae5c65b9dd | ||
|
|
1f8f9e8e2c | ||
|
|
cf23ca73c9 | ||
|
|
b2b988cdc8 | ||
|
|
1ff68d64e3 | ||
|
|
051fd875ef | ||
|
|
ffb155a032 | ||
|
|
dd2a651093 | ||
|
|
bcbc8619e9 | ||
|
|
ab5ee42049 | ||
|
|
e00ff04d4a | ||
|
|
220994decf | ||
|
|
ab0020d6f4 | ||
|
|
5702fa08b2 | ||
|
|
1a8932b3ed | ||
|
|
8865455cfb | ||
|
|
0842d4faec | ||
|
|
14513ae5f6 | ||
|
|
b47079fc79 | ||
|
|
7d9968fd98 | ||
|
|
866c1f9cac | ||
|
|
0bb9add551 | ||
|
|
0a5b4221fd | ||
|
|
52a8a5db76 | ||
|
|
421bc8cdcd | ||
|
|
3ce9a84f81 | ||
|
|
bbe3547dd1 | ||
|
|
389fa90c29 | ||
|
|
84b4623e14 | ||
|
|
e2bc9e017e | ||
|
|
41d46625e0 | ||
|
|
4f2d0da04c | ||
|
|
242e850aaa | ||
|
|
ee8622ddb3 | ||
|
|
72327bdd36 | ||
|
|
fbe68547f4 | ||
|
|
868fffa6fe | ||
|
|
89c0d516ae | ||
|
|
3e2c79407d | ||
|
|
bf9e9fb8ae | ||
|
|
7aab0b202c | ||
|
|
c4d7948e68 | ||
|
|
d393e99879 | ||
|
|
1376a5476b | ||
|
|
34525f1cb1 | ||
|
|
905570a90b | ||
|
|
03c50f8af2 | ||
|
|
a8250e6472 | ||
|
|
1146691b90 | ||
|
|
c1490c5ba9 | ||
|
|
82418896f0 | ||
|
|
efb7d8ef20 | ||
|
|
2ef046bad9 | ||
|
|
e5d46bb242 | ||
|
|
9ffd30d968 | ||
|
|
96c6c19b9e | ||
|
|
919b8a5838 | ||
|
|
211377f550 | ||
|
|
09e9522a35 | ||
|
|
b7dad64d94 | ||
|
|
b545bc2757 | ||
|
|
36400f69eb | ||
|
|
6bb3f085aa | ||
|
|
9d5b98eb18 | ||
|
|
452d38da0b | ||
|
|
3181e502c5 | ||
|
|
8c3a792b49 | ||
|
|
c967f934ca | ||
|
|
d6cb183e6d | ||
|
|
449d2c2392 | ||
|
|
4ea6fea512 | ||
|
|
ccc8780b5e | ||
|
|
3110dcf02b | ||
|
|
fd7f6621a1 | ||
|
|
8a191fbf11 | ||
|
|
499fc6289e | ||
|
|
935aaf7147 | ||
|
|
1bd71f72bc | ||
|
|
bf80a31d10 | ||
|
|
a64d9748f2 | ||
|
|
c395760959 | ||
|
|
6ab1023ed6 | ||
|
|
639b2342c2 | ||
|
|
4a3586c686 | ||
|
|
ba9c4000ed | ||
|
|
e5f2885ecc | ||
|
|
5cd53f184a | ||
|
|
9e6fa432f2 | ||
|
|
9c72ddf56a | ||
|
|
e868e2a83f | ||
|
|
8686fa45ec | ||
|
|
78f3089b19 | ||
|
|
a88e005256 | ||
|
|
6e0f7e79ed | ||
|
|
3abe452b7c | ||
|
|
e6a5cd05f0 | ||
|
|
401c6c0dc2 | ||
|
|
8afe6bef6d | ||
|
|
6dae5a1d17 | ||
|
|
3d966e2418 | ||
|
|
a4bcf05e11 | ||
|
|
c934977157 | ||
|
|
a2f387567d | ||
|
|
54517ace14 | ||
|
|
a2446aa330 | ||
|
|
028d413ad6 | ||
|
|
7c20316dd5 | ||
|
|
eecbb8fe57 | ||
|
|
a20fd6216d | ||
|
|
9baacc310a | ||
|
|
e8ea394f72 | ||
|
|
55958eb0ec | ||
|
|
2be33e6dc9 | ||
|
|
5f551346a5 | ||
|
|
f2dfa0d28d | ||
|
|
fc41416804 | ||
|
|
1fb801f6bb | ||
|
|
aeae018cfd | ||
|
|
2424bf8caf | ||
|
|
05405d51a9 | ||
|
|
5b5810fe1f | ||
|
|
6e96b5470f | ||
|
|
c077c155e4 | ||
|
|
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 | ||
|
|
0d41b18301 | ||
|
|
01a1bf3921 | ||
|
|
744addabf2 | ||
|
|
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 | ||
|
|
62948a2c31 | ||
|
|
de3bfaf049 | ||
|
|
f8e327b068 | ||
|
|
50a8ebe307 | ||
|
|
f270dbd3b0 | ||
|
|
55dce8eebf | ||
|
|
e490c13cdb | ||
|
|
ffa35d62db | ||
|
|
436d444ef0 | ||
|
|
fa657a1c41 | ||
|
|
ffa127c615 | ||
|
|
8cdc172171 | ||
|
|
d603983a61 | ||
|
|
72b8818ea6 | ||
|
|
e4765c999d | ||
|
|
a47412aef9 | ||
|
|
3051fab792 | ||
|
|
74449b87fb | ||
|
|
bb023684f2 | ||
|
|
9b91b64d60 | ||
|
|
efa570a9be | ||
|
|
7ebfcf0ee2 | ||
|
|
46a3067e20 | ||
|
|
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 | ||
|
|
0cd06c786c | ||
|
|
62f9050848 | ||
|
|
f1264f3047 | ||
|
|
057350ef6e | ||
|
|
056b18b714 | ||
|
|
6a2b520fa4 | ||
|
|
20205a5051 | ||
|
|
574cef34c9 | ||
|
|
9e4adaf320 | ||
|
|
f6f9d65b0e | ||
|
|
b2787bf36e | ||
|
|
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 | ||
|
|
c1484abe80 | ||
|
|
49600e0344 | ||
|
|
f199efde84 | ||
|
|
823ce76e39 | ||
|
|
0bdd2a3e04 | ||
|
|
9129d713ca | ||
|
|
f501f7cb5f | ||
|
|
4f071a5b88 | ||
|
|
de8a85dc75 | ||
|
|
7f236c3ec5 | ||
|
|
01e32ff863 | ||
|
|
9fca3bae3a | ||
|
|
4c9ff5db35 | ||
|
|
4dfdecf3d7 | ||
|
|
e0014ff1d9 | ||
|
|
9a8f4669fa | ||
|
|
e7eda2aff0 | ||
|
|
577d5d61f8 | ||
|
|
d2f45ddd96 | ||
|
|
281392ac15 | ||
|
|
401d811fa0 | ||
|
|
695b2688d8 | ||
|
|
f06a1d3ba5 | ||
|
|
6fac5994f7 | ||
|
|
8e0353ae41 | ||
|
|
4c246f082a | ||
|
|
86b44280ac | ||
|
|
3db25e4aa9 | ||
|
|
753c624a7d | ||
|
|
6f7da7345f | ||
|
|
00856d7178 | ||
|
|
3d944e735a | ||
|
|
6766118d05 | ||
|
|
6b23f3b368 | ||
|
|
3a8ff6b6bc | ||
|
|
8a18026a13 | ||
|
|
4f5e596c71 | ||
|
|
c7dfd92ff1 | ||
|
|
66e92e3698 | ||
|
|
6142567b6a | ||
|
|
02fffea7c9 | ||
|
|
8ce3eb58c7 | ||
|
|
3d51cd3db2 | ||
|
|
08da5c5433 | ||
|
|
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,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')
|
||||
|
||||
@@ -6,6 +6,7 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'Cargo*'
|
||||
- 'craft-i18n.rev'
|
||||
- 'apps/wordcraft/**'
|
||||
- 'apps/wordcraft-web/**'
|
||||
- 'crates/ui-egui/**'
|
||||
@@ -14,6 +15,7 @@ on:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'Cargo*'
|
||||
- 'craft-i18n.rev'
|
||||
- 'apps/wordcraft/**'
|
||||
- 'apps/wordcraft-web/**'
|
||||
- 'crates/ui-egui/**'
|
||||
@@ -41,6 +43,26 @@ jobs:
|
||||
CARGO_INCREMENTAL: '0'
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# The interface catalogs and craft-proof's Language data come from craft-i18n at the commit in
|
||||
# craft-i18n.rev, so these tests build with the same data as the gate (ci.yml).
|
||||
- name: Check out craft-i18n
|
||||
uses: storytold/craft-common-actions/actions/checkout-craft-i18n@main
|
||||
|
||||
# The catalogs include CJK interface languages, so the UI tests need the suite's fonts, as in
|
||||
# the gate (same commit as ci.yml and release.yml).
|
||||
- name: Check out craft-fonts
|
||||
uses: storytold/craft-common-actions/actions/checkout-craft-fonts@main
|
||||
with:
|
||||
ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
|
||||
- name: Build with craft-fonts
|
||||
shell: bash
|
||||
run: |
|
||||
echo "CRAFT_FONTS_DIR=$GITHUB_WORKSPACE/craft-fonts" >> "$GITHUB_ENV"
|
||||
echo "CRAFT_FONTS_REQUIRED=1" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install Linux build and software-rendering dependencies
|
||||
if: runner.os == 'Linux'
|
||||
|
||||
@@ -11,7 +11,7 @@ on:
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
@@ -19,6 +19,10 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
release:
|
||||
# Only this job publishes the GitHub release, so only it gets write access.
|
||||
permissions:
|
||||
contents: write
|
||||
packages: 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:
|
||||
|
||||
@@ -31,3 +31,5 @@ jobs:
|
||||
with:
|
||||
app-name: wordcraft
|
||||
app-dir: WordCraft
|
||||
install-relative-path: Programs/ArtCraft/WordCraft
|
||||
install-scope: perUser
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# WordCraft — instructions for agents
|
||||
|
||||
WordCraft is a clean-room, open-source, Rust-native word processor targeting Microsoft Word parity — and going further on speed, openness and agent control. It runs natively on macOS, Windows, Linux and BSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../pdfcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign). Shared rules and learnings: [`storytold/craftrules`](https://github.com/storytold/craftrules) (`../../craftrules`) — read its `AGENTS.md`.
|
||||
WordCraft is a clean-room, open-source, Rust-native word processor targeting Microsoft Word parity — and going further on speed, openness and agent control. It runs natively on macOS, Windows, Linux and BSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../pdfcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign).
|
||||
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. `plan/` is gitignored (local only); if it's missing, start from `ROADMAP.md`, `docs/gaps.md` and `docs/roadmap.md` (Current focus).
|
||||
2. Follow the autonomous operation protocol (`plan/execution-plan.md` §5). Don't stop to ask unless a decision is genuinely the owner's (licensing, publishing, pushing to new remotes, secrets).
|
||||
|
||||
## Never crash
|
||||
People trust WordCraft with their writing; a crash loses their work. **This outranks feature work**: never ship a feature through a panic path, and fix a crash before building on top of it. Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md).
|
||||
People trust WordCraft with their writing; a crash loses their work. **This outranks feature work**: never ship a feature through a panic path, and fix a crash before building on top of it.
|
||||
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
|
||||
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature returns an error or is disabled; it never panics.
|
||||
- **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.
|
||||
@@ -20,7 +20,7 @@ People trust WordCraft with their writing; a crash loses their work. **This outr
|
||||
- Never copy, trace, screenshot-crop or recreate icons, images, templates, themes, clip art, fonts, cursors, sounds or any other asset from Microsoft Word/Office or any Microsoft, Adobe, Avid or Autodesk product. Not even "temporarily".
|
||||
- Every asset must be **original** (drawn in code — prefer this: `crates/ui-egui/src/icons.rs`), **public domain / CC0**, **OSI open source**, or **Creative Commons that allows redistribution**, or licensed open source by a contributor who made it.
|
||||
- **Every asset file has a row in [`ATTRIBUTION.md`](ATTRIBUTION.md)** (author, source, licence). `cargo xtask assets` (part of `cargo xtask ci`) fails otherwise. Add the row in the same commit as the file.
|
||||
- Fonts: new font files go to [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts), never this repo; it's an optional build input (`CRAFT_FONTS_DIR`). Documents asking for proprietary fonts (Calibri, Aptos, Cambria…) are rendered with installed system fonts or metric-compatible open substitutes (`crates/fonts/src/word.rs`) — never bundle proprietary fonts.
|
||||
- Fonts: new font files go to [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts), never this repo; it's an optional build input (`CRAFT_FONTS_DIR`). Spelling word lists and hyphenation patterns live in [`storytold/craft-i18n`](https://github.com/storytold/craft-i18n) `language-data/`, never this repo; they are embedded from `CRAFT_I18N_DIR` (pinned by `craft-i18n.rev`, which CI checks out). Without it the app builds, but English spelling falls back to an installed Hunspell English or the built-in Moby word list (with no American/British variant table) and pattern hyphenation is off (Bulgarian, Turkish and Russian still hyphenate by rule), so proofing tests need it. Documents asking for proprietary fonts (Calibri, Aptos, Cambria…) are rendered with installed system fonts or metric-compatible open substitutes (`crates/fonts/src/word.rs`) — never bundle proprietary fonts.
|
||||
- Templates, sample documents and themes are our own text and colours (`crates/engine/src/sample.rs`, `cmd/design.rs`). Demo images are public domain, with sources recorded.
|
||||
|
||||
## Clean room
|
||||
@@ -34,11 +34,12 @@ People trust WordCraft with their writing; a crash loses their work. **This outr
|
||||
| L0 | `geom` | units, rects, measurement parsing |
|
||||
| L1 | `doc`, `fonts` | document model + edit primitives; font DB, shaping, Word font substitution |
|
||||
| L2 | `layout`, `docx`, `formats` | line breaking, pagination, tables, headers/footers, hit testing; OOXML I/O; TXT/MD/HTML/RTF/ODT |
|
||||
| L3 | `render`, `pdf`, `proof` | vello_cpu rasteriser; krilla PDF; proofing |
|
||||
| L3 | `render`, `pdf` | vello_cpu rasteriser; krilla PDF |
|
||||
| L4 | `engine` | Session, undo, **command registry**, Word feature catalog, samples, I/O dispatch |
|
||||
| L5 | `mcp` | MCP server (headless or bridged to the app) |
|
||||
| L6 | `ui-egui` | Word-style egui front end (swappable) |
|
||||
| apps | `apps/wordcraft`, `apps/wordcraft-cli`, `apps/wordcraft-web` | binaries |
|
||||
| external | `craft-proof` (craft-libs, used as `wordcraft-proof`, sits at L1) | proofing: spelling, grammar, hyphenation; its word lists and patterns are craft-i18n's `language-data/` (`CRAFT_I18N_DIR`) |
|
||||
|
||||
- **Everything is a command.** User-visible behaviour = a `CommandSpec` in `crates/engine/src/cmd/*.rs` (id, label, ribbon location, shortcut, params doc, `enabled`, `run`) + tests. UI-only commands (`ui.*`) live in `crates/ui-egui/src/lib.rs`. The ribbon, shortcuts, command search, CLI, control channel and MCP all dispatch by id. Programmatic calls never open dialogs.
|
||||
- **Parity is measured:** `crates/engine/src/catalog.rs` lists Word's ribbon/menu features with command ids. `cargo xtask parity` writes `docs/parity-checklist.md`; a test enforces a floor that only rises. Implement missing ids to raise it.
|
||||
@@ -50,7 +51,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,22 +64,24 @@ Typing, Enter, Backspace, Tab, lists, AutoCorrect and AutoFormat follow Word's o
|
||||
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done (disk).
|
||||
|
||||
## 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.
|
||||
Progress docs: `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.
|
||||
|
||||
## Contributor credits (About window)
|
||||
|
||||
- About ▸ Contributors/Models are compiled into the binary from `contributors/contributors.json`
|
||||
(commit stats; generated, never hand-edit) and `contributors/people.toml` (names people chose for
|
||||
(commit stats; never hand-edit) and `contributors/people.toml` (names people chose for
|
||||
themselves). See `docs/contributors.md`.
|
||||
- **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub
|
||||
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
|
||||
whether they want to be credited by more than their username: a real name, a display name, and/or
|
||||
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
|
||||
(copy the template at the top of the file, or run
|
||||
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
|
||||
(copy the template at the top of the file)
|
||||
and include it in their PR, committed as them. If no, change nothing: they are credited as
|
||||
`@username` anyway.
|
||||
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
|
||||
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.
|
||||
|
||||
+10
-18
@@ -10,15 +10,6 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
|
||||
|---|---|---|---|---|
|
||||
| `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) |
|
||||
| `assets/spelling/en-variants.txt` | WordCraft contributors | original list written for this repository from general knowledge of English spelling; no other dictionary or variant list copied | MIT OR Apache-2.0 | ~280 American/British spelling pairs (-or/-our, -er/-re, -ize/-ise, -yze/-yse, -ense/-ence, doubled l, ae/oe…) with a policy per English: en-US, en-CA and the British-spelling Englishes (#1281, #1045) |
|
||||
| `assets/spelling/ru-extra.txt` | WordCraft contributors | original word list written for this repository (common modern Russian words older Hunspell dictionaries lack, with their declension classes); no other dictionary copied | MIT OR Apache-2.0 | ~200 Russian base forms supplementing spelling; their forms are generated by `crates/proof/src/ru/extra.rs` |
|
||||
| `assets/hyphenation/pl.pat` | Hanna Kołodziejska, Bogusław Jackowski, Marek Ryćko (Polish TeX hyphenation patterns, 1987–1995); hyph-utf8 package | https://github.com/hyphenation/tex-hyphen (`hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-pl.tex`, SHA-256 `83ddbbd21bc06a44bc4f4a4c49c38782e809cff5f026b764c044daa29e818843`), bundled unmodified | MIT (the file is offered under MIT or Knuth's public-domain terms; WordCraft uses MIT, whose notice is in the file's header) | 4,053 Liang patterns and 20 exception words; Polish hyphenation |
|
||||
| `assets/hyphenation/pt.pat` | Pedro J. de Rezende, J. Joao Dias Almeida, Leonardo Araujo, Aline Benevides (Portuguese TeX hyphenation patterns, 1987–2024); hyph-utf8 package | https://github.com/hyphenation/tex-hyphen (`hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-pt.tex`, SHA-256 `c110a39e501db372d1d755e1c64464321a2efa7d8e9617f66001000389b941f2`), bundled unmodified | BSD 3-clause (the file's licence header names it and carries the notice) | 426 Liang patterns and 2 exception words; Portuguese hyphenation |
|
||||
| `assets/hyphenation/it.pat` | Claudio Beccari (Italian TeX hyphenation patterns, 2008–2014); hyph-utf8 package | https://github.com/hyphenation/tex-hyphen (`hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-it.tex`, SHA-256 `6ce56ed6ed2ca7c0688838e366e6595e0429f984f4fc6e96ec045cebb1e76a47`), bundled unmodified | MIT (the file is offered under LPPL 1.3+ or MIT; WordCraft uses MIT, whose notice is in the file's header) | 355 Liang patterns, no exception words; Italian hyphenation |
|
||||
| `assets/hyphenation/de.pat` | Deutschsprachige Trennmustermannschaft: Stephan Hennig, Werner Lemberg, Günter Milde, Sander van Geloven, Georg Pfeiffer, Gisbert W. Selke, Tobias Wendorf, Keno Wehr (German TeX hyphenation patterns, reformed spelling, 2013–2024); hyph-utf8 package | https://github.com/hyphenation/tex-hyphen (`hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-de-1996.tex`, SHA-256 `374ad1ce3263f8a2791ec070ef9b516c532dae64456f3fb656e617e8ae53a9d3`), bundled unmodified | MIT (the notice is in the file's header) | 36,709 Liang patterns, no exception words; German hyphenation (de-DE, de-AT, de-CH) |
|
||||
| `assets/app-icon/wordcraft-1024.png` | WordCraft contributors | original artwork drawn in SVG (`assets/app-icon/wordcraft.svg`), rendered by `packaging/icons.sh` | MIT OR Apache-2.0 | Placeholder app icon until the WordCraft mascot drawing lands |
|
||||
| `packaging/macos/dmg/background.svg` | @XusBadia | original work for this repository, derived from the WordCraft app icon (`assets/app-icon/wordcraft.svg`, referenced via `<image>`, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0, like the code ([`LICENSE-MIT`](LICENSE-MIT), [`LICENSE-APACHE`](LICENSE-APACHE)) | WordCraft macOS DMG window background (2026-10-08); used for the Finder window of the macOS DMG, rendered to `background.tiff` by `packaging/macos/dmg/generate.py` |
|
||||
| `packaging/macos/dmg/background.tiff` | @XusBadia | Rendered from `packaging/macos/dmg/background.svg` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0, like the code ([`LICENSE-MIT`](LICENSE-MIT), [`LICENSE-APACHE`](LICENSE-APACHE)) | 1x + 2x HiDPI TIFF for the macOS DMG window; includes the app icon (see its row) and LittleCMS's built-in sRGB profile ("No copyright, use freely") |
|
||||
@@ -59,14 +50,15 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
|
||||
| `docs/images/dark.png` | WordCraft contributors | screenshot of WordCraft itself (sample document written for WordCraft, rendered by `ui_shot`) | MIT OR Apache-2.0 | Original; no Microsoft UI or assets |
|
||||
| `docs/images/backstage.png` | WordCraft contributors | screenshot of WordCraft itself (sample document written for WordCraft, rendered by `ui_shot`) | MIT OR Apache-2.0 | Original; no Microsoft UI or assets |
|
||||
| `docs/images/layout.png` | WordCraft contributors | screenshot of WordCraft itself (sample document written for WordCraft, rendered by `ui_shot`) | MIT OR Apache-2.0 | Original; no Microsoft UI or assets |
|
||||
| `docs/brand/LICENSE-brand.txt` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| `docs/brand/artcraft-logo.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| `docs/brand/artcraft-mark.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| `docs/brand/LICENSE-brand.txt` | ArtCraft Team | ArtCraft brand kit (project owner) | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | ArtCraft brand kit (project owner) | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | ArtCraft brand kit (project owner) | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| `docs/brand/artcraft-logo.png` | ArtCraft Team | ArtCraft brand kit (project owner) | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | ArtCraft brand kit (project owner) | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | ArtCraft brand kit (project owner) | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | ArtCraft brand kit (project owner) | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| `docs/brand/artcraft-mark.png` | ArtCraft Team | ArtCraft brand kit (project owner) | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | ArtCraft brand kit (project owner) | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| craft-fonts (optional build input, not files in this repo): BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans CJK SC, Noto Sans Arabic | see https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | embedded only when built with `CRAFT_FONTS_DIR` (all official releases) | OFL-1.1 | Japanese and Chinese UI, Japanese document fallback |
|
||||
| craft-i18n (optional build input, not files in this repo): the interface translations | see https://github.com/storytold/craft-i18n/blob/main/ATTRIBUTION.md, which credits every translator of WordCraft's former in-repo catalogs (`crates/ui-egui/src/i18n/*.tsv`, migrated at `wordcraft@806a945`) | embedded only when built with `CRAFT_I18N_DIR`, at the commit in `craft-i18n.rev` (all official releases) | MIT OR Apache-2.0 | Interface languages other than English; clean-room translations from the English interface labels |
|
||||
| craft-i18n Language data (optional build input, not files in this repo): the spelling word lists and hyphenation patterns (`language-data/`: the Moby Hyphenator II list and our patterns for English, `en-extra.txt`, `en-variants.txt`, `ru-extra.txt`, and the hyph-utf8 German, Polish, Portuguese and Italian patterns) | see the Language data section of https://github.com/storytold/craft-i18n/blob/main/ATTRIBUTION.md (authors, upstream sources with SHA-256, licences); moved there from `assets/hyphenation/` and `assets/spelling/` byte for byte | embedded by craft-proof (storytold/craft-libs) only when built with `CRAFT_I18N_DIR`, at the commit in `craft-i18n.rev` (all official releases) | Public Domain / CC0-1.0, MIT, BSD-3-Clause, MIT OR Apache-2.0 (per file; see NOTICE) | Spelling and hyphenation; without it English spelling falls back to an installed Hunspell English or craft-proof's built-in Moby list, and pattern hyphenation is off |
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
# WordCraft — instructions for agents
|
||||
|
||||
WordCraft is a clean-room, open-source, Rust-native word processor targeting Microsoft Word parity — and going further on speed, openness and agent control. It runs natively on macOS, Windows, Linux and BSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../pdfcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign). Shared rules and learnings: [`storytold/craftrules`](https://github.com/storytold/craftrules) (`../../craftrules`) — read its `AGENTS.md`.
|
||||
WordCraft is a clean-room, open-source, Rust-native word processor targeting Microsoft Word parity — and going further on speed, openness and agent control. It runs natively on macOS, Windows, Linux and BSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../pdfcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign).
|
||||
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. `plan/` is gitignored (local only); if it's missing, start from `ROADMAP.md`, `docs/gaps.md` and `docs/roadmap.md` (Current focus).
|
||||
2. Follow the autonomous operation protocol (`plan/execution-plan.md` §5). Don't stop to ask unless a decision is genuinely the owner's (licensing, publishing, pushing to new remotes, secrets).
|
||||
|
||||
## Never crash
|
||||
People trust WordCraft with their writing; a crash loses their work. **This outranks feature work**: never ship a feature through a panic path, and fix a crash before building on top of it. Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md).
|
||||
People trust WordCraft with their writing; a crash loses their work. **This outranks feature work**: never ship a feature through a panic path, and fix a crash before building on top of it.
|
||||
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
|
||||
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature returns an error or is disabled; it never panics.
|
||||
- **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.
|
||||
- Never copy, trace, screenshot-crop or recreate icons, images, templates, themes, clip art, fonts, cursors, sounds or any other asset from Microsoft Word/Office or any Microsoft, Adobe, Avid or Autodesk product. Not even "temporarily".
|
||||
- Every asset must be **original** (drawn in code — prefer this: `crates/ui-egui/src/icons.rs`), **public domain / CC0**, **OSI open source**, or **Creative Commons that allows redistribution**, or licensed open source by a contributor who made it.
|
||||
- **Every asset file has a row in [`ATTRIBUTION.md`](ATTRIBUTION.md)** (author, source, licence). `cargo xtask assets` (part of `cargo xtask ci`) fails otherwise. Add the row in the same commit as the file.
|
||||
- Fonts: new font files go to [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts), never this repo; it's an optional build input (`CRAFT_FONTS_DIR`). Documents asking for proprietary fonts (Calibri, Aptos, Cambria…) are rendered with installed system fonts or metric-compatible open substitutes (`crates/fonts/src/word.rs`) — never bundle proprietary fonts.
|
||||
- Fonts: new font files go to [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts), never this repo; it's an optional build input (`CRAFT_FONTS_DIR`). Spelling word lists and hyphenation patterns live in [`storytold/craft-i18n`](https://github.com/storytold/craft-i18n) `language-data/`, never this repo; they are embedded from `CRAFT_I18N_DIR` (pinned by `craft-i18n.rev`, which CI checks out). Without it the app builds, but English spelling falls back to an installed Hunspell English or the built-in Moby word list (with no American/British variant table) and pattern hyphenation is off (Bulgarian, Turkish and Russian still hyphenate by rule), so proofing tests need it. Documents asking for proprietary fonts (Calibri, Aptos, Cambria…) are rendered with installed system fonts or metric-compatible open substitutes (`crates/fonts/src/word.rs`) — never bundle proprietary fonts.
|
||||
- Templates, sample documents and themes are our own text and colours (`crates/engine/src/sample.rs`, `cmd/design.rs`). Demo images are public domain, with sources recorded.
|
||||
|
||||
## Clean room
|
||||
@@ -34,11 +34,12 @@ People trust WordCraft with their writing; a crash loses their work. **This outr
|
||||
| L0 | `geom` | units, rects, measurement parsing |
|
||||
| L1 | `doc`, `fonts` | document model + edit primitives; font DB, shaping, Word font substitution |
|
||||
| L2 | `layout`, `docx`, `formats` | line breaking, pagination, tables, headers/footers, hit testing; OOXML I/O; TXT/MD/HTML/RTF/ODT |
|
||||
| L3 | `render`, `pdf`, `proof` | vello_cpu rasteriser; krilla PDF; proofing |
|
||||
| L3 | `render`, `pdf` | vello_cpu rasteriser; krilla PDF |
|
||||
| L4 | `engine` | Session, undo, **command registry**, Word feature catalog, samples, I/O dispatch |
|
||||
| L5 | `mcp` | MCP server (headless or bridged to the app) |
|
||||
| L6 | `ui-egui` | Word-style egui front end (swappable) |
|
||||
| apps | `apps/wordcraft`, `apps/wordcraft-cli`, `apps/wordcraft-web` | binaries |
|
||||
| external | `craft-proof` (craft-libs, used as `wordcraft-proof`, sits at L1) | proofing: spelling, grammar, hyphenation; its word lists and patterns are craft-i18n's `language-data/` (`CRAFT_I18N_DIR`) |
|
||||
|
||||
- **Everything is a command.** User-visible behaviour = a `CommandSpec` in `crates/engine/src/cmd/*.rs` (id, label, ribbon location, shortcut, params doc, `enabled`, `run`) + tests. UI-only commands (`ui.*`) live in `crates/ui-egui/src/lib.rs`. The ribbon, shortcuts, command search, CLI, control channel and MCP all dispatch by id. Programmatic calls never open dialogs.
|
||||
- **Parity is measured:** `crates/engine/src/catalog.rs` lists Word's ribbon/menu features with command ids. `cargo xtask parity` writes `docs/parity-checklist.md`; a test enforces a floor that only rises. Implement missing ids to raise it.
|
||||
@@ -49,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).
|
||||
@@ -62,4 +63,9 @@ Typing, Enter, Backspace, Tab, lists, AutoCorrect and AutoFormat follow Word's o
|
||||
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done (disk).
|
||||
|
||||
## 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.
|
||||
Progress docs: `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
+195
-54
@@ -235,7 +235,7 @@ dependencies = [
|
||||
"objc2-foundation 0.3.2",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
@@ -809,9 +809,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clipboard-win"
|
||||
version = "5.4.1"
|
||||
version = "5.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
|
||||
checksum = "052af7878466feccda12ba7a6a67d019d712f296809559b47c12c8cc196d3a9a"
|
||||
dependencies = [
|
||||
"error-code",
|
||||
]
|
||||
@@ -1011,10 +1011,54 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-atomic"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=762a06a4c6b48b522b7d4de84a87917ef90265e6#762a06a4c6b48b522b7d4de84a87917ef90265e6"
|
||||
dependencies = [
|
||||
"rustix 1.1.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-font-catalog"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=762a06a4c6b48b522b7d4de84a87917ef90265e6#762a06a4c6b48b522b7d4de84a87917ef90265e6"
|
||||
dependencies = [
|
||||
"fontique",
|
||||
"skrifa 0.47.0",
|
||||
"thiserror 2.0.21",
|
||||
"windows-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-font-store"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=762a06a4c6b48b522b7d4de84a87917ef90265e6#762a06a4c6b48b522b7d4de84a87917ef90265e6"
|
||||
dependencies = [
|
||||
"craft-atomic",
|
||||
"craft-font-catalog",
|
||||
"flate2",
|
||||
"log",
|
||||
"ruzstd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"skrifa 0.47.0",
|
||||
"tar",
|
||||
"thiserror 2.0.21",
|
||||
"toml",
|
||||
"ureq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-fonts-build"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
|
||||
[[package]]
|
||||
name = "craft-i18n"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=17744430be327d0257496a6a31c8d38fe20a6756#17744430be327d0257496a6a31c8d38fe20a6756"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"fixed_decimal",
|
||||
"icu_decimal",
|
||||
@@ -1037,7 +1081,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=17744430be327d0257496a6a31c8d38fe20a6756#17744430be327d0257496a6a31c8d38fe20a6756"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"krilla",
|
||||
"thiserror 2.0.21",
|
||||
@@ -1046,34 +1090,58 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-print"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=17744430be327d0257496a6a31c8d38fe20a6756#17744430be327d0257496a6a31c8d38fe20a6756"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"craft-winprint-gdi",
|
||||
"miniz_oxide 0.8.9",
|
||||
"miniz_oxide 0.9.1",
|
||||
"roxmltree",
|
||||
"tempfile",
|
||||
"winprint",
|
||||
"winsafe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-proof"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=47c9f62530db72bc5c3831c86653b47b20c640be#47c9f62530db72bc5c3831c86653b47b20c640be"
|
||||
dependencies = [
|
||||
"log",
|
||||
"miniz_oxide 0.9.1",
|
||||
"toml",
|
||||
"unicode-normalization",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=25da1314d443171b4adeaad2cd9454b273f35c6f#25da1314d443171b4adeaad2cd9454b273f35c6f"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"craft-fonts-build",
|
||||
"craft-i18n",
|
||||
"egui",
|
||||
"epaint",
|
||||
"js-sys",
|
||||
"log",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"skrifa 0.44.0",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
"egui",
|
||||
"flate2",
|
||||
"getrandom 0.3.4",
|
||||
"image",
|
||||
"log",
|
||||
"plist",
|
||||
"semver",
|
||||
@@ -1089,6 +1157,17 @@ dependencies = [
|
||||
"zip 9.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-window"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"wayland-client",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-winprint-gdi"
|
||||
version = "0.1.0"
|
||||
@@ -1179,9 +1258,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ctutils"
|
||||
version = "0.4.3"
|
||||
version = "0.4.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574"
|
||||
checksum = "a2376ffd6f96d8a9db59875b4b6b0237d6900b5baf64279714e05493ce64828a"
|
||||
dependencies = [
|
||||
"cmov",
|
||||
]
|
||||
@@ -1573,7 +1652,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1772,6 +1851,28 @@ dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fontique"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6688bc1294fe7117d788937b6c53480169b29c566954af490830d4c09da9516a"
|
||||
dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
"linebender_resource_handle",
|
||||
"memmap2",
|
||||
"objc2 0.6.5",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-text",
|
||||
"objc2-foundation 0.3.2",
|
||||
"parlance",
|
||||
"read-fonts 0.41.0",
|
||||
"roxmltree",
|
||||
"smallvec",
|
||||
"windows",
|
||||
"windows-core",
|
||||
"yeslogic-fontconfig-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.5.0"
|
||||
@@ -2826,7 +2927,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "krilla"
|
||||
version = "0.8.2"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=17744430be327d0257496a6a31c8d38fe20a6756#17744430be327d0257496a6a31c8d38fe20a6756"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bumpalo",
|
||||
@@ -3305,6 +3406,16 @@ dependencies = [
|
||||
"objc2-foundation 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-text"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-encode"
|
||||
version = "4.1.0"
|
||||
@@ -3544,7 +3655,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3576,6 +3687,12 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parlance"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73072a9470554c8e794778acece2312661218aaabb635600957d952cf01c716e"
|
||||
|
||||
[[package]]
|
||||
name = "pdf-writer"
|
||||
version = "0.15.0"
|
||||
@@ -4038,7 +4155,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4338,9 +4455,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "roxmltree"
|
||||
version = "0.20.0"
|
||||
version = "0.21.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
|
||||
checksum = "9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
@@ -4386,7 +4506,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4445,7 +4565,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4484,6 +4604,12 @@ dependencies = [
|
||||
"wait-timeout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruzstd"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69e4ce4a039f42bf25920b95667e4d789e98749ab11bbb8d2c2936f788421bb5"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
@@ -5229,10 +5355,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.3.4",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix 1.1.5",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5691,6 +5817,7 @@ dependencies = [
|
||||
"rustls-platform-verifier",
|
||||
"ureq-proto",
|
||||
"utf8-zero",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6074,6 +6201,15 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weezl"
|
||||
version = "0.1.12"
|
||||
@@ -6269,7 +6405,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6616,12 +6752,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft"
|
||||
version = "0.11.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
"clipboard-win",
|
||||
"craft-print",
|
||||
"craft-proof",
|
||||
"craft-update",
|
||||
"dispatch2",
|
||||
"eframe",
|
||||
@@ -6644,14 +6781,13 @@ dependencies = [
|
||||
"wordcraft-doc",
|
||||
"wordcraft-engine",
|
||||
"wordcraft-fonts",
|
||||
"wordcraft-proof",
|
||||
"wordcraft-ui-egui",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-cli"
|
||||
version = "0.11.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"sys-locale",
|
||||
@@ -6664,11 +6800,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-control-key"
|
||||
version = "0.11.0"
|
||||
version = "0.15.0"
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-doc"
|
||||
version = "0.11.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"log",
|
||||
@@ -6682,7 +6818,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-docbin"
|
||||
version = "0.11.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chrono",
|
||||
@@ -6699,7 +6835,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-docx"
|
||||
version = "0.11.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64 0.23.1",
|
||||
@@ -6723,10 +6859,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-engine"
|
||||
version = "0.11.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chrono",
|
||||
"craft-font-catalog",
|
||||
"craft-font-store",
|
||||
"craft-proof",
|
||||
"encoding_rs",
|
||||
"getrandom 0.3.4",
|
||||
"image",
|
||||
@@ -6746,7 +6885,6 @@ dependencies = [
|
||||
"wordcraft-geom",
|
||||
"wordcraft-layout",
|
||||
"wordcraft-pdf",
|
||||
"wordcraft-proof",
|
||||
"wordcraft-render",
|
||||
"zeroize",
|
||||
"zip 3.0.0",
|
||||
@@ -6754,7 +6892,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-fonts"
|
||||
version = "0.11.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"brotli-decompressor",
|
||||
"harfrust 0.13.3",
|
||||
@@ -6768,7 +6906,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-formats"
|
||||
version = "0.11.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chardetng",
|
||||
@@ -6781,12 +6919,13 @@ dependencies = [
|
||||
"quick-xml 0.41.0",
|
||||
"wordcraft-doc",
|
||||
"wordcraft-geom",
|
||||
"wordcraft-metafile",
|
||||
"zip 3.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-geom"
|
||||
version = "0.11.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde",
|
||||
@@ -6794,8 +6933,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-layout"
|
||||
version = "0.11.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"craft-proof",
|
||||
"image",
|
||||
"js-sys",
|
||||
"kurbo",
|
||||
@@ -6809,12 +6949,11 @@ dependencies = [
|
||||
"wordcraft-fonts",
|
||||
"wordcraft-geom",
|
||||
"wordcraft-metafile",
|
||||
"wordcraft-proof",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-mcp"
|
||||
version = "0.11.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde_json",
|
||||
@@ -6826,7 +6965,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-metafile"
|
||||
version = "0.11.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"thiserror 2.0.21",
|
||||
@@ -6834,7 +6973,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-pdf"
|
||||
version = "0.11.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-pdf",
|
||||
@@ -6853,19 +6992,9 @@ dependencies = [
|
||||
"wordcraft-metafile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-proof"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"miniz_oxide 0.9.1",
|
||||
"proptest",
|
||||
"unicode-normalization",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-render"
|
||||
version = "0.11.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -6879,13 +7008,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-ui-egui"
|
||||
version = "0.11.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chrono",
|
||||
"craft-i18n",
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
"craft-window",
|
||||
"egui",
|
||||
"egui_kittest",
|
||||
"image",
|
||||
@@ -6909,7 +7039,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-web"
|
||||
version = "0.11.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -6928,7 +7058,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-zotero"
|
||||
version = "0.11.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -7091,11 +7221,22 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.11.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yeslogic-fontconfig-sys"
|
||||
version = "6.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76"
|
||||
dependencies = [
|
||||
"dlib",
|
||||
"once_cell",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.4"
|
||||
|
||||
+17
-6
@@ -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.15.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -21,7 +21,15 @@ wordcraft-docbin = { path = "crates/docbin" }
|
||||
wordcraft-formats = { path = "crates/formats" }
|
||||
wordcraft-render = { path = "crates/render" }
|
||||
wordcraft-pdf = { path = "crates/pdf" }
|
||||
wordcraft-proof = { path = "crates/proof" }
|
||||
# Proofing is craft-libs' craft-proof (it was crates/proof); its Language data is craft-i18n's
|
||||
# language-data/, embedded from CRAFT_I18N_DIR (craft-i18n.rev). The package keeps the
|
||||
# `wordcraft-proof` name here so `wordcraft_proof::` paths are unchanged.
|
||||
# craft-proof is storytold/craft-libs main at the merge of #497 (flag-based compounding), which
|
||||
# also has #496 (review follow-ups, en-AU) and the merged #482–#490/#495 stack; the font store's
|
||||
# `language-pack` kind is #486 (CP4.1).
|
||||
wordcraft-proof = { package = "craft-proof", git = "https://github.com/storytold/craft-libs", rev = "47c9f62530db72bc5c3831c86653b47b20c640be", version = "=0.1.0" }
|
||||
craft-font-store = { git = "https://github.com/storytold/craft-libs", rev = "762a06a4c6b48b522b7d4de84a87917ef90265e6", version = "=0.1.0" }
|
||||
craft-font-catalog = { git = "https://github.com/storytold/craft-libs", rev = "762a06a4c6b48b522b7d4de84a87917ef90265e6", version = "=0.1.0" }
|
||||
wordcraft-engine = { path = "crates/engine" }
|
||||
wordcraft-mcp = { path = "crates/mcp" }
|
||||
wordcraft-zotero = { path = "crates/zotero" }
|
||||
@@ -58,8 +66,9 @@ wuff = "0.2.9"
|
||||
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-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] }
|
||||
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 = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", 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.
|
||||
@@ -96,9 +105,11 @@ arboard = { version = "3.7", default-features = false, features = ["wayland-data
|
||||
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
|
||||
@@ -120,7 +131,7 @@ 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" }
|
||||
|
||||
@@ -20,13 +20,77 @@ list (author, source, license) is in ATTRIBUTION.md.
|
||||
https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (BIZ UDPGothic, BIZ UDMincho,
|
||||
Shippori Mincho), under the SIL Open Font License 1.1. Their licence texts ship with the
|
||||
packages as OFL-<family>.txt. Builds without CRAFT_FONTS_DIR do not include them.
|
||||
- English word list (assets/hyphenation/en-us.dic) — derived from the Moby Hyphenator II list by
|
||||
- Language data (craft-i18n): the word lists, dictionaries and hyphenation patterns below are embedded at build
|
||||
time from https://github.com/storytold/craft-i18n (language-data/, CRAFT_I18N_DIR at the commit
|
||||
in craft-i18n.rev; all official releases) by craft-proof (storytold/craft-libs). Builds without
|
||||
CRAFT_I18N_DIR do not include them.
|
||||
- English word list (craft-i18n language-data/en-us/en-us.dic) — derived from the Moby Hyphenator II list by
|
||||
Grady Ward, public domain (Project Gutenberg eBook #3204). The hyphenation patterns
|
||||
(assets/hyphenation/en-us.pat) were trained from it and are released as CC0-1.0.
|
||||
- Polish hyphenation patterns (assets/hyphenation/pl.pat) — hyph-pl.tex from the hyph-utf8
|
||||
(craft-i18n language-data/en-us/hyphenation.pat) were trained from it and are released as CC0-1.0.
|
||||
- English spelling dictionaries (craft-i18n language-data/en-us, en-gb and en-ca: spelling.aff and
|
||||
spelling.dic) — the SCOWL rel-2026.02.25 Hunspell dictionaries en_US, en_GB-ise and en_CA from
|
||||
https://wordlist.aspell.net, bundled unmodified. Their upstream notice (embedded too, for the
|
||||
About window) is language-data/en-us/LICENCE-SCOWL.txt in craft-i18n. The word lists:
|
||||
|
||||
Copyright 2000-2026 by Kevin Atkinson
|
||||
|
||||
Permission to use, copy, modify, distribute, and sell any part of SCOWLv2, or
|
||||
word lists created from it, is hereby granted without fee, provided that the
|
||||
above copyright notice appears in all copies and that both the above
|
||||
copyright notice and this notice appear in supporting documentation. Kevin
|
||||
Atkinson makes no representations about the suitability of this database for
|
||||
any purpose. It is provided "as is" without express or implied warranty.
|
||||
|
||||
The Australian data:
|
||||
|
||||
Copyright 2016 by Benjamin Titze
|
||||
|
||||
Permission to use, copy, modify, distribute and sell this array, the
|
||||
associated software, and its documentation for any purpose is hereby
|
||||
granted without fee, provided that the above copyright notice appears
|
||||
in all copies and that both that copyright notice and this permission
|
||||
notice appear in supporting documentation. Benjamin Titze makes no
|
||||
representations about the suitability of this array for any
|
||||
purpose. It is provided "as is" without express or implied warranty.
|
||||
|
||||
The affix files, a modified version of Geoff Kuenning's Ispell english.aff:
|
||||
|
||||
Copyright 1993, Geoff Kuenning, Granada Hills, CA
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions
|
||||
are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
2. Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
3. All modifications to the source code must be clearly marked as
|
||||
such. Binary redistributions based on modified source code
|
||||
must be clearly marked as modified versions in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
(clause 4 removed with permission from Geoff Kuenning)
|
||||
5. The name of Geoff Kuenning may not be used to endorse or promote
|
||||
products derived from this software without specific prior
|
||||
written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY GEOFF KUENNING AND CONTRIBUTORS ``AS IS'' AND
|
||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
ARE DISCLAIMED. IN NO EVENT SHALL GEOFF KUENNING OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
|
||||
OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
|
||||
HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
|
||||
LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
|
||||
OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGE.
|
||||
- Polish hyphenation patterns (craft-i18n language-data/pl/hyphenation.pat) — hyph-pl.tex from the hyph-utf8
|
||||
package, Copyright (C) 1987-1995 Hanna Kołodziejska, Bogusław Jackowski, Marek Ryćko, bundled
|
||||
unmodified and used under the MIT License; the copyright and permission notice is in the file.
|
||||
- Portuguese hyphenation patterns (assets/hyphenation/pt.pat) — hyph-pt.tex from the hyph-utf8
|
||||
- Portuguese hyphenation patterns (craft-i18n language-data/pt/hyphenation.pat) — hyph-pt.tex from the hyph-utf8
|
||||
package, bundled unmodified and used under the BSD 3-Clause License:
|
||||
|
||||
Copyright (C) 1987, 1994, 1996, 2015 Pedro J. de Rezende, 1996, 2015 J. Joao Dias Almeida,
|
||||
@@ -52,7 +116,7 @@ list (author, source, license) is in ATTRIBUTION.md.
|
||||
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
||||
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
|
||||
OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
- German hyphenation patterns (assets/hyphenation/de.pat) — hyph-de-1996.tex (reformed spelling)
|
||||
- German hyphenation patterns (craft-i18n language-data/de/hyphenation.pat) — hyph-de-1996.tex (reformed spelling)
|
||||
from the hyph-utf8 package, bundled unmodified and used under the MIT License; the copyright
|
||||
and permission notice is in the file:
|
||||
|
||||
@@ -74,7 +138,7 @@ 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.
|
||||
- Italian hyphenation patterns (assets/hyphenation/it.pat) — hyph-it.tex from the hyph-utf8
|
||||
- Italian hyphenation patterns (craft-i18n language-data/it/hyphenation.pat) — hyph-it.tex from the hyph-utf8
|
||||
package, Copyright (C) 2008-2011 Claudio Beccari, bundled unmodified and used under the MIT
|
||||
License (offered under LPPL 1.3+ or MIT); the copyright and permission notice is in the file:
|
||||
|
||||
@@ -101,7 +165,7 @@ list (author, source, license) is in ATTRIBUTION.md.
|
||||
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
|
||||
17744430be327d0257496a6a31c8d38fe20a6756. 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.
|
||||
|
||||
@@ -124,7 +124,7 @@ Every screenshot below is WordCraft itself, rendered offscreen by its own UI tes
|
||||
| **References** | Table of contents, footnotes and endnotes, citations and bibliography (APA, MLA, Chicago, IEEE), captions, table of figures, cross-references, index, table of authorities |
|
||||
| **Review** | Spelling and grammar with suggestions (English and Polish, by each run's language), thesaurus, word count, comments in margin balloons or a pane, track changes, accept/reject, compare documents, restrict editing, accessibility checker, document inspector |
|
||||
| **Mailings** | Mail merge from CSV, merge fields, address block, greeting line, rules, preview, finish to a document; envelopes and labels |
|
||||
| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe, Русский, Italiano, Deutsch, Polski, Čeština, Magyar, Français, Български, Tiếng Việt, 한국어, Svenska, Hrvatski or Slovenščina (follows the system language by default) |
|
||||
| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe, Русский, Italiano, Deutsch, Polski, Čeština, Magyar, Français, Български, Tiếng Việt, 한국어, Svenska, Hrvatski, Nederlands, Dansk, Română, العربية (mirrored) or Slovenščina (follows the system language by default) |
|
||||
| **Files** | .docx read/write, legacy 97-2003 .doc import, PDF export, .odt, .rtf, .html, .md, .tex, .txt import/export, page images |
|
||||
|
||||
The honest picture, area by area, is in [ROADMAP.md](ROADMAP.md), the
|
||||
@@ -140,6 +140,19 @@ cargo run --release -p wordcraft -- --sample # the desktop app with the s
|
||||
cargo run --release -p wordcraft -- report.docx # open a document
|
||||
```
|
||||
|
||||
The interface translations and the spelling and hyphenation data come from
|
||||
[craft-i18n](https://github.com/storytold/craft-i18n), an optional build input. To build with them,
|
||||
check it out at the commit in `craft-i18n.rev` and point `CRAFT_I18N_DIR` at it (an absolute
|
||||
path); without it WordCraft builds with an English interface, English spelling falls back to an
|
||||
installed Hunspell English or craft-proof's built-in word list, and only Bulgarian, Turkish and
|
||||
Russian hyphenate (by rule):
|
||||
|
||||
```sh
|
||||
git clone https://github.com/storytold/craft-i18n ../craft-i18n
|
||||
git -C ../craft-i18n checkout "$(cat craft-i18n.rev)"
|
||||
CRAFT_I18N_DIR="$PWD/../craft-i18n" cargo run --release -p wordcraft -- --sample
|
||||
```
|
||||
|
||||
Command line:
|
||||
|
||||
```sh
|
||||
@@ -194,7 +207,7 @@ covers every target starting with it (`wordcraft*=debug`). The logger is
|
||||
| Layer | Crate | Job |
|
||||
|---|---|---|
|
||||
| L0 | `wordcraft-geom` | units and measurements |
|
||||
| L1 | `wordcraft-doc`, `wordcraft-fonts`, `wordcraft-proof` | document model and editing; fonts and shaping; spelling, grammar, hyphenation |
|
||||
| L1 | `wordcraft-doc`, `wordcraft-fonts`, `wordcraft-proof` ([`craft-proof`](https://github.com/storytold/craft-libs/tree/main/crates/proof) from craft-libs) | document model and editing; fonts and shaping; spelling, grammar, hyphenation (data: craft-i18n `language-data/`, `CRAFT_I18N_DIR`) |
|
||||
| L2 | `wordcraft-layout`, `wordcraft-docx`, `wordcraft-formats` | line breaking, pagination, tables, notes, hit testing; OOXML; ODT/RTF/HTML/Markdown/TXT |
|
||||
| L3 | `wordcraft-render`, `wordcraft-pdf` | rasteriser (vello_cpu); PDF ([craft-pdf](docs/shared-pdf.md), Krilla) |
|
||||
| L4 | `wordcraft-engine` | session, undo, 422 commands, document feature catalog |
|
||||
@@ -221,16 +234,17 @@ closer pagination fidelity, a stability sweep and native printing. Details and e
|
||||
|
||||
### Windows
|
||||
|
||||
| Build | Installer (all users) | Installer (just me) | Portable |
|
||||
|---|---|---|---|
|
||||
| x64 (64-bit Intel/AMD) | `wordcraft-<ver>-windows-x64.msi` | `wordcraft-<ver>-windows-user-x64.msi` | `wordcraft-<ver>-windows-x64-portable.zip` |
|
||||
| arm64 (Snapdragon and other ARM PCs) | `wordcraft-<ver>-windows-arm64.msi` | `wordcraft-<ver>-windows-user-arm64.msi` | `wordcraft-<ver>-windows-arm64-portable.zip` |
|
||||
| x86 (32-bit) | `wordcraft-<ver>-windows-x86.msi` | `wordcraft-<ver>-windows-user-x86.msi` | `wordcraft-<ver>-windows-x86-portable.zip` |
|
||||
| Build | Installer | Portable |
|
||||
|---|---|---|
|
||||
| x64 (64-bit Intel/AMD) | `wordcraft-<ver>-windows-x64.msi` | `wordcraft-<ver>-windows-x64-portable.zip` |
|
||||
| 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` |
|
||||
|
||||
The all-users installer puts WordCraft in Program Files and needs administrator rights. The
|
||||
just-me installer needs none: it installs into `%LOCALAPPDATA%\Programs\WordCraft` for your
|
||||
account only. Choose one: installing both gives two WordCraft entries in Settings › Apps and two
|
||||
in the Start menu. 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
|
||||
@@ -344,7 +358,15 @@ stand on its own.
|
||||
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/wordcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.wordcraft.png" alt="" width="32" height="32"> | **WordCraft** | **Writing and document design · you are here** | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/figcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.figcraft.png" alt="" width="32" height="32"> | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/wordcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.wordcraft.png" alt="" width="32" height="32"> | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/deckcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.deckcraft.png" alt="" width="32" height="32"> | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/cadcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.cadcraft.png" alt="" width="32" height="32"> | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/gridcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.gridcraft.png" alt="" width="32" height="32"> | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/texturecraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.texturecraft.png" alt="" width="32" height="32"> | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/craft-anything/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.craft-anything.png" alt="" width="32" height="32"> | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) |
|
||||
| <img src="https://raw.githubusercontent.com/tangency-labs/solidcraft/main/assets/icons/app/linux/exports/hicolor/64x64/apps/io.tangencylabs.solidcraft.png" alt="" width="32" height="32"> | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | |
|
||||
|
||||
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
|
||||
|
||||
|
||||
+127
-7
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)).
|
||||
+39
-573
@@ -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,27 @@ 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> {
|
||||
@@ -470,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
|
||||
}
|
||||
};
|
||||
@@ -738,6 +758,12 @@ fn parity_markdown(p: &Value) -> String {
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<OsString> = std::env::args_os().skip(1).collect();
|
||||
// Language packs installed in the suite's font store are used; `proof.installPack` installs
|
||||
// only while the person has turned downloading on in the app.
|
||||
wordcraft_engine::packs::use_font_store(false);
|
||||
if let Some(source) = wordcraft_engine::packs::index_override() {
|
||||
eprintln!("wordcraft-cli: Language packs come from a test source: {source}");
|
||||
}
|
||||
match run(&args) {
|
||||
Ok(()) | Err(Failure::StdoutClosed) => ExitCode::SUCCESS,
|
||||
Err(Failure::Error(e)) => {
|
||||
@@ -748,564 +774,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 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);
|
||||
}
|
||||
}
|
||||
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,
|
||||
|
||||
@@ -9,7 +9,8 @@ use wordcraft_engine::Session;
|
||||
use wordcraft_ui_egui::async_saves::{AsyncSaves, SaveOutcome};
|
||||
use wordcraft_ui_egui::{Inbox, Services, WordApp};
|
||||
|
||||
const DOC_EXTS: &[&str] = &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json", "xml"];
|
||||
const DOC_EXTS: &[&str] =
|
||||
&["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "mht", "mhtml", "tex", "json", "xml"];
|
||||
const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "wmf", "emf"];
|
||||
const RECIPIENT_EXTS: &[&str] = wordcraft_engine::cmd::mailings::RECIPIENT_EXTENSIONS;
|
||||
mod font_store;
|
||||
|
||||
@@ -73,7 +73,7 @@ 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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -62,8 +62,17 @@ impl eframe::App for App {
|
||||
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 = WindowGeometry::track(prev, &window);
|
||||
// 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(|_| window.monitor_size.is_some());
|
||||
@@ -73,9 +82,11 @@ impl eframe::App for App {
|
||||
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
|
||||
@@ -318,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.
|
||||
@@ -329,7 +360,9 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,7 +372,7 @@ fn open_non_unicode(app: &mut WordApp, path: &std::path::Path) {
|
||||
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(&craft_update::suite::WORDCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
|
||||
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`).
|
||||
@@ -349,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() {
|
||||
@@ -418,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);
|
||||
@@ -453,11 +490,13 @@ fn main() -> eframe::Result {
|
||||
app.set_startup_update_check(true);
|
||||
load_prefs(&mut app);
|
||||
load_user_dictionary(&mut app);
|
||||
// Language packs from the suite's font store; an Install click is the person's own.
|
||||
wordcraft_engine::packs::use_font_store(true);
|
||||
// A region chosen in File › Options replaces the system's for the first blank
|
||||
// document too (#838).
|
||||
if !sample && app.session.prefs.region.is_some() {
|
||||
let doc = app.session.blank_document();
|
||||
app.session.set_document(doc);
|
||||
app.replace_startup_document(doc);
|
||||
}
|
||||
app.watch_desktop_theme(&cc.egui_ctx);
|
||||
if prefs_enabled()
|
||||
@@ -469,6 +508,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() {
|
||||
@@ -490,7 +530,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 {
|
||||
@@ -576,6 +616,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;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -1,431 +0,0 @@
|
||||
% title: Hyphenation patterns for Italian
|
||||
% copyright: Copyright (C) 2008-2011 Claudio Beccari
|
||||
% notice: This file is part of the hyph-utf8 package.
|
||||
% See http://www.hyphenation.org/tex for more information.
|
||||
% language:
|
||||
% name: Italian
|
||||
% tag: it
|
||||
% version: 4.9 2014/04/22
|
||||
% authors:
|
||||
% -
|
||||
% name: Claudio Beccari
|
||||
% contact: claudio.beccari (at) gmail.com
|
||||
% licence:
|
||||
% - This file is available under any of the following licences:
|
||||
% -
|
||||
% name: LPPL
|
||||
% version: 1.3
|
||||
% or_later: true
|
||||
% url: http://www.latex-project.org/lppl.txt
|
||||
% status: maintained
|
||||
% maintainer: Claudio Beccari, e-mail claudio dot beccari at gmail dot com
|
||||
% -
|
||||
% name: MIT
|
||||
% url: https://opensource.org/licenses/MIT
|
||||
% text: >
|
||||
% 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.
|
||||
% hyphenmins:
|
||||
% typesetting:
|
||||
% left: 2
|
||||
% right: 2
|
||||
% changes:
|
||||
% - 2014-04-22 - Add few patterns involving `h'
|
||||
% - 2011-08-16 - Change the licence from GNU LGPL into LPPL v1.3.
|
||||
% - 2010-05-24 - Fix for Italian patterns for proper hyphenation of -ich and Ljubljana.
|
||||
% - 2008-06-09 - Import of original ithyph.tex into hyph-utf8 package.
|
||||
% - 2008-03-08 - (last change in ithyph.tex)
|
||||
% texlive:
|
||||
% encoding: ascii
|
||||
% babelname: italian
|
||||
% legacy_patterns: ithyph.tex
|
||||
% message: Italian hyphenation patterns
|
||||
% description: |-
|
||||
% Hyphenation patterns for Italian in ASCII encoding.
|
||||
% Compliant with the Recommendation UNI 6461 on hyphenation
|
||||
% issued by the Italian Standards Institution
|
||||
% (Ente Nazionale di Unificazione UNI).
|
||||
% ==========================================
|
||||
%
|
||||
% These hyphenation patterns for the Italian language are supposed to comply
|
||||
% with the Recommendation UNI 6461 on hyphenation issued by the Italian
|
||||
% Standards Institution (Ente Nazionale di Unificazione UNI). No guarantee
|
||||
% or declaration of fitness to any particular purpose is given and any
|
||||
% liability is disclaimed.
|
||||
%
|
||||
\patterns{
|
||||
.a3p2n % After the Garzanti dictionary: a-pnea, a-pnoi-co,...
|
||||
.anti1
|
||||
.anti3m2n
|
||||
.bio1
|
||||
.ca4p3s
|
||||
.circu2m1
|
||||
.contro1
|
||||
.di2s3cine
|
||||
.e2x1eu
|
||||
.fran2k3
|
||||
.free3
|
||||
.li3p2sa
|
||||
.narco1
|
||||
.opto1
|
||||
.orto3p2
|
||||
.para1
|
||||
.ph2l
|
||||
.ph2r
|
||||
.poli3p2
|
||||
.pre1
|
||||
.p2s
|
||||
.re1i2scr
|
||||
.sha2re3
|
||||
.tran2s3c
|
||||
.tran2s3d
|
||||
.tran2s3l
|
||||
.tran2s3n
|
||||
.tran2s3p
|
||||
.tran2s3r
|
||||
.tran2s3t
|
||||
.su2b3lu
|
||||
.su2b3r
|
||||
.wa2g3n
|
||||
.wel2t1
|
||||
2'2
|
||||
a1ia
|
||||
a1ie
|
||||
a1io
|
||||
a1iu
|
||||
a1uo
|
||||
a1ya
|
||||
2at.
|
||||
e1iu
|
||||
e2w
|
||||
o1ia
|
||||
o1ie
|
||||
o1io
|
||||
o1iu
|
||||
1b
|
||||
2bb
|
||||
2bc
|
||||
2bd
|
||||
2bf
|
||||
2bm
|
||||
2bn
|
||||
2bp
|
||||
2bs
|
||||
2bt
|
||||
2bv
|
||||
b2l
|
||||
b2r
|
||||
2b.
|
||||
2b'
|
||||
1c
|
||||
2cb
|
||||
2cc
|
||||
2cd
|
||||
2cf
|
||||
2ck
|
||||
2cm
|
||||
2cn
|
||||
2cq
|
||||
2cs
|
||||
2ct
|
||||
2cz
|
||||
2chh
|
||||
c2h
|
||||
2ch.
|
||||
2ch'.
|
||||
2ch''.
|
||||
2chb
|
||||
ch2r
|
||||
2chn
|
||||
c2l
|
||||
c2r
|
||||
2c.
|
||||
2c'
|
||||
.c2
|
||||
1d
|
||||
2db
|
||||
2dd
|
||||
2dg
|
||||
2dl
|
||||
2dm
|
||||
2dn
|
||||
2dp
|
||||
d2r
|
||||
2ds
|
||||
2dt
|
||||
2dv
|
||||
2dw
|
||||
2d.
|
||||
2d'
|
||||
.d2
|
||||
1f
|
||||
2fb
|
||||
2fg
|
||||
2ff
|
||||
2fn
|
||||
f2l
|
||||
f2r
|
||||
2fs
|
||||
2ft
|
||||
2f.
|
||||
2f'
|
||||
1g
|
||||
2gb
|
||||
2gd
|
||||
2gf
|
||||
2gg
|
||||
g2h
|
||||
g2l
|
||||
2gm
|
||||
g2n
|
||||
2gp
|
||||
g2r
|
||||
2gs
|
||||
2gt
|
||||
2gv
|
||||
2gw
|
||||
2gz
|
||||
2gh2t
|
||||
2g.
|
||||
2g'
|
||||
.h2
|
||||
1h
|
||||
2hb
|
||||
2hd
|
||||
2hh
|
||||
hi3p2n
|
||||
h2l
|
||||
2hm
|
||||
2hn
|
||||
2hr
|
||||
2hv
|
||||
2h.
|
||||
2h'
|
||||
.j2
|
||||
1j
|
||||
2j.
|
||||
2j'
|
||||
.k2
|
||||
1k
|
||||
2kg
|
||||
2kf
|
||||
k2h
|
||||
2kk
|
||||
k2l
|
||||
2km
|
||||
k2r
|
||||
2ks
|
||||
2kt
|
||||
2k.
|
||||
2k'
|
||||
1l
|
||||
2lb
|
||||
2lc
|
||||
2ld
|
||||
2l3f2
|
||||
2lg
|
||||
l2h
|
||||
l2j
|
||||
2lk
|
||||
2ll
|
||||
2lm
|
||||
2ln
|
||||
2lp
|
||||
2lq
|
||||
2lr
|
||||
2ls
|
||||
2lt
|
||||
2lv
|
||||
2lw
|
||||
2lz
|
||||
2l.
|
||||
2l'.
|
||||
2l''
|
||||
1m
|
||||
2mb
|
||||
2mc
|
||||
2mf
|
||||
2ml
|
||||
2mm
|
||||
2mn
|
||||
2mp
|
||||
2mq
|
||||
2mr
|
||||
2ms
|
||||
2mt
|
||||
2mv
|
||||
2mw
|
||||
2m.
|
||||
2m'
|
||||
1n
|
||||
2nb
|
||||
2nc
|
||||
2nd
|
||||
2nf
|
||||
2ng
|
||||
2nk
|
||||
2nl
|
||||
2nm
|
||||
2nn
|
||||
2np
|
||||
2nq
|
||||
2nr
|
||||
2ns
|
||||
n2s3fer
|
||||
2nt
|
||||
2nv
|
||||
2nz
|
||||
n2g3n
|
||||
2nheit
|
||||
2n.
|
||||
2n'
|
||||
1p
|
||||
2pd
|
||||
p2h
|
||||
p2l
|
||||
2pn
|
||||
3p2ne
|
||||
2pp
|
||||
p2r
|
||||
2ps
|
||||
3p2sic
|
||||
2pt
|
||||
2pz
|
||||
2p.
|
||||
2p'
|
||||
1q
|
||||
2qq
|
||||
2q.
|
||||
2q'
|
||||
1r
|
||||
2rb
|
||||
2rc
|
||||
2rd
|
||||
2rf
|
||||
r2h
|
||||
2rg
|
||||
2rk
|
||||
2rl
|
||||
2rm
|
||||
2rn
|
||||
2rp
|
||||
2rq
|
||||
2rr
|
||||
2rs
|
||||
2rt
|
||||
r2t2s3
|
||||
2rv
|
||||
2rx
|
||||
2rw
|
||||
2rz
|
||||
2r.
|
||||
2r'
|
||||
1s2
|
||||
2shm
|
||||
2sh.
|
||||
2sh'
|
||||
2s3s
|
||||
s4s3m
|
||||
2s3p2n
|
||||
2stb
|
||||
2stc
|
||||
2std
|
||||
2stf
|
||||
2stg
|
||||
2stm
|
||||
2stn
|
||||
2stp
|
||||
2sts
|
||||
2stt
|
||||
2stv
|
||||
2sz
|
||||
4s.
|
||||
4s'.
|
||||
4s''
|
||||
.t2
|
||||
1t
|
||||
2tb
|
||||
2tc
|
||||
2td
|
||||
2tf
|
||||
2tg
|
||||
t2h
|
||||
2th.
|
||||
t2l
|
||||
2tm
|
||||
2tn
|
||||
2tp
|
||||
t2r
|
||||
t2s
|
||||
3t2sch
|
||||
2tt
|
||||
t2t3s
|
||||
2tv
|
||||
2tw
|
||||
t2z
|
||||
2tzk
|
||||
tz2s
|
||||
2t.
|
||||
2t'.
|
||||
2t''
|
||||
1v
|
||||
2vc
|
||||
v2l
|
||||
v2r
|
||||
2vv
|
||||
2v.
|
||||
2v'.
|
||||
2v''
|
||||
1w
|
||||
w2h
|
||||
wa2r
|
||||
2w1y
|
||||
2w.
|
||||
2w'
|
||||
1x
|
||||
2xb
|
||||
2xc
|
||||
2xf
|
||||
2xh
|
||||
2xm
|
||||
2xp
|
||||
2xt
|
||||
2xw
|
||||
2x.
|
||||
2x'
|
||||
y1ou
|
||||
y1i
|
||||
1z
|
||||
2zb
|
||||
2zd
|
||||
2zl
|
||||
2zn
|
||||
2zp
|
||||
2zt
|
||||
2zs
|
||||
2zv
|
||||
2zz
|
||||
2z.
|
||||
2z'.
|
||||
2z''
|
||||
.z2
|
||||
} % Pattern end
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,503 +0,0 @@
|
||||
% title: Hyphenation patterns for Portuguese
|
||||
% copyright: Copyright (C) 1987, 1994, 1996, 2015 Pedro J. de Rezende, 1996, 2015 J. Joao Dias Almeida, 2024 Leonardo Araujo and Aline Benevides
|
||||
% notice: This file is part of the hyph-utf8 package.
|
||||
% See http://www.hyphenation.org/tex for more information.
|
||||
% language:
|
||||
% name: Portuguese
|
||||
% tag: pt
|
||||
% version: 1.4 2024-07-13
|
||||
% authors:
|
||||
% -
|
||||
% name: Pedro J. de Rezende
|
||||
% contact: rezende (at) ic.unicamp.br
|
||||
% -
|
||||
% name: J. Joao Dias Almeida
|
||||
% contact: jj (at) di.uminho.pt
|
||||
% -
|
||||
% name: Leonardo Araujo
|
||||
% contact: leolca (at) gmail.com
|
||||
% -
|
||||
% name: Aline Benevides
|
||||
% contact: benevides.aline12 (at) gmail.com
|
||||
% licence:
|
||||
% name: BSD 3-clause licence
|
||||
% url: https://opensource.org/licenses/BSD-3-Clause
|
||||
% text: >
|
||||
% Redistribution and use in source and binary forms, with or without
|
||||
% modification, are permitted provided that the following conditions
|
||||
% are met:
|
||||
% * Redistributions of source code must retain the above copyright
|
||||
% notice, this list of conditions and the following disclaimer.
|
||||
% * Redistributions in binary form must reproduce the above copyright
|
||||
% notice, this list of conditions and the following disclaimer in the
|
||||
% documentation and/or other materials provided with the
|
||||
% distribution.
|
||||
% * Neither the name of the University of Campinas, of the University
|
||||
% of Minho nor the names of its contributors may be used to endorse
|
||||
% or promote products derived from this software without specific
|
||||
% prior written permission.
|
||||
%
|
||||
% THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
% "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
% LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
% A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL PEDRO J. DE
|
||||
% REZENDE OR J.JOAO DIAS ALMEIDA BE LIABLE FOR ANY DIRECT, INDIRECT,
|
||||
% INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
|
||||
% BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS
|
||||
% OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
|
||||
% AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
|
||||
% LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY
|
||||
% WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
% POSSIBILITY OF SUCH DAMAGE.
|
||||
% hyphenmins:
|
||||
% typesetting:
|
||||
% left: 2
|
||||
% right: 3
|
||||
% changes:
|
||||
% - Version 1.4 Release date: 13/07/2024 Leonardo Araujo and Aline Benevides
|
||||
% - Version 1.3 Release date: 12/08/2015 Pedro J. de Rezende and J. Joao Dias Almeida
|
||||
% - Version 1.2 Release date: 07/21/1996 Pedro J. de Rezende and J. Joao Dias Almeida
|
||||
% - Version 1.1 Release date: 04/12/1994 Pedro J. de Rezende
|
||||
% - Version 1.0 Release date: 02/13/1987 Pedro J. de Rezende
|
||||
% texlive:
|
||||
% synonyms:
|
||||
% - portuges
|
||||
% encoding: ec
|
||||
% babelname: portuguese
|
||||
% legacy_patterns: pthyph.tex
|
||||
% message: Portuguese hyphenation patterns
|
||||
% description: Hyphenation patterns for Portuguese in T1/EC and UTF-8 encodings.
|
||||
%
|
||||
\patterns{
|
||||
1b2l
|
||||
1b2r
|
||||
1ba
|
||||
1be
|
||||
1bi
|
||||
1bo
|
||||
1bu
|
||||
1bá
|
||||
1bâ
|
||||
1bã
|
||||
1bé
|
||||
1bí
|
||||
1bó
|
||||
1bú
|
||||
1bê
|
||||
1bõ
|
||||
1c2h
|
||||
1c2l
|
||||
1c2r
|
||||
1ca
|
||||
1ce
|
||||
1ci
|
||||
1co
|
||||
1cu
|
||||
1cá
|
||||
1câ
|
||||
1cã
|
||||
1cé
|
||||
1cí
|
||||
1có
|
||||
1cú
|
||||
1cê
|
||||
1cõ
|
||||
1ça
|
||||
1çe
|
||||
1çi
|
||||
1ço
|
||||
1çu
|
||||
1çá
|
||||
1çâ
|
||||
1çã
|
||||
1çé
|
||||
1çí
|
||||
1çó
|
||||
1çú
|
||||
1çê
|
||||
1çõ
|
||||
1d2l
|
||||
1d2r
|
||||
1da
|
||||
1de
|
||||
1di
|
||||
1do
|
||||
1du
|
||||
1dá
|
||||
1dâ
|
||||
1dã
|
||||
1dé
|
||||
1dí
|
||||
1dó
|
||||
1dú
|
||||
1dê
|
||||
1dõ
|
||||
1f2l
|
||||
1f2r
|
||||
1fa
|
||||
1fe
|
||||
1fi
|
||||
1fo
|
||||
1fu
|
||||
1fá
|
||||
1fâ
|
||||
1fã
|
||||
1fé
|
||||
1fí
|
||||
1fó
|
||||
1fú
|
||||
1fê
|
||||
1fõ
|
||||
1g2l
|
||||
1g2r
|
||||
1ga
|
||||
1ge
|
||||
1gi
|
||||
1go
|
||||
1gu
|
||||
1gu4a
|
||||
1gu4e
|
||||
1gu4i
|
||||
1gu4o
|
||||
1gá
|
||||
1gâ
|
||||
1gã
|
||||
1gé
|
||||
1gí
|
||||
1gó
|
||||
1gú
|
||||
1gê
|
||||
1gõ
|
||||
1ja
|
||||
1je
|
||||
1ji
|
||||
1jo
|
||||
1ju
|
||||
1já
|
||||
1jâ
|
||||
1jã
|
||||
1jé
|
||||
1jí
|
||||
1jó
|
||||
1jú
|
||||
1jê
|
||||
1jõ
|
||||
1k2l
|
||||
1k2r
|
||||
1ka
|
||||
1ke
|
||||
1ki
|
||||
1ko
|
||||
1ku
|
||||
1ká
|
||||
1kâ
|
||||
1kã
|
||||
1ké
|
||||
1kí
|
||||
1kó
|
||||
1kú
|
||||
1kê
|
||||
1kõ
|
||||
1l2h
|
||||
1la
|
||||
1le
|
||||
1li
|
||||
1lo
|
||||
1lu
|
||||
1lá
|
||||
1lâ
|
||||
1lã
|
||||
1lé
|
||||
1lí
|
||||
1ló
|
||||
1lú
|
||||
1lê
|
||||
1lõ
|
||||
1ma
|
||||
1me
|
||||
1mi
|
||||
1mo
|
||||
1mu
|
||||
1má
|
||||
1mâ
|
||||
1mã
|
||||
1mé
|
||||
1mí
|
||||
1mó
|
||||
1mú
|
||||
1mê
|
||||
1mõ
|
||||
1n2h
|
||||
1na
|
||||
1ne
|
||||
1ni
|
||||
1no
|
||||
1nu
|
||||
1ná
|
||||
1nâ
|
||||
1nã
|
||||
1né
|
||||
1ní
|
||||
1nó
|
||||
1nú
|
||||
1nê
|
||||
1nõ
|
||||
1p2l
|
||||
1p2r
|
||||
1pa
|
||||
1pe
|
||||
1pi
|
||||
1po
|
||||
1pu
|
||||
1pá
|
||||
1pâ
|
||||
1pã
|
||||
1pé
|
||||
1pí
|
||||
1pó
|
||||
1pú
|
||||
1pê
|
||||
1põ
|
||||
1qu4a
|
||||
1qu4e
|
||||
1qu4i
|
||||
1qu4o
|
||||
1ra
|
||||
1re
|
||||
1ri
|
||||
1ro
|
||||
1ru
|
||||
1rá
|
||||
1râ
|
||||
1rã
|
||||
1ré
|
||||
1rí
|
||||
1ró
|
||||
1rú
|
||||
1rê
|
||||
1rõ
|
||||
1sa
|
||||
1se
|
||||
1si
|
||||
1so
|
||||
1su
|
||||
1sá
|
||||
1sâ
|
||||
1sã
|
||||
1sé
|
||||
1sí
|
||||
1só
|
||||
1sú
|
||||
1sê
|
||||
1sõ
|
||||
1t2l
|
||||
1t2r
|
||||
1ta
|
||||
1te
|
||||
1ti
|
||||
1to
|
||||
1tu
|
||||
1tá
|
||||
1tâ
|
||||
1tã
|
||||
1té
|
||||
1tí
|
||||
1tó
|
||||
1tú
|
||||
1tê
|
||||
1tõ
|
||||
1v2l
|
||||
1v2r
|
||||
1va
|
||||
1ve
|
||||
1vi
|
||||
1vo
|
||||
1vu
|
||||
1vá
|
||||
1vâ
|
||||
1vã
|
||||
1vé
|
||||
1ví
|
||||
1vó
|
||||
1vú
|
||||
1vê
|
||||
1võ
|
||||
1w2l
|
||||
1w2r
|
||||
1xa
|
||||
1xe
|
||||
1xi
|
||||
1xo
|
||||
1xu
|
||||
1xá
|
||||
1xâ
|
||||
1xã
|
||||
1xé
|
||||
1xí
|
||||
1xó
|
||||
1xú
|
||||
1xê
|
||||
1xõ
|
||||
1za
|
||||
1ze
|
||||
1zi
|
||||
1zo
|
||||
1zu
|
||||
1zá
|
||||
1zâ
|
||||
1zã
|
||||
1zé
|
||||
1zí
|
||||
1zó
|
||||
1zú
|
||||
1zê
|
||||
1zõ
|
||||
a3a
|
||||
a3e
|
||||
a3o
|
||||
c3c
|
||||
e3a
|
||||
e3e
|
||||
e3o
|
||||
i3a
|
||||
i3e
|
||||
i3i
|
||||
i3o
|
||||
i3â
|
||||
i3ê
|
||||
i3ô
|
||||
o3a
|
||||
o3e
|
||||
o3o
|
||||
r3r
|
||||
s3s
|
||||
u3a
|
||||
u3e
|
||||
u3o
|
||||
u3u
|
||||
.p2si
|
||||
.p2sí
|
||||
.g2no
|
||||
.g2nó
|
||||
.g2nô
|
||||
t2c
|
||||
1p2neu
|
||||
.t2m
|
||||
.p2t
|
||||
su2b3r
|
||||
su2b3l
|
||||
.su3b4li
|
||||
a1ir.
|
||||
u1ir.
|
||||
1qu
|
||||
1vô
|
||||
1lô
|
||||
1cô
|
||||
1gô
|
||||
1bô
|
||||
1tô
|
||||
1rô
|
||||
1pô
|
||||
a1é
|
||||
a1í
|
||||
a1ó
|
||||
a1ú
|
||||
e1á
|
||||
e1â
|
||||
e1ã
|
||||
e1é
|
||||
e1ê
|
||||
e1í
|
||||
e1ó
|
||||
é1o
|
||||
e1ú
|
||||
i1á
|
||||
i1ã
|
||||
í1a
|
||||
i1é
|
||||
i1í
|
||||
i1ó
|
||||
í1o
|
||||
i1u
|
||||
i1ú
|
||||
o1á
|
||||
o1é
|
||||
o1í
|
||||
o1ó
|
||||
u1á
|
||||
u1ã
|
||||
u1â
|
||||
u1í
|
||||
ú1o
|
||||
1qu2á
|
||||
1qu2â
|
||||
1qu2í
|
||||
1gu2í
|
||||
a1ind
|
||||
a1i1nh
|
||||
e1imp
|
||||
e1inc
|
||||
e1inf
|
||||
e1ing
|
||||
e1ins
|
||||
e1int
|
||||
e1inv
|
||||
u1iz.
|
||||
a1iz.
|
||||
4a.
|
||||
4e.
|
||||
4o.
|
||||
1gu2á
|
||||
1gu2ã
|
||||
1qu2ã
|
||||
.m2n
|
||||
c2za
|
||||
.s2
|
||||
1p2seu1d
|
||||
1dô
|
||||
1fô
|
||||
1mô
|
||||
1nô
|
||||
1sô
|
||||
1zô
|
||||
tu1i
|
||||
tu2it
|
||||
tu2id
|
||||
bu1i
|
||||
nu1i
|
||||
o1in
|
||||
u1in
|
||||
su1i
|
||||
í1e
|
||||
ju1i
|
||||
fu1i
|
||||
du1i
|
||||
do1im
|
||||
au1i
|
||||
u1i1ç
|
||||
u1ê
|
||||
1gu2ê
|
||||
1qu2ê
|
||||
1çô
|
||||
u1é
|
||||
1gu2é
|
||||
1qu2é
|
||||
1xô
|
||||
a1â
|
||||
a1ã
|
||||
a1ô
|
||||
e1ô
|
||||
.ne4o
|
||||
o1ã
|
||||
o1ê
|
||||
o1im
|
||||
o2i1na
|
||||
pro1i1b
|
||||
co2ima
|
||||
.t2
|
||||
1-
|
||||
}
|
||||
\hyphenation{% Do NOT make any alterations to this list! --- PdR
|
||||
hard-ware
|
||||
soft-ware
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
# English spelling supplement: common words the public-domain Moby list (1990s) lacks.
|
||||
# Written by WordCraft contributors (MIT OR Apache-2.0); see ATTRIBUTION.md.
|
||||
# One lowercase base form per line, sorted. Plurals, -ed, -ing, -er, -ly… come from the
|
||||
# inflection rules in crates/proof/src/lib.rs, so list "desktop", not "desktops".
|
||||
airbag
|
||||
airfare
|
||||
artcraft
|
||||
autocomplete
|
||||
autocorrect
|
||||
autofill
|
||||
autoformat
|
||||
autosave
|
||||
backend
|
||||
bitcoin
|
||||
bitmap
|
||||
blockchain
|
||||
blog
|
||||
blogger
|
||||
bluetooth
|
||||
captcha
|
||||
caregiver
|
||||
carpool
|
||||
cellphone
|
||||
chatbot
|
||||
chatroom
|
||||
checkbox
|
||||
checklist
|
||||
childcare
|
||||
clamour
|
||||
clickable
|
||||
colourful
|
||||
colourisation
|
||||
colourise
|
||||
colourless
|
||||
configurable
|
||||
copyedit
|
||||
coronavirus
|
||||
covid
|
||||
crowdfunding
|
||||
crowdsource
|
||||
crypto
|
||||
cryptocurrency
|
||||
customizable
|
||||
customization
|
||||
cyber
|
||||
cybersecurity
|
||||
cyberspace
|
||||
database
|
||||
dataset
|
||||
daycare
|
||||
desktop
|
||||
download
|
||||
downloadable
|
||||
downsize
|
||||
dropdown
|
||||
earbud
|
||||
ebook
|
||||
ecommerce
|
||||
email
|
||||
emoji
|
||||
emoticon
|
||||
endeavour
|
||||
esports
|
||||
facebook
|
||||
favicon
|
||||
fintech
|
||||
firewall
|
||||
flowchart
|
||||
foodie
|
||||
freeware
|
||||
frontend
|
||||
fundraiser
|
||||
gameplay
|
||||
gamification
|
||||
geeky
|
||||
geolocation
|
||||
gigabit
|
||||
gigabyte
|
||||
github
|
||||
glamour
|
||||
globalization
|
||||
google
|
||||
groupthink
|
||||
hackathon
|
||||
hardcode
|
||||
hashtag
|
||||
healthcare
|
||||
homepage
|
||||
homeschool
|
||||
honour
|
||||
honourable
|
||||
honourably
|
||||
hotspot
|
||||
hyperlink
|
||||
hypertext
|
||||
inbox
|
||||
incentivize
|
||||
infographic
|
||||
instagram
|
||||
internet
|
||||
interoperability
|
||||
intranet
|
||||
ipad
|
||||
iphone
|
||||
javascript
|
||||
kickstart
|
||||
kilobyte
|
||||
laptop
|
||||
lifestyle
|
||||
linkedin
|
||||
linux
|
||||
livestream
|
||||
lockdown
|
||||
login
|
||||
logout
|
||||
lowercase
|
||||
makeover
|
||||
malware
|
||||
manoeuvrable
|
||||
megabyte
|
||||
megapixel
|
||||
meme
|
||||
metadata
|
||||
microchip
|
||||
microsoft
|
||||
middleware
|
||||
mindset
|
||||
multicultural
|
||||
multiplayer
|
||||
multitask
|
||||
nerdy
|
||||
netbook
|
||||
netflix
|
||||
newsfeed
|
||||
offboard
|
||||
offline
|
||||
onboard
|
||||
online
|
||||
onscreen
|
||||
outsource
|
||||
paediatric
|
||||
paralegal
|
||||
passcode
|
||||
petabyte
|
||||
phishing
|
||||
photobomb
|
||||
playdate
|
||||
playlist
|
||||
plugin
|
||||
podcast
|
||||
popup
|
||||
prequel
|
||||
printout
|
||||
prioritise
|
||||
prioritization
|
||||
prioritize
|
||||
privatise
|
||||
ransomware
|
||||
recyclable
|
||||
rigour
|
||||
ringtone
|
||||
roadmap
|
||||
rollout
|
||||
runtime
|
||||
scalability
|
||||
screencast
|
||||
screensaver
|
||||
screenshot
|
||||
scrollbar
|
||||
selfie
|
||||
signup
|
||||
sitemap
|
||||
skillset
|
||||
smartphone
|
||||
smartwatch
|
||||
smiley
|
||||
snowboard
|
||||
spellcheck
|
||||
spellchecker
|
||||
spreadsheet
|
||||
spyware
|
||||
stakeholder
|
||||
standup
|
||||
startup
|
||||
subfolder
|
||||
sunscreen
|
||||
superfood
|
||||
superuser
|
||||
synthesise
|
||||
tagline
|
||||
takeout
|
||||
techie
|
||||
telecommute
|
||||
teleconference
|
||||
telehealth
|
||||
telemedicine
|
||||
terabyte
|
||||
textbox
|
||||
timeline
|
||||
timestamp
|
||||
toolbar
|
||||
toolkit
|
||||
touchpad
|
||||
touchscreen
|
||||
trackball
|
||||
trackpad
|
||||
travelogue
|
||||
upcycle
|
||||
upload
|
||||
uppercase
|
||||
upsell
|
||||
upskill
|
||||
usability
|
||||
username
|
||||
videoconference
|
||||
videogame
|
||||
vlog
|
||||
vlogger
|
||||
voicemail
|
||||
walkthrough
|
||||
webcam
|
||||
webcast
|
||||
webinar
|
||||
weblog
|
||||
webmaster
|
||||
webpage
|
||||
website
|
||||
whatsapp
|
||||
whiteboard
|
||||
wifi
|
||||
wiki
|
||||
wikipedia
|
||||
wireframe
|
||||
wordcraft
|
||||
workaround
|
||||
workflow
|
||||
workforce
|
||||
workplace
|
||||
workspace
|
||||
workstation
|
||||
youtube
|
||||
@@ -1,305 +0,0 @@
|
||||
# English spelling variants: American and British forms of the same word, for the English
|
||||
# dialects (#1281, #1045). Written by WordCraft contributors from general knowledge of English
|
||||
# spelling (MIT OR Apache-2.0); no other word list copied. See ATTRIBUTION.md.
|
||||
#
|
||||
# Each line: POLICY AMERICAN BRITISH
|
||||
# POLICY is three letters, for American English (en-US), Canadian English (en-CA) and the
|
||||
# British-spelling Englishes (en-GB, en-AU, en-NZ, en-IE, en-ZA, en-IN…), in that order:
|
||||
# u only the American form is right there (the British one is flagged, American suggested)
|
||||
# b only the British form is right there
|
||||
# * both are right
|
||||
# Inflections (colours, centred, organising, organisation…) come from the rules in
|
||||
# crates/proof/src/en_variants.rs for -or/-our, -er/-re and -ize/-ise style pairs; other pairs
|
||||
# get only a plural, so list their other forms on lines of their own. When in doubt a form is
|
||||
# accepted: a false alarm is worse than a missed variant.
|
||||
|
||||
# -or / -our
|
||||
ubb arbor arbour
|
||||
ubb ardor ardour
|
||||
ubb armor armour
|
||||
ubb armory armoury
|
||||
ubb behavior behaviour
|
||||
ubb behavioral behavioural
|
||||
ubb candor candour
|
||||
ubb clamor clamour
|
||||
ubb color colour
|
||||
ubb demeanor demeanour
|
||||
ubb discolor discolour
|
||||
ubb dishonor dishonour
|
||||
ubb endeavor endeavour
|
||||
ubb favor favour
|
||||
ubb favorite favourite
|
||||
ubb favoritism favouritism
|
||||
ubb fervor fervour
|
||||
ubb flavor flavour
|
||||
ubb harbor harbour
|
||||
ubb honor honour
|
||||
ubb humor humour
|
||||
ubb labor labour
|
||||
ubb misbehavior misbehaviour
|
||||
ubb neighbor neighbour
|
||||
ubb neighborhood neighbourhood
|
||||
ubb neighborly neighbourly
|
||||
ubb odor odour
|
||||
ubb parlor parlour
|
||||
ubb rancor rancour
|
||||
ubb rigor rigour
|
||||
ubb rumor rumour
|
||||
ubb savior saviour
|
||||
ubb savor savour
|
||||
ubb savory savoury
|
||||
ubb splendor splendour
|
||||
ubb succor succour
|
||||
ubb tumor tumour
|
||||
ubb unfavorable unfavourable
|
||||
ubb valor valour
|
||||
ubb vapor vapour
|
||||
ubb vigor vigour
|
||||
|
||||
# -er / -re
|
||||
ubb caliber calibre
|
||||
ubb center centre
|
||||
ubb centimeter centimetre
|
||||
ubb epicenter epicentre
|
||||
ubb fiber fibre
|
||||
ubb kilometer kilometre
|
||||
ubb liter litre
|
||||
ubb luster lustre
|
||||
ubb maneuver manoeuvre
|
||||
ubb meager meagre
|
||||
ubb millimeter millimetre
|
||||
ubb miter mitre
|
||||
ubb ocher ochre
|
||||
ubb saber sabre
|
||||
ubb sepulcher sepulchre
|
||||
ubb somber sombre
|
||||
ubb specter spectre
|
||||
*bb theater theatre
|
||||
# A meter is also a measuring device in British English.
|
||||
u** meter metre
|
||||
|
||||
# -ize / -ise (British English accepts both: Oxford spelling uses -ize)
|
||||
uu* agonize agonise
|
||||
uu* apologize apologise
|
||||
uu* authorize authorise
|
||||
uu* capitalize capitalise
|
||||
uu* categorize categorise
|
||||
uu* centralize centralise
|
||||
uu* characterize characterise
|
||||
uu* civilize civilise
|
||||
uu* colonize colonise
|
||||
uu* commercialize commercialise
|
||||
uu* computerize computerise
|
||||
uu* criticize criticise
|
||||
uu* crystallize crystallise
|
||||
uu* customize customise
|
||||
uu* decentralize decentralise
|
||||
uu* demonize demonise
|
||||
uu* deodorize deodorise
|
||||
uu* destabilize destabilise
|
||||
uu* digitize digitise
|
||||
uu* dramatize dramatise
|
||||
uu* economize economise
|
||||
uu* emphasize emphasise
|
||||
uu* energize energise
|
||||
uu* equalize equalise
|
||||
uu* familiarize familiarise
|
||||
uu* fantasize fantasise
|
||||
uu* fertilize fertilise
|
||||
uu* finalize finalise
|
||||
uu* formalize formalise
|
||||
uu* fossilize fossilise
|
||||
uu* galvanize galvanise
|
||||
uu* generalize generalise
|
||||
uu* globalize globalise
|
||||
uu* harmonize harmonise
|
||||
uu* hospitalize hospitalise
|
||||
uu* humanize humanise
|
||||
uu* hypnotize hypnotise
|
||||
uu* hypothesize hypothesise
|
||||
uu* idealize idealise
|
||||
uu* idolize idolise
|
||||
uu* immobilize immobilise
|
||||
uu* immortalize immortalise
|
||||
uu* immunize immunise
|
||||
uu* industrialize industrialise
|
||||
uu* initialize initialise
|
||||
uu* italicize italicise
|
||||
uu* itemize itemise
|
||||
uu* jeopardize jeopardise
|
||||
uu* legalize legalise
|
||||
uu* legitimize legitimise
|
||||
uu* liberalize liberalise
|
||||
uu* localize localise
|
||||
uu* magnetize magnetise
|
||||
uu* marginalize marginalise
|
||||
uu* maximize maximise
|
||||
uu* mechanize mechanise
|
||||
uu* memorize memorise
|
||||
uu* mesmerize mesmerise
|
||||
uu* minimize minimise
|
||||
uu* mobilize mobilise
|
||||
uu* modernize modernise
|
||||
uu* monopolize monopolise
|
||||
uu* moralize moralise
|
||||
uu* nationalize nationalise
|
||||
uu* naturalize naturalise
|
||||
uu* neutralize neutralise
|
||||
uu* normalize normalise
|
||||
uu* optimize optimise
|
||||
uu* organize organise
|
||||
uu* ostracize ostracise
|
||||
uu* oxidize oxidise
|
||||
uu* pasteurize pasteurise
|
||||
uu* patronize patronise
|
||||
uu* penalize penalise
|
||||
uu* personalize personalise
|
||||
uu* plagiarize plagiarise
|
||||
uu* polarize polarise
|
||||
uu* popularize popularise
|
||||
uu* pressurize pressurise
|
||||
uu* prioritize prioritise
|
||||
uu* privatize privatise
|
||||
uu* publicize publicise
|
||||
uu* pulverize pulverise
|
||||
uu* randomize randomise
|
||||
uu* rationalize rationalise
|
||||
uu* realize realise
|
||||
uu* recognize recognise
|
||||
uu* reorganize reorganise
|
||||
uu* revolutionize revolutionise
|
||||
uu* satirize satirise
|
||||
uu* scrutinize scrutinise
|
||||
uu* sensitize sensitise
|
||||
uu* socialize socialise
|
||||
uu* specialize specialise
|
||||
uu* stabilize stabilise
|
||||
uu* standardize standardise
|
||||
uu* sterilize sterilise
|
||||
uu* stigmatize stigmatise
|
||||
uu* subsidize subsidise
|
||||
uu* summarize summarise
|
||||
uu* symbolize symbolise
|
||||
uu* sympathize sympathise
|
||||
uu* synchronize synchronise
|
||||
uu* synthesize synthesise
|
||||
uu* systematize systematise
|
||||
uu* tantalize tantalise
|
||||
uu* terrorize terrorise
|
||||
uu* theorize theorise
|
||||
uu* trivialize trivialise
|
||||
uu* urbanize urbanise
|
||||
uu* utilize utilise
|
||||
uu* vandalize vandalise
|
||||
uu* verbalize verbalise
|
||||
uu* victimize victimise
|
||||
uu* visualize visualise
|
||||
uu* vocalize vocalise
|
||||
uu* westernize westernise
|
||||
|
||||
# -yze / -yse
|
||||
u*b analyze analyse
|
||||
u*b catalyze catalyse
|
||||
u*b dialyze dialyse
|
||||
u*b electrolyze electrolyse
|
||||
u*b hydrolyze hydrolyse
|
||||
u*b paralyze paralyse
|
||||
u*b psychoanalyze psychoanalyse
|
||||
|
||||
# -ense / -ence
|
||||
ubb defense defence
|
||||
ubb offense offence
|
||||
ubb pretense pretence
|
||||
ubb defenseless defenceless
|
||||
# In British English "license" and "practise" are the verbs, so only American English flags.
|
||||
u** license licence
|
||||
u** practice practise
|
||||
|
||||
# Doubled l (American English accepts both)
|
||||
*bb canceled cancelled
|
||||
*bb canceling cancelling
|
||||
*bb channeled channelled
|
||||
*bb channeling channelling
|
||||
*bb counseled counselled
|
||||
*bb counseling counselling
|
||||
*bb counselor counsellor
|
||||
*bb counselors counsellors
|
||||
*bb dueling duelling
|
||||
*bb equaled equalled
|
||||
*bb equaling equalling
|
||||
*bb fueled fuelled
|
||||
*bb fueling fuelling
|
||||
*bb jeweler jeweller
|
||||
*bb jewelers jewellers
|
||||
*bb jewelry jewellery
|
||||
*bb labeled labelled
|
||||
*bb labeling labelling
|
||||
*bb leveled levelled
|
||||
*bb leveling levelling
|
||||
*bb marveled marvelled
|
||||
*bb marveling marvelling
|
||||
*bb marvelous marvellous
|
||||
*bb modeled modelled
|
||||
*bb modeling modelling
|
||||
*bb quarreled quarrelled
|
||||
*bb quarreling quarrelling
|
||||
*bb signaled signalled
|
||||
*bb signaling signalling
|
||||
*bb totaled totalled
|
||||
*bb totaling totalling
|
||||
*bb traveled travelled
|
||||
*bb traveler traveller
|
||||
*bb travelers travellers
|
||||
*bb traveling travelling
|
||||
*bb tunneled tunnelled
|
||||
*bb tunneling tunnelling
|
||||
*bb woolen woollen
|
||||
|
||||
# -og / -ogue
|
||||
*bb catalog catalogue
|
||||
*bb cataloged catalogued
|
||||
*bb cataloging cataloguing
|
||||
|
||||
# ae / oe
|
||||
u*b anemia anaemia
|
||||
u*b anemic anaemic
|
||||
u*b anesthesia anaesthesia
|
||||
u*b anesthetic anaesthetic
|
||||
**b archeology archaeology
|
||||
**b archeologist archaeologist
|
||||
u*b diarrhea diarrhoea
|
||||
u*b edema oedema
|
||||
u*b esophagus oesophagus
|
||||
u*b estrogen oestrogen
|
||||
u*b gynecologist gynaecologist
|
||||
u*b gynecology gynaecology
|
||||
u*b hematology haematology
|
||||
u*b hemoglobin haemoglobin
|
||||
u*b hemorrhage haemorrhage
|
||||
u*b leukemia leukaemia
|
||||
u*b orthopedic orthopaedic
|
||||
u*b pediatric paediatric
|
||||
u*b pediatrician paediatrician
|
||||
u*b pediatrics paediatrics
|
||||
|
||||
# Other words
|
||||
u*b aluminum aluminium
|
||||
u*b cozy cosy
|
||||
ubb mold mould
|
||||
ubb molded moulded
|
||||
ubb molding moulding
|
||||
ubb moldy mouldy
|
||||
ubb molt moult
|
||||
ubb pajamas pyjamas
|
||||
ubb plow plough
|
||||
ubb plowed ploughed
|
||||
ubb plowing ploughing
|
||||
u** program programme
|
||||
u*b skeptic sceptic
|
||||
u*b skeptical sceptical
|
||||
u*b skepticism scepticism
|
||||
ubb smolder smoulder
|
||||
ubb smoldering smouldering
|
||||
# "Tire" and "curb" are also verbs everywhere, so only American English flags.
|
||||
uu* tire tyre
|
||||
uu* curb kerb
|
||||
@@ -1,214 +0,0 @@
|
||||
# Russian spelling supplement: common words older Russian Hunspell dictionaries lack.
|
||||
# Written by WordCraft contributors (MIT OR Apache-2.0); see ATTRIBUTION.md.
|
||||
# One word per line: the base form, a TAB, then its declension, from which
|
||||
# crates/proof/src/ru/extra.rs makes every form:
|
||||
# m masculine noun ending in a consonant (маркетплейс, -а, -у, -ом, -е, -ы/-и, -ов/-ей…)
|
||||
# f feminine noun in -а (инфографика, -и, -е, -у, -ой…; genitive plural without ending)
|
||||
# fia feminine noun in -ия (интеграция, -ии, -ию, -ией, -ий…)
|
||||
# fs feminine noun in -ь (соцсеть, -и, -ью, -ей, -ям…)
|
||||
# nie neuter noun in -ие (согласие, -ия, -ию, -ием, -ий…)
|
||||
# no neuter noun in -о (юрлицо, -а, -у, -ом, -е; genitive plural without ending)
|
||||
# adj adjective or participle in -ый, -ий, -ой (бонусный, газоиспользующий)
|
||||
# ovat verb in -овать with its conjugation and participles (колеровать, администрировать)
|
||||
# = the forms themselves, space-separated (verbs, nouns with a fleeting vowel)
|
||||
# A word alone on its line doesn't decline (онлайн, медиа). Capitalized words are names and match
|
||||
# only when written with a capital.
|
||||
|
||||
# Commerce, marketing and the web
|
||||
маркетплейс m
|
||||
селлер m
|
||||
фулфилмент m
|
||||
ритейл m
|
||||
ритейлер m
|
||||
дистрибьютор m
|
||||
дропшиппинг m
|
||||
мерчандайзер m
|
||||
эквайринг m
|
||||
промокод m
|
||||
кешбэк m
|
||||
кэшбэк m
|
||||
лендинг m
|
||||
лид m
|
||||
тренд m
|
||||
бренд m
|
||||
брендбук m
|
||||
ребрендинг m
|
||||
таргетинг m
|
||||
геотаргетинг m
|
||||
ретаргетинг m
|
||||
таргетолог m
|
||||
маркетолог m
|
||||
копирайтер m
|
||||
рерайтер m
|
||||
контент m
|
||||
блогер m
|
||||
инфлюенсер m
|
||||
хештег m
|
||||
хэштег m
|
||||
репост m
|
||||
лайк m
|
||||
подкаст m
|
||||
стриминг m
|
||||
вебинар m
|
||||
чат m
|
||||
бот m
|
||||
чат-бот m
|
||||
стартап m
|
||||
франчайзинг m
|
||||
аутсорсинг m
|
||||
аутстаффинг m
|
||||
коворкинг m
|
||||
дедлайн m
|
||||
фидбэк m
|
||||
фидбек m
|
||||
кейс m
|
||||
скриншот m
|
||||
скринкаст m
|
||||
баннер m
|
||||
виджет m
|
||||
дашборд m
|
||||
сервис m
|
||||
сайт m
|
||||
скан m
|
||||
токен m
|
||||
аккаунт m
|
||||
логин m
|
||||
пароль m
|
||||
смартфон m
|
||||
гаджет m
|
||||
планшет m
|
||||
ноутбук m
|
||||
апгрейд m
|
||||
апдейт m
|
||||
бэкап m
|
||||
бэкенд m
|
||||
фронтенд m
|
||||
датасет m
|
||||
промпт m
|
||||
интерфейс m
|
||||
хостинг m
|
||||
домен m
|
||||
сниппет m
|
||||
трафик m
|
||||
юзер m
|
||||
стикер m
|
||||
чекбокс m
|
||||
инпут m
|
||||
плейсхолдер m
|
||||
тултип m
|
||||
гипсокартон m
|
||||
пеноплекс m
|
||||
праймер m
|
||||
колер m
|
||||
колорант m
|
||||
Яндекс m
|
||||
Битрикс m
|
||||
Вебвизор m
|
||||
Дзен m
|
||||
инфографика f
|
||||
аналитика f
|
||||
оплата f
|
||||
предоплата f
|
||||
постоплата f
|
||||
доплата f
|
||||
переплата f
|
||||
недоплата f
|
||||
маркировка = маркировка маркировки маркировке маркировку маркировкой маркировкою маркировок маркировкам маркировками маркировках
|
||||
отгрузка = отгрузка отгрузки отгрузке отгрузку отгрузкой отгрузкою отгрузок отгрузкам отгрузками отгрузках
|
||||
колеровка = колеровка колеровки колеровке колеровку колеровкой колеровкою колеровок колеровкам колеровками колеровках
|
||||
рассылка = рассылка рассылки рассылке рассылку рассылкой рассылкою рассылок рассылкам рассылками рассылках
|
||||
выгрузка = выгрузка выгрузки выгрузке выгрузку выгрузкой выгрузкою выгрузок выгрузкам выгрузками выгрузках
|
||||
интеграция fia
|
||||
пагинация fia
|
||||
госрегистрация fia
|
||||
геолокация fia
|
||||
монетизация fia
|
||||
кастомизация fia
|
||||
персонализация fia
|
||||
сегментация fia
|
||||
верификация fia
|
||||
валидация fia
|
||||
авторизация fia
|
||||
аутентификация fia
|
||||
оптимизация fia
|
||||
визуализация fia
|
||||
соцсеть fs
|
||||
согласие nie
|
||||
домовладение nie
|
||||
рождение nie
|
||||
паллетирование nie
|
||||
неисполнение nie
|
||||
непредоставление nie
|
||||
бонусный adj
|
||||
маркетинговый adj
|
||||
мотивационный adj
|
||||
гипоаллергенный adj
|
||||
тиксотропный adj
|
||||
сополимерный adj
|
||||
покупочный adj
|
||||
таргетированный adj
|
||||
брендированный adj
|
||||
кастомный adj
|
||||
онлайновый adj
|
||||
офлайновый adj
|
||||
колеруемый adj
|
||||
колерованный adj
|
||||
заколерованный adj
|
||||
газоиспользующий adj
|
||||
колеровать ovat
|
||||
заколеровать ovat
|
||||
администрировать ovat
|
||||
модерировать ovat
|
||||
отмодерировать ovat
|
||||
таргетировать ovat
|
||||
монетизировать ovat
|
||||
брендировать ovat
|
||||
кастомизировать ovat
|
||||
детектировать ovat
|
||||
верифицировать ovat
|
||||
валидировать ovat
|
||||
генерировать ovat
|
||||
сгенерировать ovat
|
||||
мониторить = мониторить мониторю мониторишь мониторит мониторим мониторите мониторят мониторил мониторила мониторили мониторя мониторинг
|
||||
гуглить = гуглить гуглю гуглишь гуглит гуглим гуглите гуглят гуглил гуглила гуглили
|
||||
скачивать = скачивать скачиваю скачиваешь скачивает скачиваем скачиваете скачивают скачивал скачивала скачивали скачивается скачиваются
|
||||
оценивание nie
|
||||
медучреждение nie
|
||||
юрлицо no
|
||||
учётка = учётка учётки учётке учётку учёткой учёток учёткам учётками учётках учетка учетки учетке учетку учеткой учеток учеткам учетками учетках
|
||||
перелинковка = перелинковка перелинковки перелинковке перелинковку перелинковкой перелинковок
|
||||
поклейка = поклейка поклейки поклейке поклейку поклейкой поклеек
|
||||
детекция fia
|
||||
досудебный adj
|
||||
категорийный adj
|
||||
оркестратор m
|
||||
оффер m
|
||||
трекер m
|
||||
бриф m
|
||||
гайд m
|
||||
монтажёр m
|
||||
тенант m
|
||||
мерчант m
|
||||
амбассадор m
|
||||
Роскомнадзор m
|
||||
Директ m
|
||||
Маркет m
|
||||
вовлечённость fs
|
||||
вовлеченность fs
|
||||
укрывистость fs
|
||||
# Words that don't decline
|
||||
онлайн
|
||||
офлайн
|
||||
оффлайн
|
||||
медиа
|
||||
демо
|
||||
франчайзи
|
||||
киноа
|
||||
смс
|
||||
пн
|
||||
вт
|
||||
ср
|
||||
чт
|
||||
пт
|
||||
сб
|
||||
вс
|
||||
@@ -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
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
# Contributor names for the About window (craftrules standards/contributors.md).
|
||||
# Contributor names for the About window.
|
||||
#
|
||||
# Everyone who contributes is credited by GitHub username automatically. Anything more is up to
|
||||
# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a
|
||||
# maintainer may add a person only when that person asked, recording where in consent_source.
|
||||
#
|
||||
# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \
|
||||
# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name]
|
||||
#
|
||||
# Or copy this block, replacing your-github-username (every field is optional):
|
||||
# Copy this block, replacing your-github-username (every field is optional):
|
||||
#
|
||||
# [people.your-github-username]
|
||||
# real_name = "Your Name" # shown when the viewer picks "Real name"
|
||||
@@ -181,3 +178,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 @@
|
||||
9205108cfdced5177706264038848b68b2422d2b
|
||||
059cbf95c2a3eb95d637ca8584faca1129d17f48
|
||||
|
||||
@@ -9,7 +9,8 @@ description = "WordCraft document model: stories, paragraphs, runs, tables, sect
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
# Test inputs other crates' tests share (`cfb_guard::repeating_difat_sample`); turned on only as a
|
||||
# Test inputs other crates' tests share (`cfb_guard::repeating_difat_sample`,
|
||||
# `zip_guard::zip64_entry_count_sample`); turned on only as a
|
||||
# dev-dependency, so never built into the app.
|
||||
test-support = []
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
+201
-37
@@ -48,6 +48,131 @@ impl LineCap {
|
||||
}
|
||||
}
|
||||
|
||||
/// An arrowhead's width across its line or length along it (ST_LineEndWidth and
|
||||
/// ST_LineEndLength, ECMA-376 §20.1.10.39–40): 2, 3 or 5 times the line's width, as Word draws it.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ArrowSize {
|
||||
Small,
|
||||
#[default]
|
||||
Medium,
|
||||
Large,
|
||||
}
|
||||
|
||||
impl ArrowSize {
|
||||
/// Read a DrawingML size (`sm`, `med`, `lg`); absent or unknown values are medium.
|
||||
pub fn from_ooxml(value: &str) -> Self {
|
||||
match value {
|
||||
"sm" => Self::Small,
|
||||
"lg" => Self::Large,
|
||||
_ => Self::Medium,
|
||||
}
|
||||
}
|
||||
|
||||
/// The DrawingML spelling.
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
Self::Small => "sm",
|
||||
Self::Medium => "med",
|
||||
Self::Large => "lg",
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a VML width (`narrow`, `medium`, `wide`) or length (`short`, `medium`, `long`).
|
||||
pub fn from_vml(value: &str) -> Self {
|
||||
match value {
|
||||
"narrow" | "short" => Self::Small,
|
||||
"wide" | "long" => Self::Large,
|
||||
_ => Self::Medium,
|
||||
}
|
||||
}
|
||||
|
||||
/// How many [`arrow_unit`]s the arrowhead measures.
|
||||
pub fn factor(self) -> f32 {
|
||||
match self {
|
||||
Self::Small => 2.0,
|
||||
Self::Medium => 3.0,
|
||||
Self::Large => 5.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The kind of an arrowhead (ST_LineEndType, ECMA-376 §20.1.10.33), kept for saving; every kind
|
||||
/// is drawn as a triangle.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ArrowKind {
|
||||
#[default]
|
||||
Triangle,
|
||||
Stealth,
|
||||
Diamond,
|
||||
Oval,
|
||||
Arrow,
|
||||
}
|
||||
|
||||
impl ArrowKind {
|
||||
/// Read a DrawingML end type other than `none`; unknown values are triangles.
|
||||
pub fn from_ooxml(value: &str) -> Self {
|
||||
match value {
|
||||
"stealth" => Self::Stealth,
|
||||
"diamond" => Self::Diamond,
|
||||
"oval" => Self::Oval,
|
||||
"arrow" => Self::Arrow,
|
||||
_ => Self::Triangle,
|
||||
}
|
||||
}
|
||||
|
||||
/// The DrawingML spelling.
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
Self::Triangle => "triangle",
|
||||
Self::Stealth => "stealth",
|
||||
Self::Diamond => "diamond",
|
||||
Self::Oval => "oval",
|
||||
Self::Arrow => "arrow",
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a VML arrow (`block`, `classic`, `diamond`, `oval`, `open`) other than `none`.
|
||||
pub fn from_vml(value: &str) -> Self {
|
||||
match value {
|
||||
"classic" => Self::Stealth,
|
||||
"diamond" => Self::Diamond,
|
||||
"oval" => Self::Oval,
|
||||
"open" => Self::Arrow,
|
||||
_ => Self::Triangle,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An arrowhead's kind and size (`a:headEnd` / `a:tailEnd`). The default is a medium triangle.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ArrowStyle {
|
||||
pub kind: ArrowKind,
|
||||
pub width: ArrowSize,
|
||||
pub length: ArrowSize,
|
||||
}
|
||||
|
||||
impl ArrowStyle {
|
||||
pub fn is_default(&self) -> bool {
|
||||
*self == Self::default()
|
||||
}
|
||||
|
||||
/// The arrowhead's length along its line and width across it, for a line `line_width` wide.
|
||||
pub fn dims(self, line_width: f32) -> (f32, f32) {
|
||||
let unit = arrow_unit(line_width);
|
||||
(self.length.factor() * unit, self.width.factor() * unit)
|
||||
}
|
||||
}
|
||||
|
||||
/// What an arrowhead's size is a multiple of: the line's width, but at least 2 points (Word draws
|
||||
/// a thinner line's arrowheads as for a 2-point line).
|
||||
pub fn arrow_unit(line_width: f32) -> f32 {
|
||||
let w = if line_width.is_finite() { line_width.clamp(0.25, 200.0) } else { 0.75 };
|
||||
w.max(2.0)
|
||||
}
|
||||
|
||||
/// The rest of a shape's properties. Empty (the default) for most shapes.
|
||||
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
@@ -69,6 +194,11 @@ pub struct ShapeExtra {
|
||||
pub arrow_start: bool,
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
pub arrow_end: bool,
|
||||
/// The kind and size of the start and end arrowheads, when there are any.
|
||||
#[serde(skip_serializing_if = "ArrowStyle::is_default")]
|
||||
pub start_style: ArrowStyle,
|
||||
#[serde(skip_serializing_if = "ArrowStyle::is_default")]
|
||||
pub end_style: ArrowStyle,
|
||||
/// A rounded rectangle's corner radius (`roundRect`'s `adj`, ECMA-376 §20.1.10.56): in
|
||||
/// 100000ths of the shorter side, at most [`MAX_CORNER`]; `None` draws [`DEFAULT_CORNER`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -83,6 +213,39 @@ pub struct ShapeExtra {
|
||||
/// main colour (the shape's `stroke`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub outline_fill: Option<crate::fill::Fill>,
|
||||
/// A horizontal rule (VML `o:hr`: a web page's `<hr>` pasted into Word): it takes its whole
|
||||
/// line, and is drawn across [`HorizontalRule::pct`] of it or at the shape's own width.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hr: Option<HorizontalRule>,
|
||||
}
|
||||
|
||||
/// How a horizontal rule sits on its line (VML `o:hrpct`, `o:hralign`), as Word draws one:
|
||||
/// across `pct` percent of the line's width (the paragraph's, inside its indents), or at the
|
||||
/// shape's own width when `pct` is 0; `align` places it when it is narrower than the line.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Default, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct HorizontalRule {
|
||||
pub pct: f32,
|
||||
pub align: crate::props::Align,
|
||||
/// Word's standard look (`o:hrstd`), kept for saving.
|
||||
pub standard: bool,
|
||||
}
|
||||
|
||||
impl HorizontalRule {
|
||||
/// The rule's width on a line `line` wide, for a shape `own` wide.
|
||||
pub fn width(&self, line: f32, own: f32) -> f32 {
|
||||
let line = line.max(0.0);
|
||||
if self.pct.is_finite() && self.pct > 0.0 { line * (self.pct.min(100.0) / 100.0) } else { own.clamp(0.0, line) }
|
||||
}
|
||||
|
||||
/// Where a rule `w` wide starts on a line from `x`, `line` wide.
|
||||
pub fn left(&self, x: f32, line: f32, w: f32) -> f32 {
|
||||
match self.align {
|
||||
crate::props::Align::Center => x + (line - w) / 2.0,
|
||||
crate::props::Align::Right => x + line - w,
|
||||
_ => x,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_zero(v: &u32) -> bool {
|
||||
@@ -362,6 +525,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 +569,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+26
-13
@@ -14,7 +14,7 @@
|
||||
//! 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};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -422,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() {
|
||||
|
||||
@@ -0,0 +1,865 @@
|
||||
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 ([`crate::count_words`], plus 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
|
||||
}
|
||||
}
|
||||
+453
-657
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,911 @@
|
||||
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_formatting_they_copy() {
|
||||
// A story holding a text box and a run whose font name is 1 MiB: each copy clones the
|
||||
// formatting too, so 64 copies used to copy 64 MiB of it past the 32 MiB cap.
|
||||
let props = CharProps { font: Some("f".repeat(1 << 20)), ..CharProps::default() };
|
||||
let mut p = Paragraph::with_text("x", props);
|
||||
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();
|
||||
d.insert_fragment(&Pos::body(0, 1), &f).unwrap();
|
||||
let added = d.parts.len() - before;
|
||||
let font = |p: &Paragraph| p.runs.iter().map(|r| r.props.font.as_ref().map_or(0, String::len)).sum::<usize>();
|
||||
let copied: usize = d.parts.values().flat_map(|p| p.blocks.iter()).map(|b| if let Block::Para(p) = &**b { font(p) } else { 0 }).sum();
|
||||
assert!((1..64).contains(&added), "{added}");
|
||||
assert!(copied <= MAX_PASTED_BOX_BYTES, "{copied}");
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
#[test]
|
||||
fn adopting_a_document_renumbers_what_it_refers_to() {
|
||||
use crate::para::NoteKind;
|
||||
use crate::{Comment, Revision, RevisionKind};
|
||||
// Ours already has a footnote, a comment, a list and a revision with the ids the other's use.
|
||||
let mut ours = doc(&["ours"]);
|
||||
let our_note = ours.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("our note", CharProps::default()))]);
|
||||
ours.comments.insert(0, Comment { part: our_note, ..Comment::default() });
|
||||
let our_list = ours.numbering.add_list(crate::numbering::ListKind::Bullet);
|
||||
ours.revisions.push(Revision { kind: RevisionKind::Insert, ..Revision::default() });
|
||||
|
||||
let mut theirs = doc(&["theirs"]);
|
||||
let note = theirs.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("their note", CharProps::default()))]);
|
||||
let said = theirs.add_part(PartKind::Comment, vec![para_block(Paragraph::with_text("their comment", CharProps::default()))]);
|
||||
theirs.comments.insert(0, Comment { author: "Them".into(), part: said, durable_id: Some("0A0B0C0D".into()), ..Comment::default() });
|
||||
let list = theirs.numbering.add_list(crate::numbering::ListKind::Bullet);
|
||||
assert_eq!(list, our_list);
|
||||
theirs.revisions.push(Revision { kind: RevisionKind::Delete, ..Revision::default() });
|
||||
let mut style = crate::styles::Style { id: "TheirList".into(), name: "Their List".into(), ..Default::default() };
|
||||
style.para.numbering = Some(crate::props::NumRef { num: list, level: 0 });
|
||||
theirs.styles.upsert(style);
|
||||
let p = theirs.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
p.props.numbering = Some(crate::props::NumRef { num: list, level: 0 });
|
||||
p.runs[0].props.del = Some(0);
|
||||
// An insertion naming a revision it doesn't have.
|
||||
p.runs[0].props.ins = Some(7);
|
||||
let props = p.props_of_char(0).clone();
|
||||
p.insert_object(0, InlineObject::NoteRef { kind: NoteKind::Footnote, id: note, custom: String::new() }, &props).unwrap();
|
||||
p.insert_object(0, InlineObject::CommentStart { id: 0 }, &props).unwrap();
|
||||
|
||||
let frag = ours.adopt(theirs);
|
||||
let Some(Block::Para(p)) = frag.blocks.first() else { panic!() };
|
||||
let new_list = p.props.numbering.unwrap().num;
|
||||
assert_ne!(new_list, our_list);
|
||||
assert!(ours.numbering.num(new_list).is_some());
|
||||
assert_eq!(ours.styles.get("TheirList").and_then(|s| s.para.numbering).map(|n| n.num), Some(new_list));
|
||||
// Their deletion is their revision, after ours.
|
||||
let del = p.runs.iter().find_map(|r| r.props.del).unwrap();
|
||||
assert_eq!(ours.revisions.get(del as usize).map(|r| r.kind), Some(RevisionKind::Delete));
|
||||
assert!(p.runs.iter().all(|r| r.props.ins.is_none()));
|
||||
// Their note and comment come along under ids of their own.
|
||||
let (note_id, comment_id) = p.objects.iter().fold((None, None), |(n, c), o| match o {
|
||||
InlineObject::NoteRef { id, .. } => (Some(*id), c),
|
||||
InlineObject::CommentStart { id } => (n, Some(*id)),
|
||||
_ => (n, c),
|
||||
});
|
||||
let text = |id: u32| {
|
||||
ours.parts
|
||||
.get(&id)
|
||||
.map(|p| p.blocks.iter().map(|b| if let Block::Para(p) = &**b { p.text.clone() } else { String::new() }).collect::<String>())
|
||||
};
|
||||
assert_eq!(text(note_id.unwrap()).as_deref(), Some("their note"));
|
||||
let comment = ours.comments.get(&comment_id.unwrap()).unwrap();
|
||||
assert_eq!((comment.author.as_str(), text(comment.part).as_deref()), ("Them", Some("their comment")));
|
||||
// Its durable id is the other document's: the writer gives the copy one of its own.
|
||||
assert_eq!(comment.durable_id, None);
|
||||
assert_eq!(text(our_note).as_deref(), Some("our note"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_kept_character_style_leaves_what_it_doesnt_set_to_the_paragraph_style() {
|
||||
use crate::styles::{Style, StyleKind, StyleSheet};
|
||||
let mut ours = doc(&["ours"]);
|
||||
let mut theirs = doc(&["theirs"]);
|
||||
theirs.styles = StyleSheet::empty();
|
||||
theirs.styles.default_chr = CharProps { size: Some(9.0), ..Default::default() };
|
||||
let big = CharProps { size: Some(20.0), ..Default::default() };
|
||||
theirs.styles.upsert(Style { id: "Big".into(), name: "Big".into(), chr: big, ..Default::default() });
|
||||
theirs.styles.upsert(Style { id: "Link".into(), name: "Link".into(), kind: StyleKind::Character, ..Default::default() });
|
||||
let p = theirs.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
p.props.style = Some("Big".into());
|
||||
p.runs[0].props.style = Some("Link".into());
|
||||
|
||||
let frag = ours.adopt_with(theirs, true);
|
||||
let Some(Block::Para(p)) = frag.blocks.first() else { panic!() };
|
||||
assert_eq!(ours.styles.resolve_char(p.props.style.as_deref(), &p.runs[0].props).size, 20.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kept_styles_renamed_on_adoption_are_renamed_in_list_levels_too() {
|
||||
use crate::styles::Style;
|
||||
let mut ours = doc(&["ours"]);
|
||||
ours.styles.upsert(Style { id: "Heading1".into(), name: "heading 1".into(), ..Default::default() });
|
||||
let mut theirs = doc(&["theirs"]);
|
||||
theirs.styles.upsert(Style { id: "Heading1".into(), name: "heading 1".into(), ..Default::default() });
|
||||
let list = theirs.numbering.add_list(crate::numbering::ListKind::Numbered);
|
||||
let abs = theirs.numbering.nums.iter().find(|n| n.id == list).map(|n| n.abstract_id).unwrap();
|
||||
let a = theirs.numbering.abstracts.iter_mut().find(|a| a.id == abs).unwrap();
|
||||
a.levels[0].style = Some("Heading1".into());
|
||||
let level = a.levels[0].clone();
|
||||
theirs.numbering.nums.iter_mut().find(|n| n.id == list).unwrap().level_overrides.push((1, level));
|
||||
|
||||
let before = ours.numbering.abstracts.len();
|
||||
ours.adopt_with(theirs, true);
|
||||
let copied = &ours.numbering.abstracts[before..];
|
||||
let styles: Vec<_> = copied
|
||||
.iter()
|
||||
.flat_map(|a| a.levels.iter())
|
||||
.chain(ours.numbering.nums.iter().flat_map(|n| n.level_overrides.iter().map(|(_, l)| l)))
|
||||
.filter_map(|l| l.style.clone())
|
||||
.collect();
|
||||
assert_eq!(styles.len(), 2, "{styles:?}");
|
||||
for st in &styles {
|
||||
assert_ne!(st, "Heading1");
|
||||
assert!(ours.styles.get(st).is_some(), "{st}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_adopted_section_breaks_tracked_change_names_its_revision() {
|
||||
use crate::{Revision, RevisionKind, SectionProps};
|
||||
let mut ours = doc(&["ours"]);
|
||||
ours.revisions.push(Revision { kind: RevisionKind::Insert, ..Revision::default() });
|
||||
let mut theirs = doc(&["theirs"]);
|
||||
theirs.revisions.push(Revision { kind: RevisionKind::Format, ..Revision::default() });
|
||||
let p = theirs.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
p.section = Some(Box::new(SectionProps { fmt_change: crate::props::PropChange::boxed(0, SectionProps::default()), ..SectionProps::default() }));
|
||||
|
||||
let frag = ours.adopt(theirs);
|
||||
let Some(Block::Para(p)) = frag.blocks.first() else { panic!() };
|
||||
let rev = p.section.as_ref().and_then(|s| s.fmt_change.as_ref()).map(|c| c.rev);
|
||||
assert_eq!(rev.and_then(|r| ours.revisions.get(r as usize)).map(|r| r.kind), Some(RevisionKind::Format));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_adopted_tracked_format_change_keeps_its_old_styles_and_list() {
|
||||
use crate::props::{NumRef, ParaProps, PropChange};
|
||||
use crate::{Revision, RevisionKind};
|
||||
for keep in [false, true] {
|
||||
let mut ours = doc(&["ours"]);
|
||||
let our_list = ours.numbering.add_list(crate::numbering::ListKind::Bullet);
|
||||
let mut theirs = doc(&["theirs"]);
|
||||
theirs.revisions.push(Revision { kind: RevisionKind::Format, ..Revision::default() });
|
||||
let list = theirs.numbering.add_list(crate::numbering::ListKind::Bullet);
|
||||
assert_eq!(list, our_list);
|
||||
let p = theirs.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
// Live and old props name the same styles (ours has them too) and their list.
|
||||
p.props.style = Some("Quote".into());
|
||||
p.props.numbering = Some(NumRef { num: list, level: 0 });
|
||||
let old = ParaProps { style: Some("Quote".into()), numbering: Some(NumRef { num: list, level: 0 }), ..ParaProps::default() };
|
||||
p.props.fmt_change = PropChange::boxed(0, old);
|
||||
p.runs[0].props.style = Some("Strong".into());
|
||||
p.runs[0].props.fmt_change = PropChange::boxed(0, CharProps { style: Some("Strong".into()), ..CharProps::default() });
|
||||
|
||||
let frag = ours.adopt_with(theirs, keep);
|
||||
let Some(Block::Para(p)) = frag.blocks.first() else { panic!() };
|
||||
let old = &p.props.fmt_change.as_ref().unwrap().old;
|
||||
assert_eq!((&old.style, old.numbering), (&p.props.style, p.props.numbering), "keep_styles {keep}");
|
||||
assert_ne!(old.numbering.map(|n| n.num), Some(our_list));
|
||||
let run = &p.runs[0].props;
|
||||
assert_eq!(run.fmt_change.as_ref().unwrap().old.style, run.style, "keep_styles {keep}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adopted_styles_never_become_our_defaults() {
|
||||
use crate::styles::{Style, StyleKind};
|
||||
for keep in [false, true] {
|
||||
let mut ours = doc(&["ours"]);
|
||||
let mut theirs = doc(&["theirs"]);
|
||||
// A localized default character style, and one whose id we have too.
|
||||
theirs.styles.upsert(Style { id: "Absatz".into(), kind: StyleKind::Character, default: true, ..Default::default() });
|
||||
theirs.styles.upsert(Style { id: "Strong".into(), kind: StyleKind::Character, default: true, ..Default::default() });
|
||||
ours.adopt_with(theirs, keep);
|
||||
assert!(ours.styles.styles.iter().all(|s| !s.default), "keep_styles {keep}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adopted_permission_ranges_keep_ids_of_their_own() {
|
||||
let perm = |id, editor: &str| InlineObject::PermStart { id, group: String::new(), editor: editor.into() };
|
||||
let mut ours = doc(&["ours"]);
|
||||
ours.insert_object(&Pos::body(0, 4), InlineObject::PermEnd { id: 0 }, &CharProps::default()).unwrap();
|
||||
ours.insert_object(&Pos::body(0, 0), perm(0, "us"), &CharProps::default()).unwrap();
|
||||
// Theirs: a closed range and one left open, numbered from 0 like ours.
|
||||
let mut theirs = doc(&["theirs"]);
|
||||
theirs.insert_object(&Pos::body(0, 6), perm(1, "them"), &CharProps::default()).unwrap();
|
||||
theirs.insert_object(&Pos::body(0, 6), InlineObject::PermEnd { id: 0 }, &CharProps::default()).unwrap();
|
||||
theirs.insert_object(&Pos::body(0, 0), perm(0, "them"), &CharProps::default()).unwrap();
|
||||
|
||||
let frag = ours.adopt(theirs);
|
||||
let Some(Block::Para(p)) = frag.blocks.first() else { panic!() };
|
||||
let ids: Vec<u32> = p
|
||||
.objects
|
||||
.iter()
|
||||
.filter_map(|o| match o {
|
||||
InlineObject::PermStart { id, .. } | InlineObject::PermEnd { id } => Some(*id),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(ids.len(), 3);
|
||||
assert!(!ids.contains(&0), "{ids:?}");
|
||||
assert_eq!(ids[0], ids[1], "a range's start and end still pair: {ids:?}");
|
||||
assert_ne!(ids[1], ids[2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_adopted_notes_text_box_gets_a_story_of_its_own() {
|
||||
use crate::para::NoteKind;
|
||||
// Ours has a text box under the id their note's text box has.
|
||||
let mut ours = doc(&["ours"]);
|
||||
ours.add_part(PartKind::Footnote, Vec::new());
|
||||
let our_box = text_box(&mut ours, &Pos::body(0, 0), "our box");
|
||||
let mut theirs = doc(&["theirs"]);
|
||||
let note = theirs.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("note", CharProps::default()))]);
|
||||
let their_box = theirs.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text("their box", CharProps::default()))]);
|
||||
assert_eq!(their_box, our_box);
|
||||
theirs
|
||||
.insert_object(&Pos { story: StoryRef::Part(note), path: Path::top(0), off: 0 }, box_shape(Some(their_box)), &CharProps::default())
|
||||
.unwrap();
|
||||
theirs
|
||||
.insert_object(&Pos::body(0, 0), InlineObject::NoteRef { kind: NoteKind::Footnote, id: note, custom: String::new() }, &CharProps::default())
|
||||
.unwrap();
|
||||
|
||||
let blocks = ours.adopt(theirs).blocks;
|
||||
let Some(Block::Para(p)) = blocks.first() else { panic!() };
|
||||
let new_note = p.objects.iter().find_map(|o| if let InlineObject::NoteRef { id, .. } = o { Some(*id) } else { None }).unwrap();
|
||||
let story = ours.parts[&new_note]
|
||||
.blocks
|
||||
.iter()
|
||||
.find_map(|b| if let Block::Para(p) = &**b { p.objects.iter().find_map(|o| o.text_boxes().first().copied()) } else { None })
|
||||
.unwrap();
|
||||
assert_ne!(story, our_box);
|
||||
let part = &ours.parts[&story];
|
||||
assert_eq!(part.kind, PartKind::TextBox);
|
||||
assert!(matches!(part.blocks.first().map(|b| &**b), Some(Block::Para(p)) if p.text == "their box"));
|
||||
}
|
||||
@@ -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>;
|
||||
+23
-1785
File diff suppressed because it is too large
Load Diff
+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() {
|
||||
|
||||
+233
-214
@@ -863,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::*;
|
||||
|
||||
@@ -570,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`).
|
||||
|
||||
@@ -338,6 +338,23 @@ impl Counters {
|
||||
*f = LevelFlags { started: false, first: Some(*start) };
|
||||
}
|
||||
}
|
||||
// A level above that hasn't counted yet counts its first number here, as in Word: after
|
||||
// 1.1 and 1.2 with no level-1 item before them, the next level-1 item is 2, whether or not
|
||||
// the deeper level's text shows it.
|
||||
for (k, (v, f)) in st.iter_mut().zip(fl.iter_mut()).enumerate().take(lv) {
|
||||
if f.started {
|
||||
continue;
|
||||
}
|
||||
let start = match self.seen.entry((num, k as u8)) {
|
||||
std::collections::hash_map::Entry::Vacant(e) => {
|
||||
e.insert(true);
|
||||
n.start_overrides.iter().find(|(l, _)| *l as usize == k).map(|(_, s)| *s)
|
||||
}
|
||||
std::collections::hash_map::Entry::Occupied(_) => None,
|
||||
};
|
||||
*v = start.or(f.first).unwrap_or_else(|| level_def(k).map_or(1, |l| l.start));
|
||||
*f = LevelFlags { started: true, first: None };
|
||||
}
|
||||
if let (Some(v), Some(f)) = (st.get_mut(lv), fl.get_mut(lv)) {
|
||||
*v = if f.started { v.saturating_add(1) } else { f.first.unwrap_or(def.start) };
|
||||
*f = LevelFlags { started: true, first: None };
|
||||
@@ -426,8 +443,9 @@ mod tests {
|
||||
assert_eq!(c.next_label(&n, id, 2).unwrap().0, "1.02");
|
||||
assert_eq!(c.next_label(&n, id, 1).unwrap().0, "");
|
||||
assert_eq!(c.next_label(&n, id, 2).unwrap().0, "2.01");
|
||||
// Levels it doesn't define still come from the abstract list.
|
||||
assert_eq!(c.next_label(&n, id, 0).unwrap().0, "1.");
|
||||
// Levels it doesn't define still come from the abstract list; level 0 took its first number
|
||||
// with the first level-1 item, so this is its second.
|
||||
assert_eq!(c.next_label(&n, id, 0).unwrap().0, "2.");
|
||||
assert_eq!(n.level(id, 2).map(|l| l.format), Some(NumFormat::DecimalZero));
|
||||
}
|
||||
|
||||
@@ -476,6 +494,40 @@ mod tests {
|
||||
assert_eq!(l(2), "(1)", "level 1 restarts level 3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_deeper_item_first_counts_the_levels_above_it() {
|
||||
// Measured in Word: a level above that hasn't counted yet takes its first number at the
|
||||
// first deeper item, whether or not that item's text shows it.
|
||||
let labels_with = |starts: [u32; 3], texts: [&str; 3], overrides: &[(u8, u32)], seq: &[u8]| {
|
||||
let mut n = Numbering::default();
|
||||
let id = n.add_list(ListKind::Legal);
|
||||
if let Some(a) = n.abstracts.first_mut() {
|
||||
for (l, (s, t)) in a.levels.iter_mut().zip(starts.into_iter().zip(texts)) {
|
||||
l.start = s;
|
||||
l.text = t.into();
|
||||
}
|
||||
}
|
||||
if let Some(num) = n.nums.iter_mut().find(|x| x.id == id) {
|
||||
num.start_overrides = overrides.to_vec();
|
||||
}
|
||||
let mut c = Counters::default();
|
||||
seq.iter().map(|lv| c.next_label(&n, id, *lv).unwrap().0).collect::<Vec<_>>()
|
||||
};
|
||||
let labels = |starts: [u32; 3], texts: [&str; 3], seq: &[u8]| labels_with(starts, texts, &[], seq);
|
||||
let std = ["%1.", "%1.%2.", "%1.%2.%3."];
|
||||
assert_eq!(labels([1, 1, 1], std, &[1, 1, 0, 0]), ["1.1.", "1.2.", "2.", "3."]);
|
||||
assert_eq!(labels([10, 1, 1], std, &[1, 1, 1, 1, 0, 0]), ["10.1.", "10.2.", "10.3.", "10.4.", "11.", "12."]);
|
||||
assert_eq!(labels([1, 1, 1], ["%1.", "(%2)", "%1.%2.%3."], &[1, 1, 0, 0]), ["(1)", "(2)", "2.", "3."]);
|
||||
assert_eq!(labels([1, 1, 1], std, &[2, 1, 0, 1]), ["1.1.1.", "1.2.", "2.", "2.1."]);
|
||||
assert_eq!(labels([1, 1, 1], ["%1.", "%1.%2.", "(%3)"], &[2, 1, 0]), ["(1)", "1.2.", "2."]);
|
||||
assert_eq!(labels([1, 1, 1], std, &[0, 2, 1, 0]), ["1.", "1.1.1.", "1.2.", "2."]);
|
||||
// The num's own start for the level above counts in its place, as the level's start does.
|
||||
assert_eq!(labels_with([1, 1, 1], std, &[(0, 10)], &[1, 1, 0]), ["10.1.", "10.2.", "11."]);
|
||||
assert_eq!(labels_with([1, 1, 1], std, &[(0, 5)], &[1, 0, 1]), ["5.1.", "6.", "6.1."]);
|
||||
// A level-1 item after the first deeper ones restarts level 2 below it, as usual.
|
||||
assert_eq!(labels([1, 1, 1], std, &[1, 1, 0, 1, 1, 0, 2]), ["1.1.", "1.2.", "2.", "2.1.", "2.2.", "3.", "3.1.1."]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_level_starting_at_zero_counts_up() {
|
||||
let mut n = Numbering::default();
|
||||
|
||||
+10
-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)]
|
||||
@@ -1338,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()) {
|
||||
|
||||
@@ -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),
|
||||
@@ -295,8 +300,12 @@ impl StyleSheet {
|
||||
self.para_style_props_over(style, self.default_para.clone(), self.default_chr.clone())
|
||||
}
|
||||
|
||||
/// The paragraph style chain of `style` applied over `pp` / `cp`.
|
||||
/// The paragraph style chain of `style` applied over `pp` / `cp`. A style the document doesn't
|
||||
/// define is the default paragraph style, Normal, as in Word: a footer naming a `Footer` style
|
||||
/// that `styles.xml` leaves out is formatted as Normal, not with the document defaults alone
|
||||
/// (#5077).
|
||||
fn para_style_props_over(&self, style: &str, mut pp: ParaProps, mut cp: CharProps) -> (ParaProps, CharProps) {
|
||||
let style = if self.get(style).is_some() { style } else { "Normal" };
|
||||
for s in self.chain(style) {
|
||||
if s.kind == StyleKind::Paragraph {
|
||||
let mut sp = s.para.clone();
|
||||
@@ -395,6 +404,31 @@ mod tests {
|
||||
assert_eq!(s.resolve_char(None, &direct).font, "Wingdings");
|
||||
}
|
||||
|
||||
/// #5077: a paragraph naming a style the document doesn't define is formatted as Normal,
|
||||
/// paragraph and run alike, not with the document defaults alone.
|
||||
#[test]
|
||||
fn an_undefined_paragraph_style_is_normal() {
|
||||
let mut s = StyleSheet::builtin();
|
||||
s.default_para.space_after = Some(10.0);
|
||||
if let Some(n) = s.get_mut("Normal") {
|
||||
n.para.space_after = Some(2.0);
|
||||
n.chr.size = Some(13.0);
|
||||
}
|
||||
let undefined = ParaProps { style: Some("NoSuchStyle".into()), ..Default::default() };
|
||||
let normal = s.resolve_para(&ParaProps::default());
|
||||
let p = s.resolve_para(&undefined);
|
||||
assert_eq!(p.space_after, 2.0);
|
||||
assert_eq!(p.space_after, normal.space_after);
|
||||
assert_eq!(p.line_spacing, normal.line_spacing);
|
||||
assert_eq!(s.resolve_char(Some("NoSuchStyle"), &CharProps::default()).size, 13.0);
|
||||
// The paragraph keeps the style it names; direct formatting still wins.
|
||||
assert_eq!(p.style, "NoSuchStyle");
|
||||
let direct = ParaProps { space_after: Some(7.0), ..undefined };
|
||||
assert_eq!(s.resolve_para(&direct).space_after, 7.0);
|
||||
// A defined style is unchanged.
|
||||
assert_eq!(s.resolve_para(&ParaProps { style: Some("Heading1".into()), ..Default::default() }).space_after, 4.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heading_resolves() {
|
||||
let s = StyleSheet::builtin();
|
||||
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,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);
|
||||
@@ -566,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);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use super::*;
|
||||
|
||||
mod note_conversion;
|
||||
|
||||
#[test]
|
||||
fn new_document_is_valid() {
|
||||
let d = Document::new();
|
||||
@@ -16,6 +18,20 @@ fn word_count() {
|
||||
assert_eq!(count_words(" -- "), 0);
|
||||
}
|
||||
|
||||
/// #4547: each Han ideograph and kana counts as a word; punctuation and Hangul spacing don't change.
|
||||
#[test]
|
||||
fn word_count_counts_each_chinese_and_japanese_character() {
|
||||
// #4547: unspaced Chinese counted as one word per run.
|
||||
assert_eq!(count_words("我们今天去公园。"), 7);
|
||||
assert_eq!(count_words("WordCraft是一个文字处理器,很好用!"), 1 + 11);
|
||||
assert_eq!(count_words("ひらがなとカタカナ"), 9);
|
||||
assert_eq!(count_words("カタカナ"), 4);
|
||||
assert_eq!(count_words("ガギ"), 2, "voiced sound marks belong to their kana");
|
||||
assert_eq!(count_words("。、・「」"), 0);
|
||||
// Korean is spaced: Hangul counts by tokens.
|
||||
assert_eq!(count_words("안녕하세요 세계"), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_count_includes_numbered_list_labels_but_not_bullets() {
|
||||
let mut d = Document::new();
|
||||
@@ -61,6 +77,23 @@ fn word_count_includes_numbered_labels_with_nothing_suffix() {
|
||||
assert_eq!(d.word_count(), 5);
|
||||
}
|
||||
|
||||
/// #4547: a generated CJK list label such as 第十二章 still counts as one word.
|
||||
#[test]
|
||||
fn word_count_keeps_a_cjk_list_label_one_word() {
|
||||
let mut d = Document::new();
|
||||
let num_id = d.numbering.add_list(ListKind::Numbered);
|
||||
let aid = d.numbering.num(num_id).expect("list num exists").abstract_id;
|
||||
let a = d.numbering.abstracts.iter_mut().find(|a| a.id == aid).expect("abstract num exists");
|
||||
a.levels[0].format = section::NumFormat::ChineseCounting;
|
||||
a.levels[0].text = "第%1章".into();
|
||||
a.levels[0].start = 12;
|
||||
let mut p = Paragraph::with_text("总结", CharProps::default());
|
||||
p.props.numbering = Some(props::NumRef { num: num_id, level: 0 });
|
||||
d.body = vec![Arc::new(Block::Para(p))];
|
||||
// The label 第十二章 stays one word (#4547 changed only document text); 总结 is two.
|
||||
assert_eq!(d.word_count(), 1 + 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sections_and_section_mut() {
|
||||
let mut d = Document::from_text("a\nb\nc");
|
||||
@@ -415,6 +448,25 @@ fn box_para(story: u32, id: u32) -> Blocks {
|
||||
vec![para_block(p)]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adopting_relinks_lists_in_deeply_nested_tables() {
|
||||
// As #1339: a list paragraph 20 tables down takes the list it is brought in with.
|
||||
let mut ours = Document::from_text("ours");
|
||||
let our_list = ours.numbering.add_list(numbering::ListKind::Bullet);
|
||||
let mut theirs = Document::from_text("theirs");
|
||||
let list = theirs.numbering.add_list(numbering::ListKind::Bullet);
|
||||
assert_eq!(list, our_list);
|
||||
let mut p = Paragraph::with_text("item", CharProps::default());
|
||||
p.props.numbering = Some(props::NumRef { num: list, level: 0 });
|
||||
theirs.body = nested_tables(20, vec![para_block(p)]);
|
||||
let mut nums = Vec::new();
|
||||
for b in &ours.adopt(theirs).blocks {
|
||||
edit::each_para(b, 0, &mut |p| nums.extend(p.props.numbering.map(|n| n.num)));
|
||||
}
|
||||
assert_eq!(nums.len(), 1);
|
||||
assert_ne!(nums[0], our_list);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_boxes_shown_from_deeply_nested_tables_are_kept() {
|
||||
// #1339: the paragraph walk stopped 16 tables down, so a box shown only from deeper was taken
|
||||
@@ -651,3 +703,66 @@ fn control_markers_kept_are_sorted() {
|
||||
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,190 @@
|
||||
use super::block::{Block, Blocks};
|
||||
use super::parts::PartKind;
|
||||
use super::position::Path;
|
||||
use super::{InlineObject, Numbering, numbering, para, section, styles};
|
||||
|
||||
/// Words in `s`, as Word counts them: tokens separated by whitespace that contain at least one
|
||||
/// alphanumeric char, plus each Han ideograph and kana as a word of its own (Chinese and Japanese
|
||||
/// are written without spaces, #4547; Korean is spaced, so Hangul counts by tokens).
|
||||
pub fn count_words(s: &str) -> usize {
|
||||
// The half-width voiced sound marks belong to the kana before them, not a word of their own.
|
||||
let word_char = |c: char| c.is_alphanumeric() && !matches!(c, '\u{FF9E}' | '\u{FF9F}');
|
||||
let tokens = s.split(|c: char| c.is_whitespace() || c == para::OBJ || is_cjk_word(c)).filter(|w| w.chars().any(word_char)).count();
|
||||
tokens.saturating_add(s.chars().filter(|c| is_cjk_word(*c)).count())
|
||||
}
|
||||
|
||||
/// Words in a generated list label: whitespace-separated tokens holding a letter or digit, so a
|
||||
/// CJK label such as 第十二章 stays one word (how Word counts those is unverified).
|
||||
fn count_label_words(s: &str) -> usize {
|
||||
s.split(char::is_whitespace).filter(|w| w.chars().any(char::is_alphanumeric)).count()
|
||||
}
|
||||
|
||||
/// A Han ideograph or kana letter (not CJK punctuation), which Word counts as one word.
|
||||
fn is_cjk_word(c: char) -> bool {
|
||||
matches!(c,
|
||||
'\u{3005}'..='\u{3007}' // 々 〆 〇
|
||||
| '\u{3031}'..='\u{3035}' | '\u{303B}' // vertical kana repeat marks, 〻
|
||||
| '\u{3040}'..='\u{30FF}' // hiragana, katakana
|
||||
| '\u{31F0}'..='\u{31FF}' // katakana phonetic extensions
|
||||
| '\u{3400}'..='\u{4DBF}' // extension A
|
||||
| '\u{4E00}'..='\u{9FFF}' // unified ideographs
|
||||
| '\u{F900}'..='\u{FAFF}' // compatibility ideographs
|
||||
| '\u{FF66}'..='\u{FF9D}' // half-width katakana
|
||||
| '\u{1B000}'..='\u{1B16F}' // kana supplement and extended
|
||||
| '\u{20000}'..='\u{3FFFF}' // extensions B and later
|
||||
) && c.is_alphanumeric()
|
||||
}
|
||||
|
||||
/// 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_label_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
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
//! A bytes-only check of a ZIP package's declared entry count before the `zip` crate opens it
|
||||
//! (#3660). Every reader of `.docx`, `.odt` and spreadsheet packages calls [`check`] first.
|
||||
//!
|
||||
//! `zip::ZipArchive::new` reserves room for, and parses, as many central directory headers as the
|
||||
//! end record declares before its caller can look at `len()`. A ZIP64 end record can declare
|
||||
//! millions of entries for a file of a few hundred megabytes (only 46 bytes each have to fit), and
|
||||
//! each parsed entry costs several times that in memory. Here the end records are found the way
|
||||
//! `zip` finds them (APPNOTE.TXT §4.3.14 to §4.3.16), and a package declaring more than the
|
||||
//! reader's limit is refused before `zip` parses anything. Anything else that is wrong is left for
|
||||
//! `zip` to report.
|
||||
|
||||
const EOCD: &[u8; 4] = b"PK\x05\x06";
|
||||
const EOCD64_LOCATOR: &[u8; 4] = b"PK\x06\x07";
|
||||
const EOCD64: &[u8; 4] = b"PK\x06\x06";
|
||||
const CENTRAL_HEADER: &[u8; 4] = b"PK\x01\x02";
|
||||
|
||||
/// End records looked at, at most. A package has one; a few more can turn up inside stored data.
|
||||
const MAX_END_RECORDS: usize = 16;
|
||||
|
||||
/// Refuse `bytes` when the end record `zip` would use declares more than `max_entries` entries.
|
||||
///
|
||||
/// `zip` searches the file backwards for an end of central directory record. A ZIP64 one points
|
||||
/// through its locator at the ZIP64 end record, which `zip` looks for from the locator's offset
|
||||
/// on: every such record there is checked, and as `zip` may fall back to an earlier end record
|
||||
/// when one doesn't parse, the search goes on. A plain one is used when it declares no entries, or
|
||||
/// a central directory header lies between its directory offset and itself: it is the last one
|
||||
/// looked at.
|
||||
pub fn check(bytes: &[u8], max_entries: u64) -> Result<(), String> {
|
||||
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]]));
|
||||
let u64_at = |at: usize| bytes.get(at..at.checked_add(8)?).and_then(|b| <[u8; 8]>::try_from(b).ok()).map(u64::from_le_bytes);
|
||||
let refuse = |n: u64| -> Result<(), String> {
|
||||
if n > max_entries { Err(format!("the package declares {n} entries, more than {max_entries}")) } else { Ok(()) }
|
||||
};
|
||||
let mut end = bytes.len();
|
||||
let mut seen = 0;
|
||||
while let Some(at) = bytes.get(..end).and_then(|b| b.windows(4).rposition(|w| w == EOCD)) {
|
||||
end = at;
|
||||
seen += 1;
|
||||
if seen > MAX_END_RECORDS {
|
||||
return Err(format!("more than {MAX_END_RECORDS} end of central directory records"));
|
||||
}
|
||||
// `zip` skips a record whose comment runs past the end of the file.
|
||||
let Some(comment) = u16_at(at + 20) else { continue };
|
||||
if at + 22 + usize::from(comment) > bytes.len() {
|
||||
continue;
|
||||
}
|
||||
let (Some(on_disk), Some(total), Some(cd_offset)) = (u16_at(at + 8), u16_at(at + 10), u32_at(at + 16)) else { continue };
|
||||
let zip64 = total == u16::MAX || cd_offset == u32::MAX;
|
||||
if zip64
|
||||
&& let Some(locator) = at.checked_sub(20)
|
||||
&& bytes.get(locator..locator + 4) == Some(EOCD64_LOCATOR.as_slice())
|
||||
{
|
||||
let (Some(eocd64), Some(disks)) = (u64_at(locator + 8), u32_at(locator + 16)) else { continue };
|
||||
let Some(from) = usize::try_from(eocd64).ok().filter(|&f| f < locator) else { continue };
|
||||
if disks > 1 {
|
||||
continue;
|
||||
}
|
||||
let records = bytes.get(from..locator).unwrap_or_default().windows(4).enumerate().filter(|(_, w)| *w == EOCD64);
|
||||
for (i, _) in records {
|
||||
let p = from + i;
|
||||
refuse(u64_at(p + 24).unwrap_or(0).max(u64_at(p + 32).unwrap_or(0)))?;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if total == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let cd = cd_offset as usize;
|
||||
if cd < at && bytes.get(cd..at).is_some_and(|b| b.windows(4).any(|w| w == CENTRAL_HEADER)) {
|
||||
return refuse(u64::from(on_disk.max(total)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A package of one empty stored file, `a`, whose central directory lists it `entries` times (at
|
||||
/// most as many as fit in 64 MiB) under a ZIP64 end record that declares them all: `zip` 3.0.0
|
||||
/// parses and keeps every one of them before its caller can count them. 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 zip64_entry_count_sample(entries: u64) -> Vec<u8> {
|
||||
// Local file header (APPNOTE.TXT §4.3.7): version 10, stored, no data, name "a".
|
||||
let mut f = b"PK\x03\x04".to_vec();
|
||||
f.extend_from_slice(&[10, 0, 0, 0, 0, 0, 0, 0, 0x21, 0]); // version, flags, method, time, date
|
||||
f.extend_from_slice(&[0; 12]); // crc, sizes
|
||||
f.extend_from_slice(&[1, 0, 0, 0]); // name and extra lengths
|
||||
f.push(b'a');
|
||||
let cd_at = f.len() as u64;
|
||||
// Its central header (§4.3.12), 47 bytes.
|
||||
let mut header = CENTRAL_HEADER.to_vec();
|
||||
header.extend_from_slice(&[10, 0, 10, 0, 0, 0, 0, 0, 0, 0, 0x21, 0]); // versions, flags, method, time, date
|
||||
header.extend_from_slice(&[0; 12]); // crc, sizes
|
||||
header.extend_from_slice(&[1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); // name, extra, comment, disk, attributes
|
||||
header.extend_from_slice(&[0; 4]); // local header offset
|
||||
header.push(b'a');
|
||||
let count = entries.min((64 << 20) / header.len() as u64);
|
||||
for _ in 0..count {
|
||||
f.extend_from_slice(&header);
|
||||
}
|
||||
let cd_len = f.len() as u64 - cd_at;
|
||||
let eocd64_at = f.len() as u64;
|
||||
// ZIP64 end of central directory record (§4.3.14), 56 bytes.
|
||||
f.extend_from_slice(EOCD64);
|
||||
f.extend_from_slice(&44u64.to_le_bytes()); // size of the rest of the record
|
||||
f.extend_from_slice(&[45, 0, 45, 0]); // versions
|
||||
f.extend_from_slice(&[0; 8]); // this disk, disk with the central directory
|
||||
f.extend_from_slice(&entries.to_le_bytes()); // entries on this disk
|
||||
f.extend_from_slice(&entries.to_le_bytes()); // entries
|
||||
f.extend_from_slice(&cd_len.to_le_bytes());
|
||||
f.extend_from_slice(&cd_at.to_le_bytes());
|
||||
// Its locator (§4.3.15), 20 bytes.
|
||||
f.extend_from_slice(EOCD64_LOCATOR);
|
||||
f.extend_from_slice(&0u32.to_le_bytes());
|
||||
f.extend_from_slice(&eocd64_at.to_le_bytes());
|
||||
f.extend_from_slice(&1u32.to_le_bytes());
|
||||
// End of central directory record (§4.3.16), 22 bytes, each count and offset "see ZIP64".
|
||||
f.extend_from_slice(EOCD);
|
||||
f.extend_from_slice(&[0; 4]);
|
||||
f.extend_from_slice(&[0xFF; 12]);
|
||||
f.extend_from_slice(&[0; 2]);
|
||||
f
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{CENTRAL_HEADER, EOCD, check, zip64_entry_count_sample};
|
||||
|
||||
/// A plain package's tail: one central header at 0, then an end record declaring `entries`.
|
||||
fn plain(entries: u16, comment: &[u8]) -> Vec<u8> {
|
||||
let mut f = CENTRAL_HEADER.to_vec();
|
||||
f.resize(46, 0);
|
||||
f.extend_from_slice(EOCD);
|
||||
f.extend_from_slice(&[0, 0, 0, 0]);
|
||||
f.extend_from_slice(&entries.to_le_bytes());
|
||||
f.extend_from_slice(&entries.to_le_bytes());
|
||||
f.extend_from_slice(&46u32.to_le_bytes());
|
||||
f.extend_from_slice(&0u32.to_le_bytes());
|
||||
f.extend_from_slice(&(comment.len() as u16).to_le_bytes());
|
||||
f.extend_from_slice(comment);
|
||||
f
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zip64_end_record_declaring_too_many_entries_is_refused() {
|
||||
// #3660: `zip` would reserve and parse them all before the reader's cap.
|
||||
let sample = zip64_entry_count_sample(20_001);
|
||||
assert!(check(&sample, 20_000).is_err_and(|e| e.contains("20001 entries")));
|
||||
assert!(check(&zip64_entry_count_sample(5), 20_000).is_ok());
|
||||
// Junk before the package (a self-extracting stub) doesn't hide it.
|
||||
let mut prefixed = vec![0x55u8; 4096];
|
||||
prefixed.extend_from_slice(&sample);
|
||||
assert!(check(&prefixed, 20_000).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plain_end_record_is_checked_as_zip_reads_it() {
|
||||
assert!(check(&plain(3, b""), 20_000).is_ok());
|
||||
assert!(check(&plain(30_000, b"a comment"), 20_000).is_err_and(|e| e.contains("30000 entries")));
|
||||
// A record whose comment runs past the end is not the one `zip` uses.
|
||||
let mut cut = plain(30_000, b"");
|
||||
cut.truncate(cut.len() - 2);
|
||||
cut.extend_from_slice(&500u16.to_le_bytes());
|
||||
assert!(check(&cut, 20_000).is_ok());
|
||||
assert!(check(b"not a package", 20_000).is_ok());
|
||||
assert!(check(b"", 20_000).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fake_end_record_after_the_real_one_does_not_hide_it() {
|
||||
// `zip` skips a last record with no central header before it and uses the one before.
|
||||
let mut f = zip64_entry_count_sample(1_000_000);
|
||||
f.extend_from_slice(EOCD);
|
||||
f.extend_from_slice(&[0, 0, 0, 0]);
|
||||
f.extend_from_slice(&7u16.to_le_bytes());
|
||||
f.extend_from_slice(&7u16.to_le_bytes());
|
||||
f.extend_from_slice(&0u32.to_le_bytes());
|
||||
f.extend_from_slice(&((f.len() - 12) as u32).to_le_bytes());
|
||||
f.extend_from_slice(&0u16.to_le_bytes());
|
||||
assert!(check(&f, 20_000).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_end_records_are_refused() {
|
||||
// Each declares one entry and has no central header before it, so `zip` would try them all.
|
||||
let record = [0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||
let f: Vec<u8> = (0..20).flat_map(|_| EOCD.iter().copied().chain(record)).collect();
|
||||
assert!(check(&f, 20_000).is_err_and(|e| e.contains("end of central directory records")));
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,9 @@ const C_ICO: u16 = 0x2A42; // palette colour
|
||||
const C_CR: u16 = 0x6870; // explicit COLORREF
|
||||
const C_HPS: u16 = 0x4A43; // half-points
|
||||
const C_HPS_POS: u16 = 0x4845; // super/subscript by half-points
|
||||
const C_FTC0: u16 = 0x4A4F; // font index
|
||||
const C_FTC0: u16 = 0x4A4F; // font index (ASCII)
|
||||
const C_FTC1: u16 = 0x4A50; // East Asian font index (sprmCRgFtc1)
|
||||
const C_FTC_BI: u16 = 0x4A5E; // complex-script font index (sprmCFtcBi)
|
||||
const C_HIGHLIGHT: u16 = 0x2A0C;
|
||||
const C_ISTD: u16 = 0x4A30; // character style
|
||||
|
||||
@@ -254,6 +256,16 @@ pub(crate) fn apply_char(p: &mut CharProps, prl: &Prl, base: &CharProps, fonts:
|
||||
p.font = Some(name.clone());
|
||||
}
|
||||
}
|
||||
C_FTC1 => {
|
||||
if let Some(name) = u16_of(prl).and_then(|i| fonts.get(i as usize)) {
|
||||
p.font_ea = Some(name.clone());
|
||||
}
|
||||
}
|
||||
C_FTC_BI => {
|
||||
if let Some(name) = u16_of(prl).and_then(|i| fonts.get(i as usize)) {
|
||||
p.font_cs = Some(name.clone());
|
||||
}
|
||||
}
|
||||
C_HIGHLIGHT => {
|
||||
if let Some(h) = HL.get(prl.operand.first().copied().unwrap_or(0) as usize) {
|
||||
p.highlight = Some(*h);
|
||||
@@ -417,10 +429,15 @@ pub(crate) fn apply_para(p: &mut ParaProps, prl: &Prl) {
|
||||
|
||||
/// Build the model stylesheet: resolve each raw style's chain (base → this) and the ids we
|
||||
/// reference from paragraphs.
|
||||
pub(crate) fn stylesheet(raw: &[crate::stsh::RawStyle], fonts: &[String]) -> StyleSheet {
|
||||
pub(crate) fn stylesheet(raw: &[crate::stsh::RawStyle], fonts: &[String], standard: crate::stsh::StandardFonts) -> StyleSheet {
|
||||
let mut sheet = StyleSheet::empty();
|
||||
// Word's base character properties: font index 0 of the table, 10 pt ([MS-DOC] sprmCHps).
|
||||
sheet.default_chr.font = fonts.first().cloned();
|
||||
// Word's base character properties: the stylesheet's standard fonts (Aptos in a Word 365
|
||||
// file, whose font table still starts with Times New Roman, #5066), else font index 0 of the
|
||||
// table; 10 pt ([MS-DOC] sprmCHps).
|
||||
let font = |i: Option<u16>| i.and_then(|i| fonts.get(usize::from(i))).cloned();
|
||||
sheet.default_chr.font = font(standard.ascii).or_else(|| fonts.first().cloned());
|
||||
sheet.default_chr.font_ea = font(standard.east_asian);
|
||||
sheet.default_chr.font_cs = font(standard.bidi);
|
||||
if sheet.default_chr.size.is_none() {
|
||||
sheet.default_chr.size = Some(10.0);
|
||||
}
|
||||
|
||||
@@ -233,6 +233,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
|
||||
Some((f, l)) => stsh::parse(&table, f, l)?,
|
||||
None => Vec::new(),
|
||||
};
|
||||
let standard_fonts = fib.pair(fib::pair::STSHF).map(|(f, l)| stsh::standard_fonts(&table, f, l)).unwrap_or_default();
|
||||
let chpx_bins = match fib.pair(fib::pair::PLCF_BTE_CHPX) {
|
||||
Some((f, l)) => Bins::parse(&table, f, l)?,
|
||||
None => Bins::parse(&[], 0, 0)?,
|
||||
@@ -247,7 +248,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
|
||||
doc.core = core;
|
||||
}
|
||||
doc.body.clear();
|
||||
doc.styles = fmt::stylesheet(&raw_styles, &fonts);
|
||||
doc.styles = fmt::stylesheet(&raw_styles, &fonts, standard_fonts);
|
||||
doc.numbering = list::parse(&table, &fib, &fonts);
|
||||
let sheet: StyleSheet = doc.styles.clone();
|
||||
let sects = sections::parse(&word, &table, &fib);
|
||||
@@ -259,7 +260,7 @@ 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
|
||||
@@ -363,6 +364,11 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
|
||||
// Sections: each section's properties attach to the paragraph that ends it (its story of
|
||||
// header/footer parts included); the section reaching the end of the text is the final one.
|
||||
let mut paras = walk(&ctx, 0, fib.ccp.text, &mut pics);
|
||||
// The first paragraph ending at each CP, found once rather than by a scan per section.
|
||||
let mut ending: std::collections::HashMap<u32, usize> = std::collections::HashMap::new();
|
||||
for (i, p) in paras.iter().enumerate() {
|
||||
ending.entry(p.end_cp).or_insert(i);
|
||||
}
|
||||
let last = sects.last().map(|s| s.props.clone());
|
||||
for (si, sec) in sects.iter().enumerate() {
|
||||
let mut props = sec.props.clone();
|
||||
@@ -373,11 +379,8 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
|
||||
if is_final {
|
||||
continue; // handled below through `last`
|
||||
}
|
||||
for p in paras.iter_mut() {
|
||||
if p.end_cp == sec.end_cp {
|
||||
p.para.section = Some(Box::new(props));
|
||||
break;
|
||||
}
|
||||
if let Some(p) = ending.get(&sec.end_cp).and_then(|&i| paras.get_mut(i)) {
|
||||
p.para.section = Some(Box::new(props));
|
||||
}
|
||||
}
|
||||
if let Some(mut l) = last {
|
||||
|
||||
+39
-20
@@ -21,6 +21,20 @@ pub(crate) const MAX_MEDIA_BYTES: usize = 256 << 20;
|
||||
/// 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
|
||||
@@ -32,16 +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>>,
|
||||
/// (foDelay, end) of a BLIP record outside the store → media key: store entries that alias
|
||||
/// one record share one decode (`None`: failed, not retried).
|
||||
by_delay: HashMap<(usize, usize), 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: usize,
|
||||
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).
|
||||
@@ -57,7 +72,7 @@ impl Default for Pictures {
|
||||
media: BTreeMap::new(),
|
||||
total: 0,
|
||||
limit: MAX_MEDIA_BYTES,
|
||||
work: MAX_DECODE_WORK,
|
||||
work: officeart::Decode::new(MAX_DECODE_WORK),
|
||||
dropped: 0,
|
||||
placeholders: 0,
|
||||
}
|
||||
@@ -75,6 +90,12 @@ 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.
|
||||
@@ -96,16 +117,13 @@ impl Pictures {
|
||||
if room == 0 {
|
||||
return None;
|
||||
}
|
||||
match locate(data, fc_pic, room, &mut self.work)? {
|
||||
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 }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +145,7 @@ impl Pictures {
|
||||
/// 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();
|
||||
}
|
||||
@@ -174,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
|
||||
@@ -183,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, work: &mut 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;
|
||||
@@ -203,12 +222,12 @@ fn locate(data: &[u8], fc_pic: u32, room: usize, work: &mut usize) -> Option<Fou
|
||||
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, work)?;
|
||||
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);
|
||||
@@ -220,16 +239,16 @@ fn locate(data: &[u8], fc_pic: u32, room: usize, work: &mut usize) -> Option<Fou
|
||||
}
|
||||
|
||||
/// 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, work: &mut 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, work)?;
|
||||
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,16 +112,50 @@ 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, work: &mut 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`, end) location is returned too (store entries can share one record).
|
||||
/// 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)?;
|
||||
@@ -129,15 +163,14 @@ pub(crate) fn fbse_record<'a>(fbse: &'a [u8], delay: &'a [u8]) -> Option<(Rec<'a
|
||||
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)?)?;
|
||||
Some((rec, Some((fo_delay, end))))
|
||||
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). `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 usize) -> Option<Blip> {
|
||||
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 {
|
||||
@@ -163,11 +196,12 @@ pub(crate) fn blip(rec: &Rec, limit: usize, work: &mut usize) -> Option<Blip> {
|
||||
let raw = raw.get(..cb_save.min(raw.len()))?;
|
||||
let cap = limit.min(MAX_METAFILE);
|
||||
let bytes = if compression == 0 {
|
||||
let want = (if cb_size > 0 { cb_size.min(cap) } else { cap }).min(*work);
|
||||
if want == 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;
|
||||
}
|
||||
*work -= want;
|
||||
miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(raw, want).ok()?
|
||||
} else {
|
||||
if raw.len() > cap {
|
||||
@@ -192,7 +226,7 @@ pub(crate) fn blip(rec: &Rec, limit: usize, work: &mut usize) -> Option<Blip> {
|
||||
/// the 14-byte BMP file header it lacks, is decoded with size limits and re-encoded. When the
|
||||
/// 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 usize) -> Option<Blip> {
|
||||
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;
|
||||
@@ -211,6 +245,20 @@ fn dib_to_png(dib: &[u8], limit: usize, work: &mut 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());
|
||||
@@ -218,16 +266,9 @@ fn dib_to_png(dib: &[u8], limit: usize, work: &mut usize) -> Option<Blip> {
|
||||
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 });
|
||||
// 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 Some(left) = w.saturating_mul(h).saturating_mul(4).try_into().ok().and_then(|cost: usize| work.checked_sub(cost)) else {
|
||||
if !decode {
|
||||
return placeholder(bmp);
|
||||
};
|
||||
*work = left;
|
||||
}
|
||||
let mut reader = image::ImageReader::new(std::io::Cursor::new(&bmp));
|
||||
reader.set_format(image::ImageFormat::Bmp);
|
||||
let mut limits = image::Limits::default();
|
||||
@@ -336,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),
|
||||
|
||||
@@ -20,6 +20,41 @@ pub(crate) struct RawStyle {
|
||||
pub(crate) chpx: Vec<u8>,
|
||||
}
|
||||
|
||||
/// The stylesheet's standard fonts, the ones text takes when neither its style nor its own
|
||||
/// formatting names a font: font-table indexes of the ASCII, East Asian and other
|
||||
/// (non-East-Asian, non-ASCII) fonts (`Stshif.rgftcStandardChpStsh`, [MS-DOC] §2.9.273) and the
|
||||
/// complex-script font (`Stshi.ftcBi`, §2.9.272). `None` where the header is too short for one.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) struct StandardFonts {
|
||||
pub(crate) ascii: Option<u16>,
|
||||
pub(crate) east_asian: Option<u16>,
|
||||
pub(crate) other: Option<u16>,
|
||||
pub(crate) bidi: Option<u16>,
|
||||
}
|
||||
|
||||
/// `StshiLsd.cbLSD`: the size of a latent style's `LSD` ([MS-DOC] `StshiLsd`).
|
||||
const LSD_SIZE: u16 = 4;
|
||||
|
||||
/// The [`StandardFonts`] in the header (`Stshi`) of the `STSH` at `table[fc..fc+lcb]`: Word 365
|
||||
/// writes Aptos there while the font table's first entry is still Times New Roman (#5066).
|
||||
pub(crate) fn standard_fonts(table: &[u8], fc: u32, lcb: u32) -> StandardFonts {
|
||||
let Some(stsh) = (fc as usize).checked_add(lcb as usize).and_then(|end| table.get(fc as usize..end)) else { return StandardFonts::default() };
|
||||
let cb_stshi = stsh.get(0..2).map_or(0, |b| u16::from_le_bytes([b[0], b[1]]) as usize);
|
||||
let stshi = stsh.get(2..).map_or(&[][..], |s| s.get(..cb_stshi.min(s.len())).unwrap_or(&[]));
|
||||
// Stshif: cstd, cbSTDBaseInFile, flags, stiMaxWhenSaved, istdMaxFixedWhenSaved,
|
||||
// nVerBuiltInNamesWhenSaved (2 bytes each), then rgftcStandardChpStsh (3 × 2); ftcBi follows.
|
||||
let at = |o: usize| stshi.get(o..o + 2).map(|b| u16::from_le_bytes([b[0], b[1]]));
|
||||
// ftcBi is optional, and the latent style data (`StshiLsd`, whose `cbLSD` MUST be 4) follows
|
||||
// it: a 4 right after it says it is there, a 4 in its place with more data after says it was
|
||||
// left out. A header that ends right after the field (as Word 97's) has it, a 4 included.
|
||||
let bidi = match (at(18), at(20)) {
|
||||
(b, Some(LSD_SIZE) | None) => b,
|
||||
(Some(LSD_SIZE), Some(_)) => None,
|
||||
(b, _) => b,
|
||||
};
|
||||
StandardFonts { ascii: at(12), east_asian: at(14), other: at(16), bidi }
|
||||
}
|
||||
|
||||
/// Parse the `STSH` at `table[fc..fc+lcb]`.
|
||||
pub(crate) fn parse(table: &[u8], fc: u32, lcb: u32) -> Result<Vec<RawStyle>, DocbinError> {
|
||||
let fc = fc as usize;
|
||||
|
||||
@@ -101,6 +101,12 @@ impl RowInfo {
|
||||
self.widths.iter().rfind(|(k, _)| *k == i).map(|(_, w)| *w)
|
||||
}
|
||||
|
||||
/// How many per-cell overrides the row keeps, for tests.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn overrides(&self) -> usize {
|
||||
self.vmerge.len() + self.shading.len() + self.widths.len() + self.width_pcts.len() + self.valign.len() + self.merges.len()
|
||||
}
|
||||
|
||||
/// The width percentage cell `i` ends up with, for tests.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn width_pct_of(&self, i: u8) -> Option<f32> {
|
||||
@@ -108,6 +114,15 @@ impl RowInfo {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set cell `c`'s override to `v`, replacing an earlier one: one slot per cell, however often
|
||||
/// a PAPX repeats the sprm, so a shared PAPX can't pile up copies on every paragraph mark.
|
||||
fn set<T>(slots: &mut Vec<(u8, T)>, c: u8, v: T) {
|
||||
match slots.iter_mut().find(|(k, _)| *k == c) {
|
||||
Some(slot) => slot.1 = v,
|
||||
None => slots.push((c, v)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn decode(papx: &[u8]) -> RowInfo {
|
||||
let mut r = RowInfo::default();
|
||||
let mut gap_half: Option<i16> = None;
|
||||
@@ -144,8 +159,10 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo {
|
||||
T_DXA_GAP_HALF => gap_half = s16(&prl),
|
||||
T_TABLE_BORDERS_80 => r.borders = table_borders(prl.operand),
|
||||
T_MERGE => {
|
||||
// Rows apply only the first MAX_COLS merges.
|
||||
if let [a, b, ..] = prl.operand
|
||||
&& b > a
|
||||
&& r.merges.len() < MAX_COLS
|
||||
{
|
||||
r.merges.push((*a, *b));
|
||||
}
|
||||
@@ -158,7 +175,8 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo {
|
||||
1 => VMerge::Continue,
|
||||
_ => VMerge::None,
|
||||
};
|
||||
if m != VMerge::None {
|
||||
// The first override for a cell wins.
|
||||
if m != VMerge::None && !r.vmerge.iter().any(|(k, _)| k == itc) {
|
||||
r.vmerge.push((*itc, m));
|
||||
}
|
||||
}
|
||||
@@ -168,7 +186,7 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo {
|
||||
&& let [a, b, ..] = prl.operand.get(1..3).unwrap_or(&[])
|
||||
{
|
||||
for c in *a..(*b).min(MAX_COLS as u8) {
|
||||
r.shading.push((c, Some(rgb)));
|
||||
set(&mut r.shading, c, Some(rgb));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -176,7 +194,7 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo {
|
||||
let cells = prl.operand.get(1..).unwrap_or(&[]);
|
||||
for (i, b) in cells.as_chunks::<2>().0.iter().take(MAX_COLS).enumerate() {
|
||||
if let Some(rgb) = shd80_rgb(b) {
|
||||
r.shading.push((i as u8, Some(rgb)));
|
||||
set(&mut r.shading, i as u8, Some(rgb));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -196,7 +214,7 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo {
|
||||
if raw && b.get(8..10) == Some(&[0xFF, 0xFF]) {
|
||||
continue;
|
||||
}
|
||||
r.shading.push(((first + i) as u8, shd_rgb(b)));
|
||||
set(&mut r.shading, (first + i) as u8, shd_rgb(b));
|
||||
}
|
||||
}
|
||||
T_CELL_WIDTH => {
|
||||
@@ -207,12 +225,12 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo {
|
||||
if *fts == 3 {
|
||||
let w = (i16::from_le_bytes([*w0, *w1]) as f32 / 20.0).clamp(0.0, 1584.0);
|
||||
for c in *a..(*b).min(MAX_COLS as u8) {
|
||||
r.widths.push((c, w));
|
||||
set(&mut r.widths, c, w);
|
||||
}
|
||||
} else if *fts == 2 {
|
||||
let pct = (u16::from_le_bytes([*w0, *w1]) as f32 / 50.0).clamp(0.0, 100.0);
|
||||
for c in *a..(*b).min(MAX_COLS as u8) {
|
||||
r.width_pcts.push((c, pct));
|
||||
set(&mut r.width_pcts, c, pct);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -224,8 +242,8 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo {
|
||||
2 => VAlign::Bottom,
|
||||
_ => VAlign::Top,
|
||||
};
|
||||
for c in *a..*b {
|
||||
r.valign.push((c, v));
|
||||
for c in *a..(*b).min(MAX_COLS as u8) {
|
||||
set(&mut r.valign, c, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+118
-2
@@ -421,7 +421,17 @@ fn std_bytes_next(stk: u8, istd_base: u16, istd_next: u16, name: &str, papx: &[u
|
||||
|
||||
/// An STSH with the given styles.
|
||||
fn stsh_bytes(styles: &[Vec<u8>]) -> Vec<u8> {
|
||||
let stshi = [(styles.len() as u16).to_le_bytes(), 10u16.to_le_bytes()].concat(); // cstd, cbSTDBaseInFile
|
||||
stsh_bytes_with(styles, None)
|
||||
}
|
||||
|
||||
/// An STSH with the given styles; with `standard`, a full Stshif and ftcBi giving the standard
|
||||
/// fonts' indexes (ASCII, East Asian, other, complex script), as Word writes them.
|
||||
fn stsh_bytes_with(styles: &[Vec<u8>], standard: Option<[u16; 4]>) -> Vec<u8> {
|
||||
let mut stshi = [(styles.len() as u16).to_le_bytes(), 10u16.to_le_bytes()].concat(); // cstd, cbSTDBaseInFile
|
||||
if let Some(fonts) = standard {
|
||||
stshi.extend_from_slice(&[0u8; 8]); // flags, stiMaxWhenSaved, istdMaxFixedWhenSaved, nVerBuiltInNamesWhenSaved
|
||||
stshi.extend(fonts.iter().flat_map(|f| f.to_le_bytes()));
|
||||
}
|
||||
let mut v = (stshi.len() as u16).to_le_bytes().to_vec();
|
||||
v.extend_from_slice(&stshi);
|
||||
for s in styles {
|
||||
@@ -532,6 +542,8 @@ struct RichExtra {
|
||||
prm: u16,
|
||||
alt_fonts: Vec<Option<&'static str>>,
|
||||
style_next: Vec<u16>,
|
||||
/// The stylesheet's standard fonts (ASCII, East Asian, other, complex script).
|
||||
standard_fonts: Option<[u16; 4]>,
|
||||
}
|
||||
|
||||
fn rich_doc(spec: &RichSpec) -> Vec<u8> {
|
||||
@@ -571,7 +583,7 @@ fn rich_doc_with(spec: &RichSpec, extra: &RichExtra) -> Vec<u8> {
|
||||
.enumerate()
|
||||
.map(|(i, s)| std_bytes_next(s.0, s.1, extra.style_next.get(i).copied().unwrap_or(0x0FFF), s.2, &s.3, &s.4))
|
||||
.collect();
|
||||
let stsh = stsh_bytes(&styles);
|
||||
let stsh = stsh_bytes_with(&styles, extra.standard_fonts);
|
||||
let fonts: Vec<(&str, Option<&str>)> = spec.fonts.iter().enumerate().map(|(i, n)| (*n, extra.alt_fonts.get(i).copied().flatten())).collect();
|
||||
let ffn = ffn_bytes_alt(&fonts);
|
||||
let clx_at = 0x40;
|
||||
@@ -654,6 +666,67 @@ fn font_and_size_from_style_and_direct() {
|
||||
assert!(p.runs.first().is_some_and(|r| r.props.font.as_deref() == Some("Arial")), "run font: {:?}", p.runs);
|
||||
}
|
||||
|
||||
/// #5066: the default font is the stylesheet's standard font (Aptos in a Word 365 file), not
|
||||
/// the font table's first entry (still Times New Roman there); the East Asian and complex-script
|
||||
/// standard fonts are the defaults for those scripts, and runs name theirs with sprmCRgFtc1 and
|
||||
/// sprmCFtcBi.
|
||||
#[test]
|
||||
fn the_default_font_is_the_stylesheets_standard_font() {
|
||||
let spec = |runs: Vec<(u32, Vec<u8>)>| RichSpec {
|
||||
text: "Hello\r",
|
||||
styles: vec![(1, 0x0FFF, "Normal", Vec::new(), grpprl(&[(0x4A43, &24u16.to_le_bytes())]))],
|
||||
fonts: vec!["Times New Roman", "Symbol", "Arial", "Aptos", "MS Mincho", "Arial Unicode"],
|
||||
chpx_runs: runs,
|
||||
papx_entries: vec![(0, 0, Vec::new())],
|
||||
};
|
||||
let with = |standard: Option<[u16; 4]>, runs| {
|
||||
read(&rich_doc_with(&spec(runs), &RichExtra { standard_fonts: standard, ..Default::default() })).expect("opens")
|
||||
};
|
||||
let doc = with(Some([3, 4, 3, 2]), vec![(0, Vec::new())]);
|
||||
let d = &doc.styles.default_chr;
|
||||
assert_eq!((d.font.as_deref(), d.font_ea.as_deref(), d.font_cs.as_deref()), (Some("Aptos"), Some("MS Mincho"), Some("Arial")));
|
||||
let p = para0(&doc);
|
||||
let resolved = doc.styles.resolve_char(p.props.style.as_deref(), &p.runs[0].props);
|
||||
assert_eq!(resolved.font, "Aptos", "Normal names no font, so the text takes the standard one");
|
||||
// A run's own East Asian and complex-script fonts.
|
||||
let doc = with(Some([3, 4, 3, 2]), vec![(0, grpprl(&[(0x4A50, &5u16.to_le_bytes()), (0x4A5E, &3u16.to_le_bytes())]))]);
|
||||
let run = ¶0(&doc).runs[0].props;
|
||||
assert_eq!((run.font_ea.as_deref(), run.font_cs.as_deref()), (Some("Arial Unicode"), Some("Aptos")));
|
||||
// Indexes past the font table, or a header too short to hold them, keep entry 0.
|
||||
for standard in [Some([99, 99, 99, 99]), None] {
|
||||
let doc = with(standard, vec![(0, Vec::new())]);
|
||||
let d = &doc.styles.default_chr;
|
||||
assert_eq!((d.font.as_deref(), d.font_ea.as_deref(), d.font_cs.as_deref()), (Some("Times New Roman"), None, None), "{standard:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #5066: `ftcBi` is optional in the stylesheet header, and the latent style data (`cbLSD`, 4,
|
||||
/// then the LSDs) follows it: a header that leaves it out doesn't make `cbLSD` the complex-script
|
||||
/// font.
|
||||
#[test]
|
||||
fn the_complex_script_standard_font_is_read_only_when_present() {
|
||||
let header = |tail: &[u16]| -> Vec<u8> {
|
||||
// cstd, cbSTDBaseInFile, flags, stiMaxWhenSaved, istdMaxFixedWhenSaved,
|
||||
// nVerBuiltInNamesWhenSaved; ASCII, East Asian, other.
|
||||
let words = [&[0u16, 10, 0, 1, 0, 0, 3, 4, 3][..], tail].concat();
|
||||
let stshi: Vec<u8> = words.iter().flat_map(|w| w.to_le_bytes()).collect();
|
||||
[(stshi.len() as u16).to_le_bytes().to_vec(), stshi].concat()
|
||||
};
|
||||
let bidi = |tail: &[u16]| {
|
||||
let b = header(tail);
|
||||
crate::stsh::standard_fonts(&b, 0, b.len() as u32).bidi
|
||||
};
|
||||
assert_eq!(bidi(&[2]), Some(2), "a header that ends with it (Word 97)");
|
||||
assert_eq!(bidi(&[4]), Some(4), "font 4, at the end of the header");
|
||||
assert_eq!(bidi(&[2, 4, 0, 0]), Some(2), "followed by the latent style data");
|
||||
assert_eq!(bidi(&[4, 4, 0, 0]), Some(4), "font 4, followed by the latent style data");
|
||||
assert_eq!(bidi(&[4, 0, 0]), None, "left out: cbLSD is in its place");
|
||||
assert_eq!(bidi(&[]), None, "a header too short for it");
|
||||
let b = header(&[4, 0, 0]);
|
||||
let fonts = crate::stsh::standard_fonts(&b, 0, b.len() as u32);
|
||||
assert_eq!((fonts.ascii, fonts.east_asian, fonts.other), (Some(3), Some(4), Some(3)), "the other fonts are read all the same");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_props_and_heading_style() {
|
||||
let spec = RichSpec {
|
||||
@@ -945,6 +1018,32 @@ fn two_sections_box_the_first() {
|
||||
assert!(!doc.last_section.title_page);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sections_ending_off_a_paragraph_mark_attach_nowhere() {
|
||||
// Hostile section ends that miss every paragraph mark are skipped; the one that ends a
|
||||
// paragraph still boxes it. Paragraphs are found by their end, not scanned per section.
|
||||
let mut sections: Vec<(u32, Vec<u8>)> = (1..6).map(|cp| (cp, Vec::new())).collect();
|
||||
sections.push((6, grpprl(&[(0x300A, &[0x01])])));
|
||||
sections.push((12, Vec::new()));
|
||||
let spec = StructSpec {
|
||||
text: "first\rsecond\r",
|
||||
header_stories: vec![],
|
||||
sections,
|
||||
chpx_runs: vec![(0, Vec::new())],
|
||||
papx_entries: vec![(0, 0, Vec::new()), (6, 0, Vec::new())],
|
||||
plf_lst: Vec::new(),
|
||||
plf_lfo: Vec::new(),
|
||||
note_stories: vec![],
|
||||
endnote_stories: vec![],
|
||||
extra_pairs: Vec::new(),
|
||||
data_stream: Vec::new(),
|
||||
};
|
||||
let doc = read(&struct_doc(&spec)).expect("opens");
|
||||
let paras: Vec<_> = doc.body.iter().filter_map(|b| b.as_para()).collect();
|
||||
assert!(paras.first().and_then(|p| p.section.as_ref()).expect("first paragraph boxed").title_page);
|
||||
assert_eq!(paras.iter().filter(|p| p.section.is_some()).count(), 1, "the missing ends attach nowhere");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_story_becomes_part() {
|
||||
let spec = StructSpec {
|
||||
@@ -2302,6 +2401,23 @@ fn second_table_gets_its_own_grid() {
|
||||
assert_eq!(tables[1].props.indent, None, "indent reset");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_cell_sprms_keep_one_override_per_cell() {
|
||||
// A PAPX repeating cell-range sprms (shared by every paragraph mark that names it) keeps one
|
||||
// slot per cell, the last value winning, instead of a copy per repetition.
|
||||
let mut sprms = Vec::new();
|
||||
for w in 0..200u16 {
|
||||
let [w0, w1] = (w * 20).to_le_bytes();
|
||||
sprms.push(sprm_cb(0xD635, &[0, 63, 3, w0, w1]));
|
||||
sprms.push(sprm_cb(0xD62C, &[0, 255, 1]));
|
||||
sprms.push([0x24, 0x56, 0, 2].to_vec());
|
||||
}
|
||||
let row = crate::table::decode(&sprms.concat());
|
||||
assert_eq!(row.width_of(0), Some(199.0), "the last width wins");
|
||||
assert_eq!(row.width_of(62), Some(199.0));
|
||||
assert!(row.overrides() <= 63 + 255 + 63, "{}", row.overrides());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sprm_t_cell_width_twips_and_percentage() {
|
||||
// [MS-DOC] §2.9.290 sprmTCellWidth (0xD635):
|
||||
|
||||
@@ -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, &mut { usize::MAX }).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, &mut { usize::MAX }).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, &mut { usize::MAX }).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, &mut { usize::MAX }).is_none());
|
||||
assert!(officeart::fbse_blip(&fbse, &[0u8; 64], 1 << 20, &mut officeart::Decode::new(usize::MAX)).is_none());
|
||||
}
|
||||
|
||||
proptest! {
|
||||
@@ -322,12 +322,12 @@ 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, &mut { usize::MAX }).is_none(), "the BMP header also needs room");
|
||||
let fallback = officeart::blip(&record, bmp_size, &mut { usize::MAX }).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, &mut { usize::MAX }).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);
|
||||
@@ -434,10 +434,10 @@ fn store_entries_aliasing_one_delayed_metafile_decode_it_once() {
|
||||
let fbse = delayed_fbse(delay.len(), 0);
|
||||
let store: Vec<&[u8]> = vec![&fbse; 5_000];
|
||||
let mut pics = crate::media::Pictures::default();
|
||||
pics.work = 8 << 20;
|
||||
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, (8 << 20) - wmf.len(), "inflated once");
|
||||
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
|
||||
@@ -456,11 +456,11 @@ fn failing_metafile_inflations_spend_the_decode_budget() {
|
||||
.collect();
|
||||
let store: Vec<&[u8]> = fbses.iter().map(Vec::as_slice).collect();
|
||||
let mut pics = crate::media::Pictures::default();
|
||||
pics.work = 3 << 20;
|
||||
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, 0, "three attempts spent the budget; the rest weren't tried");
|
||||
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
|
||||
@@ -470,12 +470,13 @@ 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 = 3;
|
||||
assert_eq!(officeart::blip(&one, 512, &mut work).map(|b| (b.ext, b.placeholder)), Some(("bmp", true)));
|
||||
assert_eq!(work, 3, "nothing decoded, nothing charged");
|
||||
let mut work = 4;
|
||||
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, 0);
|
||||
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<_>>();
|
||||
@@ -489,7 +490,128 @@ fn dib_decoding_is_charged_its_declared_pixels() {
|
||||
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 = 512 << 20;
|
||||
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, 512 << 20);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
+1
-376
@@ -984,379 +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());
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -76,7 +76,7 @@ pub fn sniff_xml(bytes: &[u8]) -> XmlKind {
|
||||
|
||||
/// Read a Flat OPC file.
|
||||
pub fn read(bytes: &[u8]) -> Result<Document, DocxError> {
|
||||
crate::read::read_opened(&unpack(bytes)?)
|
||||
crate::read::read_opened(&unpack(bytes)?, None)
|
||||
}
|
||||
|
||||
/// The data element of the part being read.
|
||||
|
||||
@@ -8,7 +8,7 @@ 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;
|
||||
@@ -41,11 +41,15 @@ pub fn embedded_fonts(doc: &Document) -> Vec<EmbeddedFont> {
|
||||
/// [`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 Some(table) = doc.passthrough.get(FONT_TABLE_PART).and_then(|b| crate::xml::parse(b).ok()) else {
|
||||
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();
|
||||
@@ -118,6 +122,7 @@ 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)`).
|
||||
|
||||
+23
-8
@@ -32,7 +32,7 @@ pub use crypt::{
|
||||
};
|
||||
pub use flat::{XmlKind, sniff_xml, write as write_flat};
|
||||
pub use fonts::{EmbeddedFont, embedded_fonts};
|
||||
pub use read::read;
|
||||
pub use read::{AltChunk, ChunkImporter, ReadOptions, read};
|
||||
pub use smart_art::smart_art_items;
|
||||
pub use wordml2003_write::write as write_wordml2003;
|
||||
pub use write::{write, write_as};
|
||||
@@ -56,11 +56,19 @@ pub fn read_xml(bytes: &[u8]) -> Result<wordcraft_doc::Document, DocxError> {
|
||||
|
||||
/// The title (`dc:title`) in a package's core properties part (`docProps/core.xml`, ECMA-376
|
||||
/// Part 2 §11), trimmed; `None` when the part can't be read or has no title. Only the package's
|
||||
/// directory and at most `max_part` bytes of that part are read: for listing templates by name
|
||||
/// without reading the whole package.
|
||||
pub fn package_title<R: std::io::Read + std::io::Seek>(package: R, max_part: u64) -> Option<String> {
|
||||
/// directory and at most `max_part` bytes of that part are inflated: for listing templates by
|
||||
/// name without unpacking the whole package. A package over 64 MiB, or one declaring more entries
|
||||
/// than `MAX_ENTRIES` (20,000), has none (#3660).
|
||||
pub fn package_title<R: std::io::Read>(package: R, max_part: u64) -> Option<String> {
|
||||
use std::io::Read;
|
||||
let mut zip = zip::ZipArchive::new(package).ok()?;
|
||||
const MAX_PACKAGE: u64 = 64 << 20;
|
||||
let mut bytes = Vec::new();
|
||||
package.take(MAX_PACKAGE + 1).read_to_end(&mut bytes).ok()?;
|
||||
if bytes.len() as u64 > MAX_PACKAGE {
|
||||
return None;
|
||||
}
|
||||
wordcraft_doc::zip_guard::check(&bytes, package::MAX_ENTRIES as u64).ok()?;
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).ok()?;
|
||||
let part = zip.by_name("docProps/core.xml").ok()?;
|
||||
let mut xml = Vec::new();
|
||||
part.take(max_part).read_to_end(&mut xml).ok()?;
|
||||
@@ -84,11 +92,18 @@ pub(crate) const CANVAS_SPIN_EXT: &str = "urn:wordcraft:canvas-xfrm";
|
||||
/// `password` first. An encrypted package without a password gives
|
||||
/// [`DocxError::PasswordRequired`]; a wrong one, [`DocxError::WrongPassword`].
|
||||
pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<wordcraft_doc::Document, DocxError> {
|
||||
read_with(bytes, &ReadOptions { password, ..Default::default() })
|
||||
}
|
||||
|
||||
/// Read a package with options: the password of an encrypted one ([`read_with_password`]) and
|
||||
/// an importer for `w:altChunk` content in formats other than WordprocessingML (see
|
||||
/// [`ChunkImporter`]).
|
||||
pub fn read_with(bytes: &[u8], opts: &ReadOptions) -> Result<wordcraft_doc::Document, DocxError> {
|
||||
if is_encrypted(bytes) {
|
||||
let package = zeroize::Zeroizing::new(decrypt(bytes, password)?);
|
||||
return read::read_package(&package);
|
||||
let package = zeroize::Zeroizing::new(decrypt(bytes, opts.password)?);
|
||||
return read::read_package(&package, opts.chunks);
|
||||
}
|
||||
read(bytes)
|
||||
read::read_package(bytes, opts.chunks)
|
||||
}
|
||||
|
||||
/// Which kind of WordprocessingML package to write. The main part's content type differs per
|
||||
|
||||
@@ -47,6 +47,7 @@ pub mod rt {
|
||||
pub const DIAGRAM_COLORS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagramColors";
|
||||
pub const CUSTOM_XML: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml";
|
||||
pub const GLOSSARY: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/glossaryDocument";
|
||||
pub const AF_CHUNK: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/aFChunk";
|
||||
}
|
||||
|
||||
/// Content type of a glossary document part (ECMA-376 Part 1 §11.3.8).
|
||||
@@ -96,6 +97,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";
|
||||
@@ -203,6 +213,8 @@ impl Package {
|
||||
}
|
||||
|
||||
pub fn open_limited(bytes: &[u8], max_part: u64, max_total: u64) -> Result<Package, DocxError> {
|
||||
// Before `zip` parses every entry the end record declares (#3660).
|
||||
wordcraft_doc::zip_guard::check(bytes, MAX_ENTRIES as u64).map_err(DocxError::Limit)?;
|
||||
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| DocxError::Zip(e.to_string()))?;
|
||||
if zip.len() > MAX_ENTRIES {
|
||||
return Err(DocxError::Limit(format!("{} zip entries", zip.len())));
|
||||
@@ -243,6 +255,11 @@ impl Package {
|
||||
Ok(Package::new(files))
|
||||
}
|
||||
|
||||
/// The bytes of all its parts.
|
||||
pub fn size(&self) -> u64 {
|
||||
self.files.values().map(|f| f.len() as u64).sum()
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -412,7 +429,8 @@ 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 Some(at) = name.rfind("_rels/").filter(|_| name.to_ascii_lowercase().ends_with(".rels")) else { continue };
|
||||
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") {
|
||||
@@ -514,6 +532,21 @@ mod tests {
|
||||
assert!(folded.iter().all(|w| w.contains("2 zip entries")), "{folded:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_package_declaring_too_many_entries_is_refused_before_zip_reads_them() {
|
||||
// #3660: a ZIP64 end record declaring more than MAX_ENTRIES.
|
||||
let bomb = wordcraft_doc::zip_guard::zip64_entry_count_sample(MAX_ENTRIES as u64 + 1);
|
||||
assert!(matches!(Package::open(&bomb), Err(DocxError::Limit(e)) if e.contains("declares")));
|
||||
assert_eq!(crate::package_title(std::io::Cursor::new(bomb), 1 << 20), None);
|
||||
// A real package still opens, and its title still reads.
|
||||
let z = zip_of(&[(
|
||||
"docProps/core.xml",
|
||||
br#"<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>Plan</dc:title></cp:coreProperties>"#.to_vec(),
|
||||
)]);
|
||||
assert!(Package::open(&z).is_ok());
|
||||
assert_eq!(crate::package_title(std::io::Cursor::new(z), 1 << 20).as_deref(), Some("Plan"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decompression_bomb_is_rejected() {
|
||||
let z = zip_of(&[("word/document.xml", vec![b' '; 2_000_000])]);
|
||||
@@ -577,7 +610,7 @@ mod tests {
|
||||
("word/media/pic.png", b"\x89PNG picture bytes".to_vec()),
|
||||
]);
|
||||
let pkg = Package::open(&z).unwrap();
|
||||
let doc = crate::read::read_opened(&pkg).unwrap();
|
||||
let doc = crate::read::read_opened(&pkg, None).unwrap();
|
||||
let held = doc.media.values().next().expect("the picture is read");
|
||||
assert!(Arc::ptr_eq(held, pkg.get_shared("word/media/pic.png").unwrap()), "the same bytes, not a copy");
|
||||
assert_eq!(pkg.get("/WORD/media/PIC.png"), Some(&b"\x89PNG picture bytes"[..]), "lookups are unchanged");
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
//! DOCX → [`Document`].
|
||||
|
||||
pub(crate) mod chart;
|
||||
mod chunk;
|
||||
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::{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};
|
||||
|
||||
use chunk::ChunkState;
|
||||
pub use chunk::{AltChunk, ChunkImporter, ReadOptions};
|
||||
|
||||
/// 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>,
|
||||
/// The next permission range id to give out (chunks' ranges take theirs from it too).
|
||||
pub next_perm: 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>>,
|
||||
/// 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,
|
||||
/// What alternative format chunks may still bring in, and how (see [`chunk`]).
|
||||
chunks: ChunkState<'p>,
|
||||
/// 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, None)
|
||||
}
|
||||
|
||||
/// [`read`] for bytes known not to be an encrypted package; `chunks` imports alternative format
|
||||
/// chunks in formats other than WordprocessingML.
|
||||
pub(crate) fn read_package(bytes: &[u8], chunks: Option<ChunkImporter>) -> Result<Document, DocxError> {
|
||||
read_opened(&Package::open(bytes)?, chunks)
|
||||
}
|
||||
|
||||
/// 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, chunks: Option<ChunkImporter>) -> Result<Document, DocxError> {
|
||||
read_opened_in(pkg, ChunkState::new(chunks)).map(|(doc, ..)| doc)
|
||||
}
|
||||
|
||||
/// [`read_opened`] with what chunks may still bring in; also gives the chunks, chunk bytes and
|
||||
/// graphic work left of that.
|
||||
fn read_opened_in(pkg: &Package, chunks: ChunkState) -> Result<(Document, usize, u64, usize), 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(),
|
||||
next_perm: 0,
|
||||
graphic_parts: HashMap::new(),
|
||||
graphics: HashMap::new(),
|
||||
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,
|
||||
chunks,
|
||||
};
|
||||
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, r.chunks.depth);
|
||||
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 (left, bytes, graphic) = (r.chunks.left, r.chunks.bytes, r.chunks.graphic);
|
||||
let mut doc = r.doc;
|
||||
doc.ensure_nonempty();
|
||||
Ok((doc, left, bytes, graphic))
|
||||
}
|
||||
|
||||
/// 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
use super::canvas::{Rect, clip_text, fill, stroke, text, text_w, widest};
|
||||
use super::format::format_value;
|
||||
use super::model::{DataLabel, Kind, Series};
|
||||
use super::plot::{Axes, has_values};
|
||||
use super::{Chart, FRAC_PI_2, GAP, GRID, GraphicItem, LINE, PathSeg, Rgb, ShapeKind, TAU, TEXT, TextAlign};
|
||||
|
||||
pub(super) fn pie(out: &mut Vec<GraphicItem>, c: &Chart, area: Rect) {
|
||||
let Some(s) = c.series.first() else { return };
|
||||
let vals: Vec<f64> = s.values.iter().map(|v| v.unwrap_or(0.0).max(0.0)).collect();
|
||||
let total: f64 = vals.iter().sum();
|
||||
let r = (area.w.min(area.h) / 2.0 - 2.0) as f64;
|
||||
if !(total > 0.0 && total.is_finite() && r >= 1.0) {
|
||||
return;
|
||||
}
|
||||
let (cx, cy) = ((area.x + area.w / 2.0) as f64, (area.y + area.h / 2.0) as f64);
|
||||
let inner = if c.kind == Kind::Doughnut { r * c.hole as f64 } else { 0.0 };
|
||||
let mut a = c.first_angle.to_radians();
|
||||
let mut labels = Vec::new();
|
||||
for (i, v) in vals.iter().enumerate() {
|
||||
if *v <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let b = a + v / total * TAU;
|
||||
let mut segs = Vec::new();
|
||||
if inner > 0.0 {
|
||||
segs.push(pt(polar(cx, cy, r, a)));
|
||||
arc(&mut segs, (cx, cy, r), a, b);
|
||||
segs.push(line_to(polar(cx, cy, inner, b)));
|
||||
arc(&mut segs, (cx, cy, inner), b, a);
|
||||
} else {
|
||||
segs.push(pt((cx, cy)));
|
||||
segs.push(line_to(polar(cx, cy, r, a)));
|
||||
arc(&mut segs, (cx, cy, r), a, b);
|
||||
}
|
||||
segs.push(PathSeg::Close);
|
||||
fill(out, segs, c.point_colour(0, i));
|
||||
if let Some(text) = label_of(s, i) {
|
||||
// Halfway across the ring (pie: two thirds out from the centre).
|
||||
let at = if inner > 0.0 { (r + inner) / 2.0 } else { r * 0.65 };
|
||||
let (x, y) = polar(cx, cy, at, (a + b) / 2.0);
|
||||
labels.push(DataLabel { x: x as f32, y: y as f32, text, inside: true });
|
||||
}
|
||||
a = b;
|
||||
}
|
||||
draw_data_labels(out, &labels, c.text_size, false);
|
||||
}
|
||||
|
||||
/// A radar chart in `area`: category spokes from the centre, concentric grid polygons at the value
|
||||
/// ticks, one polygon per series (filled, or outlined with markers) and the category labels at the
|
||||
/// spoke ends. The lowest tick is the centre.
|
||||
pub(super) fn radar(out: &mut Vec<GraphicItem>, c: &Chart, area: Rect) {
|
||||
const MARKER: f32 = 5.0;
|
||||
if c.n == 0 {
|
||||
return;
|
||||
}
|
||||
let fs = c.text_size;
|
||||
let lh = fs * LINE;
|
||||
let labelled = c.cats.iter().any(|s| !s.is_empty());
|
||||
let side = if labelled { widest(&c.cats, fs).min(area.w * 0.25) + GAP + 2.0 } else { 0.0 };
|
||||
let vert = if labelled { lh + GAP } else { 0.0 };
|
||||
let r = ((area.w / 2.0 - side).min(area.h / 2.0 - vert) - 2.0) as f64;
|
||||
if r.is_nan() || r < 8.0 {
|
||||
return;
|
||||
}
|
||||
let (cx, cy) = ((area.x + area.w / 2.0) as f64, (area.y + area.h / 2.0) as f64);
|
||||
let vs = Axes::of(c).vs;
|
||||
let span = vs.hi - vs.lo;
|
||||
let angle = |i: usize| i as f64 / c.n as f64 * TAU;
|
||||
let at = |v: f64, i: usize| {
|
||||
let t = if span > 0.0 { ((v - vs.lo) / span).clamp(0.0, 1.0) } else { 0.0 };
|
||||
polar(cx, cy, r * t, angle(i))
|
||||
};
|
||||
let ticks = vs.ticks();
|
||||
if c.val.grid {
|
||||
for t in ticks.iter().skip(1) {
|
||||
let mut segs: Vec<PathSeg> = (0..c.n).map(|i| if i == 0 { pt(at(*t, i)) } else { line_to(at(*t, i)) }).collect();
|
||||
segs.push(PathSeg::Close);
|
||||
stroke(out, segs, GRID, 0.75);
|
||||
}
|
||||
}
|
||||
for i in 0..c.n {
|
||||
let end = polar(cx, cy, r, angle(i));
|
||||
stroke(out, vec![pt((cx, cy)), line_to(end)], GRID, 0.75);
|
||||
}
|
||||
let mut labels = Vec::new();
|
||||
for (j, s) in c.series.iter().enumerate().filter(|(_, s)| has_values(s)) {
|
||||
let col = c.series_colour(j);
|
||||
// A missing value counts as the centre, as Word draws it.
|
||||
let ring: Vec<(f64, f64)> = (0..c.n).map(|i| at(s.values.get(i).copied().flatten().unwrap_or(vs.lo), i)).collect();
|
||||
let mut segs: Vec<PathSeg> = ring.iter().enumerate().map(|(i, p)| if i == 0 { pt(*p) } else { line_to(*p) }).collect();
|
||||
segs.push(PathSeg::Close);
|
||||
if c.radar_fill {
|
||||
fill(out, segs.clone(), col);
|
||||
}
|
||||
stroke(out, segs, col, s.line_w);
|
||||
for (i, p) in ring.iter().enumerate() {
|
||||
if c.radar_marks && s.values.get(i).copied().flatten().is_some() {
|
||||
let rect = [p.0 as f32 - MARKER / 2.0, p.1 as f32 - MARKER / 2.0, MARKER, MARKER];
|
||||
out.push(GraphicItem::Shape { rect, kind: ShapeKind::Ellipse, fill: Some(col), stroke: None, stroke_width: 0.0 });
|
||||
}
|
||||
if let Some(text) = label_of(s, i) {
|
||||
labels.push(DataLabel { x: p.0 as f32, y: p.1 as f32, text, inside: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
draw_data_labels(out, &labels, fs, false);
|
||||
if !c.val.deleted {
|
||||
for t in &ticks {
|
||||
let y = at(*t, 0).1 as f32;
|
||||
let s = format_value(*t, &c.val_fmt, vs.step);
|
||||
let w = text_w(&s, fs) + 4.0;
|
||||
text(out, Rect { x: cx as f32 - GAP / 2.0 - w, y: y - lh / 2.0, w, h: lh }, &s, fs, TextAlign::Right, false);
|
||||
}
|
||||
}
|
||||
if labelled {
|
||||
for (i, s) in c.cats.iter().enumerate().filter(|(_, s)| !s.is_empty()) {
|
||||
let a = angle(i);
|
||||
let (x, y) = polar(cx, cy, r + GAP as f64, a);
|
||||
let (x, y) = (x as f32, y as f32);
|
||||
let w = side - GAP;
|
||||
let txt = clip_text(s, w, fs);
|
||||
let (sx, sy) = (a.sin(), a.cos());
|
||||
let (rect, align) = if sx > 0.2 {
|
||||
(Rect { x, y: y - lh / 2.0, w, h: lh }, TextAlign::Left)
|
||||
} else if sx < -0.2 {
|
||||
(Rect { x: x - w, y: y - lh / 2.0, w, h: lh }, TextAlign::Right)
|
||||
} else {
|
||||
let cw = (text_w(&txt, fs) + 4.0).max(1.0);
|
||||
(Rect { x: x - cw / 2.0, y: if sy > 0.0 { y - lh } else { y }, w: cw, h: lh }, TextAlign::Center)
|
||||
};
|
||||
text(out, rect, &txt, fs, align, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The data label of point `i` of series `s`, when it shows one: the value as the file has it,
|
||||
/// in the series' number format.
|
||||
pub(super) fn label_of(s: &Series, i: usize) -> Option<String> {
|
||||
if s.labels != Some(true) {
|
||||
return None;
|
||||
}
|
||||
let v = s.raw.get(i).copied().flatten()?;
|
||||
let fmt = s.fmt.as_deref().filter(|f| !f.eq_ignore_ascii_case("General"));
|
||||
Some(match fmt {
|
||||
Some(f) => format_value(v, f, 1.0),
|
||||
None => general_value(v),
|
||||
})
|
||||
}
|
||||
|
||||
/// A value in the `General` format: whole numbers without decimals, others with up to six.
|
||||
fn general_value(v: f64) -> String {
|
||||
if v.fract() == 0.0 && v.abs() < 1e15 {
|
||||
return format!("{v:.0}");
|
||||
}
|
||||
let s = format!("{v:.6}");
|
||||
let s = s.trim_end_matches('0').trim_end_matches('.');
|
||||
if s == "-0" { "0".to_string() } else { s.to_string() }
|
||||
}
|
||||
|
||||
/// Data labels over the drawn chart. Beside labels sit above their point, or right of it for
|
||||
/// horizontal bars (`beside_right`).
|
||||
pub(super) fn draw_data_labels(out: &mut Vec<GraphicItem>, labels: &[DataLabel], fs: f32, beside_right: bool) {
|
||||
let lh = fs * LINE;
|
||||
for l in labels {
|
||||
let w = text_w(&l.text, fs) + 4.0;
|
||||
let (rect, align) = if l.inside {
|
||||
(Rect { x: l.x - w / 2.0, y: l.y - lh / 2.0, w, h: lh }, TextAlign::Center)
|
||||
} else if beside_right {
|
||||
(Rect { x: l.x + GAP / 2.0, y: l.y - lh / 2.0, w, h: lh }, TextAlign::Left)
|
||||
} else {
|
||||
(Rect { x: l.x - w / 2.0, y: l.y - lh, w, h: lh }, TextAlign::Center)
|
||||
};
|
||||
let color = if l.inside { Rgb::WHITE } else { TEXT };
|
||||
out.push(GraphicItem::Text { rect: rect.arr(), text: l.text.clone(), size: fs, color, bold: false, align, font: None });
|
||||
}
|
||||
}
|
||||
|
||||
/// A point at clockwise angle `t` (radians from 12 o'clock) on a circle of radius `r`.
|
||||
fn polar(cx: f64, cy: f64, r: f64, t: f64) -> (f64, f64) {
|
||||
(cx + r * t.sin(), cy - r * t.cos())
|
||||
}
|
||||
|
||||
fn pt(p: (f64, f64)) -> PathSeg {
|
||||
PathSeg::Move(p.0 as f32, p.1 as f32)
|
||||
}
|
||||
|
||||
fn line_to(p: (f64, f64)) -> PathSeg {
|
||||
PathSeg::Line(p.0 as f32, p.1 as f32)
|
||||
}
|
||||
|
||||
/// Cubic segments along a circle from angle `a` to `b` (the current point is at `a`). Each piece
|
||||
/// spans at most a quarter turn.
|
||||
fn arc(segs: &mut Vec<PathSeg>, (cx, cy, r): (f64, f64, f64), a: f64, b: f64) {
|
||||
let n = (((b - a).abs() / FRAC_PI_2).ceil() as usize).clamp(1, 16);
|
||||
let d = (b - a) / n as f64;
|
||||
let k = 4.0 / 3.0 * (d / 4.0).tan();
|
||||
for j in 0..n {
|
||||
let t0 = a + d * j as f64;
|
||||
let t1 = t0 + d;
|
||||
let p0 = polar(cx, cy, r, t0);
|
||||
let p1 = polar(cx, cy, r, t1);
|
||||
let c1 = (p0.0 + k * r * t0.cos(), p0.1 + k * r * t0.sin());
|
||||
let c2 = (p1.0 - k * r * t1.cos(), p1.1 - k * r * t1.sin());
|
||||
segs.push(PathSeg::Cubic(c1.0 as f32, c1.1 as f32, c2.0 as f32, c2.1 as f32, p1.0 as f32, p1.1 as f32));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
use super::*;
|
||||
use wordcraft_doc::THEME_COLORS;
|
||||
|
||||
const NS: &str =
|
||||
r#"xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main""#;
|
||||
|
||||
fn space(plot: &str) -> El {
|
||||
let x = format!(r#"<c:chartSpace {NS}><c:chart><c:plotArea>{plot}</c:plotArea></c:chart></c:chartSpace>"#);
|
||||
crate::xml::parse(x.as_bytes()).unwrap()
|
||||
}
|
||||
|
||||
fn theme() -> Vec<Rgb> {
|
||||
THEME_COLORS.to_vec()
|
||||
}
|
||||
|
||||
fn ser(i: usize, name: &str, color: Option<&str>, cats: &[&str], vals: &[&str]) -> String {
|
||||
let fill = color.map_or(String::new(), |c| format!(r#"<c:spPr><a:solidFill><a:srgbClr val="{c}"/></a:solidFill></c:spPr>"#));
|
||||
let cat_pts: String = cats.iter().enumerate().map(|(k, c)| format!(r#"<c:pt idx="{k}"><c:v>{c}</c:v></c:pt>"#)).collect();
|
||||
let val_pts: String = vals.iter().enumerate().map(|(k, v)| format!(r#"<c:pt idx="{k}"><c:v>{v}</c:v></c:pt>"#)).collect();
|
||||
format!(
|
||||
r#"<c:ser><c:idx val="{i}"/><c:order val="{i}"/><c:tx><c:strRef><c:strCache><c:ptCount val="1"/><c:pt idx="0"><c:v>{name}</c:v></c:pt></c:strCache></c:strRef></c:tx>{fill}<c:cat><c:strRef><c:strCache><c:ptCount val="{nc}"/>{cat_pts}</c:strCache></c:strRef></c:cat><c:val><c:numRef><c:numCache><c:formatCode>General</c:formatCode><c:ptCount val="{nv}"/>{val_pts}</c:numCache></c:numRef></c:val></c:ser>"#,
|
||||
nc = cats.len(),
|
||||
nv = vals.len()
|
||||
)
|
||||
}
|
||||
|
||||
fn filled(items: &[GraphicItem]) -> Vec<(Rgb, f32, f32)> {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(|it| match it {
|
||||
GraphicItem::Path { segs, fill: Some(c), .. } => {
|
||||
let ys: Vec<f32> = segs
|
||||
.iter()
|
||||
.filter_map(|s| match s {
|
||||
PathSeg::Move(_, y) | PathSeg::Line(_, y) => Some(*y),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let lo = ys.iter().copied().fold(f32::INFINITY, f32::min);
|
||||
let hi = ys.iter().copied().fold(f32::NEG_INFINITY, f32::max);
|
||||
Some((*c, lo, hi))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clustered_columns_draw_one_bar_per_point() {
|
||||
let plot = format!(
|
||||
r#"<c:barChart><c:barDir val="col"/><c:grouping val="clustered"/>{}{}<c:axId val="1"/><c:axId val="2"/></c:barChart><c:catAx><c:axId val="1"/></c:catAx><c:valAx><c:axId val="2"/><c:majorGridlines/></c:valAx>"#,
|
||||
ser(0, "North", Some("FF0000"), &["Q1", "Q2", "Q3"], &["4", "8", "2"]),
|
||||
ser(1, "South", Some("00FF00"), &["Q1", "Q2", "Q3"], &["2", "6", "4"]),
|
||||
);
|
||||
let items = chart_items(&space(&plot), &theme(), 300.0, 200.0);
|
||||
let bars = filled(&items);
|
||||
assert_eq!(bars.len(), 6);
|
||||
let heights: Vec<f32> = bars.iter().map(|(_, lo, hi)| hi - lo).collect();
|
||||
// Scale 0..8: North Q2 is the full plot height, North Q1 half of it.
|
||||
assert!((heights[1] / heights[0] - 2.0).abs() < 1e-3, "{heights:?}");
|
||||
// South (second series) Q1 is 2 of 8, Q2 is 6 of 8.
|
||||
assert!((heights[3] / heights[1] - 0.25).abs() < 1e-3, "{heights:?}");
|
||||
assert!((heights[4] / heights[1] - 0.75).abs() < 1e-3, "{heights:?}");
|
||||
assert_eq!(bars[0].0, Rgb(0xFF, 0, 0));
|
||||
assert_eq!(bars[3].0, Rgb(0, 0xFF, 0));
|
||||
assert!(items.iter().any(|it| matches!(it, GraphicItem::Text { text, .. } if text == "Q2")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_labels_show_the_values() {
|
||||
let texts = |items: &[GraphicItem]| -> Vec<String> {
|
||||
items.iter().filter_map(|it| if let GraphicItem::Text { text, .. } = it { Some(text.clone()) } else { None }).collect()
|
||||
};
|
||||
let labels = r#"<c:dLbls><c:showLegendKey val="0"/><c:showVal val="1"/></c:dLbls>"#;
|
||||
let plot = format!(
|
||||
r#"<c:barChart><c:barDir val="col"/><c:grouping val="clustered"/>{}{labels}<c:axId val="1"/><c:axId val="2"/></c:barChart><c:catAx><c:axId val="1"/></c:catAx><c:valAx><c:axId val="2"/></c:valAx>"#,
|
||||
ser(0, "North", None, &["Q1", "Q2"], &["4.25", "17"]),
|
||||
);
|
||||
let shown = texts(&chart_items(&space(&plot), &theme(), 300.0, 200.0));
|
||||
assert!(shown.contains(&"4.25".to_string()) && shown.contains(&"17".to_string()), "{shown:?}");
|
||||
let pie = format!(r#"<c:pieChart><c:varyColors val="1"/>{}{labels}</c:pieChart>"#, ser(0, "S", None, &["A", "B"], &["1", "3"]));
|
||||
let shown = texts(&chart_items(&space(&pie), &theme(), 200.0, 150.0));
|
||||
assert!(shown.contains(&"3".to_string()), "{shown:?}");
|
||||
// Without `c:dLbls`, no values are written next to the points.
|
||||
let plain = plot.replace(labels, "");
|
||||
assert!(!texts(&chart_items(&space(&plain), &theme(), 300.0, 200.0)).contains(&"4.25".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pie_draws_one_slice_per_point() {
|
||||
let plot =
|
||||
format!(r#"<c:pieChart><c:varyColors val="1"/>{}</c:pieChart>"#, ser(0, "Share", None, &["A", "B", "C", "D"], &["50", "25", "15", "10"]));
|
||||
let items = chart_items(&space(&plot), &theme(), 200.0, 150.0);
|
||||
let slices = filled(&items);
|
||||
assert_eq!(slices.len(), 4);
|
||||
assert_eq!(slices[0].0, theme()[4], "first slice is accent1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doughnut_slices_are_annuli() {
|
||||
let plot =
|
||||
format!(r#"<c:doughnutChart><c:varyColors val="1"/>{}<c:holeSize val="50"/></c:doughnutChart>"#, ser(0, "S", None, &["A", "B"], &["1", "3"]));
|
||||
let items = chart_items(&space(&plot), &theme(), 200.0, 150.0);
|
||||
assert_eq!(items.iter().filter(|it| matches!(it, GraphicItem::Path { fill: Some(_), .. })).count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nice_scale_rounds_to_round_steps() {
|
||||
assert_eq!(nice_scale(0.0, 22.3, true, None, None), Scale { lo: 0.0, hi: 25.0, step: 5.0 });
|
||||
assert_eq!(nice_scale(0.0, 100.0, true, None, None), Scale { lo: 0.0, hi: 120.0, step: 20.0 });
|
||||
assert_eq!(nice_scale(0.0, 22.3, true, Some(0.0), Some(30.0)).hi, 30.0);
|
||||
let flat = nice_scale(0.0, 0.0, true, None, None);
|
||||
assert!(flat.lo == 0.0 && (flat.hi - 1.2).abs() < 1e-9 && flat.step == 0.2, "{flat:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_formats() {
|
||||
assert_eq!(format_value(1234567.0, "#,##0", 1.0), "1,234,567");
|
||||
assert_eq!(format_value(-1500.0, "#,##0", 500.0), "-1,500");
|
||||
assert_eq!(format_value(0.25, "0%", 0.05), "25%");
|
||||
assert_eq!(format_value(0.5, "General", 0.25), "0.50");
|
||||
assert_eq!(format_value(10.0, "\"€\"0.00", 5.0), "€10.00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_values_never_panic() {
|
||||
let bodies = [
|
||||
format!(
|
||||
r#"<c:barChart><c:barDir val="col"/><c:grouping val="stacked"/><c:gapWidth val="-9999999999999999999999"/><c:overlap val="NaN"/>{}<c:axId val="1"/></c:barChart><c:valAx><c:axId val="1"/><c:scaling><c:max val="1e400"/><c:min val="-1e308"/></c:scaling></c:valAx>"#,
|
||||
r#"<c:ser><c:tx><c:v>x</c:v></c:tx><c:cat><c:strRef><c:strCache><c:ptCount val="99999999999"/><c:pt idx="18446744073709551615"><c:v>a</c:v></c:pt><c:pt idx="-3"><c:v>b</c:v></c:pt></c:strCache></c:strRef></c:cat><c:val><c:numRef><c:numCache><c:ptCount val="3"/><c:pt idx="0"><c:v>1e400</c:v></c:pt><c:pt idx="1"><c:v>NaN</c:v></c:pt><c:pt idx="2"><c:v>-1e308</c:v></c:pt><c:pt idx="99999"><c:v>5</c:v></c:pt></c:numCache></c:numRef></c:val></c:ser><c:ser><c:val><c:numRef><c:numCache><c:pt idx="0"><c:v>1e308</c:v></c:pt><c:pt idx="0"><c:v>1e308</c:v></c:pt></c:numCache></c:numRef></c:val></c:ser>"#
|
||||
),
|
||||
r#"<c:scatterChart><c:ser><c:xVal><c:numLit><c:ptCount val="2"/><c:pt idx="0"><c:v>-1e308</c:v></c:pt><c:pt idx="1"><c:v>1e308</c:v></c:pt></c:numLit></c:xVal><c:yVal><c:numLit><c:ptCount val="2"/><c:pt idx="0"><c:v>1</c:v></c:pt><c:pt idx="1"><c:v>2</c:v></c:pt></c:numLit></c:yVal></c:ser></c:scatterChart>"#.to_string(),
|
||||
r#"<c:pieChart><c:ser><c:val><c:numLit><c:ptCount val="2"/><c:pt idx="0"><c:v>-4</c:v></c:pt><c:pt idx="1"><c:v>1e308</c:v></c:pt></c:numLit></c:val><c:dPt><c:idx val="9999999999999"/></c:dPt></c:ser><c:firstSliceAng val="-9"/></c:pieChart>"#.to_string(),
|
||||
];
|
||||
for body in &bodies {
|
||||
let s = space(body);
|
||||
for (w, h) in [(0.0, 10.0), (f32::NAN, 10.0), (-1.0, 1.0), (1e30, 1e30), (50.0, 40.0)] {
|
||||
let _ = chart_items(&s, &theme(), w, h);
|
||||
}
|
||||
}
|
||||
assert!(chart_items(&space(&bodies[0]), &theme(), f32::NAN, 10.0).is_empty());
|
||||
assert!(chart_items(&El::default(), &theme(), 100.0, 100.0).is_empty());
|
||||
}
|
||||
|
||||
fn texts(items: &[GraphicItem]) -> Vec<&str> {
|
||||
items.iter().filter_map(|it| if let GraphicItem::Text { text, .. } = it { Some(text.as_str()) } else { None }).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_chart_types_draw_a_placeholder() {
|
||||
let ser = r#"<c:ser><c:val><c:numLit><c:ptCount val="1"/><c:pt idx="0"><c:v>1</c:v></c:pt></c:numLit></c:val></c:ser>"#;
|
||||
let title = r#"<c:title><c:tx><c:rich><a:p><a:r><a:t>Skills</a:t></a:r></a:p></c:rich></c:tx></c:title>"#;
|
||||
let x = format!(r#"<c:chartSpace {NS}><c:chart>{title}<c:plotArea><c:bubbleChart>{ser}</c:bubbleChart></c:plotArea></c:chart></c:chartSpace>"#);
|
||||
let bubble = chart_items(&crate::xml::parse(x.as_bytes()).unwrap(), &theme(), 300.0, 200.0);
|
||||
assert_eq!(texts(&bubble), ["Skills", "Bubble chart (not supported yet)"]);
|
||||
let framed = bubble.iter().any(|it| matches!(it, GraphicItem::Shape { rect, stroke: Some(_), .. } if *rect == [0.0, 0.0, 300.0, 200.0]));
|
||||
assert!(framed, "a light border marks the chart's frame: {bubble:?}");
|
||||
|
||||
let stock = chart_items(&space(&format!("<c:stockChart>{ser}{ser}{ser}</c:stockChart>")), &theme(), 300.0, 200.0);
|
||||
assert_eq!(texts(&stock), ["Stock chart (not supported yet)"]);
|
||||
let other = chart_items(&space(&format!("<c:fancyChart>{ser}</c:fancyChart>")), &theme(), 300.0, 200.0);
|
||||
assert_eq!(texts(&other), ["Chart (not supported yet)"]);
|
||||
// A supported type in the plot area is drawn, an unsupported one beside it is skipped.
|
||||
let combo = chart_items(&space(&format!("<c:stockChart>{ser}</c:stockChart><c:lineChart>{ser}</c:lineChart>")), &theme(), 300.0, 200.0);
|
||||
assert!(!texts(&combo).iter().any(|t| t.contains("not supported")), "{combo:?}");
|
||||
}
|
||||
|
||||
fn radar_chart(style: &str, sers: &[String]) -> Vec<GraphicItem> {
|
||||
let plot = format!(r#"<c:radarChart><c:radarStyle val="{style}"/>{}</c:radarChart><c:valAx><c:majorGridlines/></c:valAx>"#, sers.concat());
|
||||
chart_items(&space(&plot), &theme(), 300.0, 220.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radar_charts_draw_spokes_grid_and_series_polygons() {
|
||||
let cats = ["Speed", "Power", "Range", "Armor", "Luck"];
|
||||
let sers = [ser(0, "A", Some("FF0000"), &cats, &["3", "4", "5", "2", "1"]), ser(1, "B", Some("0000FF"), &cats, &["2", "2", "3", "5", "4"])];
|
||||
let std = radar_chart("standard", &sers);
|
||||
assert!(!texts(&std).iter().any(|t| t.contains("not supported")), "{std:?}");
|
||||
for c in cats {
|
||||
assert!(texts(&std).contains(&c), "category label {c}: {:?}", texts(&std));
|
||||
}
|
||||
let stroked = |items: &[GraphicItem], col: Rgb| {
|
||||
items.iter().filter(|it| matches!(it, GraphicItem::Path { stroke: Some(c), segs, .. } if *c == col && segs.len() == 6)).count()
|
||||
};
|
||||
assert_eq!(stroked(&std, Rgb(0xFF, 0, 0)), 1, "one closed pentagon per series: {std:?}");
|
||||
assert_eq!(stroked(&std, Rgb(0, 0, 0xFF)), 1);
|
||||
assert!(filled(&std).is_empty(), "standard radar is not filled");
|
||||
assert!(!std.iter().any(|it| matches!(it, GraphicItem::Shape { kind: ShapeKind::Ellipse, .. })));
|
||||
// 5 spokes (2 segs each) plus grid pentagons.
|
||||
let spokes = std.iter().filter(|it| matches!(it, GraphicItem::Path { stroke: Some(c), segs, .. } if *c == GRID && segs.len() == 2)).count();
|
||||
assert_eq!(spokes, 5);
|
||||
assert!(std.iter().any(|it| matches!(it, GraphicItem::Path { stroke: Some(c), segs, .. } if *c == GRID && segs.len() == 6)));
|
||||
|
||||
let marked = radar_chart("marker", &sers);
|
||||
let dots = marked.iter().filter(|it| matches!(it, GraphicItem::Shape { kind: ShapeKind::Ellipse, .. })).count();
|
||||
assert_eq!(dots, 10, "a marker per point");
|
||||
|
||||
let solid = radar_chart("filled", &sers);
|
||||
let f = filled(&solid);
|
||||
assert_eq!(f.len(), 2);
|
||||
assert!(f.iter().any(|(c, ..)| *c == Rgb(0xFF, 0, 0)) && f.iter().any(|(c, ..)| *c == Rgb(0, 0, 0xFF)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radar_charts_show_a_legend() {
|
||||
let cats = ["a", "b", "c"];
|
||||
let plot = format!("<c:radarChart>{}</c:radarChart>", ser(0, "Team", None, &cats, &["1", "2", "3"]));
|
||||
let items = with_legend(&plot, "r", 300.0, 200.0);
|
||||
assert!(texts(&items).contains(&"Team"), "{:?}", texts(&items));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_radar_charts_never_panic() {
|
||||
// No categories and no values.
|
||||
let empty = r#"<c:ser><c:cat><c:strLit><c:ptCount val="0"/></c:strLit></c:cat><c:val><c:numLit><c:ptCount val="0"/></c:numLit></c:val></c:ser>"#;
|
||||
let _ = chart_items(&space(&format!("<c:radarChart>{empty}</c:radarChart>")), &theme(), 300.0, 200.0);
|
||||
let none = chart_items(&space("<c:radarChart/>"), &theme(), 300.0, 200.0);
|
||||
assert!(none.is_empty(), "{none:?}");
|
||||
// One category: a degenerate polygon, nothing to crash on; two as well.
|
||||
for n in 1..=2 {
|
||||
let cats: Vec<String> = (0..n).map(|i| format!("c{i}")).collect();
|
||||
let refs: Vec<&str> = cats.iter().map(String::as_str).collect();
|
||||
let vals = vec!["5"; n];
|
||||
let items = radar_chart("filled", &[ser(0, "S", None, &refs, &vals)]);
|
||||
assert!(texts(&items).contains(&"c0"), "{items:?}");
|
||||
}
|
||||
// NaN, infinities, junk and a tiny frame.
|
||||
let junk = ser(0, "S", None, &["a", "b", "c"], &["NaN", "inf", "1e999"]);
|
||||
let _ = radar_chart("filled", &[junk]);
|
||||
let small =
|
||||
chart_items(&space(&format!("<c:radarChart>{}</c:radarChart>", ser(0, "S", None, &["a", "b", "c"], &["1", "2", "3"]))), &theme(), 20.0, 20.0);
|
||||
let _ = small;
|
||||
// Caps: thousands of categories and series stay bounded.
|
||||
let cats: Vec<String> = (0..5000).map(|i| format!("c{i}")).collect();
|
||||
let refs: Vec<&str> = cats.iter().map(String::as_str).collect();
|
||||
let vals = vec!["1"; 5000];
|
||||
let many: Vec<String> = (0..300).map(|i| ser(i, "S", None, &refs, &vals)).collect();
|
||||
let items = radar_chart("marker", &many);
|
||||
let polygons = items.iter().filter(|it| matches!(it, GraphicItem::Path { stroke: Some(c), .. } if *c != GRID)).count();
|
||||
assert!(polygons <= MAX_RADAR_SERIES, "{polygons}");
|
||||
let spokes = items.iter().filter(|it| matches!(it, GraphicItem::Path { stroke: Some(c), segs, .. } if *c == GRID && segs.len() == 2)).count();
|
||||
assert_eq!(spokes, MAX_SPOKES);
|
||||
}
|
||||
|
||||
/// Chart items for a chart with a legend at `pos` (`r`, `b`…).
|
||||
fn with_legend(plot: &str, pos: &str, w: f32, h: f32) -> Vec<GraphicItem> {
|
||||
let x = format!(
|
||||
r#"<c:chartSpace {NS}><c:chart><c:plotArea>{plot}</c:plotArea><c:legend><c:legendPos val="{pos}"/></c:legend></c:chart></c:chartSpace>"#
|
||||
);
|
||||
chart_items(&crate::xml::parse(x.as_bytes()).unwrap(), &theme(), w, h)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn series_are_capped_to_the_cell_budget() {
|
||||
// 256 series each declaring 10,000 points: only the 10 that fit the cell budget are kept.
|
||||
let sers: String = (0..256)
|
||||
.map(|i| format!(r#"<c:ser><c:idx val="{i}"/><c:val><c:numRef><c:numCache><c:ptCount val="10000"/><c:pt idx="0"><c:v>1</c:v></c:pt></c:numCache></c:numRef></c:val></c:ser>"#))
|
||||
.collect();
|
||||
let plot = format!(r#"<c:lineChart><c:grouping val="standard"/>{sers}<c:axId val="1"/></c:lineChart>"#);
|
||||
let items = chart_items(&space(&plot), &theme(), 300.0, 200.0);
|
||||
let strokes = items.iter().filter(|it| matches!(it, GraphicItem::Path { fill: None, stroke: Some(_), .. })).count();
|
||||
assert_eq!(strokes, MAX_CELLS / 10_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn series_without_values_draw_nothing() {
|
||||
let plot = format!(
|
||||
r#"<c:areaChart><c:grouping val="standard"/>{}{}<c:axId val="1"/></c:areaChart>"#,
|
||||
ser(0, "A", Some("FF0000"), &["Q1", "Q2"], &["4", "8"]),
|
||||
ser(1, "Empty", Some("00FF00"), &["Q1", "Q2"], &[]),
|
||||
);
|
||||
let fills = filled(&chart_items(&space(&plot), &theme(), 300.0, 200.0));
|
||||
assert_eq!(fills.len(), 1);
|
||||
assert_eq!(fills[0].0, Rgb(0xFF, 0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_scale_starts_bars_at_zero_for_negative_data() {
|
||||
// All-negative data pads below only: the axis ends at zero, as positive data's starts there.
|
||||
assert_eq!(nice_scale(-10.0, -2.0, true, None, None), Scale { lo: -12.0, hi: 0.0, step: 2.0 });
|
||||
assert_eq!(nice_scale(-1050.0, -1000.0, false, None, None).hi, -1000.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn narrow_line_scales_leave_zero_out_but_bars_keep_it() {
|
||||
assert!(narrow(1000.0, 1050.0) && narrow(-1050.0, -1000.0));
|
||||
assert!(!narrow(10.0, 100.0) && !narrow(0.0, 1.0));
|
||||
assert_eq!(nice_scale(1000.0, 1050.0, false, None, None).lo, 1000.0);
|
||||
assert_eq!(nice_scale(1000.0, 1050.0, true, None, None).lo, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizontal_bar_labels_wrap_in_a_column_of_forty_percent() {
|
||||
let long = "A category label that is much too long for its column";
|
||||
let plot = format!(
|
||||
r#"<c:barChart><c:barDir val="bar"/><c:grouping val="clustered"/>{}<c:axId val="1"/><c:axId val="2"/></c:barChart><c:catAx><c:axId val="1"/></c:catAx><c:valAx><c:axId val="2"/></c:valAx>"#,
|
||||
ser(0, "S", Some("FF0000"), &[long, "Short"], &["4", "8"]),
|
||||
);
|
||||
let items = chart_items(&space(&plot), &theme(), 300.0, 200.0);
|
||||
let labels: Vec<(&str, f32)> = items
|
||||
.iter()
|
||||
.filter_map(|it| match it {
|
||||
GraphicItem::Text { rect, text, align: TextAlign::Right, .. } => Some((text.as_str(), rect[2])),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(labels.iter().all(|(_, w)| *w <= 0.4 * 300.0), "{labels:?}");
|
||||
assert!(labels.iter().filter(|(t, _)| long.contains(t)).count() > 1, "wrapped: {labels:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stacked_lines_add_up_like_areas() {
|
||||
let stacked = format!(
|
||||
r#"<c:lineChart><c:grouping val="stacked"/>{}{}<c:axId val="1"/></c:lineChart>"#,
|
||||
ser(0, "A", Some("FF0000"), &["Q1", "Q2"], &["0", "10"]),
|
||||
ser(1, "B", Some("00FF00"), &["Q1", "Q2"], &["0", "5"]),
|
||||
);
|
||||
let single = format!(
|
||||
r#"<c:lineChart><c:grouping val="standard"/>{}<c:axId val="1"/></c:lineChart>"#,
|
||||
ser(0, "T", Some("0000FF"), &["Q1", "Q2"], &["0", "15"])
|
||||
);
|
||||
// Where the last point of the line drawn in `colour` is.
|
||||
let end_y = |items: &[GraphicItem], colour: Rgb| {
|
||||
items.iter().find_map(|it| match it {
|
||||
GraphicItem::Path { segs, stroke: Some(c), .. } if *c == colour => match segs.last() {
|
||||
Some(PathSeg::Line(_, y)) => Some(*y),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
let top = end_y(&chart_items(&space(&stacked), &theme(), 300.0, 200.0), Rgb(0, 0xFF, 0)).expect("stacked line");
|
||||
let total = end_y(&chart_items(&space(&single), &theme(), 300.0, 200.0), Rgb(0, 0, 0xFF)).expect("single line");
|
||||
assert!((top - total).abs() < 1e-3, "stacked {top} vs {total}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn category_numbers_show_in_their_format() {
|
||||
// Excel serial days: 45292 is 1 January 2024, 45323 is 1 February 2024.
|
||||
let cat = |code: &str| -> Vec<String> {
|
||||
let x = format!(
|
||||
r#"<c:cat {NS}><c:numRef><c:numCache><c:formatCode>{code}</c:formatCode><c:ptCount val="2"/><c:pt idx="0"><c:v>45292</c:v></c:pt><c:pt idx="1"><c:v>45323</c:v></c:pt></c:numCache></c:numRef></c:cat>"#
|
||||
);
|
||||
labels(Some(&crate::xml::parse(x.as_bytes()).unwrap()), 10, false)
|
||||
};
|
||||
assert_eq!(cat("m/d/yyyy"), ["1/1/2024", "2/1/2024"]);
|
||||
assert_eq!(cat("d/m/yyyy"), ["1/1/2024", "1/2/2024"]);
|
||||
assert_eq!(cat("mmm-yy"), ["Jan-24", "Feb-24"]);
|
||||
assert_eq!(cat("yyyy"), ["2024", "2024"]);
|
||||
assert_eq!(cat("0.00"), ["45292.00", "45323.00"]);
|
||||
let ymd = |v: f64| excel_stamp(v, false).map(|t| (t.y, t.m, t.d));
|
||||
assert_eq!(ymd(61.0), Some((1900, 3, 1)));
|
||||
assert_eq!(ymd(45292.0), Some((2024, 1, 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn category_format_code_length_is_capped() {
|
||||
let cat = |code: &str| -> Vec<String> {
|
||||
let x = format!(
|
||||
r#"<c:cat {NS}><c:numRef><c:numCache><c:formatCode>{code}</c:formatCode><c:ptCount val="1"/><c:pt idx="0"><c:v>5</c:v></c:pt></c:numCache></c:numRef></c:cat>"#
|
||||
);
|
||||
labels(Some(&crate::xml::parse(x.as_bytes()).unwrap()), 10, false)
|
||||
};
|
||||
let ok = format!("{}0", "x".repeat(254));
|
||||
assert!(cat(&ok)[0].starts_with("xxx"));
|
||||
let long = format!("{}0", "x".repeat(300));
|
||||
assert_eq!(cat(&long), ["5"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_format_code_length_is_capped() {
|
||||
let val = |code: &str| -> Option<String> {
|
||||
let x = format!(
|
||||
r#"<c:val {NS}><c:numRef><c:numCache><c:formatCode>{code}</c:formatCode><c:ptCount val="1"/><c:pt idx="0"><c:v>5</c:v></c:pt></c:numCache></c:numRef></c:val>"#
|
||||
);
|
||||
numbers(Some(&crate::xml::parse(x.as_bytes()).unwrap()), 10).1
|
||||
};
|
||||
let ok = format!("{}0", "x".repeat(254));
|
||||
assert_eq!(val(&ok), Some(ok.clone()));
|
||||
assert_eq!(val(&format!("{}0", "x".repeat(300))), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn axis_format_code_length_is_capped() {
|
||||
let fmt = |code: &str| -> Option<String> {
|
||||
let x = format!(r#"<c:valAx {NS}><c:numFmt formatCode="{code}" sourceLinked="0"/></c:valAx>"#);
|
||||
axis(Some(&crate::xml::parse(x.as_bytes()).unwrap())).fmt
|
||||
};
|
||||
let ok = format!("{}0", "x".repeat(254));
|
||||
assert_eq!(fmt(&ok), Some(ok.clone()));
|
||||
assert_eq!(fmt(&format!("{}0", "x".repeat(300))), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn civil_is_exact_beyond_chronos_range() {
|
||||
// Serial 1 is 1899-12-31, serial 60 Excel's phantom 1900-02-29 handled by `excel_stamp`.
|
||||
assert_eq!(civil(1), (1899, 12, 31));
|
||||
// Past `NaiveDate`'s ±262143 the 400-year cycles carry the year, exactly, both sides of
|
||||
// the epoch; values from the pre-chrono proleptic Gregorian formula.
|
||||
assert_eq!(civil(100_000_000), (275_690, 9, 11));
|
||||
assert_eq!(civil(-100_000_000), (-271_891, 4, 18));
|
||||
// The extremes must not overflow or panic (i128 intermediates).
|
||||
let _ = (civil(i64::MAX), civil(i64::MIN));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_formats_keep_literal_text_and_currency() {
|
||||
assert_eq!(format_value(1234.5, "#,##0.00 [$€-407]", 0.25), "1,234.50 €");
|
||||
assert_eq!(format_value(5.0, "[Red]0", 1.0), "5");
|
||||
assert_eq!(format_value(3.0, "\"Items: \"0", 1.0), "Items: 3");
|
||||
assert_eq!(format_value(3.0, "0\" units\"", 1.0), "3 units");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn side_legend_keeps_only_the_entries_that_fit() {
|
||||
let sers: String = (0..10).map(|i| ser(i, &format!("S{i}"), Some("FF0000"), &["Q1"], &["1"])).collect();
|
||||
let plot = format!(
|
||||
r#"<c:barChart><c:barDir val="col"/><c:grouping val="clustered"/>{sers}<c:axId val="1"/><c:axId val="2"/></c:barChart><c:catAx><c:axId val="1"/></c:catAx><c:valAx><c:axId val="2"/></c:valAx>"#
|
||||
);
|
||||
let items = with_legend(&plot, "r", 300.0, 60.0);
|
||||
let swatches = items.iter().filter(|it| matches!(it, GraphicItem::Shape { rect, .. } if rect[2] == 7.0)).count();
|
||||
assert!(swatches > 0 && swatches < 10, "{swatches} swatches");
|
||||
let bottom =
|
||||
items.iter().filter_map(|it| if let GraphicItem::Text { rect, .. } = it { Some(rect[1] + rect[3]) } else { None }).fold(0.0, f32::max);
|
||||
assert!(bottom <= 60.0, "legend text passes the chart's bottom: {bottom}");
|
||||
}
|
||||
|
||||
/// The swatches of a legend: their (x, y).
|
||||
fn swatches(items: &[GraphicItem]) -> Vec<(f32, f32)> {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(|it| if let GraphicItem::Shape { rect, .. } = it { (rect[2] == SWATCH).then_some((rect[0], rect[1])) } else { None })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A column chart of one category with a series per name.
|
||||
fn columns(names: &[&str]) -> String {
|
||||
let sers: String = names.iter().enumerate().map(|(i, n)| ser(i, n, Some("FF0000"), &["Q1"], &["1"])).collect();
|
||||
format!(
|
||||
r#"<c:barChart><c:barDir val="col"/><c:grouping val="clustered"/>{sers}<c:axId val="1"/><c:axId val="2"/></c:barChart><c:catAx><c:axId val="1"/></c:catAx><c:valAx><c:axId val="2"/></c:valAx>"#
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bottom_legend_wraps_into_rows_inside_the_chart() {
|
||||
let names: Vec<String> = (0..12).map(|i| format!("Region number {i}")).collect();
|
||||
let names: Vec<&str> = names.iter().map(String::as_str).collect();
|
||||
let items = with_legend(&columns(&names), "b", 200.0, 200.0);
|
||||
let sw = swatches(&items);
|
||||
let rows: std::collections::BTreeSet<i32> = sw.iter().map(|(_, y)| *y as i32).collect();
|
||||
assert!(rows.len() > 1 && rows.len() <= MAX_LEGEND_ROWS, "rows {rows:?}");
|
||||
assert!(sw.iter().all(|(x, _)| *x >= 0.0 && *x <= 200.0), "{sw:?}");
|
||||
for it in &items {
|
||||
if let GraphicItem::Text { rect, text, .. } = it {
|
||||
assert!(rect[0] >= 0.0 && rect[0] + text_w(text, FONT) <= 200.0 + 1e-3, "{text:?} at {rect:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_legend_names_are_cut_to_a_share_of_the_chart() {
|
||||
let long = "A very long series name that would push the legend past the left edge of the chart";
|
||||
let items = with_legend(&columns(&[long, "Short"]), "r", 200.0, 150.0);
|
||||
assert!(swatches(&items).iter().all(|(x, _)| *x >= 0.0), "{:?}", swatches(&items));
|
||||
let cut = items.iter().find_map(|it| match it {
|
||||
GraphicItem::Text { text, rect, .. } if text.ends_with('…') => Some((text.clone(), rect[2])),
|
||||
_ => None,
|
||||
});
|
||||
let (text, w) = cut.expect("the long name is cut");
|
||||
assert!(text_w(&text, FONT) <= 200.0 * LEGEND_SHARE && w <= 200.0 * LEGEND_SHARE + 2.0, "{text:?} {w}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scatter_dates_show_as_dates() {
|
||||
let pts = |vals: &[&str]| -> String { vals.iter().enumerate().map(|(k, v)| format!(r#"<c:pt idx="{k}"><c:v>{v}</c:v></c:pt>"#)).collect() };
|
||||
let plot = format!(
|
||||
r#"<c:scatterChart><c:ser><c:idx val="0"/><c:xVal><c:numRef><c:numCache><c:formatCode>m/d/yyyy</c:formatCode><c:ptCount val="3"/>{}</c:numCache></c:numRef></c:xVal><c:yVal><c:numRef><c:numCache><c:formatCode>General</c:formatCode><c:ptCount val="3"/>{}</c:numCache></c:numRef></c:yVal></c:ser><c:axId val="1"/><c:axId val="2"/></c:scatterChart><c:valAx><c:axId val="1"/><c:axPos val="b"/></c:valAx><c:valAx><c:axId val="2"/><c:axPos val="l"/></c:valAx>"#,
|
||||
pts(&["45292", "45323", "45352"]),
|
||||
pts(&["1", "2", "3"])
|
||||
);
|
||||
let items = chart_items(&space(&plot), &theme(), 300.0, 200.0);
|
||||
let texts: Vec<&str> = items.iter().filter_map(|it| if let GraphicItem::Text { text, .. } = it { Some(text.as_str()) } else { None }).collect();
|
||||
assert!(texts.iter().any(|t| t.ends_with("/2024")), "{texts:?}");
|
||||
assert!(!texts.iter().any(|t| t.starts_with("45")), "serials shown: {texts:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn date1904_counts_from_1904() {
|
||||
assert_eq!(category_text(0.0, "m/d/yyyy", 1.0, true), "1/1/1904");
|
||||
assert_eq!(category_text(1462.0, "m/d/yyyy", 1.0, false), "1/1/1904");
|
||||
let x = format!(
|
||||
r#"<c:chartSpace {NS}><c:date1904 val="1"/><c:chart><c:plotArea><c:lineChart><c:ser><c:cat><c:numRef><c:numCache><c:formatCode>yyyy-mm-dd</c:formatCode><c:ptCount val="1"/><c:pt idx="0"><c:v>366</c:v></c:pt></c:numCache></c:numRef></c:cat><c:val><c:numLit><c:ptCount val="1"/><c:pt idx="0"><c:v>1</c:v></c:pt></c:numLit></c:val></c:ser></c:lineChart><c:catAx/><c:valAx/></c:plotArea></c:chart></c:chartSpace>"#
|
||||
);
|
||||
let chart = Chart::parse(&crate::xml::parse(x.as_bytes()).unwrap(), &theme()).expect("chart");
|
||||
assert_eq!(chart.cats, ["1905-01-01"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn date_codes_show_times_weekdays_and_early_serials() {
|
||||
let t = |v: f64, code: &str| category_text(v, code, 1.0, false);
|
||||
// 45292 is Monday 1 January 2024; 13:05:09 into it.
|
||||
let at = 45292.0 + (13.0 * 3600.0 + 5.0 * 60.0 + 9.0) / 86_400.0;
|
||||
assert_eq!(t(at, "hh:mm:ss"), "13:05:09");
|
||||
assert_eq!(t(at, "h:mm AM/PM"), "1:05 PM");
|
||||
assert_eq!(t(at, "m:ss"), "5:09");
|
||||
assert_eq!(t(45292.0, "h:mm a/p"), "12:00 A");
|
||||
assert_eq!(t(at, "mm/dd"), "01/01");
|
||||
assert_eq!(t(45292.0, "dddd, mmmm d"), "Monday, January 1");
|
||||
assert_eq!(t(45292.0, "ddd d mmm"), "Mon 1 Jan");
|
||||
// Serials before March 1900, Excel's phantom 29 February included.
|
||||
assert_eq!(t(0.0, "m/d/yyyy"), "1/0/1900");
|
||||
assert_eq!(t(1.0, "m/d/yyyy"), "1/1/1900");
|
||||
assert_eq!(t(59.0, "m/d/yyyy"), "2/28/1900");
|
||||
assert_eq!(t(60.0, "m/d/yyyy"), "2/29/1900");
|
||||
assert_eq!(t(61.0, "m/d/yyyy dddd"), "3/1/1900 Thursday");
|
||||
// Outside the calendar: a number.
|
||||
assert_eq!(t(-1.0, "m/d/yyyy"), "-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_sections_and_scientific_numbers() {
|
||||
assert_eq!(format_value(-5.0, "#,##0;(#,##0)", 1.0), "(5)");
|
||||
assert_eq!(format_value(5.0, "#,##0;(#,##0)", 1.0), "5");
|
||||
assert_eq!(format_value(-1500.0, "#,##0_);(#,##0)", 500.0), "(1,500)");
|
||||
assert_eq!(format_value(-2.0, "0\";\"0;-0", 1.0), "-2");
|
||||
assert_eq!(format_value(12345.0, "0.00E+00", 1.0), "1.23E+04");
|
||||
assert_eq!(format_value(0.00012, "0.0E+0", 0.0001), "1.2E-4");
|
||||
assert_eq!(format_value(0.0, "0.00E+00", 1.0), "0.00E+00");
|
||||
assert_eq!(format_value(-12345.0, "0.0E-00", 1.0), "-1.2E04");
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
//! `w:altChunk` (ECMA-376 §17.17.2.1): content kept in another format (HTML, MHT, RTF, plain
|
||||
//! text, another WordprocessingML document) that Word brings into the document where the chunk
|
||||
//! stands when it opens the file. A nested WordprocessingML package is read here; other formats
|
||||
//! go to the caller's [`ChunkImporter`] (the format crates sit beside this one), and without one
|
||||
//! the chunk is left out.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::{Block, Blocks, Document};
|
||||
|
||||
use super::story::StoryCtx;
|
||||
use super::{Reader, part_of};
|
||||
use crate::package::{ContentTypes, MAX_PART, MAX_TOTAL, Package, Rels, rt};
|
||||
use crate::xml::El;
|
||||
|
||||
/// Most chunks brought in from one file (nested documents' chunks included).
|
||||
pub(crate) const MAX_CHUNKS: usize = 256;
|
||||
/// Deepest a WordprocessingML chunk may nest in another.
|
||||
pub(crate) const MAX_CHUNK_NESTING: u8 = 4;
|
||||
|
||||
/// An alternative format chunk's content, as a [`ChunkImporter`] gets it.
|
||||
pub struct AltChunk<'a> {
|
||||
/// The part's content type (`application/xhtml+xml`, `message/rfc822`, `application/rtf`,
|
||||
/// `text/plain`…), empty when the package doesn't give one.
|
||||
pub content_type: &'a str,
|
||||
/// The part's name (`word/afchunk.mht`): its extension tells the format when the content type
|
||||
/// doesn't.
|
||||
pub part: &'a str,
|
||||
pub bytes: &'a [u8],
|
||||
}
|
||||
|
||||
/// Turns a chunk the docx reader can't read itself into a document whose body takes the chunk's
|
||||
/// place; `None` leaves the chunk out.
|
||||
pub type ChunkImporter<'a> = &'a dyn Fn(&AltChunk) -> Option<Document>;
|
||||
|
||||
/// How [`crate::read_with`] reads a package.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct ReadOptions<'a> {
|
||||
/// The password of an encrypted package.
|
||||
pub password: Option<&'a str>,
|
||||
/// Imports `w:altChunk` content in formats other than WordprocessingML.
|
||||
pub chunks: Option<ChunkImporter<'a>>,
|
||||
}
|
||||
|
||||
impl Reader<'_> {
|
||||
/// Bring the chunk `e` names into `out` where it stands (`depth`: of the XML around it).
|
||||
pub(crate) fn read_alt_chunk(&mut self, sc: &mut StoryCtx, e: &El, rels: &Rels, out: &mut Blocks, depth: usize) {
|
||||
let Some(part) = e.attr("r:id").and_then(|id| part_of(rels, id, rt::AF_CHUNK)) else { return };
|
||||
let pkg = self.pkg;
|
||||
let Some(bytes) = pkg.get(&part) else {
|
||||
log::warn!("docx: alternative format chunk {part} is missing");
|
||||
return;
|
||||
};
|
||||
// Every chunk's bytes count against one budget for the whole file, however often the same
|
||||
// part is named and however deep chunks nest.
|
||||
if self.chunks.left == 0 || bytes.len() as u64 > self.chunks.bytes {
|
||||
log::warn!("docx: more than {MAX_CHUNKS} alternative format chunks or {MAX_TOTAL} bytes of them; the rest are left out");
|
||||
return;
|
||||
}
|
||||
self.chunks.left -= 1;
|
||||
self.chunks.bytes -= bytes.len() as u64;
|
||||
let ct = self.content_types.get_or_insert_with(|| ContentTypes::read(pkg)).of(&part).unwrap_or("").to_string();
|
||||
let chunk = AltChunk { content_type: &ct, part: &part, bytes };
|
||||
// A WordprocessingML chunk is a package; one that isn't Word's fails to read (logged).
|
||||
let is_docx = bytes.starts_with(b"PK\x03\x04");
|
||||
let doc = if is_docx { self.read_nested(bytes, depth) } else { self.chunks.import.and_then(|f| f(&chunk)) };
|
||||
let Some(mut doc) = doc else {
|
||||
log::warn!("docx: alternative format chunk {part} ({ct}) is in a format WordCraft can't bring in");
|
||||
return;
|
||||
};
|
||||
// The chunk flows in the section it stands in; its own section breaks don't come along.
|
||||
for b in &mut doc.body {
|
||||
if let Block::Para(p) = &**b
|
||||
&& p.section.is_some()
|
||||
&& let Block::Para(p) = Arc::make_mut(b)
|
||||
{
|
||||
p.section = None;
|
||||
}
|
||||
}
|
||||
// `w:matchSrc`: a Word document's content keeps its own styles (other formats' formatting
|
||||
// is their own already).
|
||||
let keep = is_docx && e.child("w:altChunkPr").and_then(|p| p.child("w:matchSrc")).is_some_and(crate::units::on_off);
|
||||
let mut blocks = self.doc.adopt_blocks(doc, keep, &mut self.next_perm);
|
||||
// Bookmarks and comment marks read before the chunk stay before it.
|
||||
self.flush_pending_before(sc, out, &mut blocks);
|
||||
out.extend(blocks.into_iter().map(Arc::new));
|
||||
}
|
||||
|
||||
/// A WordprocessingML chunk: its document, read with the same importer and what is left of
|
||||
/// the budgets, its XML depth counting on from `depth`.
|
||||
fn read_nested(&mut self, bytes: &[u8], depth: usize) -> Option<Document> {
|
||||
if self.chunks.nesting >= MAX_CHUNK_NESTING {
|
||||
log::warn!("docx: alternative format chunks nest deeper than {MAX_CHUNK_NESTING}; the inner ones are left out");
|
||||
return None;
|
||||
}
|
||||
let pkg =
|
||||
Package::open_limited(bytes, MAX_PART, self.chunks.bytes).map_err(|e| log::warn!("docx: unreadable nested document chunk: {e}")).ok()?;
|
||||
self.chunks.bytes = self.chunks.bytes.saturating_sub(pkg.size());
|
||||
let state = ChunkState { nesting: self.chunks.nesting + 1, depth: depth + 1, ..self.chunks };
|
||||
let (doc, left, bytes, graphic) =
|
||||
super::read_opened_in(&pkg, state).map_err(|e| log::warn!("docx: unreadable nested document chunk: {e}")).ok()?;
|
||||
self.chunks.left = left;
|
||||
self.chunks.bytes = bytes;
|
||||
self.chunks.graphic = graphic;
|
||||
Some(doc)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a reader may still bring in from chunks.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct ChunkState<'a> {
|
||||
/// Chunks left in the file's budget ([`MAX_CHUNKS`]).
|
||||
pub left: usize,
|
||||
/// Chunk bytes (nested packages unpacked) left in the file's budget.
|
||||
pub bytes: u64,
|
||||
/// Graphic work left in the file's budget ([`super::MAX_GRAPHIC_WORK`]): nested documents'
|
||||
/// charts and diagrams count against it too.
|
||||
pub graphic: usize,
|
||||
/// How deep in nested WordprocessingML chunks this reader is.
|
||||
pub nesting: u8,
|
||||
/// The XML depth its stories start at (a nested document's: that of the chunk naming it).
|
||||
pub depth: usize,
|
||||
pub import: Option<ChunkImporter<'a>>,
|
||||
}
|
||||
|
||||
impl<'a> ChunkState<'a> {
|
||||
pub fn new(import: Option<ChunkImporter<'a>>) -> Self {
|
||||
ChunkState { left: MAX_CHUNKS, bytes: MAX_TOTAL, graphic: super::MAX_GRAPHIC_WORK, nesting: 0, depth: 0, import }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io::Write;
|
||||
|
||||
use wordcraft_doc::{Block, Document, InlineObject};
|
||||
|
||||
use super::ChunkState;
|
||||
use crate::package::Package;
|
||||
|
||||
/// A package whose body is `body`, with the document relationship `rel` (id, type, target) to
|
||||
/// the part `(name, bytes)`.
|
||||
fn package(body: &str, (id, ty, target): (&str, &str, &str), (name, bytes): (&str, &[u8])) -> Vec<u8> {
|
||||
let ns = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main""#;
|
||||
let rels =
|
||||
|inner: &str| format!(r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{inner}</Relationships>"#);
|
||||
let rel = |id: &str, ty: &str, target: &str| {
|
||||
format!(r#"<Relationship Id="{id}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/{ty}" Target="{target}"/>"#)
|
||||
};
|
||||
let files: [(&str, Vec<u8>); 4] = [
|
||||
("_rels/.rels", rels(&rel("rId1", "officeDocument", "word/document.xml")).into_bytes()),
|
||||
("word/document.xml", format!(r#"<w:document {ns}><w:body>{body}</w:body></w:document>"#).into_bytes()),
|
||||
("word/_rels/document.xml.rels", rels(&rel(id, ty, target)).into_bytes()),
|
||||
(name, bytes.to_vec()),
|
||||
];
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, bytes) in files {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(&bytes).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
/// Whether the body's first paragraph's chart is drawn.
|
||||
fn drawn(d: &Document) -> bool {
|
||||
let Some(Block::Para(p)) = d.body.first().map(|b| &**b) else { panic!("no paragraph") };
|
||||
p.objects.iter().any(|o| matches!(o, InlineObject::Graphic { graphic, .. } if !graphic.items.is_empty()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nested_documents_charts_count_against_the_files_graphic_budget() {
|
||||
let chart = r#"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart><c:plotArea><c:barChart><c:ser><c:val><c:numLit><c:ptCount val="2"/><c:pt idx="0"><c:v>1</c:v></c:pt><c:pt idx="1"><c:v>2</c:v></c:pt></c:numLit></c:val></c:ser></c:barChart></c:plotArea></c:chart></c:chartSpace>"#;
|
||||
let drawing = r#"<w:p><w:r><w:drawing><wp:inline><wp:extent cx="2743200" cy="1828800"/><wp:docPr id="5" name="G"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" r:id="rIdChart"/></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>"#;
|
||||
let inner = package(drawing, ("rIdChart", "chart", "charts/chart1.xml"), ("word/charts/chart1.xml", chart.as_bytes()));
|
||||
let outer = package(r#"<w:altChunk r:id="rIdC"/>"#, ("rIdC", "aFChunk", "afchunk.docx"), ("word/afchunk.docx", &inner));
|
||||
let pkg = Package::open(&outer).unwrap();
|
||||
let read = |graphic| {
|
||||
let (d, .., left) = super::super::read_opened_in(&pkg, ChunkState { graphic, ..ChunkState::new(None) }).unwrap();
|
||||
(drawn(&d), left)
|
||||
};
|
||||
let (shown, left) = read(super::super::MAX_GRAPHIC_WORK);
|
||||
assert!(shown);
|
||||
assert!(left < super::super::MAX_GRAPHIC_WORK, "the nested chart's work is the file's");
|
||||
// With the file's budget spent, the nested document doesn't get one of its own.
|
||||
assert_eq!(read(0), (false, 0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
use super::{
|
||||
Arc, Blocks, Comment, DocxError, El, HashMap, MAX_PARTS, MAX_SOURCES, MAX_TEMPLATE_PATH, NoteKind, NumFormat, Paragraph, PartKind, Reader, Rels,
|
||||
Seen, StoryCtx, Zoom, ZoomFit, int, math, on_off, para_block, props, tw, u32_of,
|
||||
};
|
||||
|
||||
impl Reader<'_> {
|
||||
pub(super) fn read_settings(&mut self, path: &str) -> Result<(), DocxError> {
|
||||
let Some(root) = self.xml(path)? else { return Ok(()) };
|
||||
if let Some(b) = self.pkg.get(path).filter(|b| crate::write::settings_has_unowned(b)) {
|
||||
self.doc.passthrough.insert(crate::package::SETTINGS_ORIG.into(), Arc::new(b.to_vec()));
|
||||
}
|
||||
let rels = self.pkg.rels(path);
|
||||
let s = &mut self.doc.settings;
|
||||
for k in root.els() {
|
||||
match k.name.as_str() {
|
||||
// §17.15.1.6: the template, by a relationship (usually external) of the settings part.
|
||||
"w:attachedTemplate" => {
|
||||
s.attached_template = k
|
||||
.attr("r:id")
|
||||
.and_then(|id| rels.by_id(id))
|
||||
.map(|r| r.target.trim().to_string())
|
||||
.filter(|t| !t.is_empty() && t.len() <= MAX_TEMPLATE_PATH);
|
||||
}
|
||||
"w:linkStyles" => s.link_styles = on_off(k),
|
||||
"w:removePersonalInformation" => s.remove_personal_info = on_off(k),
|
||||
"w:removeDateAndTime" => s.remove_date_time = on_off(k),
|
||||
"w:compat" => {
|
||||
s.allow_space_of_same_style_in_table = k.child("w:allowSpaceOfSameStyleInTable").is_some_and(on_off);
|
||||
// Only Word's own setting: another `w:uri` names a different application's.
|
||||
let mode = k
|
||||
.children("w:compatSetting")
|
||||
.find(|c| {
|
||||
c.attr("w:name") == Some("compatibilityMode")
|
||||
&& c.attr("w:uri").is_none_or(|u| u == "http://schemas.microsoft.com/office/word")
|
||||
})
|
||||
.and_then(|c| c.attr("w:val"));
|
||||
if let Some(m) = mode.and_then(u32_of) {
|
||||
s.compat_mode = m.clamp(11, 99);
|
||||
}
|
||||
s.sum_paragraph_spacing = k.children("w:doNotUseHTMLParagraphAutoSpacing").any(on_off);
|
||||
s.do_not_expand_shift_return = k.children("w:doNotExpandShiftReturn").any(on_off);
|
||||
}
|
||||
"w:trackRevisions" => s.track_changes = on_off(k),
|
||||
"w:doNotTrackMoves" => s.track_moves = !on_off(k),
|
||||
"w:defaultTabStop" => {
|
||||
if let Some(v) = tw(k, "w:val").filter(|v| *v > 0.0) {
|
||||
s.default_tab = v.clamp(1.0, 1584.0);
|
||||
}
|
||||
}
|
||||
"w:evenAndOddHeaders" => s.even_odd_headers = on_off(k),
|
||||
"w:drawingGridHorizontalSpacing" | "w:drawingGridVerticalSpacing" => {
|
||||
if let Some(v) = tw(k, "w:val").filter(|v| *v > 0.0) {
|
||||
let v = v.clamp(0.5, 1584.0);
|
||||
if k.name == "w:drawingGridHorizontalSpacing" { s.grid_h = v } else { s.grid_v = v }
|
||||
}
|
||||
}
|
||||
"m:mathPr" => s.math = Some(math::read_math_pr(k)),
|
||||
"w:mirrorMargins" => s.mirror_margins = on_off(k),
|
||||
"w:autoHyphenation" => s.auto_hyphenation = on_off(k),
|
||||
"w:doNotHyphenateCaps" => s.hyphenate_caps = !on_off(k),
|
||||
"w:hyphenationZone" => {
|
||||
if let Some(v) = tw(k, "w:val").filter(|v| v.is_finite() && *v >= 0.0) {
|
||||
s.hyphenation_zone = v.min(1584.0);
|
||||
}
|
||||
}
|
||||
"w:consecutiveHyphenLimit" => {
|
||||
if let Some(v) = k.attr("w:val").and_then(u32_of) {
|
||||
s.consecutive_hyphen_limit = v.min(32_767);
|
||||
}
|
||||
}
|
||||
"w:footnotePr" => {
|
||||
if let Some(f) = k.child_val("w:numFmt") {
|
||||
s.footnote_format = NumFormat::from_ooxml(f);
|
||||
}
|
||||
s.footnote_pr = props::note_pr(k, false);
|
||||
}
|
||||
"w:endnotePr" => {
|
||||
if let Some(f) = k.child_val("w:numFmt") {
|
||||
s.endnote_format = NumFormat::from_ooxml(f);
|
||||
}
|
||||
s.endnote_pr = props::note_pr(k, false);
|
||||
}
|
||||
"w:documentProtection" => read_protection(k, s),
|
||||
"w:zoom" => {
|
||||
let percent = k.attr("w:percent").and_then(|v| u32_of(v.trim_end_matches('%'))).unwrap_or(100).clamp(10, 500);
|
||||
let fit = match k.attr("w:val") {
|
||||
Some("fullPage") => Some(ZoomFit::OnePage),
|
||||
Some("bestFit") => Some(ZoomFit::PageWidth),
|
||||
Some("textFit") => Some(ZoomFit::TextWidth),
|
||||
_ => None,
|
||||
};
|
||||
s.zoom = Some(Zoom { percent, fit }).filter(|z| z.percent != 100 || z.fit.is_some());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn read_notes(&mut self, path: &str, foot: bool) -> Result<(), DocxError> {
|
||||
let Some(root) = self.xml(path)? else { return Ok(()) };
|
||||
let rels = self.pkg.rels(path);
|
||||
let tag = if foot { "w:footnote" } else { "w:endnote" };
|
||||
for n in root.children(tag) {
|
||||
match n.attr("w:type") {
|
||||
None | Some("normal") => {}
|
||||
Some(t @ ("separator" | "continuationSeparator" | "continuationNotice")) => {
|
||||
self.read_note_sep(n, &rels, foot, t);
|
||||
continue;
|
||||
}
|
||||
Some(_) => continue,
|
||||
}
|
||||
let Some(fid) = n.attr("w:id").and_then(int) else { continue };
|
||||
if self.doc.parts.len() >= MAX_PARTS {
|
||||
break;
|
||||
}
|
||||
// Reserve the id first: the note's own reference mark points at it.
|
||||
let (pk, kind) = if foot { (PartKind::Footnote, NoteKind::Footnote) } else { (PartKind::Endnote, NoteKind::Endnote) };
|
||||
let id = self.doc.add_part(pk, Blocks::new());
|
||||
let mut sc = StoryCtx::default();
|
||||
let mut blocks = Blocks::new();
|
||||
self.current_note = Some((kind, id));
|
||||
self.read_blocks(&mut sc, n, &rels, &mut blocks, self.chunks.depth);
|
||||
self.flush_pending(&mut sc, &mut blocks);
|
||||
self.current_note = None;
|
||||
if let Some(p) = self.doc.parts.get_mut(&id)
|
||||
&& !blocks.is_empty()
|
||||
{
|
||||
p.blocks = blocks;
|
||||
}
|
||||
if foot {
|
||||
self.footnotes.insert(fid, id);
|
||||
} else {
|
||||
self.endnotes.insert(fid, id);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A document's own separator, continuation separator or continuation notice (`kind`, the
|
||||
/// note's `w:type`) for its footnotes or endnotes. The first of each kind counts.
|
||||
fn read_note_sep(&mut self, n: &El, rels: &Rels, foot: bool, kind: &str) {
|
||||
let seps = if foot { &self.doc.footnote_separators } else { &self.doc.endnote_separators };
|
||||
let taken = match kind {
|
||||
"separator" => seps.separator.is_some(),
|
||||
"continuationSeparator" => seps.continuation_separator.is_some(),
|
||||
_ => seps.continuation_notice.is_some(),
|
||||
};
|
||||
if taken {
|
||||
return;
|
||||
}
|
||||
let mut sc = StoryCtx::default();
|
||||
let mut blocks = Blocks::new();
|
||||
self.in_note_sep = true;
|
||||
self.read_blocks(&mut sc, n, rels, &mut blocks, self.chunks.depth);
|
||||
self.flush_pending(&mut sc, &mut blocks);
|
||||
self.in_note_sep = false;
|
||||
if blocks.is_empty() {
|
||||
blocks.push(para_block(Paragraph::new()));
|
||||
}
|
||||
let seps = if foot { &mut self.doc.footnote_separators } else { &mut self.doc.endnote_separators };
|
||||
let slot = match kind {
|
||||
"separator" => &mut seps.separator,
|
||||
"continuationSeparator" => &mut seps.continuation_separator,
|
||||
_ => &mut seps.continuation_notice,
|
||||
};
|
||||
*slot = Some(blocks);
|
||||
}
|
||||
|
||||
/// The extended properties a save carries over ([`crate::package::APP_KEPT_PROPS`]).
|
||||
pub(super) fn read_app(&mut self, path: &str) {
|
||||
let Ok(Some(root)) = self.xml(path) else { return };
|
||||
let mut kept = String::new();
|
||||
for e in root.els() {
|
||||
let name = e.local();
|
||||
let value = e.text();
|
||||
if crate::package::APP_KEPT_PROPS.contains(&name) && value.len() <= 2048 && !value.contains(['\t', '\n']) {
|
||||
kept.push_str(&format!("{name}\t{value}\n"));
|
||||
}
|
||||
}
|
||||
if !kept.is_empty() {
|
||||
self.doc.passthrough.insert(crate::package::APP_KEPT.into(), Arc::new(kept.into_bytes()));
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn read_comments(&mut self, path: &str, ext: Option<&str>, ids: Option<&str>, cex: Option<&str>) -> Result<(), DocxError> {
|
||||
let Some(root) = self.xml(path)? else { return Ok(()) };
|
||||
let rels = self.pkg.rels(path);
|
||||
let mut by_para: HashMap<String, u32> = HashMap::new();
|
||||
let mut next_id = 0u32;
|
||||
// Straight into the document, beside the comments chunks in notes brought in.
|
||||
for c in root.children("w:comment") {
|
||||
if self.doc.parts.len() >= MAX_PARTS {
|
||||
break;
|
||||
}
|
||||
let file_id = c.attr("w:id").unwrap_or("").to_string();
|
||||
if self.comment_map.contains_key(&file_id) {
|
||||
continue;
|
||||
}
|
||||
let id = match file_id.parse::<u32>() {
|
||||
Ok(v) if !self.doc.comments.contains_key(&v) => v,
|
||||
_ => {
|
||||
// Every id handed out so far is a key of `doc.comments` (inserted below).
|
||||
while self.doc.comments.contains_key(&next_id) {
|
||||
next_id += 1;
|
||||
}
|
||||
next_id
|
||||
}
|
||||
};
|
||||
// Taken before its text is read: a chunk there brings comments of its own.
|
||||
self.doc.comments.insert(id, Comment::default());
|
||||
let mut sc = StoryCtx::default();
|
||||
let mut blocks = Blocks::new();
|
||||
self.read_blocks(&mut sc, c, &rels, &mut blocks, self.chunks.depth);
|
||||
self.flush_pending(&mut sc, &mut blocks);
|
||||
let part = self.doc.add_part(PartKind::Comment, blocks);
|
||||
if let Some(pid) = c.children("w:p").last().and_then(|p| p.attr("w14:paraId")) {
|
||||
by_para.insert(pid.to_string(), id);
|
||||
}
|
||||
self.doc.comments.insert(
|
||||
id,
|
||||
Comment {
|
||||
author: c.attr("w:author").unwrap_or("").to_string(),
|
||||
initials: c.attr("w:initials").unwrap_or("").to_string(),
|
||||
date: c.attr("w:date").unwrap_or("").to_string(),
|
||||
parent: None,
|
||||
resolved: false,
|
||||
part,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
self.comment_map.insert(file_id, id);
|
||||
}
|
||||
if let Some(ext) = ext
|
||||
&& let Ok(Some(x)) = self.xml(ext)
|
||||
{
|
||||
for e in x.els().filter(|e| e.local() == "commentEx") {
|
||||
let Some(cid) = e.attr("w15:paraId").and_then(|p| by_para.get(p)).copied() else { continue };
|
||||
let parent = e.attr("w15:paraIdParent").and_then(|p| by_para.get(p)).copied().filter(|p| *p != cid);
|
||||
let done = e.attr("w15:done").is_some_and(|v| v == "1" || v == "true");
|
||||
if let Some(c) = self.doc.comments.get_mut(&cid) {
|
||||
c.parent = parent;
|
||||
c.resolved = done;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Durable ids (by paragraph id) and UTC dates (by durable id), which the writer
|
||||
// carries over (`write_comment_ids`). Their namespaces (w16cid, w16cex) have no canonical
|
||||
// prefix here, so attributes are found by local name.
|
||||
let local = |e: &crate::xml::El, name: &str| -> Option<String> {
|
||||
e.attrs.iter().find(|(k, _)| k.rsplit(':').next() == Some(name)).map(|(_, v)| v.clone())
|
||||
};
|
||||
let mut by_durable: HashMap<String, u32> = HashMap::new();
|
||||
if let Some(ids) = ids
|
||||
&& let Ok(Some(x)) = self.xml(ids)
|
||||
{
|
||||
for e in x.els().filter(|e| e.local() == "commentId") {
|
||||
let Some(cid) = local(e, "paraId").and_then(|p| by_para.get(&p).copied()) else { continue };
|
||||
let Some(durable) = local(e, "durableId").filter(|d| crate::write::durable_id_ok(d)) else { continue };
|
||||
if let Some(c) = self.doc.comments.get_mut(&cid) {
|
||||
c.durable_id = Some(durable.to_ascii_uppercase());
|
||||
by_durable.insert(durable.to_ascii_uppercase(), cid);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(cex) = cex
|
||||
&& let Ok(Some(x)) = self.xml(cex)
|
||||
{
|
||||
for e in x.els().filter(|e| e.local() == "commentExtensible") {
|
||||
let Some(cid) = local(e, "durableId").and_then(|d| by_durable.get(&d.to_ascii_uppercase()).copied()) else { continue };
|
||||
let date = local(e, "dateUtc").filter(|d| d.len() <= 40 && d.chars().all(|c| c.is_ascii_alphanumeric() || ":-.+".contains(c)));
|
||||
if let (Some(c), Some(date)) = (self.doc.comments.get_mut(&cid), date) {
|
||||
c.date_utc = Some(date);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sources from a `b:Sources` custom XML part (other custom XML is left alone); at most
|
||||
/// [`MAX_SOURCES`] in all, and the document's citation style from `StyleName`.
|
||||
pub(super) fn read_bibliography(&mut self, path: &str, seen: &mut Seen) -> Result<(), DocxError> {
|
||||
let Some(root) = self.xml(path)? else { return Ok(()) };
|
||||
if root.local() != "Sources" {
|
||||
return Ok(());
|
||||
}
|
||||
let room = MAX_SOURCES.saturating_sub(self.doc.sources.len());
|
||||
let bib = crate::bibliography::parse(&root, seen, room);
|
||||
if self.doc.settings.bib_style.is_none() {
|
||||
self.doc.settings.bib_style = bib.style;
|
||||
}
|
||||
self.doc.sources.extend(bib.sources);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn read_core(&mut self, path: &str) {
|
||||
let Ok(Some(root)) = self.xml(path) else { return };
|
||||
let c = &mut self.doc.core;
|
||||
for k in root.els() {
|
||||
let t = k.text().trim().to_string();
|
||||
match k.name.as_str() {
|
||||
"dc:title" => c.title = t,
|
||||
"dc:subject" => c.subject = t,
|
||||
"dc:creator" => c.creator = t,
|
||||
"cp:keywords" => c.keywords = t,
|
||||
"dc:description" => c.description = t,
|
||||
"cp:lastModifiedBy" => c.last_modified_by = t,
|
||||
"cp:revision" => c.revision = u32_of(&t).unwrap_or(0),
|
||||
"dcterms:created" => c.created = t,
|
||||
"dcterms:modified" => c.modified = t,
|
||||
"cp:category" => c.category = t,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The password hash attributes of `w:documentProtection` that are kept (ECMA-376 §17.15.1.29:
|
||||
/// the current `algorithmName`/`hashValue`/`saltValue`/`spinCount`, and the older transitional
|
||||
/// `crypt*`/`hash`/`salt` set).
|
||||
pub(crate) const PROTECTION_HASH_ATTRS: [&str; 16] = [
|
||||
"w:algorithmName",
|
||||
"w:hashValue",
|
||||
"w:saltValue",
|
||||
"w:spinCount",
|
||||
"w:cryptProviderType",
|
||||
"w:cryptAlgorithmClass",
|
||||
"w:cryptAlgorithmType",
|
||||
"w:cryptAlgorithmSid",
|
||||
"w:cryptSpinCount",
|
||||
"w:cryptProvider",
|
||||
"w:algIdExt",
|
||||
"w:algIdExtSource",
|
||||
"w:cryptProviderTypeExt",
|
||||
"w:cryptProviderTypeExtSource",
|
||||
"w:hash",
|
||||
"w:salt",
|
||||
];
|
||||
|
||||
/// Restrict Editing (`w:documentProtection`, §17.15.1.29): the editing restriction, whether
|
||||
/// formatting is limited to unlocked styles, and the password hash. Only enforced protection is
|
||||
/// kept; a hostile attribute value can't be longer than a hash needs.
|
||||
fn read_protection(k: &El, s: &mut wordcraft_doc::Settings) {
|
||||
let on = |v: &str| !matches!(v, "0" | "false" | "off");
|
||||
if !k.attr("w:enforcement").is_some_and(on) {
|
||||
return;
|
||||
}
|
||||
let edit = k.attr("w:edit").filter(|e| matches!(*e, "readOnly" | "comments" | "trackedChanges" | "forms"));
|
||||
let formatting = k.attr("w:formatting").is_some_and(on);
|
||||
if edit.is_none() && !formatting {
|
||||
return;
|
||||
}
|
||||
s.protection = Some(edit.unwrap_or("none").to_string());
|
||||
s.protect_formatting = formatting;
|
||||
s.protect_hash = PROTECTION_HASH_ATTRS
|
||||
.iter()
|
||||
.filter_map(|n| k.attr(n).filter(|v| !v.is_empty() && v.len() <= 1024).map(|v| (n.to_string(), v.to_string())))
|
||||
.collect();
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,256 @@
|
||||
use super::{
|
||||
Arc, Blocks, ContentTypes, DocxError, El, MAX_FONT_NAME, MAX_HF_DEPTH, MAX_PARTS, MAX_TABLE_FONTS, MAX_VBA_RELATED, PartKind, Reader, Rels,
|
||||
Revision, RevisionKind, Rgb, StoryCtx, VBA_PROJECT_PART, VBA_RELATED, props, shape_style,
|
||||
};
|
||||
|
||||
impl Reader<'_> {
|
||||
/// Keep the parts the VBA project at `project` relates to, with their relationship types and
|
||||
/// content types (see [`VBA_RELATED`]). Parts outside `word/`, relationship parts, the project
|
||||
/// itself and missing targets are skipped (logged); at most [`MAX_VBA_RELATED`] are kept.
|
||||
pub(super) fn read_vba_related(&mut self, project: &str) {
|
||||
let pkg = self.pkg;
|
||||
let types = self.content_types.get_or_insert_with(|| ContentTypes::read(pkg));
|
||||
let mut manifest = String::new();
|
||||
let mut kept = 0usize;
|
||||
for rel in self.pkg.rels(project).list.iter().filter(|r| !r.external) {
|
||||
// Part names compare case-insensitively (OPC): `/WORD/x` names the part the writer
|
||||
// stores as `word/x`.
|
||||
let target = rel.target.as_str();
|
||||
let path = match (target.get(..5), target.get(5..)) {
|
||||
(Some(dir), Some(rest)) if dir.eq_ignore_ascii_case("word/") => format!("word/{rest}"),
|
||||
_ => target.to_string(),
|
||||
};
|
||||
let path = path.as_str();
|
||||
let ct = types.of(path).unwrap_or("application/octet-stream");
|
||||
let usable = path.strip_prefix("word/").is_some_and(|t| !t.is_empty())
|
||||
&& !path.to_ascii_lowercase().ends_with(".rels")
|
||||
&& !path.eq_ignore_ascii_case(project)
|
||||
&& !path.eq_ignore_ascii_case(VBA_PROJECT_PART)
|
||||
&& ![rel.kind.as_str(), path, ct].iter().any(|f| f.is_empty() || f.contains(['\t', '\r', '\n']));
|
||||
if !usable {
|
||||
log::warn!("docx: not carrying VBA-related part {path:?}");
|
||||
continue;
|
||||
}
|
||||
let Some(bytes) = self.pkg.get_shared(path) else {
|
||||
log::warn!("docx: VBA-related part {path} is missing; keeping the project without it");
|
||||
continue;
|
||||
};
|
||||
if kept >= MAX_VBA_RELATED {
|
||||
log::warn!("docx: more than {MAX_VBA_RELATED} VBA-related parts; the rest are dropped");
|
||||
break;
|
||||
}
|
||||
kept += 1;
|
||||
manifest.push_str(&format!("{}\t{path}\t{ct}\n", rel.kind));
|
||||
self.doc.passthrough.entry(path.to_string()).or_insert_with(|| bytes.clone());
|
||||
}
|
||||
if !manifest.is_empty() {
|
||||
self.doc.passthrough.insert(VBA_RELATED.into(), Arc::new(manifest.into_bytes()));
|
||||
}
|
||||
}
|
||||
|
||||
/// The fonts the font table classes as roman (`w:font/w:family w:val="roman"`).
|
||||
pub(super) fn read_font_families(&mut self, path: &str) {
|
||||
let Ok(Some(table)) = self.xml(path) else { return };
|
||||
self.doc.settings.roman_fonts = table
|
||||
.children("w:font")
|
||||
.filter(|f| f.child("w:family").and_then(|v| v.attr("w:val")) == Some("roman"))
|
||||
.filter_map(|f| f.attr("w:name"))
|
||||
.filter(|n| !n.is_empty() && n.len() <= MAX_FONT_NAME)
|
||||
.take(MAX_TABLE_FONTS)
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
}
|
||||
|
||||
pub(super) fn xml(&self, path: &str) -> Result<Option<El>, DocxError> {
|
||||
self.pkg.xml(path)
|
||||
}
|
||||
|
||||
pub fn revision(&mut self, kind: RevisionKind, e: &El) -> u32 {
|
||||
self.revision_named(kind, e, None)
|
||||
}
|
||||
|
||||
pub(super) fn revision_named(&mut self, kind: RevisionKind, e: &El, move_name: Option<String>) -> u32 {
|
||||
let author = e.attr("w:author").unwrap_or("").chars().take(256).collect::<String>();
|
||||
let date = e.attr("w:date").unwrap_or("").chars().take(64).collect::<String>();
|
||||
let k = match kind {
|
||||
RevisionKind::Insert => 0,
|
||||
RevisionKind::Delete => 1,
|
||||
RevisionKind::Format => 2,
|
||||
};
|
||||
let key = (k, author.clone(), date.clone(), move_name.clone());
|
||||
if let Some(i) = self.revs.get(&key) {
|
||||
return *i;
|
||||
}
|
||||
let i = self.doc.revisions.len() as u32;
|
||||
self.doc.revisions.push(Revision { kind, author, date, move_name });
|
||||
self.revs.insert(key, i);
|
||||
i
|
||||
}
|
||||
|
||||
/// Media key for an image relationship (loads the bytes once).
|
||||
pub fn media_for(&mut self, rels: &Rels, id: &str) -> Option<String> {
|
||||
let rel = rels.by_id(id)?;
|
||||
if rel.external {
|
||||
return None;
|
||||
}
|
||||
if let Some(k) = self.media_by_path.get(&rel.target) {
|
||||
return Some(k.clone());
|
||||
}
|
||||
let bytes = self.pkg.get_shared(&rel.target)?;
|
||||
let base: String =
|
||||
rel.target.rsplit('/').next().unwrap_or("image").chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')).collect();
|
||||
let base = if base.is_empty() || base.starts_with('.') { format!("image{base}") } else { base };
|
||||
let mut key = base.clone();
|
||||
let mut n = 1;
|
||||
while self.doc.media.contains_key(&key) {
|
||||
n += 1;
|
||||
key = match base.rsplit_once('.') {
|
||||
Some((stem, ext)) => format!("{stem}_{n}.{ext}"),
|
||||
None => format!("{base}_{n}"),
|
||||
};
|
||||
}
|
||||
self.doc.media.insert(key.clone(), bytes.clone());
|
||||
self.media_by_path.insert(rel.target.clone(), key.clone());
|
||||
Some(key)
|
||||
}
|
||||
|
||||
/// A chart or diagram part, parsed once however often it's referenced (`None` when unreadable).
|
||||
pub(super) fn graphic_part(&mut self, path: &str) -> Option<Arc<El>> {
|
||||
if let Some(p) = self.graphic_parts.get(path) {
|
||||
return p.clone();
|
||||
}
|
||||
let p = self.xml(path).ok().flatten().map(Arc::new);
|
||||
self.graphic_parts.insert(path.to_string(), p.clone());
|
||||
p
|
||||
}
|
||||
|
||||
pub fn load_header_footer(&mut self, path: &str, footer: bool) -> Option<u32> {
|
||||
if let Some(id) = self.hf_by_path.get(path) {
|
||||
return Some(*id);
|
||||
}
|
||||
if self.doc.parts.len() >= MAX_PARTS || self.hf_depth >= MAX_HF_DEPTH {
|
||||
return None;
|
||||
}
|
||||
// Reserve the id first so a (malformed) self-reference can't recurse.
|
||||
let kind = if footer { PartKind::Footer } else { PartKind::Header };
|
||||
let id = self.doc.add_part(kind, Blocks::new());
|
||||
self.hf_by_path.insert(path.to_string(), id);
|
||||
let root = match self.xml(path) {
|
||||
Ok(Some(r)) => r,
|
||||
_ => return Some(id),
|
||||
};
|
||||
let rels = self.pkg.rels(path);
|
||||
let mut sc = StoryCtx::default();
|
||||
sc.in_hf = true;
|
||||
let mut blocks = Blocks::new();
|
||||
self.hf_depth += 1;
|
||||
self.read_blocks(&mut sc, &root, &rels, &mut blocks, self.chunks.depth);
|
||||
self.hf_depth = self.hf_depth.saturating_sub(1);
|
||||
self.flush_pending(&mut sc, &mut blocks);
|
||||
if let Some(p) = self.doc.parts.get_mut(&id)
|
||||
&& !blocks.is_empty()
|
||||
{
|
||||
p.blocks = blocks;
|
||||
}
|
||||
Some(id)
|
||||
}
|
||||
|
||||
pub(super) fn read_theme(&mut self, path: &str) {
|
||||
let Ok(Some(t)) = self.xml(path) else { return };
|
||||
if let Some(n) = t.attr("name").filter(|n| !n.is_empty()) {
|
||||
self.doc.settings.theme_name = n.to_string();
|
||||
}
|
||||
let Some(el) = t.child("a:themeElements") else { return };
|
||||
if let Some(cs) = el.child("a:clrScheme") {
|
||||
let names = ["dk1", "lt1", "dk2", "lt2", "accent1", "accent2", "accent3", "accent4", "accent5", "accent6", "hlink", "folHlink"];
|
||||
let mut colors = self.doc.settings.theme_colors.clone();
|
||||
colors.resize(12, Rgb::BLACK);
|
||||
for (i, n) in names.iter().enumerate() {
|
||||
let Some(c) = cs.child(&format!("a:{n}")) else { continue };
|
||||
let rgb = c
|
||||
.child("a:srgbClr")
|
||||
.and_then(|x| x.attr("val"))
|
||||
.and_then(Rgb::parse)
|
||||
.or_else(|| c.child("a:sysClr").and_then(|x| x.attr("lastClr")).and_then(Rgb::parse));
|
||||
if let (Some(rgb), Some(slot)) = (rgb, colors.get_mut(i)) {
|
||||
*slot = rgb;
|
||||
}
|
||||
}
|
||||
self.pc.theme_colors = colors.clone();
|
||||
self.doc.settings.theme_colors = colors;
|
||||
}
|
||||
self.theme_fills = shape_style::ThemeFills::read(el.child("a:fmtScheme"));
|
||||
if let Some(fs) = el.child("a:fontScheme") {
|
||||
let face =
|
||||
|n: &str| fs.child(n).and_then(|f| f.child("a:latin")).and_then(|l| l.attr("typeface")).filter(|t| !t.is_empty()).map(str::to_string);
|
||||
if let Some(f) = face("a:majorFont") {
|
||||
self.doc.settings.major_font = f;
|
||||
}
|
||||
if let Some(f) = face("a:minorFont") {
|
||||
self.doc.settings.minor_font = f;
|
||||
}
|
||||
let cs = |n: &str| fs.child(n).and_then(|f| f.child("a:cs")).and_then(|c| c.attr("typeface")).unwrap_or_default().to_string();
|
||||
self.pc.major_cs = cs("a:majorFont");
|
||||
self.pc.minor_cs = cs("a:minorFont");
|
||||
// Per-script fonts (`a:font script="Arab"`); Office's themes list about 30.
|
||||
let scripts = |n: &str| -> Vec<(String, String)> {
|
||||
let Some(f) = fs.child(n) else { return Vec::new() };
|
||||
f.els()
|
||||
.filter(|e| e.name == "a:font")
|
||||
.filter_map(|e| Some((e.attr("script")?, e.attr("typeface")?)))
|
||||
.filter(|(s, t)| s.len() <= 8 && !t.is_empty() && t.len() < 256)
|
||||
.take(256)
|
||||
.map(|(s, t)| (s.to_string(), t.to_string()))
|
||||
.collect()
|
||||
};
|
||||
self.pc.major_scripts = scripts("a:majorFont");
|
||||
self.pc.minor_scripts = scripts("a:minorFont");
|
||||
}
|
||||
self.pc.major_font = self.doc.settings.major_font.clone();
|
||||
self.pc.minor_font = self.doc.settings.minor_font.clone();
|
||||
self.pc.theme = self.doc.settings.theme_colors.clone();
|
||||
}
|
||||
|
||||
/// `w:settings/w:clrSchemeMapping`: which scheme colour text1, background1… stand for. Also
|
||||
/// `w:themeFontLang/@w:bidi`, the language whose script picks the `…Bidi` theme fonts.
|
||||
pub(super) fn read_color_mapping(&mut self, path: &str) {
|
||||
let Ok(Some(root)) = self.xml(path) else { return };
|
||||
self.pc.bidi_script = root.child("w:themeFontLang").and_then(|l| l.attr("w:bidi")).filter(|v| !v.trim().is_empty()).map(props::lang_script);
|
||||
let Some(m) = root.child("w:clrSchemeMapping") else { return };
|
||||
let names = [
|
||||
"dark1",
|
||||
"light1",
|
||||
"dark2",
|
||||
"light2",
|
||||
"accent1",
|
||||
"accent2",
|
||||
"accent3",
|
||||
"accent4",
|
||||
"accent5",
|
||||
"accent6",
|
||||
"hyperlink",
|
||||
"followedHyperlink",
|
||||
];
|
||||
let attrs = [
|
||||
"w:t1",
|
||||
"w:bg1",
|
||||
"w:t2",
|
||||
"w:bg2",
|
||||
"w:accent1",
|
||||
"w:accent2",
|
||||
"w:accent3",
|
||||
"w:accent4",
|
||||
"w:accent5",
|
||||
"w:accent6",
|
||||
"w:hyperlink",
|
||||
"w:followedHyperlink",
|
||||
];
|
||||
let mut map = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
|
||||
for (slot, a) in map.iter_mut().zip(attrs) {
|
||||
if let Some(i) = m.attr(a).and_then(|v| names.iter().position(|n| *n == v)) {
|
||||
*slot = i;
|
||||
}
|
||||
}
|
||||
self.pc.color_map = Some(map);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user