mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-29 12:57:09 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba145e9405 | ||
|
|
f7d7c0288c | ||
|
|
e65d188747 | ||
|
|
e05b7fe86f | ||
|
|
e3bb06a298 | ||
|
|
cd709ea0a9 | ||
|
|
d1b0bfc3f9 | ||
|
|
fd24c1ab3f | ||
|
|
2e6db0bd88 | ||
|
|
9b4b948c6e | ||
|
|
e6c5a701dd | ||
|
|
4e2568e2da | ||
|
|
b8debb3050 | ||
|
|
f1b4be8a04 | ||
|
|
2304bf6ac6 | ||
|
|
e8866ea58b | ||
|
|
74ab8d2819 | ||
|
|
517ba09250 | ||
|
|
49a934fba7 | ||
|
|
117aea31a8 | ||
|
|
fdf791a8b2 | ||
|
|
dbc5c3407b | ||
|
|
3f9c19d9f3 | ||
|
|
435be0a4cb | ||
|
|
6950c22d73 | ||
|
|
e0ff4b0c3d | ||
|
|
ebabb57e5e | ||
|
|
25ad07f868 | ||
|
|
e1e6c31571 | ||
|
|
396344c31e | ||
|
|
6e3f800a8b | ||
|
|
83014016c6 | ||
|
|
570b992947 | ||
|
|
76d8347f77 | ||
|
|
19e5bb48cf | ||
|
|
ab968b072d | ||
|
|
d26b8dcae7 | ||
|
|
68bc118a68 | ||
|
|
18c7c1d114 | ||
|
|
60ad86acb3 | ||
|
|
d588e0b873 | ||
|
|
61e565cd1c | ||
|
|
2d0169e718 | ||
|
|
384d5ef307 | ||
|
|
99ff497180 | ||
|
|
4b18b307be | ||
|
|
972252d0b4 | ||
|
|
100dfb37c8 | ||
|
|
00abb59a85 | ||
|
|
72328ab521 | ||
|
|
652a1596aa | ||
|
|
d82f17ac58 | ||
|
|
0b3733749d | ||
|
|
3af927501b | ||
|
|
c266e5a751 | ||
|
|
e85fc75808 | ||
|
|
12428df221 | ||
|
|
8b789b87d1 | ||
|
|
528da1f6a2 | ||
|
|
b7579bfe34 | ||
|
|
b8b1ec6141 | ||
|
|
914e92b163 | ||
|
|
8b0f1762a1 | ||
|
|
de8a81c1e4 | ||
|
|
2646700ca0 | ||
|
|
9b9eeb8c61 | ||
|
|
72467d037b | ||
|
|
ca6faa8c1d | ||
|
|
0786b2eae5 | ||
|
|
ae5da324ad | ||
|
|
69302481ad | ||
|
|
03a992a46a | ||
|
|
9ce4553899 | ||
|
|
fad83fd876 | ||
|
|
8bde3b3c25 | ||
|
|
fe4faca3f0 | ||
|
|
596176c794 | ||
|
|
c91c1793a3 | ||
|
|
00cd3f3368 | ||
|
|
cff4cd35b8 | ||
|
|
5909ab642a | ||
|
|
60617d9a71 | ||
|
|
e7cd8679ad | ||
|
|
4495e2be53 | ||
|
|
833f5b64d9 | ||
|
|
866b48ac55 | ||
|
|
6e30349bbb | ||
|
|
8c812bf1d6 | ||
|
|
dec24458cf | ||
|
|
473df388f3 | ||
|
|
beedd1b30d | ||
|
|
87cab84e4a | ||
|
|
5fdd0a0e89 | ||
|
|
a7abd346e7 | ||
|
|
bdb7672c0f | ||
|
|
b5759bcd99 | ||
|
|
5b211d6dc5 | ||
|
|
6dd7c3bc3a | ||
|
|
1005c59bfc | ||
|
|
11f8881cd2 | ||
|
|
913621c1c4 | ||
|
|
8c328386fa | ||
|
|
63ee34c557 | ||
|
|
e04dd0e785 | ||
|
|
9cd34ebc80 | ||
|
|
d9ca2573a6 | ||
|
|
a114a79a69 | ||
|
|
61a626820e | ||
|
|
545673d644 | ||
|
|
973e6e12f5 | ||
|
|
94e3dda6af | ||
|
|
3031033790 | ||
|
|
95b5c71f80 | ||
|
|
6cf7159a71 | ||
|
|
65127c2727 | ||
|
|
874e5a76a0 | ||
|
|
720f8acd2c | ||
|
|
2054f8fd88 | ||
|
|
4c8508cd93 | ||
|
|
e7babf364e | ||
|
|
c9468a13cb | ||
|
|
e2d9224996 | ||
|
|
eedf9c9740 | ||
|
|
5977112a0d | ||
|
|
872b2bc99d | ||
|
|
aed0b71786 | ||
|
|
0f5e945a69 | ||
|
|
e50850d4dd | ||
|
|
166b95feba | ||
|
|
072acce71b | ||
|
|
70fc00c80f | ||
|
|
20188e4610 | ||
|
|
1b45615940 | ||
|
|
ac76375d74 | ||
|
|
f4c109a0ab | ||
|
|
715f491d29 | ||
|
|
7333a302f5 | ||
|
|
c9a5191b70 | ||
|
|
492aceb960 | ||
|
|
ff39a5c357 | ||
|
|
1595343fa0 | ||
|
|
a5d817a9ef | ||
|
|
09b822fdbe | ||
|
|
8107581df2 | ||
|
|
ca45f97ace | ||
|
|
9726d6f1cb | ||
|
|
57dfacfc07 | ||
|
|
4cb4b2d231 | ||
|
|
a14948a23d | ||
|
|
4deb654862 | ||
|
|
d7bd5bcd3c | ||
|
|
28d6260a81 | ||
|
|
a492189a84 | ||
|
|
5c4408f341 | ||
|
|
8f0000de63 | ||
|
|
9bed0156df | ||
|
|
f7de98a711 | ||
|
|
d6f20f93fd | ||
|
|
3348e93140 | ||
|
|
11f1d0aee6 | ||
|
|
b8279c4da5 | ||
|
|
2caa9db109 | ||
|
|
4dcd4749ca | ||
|
|
d4a3292834 | ||
|
|
7f4b649f6f | ||
|
|
0c8399896f | ||
|
|
69c2414615 | ||
|
|
879e688fec | ||
|
|
f5b3c5d069 | ||
|
|
f8ffab72e2 | ||
|
|
0b1c957233 | ||
|
|
999e2ae896 | ||
|
|
438dec0d61 | ||
|
|
1cea0f7e11 | ||
|
|
d04d756c4e | ||
|
|
85c7d1214b | ||
|
|
5e751c848b | ||
|
|
2707ac403f | ||
|
|
3bede06646 | ||
|
|
da607a00ee | ||
|
|
4a251d4b9c | ||
|
|
4362e6624d | ||
|
|
c92d120098 | ||
|
|
86ad3ddb63 | ||
|
|
fc08b0e4f6 | ||
|
|
455e1f419f | ||
|
|
56d0a162b3 | ||
|
|
3a2cea6ca4 | ||
|
|
5a82eafa5a | ||
|
|
1880f6162c | ||
|
|
61ad110e9f | ||
|
|
fa755e9de4 | ||
|
|
d2321d05d9 | ||
|
|
7c95f02050 | ||
|
|
2db55b5498 | ||
|
|
341f370368 | ||
|
|
09f14d5a5e | ||
|
|
088156d4ca | ||
|
|
e1b39cd41d | ||
|
|
d32119c377 | ||
|
|
6b3de1c4dc | ||
|
|
5e2b63b710 | ||
|
|
a3aab66bfa | ||
|
|
8fcaa66e19 | ||
|
|
559f16601d | ||
|
|
0254f3afc7 | ||
|
|
da8b7b7feb | ||
|
|
3787af0a67 | ||
|
|
c9f3da2fc5 | ||
|
|
82bf806118 | ||
|
|
6b192f58ce | ||
|
|
eff1444f08 | ||
|
|
228a3eae3e | ||
|
|
db9d9d488a | ||
|
|
af90833305 | ||
|
|
31cb2c8714 | ||
|
|
bcdad07d00 | ||
|
|
0c4558cd08 | ||
|
|
6d83d13257 | ||
|
|
c5a9c954f7 | ||
|
|
1f0aeb4626 | ||
|
|
211d6c0c06 | ||
|
|
9acc34f22c | ||
|
|
c2b97f1180 | ||
|
|
47c2eda22c | ||
|
|
8701f4e28d | ||
|
|
6fefeb72e6 | ||
|
|
6311f9d411 | ||
|
|
963df12a11 | ||
|
|
e7f5d32c35 | ||
|
|
8cdd85f7e7 | ||
|
|
1d37043128 | ||
|
|
35cbdb8002 | ||
|
|
498d9aaa9b | ||
|
|
72cea2daf4 | ||
|
|
ab57696ba7 | ||
|
|
a30a1866e2 | ||
|
|
04eadcb2ca | ||
|
|
f354e7fc9b | ||
|
|
1c8c352cf5 | ||
|
|
7fc2bbf248 | ||
|
|
540692515b | ||
|
|
986d7e2a5a | ||
|
|
cdb13144b3 | ||
|
|
8812fe068d | ||
|
|
7bd3eb8ca7 | ||
|
|
357deba3c2 | ||
|
|
ecd94e0f8a | ||
|
|
1cc3d190a7 | ||
|
|
35fb78f0cc | ||
|
|
a93932d491 | ||
|
|
6670b9c867 | ||
|
|
d8b11c69d3 | ||
|
|
7f94643154 | ||
|
|
edee285c36 | ||
|
|
2e062168f0 | ||
|
|
8fb4d44d2c | ||
|
|
abb36aceb2 | ||
|
|
da11d6634b | ||
|
|
ce38b0c372 | ||
|
|
81098c52f6 | ||
|
|
a3fcb9ed47 | ||
|
|
ff61b32177 | ||
|
|
da84f48040 | ||
|
|
795cad3326 | ||
|
|
ba8dc7bf1c | ||
|
|
f6115ea7fa | ||
|
|
ace1adc624 | ||
|
|
4d8204c2f2 | ||
|
|
5b322e1530 | ||
|
|
70c5c44f01 | ||
|
|
33f00e8dfe | ||
|
|
4950690479 | ||
|
|
4442390213 | ||
|
|
bd0521d476 | ||
|
|
55bef52d38 | ||
|
|
df9c29f673 | ||
|
|
03e713b72f | ||
|
|
b2b8ad8f52 | ||
|
|
fc15682fb0 | ||
|
|
aa665b16a9 | ||
|
|
8c1a39ba95 | ||
|
|
16622f0853 | ||
|
|
75122c1178 | ||
|
|
ad820dce6c | ||
|
|
3507a34430 | ||
|
|
db0affc0d3 | ||
|
|
74c1f26c4e | ||
|
|
f2e15cf8f8 | ||
|
|
2f6311cc37 | ||
|
|
5834dc5a19 | ||
|
|
72453d9def | ||
|
|
eae2421761 | ||
|
|
40c4ebc432 | ||
|
|
86adfd34eb | ||
|
|
df28b78de9 | ||
|
|
fd0bb2fb1e | ||
|
|
b3b6c451e3 | ||
|
|
6a2bad8afa | ||
|
|
dac8c6d6c7 | ||
|
|
18b680c9aa | ||
|
|
e127a080a1 | ||
|
|
5b49d692a5 | ||
|
|
ffacc4c08c | ||
|
|
109956bb90 | ||
|
|
add4851443 | ||
|
|
9a19e09d97 | ||
|
|
6e25d482c4 | ||
|
|
94bfc29b0b | ||
|
|
cb6418dc6c | ||
|
|
5c8dba229f | ||
|
|
0aab56934b | ||
|
|
7af9748ace | ||
|
|
5f83a4727a | ||
|
|
a9d3597549 | ||
|
|
f43928324f | ||
|
|
e45c0159e8 | ||
|
|
1e02093462 | ||
|
|
3cbea099f1 | ||
|
|
fbbf9c04eb | ||
|
|
e5d9b3e7cf | ||
|
|
3292ba05d3 | ||
|
|
3f9b6a029f | ||
|
|
60b9500347 | ||
|
|
8d04d14d96 | ||
|
|
1fc34fc147 | ||
|
|
cd31095163 | ||
|
|
3ef4d1fb9a | ||
|
|
4062db0ac2 | ||
|
|
aa9c31fc69 | ||
|
|
058fd1bacd | ||
|
|
9d4d2ea01c | ||
|
|
850bbb5dbc | ||
|
|
63d5faa9b2 | ||
|
|
20cf5b45d0 | ||
|
|
097d81a41b | ||
|
|
2c7629fa7a | ||
|
|
35e24aa436 | ||
|
|
13070ca85e | ||
|
|
4b33e7b4ab | ||
|
|
daf8caada3 | ||
|
|
babb331528 | ||
|
|
056771a33a | ||
|
|
67b0428657 | ||
|
|
e5a055a310 | ||
|
|
1c814e0269 | ||
|
|
b53796d412 | ||
|
|
29320804f2 | ||
|
|
faa621104c | ||
|
|
47907a820b | ||
|
|
6528f972db | ||
|
|
8b0194bc23 | ||
|
|
718eb02b89 | ||
|
|
87410b4e3f | ||
|
|
c3ba9011a2 | ||
|
|
2c26fa58a8 | ||
|
|
ed980e4df7 | ||
|
|
98cda1ea50 | ||
|
|
116e7697a1 | ||
|
|
6eeb6d10db | ||
|
|
a8c1c228d7 | ||
|
|
4ad625eef1 | ||
|
|
63ed4daab2 | ||
|
|
74a279d5f2 | ||
|
|
fe9d560b49 | ||
|
|
f3f110b442 | ||
|
|
6d5cc9ef84 | ||
|
|
1b60773196 | ||
|
|
fee9b984eb | ||
|
|
2444e64a25 | ||
|
|
241743b4ab | ||
|
|
c2872f23c3 | ||
|
|
3b16face49 | ||
|
|
4570952069 | ||
|
|
64e430354c | ||
|
|
39af5b17ca | ||
|
|
90d8844e44 | ||
|
|
952eb57688 | ||
|
|
a3ec498f2a | ||
|
|
2c5249d13e | ||
|
|
da0370c30b | ||
|
|
b70ab1a6e7 | ||
|
|
ba1e28382d | ||
|
|
e9e8af517c | ||
|
|
4175d68be5 | ||
|
|
663de991df | ||
|
|
2a1cdbb17f | ||
|
|
8fd4951d76 | ||
|
|
1b05f9a668 | ||
|
|
21f2b938e2 | ||
|
|
8ba285cdef | ||
|
|
3cc0c16302 | ||
|
|
bbdcc7c62f | ||
|
|
aec3f50582 | ||
|
|
81b5602bf0 | ||
|
|
9c57b950b9 | ||
|
|
e4e389cdb3 | ||
|
|
3370fd1ba2 | ||
|
|
4d2649738e | ||
|
|
a4b335f6a3 | ||
|
|
52190491ff | ||
|
|
c80f51e810 | ||
|
|
85aa9cdea4 | ||
|
|
18ecff6fbb | ||
|
|
e3ed7da1be | ||
|
|
68d15dafba | ||
|
|
910a2bb312 | ||
|
|
1024fc471b | ||
|
|
81c52fecc0 | ||
|
|
a09162603d | ||
|
|
3a201c8246 | ||
|
|
d1b14504dc | ||
|
|
4262276418 | ||
|
|
825538fc8b | ||
|
|
f8d06ebb27 | ||
|
|
7188d8ae04 | ||
|
|
7591283266 | ||
|
|
cf8aea6886 | ||
|
|
f732c1a370 | ||
|
|
b574cb9638 | ||
|
|
31b622ad79 | ||
|
|
7052cfc80d | ||
|
|
23e26628ed | ||
|
|
2c33a66ca9 | ||
|
|
a21b069390 | ||
|
|
41cf3c7563 | ||
|
|
f47cd975ae | ||
|
|
2ffe707b03 | ||
|
|
e0a1346590 | ||
|
|
cf140ae600 | ||
|
|
5f2e18e1cd | ||
|
|
ae8048e3f1 | ||
|
|
40cf85c25b | ||
|
|
44787764a9 | ||
|
|
48ab796792 | ||
|
|
7658819bf7 | ||
|
|
e5780698f2 | ||
|
|
5ffd7a99f5 | ||
|
|
78c37cafa7 | ||
|
|
36553301d8 | ||
|
|
00c8ee3407 | ||
|
|
d129fa2daa | ||
|
|
c3bdcd73b4 | ||
|
|
1190fa8881 | ||
|
|
6dd027fcfa | ||
|
|
0c5804767c | ||
|
|
f7dc68d543 | ||
|
|
9710db73da | ||
|
|
70a4917973 | ||
|
|
8ae23cee29 | ||
|
|
6655ba675d | ||
|
|
01efd53aea | ||
|
|
b309af4148 | ||
|
|
9f7330664b | ||
|
|
373df5adf1 | ||
|
|
b3af58602b | ||
|
|
bfacaf3627 | ||
|
|
3d8f8144d9 | ||
|
|
92c0036d72 | ||
|
|
248dae4bd1 | ||
|
|
575c5f98df | ||
|
|
eb54d88ec0 | ||
|
|
cf9a4ff7fa | ||
|
|
8e0f42e4bc | ||
|
|
f822dcb23c | ||
|
|
1e4ed377d8 | ||
|
|
2686e20330 | ||
|
|
3e20488c15 | ||
|
|
77da439d0b | ||
|
|
2215e52aa4 | ||
|
|
e1f34a90d6 | ||
|
|
b8e46b244d | ||
|
|
3962d397e5 | ||
|
|
3e9dde7799 | ||
|
|
a04356933c | ||
|
|
896c1bbebf | ||
|
|
a4870128b5 | ||
|
|
d2ff87856f | ||
|
|
b53fc8ca6e | ||
|
|
f9f8b67c54 | ||
|
|
1f01a13dfc | ||
|
|
85fcbabcb1 | ||
|
|
e29fbdb0bf | ||
|
|
060a3333b8 | ||
|
|
2fd3b556f3 | ||
|
|
85dd93a2ba | ||
|
|
e486f7c39d | ||
|
|
68501de224 | ||
|
|
6ed1877ce6 | ||
|
|
482401f3c4 | ||
|
|
4fcf7da693 | ||
|
|
8196624e83 | ||
|
|
e9d5da31d4 | ||
|
|
aedbbc9325 | ||
|
|
5597351213 | ||
|
|
90762b0529 | ||
|
|
6be7ef10fb | ||
|
|
ee33d71173 | ||
|
|
163462338e | ||
|
|
b34a8744d0 | ||
|
|
987c41364d | ||
|
|
639a4043b8 | ||
|
|
5311069ae1 | ||
|
|
1c2f0fe65e | ||
|
|
6b107250a5 | ||
|
|
1a34583e50 | ||
|
|
3e11278a93 | ||
|
|
3fc4ef2cf4 | ||
|
|
0a2b429b05 | ||
|
|
e7e4522d0d | ||
|
|
2814dfcae9 | ||
|
|
4d79e18ae8 | ||
|
|
666b9afc5a | ||
|
|
b4f6b603ac | ||
|
|
60c4a372d5 | ||
|
|
b10c97b765 | ||
|
|
03ba9e483f | ||
|
|
52e56f8d76 | ||
|
|
37d41150e6 | ||
|
|
53b7debea5 | ||
|
|
5e8c5b3c4e | ||
|
|
b1e8ae07e7 | ||
|
|
3d2002b699 | ||
|
|
0c7b4a72f2 | ||
|
|
b78adf2def | ||
|
|
0a1f2ce7a8 | ||
|
|
7471c33fe0 | ||
|
|
a124d58e5b | ||
|
|
42c1f71b52 | ||
|
|
bf2bd69470 | ||
|
|
686f71cabb | ||
|
|
1f2b2287b6 | ||
|
|
4cf13cd178 | ||
|
|
93175f979f | ||
|
|
ff4e560e7d | ||
|
|
1be06ca323 | ||
|
|
dea3e217fa | ||
|
|
a9afeb4bfe | ||
|
|
b8b8ff6128 | ||
|
|
5ff69b8bab | ||
|
|
47ae2d495d | ||
|
|
26cdede600 | ||
|
|
45d2546b63 | ||
|
|
f2a96a21f9 | ||
|
|
17ec132c7c | ||
|
|
1059b196c1 | ||
|
|
533615620e | ||
|
|
4cb013faa1 | ||
|
|
0128e117b3 | ||
|
|
6e6cfba765 | ||
|
|
5b9010f6ad | ||
|
|
37193a14ba | ||
|
|
25ca4b0752 | ||
|
|
abe572e7fb | ||
|
|
156029fe63 | ||
|
|
7d3767ba99 | ||
|
|
3f5d8feebe | ||
|
|
48f1b6b842 | ||
|
|
b3132a0b11 | ||
|
|
086d3d0384 | ||
|
|
44086d08c8 | ||
|
|
646349d521 | ||
|
|
68cf67a6c6 | ||
|
|
2773295938 | ||
|
|
442a7226e7 | ||
|
|
d1dff91f6f | ||
|
|
76f5d4b817 |
@@ -0,0 +1,6 @@
|
||||
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
|
||||
reviews:
|
||||
auto_review:
|
||||
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
|
||||
ignore_usernames:
|
||||
- "dependabot[bot]"
|
||||
@@ -0,0 +1,30 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Maintain Rust / Cargo dependencies
|
||||
- package-ecosystem: "cargo"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
# Consolidate updates into a single PR rather than one PR per crate
|
||||
groups:
|
||||
cargo-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
|
||||
# Maintain GitHub Actions workflows
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "ci(deps)"
|
||||
@@ -0,0 +1,62 @@
|
||||
# The CLAUDE.md quality gate (`cargo xtask ci`: fmt, clippy -D warnings, tests, assets, layers,
|
||||
# wasm) on every pull request and every push to main, so a merge burst can't land a red tree that
|
||||
# only the next contributor's local run discovers.
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
# Same craft-fonts commit as release.yml and freebsd.yml, so layout tests see the fonts a
|
||||
# release build embeds (the checkout is an optional build input, see crates/fonts/build.rs).
|
||||
CRAFT_FONTS_REF: 8dcdacd5153e64560d109541a47d806f26f048c0
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
name: cargo xtask ci
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: "0"
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
|
||||
- name: Install Linux build dependencies (winit, wgpu, rfd)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev \
|
||||
libxi-dev libgtk-3-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
|
||||
|
||||
- name: Install Rust (stable + clippy, rustfmt, wasm32)
|
||||
run: |
|
||||
rustup toolchain install stable --profile minimal --component clippy,rustfmt \
|
||||
--target wasm32-unknown-unknown
|
||||
rustup default stable
|
||||
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Quality gate
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
run: cargo xtask ci
|
||||
@@ -0,0 +1,23 @@
|
||||
# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation
|
||||
# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice.
|
||||
name: Nix
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
|
||||
pull_request:
|
||||
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: nix-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
nix:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
|
||||
@@ -0,0 +1,20 @@
|
||||
name: SonarCloud
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: sonarcloud-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
sonarcloud:
|
||||
# Tracked at @main so org-wide CI improvements propagate without PR churn.
|
||||
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
|
||||
secrets: inherit
|
||||
+5
-2
@@ -10,7 +10,8 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
|
||||
|---|---|---|---|---|
|
||||
| `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 | ~220 lowercase English base forms supplementing spelling (#520) |
|
||||
| `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/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 |
|
||||
@@ -66,7 +67,7 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
|
||||
| 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 |
|
||||
| `crates/ui-egui/src/i18n/uk.tsv` | @dmatviichuk with Codex | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Ukrainian interface catalog; spelling checked with external VESUM and ULIF dictionaries, neither bundled |
|
||||
| `crates/ui-egui/src/i18n/et.tsv` | @henno with Codex | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Estonian interface catalog |
|
||||
| `crates/ui-egui/src/i18n/pl.tsv` | Konrad Witkowski with Claude (AI agent) | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Polish interface catalog; not yet reviewed by a second native speaker |
|
||||
| `crates/ui-egui/src/i18n/pl.tsv` | Konrad Witkowski with Claude (AI agent); Aleksander Kutycki (@olekkut) with Gemini 3.8 Flash | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Polish interface catalog; not yet reviewed by a second native speaker |
|
||||
| `crates/ui-egui/src/i18n/pt-br.tsv` | @edurbs with ZCode | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Brazilian Portuguese interface catalog; spelling checked with the external pt_BR Hunspell dictionary, not bundled |
|
||||
| `crates/ui-egui/src/i18n/cs.tsv` | @DanielKnourek with Antigravity for VS Code | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Czech interface catalog; review suggestions by @Losminek |
|
||||
| `crates/ui-egui/src/i18n/de.tsv` | @arekown with Kimi | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | German interface catalog |
|
||||
@@ -75,3 +76,5 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
|
||||
| `crates/ui-egui/src/i18n/bg.tsv` | Dimitar Klaturov (@bulgariamitko) with Claude Code | Original translation from the English interface labels in this repository; no other product localisation or other catalog used | MIT OR Apache-2.0 | Bulgarian interface catalog |
|
||||
| `crates/ui-egui/src/i18n/hu.tsv` | @Skaywalker | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Hungarian interface catalog |
|
||||
| `crates/ui-egui/src/i18n/fr.tsv` | @pechnikov2026-star | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | French interface catalog |
|
||||
| `crates/ui-egui/src/i18n/sv.tsv` | Erik Holmbom (@holmbom) with Claude Code | Original translation from the English interface labels in this repository; no other product localisation or other catalog used | MIT OR Apache-2.0 | Swedish interface catalog; AI-assisted, terminology and the running app reviewed by a native speaker |
|
||||
| `crates/ui-egui/src/i18n/hr.tsv` | Andrej Škvorc (@andrejskvorc) with Claude Code (Sonnet 5.5); proofread with Gemini | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Croatian interface catalog; reviewed and corrected in the running interface, menu by menu, by the native-speaker author, who also checked the Gemini suggestions |
|
||||
|
||||
Generated
+81
-35
@@ -693,6 +693,17 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chardetng"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13de944a44b5064ee5d3a5ceccc49a41bfec50f2580e66f82e87703acdb88b53"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"encoding_rs",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.5.2"
|
||||
@@ -819,6 +830,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core_detect"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
|
||||
|
||||
[[package]]
|
||||
name = "core_maths"
|
||||
version = "0.1.1"
|
||||
@@ -843,6 +860,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c3487320b2d1de657b860b3c8abb44e1f2b32bd8#c3487320b2d1de657b860b3c8abb44e1f2b32bd8"
|
||||
dependencies = [
|
||||
"krilla",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-print"
|
||||
version = "0.1.0"
|
||||
@@ -1184,6 +1210,20 @@ dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.42"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"core_detect",
|
||||
"multiversion_no_op",
|
||||
"rustversion",
|
||||
"scopeguard",
|
||||
"simdutf8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "endi"
|
||||
version = "1.1.1"
|
||||
@@ -1843,17 +1883,6 @@ dependencies = [
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hayro-write"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a0b3db92c4917aad24ff30d61413a9a7509ae88b27aee1ae34eff881e1d2723a"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hayro-syntax",
|
||||
"pdf-writer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.5.3"
|
||||
@@ -2185,15 +2214,13 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "krilla"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27da593198b20eeba65caeb73c2bbeec3e53ab08fa549898312ce81c4fce5e33"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c3487320b2d1de657b860b3c8abb44e1f2b32bd8#c3487320b2d1de657b860b3c8abb44e1f2b32bd8"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bumpalo",
|
||||
"flate2",
|
||||
"float-cmp",
|
||||
"gif",
|
||||
"hayro-write",
|
||||
"image-webp",
|
||||
"imagesize",
|
||||
"indexmap",
|
||||
@@ -2363,6 +2390,12 @@ dependencies = [
|
||||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multiversion_no_op"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
|
||||
|
||||
[[package]]
|
||||
name = "naga"
|
||||
version = "30.0.1"
|
||||
@@ -5098,7 +5131,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft"
|
||||
version = "0.7.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"clipboard-win",
|
||||
@@ -5111,6 +5144,7 @@ dependencies = [
|
||||
"log",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"open",
|
||||
"pollster 0.4.0",
|
||||
"rfd",
|
||||
@@ -5130,9 +5164,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-cli"
|
||||
version = "0.7.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"sys-locale",
|
||||
"wordcraft-doc",
|
||||
"wordcraft-engine",
|
||||
"wordcraft-mcp",
|
||||
@@ -5142,11 +5177,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-control-key"
|
||||
version = "0.7.0"
|
||||
version = "0.9.0"
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-doc"
|
||||
version = "0.7.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -5158,7 +5193,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-docbin"
|
||||
version = "0.7.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"image",
|
||||
@@ -5166,6 +5201,7 @@ dependencies = [
|
||||
"miniz_oxide 0.8.9",
|
||||
"proptest",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"thiserror 2.0.21",
|
||||
"wordcraft-doc",
|
||||
"wordcraft-geom",
|
||||
@@ -5173,7 +5209,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-docx"
|
||||
version = "0.7.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64",
|
||||
@@ -5187,6 +5223,7 @@ dependencies = [
|
||||
"sha1",
|
||||
"sha2",
|
||||
"thiserror 2.0.21",
|
||||
"unicode-bidi",
|
||||
"wordcraft-doc",
|
||||
"wordcraft-geom",
|
||||
"zeroize",
|
||||
@@ -5195,8 +5232,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-engine"
|
||||
version = "0.7.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"encoding_rs",
|
||||
"getrandom 0.3.4",
|
||||
"image",
|
||||
"js-sys",
|
||||
"log",
|
||||
@@ -5221,7 +5261,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-fonts"
|
||||
version = "0.7.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"harfrust 0.13.3",
|
||||
"kurbo",
|
||||
@@ -5234,9 +5274,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-formats"
|
||||
version = "0.7.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chardetng",
|
||||
"encoding_rs",
|
||||
"image",
|
||||
"js-sys",
|
||||
"log",
|
||||
"proptest",
|
||||
"quick-xml",
|
||||
@@ -5247,14 +5291,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-geom"
|
||||
version = "0.7.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-layout"
|
||||
version = "0.7.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"js-sys",
|
||||
@@ -5273,7 +5317,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-mcp"
|
||||
version = "0.7.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde_json",
|
||||
@@ -5285,7 +5329,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-metafile"
|
||||
version = "0.7.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"thiserror 2.0.21",
|
||||
@@ -5293,14 +5337,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-pdf"
|
||||
version = "0.7.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"craft-pdf",
|
||||
"hayro-cmap",
|
||||
"hayro-interpret",
|
||||
"hayro-syntax",
|
||||
"image",
|
||||
"js-sys",
|
||||
"krilla",
|
||||
"kurbo",
|
||||
"log",
|
||||
"thiserror 2.0.21",
|
||||
@@ -5313,7 +5357,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-proof"
|
||||
version = "0.7.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"miniz_oxide 0.8.9",
|
||||
@@ -5322,7 +5366,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-render"
|
||||
version = "0.7.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -5336,8 +5380,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-ui-egui"
|
||||
version = "0.7.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"craft-ui",
|
||||
"egui",
|
||||
"egui_kittest",
|
||||
@@ -5348,6 +5393,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sys-locale",
|
||||
"toml",
|
||||
"unicode-bidi",
|
||||
"wordcraft-doc",
|
||||
"wordcraft-engine",
|
||||
"wordcraft-fonts",
|
||||
@@ -5361,7 +5407,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-web"
|
||||
version = "0.7.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5380,7 +5426,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-zotero"
|
||||
version = "0.7.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -5493,7 +5539,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.7.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+12
-3
@@ -4,10 +4,10 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.7.0"
|
||||
version = "0.9.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
rust-version = "1.95"
|
||||
repository = "https://github.com/storytold/wordcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
@@ -52,12 +52,17 @@ harfrust = "0.13"
|
||||
wuff = "0.2.9"
|
||||
vello_cpu = "0.2"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] }
|
||||
krilla = { version = "0.8", features = ["pdf"] }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.1.0", default-features = false, features = ["write"] }
|
||||
quick-xml = "0.41"
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
# zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates.
|
||||
miniz_oxide = "0.8"
|
||||
cfb = "0.15"
|
||||
# Legacy text encodings (GBK/GB18030, Big5, Shift_JIS, EUC-KR, single-byte code pages) for plain
|
||||
# text and CSV recipient lists (#778): encoding_rs ((Apache-2.0 OR MIT) AND BSD-3-Clause) and its
|
||||
# detector chardetng (Apache-2.0 OR MIT), both pure Rust (Firefox's).
|
||||
encoding_rs = "0.8"
|
||||
chardetng = "1"
|
||||
# Password-protected (agile-encrypted) Word documents: pure-Rust RustCrypto primitives.
|
||||
aes = "0.9"
|
||||
sha1 = "0.11"
|
||||
@@ -95,3 +100,7 @@ opt-level = 3
|
||||
lto = "thin"
|
||||
codegen-units = 16
|
||||
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 = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8" }
|
||||
|
||||
@@ -76,6 +76,11 @@ list (author, source, license) is in ATTRIBUTION.md.
|
||||
- All UI icons are drawn in code (crates/ui-egui/src/icons.rs) and are original work, MIT OR Apache-2.0.
|
||||
|
||||
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.
|
||||
are permissively licensed (MIT/Apache-2.0/BSD/ISC/Zlib) and fetched through crates.io or pinned Git sources.
|
||||
The craft-pdf writer and Krilla 0.8.2 backend are pinned to storytold/craft-libs revision
|
||||
c3487320b2d1de657b860b3c8abb44e1f2b32bd8. The backend retains its MIT OR Apache-2.0 licensing
|
||||
and upstream bundled material; see that revision's licenses/pdf-writer/ and ATTRIBUTION.md.
|
||||
The backend patch prevents finalization during an unwind and rejects a writer after failed
|
||||
cleanup. WordCraft enables only writing; PDF import and its image decoders are not enabled.
|
||||
|
||||
No Microsoft, Adobe, Avid or Autodesk icons, images, templates or artwork are included.
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
<h1 align="center">WordCraft</h1>
|
||||
|
||||
<p align="center">
|
||||
<b>Writing and document design; an open-source, clean-room reimplementation of Microsoft Word, rebuilt in pure Rust.</b>
|
||||
<b>Writing and document design; an open-source, modern word processor, built in pure Rust.</b>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
A fast, open-source word processor with the Word workflow you already know: the ribbon, styles,
|
||||
A fast, open-source word processor with a familiar workflow: the ribbon, styles,
|
||||
tables, track changes, references and mail merge. It reads and writes .docx, runs natively on
|
||||
macOS, Windows, Linux and BSD, and in the browser via WebAssembly.<br>
|
||||
<i>By the ArtCraft team.</i>
|
||||
@@ -25,6 +25,9 @@
|
||||
<img alt="Runs on macOS, Windows, Linux, BSD and the web" src="https://img.shields.io/badge/runs%20on-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20BSD%20%C2%B7%20Web-3b5bdb?style=flat-square">
|
||||
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20%2F%20Apache--2.0-2b47b5?style=flat-square">
|
||||
<img alt="Agent-drivable over MCP" src="https://img.shields.io/badge/agents-MCP%20%C2%B7%20CLI-3b5bdb?style=flat-square">
|
||||
<br>
|
||||
<a href="https://sonarcloud.io/project/overview?id=storytold_wordcraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
|
||||
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/wordcraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -86,10 +89,10 @@ Every screenshot below is WordCraft itself, rendered offscreen by its own UI tes
|
||||
|
||||
## Why WordCraft
|
||||
|
||||
- **Familiar.** Word's ribbon tabs, groups, shortcuts and behaviour: Enter continues a list,
|
||||
- **Familiar.** Ribbon tabs, groups, shortcuts and behaviour: Enter continues a list,
|
||||
Tab demotes it, Ctrl/⌘+B bolds the word under the caret, the Styles gallery previews styles live,
|
||||
F4 repeats, F7 checks spelling, F8 extends the selection.
|
||||
- **Your files.** Opens and saves .docx (OOXML), and also .odt, .rtf, .html, .md, .tex (LaTeX), .txt; opens Word 97-2003 .doc; exports PDF
|
||||
- **Your files.** Opens and saves .docx (OOXML), and also .odt, .rtf, .html, .md, .tex (LaTeX), .txt; opens legacy 97-2003 .doc; exports PDF
|
||||
with real, selectable text, links and bookmarks.
|
||||
- **Fast.** Paragraph layout is cached, so typing in a 188-page document re-lays it out in about
|
||||
1.4 ms; pages render on demand.
|
||||
@@ -117,8 +120,8 @@ 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 or Български (follows the system language by default) |
|
||||
| **Files** | .docx read/write (opens in Word), Word 97-2003 .doc import, PDF export, .odt, .rtf, .html, .md, .tex, .txt import/export, page images |
|
||||
| **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, 한국어 or Svenska (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
|
||||
[parity assessment](docs/target-app-parity.md) and the generated
|
||||
@@ -141,6 +144,7 @@ wordcraft-cli text report.docx # plain text
|
||||
wordcraft-cli inspect report.docx # structure as JSON
|
||||
wordcraft-cli run --template sample \
|
||||
--cmd 'select.text={"text":"Membership"}' --cmd format.bold --save out.docx
|
||||
wordcraft-cli run --script job.json # the same from a file: commands, then save to docx and pdf
|
||||
```
|
||||
|
||||
Web: `cd apps/wordcraft-web && trunk serve`, then open <http://127.0.0.1:8771/?sample>.
|
||||
@@ -188,10 +192,10 @@ covers every target starting with it (`wordcraft*=debug`). The logger is
|
||||
| L0 | `wordcraft-geom` | units and measurements |
|
||||
| L1 | `wordcraft-doc`, `wordcraft-fonts`, `wordcraft-proof` | document model and editing; fonts and shaping; spelling, grammar, hyphenation |
|
||||
| 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 (krilla) |
|
||||
| L4 | `wordcraft-engine` | session, undo, 422 commands, Word feature catalog |
|
||||
| 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 |
|
||||
| L5 | `wordcraft-mcp` | MCP server |
|
||||
| L6 | `wordcraft-ui-egui` | the Word-style front end (swappable) |
|
||||
| L6 | `wordcraft-ui-egui` | the word processor front end (swappable) |
|
||||
| apps | `wordcraft`, `wordcraft-cli`, `wordcraft-web` | desktop, command line, browser |
|
||||
|
||||
`cargo xtask ci` runs formatting, clippy, ~800 tests, the asset-attribution check, the layering
|
||||
@@ -199,12 +203,13 @@ check and the wasm build. Contributor and agent instructions: [AGENTS.md](AGENTS
|
||||
|
||||
## Roadmap
|
||||
|
||||
WordCraft is in **alpha**. It covers 88% of Word's ribbon features with commands today; counting
|
||||
depth, file fidelity, stability and the rest, we estimate it is about 60% of the way to replacing
|
||||
all of Word for real work, about 55% for a typical professional's weekly work and about 63% for
|
||||
everyday essentials. Beta needs testing against real-world .docx files, keeping charts and SmartArt
|
||||
through a round trip, pagination closer to Word's, a stability sweep and native printing. Details
|
||||
and estimates: [ROADMAP.md](ROADMAP.md) and [where we fall short](docs/gaps.md).
|
||||
WordCraft is in **alpha**. It has commands for 97% of the ribbon and menu features in its feature catalog
|
||||
([`docs/parity-checklist.md`](docs/parity-checklist.md)) today; counting
|
||||
depth, file fidelity, stability and the rest, we estimate it is about 60% ready for real work,
|
||||
about 55% for a typical professional's weekly work and about 63% for everyday essentials. Beta
|
||||
needs testing against real-world .docx files, keeping charts and SmartArt through a round trip,
|
||||
closer pagination fidelity, a stability sweep and native printing. Details and estimates:
|
||||
[ROADMAP.md](ROADMAP.md) and [where we fall short](docs/gaps.md).
|
||||
|
||||
## Downloads
|
||||
|
||||
@@ -247,9 +252,10 @@ WordCraft compiles DirectX 12 shaders with FXC, the compiler built into Windows,
|
||||
also avoids DirectX 12 altogether.
|
||||
|
||||
**If the app is slow on an older Windows PC:** when DirectX 12 finds no graphics card with a
|
||||
driver for it (for example Intel 4th-generation graphics on Windows 11), it only offers Microsoft's
|
||||
software renderer, which draws every frame on the processor. In that case WordCraft uses OpenGL
|
||||
instead, without setting anything. The log file records the choice (lines starting with
|
||||
driver for it (for example Intel 4th-generation graphics on Windows 11), it only offers the built-in
|
||||
software renderer, which draws every frame on the processor. WordCraft normally uses OpenGL instead,
|
||||
without setting anything. If OpenGL has no usable adapter, it can use DirectX 12's WARP software
|
||||
adapter. The log file records the choice (lines starting with
|
||||
`graphics:`). `WGPU_BACKEND` still overrides it, e.g. `$env:WGPU_BACKEND = "gl"` or `"dx12"`.
|
||||
|
||||
### macOS
|
||||
@@ -289,6 +295,21 @@ to the AUR itself. Install one of them:
|
||||
| [`wordcraft`](https://aur.archlinux.org/packages/wordcraft) | The latest release, built from source with `cargo` | You prefer to build releases yourself |
|
||||
| [`wordcraft-git`](https://aur.archlinux.org/packages/wordcraft-git) | The current `main` branch, built from source | You want unreleased changes and accept an occasional broken build |
|
||||
For example `yay -S wordcraft-bin` or `paru -S wordcraft-bin`.
|
||||
|
||||
#### Community packages
|
||||
|
||||
These packages are maintained by community members, not by the WordCraft team.
|
||||
|
||||
On Gentoo, the [::snakebyte overlay](https://github.com/switch87/snakebyte-overlay) packages the
|
||||
Linux release as `app-office/wordcraft-bin` (prebuilt) and `app-office/wordcraft` (built from source):
|
||||
|
||||
```sh
|
||||
eselect repository add snakebyte git https://github.com/switch87/snakebyte-overlay.git
|
||||
emaint sync -r snakebyte
|
||||
echo 'app-office/wordcraft-bin ~amd64' >> /etc/portage/package.accept_keywords/wordcraft
|
||||
emerge --ask app-office/wordcraft-bin
|
||||
```
|
||||
|
||||
### FreeBSD
|
||||
|
||||
| Build | File |
|
||||
@@ -351,7 +372,9 @@ Bundled fonts, icons, images and other assets keep their own open licenses; each
|
||||
with its author, source and license in [ATTRIBUTION.md](ATTRIBUTION.md).
|
||||
|
||||
The English spelling dictionary and hyphenation come from Grady Ward's public-domain Moby
|
||||
Hyphenator II word list. Polish spelling reads the Hunspell `pl_PL` dictionary installed on the system (not bundled) and
|
||||
Hyphenator II word list. Polish spelling reads the Hunspell `pl_PL` dictionary installed on the system (not bundled), as
|
||||
do Russian, Portuguese, Czech, Italian, Croatian and Turkish spelling (how to install them:
|
||||
[docs/spelling-dictionaries.md](docs/spelling-dictionaries.md)), and
|
||||
Polish hyphenation the TeX patterns by Hanna Kołodziejska, Bogusław Jackowski and Marek Ryćko (MIT
|
||||
option). The sample documents and templates are original text written for WordCraft.
|
||||
|
||||
@@ -360,8 +383,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif
|
||||
part of this repository and WordCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
|
||||
Forks and modified versions must remove them.
|
||||
|
||||
<sub>Microsoft and Microsoft Word are trademarks of the Microsoft group of companies. Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. WordCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Microsoft Corporation or Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
|
||||
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
|
||||
|
||||
+406
-16
@@ -2,8 +2,8 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** trivial (progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
|
||||
|
||||
WordCraft aims for complete parity with Microsoft Word, then goes further on speed, openness and
|
||||
@@ -15,7 +15,7 @@ agent control. This page is the summary; the assessment is
|
||||
## Headline numbers (2026-10-10)
|
||||
|
||||
| Number | Value | Kind |
|
||||
|---|---|---|
|
||||
| --- | --- | --- |
|
||||
| Ribbon/menu catalog coverage | **487 / 502 (97.0%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) |
|
||||
| **Feature breadth** (weighted, incl. dialog options, styles, languages beyond the ribbon) | **~80%** | estimated |
|
||||
| **Ready for real work** (full target) | **~60%** (55–63%) | estimated, additive weighted sum over the dimensions |
|
||||
@@ -104,18 +104,19 @@ Weights and evidence per area: [docs/target-app-parity.md](docs/target-app-parit
|
||||
| Spanish | es | partial | ~95% |
|
||||
| Hindi | hi | none | 0% |
|
||||
| Arabic | ar | none (document shaping and bidi work; no mirrored UI) | 0% |
|
||||
| French | fr | partial (interface) | ~95% |
|
||||
| French | fr | partial (interface complete, native-speaker reviewed; no proofing) | 100% (2,615 entries) |
|
||||
| Portuguese | pt-br (pt-PT none) | partial | ~95% |
|
||||
| Indonesian | id | none | 0% |
|
||||
| Japanese | ja | partial | ~95% |
|
||||
| German | de | partial (#574) | ~95% |
|
||||
| Korean | ko | none | 0% |
|
||||
| Vietnamese | vi | none | 0% |
|
||||
| Korean | ko | partial (interface catalog; proofing unavailable) | 100% (2,463 entries) |
|
||||
| Vietnamese | vi | partial (#1434) | ~95% |
|
||||
|
||||
Also shipped: Traditional Chinese (`zh-hant`), Ukrainian (`uk`), Serbian (`sr`, `sr-latn`, #250), Russian (`ru`) and Polish (`pl`), all partial (~95%). Bulgarian (`bg`) covers everything the interface audit finds (2,419 entries). Estonian (`et`), Italian (`it`, #594) and Czech (`cs`) now have complete interface catalogs, and Turkish (`tr`) a complete 2,325-entry one (every key of the Russian catalog), all with
|
||||
system-locale selection and a saved language choice (Italian also has 51 strings none of the others had yet); Estonian, Turkish, Italian and Czech proofing are not included. Hungarian (`hu`) has a
|
||||
complete 2,419-entry catalog, also without proofing.
|
||||
Proofing: English, Polish and Portuguese (spelling, grammar, hyphenation), chosen per run from its language. Word ships
|
||||
Also shipped: Traditional Chinese (`zh-hant`), Ukrainian (`uk`), Serbian (`sr`, `sr-latn`, #250), Russian (`ru`) and Polish (`pl`), all partial (~95%). Bulgarian (`bg`) covers everything the interface audit finds (2,419 entries); Swedish (`sv`) covers that too, plus 44 newer strings no other catalog has yet (2,504 entries). Estonian (`et`), Italian (`it`, #594) and Czech (`cs`) now have complete interface catalogs, and Turkish (`tr`) a complete 2,325-entry one (every key of the Russian catalog), all with
|
||||
system-locale selection and a saved language choice (Italian also has 51 strings none of the others had yet); Estonian, Turkish and Italian proofing are not included. Hungarian (`hu`) has a
|
||||
complete 2,419-entry catalog, also without proofing. Vietnamese (`vi`, #1434) has a complete
|
||||
2,448-entry catalog, also without proofing.
|
||||
Proofing: English, Polish, Portuguese and Russian (spelling, grammar, hyphenation), plus Czech and Indonesian (system Hunspell spelling, generic checks), chosen per run from its language. Word ships
|
||||
30 interface languages and proofing for ~50. Detail: [docs/localization-parity.md](docs/localization-parity.md).
|
||||
|
||||
## Upcoming
|
||||
@@ -133,8 +134,146 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
|
||||
## Progress log
|
||||
|
||||
Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target on interactive Save (#2445). Save before Close waits for that write; cancel or failure keeps the draft and original file. This does not add binary Word output. Late Save As answers are rejected before writing if automation has replaced the document.
|
||||
|
||||
- 2026-10-19: Restrict Editing: with Filling in forms enforced, inserting content controls, their Properties and Remove Content Control are refused, as in Word (it greys them out), so a form's fields stay as they are and only their content is filled in.
|
||||
- 2026-10-19: Content controls: text typed over a placeholder takes the control's own run properties (`w:sdtPr/w:rPr`, ECMA-376 §17.5.2.27), as in Word, and the placeholder style is recognised by its built-in name "Placeholder Text" whatever its localized id (German Word: `Platzhaltertext`), so entries in forms from localized Word no longer stay grey.
|
||||
- 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096).
|
||||
|
||||
| Date | What landed |
|
||||
| 2026-10-19 | Save As, Export and Open no longer do nothing on Linux desktops whose file chooser portal fails to show its dialog (#2999): a "cancel" quicker than anyone could make one asks again without the parent window, then opens the built-in file browser. |
|
||||
| 2026-10-18 | Long navigation headings wrap onto further lines inside the resizable pane instead of ending in an ellipsis: the row grows with the text and the pane keeps its width (#759, #3015). |
|
||||
| 2026-10-18 | Clearing a font query with no matches keeps Recently Used Fonts visible at the top of a scrollable list. The initial scroll position no longer skips its heading; measured keyboard scrolling is unchanged (#2068). |
|
||||
| 2026-10-17 | Edit Recipient List finds records (#1576): a Find in list box hides rows with no cell containing the text (any case) and counts the matches, and "Show only records that will be merged" hides the rows the filter leaves out; both only change the view. Rows are striped, and edits and removes go to the right record however the grid is narrowed |
|
||||
| 2026-10-14 | DOCX row table border exceptions render and survive saving. Explicit cell borders win; a row exception does not change other rows. |
|
||||
| 2026-10-14 | Tab stops edited on the ruler: the corner selector picks Left/Center/Right/Decimal/Bar, a click sets a stop (Alt skips the snap), dragging moves it with a guide line and position tooltip, dragging off the ruler clears it; markers draw per kind and mirror for RTL; one `para.tabs` call and one undo step per gesture |
|
||||
| 2026-10-12 | Fields keep `w:dirty` through DOCX saves (#877): a begin marker flagged by a generator that couldn't compute a result (pandoc-style TOC placeholders) keeps the flag on open/save — as a buffered field or a range field — so Word still refreshes it on open; computing a result (Update Field, Update Table) clears it, like Word's F9 |
|
||||
| 2026-10-18 | Symbols keep their font (#2036): a `w:sym` or Insert › Symbol character in Wingdings, Symbol or Webdings remembers its symbol font apart from the text's font, so selecting all and changing the font (or Clear Formatting) no longer turns the symbols into boxes; saved back to DOCX as `w:sym`, so Word shows them too. Typed text and the Format Painter don't pick up the symbol font. |
|
||||
| 2026-10-16 | `wordcraft-cli run --script FILE.json` (`-` for stdin) runs a JSON list of commands, then saves to one or more outputs (docx and pdf in one run); errors name the command number and id and nothing is saved, #1963 |
|
||||
| 2026-10-17 | Restrict Editing holds for structure too (#1327): an exception inside a table cell no longer lets table, row or cell formatting change the protected rest of the table; formatting limited to styles refuses Paste Formatting, table shading, borders, cell alignment, cell margins, text direction, table-style edits and locked table styles; loading, filtering or sorting recipients no longer rewrites merge-field results in a protected document. |
|
||||
| 2026-10-18 | Manage Sources › Import… (#2640) reads a Word bibliography file (`b:Sources`: Word's own `Sources.xml` master list) into the document: the tags, names, kinds and fields Word saves are read by the same code that reads the bibliography custom XML part of a `.docx`, a tag already in the document is left alone (an import never overwrites an edited source), an untagged source gets a tag the way a new one does, the whole import is one undo step, and a file that isn't `b:Sources` is refused. |
|
||||
| 2026-10-14 | RTF export writes every section (gap 19): `\sect` ends each section and `\sectd` carries its break type, page size and orientation, margins, columns, page numbering and title page, so multi-section documents survive an RTF round trip |
|
||||
| 2026-10-14 | `ASK` and `SET` fields no longer show their stored result on the page or in PDFs (#1765): like Word, they only assign text to a bookmark and draw nothing; the stored result is kept and saved |
|
||||
| 2026-10-15 | Table rows with an at-least height start the next page whole when less than their minimum is left on a page that already holds something, so one-row-per-page booklets keep their pages; rows taller than a page split from the top of the next page and repeated header rows go with the row (#1742) |
|
||||
| 2026-10-16 | Strict OOXML line heights render as Word renders them (#2329): `w:line="13.90pt"` under `lineRule="auto"` is a height read through twips (13.90 × 20 = 278; the 1.1583 multiple Word shows), not the 13.9 multiple the bare transitional number counts, so a Word 2024 file with 13.9 lines of line spacing opens at Word's 1 page with matching baselines instead of 10 |
|
||||
| 2026-10-15 | RTF keeps more tracked changes (gap 19): tracked paragraph marks (a `\par` inside a `\revised`/`\deleted` group) and character and paragraph formatting changes (`\crauth`/`\crdate` with `\oldcprops`, `\prauth`/`\prdate` with `\oldpprops`) are read and written, so their balloons and Accept/Reject survive an RTF round trip |
|
||||
| 2026-10-14 | ODT export writes sections (gap 19): every new-page section gets its own page layout and master page (the first stays "Standard"), named by its first paragraph or table with the page-numbering restart; a continuous section with columns is written as a columned `text:section`, so a landscape, two-column or restarted part survives a save as .odt |
|
||||
| 2026-10-14 | A bulleted or numbered list item that starts with a lower-case word no longer gets the grammar squiggle for a missing sentence capital (#1741): list items are often fragments, so the sentence-capital rule (every proofed language) skips the first word of a list paragraph (own or style numbering); later sentences in the item are still checked |
|
||||
| 2026-10-15 | The caret, clicks and selection edges fall between the letters of a ligature (fi, ffi, lam-alef), as in Word: the glyph's advance is shared evenly among its letters, mirrored in right-to-left text (#1974) |
|
||||
| 2026-10-16 | The Spike, as in Word: Ctrl+F3 (⌘F3 on macOS) cuts the selection and adds it to the end of the Spike, each piece followed by a paragraph mark; Ctrl+Shift+F3 (⌘⇧F3) inserts everything collected, in order and with its formatting, and empties the Spike. The clipboard is left alone; each is one undo step. The Spike is the AutoText entry "Spike", so Insert › Quick Parts inserts it without emptying it. It lasts for the session (Word keeps it in Normal.dotm) |
|
||||
| 2026-10-16 | Update Field is offered on a table of contents after a save and reopen, and repeated save and update adds no blank line (#2182; the flow in that report already worked since #1197) |
|
||||
| 2026-10-16 | DOCX two lines in one and horizontal in vertical (`w:eastAsianLayout`: id, combine, brackets, vert, vertCompress) are read and kept on save, in runs and character styles, instead of being dropped. Layout doesn't apply them yet. |
|
||||
| 2026-10-16 | Read Aloud speaks Russian, Ukrainian, Greek, Arabic, Hebrew, CJK and Thai text with a voice for its language instead of spelling out letters (#1843): the sentence's language comes from its run's tag (`w:bidi` for right-to-left text) or, when that disagrees with the script, from its letters, even when English letters outnumber them; Windows reads the text as UTF-8 and picks an installed voice of that language, macOS `say -v` and Linux `espeak -v` get the same language. The user's own voice stays whenever it already speaks the language (English documents read exactly as before), and a voice espeak lacks falls back to the default voice instead of silence. Not run on Windows or macOS here. |
|
||||
| 2026-10-16 | Hint-reliant CJK fonts execute TrueType contour assembly; Windows DFKai-SB no longer loses strokes. Synthetic local-font regression; no font binaries bundled. |
|
||||
| 2026-10-16 | Ctrl/⌘+wheel only zooms: a coast or wheel notch still gliding no longer scrolls the page on top of the zoom, which keeps the page point under the pointer (#1743). |
|
||||
| 2026-10-15 | AutoCorrect follows Turkish and Azerbaijani case mapping: `istanbul` → `İstanbul`, `ılık` → `Ilık`, `İNgilizce` → `İngilizce` with no combining dot (#1923) |
|
||||
| 2026-10-16 | Releasing a table drag with the mouse (column or row border, move handle, selection areas) leaves the view where it is instead of scrolling back to the caret (#1738) |
|
||||
| 2026-10-16 | Hebrew in fields and quotes (#1670): a STYLEREF (or any field) showing right-to-left text such as "פרק א" reads right to left in headers and the body instead of reversed; smart quotes in Hebrew pinned to Word's logical-order result (“ before, ” after, ” in acronyms like צה"ל) |
|
||||
| 2026-10-15 | Backspace with a whole table selected removes it, as in Word, and selected whole rows or columns go the same way; Delete clears them and keeps the table. A selection that covers a table and the paragraph before or after it removes the table, also when it starts inside the table or the table is nested (#1844) |
|
||||
| 2026-10-15 | Image size limits are checked before allocating (#1321): a picture whose header declares more RGBA bytes than the 512 MiB budget is refused before decoding (render and PDF share one helper in `wordcraft-geom`), and page rasters are capped at 100 million pixels in total, scaled down rather than only per side. |
|
||||
| 2026-10-15 | Saved DOCX files keep the order of mixed right-to-left and left-to-right text in Word: Hebrew around a Latin name, numbers, brackets or an e-mail address no longer changes places, and Word reports the Hebrew as Hebrew rather than Arabic (#1543). |
|
||||
| 2026-10-15 | Web: typing in Cyrillic or another non-Latin script no longer repeats text. WordCraft now honours the input method's request to erase around the caret before retyping (#1480). Chrome can still move the hidden text field's caret, so later letters may still land out of order. That cause is still open |
|
||||
| 2026-10-15 | Equations: typing `V_dd` or `x^ab` and pressing Space keeps `dd` and `ab` whole in the subscript or superscript, like `V_(dd)`, as Word for Mac does; Linear format shows the bare form |
|
||||
| 2026-10-15 | Plain text pasted from another program keeps the formatting of the paragraph it lands in: Paste inserts it as Keep Text Only does, so every pasted line, empty lines too, becomes a paragraph with that paragraph's direction, style, alignment, indents and list, and the text after the caret keeps them. The lines after the first had default formatting, so pasting several lines into a right-to-left paragraph turned all but the first left-to-right |
|
||||
| 2026-10-14 | Add Font… imports every file selected in the file chooser (#1789), not just the first: the desktop dialog and the web picker are multi-select, each file is added, a bad file is reported ("Added 11 fonts; 1 couldn't be read: X.ttf") without stopping the rest, and one pick adds at most 2,000 (the status line counts the skipped). On the web each font is added and reported on its own, with no single summary line. The built-in file browser still picks one file. |
|
||||
| 2026-10-15 | Additive paragraph spacing retains the full before-space after a section page break; ordinary overlapping spacing is unchanged. |
|
||||
| 2026-10-14 | DOCX row table border exceptions render and survive saving. Explicit cell borders win; a row exception does not change other rows. |
|
||||
| 2026-10-14 | Tab stops edited on the ruler: the corner selector picks Left/Center/Right/Decimal/Bar, a click sets a stop (Alt skips the snap), dragging moves it with a guide line and position tooltip, dragging off the ruler clears it; markers draw per kind and mirror for RTL; one `para.tabs` call and one undo step per gesture |
|
||||
| 2026-10-12 | Fields keep `w:dirty` through DOCX saves (#877): a begin marker flagged by a generator that couldn't compute a result (pandoc-style TOC placeholders) keeps the flag on open/save — as a buffered field or a range field — so Word still refreshes it on open; computing a result (Update Field, Update Table) clears it, like Word's F9 |
|
||||
| 2026-10-15 | Exact line spacing keeps the run font baseline around tall equations; automatic and minimum spacing still grow for math. |
|
||||
| 2026-10-16 | Zero-extent drawing anchors contribute no inline font height. |
|
||||
| 2026-10-16 | Control-channel keyboard and text input reaches the focused widget while the native window is inactive, without activating it; normal widget targets, dialogs and document protection remain in force (#2420) |
|
||||
| 2026-10-15 | Hide Quick Access Toolbar (#1945): the toolbar's menu ends with Hide Quick Access Toolbar, as Word's does; the ribbon's right-click menu and Customize Ribbon › Quick Access Toolbar offer Show Quick Access Toolbar; kept between runs; `ui.quickAccessToolbar {"value"?}` for scripts and MCP |
|
||||
| 2026-10-15 | Rulers: the numbers thin out as the page shrinks on screen (every 1, 2, 4… units, at least 30 px apart, the small ticks at least 4 px apart), give way to the indent, tab and column markers, and the margins get ticks only; both rulers span the caret's page at its own scale in One Page and Multiple Pages and follow the caret to another page (#1878) |
|
||||
| 2026-10-15 | Spelling as you type no longer flags the word being typed; the squiggle comes when the word ends or the caret leaves it, as in Word (#1552) |
|
||||
| 2026-10-15 | Text Highlight Color: No Color becomes the Highlight button's choice, as in Word, so the next click on the button removes highlighting instead of applying the colour picked before; the ribbon and the mini toolbar share one menu (#2105) |
|
||||
| 2026-10-16 | Footnote and endnote editing keeps the body and active note text at normal opacity (#2545), without changing document colours or print/PDF output. |
|
||||
| 2026-10-16 | A picture watermark in a header that starts with a table is drawn centred on the page behind the body text, on every page, instead of in the header's cell at the top of the page (#1775); `layoutInCell="0"` and VML `o:allowincell="f"` survive save and reopen |
|
||||
| 2026-10-17 | Ribbon hover hints: a disabled control says why (the command's own reason), split-button arrows, menu buttons and the font, size and outline boxes have hints, and hints show on keyboard focus (#1086). |
|
||||
| 2026-10-17 | Insert › Drop Cap is a menu (None, Dropped, In margin, Drop Cap Options…); the dialog sets position, font, lines to drop and distance; letters in the margin; DOCX keeps them (#2601). |
|
||||
| 2026-10-16 | An otherwise empty section starting a new page retains its paragraph line before a continuous section, matching an original native Word control. |
|
||||
| 2026-10-16 | Equations from Word files keep all their content (#1273): math straight in the body or a table cell, math behind `mc:AlternateContent Requires="m"`, runs inside hyperlinks, simple fields and markup-compatibility blocks within an equation, and `w:sym`/Symbol-font characters in math; operators, delimiters and accents the math font lacks come from a fallback font. Every ECMA-376 §22.1 element is pinned by a coverage test. |
|
||||
| 2026-10-16 | Captions and title pages (#1230): Insert Caption… on the right-click menu of pictures, shapes, text boxes and charts, the caption going under (or over) the object in its own cell or text box; Page Setup's Different First Page and Different Odd & Even Pages check boxes, so a title page can go without its page number |
|
||||
| 2026-10-17 | SECTIONPAGES includes shared continuous-section pages. A native original control confirms a section starting on page 1 and continuing on page 2 reports 2. |
|
||||
| 2026-10-17 | File › Print's Scale to paper size (#2108): pick Letter, Legal, A4 or any Layout › Size paper and every page is scaled to it and centred, the paper turned to each page's orientation, in the preview, the print job (with the spooler's media) and Save these pages as PDF; the document keeps its page size. `paper` param on `ui.print`/`file.printPdf` |
|
||||
| 2026-10-17 | Non-overlapping body floating tables clear header tables. Original native controls distinguish never-overlap from allowed overlap. |
|
||||
| 2026-10-14 | Comment text being typed is no longer lost or misplaced (#1348): an edit or reply the document refuses (for example read-only protection) keeps the typed text in its field and says why on the status line; drafts belong to their document, so after New or Open a comment with the same number shows its own text; Save, Export, Print, Close, New and Open write pending comment text first; if the document refuses it, Save, Export and Print carry on with the stored comments and show why on the status line (a refused draft is dropped when Save is the only change), while Close, New and Open show the Save Changes prompt (Don't Save drops text the document refuses); Escape discards what was typed; in a document protected as read only or for forms the comment fields can't be typed in |
|
||||
|---|---|
|
||||
| 2026-10-18 | Picture Format › Size (#2573): width and height in the interface unit, and a size typed in any unit works, pixels too (`300 px` keeps the picture's proportions, #1901), applied once typed (Enter, Tab or a click elsewhere; Esc cancels) in one undo step. The group's launcher opens a Size dialog: height and width, scale of the original size (or of the size it opened with), Lock aspect ratio, the original size with its pixels, and Reset. `picture.size {"original": true, "scale"?}` sizes from the original; sizes past 4000 pt that keep the proportions shrink as a whole. |
|
||||
| 2026-10-16 | Save Changes gives assistive technology a named alert dialog with the loss warning and any save failure; visual layout and save policy unchanged (#1015). Native screen-reader verification remains pending. |
|
||||
| 2026-10-15 | The font family caches are bounded (4096 families for text, 1024 for math, names over 256 bytes aren't remembered), so a document naming thousands of missing fonts can't grow memory; equation fonts are looked up again after a font is added or removed instead of keeping the old choice (#1354) |
|
||||
| 2026-10-15 | AutoCorrect recognizes `-->` as `→` (#2223), respecting replacement settings and undo without changing paragraph-start arrow bullets. |
|
||||
| 2026-10-15 | Numbered headings (#1793): the Multilevel List gallery offers four lists linked to Heading 1–9 (1 / 1.1 / 1.1.1, Article I. / Section 1.01 / (a), I. / A. / 1., Chapter 1; `list.multilevelPreset`). The heading styles carry the numbers, so paragraphs already in a Heading style keep their style and level, any other selected paragraph (Normal, Quote, Title) becomes Heading 1 (checked in Word), a new Heading 2 is numbered 2.1 by itself and body text stays unnumbered; Numbering or typing `1. ` keeps a heading a heading |
|
||||
| 2026-10-15 | Reversing an automatic sentence capital (Undo, or Backspace and retype) adds the word before it to AutoCorrect's First Letter exceptions, as Word does; "Automatically add words to list" switches for First Letter and INitial CAps (#1857). |
|
||||
| 2026-10-15 | Dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, in both themes (#1885) |
|
||||
| 2026-10-15 | Font box: a Recently Used Fonts section at the top of the list (the fonts applied lately, most recent first, ten at most, kept between runs), then All Fonts, as in Word (#2068) |
|
||||
| 2026-10-15 | A missing Thai document font (TH Sarabun New, Angsana New, Cordia New, the UPC families…) is substituted by one Thai face for the whole run, Latin letters and digits included, as Word substitutes Cordia New; Thai no longer sits in one font next to larger Latin in another (#1549) |
|
||||
|---|---|
|
||||
| 2026-10-17 | Paste from LibreOffice: a paragraph indented on both sides (HTML `<blockquote>` on the clipboard) pastes as an indented Normal paragraph with LibreOffice's margins, not centred and italic in the Quote style (#1270). |
|
||||
| 2026-10-17 | DrawingML shape outlines retain explicit preset/custom dashes on screen and in PDF, including zero-width path strokes. |
|
||||
| 2026-10-17 | Missing Arial Unicode MS tries Arial and its proportional substitutes before code-font heuristics. |
|
||||
| 2026-10-16 | Web Layout text fills the window width and re-wraps once a resize settles, or at once on a zoom, instead of a fixed 800 pt column; Draft and Outline keep their 800 pt column (#1876). |
|
||||
| 2026-10-16 | Create Source: the automatic Tag is three letters of the first author and the last two digits of the year (`Smi20`), with 1, 2… added when taken, as Word for Mac 16.113.4 does (user-checked). Editing Author or Year keeps the Tag, as in Word, and the citations in the text show the new author and year (#2094; the reporter expected the Tag to follow). |
|
||||
| 2026-10-18 | Split Cells and Convert Text to Table dialogs (#2592), matched to Word: Split Cells starts from the selection (twice its columns, its rows; Merge cells before split offered for several cells or one cell merged across columns); `table.split` gains `rows` and `mergeFirst`, a merged cell splits into 1 to its height rows (its height cuts it into rows, 1 × 1 changes nothing, a number that doesn't divide the height or more than it is refused with Word's messages) and column splits make equal parts without moving other rows' grid lines. Convert Text to Table guesses columns live, shows the row count, and offers Initial column width (Auto or up to 22 in, kept as each column's preferred width), AutoFit to contents/window and Paragraphs/Commas/Tabs/Other separators; `table.fromText` gains `paragraph`/`other` + `char`, `columns`, `autofit` and `width`. |
|
||||
| 2026-10-16 | Font list entries of fonts with a very tall ascent (Amiri, Amiri Quran) sit in their row like the others instead of low or cut in half (#2570) |
|
||||
| 2026-10-16 | Read Mode draws documents cleanly (#1772): the arrows, Page Up/Down and Escape work as soon as it opens (the canvas kept a stable widget id, so it no longer lost the keyboard), only whole columns show, centred, at any column width, text size or window size, and the columns end above the page-turn bar instead of under it. |
|
||||
| 2026-10-16 | Comment and revision balloons are readable in the dark theme (#2045): they were drawn on a fixed white card under light text; the card, markup area and author colours are now theme tokens with a contrast test. |
|
||||
| 2026-10-17 | DOCX glossary documents (building blocks) open into the model: Insert › Quick Parts and AutoText list and insert the document's own entries, and saving an entry with `"in": "document"` keeps it in the file (undoable). An untouched glossary is saved back byte for byte; an edited one is written from the model |
|
||||
| 2026-10-16 | Indonesian proofing (#498): id-ID spelling reads the installed Hunspell `id_ID` dictionary (`hunspell-id` on Debian/Ubuntu, LibreOffice's `dict-id`, or the Dictionaries folder), suggestions use its MAP/REP/TRY/KEY tables, generic repeated-word (offering `anak-anak`) and spacing checks explain in Indonesian; Review › Language, Set Proofing Language and File › Options › Proofing offer Indonesian. No dictionary data bundled; hyphenation keeps the English fallback; parity estimates unchanged |
|
||||
| 2026-10-17 | A table inserted in a right-to-left paragraph is a right-to-left table, like Word, so Tab moves right to left through its cells (#2634) |
|
||||
| 2026-10-16 | French interface catalog completed: 402 entries added (equation gallery, built-in style and table-style names, citation styles, paper sizes, watermarks, Bookmark / Go To / Print dialogs), 2,615 in all, with a native-speaker review of the terminology; no French proofing yet |
|
||||
| 2026-10-14 | DOCX row table border exceptions render and survive saving. Explicit cell borders win; a row exception does not change other rows. |
|
||||
| 2026-10-15 | Layout › Columns with text selected puts only that text in columns, as in Word (#2024): a continuous section break goes where the selection starts and one where it ends (splitting a paragraph there; none at the document's start or end, or where a section already begins or ends), and the columns go on the section between, one undo step; More Columns… offers "Selected text" and picks it. Before, the whole section took the columns |
|
||||
| 2026-10-14 | Tab stops edited on the ruler: the corner selector picks Left/Center/Right/Decimal/Bar, a click sets a stop (Alt skips the snap), dragging moves it with a guide line and position tooltip, dragging off the ruler clears it; markers draw per kind and mirror for RTL; one `para.tabs` call and one undo step per gesture |
|
||||
| 2026-10-12 | Table formulas: cell references and ranges (`=B2+B3`, `=SUM(B2:C3)`), arithmetic and comparisons, IF, AND, OR, NOT, TRUE, FALSE, DEFINED and the `\#` number format; ABOVE and LEFT sum the run of numbers next to the cell as Word does; an unknown function or a syntax error is an error instead of a sum; Update Field (F9) recalculates formula fields |
|
||||
| 2026-10-12 | Fields keep `w:dirty` through DOCX saves (#877): a begin marker flagged by a generator that couldn't compute a result (pandoc-style TOC placeholders) keeps the flag on open/save — as a buffered field or a range field — so Word still refreshes it on open; computing a result (Update Field, Update Table) clears it, like Word's F9 |
|
||||
| 2026-10-18 | Different First Page works from page 1 (#2826): Insert › Header and Edit Header with the caret on page 1, and a double-click there while another header is being edited, go into page 1's own header instead of the default one page 1 no longer shows. |
|
||||
| 2026-10-15 | Track Changes Options › Moves (gap #5): Moved from and Moved to each have a mark and a colour (by default green double strikethrough and double underline, as before and as in Word for Mac), saved per user; `review.trackingOptions` takes `moveFromMark`, `moveFromColor`, `moveToMark`, `moveToColor`, and `trackMoves`, the document's Track moves setting: off, a tracked cut and paste is an ordinary deletion and insertion. DOCX `w:doNotTrackMoves` round-trips |
|
||||
| 2026-10-17 | Language dialog keeps inherited proofing language and no-proof choices (#2717). Opening and accepting unchanged no longer replaces style-derived values with English US and proofing enabled. |
|
||||
| 2026-10-16 | Text with the legacy Outline, Shadow, Emboss or Engrave font effects (`w:outline`, `w:shadow`, `w:emboss`, `w:imprint`) is drawn with them on screen and in PDFs, no longer as plain text: hollow outlined letters, or a grey copy behind the letters; the PDF keeps the text (#2534) |
|
||||
| 2026-10-15 | Table Layout › Cell Margins opens an editable dialog initialized from the table defaults; confirming an unchanged dialog preserves imported margins, and changing one side retains the others (#1960) |
|
||||
| 2026-10-15 | Word's native ink inside groups and drawing canvases survives DOCX saves (gap 2): the ink member keeps its InkML part, relationship, content type and picture fallback, stays in its place among the members, moves with the group and is drawn from its fallback picture; deleting the group drops its part |
|
||||
| 2026-10-14 | Consistent preferred cell widths override a stale table grid when both totals agree, for fixed and AutoFit tables. Conflicting widths, spans and percentages remain unchanged. |
|
||||
| 2026-10-14 | Legacy VML freeforms (`v:shape` with its own `path`), such as the rule lines in many older headers, are read as freeform shapes: they show on screen and in PDFs and are saved back as DrawingML custom geometry instead of vanishing |
|
||||
| 2026-10-14 | Field instructions spanning paragraphs no longer add blank lines during pagination; empty result paragraphs still take a line. |
|
||||
| 2026-10-16 | Opening a DOCX no longer copies its pictures: each picture, and each embedded object or macro part kept for saving, was copied out of the unzipped package, so a document with pictures held them twice while it opened and left the package's copies behind as freed memory. The document now shares the package's bytes: a 92 MB document with 20 pictures opens in about 90 MB less (866 → 772 MB in the app on macOS; the command line's peak, 319 → 231 MB) |
|
||||
| 2026-10-14 | Page-break-only list paragraphs no longer print stray bullets or numbers. The next item keeps its number, matching the reference export. |
|
||||
| 2026-10-12 | Backspace at the start of the paragraph after a table nested in a cell moves into the nested table's last cell, no longer into the last cell of the table around it |
|
||||
| 2026-10-14 | Grouped `flowChartOr` shapes draw their circle and crossing spokes, as in Word |
|
||||
| 2026-10-14 | Deleting a comment removes its anchors from headers, footers, notes and text boxes too, not just the body, and deletes the whole thread below it, replies to replies included; no anchor or reply is left pointing at a deleted comment (#1345) |
|
||||
| 2026-10-14 | Legacy DOC DIB conversion checks the BMP header and copy before allocation, uses the caller's decode allowance, and caps PNG output while encoding; a BMP fallback is kept when it fits. A generated 1-pixel regression covers 57/58/512-byte limits. Resource-exhaustion consequences remain unmeasured. Related: #1334 and #1554 |
|
||||
| 2026-10-15 | Draw: Ink to Shape makes two-stroke arrows and arrows with a closed triangle head. A line plus a separate head stroke (an open V or a small closed triangle, drawn after the line or before it) whose apex sits at one of the line's ends becomes one straight arrow connector, and the head stroke goes, in one undo step; this works while drawing with Ink to Shape on and on lasso-selected strokes with Convert to Shapes. A shaft with a small triangle drawn round its tip in the same stroke is an arrow too; a triangle without a shaft stays a triangle. Our own design (Word for Mac has no Ink to Shape). |
|
||||
| 2026-10-13 | Justified vertical page alignment (#1320): a section set to Justified spreads each page's paragraphs and tables from the top margin to the bottom one, as Word does, including the last page and pages ending in a manual page break. DOCX `w:vAlign="both"` opens as Justified (it showed centred) and saves back; `layout.verticalAlign` takes `justify`, and Page Setup has a Vertical alignment box |
|
||||
| 2026-10-13 | macOS shortcuts as in Word for Mac: ⌘⇧S Save As, ⌘⌥E Insert Endnote, ⌘⌥A New Comment, ⌃H Replace, ⌘⌥R collapse the ribbon |
|
||||
| 2026-10-13 | The status bar flags a proofing language whose Hunspell dictionary isn't installed (Polish, Portuguese): "Polish (no dictionary)", with a tooltip naming the dictionary and where it is looked up (#1167) |
|
||||
| 2026-10-14 | A file name that isn't valid Unicode, such as a Latin-1 `café.docx`, no longer crashes `wordcraft-cli` or the app (#1440). The app and the CLI's `convert`, `info`, `text`, `inspect`, `render`, `zotero` and `run --file` open such a file, and saving it without a new name (`run --file … --cmd file.save`, or Save in the app) writes back to it. Any other such argument is an error naming it, an output name included; the app exits with status 2 on a `--control` port that isn't valid Unicode |
|
||||
| 2026-10-13 | A picture (or shape) set to Behind Text can be dragged over the text it overlaps, whether or not it was selected first, in the body and in a header/footer. The press becomes an object drag only after the pointer moves, so an ordinary click still places the text caret in the overlapping text. The UI regression tests cover In Front of Text too |
|
||||
| 2026-10-13 | Saving to DOCX keeps the watermark: it is written as Word's own text-watermark shape in every header, and a document without headers gains one to hold it (#1290) |
|
||||
| 2026-10-13 | DOCX files with Word's text watermark (Design › Watermark, a VML text shape in the headers) show it on every page on screen and in the PDF (#1290) |
|
||||
| 2026-10-13 | Insert › Link has Word's Place in This Document: a link to a bookmark, a heading (through a hidden _Toc bookmark) or the top of the document, from the dialog's Link to: choice or `insert.link` with `bookmark`, `heading` or `top`; `link.targets` lists the places and Go To follows `_top` (#1234) |
|
||||
| 2026-10-13 | Cut copies to the system clipboard however it is run (#1265): the ribbon, the context menu and the native menu cut deleted the words without copying them (only the Cmd+X event wrote the clipboard, so Paste re-inserted the previous copy); Copy/Cut publication is centralised in one sink and every entry point is tested |
|
||||
| 2026-10-13 | Insert › Bookmark deletes bookmarks (#1236): the dialog is laid out as Word's, with Add, Delete and Go To beside the list, Sort by Name or Location and Hidden bookmarks; it opens on the bookmark around the caret, Delete and Go To keep it open, and Go To selects the bookmark's text. `insert.deleteBookmark {name}` removes both ends of a bookmark, also one across paragraphs (adding a bookmark under a name in use left such a one's end behind) |
|
||||
| 2026-10-12 | Password-protected Word 97-2003 `.doc` files open after a password prompt (XOR obfuscation, RC4, RC4 CryptoAPI; wrong passwords are rejected by the stored verifier, #1079); written from the public specs and tested on synthetic files only, AES-encrypted `.doc` files are still refused |
|
||||
| 2026-10-14 | Update Field works inside headers, footers, notes and text boxes, on the part being edited, as in Word (#1706); with the caret in the body it still leaves them alone, and printing updates every field in the document |
|
||||
| 2026-10-12 | Comments can show only for the selected text (#1055): Track Changes Options › Comments › "Only for the selected text" drops the margin column, and the comment under the caret, or the clicked one, floats beside the page (over its edge in a narrow window). The default stays "In the margin", as in Word; `review.trackingOptions {"commentDisplay"}` for agents |
|
||||
| 2026-10-12 | Clicking inside a selected comment's balloon moves the caret in its text (#1013); the click used to leave the balloon, so the caret could only be moved with the arrow keys. A press on any balloon now belongs to the balloon, not the page behind it |
|
||||
| 2026-10-12 | Page background pictures and textures (Design › Page Color › Fill Effects) are drawn (tiled or stretched) and kept on save instead of being dropped from the package (#999); PDF export paints them too |
|
||||
| 2026-10-12 | Holding a click on the ribbon or toolbar no longer scrolls the document up (#1019): a double-click in the header or footer area left a text drag running, so the next press anywhere drove the edge auto-scroll; a drag now ends with the button, wherever it's released |
|
||||
| 2026-10-12 | Tracked insertions and deletions inside a field result (a `=SUM(ABOVE)` subtotal) are kept as revisions: Accept All and Reject All act on them and a save writes the `w:del` back (#1037) |
|
||||
| 2026-10-12 | Document title opens Rename Document; the account badge opens Options › General for user name and initials. `file.rename {name}` preserves file extensions and refuses an existing destination; unsaved names become the suggested save name without changing the Title property. |
|
||||
| 2026-10-13 | Rounded rectangles (#1476): a `roundRect` keeps the corner radius its file gives it (`adj`), on screen, in PDF and when saved; all were drawn with a 16% corner |
|
||||
| 2026-10-13 | PAGEREF in headers and footers (#1477): "Page X of Y" with Y a page reference to a bookmark at each letter's end shows each letter's own total instead of the result stored in the file |
|
||||
| 2026-10-13 | Replace All respects locked content controls with Track Changes on or off; it refuses the entire batch before editing when any match would change locked content or remove a control locked against deletion |
|
||||
| 2026-10-14 | File › Print translated in all 18 interface languages — printer list, duplex, grayscale, discovery/submission feedback and print commands (18 strings that shipped English-only with the craft-print integration; written from the English meaning, pending native-speaker review like the rest of the catalogs) |
|
||||
| 2026-10-14 | All Caps and Small Caps draw ß as SS, as Word does (#1581): "Straße" in All Caps reads STRASSE, not STRASE, and other letters whose capital is two characters (ff, ʼn) draw in full. The ß is still one character: the caret never stops between the two S, and a click on them lands before or after it |
|
||||
| 2026-10-13 | Windows Magnifier follows the caret onto a new line: after Enter or Shift+Enter the view moves to the empty line at once, instead of staying on the line above until two more letters are typed (#1119) |
|
||||
| 2026-10-13 | A window too big for its screen shrinks to fit when it opens (#1215): on a laptop screen the first window reached under the taskbar or past the screen edge, with the end of the scroll bar, so dragging the scroll bar stopped where the pointer met the edge. A window saved that size is fitted on its next launch too |
|
||||
| 2026-10-14 | `layout.pageSetup` with a `section` checks its values like Margins, Size and Columns (#1582): non-finite numbers and margins that leave no room for text are refused with nothing changed; page sides, column count and gap, header/footer distances and line-numbering values are clamped to the same ranges |
|
||||
| 2026-10-13 | `wordcraft --control` checks its port (#1426): a missing port, one outside 1–65535 or one that isn't a number prints an error naming it and exits with status 2, instead of starting without the control channel (and dropping `WORDCRAFT_CONTROL_PORT`); a flag right after it, such as `--sample` or `--version`, is read as a flag rather than as the port |
|
||||
| 2026-10-14 | Text wrap (#1376): in the narrow strip beside a square-wrapped picture, a word that doesn't fit moves on whole to the next row; it used to be split across the strip ("docu" / "mentazione") |
|
||||
| 2026-10-14 | DOCX files with legacy VML preset shapes show them (#1377): Word's right arrow (`_x0000_t13`) and the other presets appear with their fill, outline and size, drawn as WordCraft draws the same DrawingML preset (the nearest shape when it has no exact one), and saving keeps them as DrawingML shapes; before, they were dropped |
|
||||
| 2026-10-14 | Lists numbered with Russian letters (`russianLower`, `russianUpper`) show а), б), в) … я), then аа), бб), as in Word, instead of 1), 2), 3); the 29-letter sequence (no ё, й, ъ or ь) was observed in Word |
|
||||
| 2026-10-14 | "Do you want to save changes?" answers from the keyboard after typing too (#1597): Save takes focus from the page when the prompt opens, so the arrow keys and Tab move between Save, Don't Save and Cancel instead of the caret behind it, and Enter answers |
|
||||
| 2026-10-12 | Text boxes from older Word files (VML) keep their internal margins, Resize shape to fit text and vertical alignment, so letterhead address blocks and form headers sit where Word puts them instead of 7 pt in and 4 pt down. Hidden drawings stay hidden (they were drawn as empty boxes) and are kept on save |
|
||||
| 2026-10-13 | Word's privacy options are kept and honoured when saving .docx (#1062): with Remove personal information, comments and tracked changes are saved by "Author" and the file has no creator or last-modified-by; with Remove date and time, they are saved without dates. The open document keeps the real names and dates; `file.privacyOptions` sets both |
|
||||
| 2026-10-13 | A selected comment balloon's Delete, Reply and Resolve buttons work again (#1085): pressing them no longer counts as a click on the page that closes the balloon first, so a new empty comment, or any other, can be deleted from its balloon |
|
||||
@@ -146,25 +285,75 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-13 | Dragging a Square-wrapped shape or picture on a multi-column page wraps the text of the column it lands in around it, and its old place closes up (#884) |
|
||||
| 2026-10-13 | Pagination with footnotes follows Word (#1116): a paragraph ending on a page with footnotes keeps its space after above the separator, otherwise its last lines move to the next page with the note; pages without notes are unchanged |
|
||||
| 2026-10-13 | Interface size means what it says (#822): a chosen size is absolute, as in PhotoCraft and PdfCraft, so "100%" is one screen pixel per point even where the system reports 200 % (a bare window manager on X11, where winit guesses the scale from the monitor's DPI). The new default, Auto, keeps the system's display scale and its menu entry shows the size it draws at; sizes run 75–300 %. A saved size from before stays the same if it was the default (now Auto) and becomes absolute otherwise |
|
||||
| 2026-10-14 | Copied text keeps its formatting in other programs (#1523): Copy and Cut put HTML beside the plain text on the system clipboard for every selection, not only one holding an equation, so a browser, mail client or another word processor pastes bold, italic, fonts and sizes. RTF on the clipboard is not written yet |
|
||||
| 2026-10-13 | Copying an equation also puts MathML in HTML on the clipboard beside the linear text like `y=2x_s` (#816; Word/LibreOffice paste not yet verified; OMML is #1297). HTML export writes equations as MathML too, and opening or pasting HTML with MathML gives equations back |
|
||||
| 2026-10-16 | Copying one equation on its own also puts it in the clipboard's MathML formats, which Microsoft documents Word reading first when it pastes math. Whether it pastes into Word as an editable equation hasn't been confirmed yet; testers welcome (#1297) |
|
||||
| 2026-10-13 | The Equation tab's galleries, structure buttons and symbols keep the theme's colours on Windows: their pictures share a few textures written in place (no wipe of pictures already on screen, room for the built-ins at high display scales), instead of hundreds of small textures freed in bulk, which on DirectX 12 showed inverted, red or teal tiles (#1002) |
|
||||
| 2026-10-13 | `file.close` over the control channel or MCP bridge no longer quits and discards unsaved changes (#1268): it returns an error unless the document is saved or `{"force": true}` is passed; the user's Save / Don't Save / Cancel prompt is unchanged |
|
||||
| 2026-10-14 | Text copied out of an exported PDF reads as shown (#1473): All Caps and Small Caps text comes out in capitals, as from Word's PDFs, and plain text after it keeps its own case (before, a capital first drawn for a lowercase letter made later capitals extract lowercase) |
|
||||
| 2026-10-14 | Web: Save in the "save changes?" prompt before New or Open now waits for the browser's save picker: the new or opened document replaces yours only once the file is written and closed. Cancelling the picker, or a write that fails, keeps your document open with its edits and undo, still unsaved, and the prompt comes back with the reason; a save that ends after another document has replaced it no longer runs the New/Open on that one or marks it unsaved, and New/Open while a picker save is still in flight ask first (#1315) |
|
||||
| 2026-10-13 | Closing with unsaved changes → Save that can't write the file (no permission, locked by another app, folder gone) now says so plainly instead of looking like Cancel: the prompt comes back with the reason in it ("Couldn't save … so it is still open and nothing was lost") for Save again, Don't Save or Cancel, the status bar says it too, and `ui.saveChanges` returns that error; the same for a Save As from the prompt; the document stays open with its edits (#1445) |
|
||||
| 2026-10-14 | File › Info › Inspect Document finds hidden text the way Word does (#1344): hidden by direct formatting, a character style or a paragraph style, in the body, headers, footers, footnotes and endnotes, comments and text boxes, and Remove All deletes all of it (and nothing visible) in one undo step, whole hidden paragraphs and the notes of hidden references included. The result lists anything it couldn't reach (`residual`), so a sanitised file is never reported clean by mistake |
|
||||
| 2026-10-14 | ODT files keep their tracked changes (gap 19): insertions and deletions made in LibreOffice open as revisions with their author and date, deleted paragraph breaks and text inserted then deleted included, so Accept All and Reject All give the right text; saving to ODT writes them back, and the Track Changes setting goes both ways |
|
||||
| 2026-10-14 | RTF keeps tracked changes (gap 19): insertions and deletions open as tracked changes with their authors and dates (`\revtbl`, `\revised`, `\deleted`, `\revdttm`) instead of plain text, so deleted text no longer reappears as normal text; saving to RTF writes them back, and Accept All / Reject All work after reopening |
|
||||
| 2026-10-14 | Alignment guides: moving a picture, shape or text box snaps its edges or centre to the page's edges and centre, the margins and the other floating objects on the page, with a thin line across the page for each guide while dragging; Alt moves freely; File › Options › Advanced › Editing turns them off (`ui.alignmentGuides`). Our own design: Word for Mac shows no guides |
|
||||
| 2026-10-14 | ODT files open with their sections (#1379): a paragraph that switches to another page style starts a new section on a new page, with that page's size, orientation, margins and columns and, when the file asks, page numbering restarting; a two-column `text:section` becomes a continuous two-column section. Before, the whole document was one portrait section, so landscape pages, columns and restarts were lost on open and on save to DOCX |
|
||||
| 2026-10-14 | Hebrew and Arabic in exported PDFs copy and search as whole words (#1383): right-to-left glyphs are written left to right as they appear, the order Chrome and Edge (PDFium) and poppler read text back from (checked by hand), instead of in reading order, which they reversed (`שלום` came back as `םולש`, Arabic in pieces). Lam-alef stays whole and vowel marks follow their letters, though Chrome and poppler can still break a fully vowelled word with spaces. The page looks the same. Readers that take glyphs strictly in file order get only plain Hebrew runs right, through `/ReversedChars`; screen readers are unverified |
|
||||
| 2026-10-14 | HTML import (#1381): header cells (`<th>`) are bold unless their own CSS says otherwise, and `text-align` on a cell, its row, row group or table aligns the cell's paragraphs (a paragraph's own alignment wins), so `wordcraft-cli convert page.html out.docx` writes the bold header and `w:jc` right-aligned cells. HTML, Markdown and plain text, which give no page size, open on the region's paper (A4 in Italy, Letter in the US) like a new blank document, in the app and in `wordcraft-cli`; documents with their own page size keep it |
|
||||
| 2026-10-14 | Custom page sizes (#1433): Layout › Size ends with More Paper Sizes…, which opens Page Setup's new Paper group — a Paper size list (the named sizes, or Custom size) and Width and Height in the measurement unit; OK sets the size only when it changed, and a wider-than-tall size comes out landscape. `layout.size` now clamps each side to Word's 0.1"–22" and refuses a page the margins don't fit (the margins rule: 36 pt of text room), and Page Setup checks the size and margins together before applying either |
|
||||
| 2026-10-14 | Bold text in Arial Black and other Black or Heavy fonts looks as in Word: older copies of these fonts (Arial Black in the core fonts) call themselves regular weight, so bold was drawn with an extra synthetic bold; a family named Black/Heavy now counts as that heavy |
|
||||
| 2026-10-13 | Highlighted text reads on a dark page (View › Switch Modes, #1252): highlights and character shading are dimmed, keeping their hue (yellow turns a dim olive, as in Word), and the text on them is drawn light (coloured text below 4.5:1 is drawn white, and underlines and strikethroughs follow the same rule), instead of white text on bright yellow; light page, print and exports unchanged |
|
||||
| 2026-10-13 | Full screen (#980): F11, or View › Window › Full Screen, fills the screen with the window (⌃⌘F on macOS); F11 again or Escape gives back the window as it was. Next Field, which Word puts on F11, keeps Word's Alt+F1 and Alt+Shift+F1 (and Shift+F11). The web app leaves full screen to the browser's own F11 |
|
||||
| 2026-10-13 | Double-clicking the top or bottom margin of a page creates its header or footer when it has none and puts the caret in it, as in Word (#960): the one that page shows (different first page, odd and even pages), only in Print Layout; one left empty is taken away again when you leave it, so nothing empty is saved. `insert.editHeader`/`insert.editFooter` take `{"page"}` for agents |
|
||||
| 2026-10-13 | Alt+numpad character codes, as in Word on Windows (#961): hold Alt, type a decimal code, release Alt — `0233` → é (Windows-1252), `233` → Θ (OEM code page 437; 850 in Western European locales and English outside the US), codes above 255 are Unicode (`8364` → €). Not on macOS; `text.typeCode` for agents |
|
||||
| 2026-10-13 | Review › Language and the status bar's language menu offer Set Proofing Language… again (#860): the Language dialog marks text as French, German or any of 29 languages and Set As Default makes it the document's language; Language Preferences… opens Options › Language. Text in a language outside the short list shows by name in the status bar and checked in the menu. Spelling and grammar remain English, Polish and Portuguese; text in other languages gets no marks |
|
||||
| 2026-10-12 | A list label moves with its text when the paragraph is centred or aligned to the end (it stayed at the margin), in left-to-right and right-to-left paragraphs (#1159) |
|
||||
| 2026-10-13 | A paragraph that ends with a page break keeps its space after on the page before, as in Word: the next page no longer starts up to a line too low, and a page break before right after it no longer leaves a blank page |
|
||||
| 2026-10-13 | Tables break across pages like Word's: a cell without its own margins lines up with its neighbours, exact row heights include the bottom margin, and a row whose margin doesn't fit at a page bottom is split with widow control instead of moving to the next page whole |
|
||||
| 2026-10-13 | Equation parsers are hardened (#1456): deeply chained input (`x^1^1…`, `a/a/…`, `\sum\sum…`) no longer builds a tree thousands of levels deep that overflows small stacks, `\text{…}` is escaped in LaTeX output so it reads back unchanged, and runs merge inside every structure. |
|
||||
| 2026-10-13 | A WMF with a million object records opens in milliseconds instead of a second, and a large polygon or a small crop of a large bitmap no longer builds a second full-size copy while it is read (#1463) |
|
||||
| 2026-10-13 | PDF export (#1474): a horizontal or vertical line inside a group (a divider in a grouped box) is drawn, as on screen; it was left out because it has no height |
|
||||
| 2026-10-14 | A paragraph that runs on into the next column or onto the next page wraps around the shapes there (#1374): its continued lines are broken again for that column's wrap areas instead of running through a shape there (or around one left on the page before); the lines before, and the paragraph's own floating objects, stay where they were. Paragraphs with nothing new to wrap around are not laid out again |
|
||||
| 2026-10-13 | A PAGE field in the body shows the number of the page it is actually on (#1352): after a page break inside its paragraph, or when the paragraph runs over onto the next page, it no longer repeats the first page's number; the rest of the paragraph is broken again for the wider number (page 9 to 10). Only paragraphs with PAGE fields are laid out again |
|
||||
| 2026-10-14 | RTF import keeps sections (#1378): a file with portrait and landscape parts, a two-column stretch or a page-numbering restart opens with one section each, as in Word, instead of one landscape section for the whole document. Each keeps its page size and orientation, margins, header/footer distance, columns, break type (continuous, next page, odd/even, column), page-number restart, start and format, title page and its own headers and footers; a section without its own links to the previous one's |
|
||||
| 2026-10-14 | The system Save As dialog's file-type list names every format (Word Document, PDF, OpenDocument Text, Rich Text Format…) instead of one "Documents" entry, with the document's own format first (#1399) |
|
||||
| 2026-10-14 | Colour emoji show on the page (#1269): 🧠, ✅ and other emoji were blank on screen and in `render_page` (only PDF export had them). Glyphs from colour fonts are now drawn in the font's own colours: bitmap emoji (Apple Color Emoji, Noto Color Emoji) and COLR fonts (Segoe UI Emoji, newer Noto builds, with gradients). A dark page doesn't invert them, the same as pictures |
|
||||
| 2026-10-14 | `.doc` tables keep their cell shading (#1380), from Word 97 through Word 365, so a dark header row with white text is readable again, and lose the extra empty column at the right of every row; a table no longer takes the previous table's columns, borders or alignment. Rows merged across the table (a legend) come with #1123 |
|
||||
| 2026-10-12 | On the web, pasting a picture while a comment, the Find box, a dialog field or the Backstage has the keyboard no longer inserts it into the document; it inserts once only when the page is the edit target (#1349) |
|
||||
| 2026-10-14 | Table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls (#1329): with tracking on, Delete Table, Rows, Columns and Cells mark the cells' text deleted and keep the table (like Word), and Change Case is a tracked deletion and insertion, so Reject All restores the original; all of them refuse to delete a control locked against deletion or change content locked against editing |
|
||||
| 2026-10-14 | Draw: Ink to Shape recognises arrows. A line and a V-shaped head drawn in one stroke (shaft first or head first, any direction) becomes a straight arrow connector with the head at the tip, in the stroke's colour and width; a lone V, a line with one barb and scribbles stay as they are. Our own recogniser: Word for Mac has no Ink to Shape to compare. Two-stroke arrows and arrows with a closed triangle head not yet. |
|
||||
| 2026-10-12 | A DOCX hyperlink whose relationship has an empty target (as LibreOffice writes) is no link (#1005): no "Ctrl+Click to follow link" tooltip over the text, which keeps its Hyperlink style |
|
||||
| 2026-10-12 | Table eraser beside vertically merged cells (#1039): the border between two cells merged down the same rows merges them into one cell that stays merged down those rows; a border whose two sides can't make one rectangle is hidden on both sides along the whole edge instead of refused |
|
||||
| 2026-10-13 | Styles gallery for DOCX files that mark no style `w:qFormat` (LibreOffice) shows Word's usual quick styles: Normal, No Spacing, Headings, Title, Subtitle… (#1238) |
|
||||
| 2026-10-13 | Pictures can be cropped on the page: Picture Format › Crop shows black crop handles; dragging one trims that side (a corner, two) while the picture keeps its scale and the visible part stays put, dragging back out restores it, and the cropped-away part shows under a dark dim. Esc, Enter, a click outside or Crop again keep the crop; each drag is one undo step |
|
||||
| 2026-10-13 | Table Layout › Cell Size: the Height and Width boxes apply to every row and column the selection covers (a selected table changed only its last row and column), and to the caret's row and column without a selection (#1242) |
|
||||
| 2026-10-12 | Forms protection: filling content controls under `w:edit="forms"` (#1077) — entries and typing inside fillable controls run, every other edit rolls back ("only the editable regions of this protected document can change"); `review.restrict` reports `formFields`; Find Next Region covers form fields (pane button shows for forms documents); a save/reopen keeps the protection, the filled values and the password guard |
|
||||
| 2026-10-12 | RTF import now uses the RTF spec's defaults where a file names none (#902): no space before or after paragraphs, single line spacing, and the `\deff` default font, instead of WordCraft's Normal style; RTF files that relied on the old defaults open tighter, like in Word |
|
||||
| 2026-10-12 | `--help` and `-h` print the usage and exit (#968): `wordcraft --help` lists the app's options without opening a window (it used to try to open a file named `--help`), and `wordcraft-cli <command> --help` works for every command instead of failing with `unknown option --help` |
|
||||
| 2026-10-11 | Right-clicking a table cell offers the table's commands, and as in Word they follow the selection: with the caret in a cell Insert, Delete Cells, Split Cells, Text Direction and Table Properties…; with several cells Merge Cells instead of Split; with whole columns or rows Delete Columns or Rows, Merge Cells and Distribute Rows and Columns; with the whole table Delete Table, Distribute, AutoFit and Insert Caption…. A right-click beside selected cells moves the caret there instead of keeping them |
|
||||
| 2026-10-11 | Drags, Shift+arrows and Shift+click over table cells select whole cells, as in Word: from one cell into another the rectangle between them (bold, alignment and Delete leave the other cells alone), into or out of a table whole rows; extending again goes on from where the selection started |
|
||||
| 2026-10-11 | More of the table mouse: arrows above a column, left of a row and along a cell's left edge select it (a drag selects more; a selected column is edited cell by cell, numbering it makes one list), "+" buttons beside the borders insert a row or column, the move handle past the top-left corner selects the table or drags it to float where it's dropped, and a double-click on a column border fits the column; Select Column in the ribbon; `table.selectAt`, `table.insertAt`, `table.moveDrag`, `table.fitColumn` and `table.selectColumn` for agents |
|
||||
| 2026-10-12 | Table fixes (#971): a double-click on a nested table's column border fits the column within the cell around the table (it could run out to the page's right margin); a selection from text into or across a table shades the whole cells of the rows it takes, not just their letters; Shift+Right at the end of a cell's text selects that cell first, as in Word, and the next press takes the next cell too |
|
||||
| 2026-10-12 | AutoCorrect keys its built-in English replacement list by language (#835): Polish, Croatian, Bosnian and Serbian `i` ("and") is no longer turned into `I`; English and untagged text unchanged |
|
||||
| 2026-10-12 | Ribbon groups fit above their labels: View › Show splits its four check boxes into two columns (Hide White Space, #645, had pushed the fourth onto the "Show" label), Developer › Controls loses the gaps that ran into its label, and Layout › Paragraph (#654) no longer trips a debug assertion and sits 2.5 pt higher; a test draws every tab in every language and fails on any group taller than the ribbon |
|
||||
| 2026-10-12 | Down and Up from the start of a line beside a table go into it (#1007): the caret, left of the first cell's text there, used to skip the whole table to the paragraph beyond it; it now lands in the cell under it or the nearest one, as in Word |
|
||||
| 2026-10-12 | Window caption: a non-Latin file name is drawn with a document fallback face (or the CJK UI font), instead of `?` |
|
||||
| 2026-10-12 | Comments: Arabic, Hebrew and other non-Latin comment text and author names are drawn with a document fallback face (or the CJK UI font) in the balloons and Comments pane, instead of boxes; read-only balloons show right-to-left text in reading order, set against the right edge |
|
||||
| 2026-10-13 | Word 365 cover pages (#743): shapes placed relative to the page's height now sit where Word puts them (they were at the top of the column), and a freeform drawn back to its start without being closed is filled, so the cover's coloured panel and its white title show |
|
||||
| 2026-10-12 | 32-bit Windows starts on OpenGL. DirectX 12 graphics pipeline creation returns "The parameter is incorrect" and the app exited before the window appeared |
|
||||
| 2026-10-12 | New documents follow the system region (#838): A4 and centimetres outside the US (Letter in the CLDR Letter territories, inches in the US), lengths written with the region's decimal separator (`2,54 cm`) and typed with either; an explicit Measurement units choice wins; `file.options {"region"}` for agents |
|
||||
| 2026-10-12 | Paste from other programs keeps their formatting (#687): Ctrl+V and Home › Paste read the system clipboard's HTML (and RTF on Windows and Wayland); Merge Formatting takes the destination's font and paragraph and keeps bold, italic, underline and strikethrough; Paste Special lists what the system clipboard holds (HTML, RTF, picture, text). Linux: pictures copied in a browser or Spectacle paste on KDE and other Wayland desktops (data-control clipboard), and a paste that finds nothing usable names what the clipboard holds instead of "empty" (#833) |
|
||||
| 2026-10-12 | Table style editor (#714): New/Modify Table Style edits each of the 13 table style parts (header row, total row, first/last column, odd/even bands, corner cells) with font, colour, fill, borders and cell alignment; every table using the style follows; all parts round-trip in DOCX; `table.modifyStyle` takes `part`/`parts` and per-edge borders |
|
||||
| 2026-10-12 | STYLEREF fields (#775): running headers and footers show each page's chapter title (page's first, or `\l` last, paragraph of the style, else the nearest before), style names, ids, heading levels and character styles, number switches; body STYLEREF updates with the other fields; STYLEREF in the Field dialog; PDF export follows the layout |
|
||||
| 2026-10-12 | Undo history list (#701): a ▾ beside the Quick Access Toolbar's Undo lists recent steps newest first; hovering highlights what a click undoes ("Undo 3 Actions"), and a click undoes them in one go. `edit.undoTo {count}` and `edit.undoHistory` (newest first, up to 100) for CLI and MCP |
|
||||
| 2026-10-12 | Start screen (#766): launched with nothing to open, WordCraft starts on File › Home (Blank document, templates, Recent) like Word, desktop and web; Esc leaves a blank document; File › Options › General › Start-up turns it off |
|
||||
| 2026-10-13 | No document behind the Start screen (#1129): Esc or Back from it shows an empty window (no page, ribbon greyed out but for Help) until Blank document, a template or a file is opened, as in Word; closing it asks nothing |
|
||||
| 2026-10-12 | Word 365 cover pages (#743): a freeform inside a group keeps its shape (it was drawn as a rectangle); text boxes keep their own internal margins and Resize shape to fit text, so a tight title box no longer loses its second line; both survive saving. The missing fills in the report were theme-coloured shapes, fixed by #611 |
|
||||
| 2026-10-12 | Alt+Q moves to "Tell me" command search, as in Word for Windows (#819); not on macOS, where Option+Q types a character |
|
||||
| 2026-10-12 | PDFW: PDF export uses the shared craft-pdf writer for metadata, font preparation and serialization while retaining WordCraft layout, drawing, tagging, links and font-outline retry. Writer-only builds do not enable PDF import codecs. |
|
||||
| 2026-10-12 | PDF export of long documents with many internal links (a linked table of contents) is linear: NIH's 67-page protocol template 10.6 s → 0.9 s |
|
||||
| 2026-10-12 | Keytips: Word for Windows' tab letters (File F, Design G, References S, Review R, View W, Help Y) |
|
||||
| 2026-10-13 | Keytips reach the table tabs as in Word: Alt, J, T for Table Design and Alt, J, L for Table Layout, then their controls' badges (#1183) |
|
||||
| 2026-10-12 | Table-of-contents links look like Word's: plain text, not blue and underlined (Word ignores the Hyperlink style on TOC entry links; measured in 9 cases) |
|
||||
| 2026-10-12 | Line heights like Word's: spaces and tabs never make a line taller (13 cases measured in Word for Windows); tables of contents with big tab runs were a third taller |
|
||||
| 2026-10-12 | `.doc` import: tab stops (centred titles, right-aligned dates, leaders) and sections ending at their section marks (continuous breaks, footers); EPA .doc templates now paginate like Word |
|
||||
@@ -172,30 +361,40 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-12 | A list label makes its line taller above the baseline only, as in Word (6 cases measured in Word for Windows): Symbol bullets no longer add their deeper descent, so bulleted lists keep Word's height and page breaks |
|
||||
| 2026-10-12 | Hidden end-of-cell marks (`w:hideMark`) read, written and laid out like Word (9 cases measured in Word for Windows): a cell's empty last paragraph takes no room, so rows of such empty cells shrink to their borders |
|
||||
| 2026-10-12 | Text frames (`w:framePr`) in headers, footers, cells and text boxes: placed apart with text wrapping around them, and kept on save (a framed footer page number took a line of its own and was lost on save) |
|
||||
| 2026-10-13 | A paragraph with more than 65,536 distinct formats keeps each one (#1340): the layout numbered a paragraph's formats with 16 bits, so every run past that drew with the 65,536th format's font, size, link and tracked-change marking |
|
||||
| 2026-10-12 | List indents like Word's when a numbered style has indents of its own: the style giving the list (and styles based on it) beat the level's indents, and a paragraph with its list switched off drops the styles' indents (27 cases measured in Word for Windows); NIH's Heading 3s now sit at their 0.9" |
|
||||
| 2026-10-12 | Keep with next like Word (20 cases measured in Word for Windows): a kept paragraph moves to the next page only when it would fit on this one by itself, otherwise it breaks as usual; chains of kept paragraphs move together |
|
||||
| 2026-10-12 | An empty paragraph that only carries a continuous section break takes no room, as in Word (10 cases measured in Word for Windows): text after it follows right on and balanced columns don't count it |
|
||||
| 2026-10-12 | Floating objects positioned relative to their paragraph start above its space before, as in Word (10 cases measured in Word for Windows) |
|
||||
| 2026-10-12 | Unequal columns: text after a column break is broken for the column it lands in (NIH's SARP form had a paragraph a word or two wide) |
|
||||
| 2026-10-13 | New comments, notes, text boxes and lists in a document whose file used the largest ids (#1338): they take a free id instead of overflowing (a crash) or reusing one already taken (a new list could pick up another's definition); `Document::try_add_part` and `Numbering::try_add_list` report ids running out instead |
|
||||
| 2026-10-12 | Space before at the top of a page like Word after page breaks (32 cases measured): a paragraph ending with a page break keeps its mark with it, the next paragraph gets no space before; page break before drops it in Word 2013 mode |
|
||||
| 2026-10-12 | Automatic paragraph spacing drops between list items, as in Word (16 cases measured in Word for Windows): bulleted items that ask for automatic space before and after now touch, as in NIH's monitoring plan |
|
||||
| 2026-10-12 | Rendering like Word for Windows, found by comparing real .gov documents page by page: text is kerned only where Kerning for fonts asks for it (line breaks now match Word's), and automatic paragraph spacing (`w:beforeAutospacing`/`w:afterAutospacing`, common in pasted web text) is 14 pt, dropped at the document's and cells' edges |
|
||||
| 2026-10-12 | Keyboard context menu (#821): Shift+F10 opens the page's right-click menu below the caret; `ui.contextMenu` |
|
||||
| 2026-10-12 | The Font list no longer keeps every family it previews in memory (#848): the Font box drew each family's name in its own face by loading the whole family for good, so every trip through the list kept tens of megabytes (1.47 GB after 40 changes on macOS); entries are now drawn from a face read for the preview alone and freed (120 families previewed: +17 MB RSS instead of +411 MB); only fonts actually used load |
|
||||
| 2026-10-13 | An empty file opens (#1384): the zero-byte .docx that File Explorer's New › Microsoft Word Document makes opens as a new blank document (the region's paper, the editing language), and Save writes it back to that file instead of asking for a name |
|
||||
| 2026-10-12 | Scrolling a document with one very large table stays smooth (#811): pages are fingerprinted for the texture cache once per layout and only when shown (not every page every frame), the markup-area check is cached per revision, and textures far from the view are dropped. The issue's 1,096-page table: ~600 ms → ~2.4 ms idle frames, ~590 ms → ~4.5 ms scroll frames |
|
||||
| 2026-10-13 | Paragraph dialog over differently formatted paragraphs (#1346): a field they differ in shows blank (a check box neither on nor off, no direction picked) and OK leaves it as each paragraph has it; OK sends only the fields that changed, as one undo step, instead of giving every paragraph the caret paragraph's formatting in three undo steps |
|
||||
| 2026-10-12 | Search (#819): Alt+Q, or Alt then Q, opens the Search box from anywhere with nothing typed into it; queries match every word; `tools.search` for agents |
|
||||
| 2026-10-12 | Section breaks (#852): Layout › Breaks with text selected puts the break before the selection and keeps the text, as Word does; it used to delete it, leaving the first section empty |
|
||||
| 2026-10-12 | Find, Replace and Go To like Word (#712): Ctrl+F types into the Navigation pane's search, one tabbed Find and Replace dialog, Go To by page (absolute or relative) and bookmark; `edit.findReplace`, `edit.goto` `pageBy` |
|
||||
| 2026-10-12 | Symbol fonts (#776): text in Wingdings, Wingdings 2, Wingdings 3, Webdings and Symbol (proprietary, never bundled) shows its intended symbols — full code tables from Unicode's published correspondence, both storage forms (`w:sym` private-use codes and plain codes); PDF text, copy and TXT export use the Unicode characters; the saved text is unchanged |
|
||||
| 2026-10-11 | Hide White Space (#645): `view.hideWhiteSpace` (View › Show checkbox, or double-click the gap between pages) collapses margins, headers and footers between pages to a thin line in Print Layout; screen only, page count unchanged |
|
||||
| 2026-10-11 | Fonts: Palatino Linotype and Book Antiqua fall back to Palatino (macOS) or TeX Gyre Pagella / P052 / URW Palladio (Linux) before generic serif (#670) |
|
||||
|---|---|
|
||||
| 2026-10-12 | Czech proofing: cs-CZ spelling reads the installed Hunspell dictionary (`hunspell-cs` on Debian/Ubuntu), suggestions use runtime MAP/REP/TRY/KEY tables, generic repeated-word and spacing checks explain in Czech; Review › Language offers Czech/Čeština; the Czech interface writes new documents and opened TXT/Markdown in cs-CZ. Affix headers accept trailing comments. No system dictionary data or Czech hyphenation patterns bundled; parity estimates unchanged |
|
||||
| 2026-10-12 | Tables: dragging the border of one selected cell (Select Cell, or a click along its left edge) moves it in that cell's row only, as in Word, like the border of cells selected across (#962) |
|
||||
| 2026-10-13 | Korean interface catalog completed: all 2,463 entries translated; the ko-KR system locale and saved language choice select the interface. The UI prefers Korean embedded/system font faces so Hangul renders instead of boxes, and print settings and printer feedback are translated. Korean proofing remains unavailable; macOS Korean IME fixes are still pending (#163, #164) |
|
||||
| 2026-10-12 | Word default shortcuts expanded (gap #24): 140 commands bound on macOS (169 combinations), 158 on Linux/Windows (188 combinations). Bound Style Inspector (Shift+F1), Macros (Alt+F8), Strikethrough (Mod+Shift+X), Find Next (Shift+F4), Copy/Paste (Mod+Insert, Shift+Insert), Go Back (Mod+Alt+Z), File Open/Save/Save As/Print (Mod+Alt+F2, Alt+Shift+F2, Alt+F2, Mod+Alt+I), Field navigation (Alt+F1, Alt+Shift+F1), Outline show first line (Alt+Shift+L), Next Footnote (Alt+Shift+>), Link to Previous (Alt+Shift+R), AutoText (Alt+F3), Spelling on Mac (Mod+Alt+L) |
|
||||
| 2026-10-13 | Web and Draft Layout now render visible full-resolution raster tiles for exceptionally tall pages rather than downsampling the entire page into one texture; verified with a 440-paragraph headless UI reproduction |
|
||||
| 2026-10-12 | Track Changes: Accept All after a tracked deletion from the end of one paragraph into a later one no longer joins the paragraph that follows the range, and Reject All after typing into an empty paragraph keeps that paragraph (#864) |
|
||||
| 2026-10-12 | Ctrl+drag copies an object (#1014): holding Ctrl (⌘ on macOS) while dragging a picture, shape or text box leaves the original and drops a copy where you let go (text boxes get their own text); the copy is selected, the clipboard is untouched, one undo step; `arrange.copyTo` |
|
||||
| 2026-10-11 | Table border drags like Word's (#711): Ctrl shares a column border's change among the columns right of it (the table keeps its width), the table's left edge moves a left-aligned table's indent (a centred or right-aligned table keeps its alignment, as in Word), a border of cells selected across moves only in their rows, and Alt shows the column widths on the ruler |
|
||||
| 2026-10-11 | Tables on the rulers (#700): with the caret in a table the horizontal ruler marks its row's column borders and the vertical ruler its rows' bottoms; dragging a marker resizes the column (the columns right of it move along; with Shift the neighbour makes up the difference) or the row, one undo step, Escape cancels; in a cell the indents and tabs are measured from the cell |
|
||||
| 2026-10-12 | Save Changes prompt answers from the keyboard: Save has focus when it opens; arrow keys and Tab move between Save, Don't Save and Cancel; Enter answers (#861) |
|
||||
| 2026-10-12 | Builds with craft-fonts draw simplified-Chinese characters in documents from the bundled Noto Sans CJK SC (it was only used by the interface), so a CJK run gets one face covering it; fixes the FreeBSD job's `east_asian_run_uses_one_face_covering_the_whole_run` failure after #567 |
|
||||
| 2026-10-12 | Insert › Picture, drag-and-drop and paste take Windows Metafiles (`.wmf`) and Enhanced Metafiles (`.emf`) at their own frame size (#802) |
|
||||
| 2026-10-14 | A picture or icon inserted by a command (`wordcraft-cli run`, MCP, control channel) leaves the caret after it, so the next `text.newParagraph` or `text.insert` no longer deletes it, and two inserted in a row are both kept; Picture Format commands still act on it, and a picture the user inserts or pastes in the app is still selected (#965) |
|
||||
| 2026-10-12 | AutoCorrect capitals (#544): Correct TWo INitial CApitals (with exceptions), accidental cAPS LOCK, first letter of table cells; each undoable and switchable |
|
||||
| 2026-10-11 | Turkish (`tr`) interface completed (#774): 2,325 entries, every key of the Russian catalog (equation gallery, built-in style names, watermarks, paper sizes, preview tiles); the interface audit checks Turkish too |
|
||||
| 2026-10-12 | Navigation headings truncate within the resizable pane, with an ellipsis for long titles and full text on hover (#759) |
|
||||
@@ -206,7 +405,9 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-11 | Polish: interface (`pl`, the thirteenth language, every catalog key) and proofing — spelling against the installed Hunspell `pl_PL` dictionary (read at runtime, not bundled) through a new Hunspell-style affix engine, suggestions tuned for Polish, Polish grammar rules with Polish messages, TeX Polish hyphenation patterns (MIT option); proofing language per run (`w:lang`) for squiggles, F7, suggestions and hyphenation, languages without tools left unmarked; Review › Language menu and the status bar's language; a Polish interface writes new documents in Polish |
|
||||
| 2026-10-11 | German (`de`) interface (#574): 2,021 translated entries covering the full zh-hans key set, locale and saved-preference tests, bundled-font checks; no German proofing resources |
|
||||
| 2026-10-12 | Sorting and table numbers follow the text's language (#836, #837): Table › Sort, Home › Sort, the index (with its letter headings), the bibliography and the Table of Authorities use the language's alphabet (Polish `Łódź` between `Kraków` and `Zakopane`, Swedish `å ä ö` after `z`, German `ä` with `a`); table sort and formulas read `1,5` and `1 250,00` as Polish decimals and `=SUM` writes `5,75` |
|
||||
| 2026-10-14 | `.doc` notes, headers and text boxes read their text once (#1319): a story table that steps back no longer has many footnotes, endnotes, headers or text boxes copy the same text into a part each; a story starting inside an earlier one is dropped (a header or footer left empty is linked to the previous section's), so the stories together never hold more than the file's text |
|
||||
| 2026-10-11 | `.doc` import keeps pictures and drawings (#551): inline and floating pictures (PNG, JPEG, DIB, EMF, WMF), shapes, lines, groups and text boxes with position and wrap, from [MS-ODRAW] |
|
||||
| 2026-10-14 | ODT and `.doc` pictures stay within their byte budget (#1334): an ODT picture entry is inflated once however many frames point at it, and only when the size it declares fits in what is left (one inflating past that uses the budget up); a `.doc` inline picture that can't be read or doesn't fit is no longer decoded and copied again for every anchor on it, and is decoded only within what is left |
|
||||
| 2026-10-11 | Tables resize with the mouse (#217): drag a column border (the neighbour makes up the difference; Shift moves one column), a row's bottom border, or the handle past the bottom-right corner to scale the table; a dashed guide while dragging, one undo step per drag; `table.resizeDrag` for agents |
|
||||
| 2026-10-11 | Animation effects setting: File › Options › Accessibility › Animation effects (Use system setting, On, Off). With the system's animations off (Windows' Animation effects, macOS' Reduce motion, the Linux/BSD desktop's setting, a browser's reduced motion) the page no longer smooth-scrolls: wheel notches and Ctrl+wheel zoom land at once, in the document and in every list and pane, and menus and buttons don't fade; `ui.animations` for agents |
|
||||
| 2026-10-11 | Linux/BSD: Open, Save As, Export and Insert Picture work without a file chooser portal or zenity (labwc, sway…): WordCraft shows its own file browser and says how to get the system dialog; File › Open › Browse in WordCraft… is always there (#576) |
|
||||
@@ -217,8 +418,11 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-11 | File › Print: page preview, page range (all, current, selected, custom 1-3,5), odd/even, copies and collate go into the printed PDF (`file.printPdf`, `ui.print` params); printer, duplex and pages per sheet are still the system dialog's (#641, stage 1) |
|
||||
| 2026-10-11 | A vertically merged table cell (eraser on a horizontal border) lays out, shows and clicks as one cell spanning its rows (#640) |
|
||||
| 2026-10-11 | Word XML files (#548): Word XML Document (Flat OPC `.xml`) opens and saves through the DOCX reader and writer; Word 2003 XML opens (styles, lists, tables, sections, notes, comments, pictures, hyperlinks) |
|
||||
| 2026-10-14 | Open DrawingML freeform paths draw `a:tailEnd` at the final path segment |
|
||||
| 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available |
|
||||
| 2026-10-11 | Font fallback indexes which characters each installed font maps, reading each font file once (#605): text in a script no installed font covers no longer re-reads every system font per distinct character (200 such characters: 13 s → 0.2 s) |
|
||||
| 2026-10-13 | Markdown import of text full of backtick runs that never close is linear (#1336): every code span and link bracket over them searched the rest of the text for a closing run (2 MB in brackets: 17.8 s → 0.06 s) |
|
||||
| 2026-10-13 | Markdown import keeps every link in a paragraph (#1452): a `[` nothing closes, bracketed text that is not a link and each link used up the bracket scans' look-ahead budget as if they had read the rest of the paragraph, so later links were plain text; a scan now costs what it reads, and brackets it has passed are looked up |
|
||||
| 2026-10-11 | Find and Replace special characters (`^p`, `^t`, `^#`, `^&` …) and Use wildcards (ranges, counts, word anchors, groups with `\1` in Replace); `^p` finds and splits paragraphs; Special ▾ menu and search options in the dialog (#543) |
|
||||
| 2026-10-11 | Typing parity (#546): `+---+` Enter makes a table, `> ` `-> ` `=> ` arrow bullets, smileys inside words (`ok:)`), Undo right after a border line brings the characters back |
|
||||
| 2026-10-11 | Windows: DirectX 12 shaders compile with FXC, built into Windows, so the app no longer quits at launch with "Parent device is lost" when no matching `dxcompiler.dll` + `dxil.dll` pair is around (#603) |
|
||||
@@ -226,7 +430,9 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-11 | Outline view (#493): Outlining tab, outline symbols and indents, collapse/expand, Show Level, first line only, plain text; catalog 428/450 → 441/463 (95.2%) |
|
||||
| 2026-10-11 | Vertical sections (#491): Layout › Text Direction turns the section (or the cell or text box the caret is in); lines run top to bottom, right to left, ideographs upright; `w:textDirection` in `w:sectPr` read and written |
|
||||
| 2026-10-11 | File › Options in Word's panes (#485): General, Display, Proofing, Save, Language, Accessibility, Advanced, Agents; initials, individual formatting marks, spelling ignore options, custom dictionary, overtype, typing replaces selection, measurement units, recent-documents count, print options; `file.options` reads and sets them headlessly |
|
||||
| 2026-10-14 | Word XML Document (Flat OPC) reading is bounded everywhere in the file (#1353): nesting and elements are capped as the XML tree reader caps a part's, every child of the package and every part's data count against the part count and size limits (a repeated part name too), and data is checked before it's copied; base64 written as CDATA or with character references reads as the same bytes, and an entity in it is an error instead of bytes gone missing |
|
||||
| 2026-10-11 | Right-click Wrap Text submenu on pictures, shapes and text boxes: wrapping styles, Move with Text / Fix Position on Page (#513) |
|
||||
| 2026-10-14 | Text boxes in tables nested deeper than 16 survive edits (#1339): the paragraph walk behind text box pruning, copy and paste, reading order, drawing ids and note conversion goes through tables however deep they nest instead of stopping 16 tables down, so an unrelated edit no longer drops the text of a box shown only from deeper, and a copied deep table takes its boxes along |
|
||||
| 2026-10-11 | Caret animation: keys and typing glide the caret to its new place in 100 ms along the curve measured from Word for Windows, and the blink fades; clicks and scripted moves don't glide. File › Options › Advanced › Animation turns it off, kept between runs; `ui.caretAnimation` for agents |
|
||||
| 2026-10-11 | Paragraph dialog options (#512): mirror indents (`w:mirrorIndents`, laid out on even pages), same-style spacing, suppress line numbers, don't hyphenate |
|
||||
| 2026-10-11 | Screen readers can read the document canvas (#488): paragraphs, headings, lists, tables, links, pictures and the caret exposed through AccessKit |
|
||||
@@ -262,11 +468,17 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-12 | Native saves use exclusively created sibling temporary files, synchronize before replacement and clean up on failure; existing temporary-name files/links and original files on failed saves are protected. Synthetic CLI and focused tests on macOS; no new Word parity or readiness claim. |
|
||||
| 2026-10-11 | Field codes like Word's field keys (#339): show codes (Alt+F9, Shift+F9), edit, unlink, lock and step through fields |
|
||||
| 2026-10-11 | Word's common keyboard shortcuts (#330): 101 → 125 of ~250 |
|
||||
| 2026-10-13 | Swedish (`sv`) interface: 2,504 catalog entries (every key of the reference catalog, the equation gallery, everything the interface audit finds, and 44 newer strings no catalog had yet, among them the native print settings), `sv-SE`/`sv-FI` system locales and a saved language choice; locale, catalog and bundled-font tests; AI-assisted, terminology and the running app reviewed by a native speaker; no Swedish proofing resources |
|
||||
| 2026-10-12 | Bulgarian (`bg`) interface: 2,419 catalog entries (every key of the reference catalog, the equation gallery and everything the interface audit finds), `bg-BG` system locales and a saved language choice; locale, catalog and bundled-font tests; no Bulgarian proofing resources |
|
||||
| 2026-10-13 | Croatian (`hr`) interface: 2,507 catalog entries (every key of the reference catalog, built-in style names and the equation gallery), `hr-*` system locales and a saved language choice; locale, catalog and bundled-font tests; no Croatian proofing yet |
|
||||
| 2026-10-11 | Hungarian (`hu`) interface: all 2,419 catalog entries, built-in style names translated, `hu-HU` system locales and a saved language choice; locale, catalog and bundled-font tests; no Hungarian proofing resources |
|
||||
| 2026-10-11 | Turkish (`tr`) interface: all 1,307 catalog entries, `tr-TR`/`tr-CY` system locales and a saved language choice; command search keeps Turkish dotted and dotless I apart (`italik` finds İtalik); locale, catalog, search and bundled-font tests; no Turkish proofing resources |
|
||||
| 2026-10-11 | Italian (`it`) interface (#594): 2,399 entries, feature names as Italian Word users know them, locale and saved-preference tests, bundled-font checks; no Italian proofing resources |
|
||||
| 2026-10-12 | Native drawing-level Word ink preserved through DOCX edits and repeated saves, including its InkML part and optional VML picture fallback; native ink rendering and editing remain missing |
|
||||
| 2026-10-12 | List-label line height: automatic spacing scales body text before adding label ascent overflow; AtLeast spacing keeps text descent. Original Word for Mac probes and deterministic layout tests cover symbol/decimal labels, wrapped lines, fixed/minimum spacing and inline pictures. Overall readiness unchanged; [evidence](docs/layout-parity.md#list-label-line-spacing) |
|
||||
| 2026-10-12 | Local desktop recovery: unsaved drafts and edits are snapshotted in the profile; File › Recover restores an unsaved copy or confirms discard. Full model storage includes media and opaque parts. Password sessions skip plaintext snapshots. See [recovery.md](docs/recovery.md); stability estimates remain unchanged. |
|
||||
| 2026-10-12 | Legacy table paragraph spacing: `allowSpaceOfSameStyleInTable` survives DOCX save; enabled modes 11/12/14 keep contextual same-style spacing in cells. Mode 15 and non-cell controls unchanged; measured against Word for Mac 16.113.3 |
|
||||
| 2026-10-11 | Contextual paragraph spacing: same-style first-only 12/8 pt and 10/12 pt gaps corrected to 0 pt and 2 pt in body and header layout, with per-side controls; measured against Word 16.113.3. Ordinary overlap remains #108 |
|
||||
| 2026-10-11 | Citations and bibliography in all 12 of Word's styles: Harvard, ISO 690 (author-date and numerical), Turabian, GB/T 7714, GOST (name and title sort) and SIST02 join APA, MLA, Chicago and IEEE; numbered styles count in order of first citation (#383) |
|
||||
| 2026-10-11 | Interface size setting (#475): File › Options › General scales the interface 80–200 % on top of the system display scale, kept between runs; `ui.interfaceSize` for agents |
|
||||
| 2026-10-11 | DOCX paragraph defaults: preserve omission separately from explicit empty defaults, with repeated-save and default-edit regressions |
|
||||
@@ -282,11 +494,135 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-07 | v0.1.0 released; contributor credits in About (#6) |
|
||||
| 2026-10-06 | Layout arc: wrap around floats, text boxes, page borders, line numbers, row splitting, drop caps, hyphenation, comment balloons; release pipeline for every platform; first estimate (~62% real feature parity, 120–150 h to 100%) |
|
||||
| 2026-10-05 | M0–M12 first versions: model, layout, render, engine with 200+ commands, Word-style UI, DOCX I/O, PDF and other formats, references, review, mailings, CLI, MCP, web |
|
||||
| 2026-10-12 | Mail-merge recipients (#778): legacy Excel .xls workbooks; CSV/TXT lists in GBK, Big5, Shift_JIS, UTF-16 and other encodings read correctly (plain-text import too); Edit Recipient List filters (equal, not equal, less, greater, blank, contains… joined by And/Or) and sorts, and preview, Finish & Merge and labels use only the matching records in that order (`mailings.filterRecipients`, `mailings.sortRecipients`) |
|
||||
| 2026-10-13 | The interface-translation audit runs in CI again (#1128): the size labels of Envelopes, Labels and Layout › Size are translated (`4,13 × 9,5 дюйма`) and use the region's decimal separator, and status-bar messages (table split and merge, ink replay, citations, SmartArt, Researcher, Design defaults), the Reading Mode toolbar, Go To and Search's Suggested heading are translated in every interface language, and the Read Aloud player no longer shows a speech error a frame before the status bar does, the race behind the audit's rare failure under load |
|
||||
| 2026-10-15 | Lists (#1683): a bullet stored as a private-use character its font can't draw (the font isn't installed) shows the level's default bullet (• ○ ▪); it used to be whatever an installed icon font has at that code, such as Font Awesome's Twitter logo |
|
||||
| 2026-10-15 | The web app keeps its preferences between visits (#2132): theme (dark mode), interface size, editing options and the rest of what the desktop app saves in `ui.json` are kept in the browser's `localStorage`, written as soon as they change |
|
||||
| 2026-10-17 | Saving into a folder that refuses a temporary file (macOS privacy protection, read-only folders, #2138) never overwrites an existing document in place: it is left untouched and the error says to use Save As to another folder or allow WordCraft access to the folder (System Settings › Privacy & Security › Files and Folders on macOS). A new file is created directly. Unit tests on Linux; not yet confirmed on a Mac |
|
||||
|
||||
Footer page-table height and body wrapping are corrected. Other pagination gaps remain open.
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-19 | trivial | Progress log: forms protection keeps its content controls: no inserting, Properties or Remove Content Control |
|
||||
| 2026-10-19 | trivial | Progress log: text typed into a content control takes the control's formatting; localized placeholder styles recognised |
|
||||
| 2026-10-18 | trivial | Progress log: long navigation headings wrap within the pane instead of ending in an ellipsis (#3015) |
|
||||
| 2026-10-18 | trivial | Font list starts at its first row after clearing a no-match query; recent heading remains visible (#2068) |
|
||||
| 2026-10-18 | trivial | A word after a slash wraps whole, like Word (#2963) |
|
||||
| 2026-10-17 | trivial | Progress log: recipient list search and merged-only view (#1576) |
|
||||
| 2026-10-18 | trivial | Progress log: symbols keep their font when the text font changes (#2036) |
|
||||
| 2026-10-17 | trivial | Progress log: Restrict Editing guards tables, Paste Formatting and merge fields (#1327) |
|
||||
| 2026-10-18 | trivial | Progress log: Picture Format › Size, sizes typed in any unit and the Size dialog (#2573); readiness estimates unchanged |
|
||||
| 2026-10-18 | trivial | Progress log: Manage Sources imports Word's `Sources.xml` (#2640) |
|
||||
| 2026-10-14 | trivial | Progress log: a list item's first word is not flagged for a missing capital (#1741) |
|
||||
| 2026-10-18 | trivial | #2445: pending Save As answers keep their originating document identity, so a replacement document is never written to an old picker destination; real Save-button and focused-editor shortcut controls verified |
|
||||
| 2026-10-16 | trivial | #2445: interactive Save on import-only .doc/.dot paths uses the existing DOCX Save As flow; cancel and failed writes keep the draft, and Close waits for a successful save. Explicit/script saves still report unsupported legacy output |
|
||||
| 2026-10-16 | trivial | Save Changes exposes an alert-dialog role, the document-specific question and loss/failure context to accessibility; full-app focus/ancestry regression. Native screen-reader verification remains pending. |
|
||||
| 2026-10-16 | trivial | Progress log: the Spike |
|
||||
| 2026-10-16 | trivial | Progress log: `w:eastAsianLayout` kept on save |
|
||||
| 2026-10-15 | trivial | Progress log: undrawable private-use list bullets show the level's default bullet (#1683) |
|
||||
| 2026-10-15 | trivial | Progress log: web preferences kept between visits (#2132) |
|
||||
| 2026-10-15 | trivial | Progress log: Recently Used Fonts in the font box (#2068) |
|
||||
| 2026-10-15 | trivial | Progress log: one Thai face for a run in a missing Thai font (#1549) |
|
||||
| 2026-10-18 | trivial | Progress log: Different First Page headers reachable from page 1 (#2826) |
|
||||
| 2026-10-18 | trivial | Progress log: bold Black/Heavy fonts are not emboldened again |
|
||||
| 2026-10-16 | trivial | Progress log: Web Layout fills the window width (#1876) |
|
||||
| 2026-10-17 | trivial | Progress log: DOCX glossary documents (building blocks) |
|
||||
| 2026-10-15 | trivial | Progress log: move options in Track Changes Options (gap #5) |
|
||||
| 2026-10-15 | trivial | Progress log: image size limits checked before allocation (#1321) |
|
||||
| 2026-10-17 | trivial | Progress log: Read Aloud speaks each sentence in its own language, #1843 |
|
||||
| 2026-10-18 | trivial | Progress log: `wordcraft-cli run --script` (#1963) |
|
||||
| 2026-10-18 | trivial | Progress log: header-cell picture watermarks drawn behind the text (#1775); readiness estimates unchanged |
|
||||
| 2026-10-14 | trivial | Progress log: comment drafts kept and written before saving or closing (#1348) |
|
||||
| 2026-10-17 | trivial | Language dialog keeps inherited proofing language and no-proof choices (#2717); readiness estimates unchanged |
|
||||
| 2026-10-14 | trivial | Progress log: ASK and SET fields show nothing (#1765) |
|
||||
| 2026-10-15 | trivial | Progress log: at-least table rows start the next page whole (#1742) |
|
||||
| 2026-10-14 | trivial | Progress log: ODT sections on export (gap 19) |
|
||||
| 2026-10-17 | trivial | Explicit DrawingML preset/custom outline dashes survive import/save and paint in raster/PDF, scaled to the effective stroke width even for zero-width paths. Synthetic controls; theme-inherited dashes and line caps remain unverified. |
|
||||
| 2026-10-15 | trivial | `wordcraft-cli convert --update-fields` explicitly refreshes unlocked fields before conversion; ordinary conversion keeps cached results. Field refresh can change pagination |
|
||||
| 2026-10-17 | trivial | Arial Unicode MS uses Arial and its proportional substitutes; glyph coverage and exact Word line height remain font-dependent. |
|
||||
| 2026-10-18 | trivial | Progress log: Split Cells and Convert Text to Table dialogs, matched to the Word check (#2592) |
|
||||
| 2026-10-16 | trivial | Progress log: tall-ascent font list entries no longer cut off (#2570) |
|
||||
| 2026-10-16 | trivial | Progress log: Read Mode keeps the keyboard, whole columns, page-turn bar clear (#1772) |
|
||||
| 2026-10-16 | trivial | Progress log: comments readable in the dark theme (#2045) |
|
||||
| 2026-10-16 | trivial | Progress log: Indonesian proofing (#498) |
|
||||
| 2026-10-16 | trivial | Progress log: legacy outline, shadow, emboss and imprint text effects drawn (#2534) |
|
||||
| 2026-10-15 | trivial | Progress log: Columns on selected text (#2024) |
|
||||
| 2026-10-15 | trivial | Navigation Find and query edits expose Results (#2096) |
|
||||
| 2026-10-15 | trivial | Progress log: numbered headings (#1793) |
|
||||
| 2026-10-15 | trivial | Add the missing `-->` → `→` AutoCorrect entry (#2223), with typing, undo and preference regression coverage; existing arrow bullets unchanged |
|
||||
| 2026-10-15 | trivial | Progress log: Update Field updates the part being edited, as in Word (#1706) |
|
||||
| 2026-10-14 | trivial | Progress log: Update Fields reaches headers, footers and text boxes (#1706) |
|
||||
| 2026-10-15 | trivial | Progress log: font family caches bounded; math font resolution follows font changes (#1354) |
|
||||
| 2026-10-15 | trivial | Progress log: web typing in non-Latin text no longer repeats (#1480) |
|
||||
| 2026-10-15 | trivial | Progress log: Backspace removes a selected table, rows or columns (#1844) |
|
||||
| 2026-10-16 | trivial | Progress log: Hebrew field results and smart quotes (#1670) |
|
||||
| 2026-10-16 | trivial | Progress log: Ctrl+wheel zoom no longer scrolls the document (#1743) |
|
||||
| 2026-10-16 | trivial | Hint-reliant CJK fonts execute TrueType contour assembly; Windows DFKai-SB no longer loses strokes. Synthetic local-font regression; no font binaries bundled. |
|
||||
| 2026-10-14 | trivial | Progress log: RTF export writes sections (gap 19) |
|
||||
| 2026-10-15 | trivial | Progress log: multi-letter equation scripts (#1609) |
|
||||
| 2026-10-15 | trivial | Progress log: plain-text paste keeps the paragraph formatting where it lands |
|
||||
| 2026-10-14 | trivial | Progress log: Add Font imports every selected font file (#1789) |
|
||||
| 2026-10-15 | trivial | Additive paragraph spacing retains the full before-space after a section page break; ordinary overlapping spacing is unchanged. |
|
||||
| 2026-10-15 | trivial | Exact line spacing keeps the run font baseline around tall equations; automatic and minimum spacing still grow for math. |
|
||||
| 2026-10-16 | trivial | Zero-extent drawing anchors contribute no inline font height. Synthetic regressions. |
|
||||
| 2026-10-16 | trivial | Control keyboard/text input works in the inactive native window (#2420) |
|
||||
| 2026-10-15 | trivial | Progress log: Hide Quick Access Toolbar (#1945) |
|
||||
| 2026-10-15 | trivial | Progress log: native ink nested in groups and drawing canvases kept on save |
|
||||
| 2026-10-15 | trivial | Progress log: the word being typed gets no squiggle until the caret leaves it (#1552) |
|
||||
| 2026-10-15 | trivial | Progress log: Highlight No Color sticks to the button (#2105) |
|
||||
| 2026-10-16 | trivial | Body and active note text keep normal opacity while editing footnotes or endnotes (#2545); header/footer dimming is unchanged |
|
||||
| 2026-10-17 | minor | Progress log: Drop Cap Options (#2601) |
|
||||
| 2026-10-16 | trivial | An otherwise empty section at page start retains its line before continuous content; later closing marks stay quiet |
|
||||
| 2026-10-16 | trivial | Progress log: equations from Word files keep their content (#1273) |
|
||||
| 2026-10-16 | trivial | Progress log: Insert Caption… on objects and Page Setup header and footer options (#1230) |
|
||||
| 2026-10-17 | trivial | SECTIONPAGES includes shared continuous-section pages in body and header/footer fields |
|
||||
| 2026-10-17 | trivial | Footer page tables no longer inflate footer text height; body lines wrap at their physical bounds |
|
||||
| 2026-10-17 | minor | Progress log: Scale to paper size on File › Print, #2108 |
|
||||
| 2026-10-17 | trivial | Non-overlapping body floating tables clear header tables; overlapping tables keep their requested position |
|
||||
| 2026-10-15 | trivial | Progress log: caret inside ligatures (#1974) |
|
||||
| 2026-10-14 | trivial | Progress log: row table border exceptions |
|
||||
| 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks |
|
||||
| 2026-10-12 | minor | Czech spelling from the installed Hunspell dictionary, runtime-table suggestions and generic checks; Czech interface writes cs-CZ documents; no Czech hyphenation patterns bundled; estimates unchanged |
|
||||
| 2026-10-12 | trivial | Progress log: one selected cell's border drags in its row only (#962) |
|
||||
| 2026-10-16 | trivial | Progress log: opening a DOCX no longer copies its pictures |
|
||||
| 2026-10-12 | minor | Progress log: Word default shortcuts expanded to 140 commands on macOS (169 combinations) and 158 on Linux/Windows (188 combinations), addressing gap #24 |
|
||||
| 2026-10-13 | trivial | Web/Draft long-page blur fixed with visible full-resolution raster tiles |
|
||||
| 2026-10-14 | trivial | Progress log: VML freeforms read and kept on save |
|
||||
| 2026-10-14 | trivial | Progress log: consistent preferred cell widths |
|
||||
| 2026-10-14 | trivial | Field instructions spanning paragraphs no longer add blank lines during pagination; empty result paragraphs still take a line. |
|
||||
| 2026-10-14 | trivial | A list paragraph containing only a page break has no visible label; it still advances the list counter. Ordinary empty list items keep their label. |
|
||||
| 2026-10-14 | trivial | Floating-table overlap checks use visible line content; empty line-break lines no longer move a table |
|
||||
| 2026-10-14 | trivial | Progress log: deleting a comment leaves no dangling anchors or replies (#1345) |
|
||||
| 2026-10-14 | minor | Legacy DOC DIB conversion byte limits only; related #1334/#1554, stage and readiness estimates unchanged |
|
||||
| 2026-10-15 | trivial | Progress log: Ink to Shape two-stroke and closed-head arrows |
|
||||
| 2026-10-13 | trivial | Progress log: justified vertical page alignment (#1320) |
|
||||
| 2026-10-14 | trivial | Progress log: file names that aren't valid Unicode (#1440) |
|
||||
| 2026-10-13 | trivial | Behind Text pictures and shapes drag over overlapping text in the body and headers, selected or not, while ordinary clicks keep editing text; In Front of Text drag covered too |
|
||||
| 2026-10-13 | trivial | Progress log: the watermark written to DOCX (#1290) |
|
||||
| 2026-10-13 | trivial | Progress log: Word's text watermark read from DOCX (#1290) |
|
||||
| 2026-10-13 | trivial | Progress log: Insert Hyperlink › Place in This Document (#1234) |
|
||||
| 2026-10-13 | trivial | Progress log: Insert › Bookmark deletes and goes to bookmarks (#1236) |
|
||||
| 2026-10-12 | trivial | Progress log: an option to show only the selected text's comment (#1055) |
|
||||
| 2026-10-12 | trivial | Progress log: clicking in a comment's text moves its caret (#1013) |
|
||||
| 2026-10-12 | trivial | Progress log: a press on the ribbon no longer scrolls the document (#1019) |
|
||||
| 2026-10-12 | trivial | Progress log: Ctrl+drag copies an object (#1014) |
|
||||
| 2026-10-12 | trivial | Document title opens Rename Document; the account badge opens Options › General for user name and initials. |
|
||||
| 2026-10-13 | trivial | Progress log: rounded rectangle corners (#1476) |
|
||||
| 2026-10-13 | trivial | Progress log: PAGEREF in headers and footers (#1477) |
|
||||
| 2026-10-13 | trivial | Replace All checks content-control locks before editing; a refused batch leaves the document and undo history unchanged; existing deletion-lock rules reused, with tracked/untracked regression coverage |
|
||||
| 2026-10-14 | trivial | Progress log: print-area strings translated in every catalog |
|
||||
| 2026-10-14 | trivial | Progress log: All Caps and Small Caps draw ß as SS (#1581) |
|
||||
| 2026-10-13 | trivial | Progress log: Windows Magnifier follows the caret onto a new line (#1119) |
|
||||
| 2026-10-13 | trivial | Progress log: a window too big for its screen shrinks to fit (#1215) |
|
||||
| 2026-10-14 | trivial | Progress log: Page Setup sections are validated (#1582) |
|
||||
| 2026-10-13 | trivial | Progress log: `--control` checks its port (#1426) |
|
||||
| 2026-10-14 | trivial | Progress log: words beside a floating picture are no longer split (#1376) |
|
||||
| 2026-10-14 | trivial | Progress log: VML preset shapes (#1377) |
|
||||
| 2026-10-14 | trivial | Progress log: Russian letter list labels |
|
||||
| 2026-10-14 | trivial | Progress log: the save prompt takes the keyboard from the page (#1597) |
|
||||
| 2026-10-12 | trivial | Progress log: VML text box margins, fit and alignment; hidden drawings |
|
||||
| 2026-10-13 | trivial | Progress log: comment balloon buttons work, empty comments can be deleted (#1085) |
|
||||
| 2026-10-12 | trivial | Progress log: sample-text formulas on Enter (#1067) |
|
||||
@@ -294,14 +630,50 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-13 | trivial | Progress log: the caret contrasts with its background (#299) |
|
||||
| 2026-10-13 | trivial | Progress log: clicks on ribbon chrome keep typing in the document (#1093) |
|
||||
| 2026-10-13 | trivial | Progress log: dragged shapes wrap in their new column (#884) |
|
||||
| 2026-10-14 | trivial | Progress log: All Caps and Small Caps text extracts from PDFs as shown (#1473) |
|
||||
| 2026-10-14 | trivial | Progress log: the web's Save before New/Open waits for the save picker (#1315) |
|
||||
| 2026-10-13 | trivial | Progress log: a failed save from the close prompt says so (#1445) |
|
||||
| 2026-10-14 | trivial | Progress log: Inspect Document removes hidden text in every story (#1344) |
|
||||
| 2026-10-14 | trivial | Progress log: ODT tracked changes (gap 19) |
|
||||
| 2026-10-14 | trivial | Progress log: RTF tracked insertions and deletions (gap 19) |
|
||||
| 2026-10-14 | trivial | Progress log: alignment guides while dragging pictures and shapes |
|
||||
| 2026-10-14 | trivial | Progress log: ODT sections on import (#1379) |
|
||||
| 2026-10-14 | trivial | Progress log: right-to-left text in exported PDFs extracts as whole words (#1383) |
|
||||
| 2026-10-14 | trivial | Progress log: HTML header cells, cell alignment and region paper for opened HTML/Markdown/text (#1381) |
|
||||
| 2026-10-14 | trivial | Progress log: custom page size from More Paper Sizes… and Page Setup (#1433) |
|
||||
| 2026-10-13 | trivial | Progress log: highlighted text on a dark page (#1252) |
|
||||
| 2026-10-13 | trivial | Progress log: full screen on F11 (#980) |
|
||||
| 2026-10-13 | trivial | Progress log: interface audit back in CI (#1128) |
|
||||
| 2026-10-13 | trivial | Progress log: double-click the margin to create and edit a header or footer (#960) |
|
||||
| 2026-10-13 | trivial | Progress log: Set Proofing Language… in the Language menu (#860) |
|
||||
| 2026-10-12 | trivial | Progress log: list label follows paragraph alignment (#1159) |
|
||||
| 2026-10-13 | trivial | Progress log: space after a page break |
|
||||
| 2026-10-13 | trivial | Progress log: table row margins and splitting |
|
||||
| 2026-10-13 | trivial | Progress log: lines inside groups in PDF export (#1474) |
|
||||
| 2026-10-14 | trivial | Progress log: continued paragraphs wrap around the next column's shapes (#1374) |
|
||||
| 2026-10-13 | trivial | Progress log: PAGE fields on the pages a paragraph runs onto (#1352) |
|
||||
| 2026-10-14 | trivial | Progress log: RTF import keeps sections (#1378) |
|
||||
| 2026-10-14 | trivial | Progress log: colour emoji drawn on screen and in page renders (#1269) |
|
||||
| 2026-10-14 | trivial | Progress log: `.doc` table shading and the extra column (#1380) |
|
||||
| 2026-10-12 | trivial | Progress log: web picture paste respects the edit target (#1349) |
|
||||
| 2026-10-14 | trivial | Progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls (#1329) |
|
||||
| 2026-10-14 | trivial | Progress log: Ink to Shape recognises single-stroke arrows |
|
||||
| 2026-10-13 | trivial | Progress log: Styles gallery fallback for DOCX files without qFormat (#1238) |
|
||||
| 2026-10-13 | trivial | Progress log: crop handles on the canvas |
|
||||
| 2026-10-13 | trivial | Progress log: cell size boxes act on the whole selection (#1242) |
|
||||
| 2026-10-12 | trivial | Progress log: Accept All and Reject All keep the right paragraph marks (#864) |
|
||||
| 2026-10-12 | trivial | Progress log: `--help` for the app and every CLI subcommand (#968) |
|
||||
| 2026-10-12 | trivial | Progress log: Down and Up no longer skip a table (#1007) |
|
||||
| 2026-10-13 | trivial | Progress log: equation pictures keep the theme colours on Windows (#1002) |
|
||||
| 2026-10-12 | trivial | Progress log: table style editor (#714) |
|
||||
| 2026-10-11 | trivial | Progress log: Ctrl, Alt, left-edge and selected-cells table border drags (#711) |
|
||||
| 2026-10-11 | trivial | Progress log: table markers on the rulers (#700) |
|
||||
| 2026-10-11 | trivial | Progress log: the table context menu |
|
||||
| 2026-10-12 | trivial | Progress log: AutoFit in a nested table, whole cells shaded for a selection through a table, Shift+Right at a cell's end (#971) |
|
||||
| 2026-10-11 | trivial | Progress log: drags and Shift+arrows over table cells select whole cells |
|
||||
| 2026-10-11 | trivial | Progress log: table selection areas, insert buttons, move handle and double-click AutoFit; catalog 484/499 |
|
||||
| 2026-10-12 | trivial | Progress log: Undo history list (#701) |
|
||||
| 2026-10-14 | trivial | Progress log: Save As type list (#1399) |
|
||||
| 2026-10-12 | trivial | Progress log: Start screen at launch (#766) |
|
||||
| 2026-10-13 | trivial | Progress log: no document behind the Start screen (#1129) |
|
||||
| 2026-10-12 | trivial | Progress log: Alt+Q command search (#819); log table header fixed |
|
||||
@@ -309,9 +681,12 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-12 | trivial | Font list previews no longer grow memory (#848) |
|
||||
| 2026-10-12 | trivial | Progress log: section break keeps the selected text (#852) |
|
||||
| 2026-10-12 | trivial | Progress log: save prompt from the keyboard (#861) |
|
||||
| 2026-10-13 | trivial | Progress log: an empty .docx opens and saves back to itself (#1384) |
|
||||
| 2026-10-12 | trivial | Progress log: craft-fonts Simplified Chinese in documents |
|
||||
| 2026-10-12 | trivial | Progress log: symbol-font text drawn as Unicode symbols (#776) |
|
||||
| 2026-10-13 | trivial | Progress log: the Paragraph dialog leaves mixed formatting alone (#1346) |
|
||||
| 2026-10-12 | trivial | Progress log: WMF and EMF pictures can be inserted (#802) |
|
||||
| 2026-10-14 | trivial | Progress log: a picture inserted by a command survives the next typing (#965) |
|
||||
| 2026-10-11 | trivial | Progress log and shipped languages: Turkish interface completed, 2,325 entries (#774) |
|
||||
| 2026-10-12 | trivial | Progress log: Navigation heading truncation (#759) |
|
||||
| 2026-10-11 | trivial | Progress log: Reading Mode implementation |
|
||||
@@ -321,12 +696,14 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-12 | trivial | Progress log: AutoCorrect replacement list follows the text's language (#835) |
|
||||
| 2026-10-11 | trivial | Progress log: German interface (#574) |
|
||||
| 2026-10-12 | trivial | Progress log: language-aware sorting and table numbers (#836, #837) |
|
||||
| 2026-10-13 | trivial | Progress log: Word 365 cover pages, positions and closed freeforms (#743) |
|
||||
| 2026-10-12 | trivial | Progress log: Word 365 cover pages (#743) |
|
||||
| 2026-10-11 | trivial | Progress log: tables resize with the mouse (#217) |
|
||||
| 2026-10-11 | trivial | Progress log: Animation effects setting |
|
||||
| 2026-10-11 | trivial | Progress log: built-in file browser when the system file dialog is unavailable (#576) |
|
||||
| 2026-10-12 | trivial | Progress log: large-table scrolling (#811) |
|
||||
| 2026-10-11 | trivial | Progress log: security review fixes (temp files, exported link schemes) |
|
||||
| 2026-10-13 | trivial | Progress log: every distinct format in a paragraph keeps its own layout style (#1340) |
|
||||
| 2026-10-11 | trivial | Progress log: Find and Replace special characters and wildcards (#543) |
|
||||
| 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, collation, duplex and grayscale; the upstream preview, ranges, parity and Print Markup remain available, along with browser printing and PDF export. Remaining print parity includes driver-specific tray/options and pages-per-sheet UI; physical printer fidelity needs hardware validation. |
|
||||
| 2026-10-11 | trivial | Progress log: Windows DX12 shaders compiled with FXC (#603) |
|
||||
@@ -342,6 +719,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-12 | trivial | Progress log: selection bar in the left margin (#856) |
|
||||
| 2026-10-11 | trivial | Progress log: content controls (#381) |
|
||||
| 2026-10-11 | trivial | Progress log: Insert › Chart (#409); catalog 408/439 (92.9%) |
|
||||
| 2026-10-13 | trivial | Progress log: free part, list and comment ids after a file's largest ones (#1338) |
|
||||
| 2026-10-11 | trivial | Progress log: Line Numbers, Hyphenation, Language, Envelopes and Labels dialogs (#407) |
|
||||
| 2026-10-11 | trivial | Progress log: Alt Text pane, decorative objects, deeper accessibility checker (#386) |
|
||||
| 2026-10-11 | trivial | Progress log: Draw lasso, Add Pen, Ink to Shape, Ink Replay (#395); catalog 406/434 |
|
||||
@@ -351,6 +729,8 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-11 | trivial | Quick Access Toolbar KeyTips and persisted enable/disable setting (#620) |
|
||||
| 2026-10-11 | trivial | Progress log: mail-merge recipients from .xlsx/.xlsm/.ods (#334) |
|
||||
| 2026-10-11 | trivial | Insert › Icons landed (#309): catalog 402/434 → 403/434 (92.9%) |
|
||||
| 2026-10-13 | trivial | Progress log: Markdown backtick runs read in linear time (#1336) |
|
||||
| 2026-10-13 | trivial | Progress log: Markdown brackets no longer turn later links into text (#1452) |
|
||||
| 2026-10-11 | trivial | Enclose Characters (#297): catalog 403/434 (92.9%) |
|
||||
| 2026-10-12 | minor | Progress log: opaque custom XML store preservation, with synthetic package regressions and explicit limits |
|
||||
| 2026-10-12 | minor | Preserve imported TOC ranges and retain #538 refresh/undo/save coverage on current main; readiness unchanged |
|
||||
@@ -360,17 +740,27 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-12 | trivial | Native saves protect existing temporary-name entries and originals on pre-replacement failure; synthetic macOS evidence, explicit durability/platform limits; readiness unchanged |
|
||||
| 2026-10-11 | trivial | Word's common keyboard shortcuts (#330): 125 of ~250 |
|
||||
| 2026-10-11 | trivial | Collapsed duplicate rows left by parallel merges |
|
||||
| 2026-10-13 | trivial | Swedish interface (`sv`): progress log and shipped languages |
|
||||
| 2026-10-12 | trivial | Bulgarian interface (`bg`): progress log and shipped languages |
|
||||
| 2026-10-13 | trivial | Croatian interface (`hr`): progress log and shipped languages |
|
||||
| 2026-10-11 | trivial | Hungarian interface (`hu`): progress log and shipped languages |
|
||||
| 2026-10-11 | trivial | Turkish interface (`tr`): progress log and shipped languages |
|
||||
| 2026-10-14 | trivial | Progress log: `.doc` notes, headers and text boxes read their text once (#1319) |
|
||||
| 2026-10-12 | minor | Native drawing-level ink and its picture fallback survive DOCX edits and repeated saves; adapted to the current graphic writer; rendering and editing InkML remain missing |
|
||||
| 2026-10-12 | minor | List-label automatic/AtLeast line-height inflation corrected; gap #4 remains open and readiness unchanged |
|
||||
| 2026-10-12 | minor | Local desktop recovery: unsaved drafts and edits are snapshotted in the profile; File › Recover restores an unsaved copy or confirms discard. Full model storage includes media and opaque parts. Password sessions skip plaintext snapshots. See [recovery.md](docs/recovery.md); stability estimates remain unchanged. |
|
||||
| 2026-10-12 | trivial | Legacy table paragraph spacing preserved; modes 11/12/14 versus 15 checked with original synthetic documents |
|
||||
| 2026-10-11 | minor | Progress log: contextual paragraph spacing in body paragraphs and headers |
|
||||
| 2026-10-11 | trivial | Progress log: eight more bibliography styles (#383) |
|
||||
| 2026-10-14 | trivial | Progress log: ODT and `.doc` pictures read once, within their byte budget (#1334) |
|
||||
| 2026-10-10 | trivial | Progress log: Draw tab ink (#307) |
|
||||
| 2026-10-10 | trivial | Draw Table and Eraser (#303): catalog 393/431 (91.2%) |
|
||||
| 2026-10-10 | trivial | Progress log: charts, SmartArt and OLE objects kept on DOCX save (#319) |
|
||||
| 2026-10-10 | trivial | Progress log: contour wrap (#354) |
|
||||
| 2026-10-14 | trivial | Progress log: text boxes in deeply nested tables kept through edits (#1339) |
|
||||
| 2026-10-10 | trivial | Progress log: free rotation and flips (#332) |
|
||||
| 2026-10-10 | trivial | Progress log: Windows OpenGL fallback without a DirectX 12 driver (#316) |
|
||||
| 2026-10-14 | trivial | Progress log: Flat OPC reading bounded everywhere in the file, CDATA base64 read (#1353) |
|
||||
| 2026-10-10 | minor | Password-protected .docx (#281): files encrypted with a password (agile encryption) open and save; File › Info › Encrypt with Password |
|
||||
| 2026-10-10 | minor | Relanded clipboard pane, table styles editor, Manage Styles, Asian typography (kinsoku), Group/Ungroup and Shape Effects (#264 #263 #270 #271 #267 #280): catalog 390/431 (90.5%) |
|
||||
| 2026-10-10 | minor | Polish interface and proofing landed: Localization 25% → 27%, Proofing 35% → 40% |
|
||||
@@ -381,8 +771,8 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-10 | major | Full re-measure; restructured to the progress-docs standard (stage banner, two numbers, dimensions, languages, upcoming, progress log); parity tables moved to `docs/target-app-parity.md`, milestones to `docs/roadmap.md` |
|
||||
| 2026-10-09 | minor | Recently landed: RTL, Ukrainian and Brazilian Portuguese |
|
||||
| 2026-10-06 | major | First honest estimate: 88% catalog, ~62% real parity, alpha ~85% of the way |
|
||||
|
||||
|
||||
### Windows installer unification — release preparation, 2026-10-12
|
||||
|
||||
The shared craft-installer UI preserves this revision's install options and provides persistent installation, repair and Windows Settings removal completion screens. Silent modes stay unattended. Earlier Windows acceptance is recorded in packaging/windows/README.md; normal CI and production-profile upgrade qualification against pinned current releases are in progress. No release or PR has been published.
|
||||
| 2026-10-12 | trivial | Progress log: recipient filter and sort, .xls and legacy-encoding recipient lists (#778) |
|
||||
| 2026-10-15 | trivial | Progress log: dialog close box (#1885) |
|
||||
| 2026-10-15 | trivial | Progress log: mixed Hebrew/Latin text keeps its order in Word (#1543) |
|
||||
| 2026-10-15 | trivial | Progress log: ruler numbers by zoom, rulers follow the caret's page (#1878) |
|
||||
| 2026-10-17 | trivial | Progress log: saving into a folder that refuses a temporary file (#2138) |
|
||||
|
||||
@@ -20,3 +20,4 @@ wordcraft-render = { workspace = true }
|
||||
wordcraft-mcp = { workspace = true }
|
||||
wordcraft-zotero = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sys-locale = "0.3"
|
||||
|
||||
+716
-48
@@ -2,9 +2,12 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod automation_args;
|
||||
mod script;
|
||||
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::fmt;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::ExitCode;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
@@ -14,9 +17,10 @@ const USAGE: &str = "\
|
||||
wordcraft-cli — WordCraft from the command line
|
||||
|
||||
USAGE:
|
||||
wordcraft-cli convert <in> <out> [--markup] [--format wordml2003|flatopc]
|
||||
wordcraft-cli convert <in> <out> [--markup] [--update-fields] [--format wordml2003|flatopc]
|
||||
convert between formats (docx, doc [read], pdf, odt, rtf, html, md, tex, txt, json, png,
|
||||
xml); an .xml file is a Word XML Document (Flat OPC), or with
|
||||
xml); --update-fields refreshes unlocked fields before saving;
|
||||
an .xml file is a Word XML Document (Flat OPC), or with
|
||||
--format wordml2003 a Word 2003 XML Document;
|
||||
--markup (Print Markup): comments beside each page and
|
||||
tracked changes in pdf/png, a comment list in html/md/txt
|
||||
@@ -24,9 +28,15 @@ USAGE:
|
||||
wordcraft-cli text <file> plain text
|
||||
wordcraft-cli inspect <file> document structure (JSON)
|
||||
wordcraft-cli render <file> <out.png> [--page N] [--scale S]
|
||||
wordcraft-cli run [--file F | --template T] --cmd 'id={json}' [--cmd …] [--save OUT] [--print]
|
||||
[--author NAME] run commands headlessly, then save (--author names
|
||||
who tracked changes and comments are by)
|
||||
wordcraft-cli run [--file F | --template T] [--script FILE|-] --cmd 'id={json}' [--cmd …]
|
||||
[--save OUT] [--print] [--author NAME]
|
||||
run commands headlessly, then save (--author names
|
||||
who tracked changes and comments are by).
|
||||
--script runs a JSON file (- is stdin) first:
|
||||
{\"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
|
||||
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]
|
||||
@@ -42,16 +52,127 @@ USAGE:
|
||||
running): addEditCitation, addEditBibliography,
|
||||
addNote, refresh, removeCodes, setDocPrefs
|
||||
(--cmd runs first, e.g. --cmd caret.docEnd)
|
||||
wordcraft-cli [<command>] -h | --help this help
|
||||
wordcraft-cli --version
|
||||
|
||||
A password-protected Word file is opened with --password PASS, --password-file PATH or the
|
||||
WORDCRAFT_PASSWORD environment variable; a password given on the command line is readable by
|
||||
every other process, so the other two are better. A file opened with a password is written with
|
||||
it again, as the app does. Exports that cannot carry a password (pdf, html, txt) stay plain.
|
||||
|
||||
Paper: documents created here, and HTML, Markdown and text files (which give no page size), use
|
||||
the system region's paper (A4, or Letter in the US and other Letter regions), read from
|
||||
LC_ALL / LC_PAPER / LANG or the platform. Set WORDCRAFT_REGION=en-US (or another tag) for the
|
||||
same output on every machine.
|
||||
";
|
||||
|
||||
fn open(path: &str) -> Result<Session, String> {
|
||||
let doc = wordcraft_engine::io::open_path(std::path::Path::new(path))?;
|
||||
let mut s = Session::new(doc);
|
||||
/// The subcommands, in `USAGE` order; `--help` or `-h` after any of them prints `USAGE` (#968).
|
||||
const SUBCOMMANDS: &[&str] = &["convert", "info", "text", "inspect", "render", "run", "commands", "parity", "mcp", "zotero"];
|
||||
|
||||
fn wants_help(cmd: &str, rest: &[String]) -> bool {
|
||||
SUBCOMMANDS.contains(&cmd) && rest.iter().any(|a| a == "--help" || a == "-h")
|
||||
}
|
||||
|
||||
/// The error every subcommand gives when a required path is not there.
|
||||
fn missing_argument() -> Failure {
|
||||
format!("missing argument\n\n{USAGE}").into()
|
||||
}
|
||||
|
||||
/// Open a document for a subcommand. A password-protected Word file needs its password
|
||||
/// ([`password_from`]). As in the app, a document opened with one is saved with it again, so
|
||||
/// `convert` does not quietly write a copy the password no longer protects.
|
||||
fn open(path: &Path, password: Option<&str>) -> Result<Session, String> {
|
||||
// A file that gives no page size (HTML, Markdown, plain text) opens on the system region's
|
||||
// paper, as in the app: A4 in Italy, Letter in the US (#1381).
|
||||
let region = system_region();
|
||||
let (doc, encrypted) = wordcraft_engine::io::open_path_in(path, password, ®ion)?;
|
||||
let mut s = session(doc);
|
||||
s.path = Some(path.into());
|
||||
if encrypted {
|
||||
s.password = password.map(wordcraft_engine::io::Password::new);
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// The commands that open a file, and which of their arguments not starting with `--` it is (as
|
||||
/// `pos` counts). `run` opens the value of `--file`.
|
||||
const OPENS_FILE: &[(&str, usize)] = &[("convert", 0), ("info", 0), ("text", 0), ("inspect", 0), ("render", 0), ("zotero", 1)];
|
||||
|
||||
/// The arguments as text. Only the file the command opens may have a name that isn't valid Unicode,
|
||||
/// such as a Latin-1 one (#1440): it comes back as the path to open, and lossy among the arguments.
|
||||
/// Any other such argument is an error naming it.
|
||||
fn text_args<S: AsRef<OsStr>>(raw: &[S]) -> Result<(Vec<String>, Option<PathBuf>), String> {
|
||||
let args: Vec<String> = raw.iter().map(|a| a.as_ref().to_string_lossy().into_owned()).collect();
|
||||
let cmd = args.first().map(String::as_str).unwrap_or("");
|
||||
let file_at = match OPENS_FILE.iter().find(|(c, _)| *c == cmd) {
|
||||
Some((_, n)) => args.iter().enumerate().skip(1).filter(|(_, a)| !a.starts_with("--")).nth(*n).map(|(i, _)| i),
|
||||
None if cmd == "run" => args.iter().position(|a| a == "--file").map(|i| i + 1),
|
||||
None => None,
|
||||
};
|
||||
// `run --script FILE` may also name its script with such a file name.
|
||||
let script_at = if cmd == "run" { args.iter().position(|a| a == "--script").map(|i| i + 1) } else { None };
|
||||
let mut file = None;
|
||||
for (i, a) in raw.iter().map(AsRef::as_ref).enumerate() {
|
||||
if a.to_str().is_none() {
|
||||
if Some(i) != file_at && Some(i) != script_at {
|
||||
return Err(format!("argument {} is not valid Unicode: {}", i + 1, a.to_string_lossy()));
|
||||
}
|
||||
if Some(i) == file_at {
|
||||
file = Some(PathBuf::from(a));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((args, file))
|
||||
}
|
||||
|
||||
/// The password for a protected Word file: `--password`, `--password-file`, or
|
||||
/// `WORDCRAFT_PASSWORD`, in that order. The file form exists because a password on a command line
|
||||
/// is readable by every other process on the machine; a trailing newline is dropped so a file
|
||||
/// written by `echo` works. Never logged, and never named in an error.
|
||||
fn password_from(args: &[String]) -> Result<Option<String>, String> {
|
||||
if let Some(password) = arg_value(args, "--password") {
|
||||
return Ok(Some(password));
|
||||
}
|
||||
if let Some(path) = arg_value(args, "--password-file") {
|
||||
let raw = std::fs::read_to_string(&path).map_err(|e| format!("--password-file {path}: {e}"))?;
|
||||
return Ok(Some(raw.trim_end_matches(['\r', '\n']).to_string()));
|
||||
}
|
||||
Ok(std::env::var("WORDCRAFT_PASSWORD").ok().filter(|p| !p.is_empty()))
|
||||
}
|
||||
|
||||
/// The positional arguments, skipping every option's value so an option value is never read as a
|
||||
/// path. Mirrors [`validate_options`], which has already checked the options are known.
|
||||
fn positionals<'a>(args: &'a [String], specs: &[OptionSpec]) -> Vec<&'a String> {
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while let Some(arg) = args.get(i) {
|
||||
let takes_value =
|
||||
arg.starts_with("--") && specs.iter().any(|s| s.name == arg && s.takes_value) && args.get(i + 1).is_some_and(|v| !v.starts_with("--"));
|
||||
if takes_value {
|
||||
i += 2;
|
||||
} else {
|
||||
if !arg.starts_with("--") {
|
||||
out.push(arg);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A headless session in the system region: every way the CLI starts one gives `file.new` the
|
||||
/// same paper (#1381).
|
||||
fn session(doc: wordcraft_doc::Document) -> Session {
|
||||
let mut s = Session::new(doc);
|
||||
s.system_region = system_region();
|
||||
s
|
||||
}
|
||||
|
||||
/// The system region, read as the app reads it (locale variables, else the platform's locale).
|
||||
fn system_region() -> wordcraft_engine::Region {
|
||||
wordcraft_engine::Region::from_locale_vars(|name| std::env::var(name).ok(), sys_locale::get_locale)
|
||||
}
|
||||
|
||||
/// The body text for `text`: the plain-text export, so list items keep their bullet or number label.
|
||||
fn listed_text(doc: &wordcraft_doc::Document) -> Result<String, String> {
|
||||
let bytes = wordcraft_engine::io::save_bytes("out.txt", doc)?;
|
||||
@@ -67,6 +188,58 @@ fn arg_value(args: &[String], name: &str) -> Option<String> {
|
||||
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).cloned()
|
||||
}
|
||||
|
||||
enum OptionArg<'a> {
|
||||
Absent,
|
||||
Duplicate,
|
||||
MissingValue,
|
||||
Value(&'a str),
|
||||
}
|
||||
|
||||
fn find_option_arg<'a>(args: &'a [String], name: &str) -> OptionArg<'a> {
|
||||
let mut matches = args.iter().enumerate().filter(|(_, a)| *a == name);
|
||||
let Some((i, _)) = matches.next() else {
|
||||
return OptionArg::Absent;
|
||||
};
|
||||
if matches.next().is_some() {
|
||||
return OptionArg::Duplicate;
|
||||
}
|
||||
match args.get(i + 1) {
|
||||
None => OptionArg::MissingValue,
|
||||
Some(val) if val.starts_with("--") => OptionArg::MissingValue,
|
||||
Some(val) => OptionArg::Value(val.as_str()),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_page_arg(command: &str, args: &[String]) -> Result<Option<u64>, String> {
|
||||
match find_option_arg(args, "--page") {
|
||||
OptionArg::Absent => Ok(None),
|
||||
OptionArg::Duplicate => Err(format!("{command}: duplicate --page option")),
|
||||
OptionArg::MissingValue => Err(format!("{command}: --page requires a value")),
|
||||
OptionArg::Value(val) => {
|
||||
let n = val.parse::<u64>().map_err(|_| format!("{command}: invalid --page `{val}`: not a valid integer"))?;
|
||||
if n == 0 {
|
||||
return Err(format!("{command}: invalid --page `0`: page numbers start at 1"));
|
||||
}
|
||||
Ok(Some(n))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_scale_arg(command: &str, args: &[String]) -> Result<Option<f64>, String> {
|
||||
match find_option_arg(args, "--scale") {
|
||||
OptionArg::Absent => Ok(None),
|
||||
OptionArg::Duplicate => Err(format!("{command}: duplicate --scale option")),
|
||||
OptionArg::MissingValue => Err(format!("{command}: --scale requires a value")),
|
||||
OptionArg::Value(val) => {
|
||||
let scale = val.parse::<f64>().map_err(|_| format!("{command}: invalid --scale `{val}`: not a number"))?;
|
||||
if !scale.is_finite() || scale <= 0.0 {
|
||||
return Err(format!("{command}: invalid --scale `{val}`: scale must be a positive number"));
|
||||
}
|
||||
Ok(Some(scale))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct OptionSpec {
|
||||
name: &'static str,
|
||||
@@ -112,13 +285,30 @@ fn automation_roots(
|
||||
Ok((roots, left))
|
||||
}
|
||||
|
||||
const NO_OPTIONS: &[OptionSpec] = &[];
|
||||
const CONVERT_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--markup", takes_value: false }, OptionSpec { name: "--format", takes_value: true }];
|
||||
const RENDER_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--page", takes_value: true }, OptionSpec { name: "--scale", takes_value: true }];
|
||||
/// The password a protected Word file is opened with: `--password` (which any other process on
|
||||
/// the machine can read from the command line), `--password-file`, or `WORDCRAFT_PASSWORD`.
|
||||
const PASSWORD_OPTIONS: &[OptionSpec] =
|
||||
&[OptionSpec { name: "--password", takes_value: true }, OptionSpec { name: "--password-file", takes_value: true }];
|
||||
const CONVERT_OPTIONS: &[OptionSpec] = &[
|
||||
OptionSpec { name: "--markup", takes_value: false },
|
||||
OptionSpec { name: "--format", takes_value: true },
|
||||
OptionSpec { name: "--update-fields", takes_value: false },
|
||||
OptionSpec { name: "--password", takes_value: true },
|
||||
OptionSpec { name: "--password-file", takes_value: true },
|
||||
];
|
||||
const RENDER_OPTIONS: &[OptionSpec] = &[
|
||||
OptionSpec { name: "--page", takes_value: true },
|
||||
OptionSpec { name: "--scale", takes_value: true },
|
||||
OptionSpec { name: "--password", takes_value: true },
|
||||
OptionSpec { name: "--password-file", takes_value: true },
|
||||
];
|
||||
const RUN_OPTIONS: &[OptionSpec] = &[
|
||||
OptionSpec { name: "--file", takes_value: true },
|
||||
OptionSpec { name: "--password", takes_value: true },
|
||||
OptionSpec { name: "--password-file", takes_value: true },
|
||||
OptionSpec { name: "--template", takes_value: true },
|
||||
OptionSpec { name: "--cmd", takes_value: true },
|
||||
OptionSpec { name: "--script", takes_value: true },
|
||||
OptionSpec { name: "--save", takes_value: true },
|
||||
OptionSpec { name: "--page", takes_value: true },
|
||||
OptionSpec { name: "--scale", takes_value: true },
|
||||
@@ -181,25 +371,37 @@ fn write_stdout(write: impl FnOnce(&mut std::io::StdoutLock<'static>) -> std::io
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run(args: &[String]) -> Result<(), Failure> {
|
||||
/// The operand after `--script`, by its real name (it may not be valid Unicode).
|
||||
fn script_operand<S: AsRef<OsStr>>(raw: &[S]) -> Option<PathBuf> {
|
||||
let at = raw.iter().position(|a| a.as_ref() == OsStr::new("--script"))?;
|
||||
raw.get(at + 1).map(|a| PathBuf::from(a.as_ref()))
|
||||
}
|
||||
|
||||
fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
||||
let script_path = script_operand(args);
|
||||
let (args, file) = text_args(args)?;
|
||||
// Opens the file by its real name, which its text in `args` may have lost, and with the
|
||||
// password of a protected document (`password_from`).
|
||||
let open = |text: &str, password: Option<&str>| open(file.as_deref().unwrap_or(Path::new(text)), password);
|
||||
let cmd = args.first().map(String::as_str).unwrap_or("");
|
||||
let rest: Vec<String> = args.iter().skip(1).cloned().collect();
|
||||
let pos = |i: usize| rest.iter().filter(|a| !a.starts_with("--")).nth(i).cloned().ok_or_else(|| format!("missing argument\n\n{USAGE}"));
|
||||
// Before options are checked or files and folders opened.
|
||||
if wants_help(cmd, &rest) {
|
||||
return write_stdout(|o| write!(o, "{USAGE}"));
|
||||
}
|
||||
match cmd {
|
||||
"convert" => {
|
||||
validate_options("convert", &rest, CONVERT_OPTIONS)?;
|
||||
// `--format wordml2003` (or `flatopc`) picks the kind of `.xml` file; its value isn't a path.
|
||||
let format = arg_value(&rest, "--format");
|
||||
let paths: Vec<&String> = rest
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(n, a)| !a.starts_with("--") && !(*n > 0 && rest.get(n - 1).is_some_and(|p| p == "--format")))
|
||||
.map(|(_, a)| a)
|
||||
.collect();
|
||||
let missing = || format!("missing argument\n\n{USAGE}");
|
||||
let (i, o) = (paths.first().ok_or_else(missing)?.to_string(), paths.get(1).ok_or_else(missing)?.to_string());
|
||||
let paths = positionals(&rest, CONVERT_OPTIONS);
|
||||
let (i, o) = (paths.first().ok_or_else(missing_argument)?.to_string(), paths.get(1).ok_or_else(missing_argument)?.to_string());
|
||||
let markup = rest.iter().any(|a| a == "--markup");
|
||||
let mut s = open(&i)?;
|
||||
let password = password_from(&rest)?;
|
||||
let mut s = open(&i, password.as_deref())?;
|
||||
if rest.iter().any(|a| a == "--update-fields") {
|
||||
s.run("references.updateFields", &json!({})).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let r = if o.to_ascii_lowercase().ends_with(".png") {
|
||||
s.run("file.exportPng", &json!({"path": o, "page": 1, "markup": markup}))
|
||||
} else {
|
||||
@@ -214,46 +416,78 @@ fn run(args: &[String]) -> Result<(), Failure> {
|
||||
Ok(())
|
||||
}
|
||||
"info" => {
|
||||
validate_options("info", &rest, NO_OPTIONS)?;
|
||||
let mut s = open(&pos(0)?)?;
|
||||
validate_options("info", &rest, PASSWORD_OPTIONS)?;
|
||||
let paths = positionals(&rest, PASSWORD_OPTIONS);
|
||||
let password = password_from(&rest)?;
|
||||
let mut s = open(paths.first().ok_or_else(missing_argument)?, password.as_deref())?;
|
||||
let info = serde_json::to_string_pretty(&s.run("file.info", &json!({})).map_err(|e| e.to_string())?).unwrap_or_default();
|
||||
write_stdout(|o| writeln!(o, "{info}"))
|
||||
}
|
||||
"text" => {
|
||||
validate_options("text", &rest, NO_OPTIONS)?;
|
||||
let s = open(&pos(0)?)?;
|
||||
validate_options("text", &rest, PASSWORD_OPTIONS)?;
|
||||
let paths = positionals(&rest, PASSWORD_OPTIONS);
|
||||
let password = password_from(&rest)?;
|
||||
let s = open(paths.first().ok_or_else(missing_argument)?, password.as_deref())?;
|
||||
let text = listed_text(&s.doc)?;
|
||||
write_stdout(|o| writeln!(o, "{text}"))
|
||||
}
|
||||
"inspect" => {
|
||||
validate_options("inspect", &rest, NO_OPTIONS)?;
|
||||
let mut s = open(&pos(0)?)?;
|
||||
validate_options("inspect", &rest, PASSWORD_OPTIONS)?;
|
||||
let paths = positionals(&rest, PASSWORD_OPTIONS);
|
||||
let password = password_from(&rest)?;
|
||||
let mut s = open(paths.first().ok_or_else(missing_argument)?, password.as_deref())?;
|
||||
let tree = serde_json::to_string_pretty(&s.run("document.inspect", &json!({})).map_err(|e| e.to_string())?).unwrap_or_default();
|
||||
write_stdout(|o| writeln!(o, "{tree}"))
|
||||
}
|
||||
"render" => {
|
||||
validate_options("render", &rest, RENDER_OPTIONS)?;
|
||||
let (i, o) = (pos(0)?, pos(1)?);
|
||||
let mut s = open(&i)?;
|
||||
let page = arg_value(&rest, "--page").and_then(|p| p.parse::<u64>().ok()).unwrap_or(1);
|
||||
let scale = arg_value(&rest, "--scale").and_then(|p| p.parse::<f64>().ok()).unwrap_or(2.0);
|
||||
let paths = positionals(&rest, RENDER_OPTIONS);
|
||||
let password = password_from(&rest)?;
|
||||
let (Some(i), Some(o)) = (paths.first(), paths.get(1)) else {
|
||||
return Err(missing_argument());
|
||||
};
|
||||
let (i, o) = (i.to_string(), o.to_string());
|
||||
let page = parse_page_arg("render", &rest)?.unwrap_or(1);
|
||||
let scale = parse_scale_arg("render", &rest)?.unwrap_or(2.0);
|
||||
let mut s = open(&i, password.as_deref())?;
|
||||
let r = s.run("file.exportPng", &json!({"path": o, "page": page, "scale": scale})).map_err(|e| e.to_string())?;
|
||||
write_stdout(|o| writeln!(o, "{r}"))
|
||||
}
|
||||
"run" => {
|
||||
validate_options("run", &rest, RUN_OPTIONS)?;
|
||||
let mut s = match (arg_value(&rest, "--file"), arg_value(&rest, "--template")) {
|
||||
(Some(f), _) => open(&f)?,
|
||||
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 mut s = match (from_file, from_template) {
|
||||
(Some(f), _) => open(&f, password.as_deref())?,
|
||||
(None, Some(t)) => {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
let mut s = session(wordcraft_doc::Document::new());
|
||||
s.run("file.new", &json!({"template": t})).map_err(|e| e.to_string())?;
|
||||
s
|
||||
}
|
||||
_ => Session::new(wordcraft_doc::Document::new()),
|
||||
_ => session(wordcraft_doc::Document::new()),
|
||||
};
|
||||
if let Some(name) = arg_value(&rest, "--author") {
|
||||
set_author(&mut s, &name)?;
|
||||
}
|
||||
let print = rest.iter().any(|a| a == "--print");
|
||||
for (n, (id, params)) in script.iter().flat_map(|c| c.commands.iter()).enumerate() {
|
||||
let r = s.run(id, params).map_err(|e| format!("command {} ({id}): {e}", n + 1))?;
|
||||
if print {
|
||||
write_stdout(|o| writeln!(o, "{id}: {r}"))?;
|
||||
}
|
||||
}
|
||||
let mut i = 0;
|
||||
while i < rest.len() {
|
||||
if rest.get(i).map(String::as_str) == Some("--cmd") {
|
||||
@@ -263,7 +497,7 @@ fn run(args: &[String]) -> Result<(), Failure> {
|
||||
None => (spec.clone(), json!({})),
|
||||
};
|
||||
let r = s.run(&id, ¶ms).map_err(|e| format!("{id}: {e}"))?;
|
||||
if rest.iter().any(|a| a == "--print") {
|
||||
if print {
|
||||
write_stdout(|o| writeln!(o, "{id}: {r}"))?;
|
||||
}
|
||||
i += 2;
|
||||
@@ -271,13 +505,27 @@ fn run(args: &[String]) -> Result<(), Failure> {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
if let Some(out) = arg_value(&rest, "--save") {
|
||||
let outputs: Vec<String> = script.iter().flat_map(|c| c.save.iter().cloned()).chain(arg_value(&rest, "--save")).collect();
|
||||
// Check every output can be made before writing any, so a bad one leaves no half-done job.
|
||||
for out in &outputs {
|
||||
let parent = Path::new(out).parent().filter(|p| !p.as_os_str().is_empty());
|
||||
if let Some(p) = parent.filter(|p| !p.is_dir()) {
|
||||
return Err(format!("{out}: folder {} does not exist", p.display()).into());
|
||||
}
|
||||
if Path::new(out).is_dir() {
|
||||
return Err(format!("{out}: is a folder, not a file").into());
|
||||
}
|
||||
if !out.to_ascii_lowercase().ends_with(".png") {
|
||||
wordcraft_engine::io::save_bytes(out, &s.doc)?;
|
||||
}
|
||||
}
|
||||
for out in outputs {
|
||||
let r = if out.to_ascii_lowercase().ends_with(".png") {
|
||||
let mut v = json!({"path": out});
|
||||
if let Some(n) = arg_value(&rest, "--page").and_then(|p| p.parse::<u64>().ok()) {
|
||||
if let Some(n) = page {
|
||||
v["page"] = json!(n);
|
||||
}
|
||||
if let Some(k) = arg_value(&rest, "--scale").and_then(|p| p.parse::<f64>().ok()) {
|
||||
if let Some(k) = scale {
|
||||
v["scale"] = json!(k);
|
||||
}
|
||||
s.run("file.exportPng", &v)
|
||||
@@ -291,7 +539,7 @@ fn run(args: &[String]) -> Result<(), Failure> {
|
||||
}
|
||||
"commands" => {
|
||||
validate_options("commands", &rest, COMMANDS_OPTIONS)?;
|
||||
let s = Session::new(wordcraft_doc::Document::new());
|
||||
let s = session(wordcraft_doc::Document::new());
|
||||
if rest.iter().any(|a| a == "--json") {
|
||||
let list = serde_json::to_string_pretty(&s.registry.describe()).unwrap_or_default();
|
||||
write_stdout(|o| writeln!(o, "{list}"))
|
||||
@@ -306,7 +554,7 @@ fn run(args: &[String]) -> Result<(), Failure> {
|
||||
}
|
||||
"parity" => {
|
||||
validate_options("parity", &rest, PARITY_OPTIONS)?;
|
||||
let s = Session::new(wordcraft_doc::Document::new());
|
||||
let s = session(wordcraft_doc::Document::new());
|
||||
let p = wordcraft_engine::catalog::parity(&s.registry);
|
||||
if rest.iter().any(|a| a == "--markdown") {
|
||||
write_stdout(|o| write!(o, "{}", parity_markdown(&p)))
|
||||
@@ -359,10 +607,12 @@ fn run(args: &[String]) -> Result<(), Failure> {
|
||||
}
|
||||
"zotero" => {
|
||||
validate_options("zotero", &rest, ZOTERO_OPTIONS)?;
|
||||
let name = pos(0)?;
|
||||
let command = wordcraft_zotero::Command::from_name(&name).ok_or_else(|| format!("unknown Zotero command `{name}`\n\n{USAGE}"))?;
|
||||
let file = pos(1)?;
|
||||
let mut s = open(&file)?;
|
||||
let paths = positionals(&rest, ZOTERO_OPTIONS);
|
||||
let (Some(name), Some(file)) = (paths.first(), paths.get(1)) else {
|
||||
return Err(missing_argument());
|
||||
};
|
||||
let command = wordcraft_zotero::Command::from_name(name).ok_or_else(|| format!("unknown Zotero command `{name}`\n\n{USAGE}"))?;
|
||||
let mut s = open(file, None)?;
|
||||
for spec in rest.iter().zip(rest.iter().skip(1)).filter(|(a, _)| *a == "--cmd").map(|(_, v)| v) {
|
||||
let (id, params) = match spec.split_once('=') {
|
||||
Some((id, p)) => (id.to_string(), serde_json::from_str::<Value>(p).map_err(|e| format!("{id}: bad JSON params: {e}"))?),
|
||||
@@ -437,7 +687,7 @@ fn parity_markdown(p: &Value) -> String {
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let args: Vec<OsString> = std::env::args_os().skip(1).collect();
|
||||
match run(&args) {
|
||||
Ok(()) | Err(Failure::StdoutClosed) => ExitCode::SUCCESS,
|
||||
Err(Failure::Error(e)) => {
|
||||
@@ -450,6 +700,7 @@ 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()
|
||||
@@ -457,7 +708,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn validators_accept_only_the_documented_options() {
|
||||
assert!(validate_options("info", &args(&["file.docx"]), NO_OPTIONS).is_ok());
|
||||
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());
|
||||
@@ -474,6 +725,41 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[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()));
|
||||
@@ -481,6 +767,26 @@ mod tests {
|
||||
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();
|
||||
@@ -547,10 +853,372 @@ mod tests {
|
||||
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/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,112 @@
|
||||
//! `run --script FILE`: commands read from a JSON file (`-` is stdin), so a job can be kept in a
|
||||
//! file instead of a long chain of `--cmd` flags (#1963).
|
||||
//!
|
||||
//! ```json
|
||||
//! {"file": "in.docx", "template": "sample",
|
||||
//! "commands": [{"command": "text.insert", "params": {"text": "Hi"}}, "caret.docEnd"],
|
||||
//! "save": ["out.docx", "out.pdf"]}
|
||||
//! ```
|
||||
//!
|
||||
//! `file` and `template` are optional (`--file` / `--template` on the command line win), and a
|
||||
//! command is an object with `command` and optional `params`, or just the id as a string. Relative
|
||||
//! `file` and `save` paths resolve against the script's folder (the working folder for stdin).
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
/// The largest script read. A job is a list of commands; anything bigger is a mistake.
|
||||
pub const MAX_SCRIPT_BYTES: u64 = 4 * 1024 * 1024;
|
||||
/// The most commands a script may hold.
|
||||
const MAX_COMMANDS: usize = 100_000;
|
||||
/// The most outputs a script may save to.
|
||||
const MAX_SAVES: usize = 64;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Script {
|
||||
pub file: Option<String>,
|
||||
pub template: Option<String>,
|
||||
pub commands: Vec<(String, Value)>,
|
||||
pub save: Vec<String>,
|
||||
}
|
||||
|
||||
/// Read the script at `arg` (`-` for stdin) and parse it.
|
||||
pub fn load(arg: &Path) -> Result<Script, String> {
|
||||
let name = arg.display().to_string();
|
||||
if arg == Path::new("-") {
|
||||
return load_from(std::io::stdin().lock(), &name, None);
|
||||
}
|
||||
let f = std::fs::File::open(arg).map_err(|e| format!("--script {name}: {e}"))?;
|
||||
load_from(f, &name, arg.parent().filter(|p| !p.as_os_str().is_empty()))
|
||||
}
|
||||
|
||||
/// Read and parse a script from `r`, called `name` in errors; relative paths resolve against `base`.
|
||||
pub fn load_from(r: impl Read, name: &str, base: Option<&Path>) -> Result<Script, String> {
|
||||
let text = read_capped(r, name)?;
|
||||
parse(&text, base).map_err(|e| format!("--script {name}: {e}"))
|
||||
}
|
||||
|
||||
fn read_capped(r: impl Read, name: &str) -> Result<String, String> {
|
||||
let mut bytes = Vec::new();
|
||||
r.take(MAX_SCRIPT_BYTES + 1).read_to_end(&mut bytes).map_err(|e| format!("--script {name}: {e}"))?;
|
||||
if bytes.len() as u64 > MAX_SCRIPT_BYTES {
|
||||
return Err(format!("--script {name}: script too large (over {} MiB)", MAX_SCRIPT_BYTES / 1024 / 1024));
|
||||
}
|
||||
String::from_utf8(bytes).map_err(|_| format!("--script {name}: not valid UTF-8"))
|
||||
}
|
||||
|
||||
fn resolve(path: &str, base: Option<&Path>) -> String {
|
||||
match base {
|
||||
Some(b) if Path::new(path).is_relative() => b.join(path).to_string_lossy().into_owned(),
|
||||
_ => path.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn opt_string(obj: &serde_json::Map<String, Value>, key: &str) -> Result<Option<String>, String> {
|
||||
match obj.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(s)) => Ok(Some(s.clone())),
|
||||
Some(_) => Err(format!("`{key}` must be a string")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(text: &str, base: Option<&Path>) -> Result<Script, String> {
|
||||
let v: Value = serde_json::from_str(text).map_err(|e| format!("bad JSON: {e}"))?;
|
||||
let obj = v.as_object().ok_or("a script is a JSON object with a `commands` list")?;
|
||||
let list = match obj.get("commands") {
|
||||
Some(Value::Array(a)) => a,
|
||||
_ => return Err("`commands` must be a list".into()),
|
||||
};
|
||||
if list.is_empty() {
|
||||
return Err("`commands` is empty".into());
|
||||
}
|
||||
if list.len() > MAX_COMMANDS {
|
||||
return Err(format!("too many commands (over {MAX_COMMANDS})"));
|
||||
}
|
||||
let mut commands = Vec::with_capacity(list.len());
|
||||
for (i, c) in list.iter().enumerate() {
|
||||
let n = i + 1;
|
||||
let entry = match c {
|
||||
Value::String(id) => Some((id.clone(), Value::Object(Default::default()))),
|
||||
Value::Object(o) => match o.get("command") {
|
||||
Some(Value::String(id)) => Some((id.clone(), o.get("params").cloned().unwrap_or_else(|| Value::Object(Default::default())))),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
};
|
||||
match entry {
|
||||
Some((id, _)) if id.is_empty() => return Err(format!("command {n}: empty command id")),
|
||||
Some(e) => commands.push(e),
|
||||
None => return Err(format!("command {n}: expected a command id or {{\"command\": id, \"params\": {{…}}}}")),
|
||||
}
|
||||
}
|
||||
let save = match obj.get("save") {
|
||||
None | Some(Value::Null) => Vec::new(),
|
||||
Some(Value::String(s)) => vec![resolve(s, base)],
|
||||
Some(Value::Array(a)) if a.len() <= MAX_SAVES => {
|
||||
a.iter().map(|s| s.as_str().map(|s| resolve(s, base)).ok_or("`save` entries must be strings")).collect::<Result<_, _>>()?
|
||||
}
|
||||
Some(_) => return Err(format!("`save` must be a path or a list of at most {MAX_SAVES} paths")),
|
||||
};
|
||||
Ok(Script { file: opt_string(obj, "file")?.map(|f| resolve(&f, base)), template: opt_string(obj, "template")?, commands, save })
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
//! Explicit field refresh for headless conversions; ordinary conversion keeps cached results.
|
||||
use std::process::Command;
|
||||
use wordcraft_doc::para::InlineObject;
|
||||
use wordcraft_doc::{Document, Path, Pos, StoryRef};
|
||||
|
||||
#[test]
|
||||
fn convert_refresh_is_opt_in() {
|
||||
let dir = std::env::temp_dir().join(format!("wc-convert-fields-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut doc = Document::from_text("Contents\nTarget");
|
||||
doc.para_mut(StoryRef::Body, &Path::top(1)).unwrap().props.page_break_before = Some(true);
|
||||
doc.insert_object(&Pos::body(1, 0), InlineObject::BookmarkStart { name: "target".into() }, &Default::default()).unwrap();
|
||||
for locked in [true, false] {
|
||||
doc.insert_object(
|
||||
&Pos::body(0, 0),
|
||||
InlineObject::Field { instr: "PAGEREF target".into(), result: "104".into(), locked, dirty: true, code: false, form: String::new() },
|
||||
&Default::default(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let input = dir.join("in.json");
|
||||
std::fs::write(&input, serde_json::to_vec(&doc).unwrap()).unwrap();
|
||||
let docx = dir.join("in.docx");
|
||||
let make = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).arg("convert").arg(&input).arg(&docx).output().unwrap();
|
||||
assert!(make.status.success(), "{}", String::from_utf8_lossy(&make.stderr));
|
||||
for refresh in [false, true] {
|
||||
let output = dir.join(if refresh { "refreshed.json" } else { "cached.json" });
|
||||
let mut cmd = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli"));
|
||||
cmd.arg("convert").arg(&docx).arg(&output);
|
||||
if refresh {
|
||||
cmd.arg("--update-fields");
|
||||
}
|
||||
let run = cmd.output().unwrap();
|
||||
assert!(run.status.success(), "{}", String::from_utf8_lossy(&run.stderr));
|
||||
let out: Document = serde_json::from_slice(&std::fs::read(output).unwrap()).unwrap();
|
||||
let results: Vec<_> = out
|
||||
.para(StoryRef::Body, &Path::top(0))
|
||||
.unwrap()
|
||||
.objects
|
||||
.iter()
|
||||
.filter_map(|o| match o {
|
||||
InlineObject::Field { result, .. } => Some(result.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(results, if refresh { vec!["2", "104"] } else { vec!["104", "104"] });
|
||||
}
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//! `wordcraft-cli convert page.html out.docx` puts an HTML file, which gives no page size, on the
|
||||
//! system region's paper, as the app does: A4 in Italy, Letter in the US (#1381).
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
const HTML: &str = "<html><body><h2>Premessa</h2><table border=1><thead><tr><th>Indicatore</th><th>Valore</th></tr></thead>\
|
||||
<tbody><tr><td>Audit</td><td style=\"text-align:right;\">4</td></tr></tbody></table></body></html>";
|
||||
|
||||
/// Run the CLI in `region` with `args` (`{src}` is the sample HTML, `{out}` a JSON file it
|
||||
/// writes) and read back the page size of the JSON it saved.
|
||||
fn page_size_of(region: &str, args: &[&str]) -> (f64, f64) {
|
||||
let dir = std::env::temp_dir().join(format!("wordcraft-cli-region-{region}-{}-{}", args.len(), std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (src, out) = (dir.join("page.html"), dir.join("out.json"));
|
||||
std::fs::write(&src, HTML).unwrap();
|
||||
let args: Vec<String> = args.iter().map(|a| a.replace("{src}", &src.to_string_lossy()).replace("{out}", &out.to_string_lossy())).collect();
|
||||
let run = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).args(&args).env("WORDCRAFT_REGION", region).env_remove("LC_ALL").output().unwrap();
|
||||
assert!(run.status.success(), "{args:?}: {}", String::from_utf8_lossy(&run.stderr));
|
||||
let doc: serde_json::Value = serde_json::from_slice(&std::fs::read(&out).unwrap()).unwrap();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let sp = &doc["lastSection"];
|
||||
(sp["pageW"].as_f64().unwrap(), sp["pageH"].as_f64().unwrap())
|
||||
}
|
||||
|
||||
/// Convert the sample to JSON in `region` and read back the page size.
|
||||
fn page_size(region: &str) -> (f64, f64) {
|
||||
page_size_of(region, &["convert", "{src}", "{out}"])
|
||||
}
|
||||
|
||||
fn is_a4((w, h): (f64, f64)) -> bool {
|
||||
(w - 595.28).abs() < 0.01 && (h - 841.89).abs() < 0.01
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_converts_onto_the_region_paper() {
|
||||
let (w, h) = page_size("it-IT");
|
||||
assert!(is_a4((w, h)), "A4 in Italy: {w} x {h}");
|
||||
assert_eq!(page_size("en-US"), (612.0, 792.0), "Letter in the US");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_run_session_follows_the_region() {
|
||||
// `run --file`, `run --template` and a bare `run` all start a session in the system region,
|
||||
// so `file.new` gives the same paper whichever way the session began.
|
||||
for args in [
|
||||
&["run", "--cmd", "file.new", "--save", "{out}"][..],
|
||||
&["run", "--template", "blank", "--save", "{out}"][..],
|
||||
&["run", "--file", "{src}", "--cmd", "file.new", "--save", "{out}"][..],
|
||||
] {
|
||||
assert!(is_a4(page_size_of("it-IT", args)), "A4 in Italy: {args:?} gave {:?}", page_size_of("it-IT", args));
|
||||
assert_eq!(page_size_of("en-US", args), (612.0, 792.0), "Letter in the US: {args:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
//! `--help` or `-h` after any subcommand prints the usage and exits 0, before options are checked
|
||||
//! or files and folders opened (#968).
|
||||
|
||||
use std::process::{Command, Output};
|
||||
|
||||
const SUBCOMMANDS: [&str; 10] = ["convert", "info", "text", "inspect", "render", "run", "commands", "parity", "mcp", "zotero"];
|
||||
|
||||
fn cli(args: &[&str]) -> Output {
|
||||
// `output()` gives the child no stdin, so `mcp` can't wait for a client.
|
||||
Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).args(args).output().unwrap()
|
||||
}
|
||||
|
||||
fn assert_prints_usage(args: &[&str]) {
|
||||
let out = cli(args);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(out.status.success(), "{args:?}: status {:?}, stderr: {stderr}", out.status);
|
||||
assert!(String::from_utf8_lossy(&out.stdout).starts_with("wordcraft-cli — "), "{args:?}: stdout: {}", String::from_utf8_lossy(&out.stdout));
|
||||
assert!(stderr.is_empty(), "{args:?}: stderr: {stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_subcommand_prints_usage_for_help_and_h() {
|
||||
assert_prints_usage(&["--help"]);
|
||||
assert_prints_usage(&["-h"]);
|
||||
for sub in SUBCOMMANDS {
|
||||
for flag in ["--help", "-h"] {
|
||||
assert_prints_usage(&[sub, flag]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_comes_before_opening_files_and_folders() {
|
||||
assert_prints_usage(&["info", "missing.docx", "--help"]);
|
||||
assert_prints_usage(&["convert", "missing.docx", "out.pdf", "-h"]);
|
||||
assert_prints_usage(&["mcp", "--automation-read-root", "/definitely/not/a/folder", "--help"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn misspelled_options_and_unknown_commands_still_fail() {
|
||||
let out = cli(&["info", "--hepl"]);
|
||||
assert!(!out.status.success());
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("info: unknown option --hepl"));
|
||||
let out = cli(&["frobnicate", "--help"]);
|
||||
assert!(!out.status.success());
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("unknown command `frobnicate`"));
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//! A file name that isn't valid Unicode (`caf\xe9.txt`, Latin-1) never panics the CLI (#1440): the
|
||||
//! file a command opens opens, and any other such argument is an error naming it. Not on macOS:
|
||||
//! APFS refuses file names that aren't valid UTF-8, so the fixture can't be made there.
|
||||
#![cfg(all(unix, not(target_os = "macos")))]
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Output};
|
||||
|
||||
fn cli(args: &[OsString]) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).args(args).output().unwrap()
|
||||
}
|
||||
|
||||
/// A temp folder holding `caf\xe9.txt`, whose text is "Bonjour".
|
||||
fn folder(name: &str) -> (PathBuf, OsString) {
|
||||
let dir = std::env::temp_dir().join(format!("wc-cli-non-utf8-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join(OsString::from_vec(b"caf\xe9.txt".to_vec()));
|
||||
std::fs::write(&file, "Bonjour\n").unwrap();
|
||||
(dir, file.into_os_string())
|
||||
}
|
||||
|
||||
fn ok(out: &Output) -> String {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(out.status.success(), "status {:?}, stderr: {stderr}", out.status);
|
||||
String::from_utf8_lossy(&out.stdout).into_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_a_command_opens_may_have_any_name() {
|
||||
let (dir, file) = folder("open");
|
||||
assert_eq!(ok(&cli(&["text".into(), file.clone()])).trim(), "Bonjour");
|
||||
assert!(ok(&cli(&["info".into(), file.clone()])).contains("\"words\""));
|
||||
assert!(ok(&cli(&["inspect".into(), file.clone()])).contains("Bonjour"));
|
||||
|
||||
let md = dir.join("out.md");
|
||||
ok(&cli(&["convert".into(), file.clone(), md.clone().into()]));
|
||||
assert!(std::fs::read_to_string(&md).unwrap().contains("Bonjour"));
|
||||
|
||||
let saved = dir.join("run.txt");
|
||||
ok(&cli(&["run".into(), "--file".into(), file.clone(), "--save".into(), saved.clone().into()]));
|
||||
assert!(std::fs::read_to_string(&saved).unwrap().contains("Bonjour"));
|
||||
|
||||
// Saving without a path writes back to the file under its real name, not a lossy one.
|
||||
let mut args: Vec<OsString> = vec!["run".into(), "--file".into(), file.clone()];
|
||||
for cmd in ["caret.docEnd", "text.insert={\"text\":\" encore\"}", "file.save"] {
|
||||
args.extend(["--cmd".into(), cmd.into()]);
|
||||
}
|
||||
ok(&cli(&args));
|
||||
assert!(std::fs::read_to_string(&file).unwrap().contains("encore"));
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 3, "a second file was written");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_other_such_argument_is_an_error_naming_it() {
|
||||
let (dir, file) = folder("error");
|
||||
let out_name = dir.join(OsString::from_vec(b"caf\xe9.md".to_vec())).into_os_string();
|
||||
let cmd = OsString::from_vec(b"text.insert={\"text\":\"\xe9\"}".to_vec());
|
||||
for (args, n) in [
|
||||
(vec!["convert".into(), file.clone(), out_name.clone()], 3),
|
||||
(vec!["run".into(), "--file".into(), file.clone(), "--cmd".into(), cmd], 5),
|
||||
(vec![OsString::from_vec(b"t\xe9xt".to_vec()), file.clone()], 1),
|
||||
] {
|
||||
let out = cli(&args);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let shown = args[n - 1].to_string_lossy();
|
||||
assert!(shown.contains('\u{FFFD}'));
|
||||
assert_eq!(out.status.code(), Some(1), "{args:?}: stderr: {stderr}");
|
||||
assert_eq!(stderr, format!("wordcraft-cli: argument {n} is not valid Unicode: {shown}\n"), "{args:?}");
|
||||
}
|
||||
assert!(!std::path::Path::new(&out_name).exists());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//! `wordcraft-cli run`: a picture inserted by `--cmd insert.picture` stays in the saved file when
|
||||
//! the next commands type (#965).
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
/// A 1x1 PNG.
|
||||
const PNG: &[u8] = &[
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf8, 0xcf, 0xc0, 0xf0,
|
||||
0x1f, 0x00, 0x05, 0x00, 0x01, 0xff, 0x89, 0x99, 0x3d, 0x1d, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn typing_after_insert_picture_keeps_it_in_the_saved_file() {
|
||||
let dir = std::env::temp_dir().join(format!("wordcraft-cli-965-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (pic, out) = (dir.join("pic.png"), dir.join("p.docx"));
|
||||
std::fs::write(&pic, PNG).unwrap();
|
||||
let picture = format!(r#"insert.picture={{"path":{},"width":250}}"#, serde_json::json!(pic.to_string_lossy()));
|
||||
let cmds = [r#"text.insert={"text":"x"}"#, "text.newParagraph", &picture, "text.newParagraph", r#"text.insert={"text":"cap"}"#];
|
||||
let mut cli = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli"));
|
||||
cli.args(["run", "--template", "blank"]);
|
||||
for c in cmds {
|
||||
cli.args(["--cmd", c]);
|
||||
}
|
||||
let run = cli.arg("--save").arg(&out).output().unwrap();
|
||||
let docx = std::fs::read(&out);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert!(run.status.success(), "stderr: {}", String::from_utf8_lossy(&run.stderr));
|
||||
assert!(docx.unwrap().windows(11).any(|w| w == b"word/media/"), "the saved file keeps the picture");
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//! #866: `wordcraft-cli text` omits tracked deletions (it does not go through `document.text`).
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
fn cli() -> Command {
|
||||
Command::new(env!("CARGO_BIN_EXE_wordcraft-cli"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_omits_tracked_deletions() {
|
||||
let dir = std::env::temp_dir().join(format!("wc-cli-866-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("t.docx");
|
||||
let path_s = path.to_str().unwrap();
|
||||
|
||||
let make = cli()
|
||||
.args([
|
||||
"run",
|
||||
"--save",
|
||||
path_s,
|
||||
"--cmd",
|
||||
r#"text.insert={"text":"Signed by Alice Smith, Partner."}"#,
|
||||
"--cmd",
|
||||
r#"review.trackChanges={"value":true}"#,
|
||||
"--cmd",
|
||||
r#"select.text={"text":"Alice Smith, Partner"}"#,
|
||||
"--cmd",
|
||||
r#"text.insert={"text":"Bob Jones, Director"}"#,
|
||||
])
|
||||
.output()
|
||||
.expect("run");
|
||||
assert!(make.status.success(), "run failed: {}", String::from_utf8_lossy(&make.stderr));
|
||||
|
||||
let out = cli().args(["text", path_s]).output().expect("text");
|
||||
assert!(out.status.success(), "text failed: {}", String::from_utf8_lossy(&out.stderr));
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
assert_eq!(stdout.trim_end(), "Signed by Bob Jones, Director.");
|
||||
assert!(!stdout.contains("Alice Smith"), "{stdout}");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -29,4 +29,4 @@ rfd = { workspace = true }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File", "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbRequest", "IdbDatabase", "IdbObjectStore", "IdbTransaction", "IdbTransactionMode", "MediaQueryList"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File", "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbRequest", "IdbDatabase", "IdbObjectStore", "IdbTransaction", "IdbTransactionMode", "MediaQueryList", "Storage"] }
|
||||
|
||||
@@ -6,9 +6,15 @@
|
||||
//!
|
||||
//! Differences from the desktop app: no TCP control channel; Open and Insert › Pictures use the
|
||||
//! browser file picker (bytes arrive through `Services::inbox`); Save and Export download.
|
||||
//! Preferences (theme, interface size, editing options…) are kept in `localStorage` (`prefs`).
|
||||
//! URL flags: `?webgl` forces WebGL2; `?sample` opens the sample document.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod prefs;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod save_fallback;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
//! Preferences between visits (#2132): the same [`UiState`] the desktop app keeps in `ui.json`
|
||||
//! (theme, interface size, recent symbols, editing options…), kept in the browser's
|
||||
//! `localStorage` under [`KEY`]. Restored at startup; written back whenever it changes, so a
|
||||
//! reload or a closed tab keeps the last setting (browsers don't reliably run exit code).
|
||||
|
||||
use wordcraft_ui_egui::{UiState, WordApp};
|
||||
|
||||
/// The `localStorage` key.
|
||||
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
|
||||
pub const KEY: &str = "wordcraft.ui";
|
||||
/// Largest stored value read back (`localStorage` holds about 5 MB per site).
|
||||
const MAX_STORED: usize = 4 << 20;
|
||||
|
||||
/// Apply preferences stored on an earlier visit. Missing, oversized or unreadable values leave
|
||||
/// the defaults.
|
||||
pub fn restore(app: &mut WordApp, stored: Option<&str>) {
|
||||
if let Some(s) = stored.filter(|s| s.len() <= MAX_STORED)
|
||||
&& let Some(ui) = UiState::from_json(s.as_bytes())
|
||||
{
|
||||
app.apply_prefs(ui);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tracks what was last written, so the store is written only when a preference changed.
|
||||
#[derive(Default)]
|
||||
pub struct PrefsSync {
|
||||
last: Option<String>,
|
||||
}
|
||||
|
||||
impl PrefsSync {
|
||||
/// Starts from what is already stored, so restoring doesn't write it straight back.
|
||||
pub fn new(stored: Option<String>) -> Self {
|
||||
Self { last: stored }
|
||||
}
|
||||
|
||||
/// The JSON to store when the app's preferences differ from what was last written.
|
||||
pub fn changed(&mut self, app: &WordApp) -> Option<String> {
|
||||
let json = match serde_json::to_string(&app.prefs()) {
|
||||
Ok(j) => j,
|
||||
Err(e) => {
|
||||
log::warn!("preferences: cannot serialize: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if self.last.as_deref() == Some(json.as_str()) {
|
||||
return None;
|
||||
}
|
||||
self.last = Some(json.clone());
|
||||
Some(json)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use wordcraft_ui_egui::Services;
|
||||
|
||||
fn app() -> WordApp {
|
||||
WordApp::new(wordcraft_engine::Session::new(wordcraft_engine::sample::sample_document()), Services::default())
|
||||
}
|
||||
|
||||
/// #2132: dark mode chosen on one visit is there after a reload.
|
||||
#[test]
|
||||
fn dark_mode_survives_reload() {
|
||||
let mut first = app();
|
||||
let mut sync = PrefsSync::new(None);
|
||||
let initial = sync.changed(&first);
|
||||
assert!(initial.is_some(), "first frame stores the preferences");
|
||||
assert_eq!(sync.changed(&first), None, "unchanged preferences are not rewritten");
|
||||
first.run("ui.theme", json!({"value": "dark"})).unwrap();
|
||||
let stored = sync.changed(&first).expect("the theme change is stored");
|
||||
|
||||
let mut reloaded = app();
|
||||
restore(&mut reloaded, Some(&stored));
|
||||
assert_eq!(reloaded.ui.theme, wordcraft_ui_egui::theme::Appearance::Dark);
|
||||
assert_eq!(PrefsSync::new(Some(stored)).changed(&reloaded), None, "restoring doesn't write back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_storage_keeps_defaults() {
|
||||
for junk in [None, Some(""), Some("not json"), Some("[1,2]"), Some("{\"theme\":42}")] {
|
||||
let mut a = app();
|
||||
restore(&mut a, junk);
|
||||
assert_eq!(a.ui.theme, wordcraft_ui_egui::theme::Appearance::System, "junk keeps the default appearance");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//! Save-picker rejection policy, shared with native tests of the browser fallback.
|
||||
|
||||
use wordcraft_ui_egui::async_saves::SaveOutcome;
|
||||
|
||||
/// A cancelled picker ends the save. Other picker failures try the browser download path.
|
||||
/// Write/close errors do not pass through this function: they retain their own failure.
|
||||
pub(crate) fn picker_rejected(name: &str, download: impl FnOnce() -> Result<(), String>) -> SaveOutcome {
|
||||
if name == "AbortError" {
|
||||
SaveOutcome::Cancelled
|
||||
} else {
|
||||
match download() {
|
||||
Ok(()) => SaveOutcome::Saved,
|
||||
Err(error) => SaveOutcome::Failed(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn unavailable_picker_downloads_once() {
|
||||
for name in ["SecurityError", "NotSupportedError", "TypeError", ""] {
|
||||
let mut calls = 0;
|
||||
let outcome = picker_rejected(name, || {
|
||||
calls += 1;
|
||||
Ok(())
|
||||
});
|
||||
assert_eq!(outcome, SaveOutcome::Saved);
|
||||
assert_eq!(calls, 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_picker_never_downloads() {
|
||||
let outcome = picker_rejected("AbortError", || panic!("cancel must not download"));
|
||||
assert_eq!(outcome, SaveOutcome::Cancelled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_fallback_remains_a_failed_save() {
|
||||
let outcome = picker_rejected("SecurityError", || Err("download unavailable".into()));
|
||||
assert_eq!(outcome, SaveOutcome::Failed("download unavailable".into()));
|
||||
}
|
||||
}
|
||||
+186
-62
@@ -6,11 +6,12 @@ use std::rc::Rc;
|
||||
|
||||
use wasm_bindgen::JsCast as _;
|
||||
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 IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "wmf", "emf"];
|
||||
const RECIPIENT_EXTS: &[&str] = &["csv", "tsv", "txt", "xlsx", "xlsm", "ods"];
|
||||
const RECIPIENT_EXTS: &[&str] = wordcraft_engine::cmd::mailings::RECIPIENT_EXTENSIONS;
|
||||
mod font_store;
|
||||
const CANVAS_ID: &str = "wordcraft_canvas";
|
||||
const LOADING_ID: &str = "wordcraft_loading";
|
||||
@@ -27,7 +28,7 @@ pub fn start() {
|
||||
return;
|
||||
};
|
||||
let dirty = Rc::new(Cell::new(false));
|
||||
let saves = Rc::new(PickerSaves::default());
|
||||
let saves = AsyncSaves::default();
|
||||
if let Err(e) = guard_unload(dirty.clone(), saves.clone()) {
|
||||
log::error!("no unsaved-changes guard: {e}");
|
||||
}
|
||||
@@ -48,7 +49,8 @@ pub fn start() {
|
||||
// Fonts the user added on an earlier visit (#582); the app notices them arrive.
|
||||
font_store::load_all(cc.egui_ctx.clone());
|
||||
let inbox: Inbox = Inbox::default();
|
||||
if let Err(e) = listen_for_pasted_pictures(inbox.clone(), cc.egui_ctx.clone()) {
|
||||
let pasted: Inbox = Inbox::default();
|
||||
if let Err(e) = listen_for_pasted_pictures(pasted.clone(), cc.egui_ctx.clone()) {
|
||||
log::error!("pasting pictures is unavailable: {e}");
|
||||
}
|
||||
// The browser's locale picks the new document's paper, the units and the
|
||||
@@ -58,13 +60,26 @@ pub fn start() {
|
||||
let doc = if sample { wordcraft_engine::sample::sample_document() } else { wordcraft_engine::blank_document(®ion) };
|
||||
let mut session = Session::new(doc);
|
||||
session.system_region = region;
|
||||
let mut app = WordApp::new(session, services(inbox.clone(), cc.egui_ctx.clone(), dirty, saves.clone()));
|
||||
let messages = Rc::new(RefCell::new(Vec::new()));
|
||||
let mut app = WordApp::new(session, services(inbox.clone(), cc.egui_ctx.clone(), dirty, saves.clone(), messages.clone()));
|
||||
// Pasted pictures wait apart from dropped files: the app inserts one only when the page is the edit target (#1349).
|
||||
app.services.pasted = Some(pasted);
|
||||
app.autosave = false;
|
||||
// Preferences from an earlier visit (#2132), as the desktop app reads `ui.json`.
|
||||
let stored = stored_prefs();
|
||||
crate::prefs::restore(&mut app, stored.as_deref());
|
||||
// A region chosen in File › Options replaces the browser'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);
|
||||
}
|
||||
let prefs = crate::prefs::PrefsSync::new(stored);
|
||||
// No document to show: start on File › Home, as the desktop app does (#766).
|
||||
if !sample {
|
||||
app.start_screen();
|
||||
}
|
||||
Ok(Box::new(WebShell { app, inbox, saves }))
|
||||
Ok(Box::new(WebShell { app, inbox, messages, prefs }))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
@@ -86,38 +101,17 @@ fn query() -> String {
|
||||
struct WebShell {
|
||||
app: WordApp,
|
||||
inbox: Inbox,
|
||||
saves: Rc<PickerSaves>,
|
||||
messages: Rc<RefCell<Vec<String>>>,
|
||||
prefs: crate::prefs::PrefsSync,
|
||||
}
|
||||
|
||||
/// Saves the browser's save picker finishes after `download` has returned (#728). The app marks
|
||||
/// the document saved as soon as `download` returns, so a cancelled or failed picker save must
|
||||
/// mark it unsaved again, and the `beforeunload` guard keeps warning while one is in flight.
|
||||
#[derive(Default)]
|
||||
struct PickerSaves {
|
||||
/// Picker saves started and not yet written and closed.
|
||||
pending: Cell<u32>,
|
||||
/// Picker saves that didn't write the file: (name, error, or `None` when cancelled).
|
||||
unsaved: RefCell<Vec<(String, Option<String>)>>,
|
||||
fn local_storage() -> Option<web_sys::Storage> {
|
||||
web_sys::window()?.local_storage().ok()?
|
||||
}
|
||||
|
||||
impl WebShell {
|
||||
/// A picker save that didn't happen leaves the document with unsaved changes.
|
||||
fn settle_picker_saves(&mut self) {
|
||||
let unsaved = std::mem::take(&mut *self.saves.unsaved.borrow_mut());
|
||||
for (name, error) in unsaved {
|
||||
// Only a save in a format that keeps everything marked the document saved.
|
||||
if wordcraft_ui_egui::keeps_everything(&name) {
|
||||
self.app.session.dirty = true;
|
||||
}
|
||||
if let Some(error) = error {
|
||||
let msg = wordcraft_ui_egui::i18n::fmt(
|
||||
wordcraft_ui_egui::i18n::t("Couldn't download {name}: {error}"),
|
||||
&[("name", &name), ("error", &error)],
|
||||
);
|
||||
self.app.status(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The preferences stored on an earlier visit, if the browser allows storage.
|
||||
fn stored_prefs() -> Option<String> {
|
||||
local_storage()?.get_item(crate::prefs::KEY).ok()?
|
||||
}
|
||||
|
||||
impl eframe::App for WebShell {
|
||||
@@ -137,8 +131,22 @@ impl eframe::App for WebShell {
|
||||
}
|
||||
});
|
||||
}
|
||||
self.settle_picker_saves();
|
||||
let msgs = std::mem::take(&mut *self.messages.borrow_mut());
|
||||
for msg in msgs {
|
||||
self.app.status(msg);
|
||||
}
|
||||
self.app.logic(ctx);
|
||||
// Store changed preferences straight away: a reload or closed tab runs no exit code (#2132).
|
||||
if let Some(json) = self.prefs.changed(&self.app) {
|
||||
match local_storage() {
|
||||
Some(store) => {
|
||||
if let Err(e) = store.set_item(crate::prefs::KEY, &json) {
|
||||
log::warn!("preferences: cannot save: {e:?}");
|
||||
}
|
||||
}
|
||||
None => log::warn!("preferences: browser storage is unavailable"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
@@ -157,10 +165,11 @@ const MAX_PASTED_PICTURES: u32 = 20;
|
||||
|
||||
/// Pasting a picture (#45): eframe's paste handler only reads text, so a clipboard holding just
|
||||
/// pictures (a screenshot, a browser's Copy Image) is read here and the pictures arrive through
|
||||
/// the inbox like dropped ones. A clipboard with text is left to eframe.
|
||||
/// the app's `pasted` queue, which inserts them only when the page is the edit target (#1349). A clipboard with text is left to eframe.
|
||||
fn listen_for_pasted_pictures(inbox: Inbox, ctx: egui::Context) -> Result<(), String> {
|
||||
let document = web_sys::window().and_then(|w| w.document()).ok_or("no document")?;
|
||||
let on_paste = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::ClipboardEvent)>::new(move |e: web_sys::ClipboardEvent| {
|
||||
// The target is always eframe's hidden text input while the page has focus; the app decides (#1349).
|
||||
let Some(data) = e.clipboard_data() else { return };
|
||||
if !data.get_data("text").unwrap_or_default().is_empty() {
|
||||
return;
|
||||
@@ -181,7 +190,7 @@ fn listen_for_pasted_pictures(inbox: Inbox, ctx: egui::Context) -> Result<(), St
|
||||
match wasm_bindgen_futures::JsFuture::from(file.array_buffer()).await {
|
||||
Ok(buf) => {
|
||||
let bytes = js_sys::Uint8Array::new(&buf).to_vec();
|
||||
// The inbox inserts files named like pictures; the engine reads the format from the bytes.
|
||||
// The engine reads the format from the bytes; the name is ignored.
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push(("pasted.png".into(), bytes));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
@@ -197,9 +206,11 @@ fn listen_for_pasted_pictures(inbox: Inbox, ctx: egui::Context) -> Result<(), St
|
||||
}
|
||||
|
||||
/// `dirty` is the flag the `beforeunload` guard reads ([`guard_unload`]).
|
||||
fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc<Cell<bool>>, saves: Rc<PickerSaves>) -> Services {
|
||||
fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc<Cell<bool>>, saves: AsyncSaves, messages: Rc<RefCell<Vec<String>>>) -> Services {
|
||||
let open_inbox = inbox.clone();
|
||||
let save_ctx = ctx.clone();
|
||||
let font_ctx = ctx.clone();
|
||||
let font_messages = messages.clone();
|
||||
Services {
|
||||
open_async: Some(Box::new(move |purpose: &str| {
|
||||
let inbox = open_inbox.clone();
|
||||
@@ -213,17 +224,28 @@ fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc<Cell<bool>>, saves: Rc<P
|
||||
} else {
|
||||
rfd::AsyncFileDialog::new().add_filter("Documents", DOC_EXTS)
|
||||
};
|
||||
let many = purpose == "font";
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let Some(file) = dialog.pick_file().await else {
|
||||
return;
|
||||
};
|
||||
let bytes = file.read().await;
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((file.file_name(), bytes));
|
||||
ctx.request_repaint();
|
||||
// Add Font takes every selected file (#1789); the others one.
|
||||
let files = if many { dialog.pick_files().await.unwrap_or_default() } else { dialog.pick_file().await.into_iter().collect() };
|
||||
for file in files.into_iter().take(MAX_PICKED_FILES) {
|
||||
let mut bytes = file.read().await;
|
||||
let mut pending = inbox.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// Fonts waiting for the app to drain them stay under a byte cap. A file
|
||||
// over it is queued empty, so Add Font reports that file on its own.
|
||||
let held: usize = pending.iter().map(|(_, b)| b.len()).fold(0, usize::saturating_add);
|
||||
if many && held.saturating_add(bytes.len()) > MAX_PENDING_PICKED_BYTES {
|
||||
bytes = Vec::new();
|
||||
}
|
||||
pending.push((file.file_name(), bytes));
|
||||
drop(pending);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
});
|
||||
})),
|
||||
// Exports ask for a name; the browser decides where the download goes.
|
||||
pick_save: Some(Box::new(|name: &str| Some(name.to_string()))),
|
||||
async_saves: Some(saves.clone()),
|
||||
download: Some(Box::new(move |path: &str, bytes: &[u8]| download(&saves, &save_ctx, path, bytes))),
|
||||
print: Some(Box::new(print_pdf)),
|
||||
inbox: Some(inbox),
|
||||
@@ -231,10 +253,116 @@ fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc<Cell<bool>>, saves: Rc<P
|
||||
animations_enabled: Some(animations_enabled()),
|
||||
store_font: Some(Box::new(font_store::store)),
|
||||
forget_font: Some(Box::new(font_store::forget)),
|
||||
load_system_fonts: Some(Box::new(move || {
|
||||
load_system_fonts(font_ctx.clone(), font_messages.clone());
|
||||
})),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Most files one multi-select pick (Add Font) reads.
|
||||
const MAX_PICKED_FILES: usize = 2000;
|
||||
/// Most bytes of picked fonts held in the inbox before the app drains them.
|
||||
const MAX_PENDING_PICKED_BYTES: usize = 256 << 20;
|
||||
/// Most local fonts one Load System Installed Fonts installs (a designer's machine has thousands).
|
||||
const MAX_SYSTEM_FONTS: usize = 500;
|
||||
/// Most font bytes one Load System Installed Fonts reads into memory (wasm memory is small).
|
||||
const MAX_SYSTEM_FONT_BYTES: usize = 256 << 20;
|
||||
/// Most local fonts looked at, installed or not (already available, unreadable, too big).
|
||||
const MAX_SYSTEM_FONTS_TRIED: u32 = 4000;
|
||||
|
||||
/// Query the browser's Local Font Access API (`window.queryLocalFonts()`) and install the faces
|
||||
/// into the global font database, up to [`MAX_SYSTEM_FONTS`] faces and [`MAX_SYSTEM_FONT_BYTES`].
|
||||
fn load_system_fonts(ctx: egui::Context, messages: Rc<RefCell<Vec<String>>>) {
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let Some(window) = web_sys::window() else {
|
||||
return;
|
||||
};
|
||||
let query_fn = match js_sys::Reflect::get(&window, &wasm_bindgen::JsValue::from_str("queryLocalFonts")) {
|
||||
Ok(val) if val.is_function() => js_sys::Function::from(val),
|
||||
_ => {
|
||||
messages.borrow_mut().push(wordcraft_ui_egui::i18n::t("Local font access is not supported by your browser.").to_string());
|
||||
ctx.request_repaint();
|
||||
return;
|
||||
}
|
||||
};
|
||||
let fonts_promise = match query_fn.call0(&window) {
|
||||
Ok(p) => js_sys::Promise::from(p),
|
||||
Err(e) => {
|
||||
log::warn!("queryLocalFonts call failed: {e:?}");
|
||||
messages.borrow_mut().push(wordcraft_ui_egui::i18n::t("Local font access was denied.").to_string());
|
||||
ctx.request_repaint();
|
||||
return;
|
||||
}
|
||||
};
|
||||
let fonts_val = match wasm_bindgen_futures::JsFuture::from(fonts_promise).await {
|
||||
Ok(val) => val,
|
||||
Err(e) => {
|
||||
log::warn!("queryLocalFonts promise rejected: {e:?}");
|
||||
messages.borrow_mut().push(wordcraft_ui_egui::i18n::t("Local font access was denied.").to_string());
|
||||
ctx.request_repaint();
|
||||
return;
|
||||
}
|
||||
};
|
||||
let font_list = js_sys::Array::from(&fonts_val);
|
||||
let len = font_list.length();
|
||||
let mut loaded = 0usize;
|
||||
let mut total_bytes = 0usize;
|
||||
for i in 0..len.min(MAX_SYSTEM_FONTS_TRIED) {
|
||||
if loaded >= MAX_SYSTEM_FONTS || total_bytes >= MAX_SYSTEM_FONT_BYTES {
|
||||
log::warn!("local fonts: stopped after {loaded} fonts ({total_bytes} bytes)");
|
||||
break;
|
||||
}
|
||||
let font_data = font_list.get(i);
|
||||
let Ok(blob_fn) = js_sys::Reflect::get(&font_data, &wasm_bindgen::JsValue::from_str("blob")) else {
|
||||
continue;
|
||||
};
|
||||
if !blob_fn.is_function() {
|
||||
continue;
|
||||
}
|
||||
let Ok(blob_val) = js_sys::Function::from(blob_fn).call0(&font_data) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(blob_js) = wasm_bindgen_futures::JsFuture::from(js_sys::Promise::from(blob_val)).await else {
|
||||
continue;
|
||||
};
|
||||
let Ok(blob) = blob_js.dyn_into::<web_sys::Blob>() else {
|
||||
continue;
|
||||
};
|
||||
// Skip a face too big on its own or for what is left of the budget, before reading it.
|
||||
let size = blob.size().clamp(0.0, usize::MAX as f64) as usize;
|
||||
if size > wordcraft_fonts::MAX_FONT_FILE_BYTES || total_bytes.saturating_add(size) > MAX_SYSTEM_FONT_BYTES {
|
||||
continue;
|
||||
}
|
||||
let Ok(array_buffer) = wasm_bindgen_futures::JsFuture::from(blob.array_buffer()).await else {
|
||||
continue;
|
||||
};
|
||||
let bytes = js_sys::Uint8Array::new(&array_buffer).to_vec();
|
||||
if bytes.len() > wordcraft_fonts::MAX_FONT_FILE_BYTES {
|
||||
continue;
|
||||
}
|
||||
total_bytes = total_bytes.saturating_add(bytes.len());
|
||||
let ps_name = js_sys::Reflect::get(&font_data, &wasm_bindgen::JsValue::from_str("postscriptName"))
|
||||
.ok()
|
||||
.and_then(|v| v.as_string())
|
||||
.unwrap_or_else(|| format!("local-font-{i}"));
|
||||
let name = format!("{ps_name}.ttf");
|
||||
match wordcraft_fonts::FontDb::global().install_font(&name, &bytes) {
|
||||
Ok(_) => loaded += 1,
|
||||
Err(wordcraft_fonts::UserFontError::AlreadyAvailable(_)) => {}
|
||||
Err(e) => log::warn!("local font {name} skipped: {e}"),
|
||||
}
|
||||
}
|
||||
if loaded > 0 {
|
||||
let msg = wordcraft_ui_egui::i18n::fmt(wordcraft_ui_egui::i18n::t("Loaded {count} system fonts."), &[("count", &loaded.to_string())]);
|
||||
messages.borrow_mut().push(msg);
|
||||
} else {
|
||||
messages.borrow_mut().push(wordcraft_ui_egui::i18n::t("All system fonts are already available.").to_string());
|
||||
}
|
||||
ctx.request_repaint();
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether the system has animation effects on, as the browser passes it on: off while the
|
||||
/// `prefers-reduced-motion: reduce` media query matches. `None` when the browser can't say. The
|
||||
/// query is made once and asked every frame; it follows the system's setting by itself.
|
||||
@@ -245,11 +373,11 @@ fn animations_enabled() -> Box<dyn Fn() -> Option<bool>> {
|
||||
|
||||
/// Closing, reloading or leaving the tab with unsaved changes asks first. Browsers show their own
|
||||
/// confirmation (a page can only ask for it), so there is no Save button: Cancel, then File › Save.
|
||||
fn guard_unload(dirty: Rc<Cell<bool>>, saves: Rc<PickerSaves>) -> Result<(), String> {
|
||||
fn guard_unload(dirty: Rc<Cell<bool>>, saves: AsyncSaves) -> Result<(), String> {
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let on_unload = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::BeforeUnloadEvent)>::new(move |e: web_sys::BeforeUnloadEvent| {
|
||||
// A save still in the browser's picker hasn't written anything yet.
|
||||
if dirty.get() || saves.pending.get() > 0 {
|
||||
if dirty.get() || saves.pending() > 0 {
|
||||
e.prevent_default();
|
||||
// Older browsers ask only when `returnValue` is set.
|
||||
e.set_return_value("unsaved changes");
|
||||
@@ -262,7 +390,7 @@ fn guard_unload(dirty: Rc<Cell<bool>>, saves: Rc<PickerSaves>) -> Result<(), Str
|
||||
}
|
||||
|
||||
/// Trigger a browser save dialog or download of `bytes` named after the last component of `path`.
|
||||
fn download(saves: &Rc<PickerSaves>, ctx: &egui::Context, path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
fn download(saves: &AsyncSaves, ctx: &egui::Context, path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "document".into());
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
|
||||
@@ -284,7 +412,7 @@ fn download(saves: &Rc<PickerSaves>, ctx: &egui::Context, path: &str, bytes: &[u
|
||||
}
|
||||
|
||||
fn trigger_save_file_picker(
|
||||
saves: &Rc<PickerSaves>,
|
||||
saves: &AsyncSaves,
|
||||
ctx: &egui::Context,
|
||||
window: &web_sys::Window,
|
||||
picker_fn: &js_sys::Function,
|
||||
@@ -325,39 +453,35 @@ fn trigger_save_file_picker(
|
||||
let prom: js_sys::Promise = prom_val.dyn_into().map_err(|_| "not a promise".to_string())?;
|
||||
|
||||
let bytes_vec = bytes.to_vec();
|
||||
let window = window.clone();
|
||||
let saves = saves.clone();
|
||||
let ctx = ctx.clone();
|
||||
let name = name.to_string();
|
||||
saves.pending.set(saves.pending.get().saturating_add(1));
|
||||
// The app holds back a Save Changes prompt's New/Open until this ticket ends (#1315).
|
||||
let ticket = saves.start();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
// The document counts as saved only once the file is written and closed.
|
||||
let unsaved = match wasm_bindgen_futures::JsFuture::from(prom).await {
|
||||
let outcome = match wasm_bindgen_futures::JsFuture::from(prom).await {
|
||||
Ok(handle_val) => match write_file_handle(&handle_val, &bytes_vec).await {
|
||||
Ok(()) => {
|
||||
log::info!("saved {name} through the save picker");
|
||||
None
|
||||
SaveOutcome::Saved
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("couldn't write {name}: {e}");
|
||||
Some(Some(e))
|
||||
SaveOutcome::Failed(e)
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
let err_name =
|
||||
js_sys::Reflect::get(&e, &wasm_bindgen::JsValue::from_str("name")).ok().and_then(|v| v.as_string()).unwrap_or_default();
|
||||
if err_name == "AbortError" {
|
||||
log::debug!("save picker cancelled");
|
||||
Some(None)
|
||||
} else {
|
||||
log::error!("showSaveFilePicker error: {e:?}");
|
||||
Some(Some(format!("{e:?}")))
|
||||
}
|
||||
// A browser can expose the picker but reject its promise (for example in
|
||||
// a cross-origin iframe). Use the same download path as an absent API;
|
||||
// cancelling a picker must never start an unwanted download (#1246).
|
||||
crate::save_fallback::picker_rejected(&err_name, || trigger_anchor_download(&window, &name, &bytes_vec))
|
||||
}
|
||||
};
|
||||
if let Some(error) = unsaved {
|
||||
saves.unsaved.borrow_mut().push((name, error));
|
||||
}
|
||||
saves.pending.set(saves.pending.get().saturating_sub(1));
|
||||
saves.finish(ticket, outcome);
|
||||
ctx.request_repaint();
|
||||
});
|
||||
Ok(())
|
||||
|
||||
@@ -36,7 +36,7 @@ async fn open() -> Result<IdbDatabase, String> {
|
||||
let req = factory.open_with_u32(DB_NAME, 1).map_err(js)?;
|
||||
let upgrade_req = req.clone();
|
||||
let upgrade = Closure::<dyn FnMut()>::new(move || {
|
||||
if let Ok(db) = upgrade_req.result().and_then(|r| r.dyn_into::<IdbDatabase>().map_err(JsValue::from))
|
||||
if let Ok(db) = upgrade_req.result().and_then(|r| r.dyn_into::<IdbDatabase>())
|
||||
&& !db.object_store_names().contains(STORE)
|
||||
&& let Err(e) = db.create_object_store(STORE)
|
||||
{
|
||||
|
||||
@@ -36,9 +36,14 @@ winresource = "0.1"
|
||||
fmv-macos-events = "=0.1.0"
|
||||
# The system's Reduce motion setting (System Settings › Accessibility › Display), read through
|
||||
# NSWorkspace: a safe call in these bindings. Already in the build through eframe, rfd and arboard,
|
||||
# at this version; this adds only the `NSAccessibility` bindings.
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSWorkspace", "NSAccessibility", "NSApplication"] }
|
||||
# at this version; this adds the `NSAccessibility` bindings and, to fit the title bar around the
|
||||
# traffic lights, the window, button and view ones.
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSWorkspace", "NSAccessibility", "NSApplication", "NSWindow", "NSButton", "NSControl", "NSView", "NSResponder", "NSPasteboard"] }
|
||||
objc2 = "0.6"
|
||||
# A copied equation's MathML goes on the pasteboard as `public.mathml.presentation` beside
|
||||
# arboard's HTML and text (#1297), through NSPasteboard's safe `setString:forType:`. Already in
|
||||
# the build through arboard, at this version; this adds only the NSString bindings.
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSString"] }
|
||||
# Closing the window must quit through NSApplication rather than by dropping the NSWindow
|
||||
# inside the AppKit run loop (see `schedule_macos_terminate`).
|
||||
dispatch2 = "0.3"
|
||||
|
||||
@@ -9,10 +9,15 @@
|
||||
//!
|
||||
//! Everything read here comes from other programs: sizes are capped as it is read.
|
||||
//!
|
||||
//! Written here: a copy holding an equation puts HTML (the equation as MathML) beside its text
|
||||
//! (#816), for programs that read MathML (pasting into Word and LibreOffice not yet verified). arboard sets HTML with a
|
||||
//! text alternative on every desktop platform; formats of other names (Word's "Office Open XML"
|
||||
//! with OMML, #1297) it can't, so they aren't offered.
|
||||
//! Written here: a copy puts HTML beside its text, so other programs keep its formatting (#1523),
|
||||
//! with equations as MathML (#816). arboard sets HTML with a text alternative on every desktop
|
||||
//! platform. A copy of one equation alone also goes in the platform's MathML formats (#1297),
|
||||
//! added beside arboard's: on Windows "MathML" and "MathML Presentation" (UTF-16LE), on macOS
|
||||
//! `public.mathml.presentation` and `public.mathml` — the names W3C MathML 3 §6.2.4 registers
|
||||
//! for transferring MathML, and the ones Microsoft documents its Office apps reading first when
|
||||
//! they paste math (learn.microsoft.com/office/math/mathml, "Clipboard support"). Pasting into
|
||||
//! Word and LibreOffice is not yet verified. Linux and the BSDs have no Word, and arboard can't
|
||||
//! add MIME types of other names, so there the HTML carries the MathML alone.
|
||||
|
||||
use wordcraft_engine::cmd::paste::MAX_PASTE_BYTES;
|
||||
use wordcraft_ui_egui::paste_picture::ClipboardPicture;
|
||||
@@ -26,13 +31,24 @@ thread_local! {
|
||||
static WRITER: std::cell::RefCell<Option<arboard::Clipboard>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Put `html` on the clipboard with `text` as its plain-text alternative. `false` when it couldn't
|
||||
/// (the caller copies the text alone), including a Wayland session without the data-control
|
||||
/// protocol, where arboard would fall back to X11 and native Wayland programs might not see it.
|
||||
pub fn set_html(html: &str, text: &str) -> bool {
|
||||
/// Put `html` on the clipboard with `text` as its plain-text alternative, and `mathml` (a copy of
|
||||
/// one equation alone; empty for any other copy) in the platform's MathML formats. `false` when
|
||||
/// the HTML and text couldn't be set (the caller copies the text alone), including a Wayland
|
||||
/// session without the data-control protocol, where arboard would fall back to X11 and native
|
||||
/// Wayland programs might not see it. MathML that can't be added is logged: the HTML still
|
||||
/// carries it.
|
||||
pub fn set_html(html: &str, text: &str, mathml: &str) -> bool {
|
||||
if !platform::can_set() {
|
||||
return false;
|
||||
}
|
||||
let set = set_html_text(html, text);
|
||||
if set && !mathml.is_empty() && mathml.len() <= MAX_PASTE_BYTES {
|
||||
platform::add_mathml(mathml);
|
||||
}
|
||||
set
|
||||
}
|
||||
|
||||
fn set_html_text(html: &str, text: &str) -> bool {
|
||||
WRITER.with(|w| {
|
||||
let Ok(mut slot) = w.try_borrow_mut() else { return false };
|
||||
if slot.is_none() {
|
||||
@@ -146,6 +162,9 @@ mod platform {
|
||||
)
|
||||
}
|
||||
|
||||
/// arboard serves its own MIME types only; Word, which reads the MathML formats, isn't here.
|
||||
pub fn add_mathml(_mathml: &str) {}
|
||||
|
||||
pub fn rtf() -> Option<Vec<u8>> {
|
||||
if !wayland() {
|
||||
return None;
|
||||
@@ -193,9 +212,68 @@ mod platform {
|
||||
pub fn can_set() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Add the MathML to what arboard just set, as "MathML" and "MathML Presentation": UTF-16LE
|
||||
/// text (with its terminating NUL, as Windows text formats have).
|
||||
pub fn add_mathml(mathml: &str) {
|
||||
let Ok(_open) = Clipboard::new_attempts(10) else {
|
||||
log::warn!("clipboard: couldn't open it to add MathML");
|
||||
return;
|
||||
};
|
||||
let bytes: Vec<u8> = mathml.encode_utf16().chain(std::iter::once(0)).flat_map(u16::to_le_bytes).collect();
|
||||
for name in super::MATHML_FORMATS {
|
||||
let Some(format) = raw::register_format(name) else { continue };
|
||||
if let Err(e) = raw::set_without_clear(format.get(), &bytes) {
|
||||
log::warn!("clipboard: {name}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "windows", all(unix, not(any(target_os = "macos", target_os = "android", target_os = "emscripten"))))))]
|
||||
/// The clipboard format names for MathML that Windows programs read (W3C MathML 3 §6.2.4; Office
|
||||
/// reads and writes both).
|
||||
#[cfg(target_os = "windows")]
|
||||
const MATHML_FORMATS: [&str; 2] = ["MathML", "MathML Presentation"];
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod platform {
|
||||
use objc2_app_kit::NSPasteboard;
|
||||
use objc2_foundation::NSString;
|
||||
|
||||
pub fn formats() -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
pub fn rtf() -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
|
||||
pub fn encoded_picture() -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
|
||||
pub fn can_set() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Add the MathML to the pasteboard item arboard just wrote, as the MathML types (W3C MathML 3
|
||||
/// §6.2.4): presentation markup, and the generic type for readers that only know that one.
|
||||
pub fn add_mathml(mathml: &str) {
|
||||
let board = NSPasteboard::generalPasteboard();
|
||||
let text = NSString::from_str(mathml);
|
||||
for name in ["public.mathml.presentation", "public.mathml"] {
|
||||
if !board.setString_forType(&text, &NSString::from_str(name)) {
|
||||
log::warn!("clipboard: couldn't add {name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(
|
||||
target_os = "windows",
|
||||
target_os = "macos",
|
||||
all(unix, not(any(target_os = "macos", target_os = "android", target_os = "emscripten")))
|
||||
)))]
|
||||
mod platform {
|
||||
pub fn formats() -> Vec<String> {
|
||||
Vec::new()
|
||||
@@ -212,4 +290,6 @@ mod platform {
|
||||
pub fn can_set() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
pub fn add_mathml(_mathml: &str) {}
|
||||
}
|
||||
|
||||
@@ -484,7 +484,16 @@ mod tests {
|
||||
assert!(!dir.exists() || std::fs::read_dir(&dir).unwrap().next().is_none(), "no key file after a failed bind");
|
||||
drop(taken);
|
||||
// Bound: the key file holds the key that opens the port.
|
||||
let (rx, key_file) = start(port, egui::Context::default(), Some(&dir)).unwrap();
|
||||
let mut free_port = port.wrapping_add(1).max(1024);
|
||||
let mut started = None;
|
||||
for _ in 0..100 {
|
||||
if let Some(res) = start(free_port, egui::Context::default(), Some(&dir)) {
|
||||
started = Some((res, free_port));
|
||||
break;
|
||||
}
|
||||
free_port = free_port.wrapping_add(1).max(1024);
|
||||
}
|
||||
let ((rx, key_file), port) = started.unwrap();
|
||||
let key_file = key_file.unwrap();
|
||||
assert_eq!(key_file.path(), wordcraft_control_key::key_path(&dir, port));
|
||||
let key = std::fs::read_to_string(key_file.path()).unwrap();
|
||||
|
||||
@@ -12,6 +12,12 @@
|
||||
//! thread first checks that one of them is there and otherwise answers with an error, and the app
|
||||
//! shows its built-in file browser. `WORDCRAFT_FILE_DIALOG=builtin` always uses the built-in one.
|
||||
//!
|
||||
//! On Linux and the BSDs, rfd reports a portal that failed to show its dialog as a cancel (#2999).
|
||||
//! There a cancel quicker than anyone could dismiss a dialog is taken as that failure: the dialog
|
||||
//! is asked for once more without a parent window, and if that also comes back at once the
|
||||
//! built-in browser shows. Windows and macOS always show their dialog, so a quick cancel there is
|
||||
//! the person's and is answered as one.
|
||||
//!
|
||||
//! [`Services::file_dialog`]: wordcraft_ui_egui::Services::file_dialog
|
||||
|
||||
use std::future::Future;
|
||||
@@ -27,7 +33,7 @@ type Request = (FileDialogRequest, Sender<FileDialogAnswer>);
|
||||
pub type Hook = Box<dyn Fn(FileDialogRequest) -> Receiver<FileDialogAnswer>>;
|
||||
|
||||
/// A picker's answer, waited for off the UI thread.
|
||||
type Picking = Pin<Box<dyn Future<Output = Option<rfd::FileHandle>> + Send>>;
|
||||
type Picking = Pin<Box<dyn Future<Output = Vec<rfd::FileHandle>> + Send>>;
|
||||
|
||||
/// Shows the dialogs the app queued; lives in the eframe app, which has the window.
|
||||
pub struct Launcher {
|
||||
@@ -54,20 +60,22 @@ impl Launcher {
|
||||
pub fn show(&self, parent: &eframe::Frame) {
|
||||
while let Ok((req, answer)) = self.queue.try_recv() {
|
||||
// Built here, on the UI thread: macOS shows the panel as a sheet on the window now.
|
||||
let dialog = rfd::AsyncFileDialog::new().set_parent(parent);
|
||||
let picking: Picking = match req {
|
||||
FileDialogRequest::Open { purpose } => Box::pin(open_filters(dialog, &purpose).pick_file()),
|
||||
FileDialogRequest::Save { name } => {
|
||||
let exts = save_exts(&name);
|
||||
Box::pin(dialog.set_file_name(name).add_filter("Documents", &exts).save_file())
|
||||
}
|
||||
};
|
||||
let picking = picker(rfd::AsyncFileDialog::new().set_parent(parent), &req);
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
let unparented = picker(rfd::AsyncFileDialog::new(), &req);
|
||||
let ctx = self.ctx.clone();
|
||||
let waiting = std::thread::Builder::new().name("file dialog".into()).spawn(move || {
|
||||
// The picker only starts when first polled: without a system dialog it never does.
|
||||
// The pickers only start when first polled: without a system dialog they never do.
|
||||
let picked = match no_system_dialog() {
|
||||
Some(reason) => Err(reason),
|
||||
None => Ok(pollster::block_on(picking).map(|f| f.path().to_string_lossy().to_string())),
|
||||
None => {
|
||||
// Only the portal folds a failure into "cancelled" (#2999).
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
let picked = pick(picking, unparented, TOO_QUICK);
|
||||
#[cfg(not(all(unix, not(target_os = "macos"))))]
|
||||
let picked: Result<_, String> = Ok(pollster::block_on(picking));
|
||||
picked.map(|f| f.iter().map(|f| f.path().to_string_lossy().to_string()).collect())
|
||||
}
|
||||
};
|
||||
let _ = answer.send(picked);
|
||||
ctx.request_repaint();
|
||||
@@ -80,6 +88,53 @@ impl Launcher {
|
||||
}
|
||||
}
|
||||
|
||||
/// The picker for `req` on `dialog`.
|
||||
fn picker(dialog: rfd::AsyncFileDialog, req: &FileDialogRequest) -> Picking {
|
||||
match req.clone() {
|
||||
FileDialogRequest::Open { purpose } => Box::pin(async move { open_filters(dialog, &purpose).pick_file().await.into_iter().collect() }),
|
||||
FileDialogRequest::OpenMany { purpose } => Box::pin(async move { open_filters(dialog, &purpose).pick_files().await.unwrap_or_default() }),
|
||||
FileDialogRequest::Save { name } => {
|
||||
let mut dialog = dialog.set_file_name(name.clone());
|
||||
for (label, exts) in save_filters(&name) {
|
||||
dialog = dialog.add_filter(label, &exts);
|
||||
}
|
||||
Box::pin(async move { dialog.save_file().await.into_iter().collect() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Quicker than anyone can see a dialog and dismiss it.
|
||||
// ponytail: a timing heuristic, as rfd folds the portal's failure code into "cancelled". A portal
|
||||
// failing slower than this still reads as a cancel; ask rfd for the response code if it matters.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
const TOO_QUICK: std::time::Duration = std::time::Duration::from_millis(300);
|
||||
|
||||
/// Wait for `picking`. Nothing picked within `too_quick` is a portal that failed to show its
|
||||
/// dialog (#2999), not a person cancelling: try `unparented`, as a portal may fail on the parent
|
||||
/// window; if that too comes back at once, an error, so the app shows its built-in browser.
|
||||
/// Linux and the BSDs only (built everywhere for its test).
|
||||
#[cfg(any(test, all(unix, not(target_os = "macos"))))]
|
||||
fn pick<T>(
|
||||
picking: impl Future<Output = Vec<T>>,
|
||||
unparented: impl Future<Output = Vec<T>>,
|
||||
too_quick: std::time::Duration,
|
||||
) -> Result<Vec<T>, String> {
|
||||
fn timed<T>(f: impl Future<Output = Vec<T>>, too_quick: std::time::Duration) -> (Vec<T>, bool) {
|
||||
let start = std::time::Instant::now();
|
||||
let picked = pollster::block_on(f);
|
||||
let quick = picked.is_empty() && start.elapsed() < too_quick;
|
||||
(picked, quick)
|
||||
}
|
||||
match timed(picking, too_quick) {
|
||||
(_, true) => log::warn!("the system file dialog closed at once; asking again without a parent window"),
|
||||
(picked, false) => return Ok(picked),
|
||||
}
|
||||
match timed(unparented, too_quick) {
|
||||
(_, true) => Err("the system file dialog closed at once".into()),
|
||||
(picked, false) => Ok(picked),
|
||||
}
|
||||
}
|
||||
|
||||
/// The file types Open offers (shared with the built-in browser).
|
||||
fn open_filters(mut d: rfd::AsyncFileDialog, purpose: &str) -> rfd::AsyncFileDialog {
|
||||
for (name, exts) in wordcraft_ui_egui::open_filters(purpose) {
|
||||
@@ -158,9 +213,77 @@ fn save_exts(name: &str) -> Vec<String> {
|
||||
exts
|
||||
}
|
||||
|
||||
/// The "Save as type" list (#1399): one named entry per format, the suggested one first (see
|
||||
/// [`save_exts`] for why the order matters).
|
||||
///
|
||||
/// rfd doesn't report which entry the user picked, so if they choose "PDF" but leave "name.docx"
|
||||
/// in the name box, the extension in the name wins, as the format is chosen by extension.
|
||||
fn save_filters(name: &str) -> Vec<(String, Vec<String>)> {
|
||||
save_exts(name).into_iter().map(|e| (format!("{} (*.{e})", format_name(&e)), vec![e])).collect()
|
||||
}
|
||||
|
||||
/// A readable name for a save format's extension.
|
||||
fn format_name(ext: &str) -> String {
|
||||
match ext {
|
||||
"docx" => "Word Document".into(),
|
||||
"docm" => "Word Macro-Enabled Document".into(),
|
||||
"dotx" => "Word Template".into(),
|
||||
"dotm" => "Word Macro-Enabled Template".into(),
|
||||
"pdf" => "PDF".into(),
|
||||
"txt" => "Plain Text".into(),
|
||||
"md" => "Markdown".into(),
|
||||
"html" => "Web Page".into(),
|
||||
"rtf" => "Rich Text Format".into(),
|
||||
"odt" => "OpenDocument Text".into(),
|
||||
"tex" => "LaTeX".into(),
|
||||
"png" => "PNG Image".into(),
|
||||
"json" => "JSON".into(),
|
||||
"xml" => "XML".into(),
|
||||
other => other.to_ascii_uppercase(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::save_exts;
|
||||
use super::{pick, save_exts, save_filters};
|
||||
use std::time::Duration;
|
||||
|
||||
/// #2999: a dialog that "cancels" at once is a portal that failed: asked again without a
|
||||
/// parent, then the built-in browser. A pick, or a cancel after a while, is the answer.
|
||||
#[test]
|
||||
fn an_instant_cancel_retries_then_falls_back() {
|
||||
let quick = Duration::from_millis(200);
|
||||
let slow_cancel = || async {
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
Vec::<u8>::new()
|
||||
};
|
||||
assert_eq!(pick(async { vec![1u8] }, async { vec![2] }, quick), Ok(vec![1]));
|
||||
assert_eq!(pick(async { vec![] }, async { vec![2u8] }, quick), Ok(vec![2]), "asked again without a parent");
|
||||
assert!(pick(async { Vec::<u8>::new() }, async { vec![] }, quick).is_err(), "the built-in browser");
|
||||
assert_eq!(pick(slow_cancel(), async { vec![2u8] }, quick), Ok(vec![]), "a real cancel isn't asked again");
|
||||
assert_eq!(pick(async { vec![] }, slow_cancel(), quick), Ok(vec![]), "the second dialog cancelled for real");
|
||||
}
|
||||
|
||||
/// #1399: the Save dialog's type list has one named entry per format, the suggested one first,
|
||||
/// every savable extension exactly once.
|
||||
#[test]
|
||||
fn save_dialog_lists_one_named_type_per_format() {
|
||||
let filters = save_filters("report.odt");
|
||||
assert_eq!(filters.len(), wordcraft_engine::io::SAVE_EXTS.len());
|
||||
assert_eq!(filters[0].1, vec!["odt".to_string()]);
|
||||
assert!(filters[0].0.contains("OpenDocument"), "{}", filters[0].0);
|
||||
let all: Vec<&String> = filters.iter().flat_map(|(_, e)| e).collect();
|
||||
for e in wordcraft_engine::io::SAVE_EXTS {
|
||||
assert_eq!(all.iter().filter(|x| x.as_str() == *e).count(), 1, "{e}");
|
||||
}
|
||||
assert!(filters.iter().all(|(l, e)| !l.trim().is_empty() && e.len() == 1));
|
||||
let mut labels: Vec<&String> = filters.iter().map(|(l, _)| l).collect();
|
||||
labels.sort();
|
||||
labels.dedup();
|
||||
assert_eq!(labels.len(), filters.len(), "labels are distinct");
|
||||
// Unknown names fall back to Word's format first.
|
||||
assert_eq!(save_filters("no extension")[0].1, vec!["docx".to_string()]);
|
||||
}
|
||||
|
||||
/// #689: the Save dialog's default extension is the suggested name's own format, so a file
|
||||
/// name the user types without one is saved in the document's format instead of coming back
|
||||
|
||||
@@ -1,12 +1,37 @@
|
||||
//! Windows graphics instance configuration (backends and the DirectX 12 shader compiler), applied
|
||||
//! before wgpu can initialize any driver.
|
||||
//! Graphics instance configuration applied before wgpu can initialize any driver: Windows
|
||||
//! backends and the DirectX 12 shader compiler, and the Intel Mac indirect-validation skip.
|
||||
// On macOS only the indirect-validation items are used outside tests. Elsewhere it is the other
|
||||
// way round, so those two carry their own `allow`.
|
||||
#![cfg_attr(target_os = "macos", allow(dead_code))]
|
||||
|
||||
use eframe::{
|
||||
NativeOptions,
|
||||
egui_wgpu::WgpuSetup,
|
||||
wgpu::{Backends, DeviceType, Dx12Compiler},
|
||||
wgpu::{Backends, DeviceType, Dx12Compiler, InstanceFlags},
|
||||
};
|
||||
|
||||
/// wgpu compiles its indirect-call validation compute shaders inside `request_device`. On old
|
||||
/// Intel Macs (HD Graphics 5000 on Big Sur, #2652) Metal's Intel
|
||||
/// compiler aborts on them, the device is lost and eframe exits before the window opens. WordCraft
|
||||
/// issues no indirect draws or dispatches, so Intel Macs skip them; every other GPU keeps wgpu's
|
||||
/// default. Same fix as PhotoCraft's storytold/PhotoCraft#3171.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
pub fn skips_indirect_validation(macos: bool, intel: bool) -> bool {
|
||||
macos && intel
|
||||
}
|
||||
|
||||
/// Leaves wgpu's indirect-call validation out of the instance eframe creates when `skip`.
|
||||
/// `WGPU_VALIDATION_INDIRECT_CALL=1` still turns it back on.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
pub fn configure_indirect_validation(options: &mut NativeOptions, skip: bool) {
|
||||
if !skip {
|
||||
return;
|
||||
}
|
||||
if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
|
||||
create.instance_descriptor.flags = create.instance_descriptor.flags.difference(InstanceFlags::VALIDATION_INDIRECT_CALL).with_env();
|
||||
}
|
||||
}
|
||||
|
||||
/// What the start-up probe learned about one DirectX 12 adapter.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Dx12Adapter {
|
||||
@@ -153,6 +178,33 @@ fn probe_dx12(options: &NativeOptions) -> Vec<Dx12Adapter> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn intel_macs_skip_the_indirect_validation_shaders() {
|
||||
// #2652: Metal's Intel compiler aborted on them, losing the device before the window opened.
|
||||
assert!(skips_indirect_validation(true, true));
|
||||
// Apple silicon, Windows and Linux keep wgpu's validation.
|
||||
for (macos, intel) in [(true, false), (false, true), (false, false)] {
|
||||
assert!(!skips_indirect_validation(macos, intel), "macos={macos} intel={intel}");
|
||||
}
|
||||
let mut options = NativeOptions::default();
|
||||
let WgpuSetup::CreateNew(before) = &options.wgpu_options.wgpu_setup else {
|
||||
panic!("default native options must create a wgpu instance");
|
||||
};
|
||||
let before = before.instance_descriptor.flags;
|
||||
configure_indirect_validation(&mut options, false);
|
||||
let WgpuSetup::CreateNew(create) = &options.wgpu_options.wgpu_setup else {
|
||||
panic!("default native options must create a wgpu instance");
|
||||
};
|
||||
assert_eq!(create.instance_descriptor.flags, before, "flags untouched when not skipping");
|
||||
if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() {
|
||||
configure_indirect_validation(&mut options, true);
|
||||
let WgpuSetup::CreateNew(create) = &options.wgpu_options.wgpu_setup else {
|
||||
panic!("default native options must create a wgpu instance");
|
||||
};
|
||||
assert!(!create.instance_descriptor.flags.contains(InstanceFlags::VALIDATION_INDIRECT_CALL));
|
||||
}
|
||||
}
|
||||
|
||||
fn configured_backends(backends: Backends) -> Backends {
|
||||
let mut options = NativeOptions::default();
|
||||
configure_backends(&mut options, backends);
|
||||
|
||||
+188
-13
@@ -1,6 +1,6 @@
|
||||
//! WordCraft desktop app.
|
||||
//!
|
||||
//! Usage: `wordcraft [--control <port>] [--sample] [files…]`
|
||||
//! Usage: `wordcraft [--control <port>] [--sample] [files…]`; `--help` prints `USAGE`.
|
||||
//!
|
||||
//! `--control <port>` (or `WORDCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
|
||||
//! `{"id":1,"key":"…","method":"engine.execute","params":{"command":"text.insert","params":{"text":"Hi"}}}`.
|
||||
@@ -16,11 +16,13 @@ mod clipboard;
|
||||
mod control_server;
|
||||
mod default_apps;
|
||||
mod file_dialogs;
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
#[cfg(any(target_os = "windows", target_os = "macos", test))]
|
||||
mod graphics;
|
||||
mod logging;
|
||||
mod print;
|
||||
mod region;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod traffic_lights;
|
||||
|
||||
use wordcraft_engine::Session;
|
||||
use wordcraft_ui_egui::{
|
||||
@@ -28,9 +30,9 @@ use wordcraft_ui_egui::{
|
||||
window_geometry::{WindowGeometry, take_rescue},
|
||||
};
|
||||
|
||||
/// The app, the restored window geometry until the first frame has checked it, the control
|
||||
/// server's key file (removed on exit), the file dialogs the app asked for and, on macOS, the
|
||||
/// documents opened from Finder.
|
||||
/// The app, the restored window geometry (or, with none, the default: no position) until the
|
||||
/// first frame that knows the monitor has checked it, the control server's key file (removed on
|
||||
/// exit), the file dialogs the app asked for and, on macOS, the documents opened from Finder.
|
||||
struct App(
|
||||
WordApp,
|
||||
Option<WindowGeometry>,
|
||||
@@ -45,11 +47,24 @@ impl eframe::App for App {
|
||||
apple_events::poll(&self.4, &mut self.0, ctx);
|
||||
self.0.logic(ctx);
|
||||
self.3.show(frame);
|
||||
// A window that doesn't fit on its screen (the default size on a laptop) shrinks to fit
|
||||
// there, so all of the scroll bar can be dragged (#1215). Checked on the frame that first
|
||||
// knows the monitor: the off-screen check below takes the pending geometry then.
|
||||
if let Some((pos, size)) = ctx.input(|i| self.1.and_then(|g| g.fit_on_monitor(i.viewport()))) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(size));
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos));
|
||||
}
|
||||
let prev = self.0.ui.window;
|
||||
self.0.ui.window = ctx.input(|i| WindowGeometry::track(prev, i.viewport(), i.viewport_rect().size()));
|
||||
// The first frame, once the monitor is known (as `take_rescue` waits for): maximize a
|
||||
// window that opened at its normal size so it never flashed on screen half-opened.
|
||||
let maximize = self.1.and_then(|g| g.maximize_after_open()).filter(|_| ctx.input(|i| i.viewport().monitor_size.is_some()));
|
||||
if let Some(pos) = ctx.input(|i| take_rescue(&mut self.1, i.viewport())) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos));
|
||||
}
|
||||
if let Some(command) = maximize {
|
||||
ctx.send_viewport_cmd(command);
|
||||
}
|
||||
// The window's close button: with unsaved changes, ask first (`WordApp::close_requested`).
|
||||
let closing = ctx.input(|i| i.viewport().close_requested());
|
||||
if closing && !self.0.close_requested() {
|
||||
@@ -189,15 +204,35 @@ fn save_prefs(app: &WordApp) {
|
||||
return;
|
||||
}
|
||||
if let Some(p) = prefs_path() {
|
||||
if let Some(dir) = p.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
if let Some(dir) = p.parent()
|
||||
&& let Err(e) = std::fs::create_dir_all(dir)
|
||||
{
|
||||
log::warn!("preferences: cannot create {}: {e}", dir.display());
|
||||
return;
|
||||
}
|
||||
if let Ok(bytes) = serde_json::to_vec_pretty(&app.prefs()) {
|
||||
let _ = std::fs::write(&p, bytes);
|
||||
match serde_json::to_vec_pretty(&app.prefs()) {
|
||||
Ok(bytes) => {
|
||||
if let Err(e) = write_prefs(&p, &bytes) {
|
||||
log::warn!("preferences: cannot save {}: {e}", p.display());
|
||||
}
|
||||
}
|
||||
Err(e) => log::warn!("preferences: cannot serialize: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes `bytes` to a sibling temporary file and renames it over `path`, so a failed or
|
||||
/// interrupted write leaves the previous preferences in place.
|
||||
fn write_prefs(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "ui.json".into());
|
||||
let tmp = path.with_file_name(format!(".{name}.{}.tmp", std::process::id()));
|
||||
let written = std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, path));
|
||||
if written.is_err() {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
}
|
||||
written
|
||||
}
|
||||
|
||||
/// Native file dialogs, shown without blocking the window (`file_dialogs`, #94), File › Print
|
||||
/// through the system spooler (`print`), the system clipboard's text, formatted copies,
|
||||
/// pictures and format names (`clipboard`, #45, #584, #687, #833), the system's animation setting
|
||||
@@ -228,11 +263,59 @@ fn app_icon() -> Option<egui::IconData> {
|
||||
eframe::icon_data::from_png_bytes(png).map_err(|e| log::warn!("app icon: {e}")).ok()
|
||||
}
|
||||
|
||||
const USAGE: &str = "\
|
||||
wordcraft — the WordCraft desktop app
|
||||
|
||||
USAGE:
|
||||
wordcraft [--sample] [--control PORT] [FILE…]
|
||||
|
||||
OPTIONS:
|
||||
--sample open the sample document
|
||||
--control PORT start the localhost control server on PORT, for agents and scripts
|
||||
(or set WORDCRAFT_CONTROL_PORT); see docs/control-protocol.md
|
||||
--version print the version
|
||||
-h, --help print this help
|
||||
";
|
||||
|
||||
/// The commit the build came from (short), or `dev`.
|
||||
fn build_sha() -> &'static str {
|
||||
option_env!("WORDCRAFT_BUILD_SHA").map(|s| s.get(..8).unwrap_or(s)).unwrap_or("dev")
|
||||
}
|
||||
|
||||
/// The arguments as text, and apart the files whose names aren't valid Unicode, such as a Latin-1
|
||||
/// one (#1440). A `--control` port that isn't is an error.
|
||||
fn text_args() -> (Vec<String>, Vec<std::path::PathBuf>) {
|
||||
let (mut args, mut files) = (Vec::new(), Vec::new());
|
||||
for (i, a) in std::env::args_os().skip(1).enumerate() {
|
||||
match a.into_string() {
|
||||
Ok(a) => args.push(a),
|
||||
Err(a) if args.last().is_some_and(|p| p == "--control") => {
|
||||
// Unlike `eprintln!`, never panics.
|
||||
let (n, lossy) = (i + 1, a.to_string_lossy());
|
||||
let _ = std::io::Write::write_fmt(&mut std::io::stderr(), format_args!("wordcraft: argument {n} is not valid Unicode: {lossy}\n"));
|
||||
std::process::exit(2);
|
||||
}
|
||||
Err(a) => files.push(a.into()),
|
||||
}
|
||||
}
|
||||
(args, files)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn open_non_unicode(app: &mut WordApp, path: &std::path::Path) {
|
||||
let name = path.to_string_lossy();
|
||||
let opened = std::fs::read(path)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|bytes| app.run("file.open", serde_json::json!({"path": name, "data": wordcraft_engine::cmd::insert::base64_encode(&bytes)})));
|
||||
match opened {
|
||||
// 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}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
// First, so the panic hook and every start-up warning are recorded (`logging`).
|
||||
let logger = logging::install();
|
||||
@@ -240,18 +323,38 @@ fn main() -> eframe::Result {
|
||||
let mut control_port: Option<u16> = std::env::var("WORDCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
||||
let mut files = Vec::new();
|
||||
let mut sample = false;
|
||||
let mut args = std::env::args().skip(1);
|
||||
let (args, other_files) = text_args();
|
||||
let mut bad_control: Option<Option<String>> = None;
|
||||
let mut args = args.into_iter().peekable();
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
|
||||
// A flag after `--control` stays a flag; a bad or missing port is an error (#1426).
|
||||
"--control" => {
|
||||
let port = args.next_if(|p| p.strip_prefix('-').is_none_or(|n| n.starts_with(|c: char| c.is_ascii_digit())));
|
||||
match port.as_deref().and_then(|p| p.parse::<std::num::NonZeroU16>().ok()) {
|
||||
Some(p) => control_port = Some(p.get()),
|
||||
None => bad_control = Some(port),
|
||||
}
|
||||
}
|
||||
"--sample" => sample = true,
|
||||
"--version" => {
|
||||
println!("wordcraft {} ({})", env!("CARGO_PKG_VERSION"), build_sha());
|
||||
return Ok(());
|
||||
}
|
||||
// Before the window opens (#968). Unlike `print!`, a closed stdout can't panic.
|
||||
"--help" | "-h" => {
|
||||
let _ = std::io::Write::write_all(&mut std::io::stdout(), USAGE.as_bytes());
|
||||
return Ok(());
|
||||
}
|
||||
_ => files.push(a),
|
||||
}
|
||||
}
|
||||
// After the loop, so `--version` later on the line still wins. Unlike `eprintln!`, never panics.
|
||||
if let Some(port) = bad_control {
|
||||
let got = port.map(|p| format!(", got {p:?}")).unwrap_or_default();
|
||||
let _ = std::io::Write::write_fmt(&mut std::io::stderr(), format_args!("wordcraft: --control needs a port number from 1 to 65535{got}\n"));
|
||||
std::process::exit(2);
|
||||
}
|
||||
// The log file lives in the settings directory, next to the preferences; opened after the
|
||||
// arguments, so `--version` leaves no file behind. Records logged until now are written to it
|
||||
// first. Runs without preferences (agents' test runs) log to standard error only, so they
|
||||
@@ -286,6 +389,9 @@ fn main() -> eframe::Result {
|
||||
// FXC, which needs no DLL of ours (#603; see graphics.rs).
|
||||
#[cfg(target_os = "windows")]
|
||||
graphics::configure(&mut options, eframe::wgpu::Backends::from_env());
|
||||
// Intel Macs: Metal's compiler aborts on wgpu's indirect-validation shaders (#2652).
|
||||
#[cfg(target_os = "macos")]
|
||||
graphics::configure_indirect_validation(&mut options, graphics::skips_indirect_validation(true, cfg!(target_arch = "x86_64")));
|
||||
load_user_fonts();
|
||||
let restored = saved_window();
|
||||
if let Some(window) = restored {
|
||||
@@ -328,8 +434,21 @@ fn main() -> eframe::Result {
|
||||
app.session.set_document(doc);
|
||||
}
|
||||
app.watch_desktop_theme(&cc.egui_ctx);
|
||||
if prefs_enabled()
|
||||
&& let Some(dir) = wordcraft_control_key::settings_dir()
|
||||
{
|
||||
let ctx = cc.egui_ctx.clone();
|
||||
if let Err(e) = app.session.enable_recovery(dir.join("recovery"), std::sync::Arc::new(move || ctx.request_repaint())) {
|
||||
log::warn!("local recovery unavailable: {e}");
|
||||
}
|
||||
}
|
||||
app.ui.window = restored;
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(bar) = traffic_lights::title_bar() {
|
||||
log::info!("title bar: {bar:?}");
|
||||
app.mac_titlebar = bar;
|
||||
}
|
||||
let mut key_file = None;
|
||||
if let Some(port) = control_port
|
||||
&& let Some((rx, file)) = control_server::start(port, cc.egui_ctx.clone(), wordcraft_control_key::settings_dir().as_deref())
|
||||
@@ -340,7 +459,7 @@ fn main() -> eframe::Result {
|
||||
// Nothing to open (no files, no sample, no control channel driving the editor): start
|
||||
// on File › Home, as Word does (#766), unless the Start screen option is off. Files
|
||||
// macOS hands over later open on top of it and close it (`apple_events`).
|
||||
if files.is_empty() && !sample && control_port.is_none() {
|
||||
if files.is_empty() && other_files.is_empty() && !sample && control_port.is_none() {
|
||||
app.start_screen();
|
||||
}
|
||||
for f in files {
|
||||
@@ -348,9 +467,13 @@ fn main() -> eframe::Result {
|
||||
log::warn!("{f}: {e}");
|
||||
}
|
||||
}
|
||||
for f in other_files {
|
||||
open_non_unicode(&mut app, &f);
|
||||
}
|
||||
Ok(Box::new(App(
|
||||
app,
|
||||
restored,
|
||||
// With nothing restored, the window the system placed is checked all the same.
|
||||
Some(restored.unwrap_or_default()),
|
||||
key_file,
|
||||
dialogs,
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -359,3 +482,55 @@ fn main() -> eframe::Result {
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// Not on macOS: APFS refuses file names that aren't valid UTF-8, so no such file can exist there.
|
||||
#[cfg(all(test, unix, not(target_os = "macos")))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
|
||||
#[test]
|
||||
fn a_file_whose_name_isnt_unicode_opens_and_saves_back_to_itself() {
|
||||
let dir = std::env::temp_dir().join(format!("wc-app-non-utf8-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join(std::ffi::OsString::from_vec(b"caf\xe9.txt".to_vec()));
|
||||
std::fs::write(&path, "Bonjour").unwrap();
|
||||
|
||||
let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
|
||||
open_non_unicode(&mut app, &path);
|
||||
assert_eq!(app.session.doc.plain_text(wordcraft_doc::StoryRef::Body).trim(), "Bonjour");
|
||||
assert_eq!(app.session.path.as_deref(), Some(path.as_path()));
|
||||
|
||||
app.run("caret.docEnd", serde_json::json!({})).unwrap();
|
||||
app.run("text.insert", serde_json::json!({"text": " encore"})).unwrap();
|
||||
app.run("file.save", serde_json::json!({})).unwrap();
|
||||
assert!(std::fs::read_to_string(&path).unwrap().contains("Bonjour encore"));
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1, "a second file was written");
|
||||
|
||||
// A file that can't be read is a warning, as one that fails to open by name is.
|
||||
open_non_unicode(&mut app, &dir.join(std::ffi::OsString::from_vec(b"gon\xe9.txt".to_vec())));
|
||||
assert_eq!(app.session.path.as_deref(), Some(path.as_path()));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod prefs_tests {
|
||||
use super::write_prefs;
|
||||
|
||||
#[test]
|
||||
fn failed_preferences_write_keeps_the_old_file_and_reports_the_error() {
|
||||
let dir = std::env::temp_dir().join(format!("wordcraft-prefs-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("ui.json");
|
||||
std::fs::write(&path, b"old").unwrap();
|
||||
write_prefs(&path, b"new").unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"new");
|
||||
// A directory squatting on the temporary name makes the preparation step fail.
|
||||
std::fs::create_dir(dir.join(format!(".ui.json.{}.tmp", std::process::id()))).unwrap();
|
||||
assert!(write_prefs(&path, b"newer").is_err());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"new");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ pub fn finish_pending_jobs() {
|
||||
}
|
||||
|
||||
pub fn submit() -> Submit {
|
||||
Box::new(|bytes, settings: Settings, collate| {
|
||||
Box::new(|bytes, settings: Settings, collate, options| {
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
let worker = std::thread::Builder::new().name("print-submit".into()).spawn(move || {
|
||||
let job = craft_print::Job {
|
||||
@@ -41,7 +41,7 @@ pub fn submit() -> Submit {
|
||||
grayscale: settings.grayscale,
|
||||
scaling: craft_print::Scaling::ShrinkToFit,
|
||||
title: "WordCraft document".into(),
|
||||
..Default::default()
|
||||
options,
|
||||
};
|
||||
let _ = sender.send(craft_print::submit(&bytes, &job).map_err(|error| error.to_string()));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
//! Where AppKit puts the traffic lights, so the integrated title bar fits around them (macOS).
|
||||
//!
|
||||
//! The lights' size and position change between macOS versions (they grew in macOS 26), so the
|
||||
//! title bar measures the real buttons instead of assuming the old 28 pt layout. These calls are
|
||||
//! safe in the `objc2-app-kit` bindings (our workspace forbids unsafe).
|
||||
|
||||
use objc2::MainThreadMarker;
|
||||
use objc2_app_kit::{NSApplication, NSWindowButton};
|
||||
use wordcraft_ui_egui::chrome::MacTitleBar;
|
||||
|
||||
/// The title bar around the app window's traffic lights, or `None` before the window exists or
|
||||
/// when AppKit has no zoom button to measure (the caller keeps the standard layout).
|
||||
pub fn title_bar() -> Option<MacTitleBar> {
|
||||
let mtm = MainThreadMarker::new()?;
|
||||
let window = NSApplication::sharedApplication(mtm).windows().firstObject()?;
|
||||
let zoom = window.standardWindowButton(NSWindowButton::ZoomButton)?;
|
||||
// In window coordinates, which count up from the bottom.
|
||||
let frame = zoom.convertRect_toView(zoom.bounds(), None);
|
||||
let window_height = window.frame().size.height;
|
||||
let right = frame.origin.x + frame.size.width;
|
||||
let centre_from_top = window_height - (frame.origin.y + frame.size.height / 2.0);
|
||||
if !(right.is_finite() && centre_from_top.is_finite() && right > 0.0 && centre_from_top > 0.0) {
|
||||
return None;
|
||||
}
|
||||
Some(MacTitleBar::around_lights(right as f32, centre_from_top as f32, frame.size.height as f32))
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
//! `wordcraft --control` checks its port before the window opens (#1426).
|
||||
|
||||
use std::process::{Command, Output, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn app(args: &[&str]) -> Output {
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_wordcraft"))
|
||||
.args(args)
|
||||
// With no display, an app that tries to open a window fails at once instead.
|
||||
.env_remove("DISPLAY")
|
||||
.env_remove("WAYLAND_DISPLAY")
|
||||
.env_remove("WAYLAND_SOCKET")
|
||||
// A bad `--control` must not fall back to this port either.
|
||||
.env("WORDCRAFT_CONTROL_PORT", "7981")
|
||||
.env("WORDCRAFT_NO_PREFS", "1")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
while child.try_wait().unwrap().is_none() {
|
||||
if Instant::now() > deadline {
|
||||
let _ = child.kill();
|
||||
panic!("wordcraft {args:?} was still running after 30 s: it started the app");
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
child.wait_with_output().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_port_is_an_error_naming_it() {
|
||||
let cases: [(&[&str], &str); 8] = [
|
||||
(&["--control", "-99999999999999999999"], ", got \"-99999999999999999999\""),
|
||||
(&["--control", "abc"], ", got \"abc\""),
|
||||
(&["--control", "79811"], ", got \"79811\""),
|
||||
(&["--control", "0"], ", got \"0\""),
|
||||
(&["--control", "-5"], ", got \"-5\""),
|
||||
(&["--control", "abc", "--control", "7981"], ", got \"abc\""),
|
||||
(&["--control"], ""),
|
||||
(&["--control", "--sample"], ""),
|
||||
];
|
||||
for (args, got) in cases {
|
||||
let out = app(args);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert_eq!(out.status.code(), Some(2), "{args:?}: stderr: {stderr}");
|
||||
assert_eq!(stderr, format!("wordcraft: --control needs a port number from 1 to 65535{got}\n"), "{args:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flag_after_control_is_read_as_a_flag() {
|
||||
let out = app(&["--control", "--version"]);
|
||||
let (stdout, stderr) = (String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
|
||||
assert!(out.status.success(), "status {:?}, stderr: {stderr}", out.status);
|
||||
assert!(stdout.starts_with("wordcraft "), "stdout: {stdout}");
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//! `wordcraft --help` and `-h` print the options and exit without opening a window (#968).
|
||||
|
||||
use std::process::{Command, Output, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn app(flag: &str) -> Output {
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_wordcraft"))
|
||||
.arg(flag)
|
||||
// With no display, an app that tries to open a window fails at once instead.
|
||||
.env_remove("DISPLAY")
|
||||
.env_remove("WAYLAND_DISPLAY")
|
||||
.env_remove("WORDCRAFT_CONTROL_PORT")
|
||||
.env("WORDCRAFT_NO_PREFS", "1")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
while child.try_wait().unwrap().is_none() {
|
||||
if Instant::now() > deadline {
|
||||
let _ = child.kill();
|
||||
panic!("wordcraft {flag} was still running after 30 s: it started the app");
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
child.wait_with_output().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_and_h_print_the_options_and_exit() {
|
||||
for flag in ["--help", "-h"] {
|
||||
let out = app(flag);
|
||||
let (stdout, stderr) = (String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
|
||||
assert!(out.status.success(), "{flag}: status {:?}, stderr: {stderr}", out.status);
|
||||
assert!(stdout.starts_with("wordcraft — "), "{flag}: stdout: {stdout}");
|
||||
for option in ["--sample", "--control PORT", "--version", "-h, --help"] {
|
||||
assert!(stdout.contains(option), "{flag}: {option} missing from: {stdout}");
|
||||
}
|
||||
assert!(stderr.is_empty(), "{flag}: stderr: {stderr}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//! An argument that isn't valid Unicode, such as a Latin-1 file name, never panics the app (#1440).
|
||||
#![cfg(unix)]
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
use std::process::{Command, Output, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn app(args: &[OsString]) -> Output {
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_wordcraft"))
|
||||
.args(args)
|
||||
// With no display, an app that tries to open a window fails at once instead.
|
||||
.env_remove("DISPLAY")
|
||||
.env_remove("WAYLAND_DISPLAY")
|
||||
.env_remove("WAYLAND_SOCKET")
|
||||
.env_remove("WORDCRAFT_CONTROL_PORT")
|
||||
.env("WORDCRAFT_NO_PREFS", "1")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
while child.try_wait().unwrap().is_none() {
|
||||
if Instant::now() > deadline {
|
||||
let _ = child.kill();
|
||||
panic!("wordcraft {args:?} was still running after 30 s: it started the app");
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
child.wait_with_output().unwrap()
|
||||
}
|
||||
|
||||
/// The file opens once the window is up (`open_non_unicode`, unit-tested in `main.rs`). Not on
|
||||
/// macOS, where the app opens its window without a display variable and so keeps running.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
#[test]
|
||||
fn a_file_with_such_a_name_starts_the_app() {
|
||||
let out = app(&["--sample".into(), OsString::from_vec(b"caf\xe9.docx".to_vec())]);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(!stderr.contains("panic"), "stderr: {stderr}");
|
||||
// It went on to open the window, which fails without a display.
|
||||
assert!(stderr.contains("WinitEventLoop"), "stderr: {stderr}");
|
||||
assert_eq!(out.status.code(), Some(1), "stderr: {stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_control_port_that_isnt_unicode_is_an_error_naming_it() {
|
||||
let out = app(&["--sample".into(), "--control".into(), OsString::from_vec(b"79\xe981".to_vec())]);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert_eq!(out.status.code(), Some(2), "stderr: {stderr}");
|
||||
assert_eq!(stderr, "wordcraft: argument 3 is not valid Unicode: 79\u{FFFD}81\n");
|
||||
}
|
||||
@@ -26,7 +26,12 @@ chatroom
|
||||
checkbox
|
||||
checklist
|
||||
childcare
|
||||
clamour
|
||||
clickable
|
||||
colourful
|
||||
colourisation
|
||||
colourise
|
||||
colourless
|
||||
configurable
|
||||
copyedit
|
||||
coronavirus
|
||||
@@ -54,6 +59,7 @@ ecommerce
|
||||
email
|
||||
emoji
|
||||
emoticon
|
||||
endeavour
|
||||
esports
|
||||
facebook
|
||||
favicon
|
||||
@@ -71,6 +77,7 @@ geolocation
|
||||
gigabit
|
||||
gigabyte
|
||||
github
|
||||
glamour
|
||||
globalization
|
||||
google
|
||||
groupthink
|
||||
@@ -80,6 +87,9 @@ hashtag
|
||||
healthcare
|
||||
homepage
|
||||
homeschool
|
||||
honour
|
||||
honourable
|
||||
honourably
|
||||
hotspot
|
||||
hyperlink
|
||||
hypertext
|
||||
@@ -106,6 +116,7 @@ logout
|
||||
lowercase
|
||||
makeover
|
||||
malware
|
||||
manoeuvrable
|
||||
megabyte
|
||||
megapixel
|
||||
meme
|
||||
@@ -127,6 +138,7 @@ onboard
|
||||
online
|
||||
onscreen
|
||||
outsource
|
||||
paediatric
|
||||
paralegal
|
||||
passcode
|
||||
petabyte
|
||||
@@ -139,10 +151,13 @@ podcast
|
||||
popup
|
||||
prequel
|
||||
printout
|
||||
prioritise
|
||||
prioritization
|
||||
prioritize
|
||||
privatise
|
||||
ransomware
|
||||
recyclable
|
||||
rigour
|
||||
ringtone
|
||||
roadmap
|
||||
rollout
|
||||
@@ -171,6 +186,7 @@ subfolder
|
||||
sunscreen
|
||||
superfood
|
||||
superuser
|
||||
synthesise
|
||||
tagline
|
||||
takeout
|
||||
techie
|
||||
@@ -188,6 +204,7 @@ touchpad
|
||||
touchscreen
|
||||
trackball
|
||||
trackpad
|
||||
travelogue
|
||||
upcycle
|
||||
upload
|
||||
uppercase
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
# 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
|
||||
онлайн
|
||||
офлайн
|
||||
оффлайн
|
||||
медиа
|
||||
демо
|
||||
франчайзи
|
||||
киноа
|
||||
смс
|
||||
пн
|
||||
вт
|
||||
ср
|
||||
чт
|
||||
пт
|
||||
сб
|
||||
вс
|
||||
@@ -17,6 +17,10 @@
|
||||
# To remove your names, delete your entry (or set fields empty) in a PR. The next
|
||||
# contributors.py run, and the next build, drop them.
|
||||
|
||||
[people.divyanshu2074]
|
||||
real_name = "Divyanshu Gupta"
|
||||
sync_github_name = true
|
||||
|
||||
[people.lokinmodar]
|
||||
sync_github_name = true
|
||||
|
||||
@@ -60,6 +64,8 @@ display_name = "Ufuk Efe"
|
||||
sync_github_name = true
|
||||
|
||||
[people.GhostMitra]
|
||||
sync_github_name = true
|
||||
|
||||
[people.DevCoderMax]
|
||||
real_name = "Gleyson do Nascimento Carvalho"
|
||||
display_name = "Gleyson Carvalho"
|
||||
@@ -76,6 +82,87 @@ real_name = "Manuel Carmona"
|
||||
display_name = "manucarcor"
|
||||
[people.odedigo]
|
||||
|
||||
[people.neupaneprashant]
|
||||
real_name = "Prashant Neupane"
|
||||
[people.cyril265]
|
||||
display_name = "cyril265"
|
||||
|
||||
[people.webdevtodayjason]
|
||||
display_name = "Jason Brashear"
|
||||
sync_github_name = false
|
||||
|
||||
[people.lupant]
|
||||
sync_github_name = true
|
||||
[people.Nael-Nathanael]
|
||||
[people.Losminek]
|
||||
[people.Nghiathan13]
|
||||
|
||||
[people.leoncral]
|
||||
sync_github_name = true
|
||||
|
||||
[people.ms-youssef]
|
||||
sync_github_name = true
|
||||
|
||||
[people.finwarman]
|
||||
real_name = "Fin Warman"
|
||||
|
||||
|
||||
[people.albayazid]
|
||||
real_name = "Sazid Al Bayazid"
|
||||
|
||||
[people.switch87]
|
||||
real_name = "Gert Pellin"
|
||||
display_name = "Gert Pellin"
|
||||
|
||||
[people.tlomts-dev]
|
||||
display_name = "The Legend Of MTS Dev"
|
||||
|
||||
[people.dotCooCoo]
|
||||
real_name = "Robert Lee"
|
||||
sync_github_name = true
|
||||
|
||||
[people.rbr48]
|
||||
real_name = "M S Rana"
|
||||
display_name = "Izhaan Intellect"
|
||||
|
||||
|
||||
|
||||
[people.holmbom]
|
||||
real_name = "Erik Holmbom"
|
||||
display_name = "Erik Holmbom"
|
||||
sync_github_name = false
|
||||
|
||||
[people.andrejskvorc]
|
||||
real_name = "Andrej Škvorc"
|
||||
display_name = "Andrej Škvorc"
|
||||
sync_github_name = false
|
||||
|
||||
[people.olekkut]
|
||||
real_name = "Aleksander Kutycki"
|
||||
display_name = "Aleksander Kutycki"
|
||||
sync_github_name = true
|
||||
|
||||
[people.marcoarnulfo]
|
||||
real_name = "Marco Arnulfo"
|
||||
display_name = "Marco Arnulfo"
|
||||
sync_github_name = false
|
||||
|
||||
[people.rem5411]
|
||||
real_name = "Vivien Janny"
|
||||
sync_github_name = true
|
||||
|
||||
[people.C00kieMonsta]
|
||||
real_name = "Antoine Boxho"
|
||||
sync_github_name = true
|
||||
|
||||
[people.matteo-andreuzza]
|
||||
sync_github_name = true
|
||||
|
||||
[people.lucAmbr0]
|
||||
sync_github_name = true
|
||||
|
||||
[people.maxm11]
|
||||
real_name = "Max Murphy"
|
||||
|
||||
[people.SlavenDj]
|
||||
sync_github_name = true
|
||||
|
||||
@@ -6,7 +6,11 @@ use std::path::{Path, PathBuf};
|
||||
/// The app's settings folder (preferences, logs, key files): macOS
|
||||
/// `~/Library/Application Support/WordCraft`, Windows `%APPDATA%\WordCraft`, otherwise
|
||||
/// `$XDG_CONFIG_HOME/wordcraft` or `~/.config/wordcraft`.
|
||||
/// `WORDCRAFT_PROFILE_DIR` overrides the entire folder for isolated runs.
|
||||
pub fn settings_dir() -> Option<PathBuf> {
|
||||
if let Some(path) = std::env::var_os("WORDCRAFT_PROFILE_DIR").filter(|p| !p.is_empty()) {
|
||||
return Some(PathBuf::from(path));
|
||||
}
|
||||
if cfg!(target_os = "macos") {
|
||||
std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library/Application Support/WordCraft"))
|
||||
} else if cfg!(windows) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Connectors and the shape properties beyond fill and outline: a shape's drawing id, its text
|
||||
//! warp, and a connector's ends.
|
||||
//! warp, a rounded rectangle's corner, and a connector's ends.
|
||||
//!
|
||||
//! A connector (DrawingML `straightConnector1`, `bentConnector3`, `curvedConnector3`) is a line
|
||||
//! from its frame's top-left to its bottom-right corner (flips swap the corners). Each end may be
|
||||
@@ -34,6 +34,13 @@ pub struct ShapeExtra {
|
||||
pub arrow_start: bool,
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
pub arrow_end: bool,
|
||||
/// 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")]
|
||||
pub corner: Option<u32>,
|
||||
/// The outline dash pattern; absent in older saved documents means solid.
|
||||
#[serde(skip_serializing_if = "crate::line_dash::LineDash::is_solid")]
|
||||
pub dash: crate::line_dash::LineDash,
|
||||
}
|
||||
|
||||
fn is_zero(v: &u32) -> bool {
|
||||
@@ -49,6 +56,19 @@ impl ShapeExtra {
|
||||
}
|
||||
}
|
||||
|
||||
/// The largest rounded-rectangle corner: half the shorter side, which makes its ends round.
|
||||
pub const MAX_CORNER: u32 = 50_000;
|
||||
|
||||
/// A rounded rectangle's corner radius without its own: 16% of the shorter side.
|
||||
pub const DEFAULT_CORNER: f32 = 0.16;
|
||||
|
||||
/// The corner radius, in points, of a `w` × `h` rounded rectangle with `corner` (see
|
||||
/// [`ShapeExtra::corner`]).
|
||||
pub fn corner_radius(corner: Option<u32>, w: f32, h: f32) -> f32 {
|
||||
let side = wordcraft_geom::finite(w).min(wordcraft_geom::finite(h)).max(0.0);
|
||||
side * corner.map_or(DEFAULT_CORNER, |c| c.min(MAX_CORNER) as f32 / 100_000.0)
|
||||
}
|
||||
|
||||
/// One glued end of a connector: the drawing id of the shape and its connection site index.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -83,6 +103,8 @@ pub fn sites(kind: ShapeKind, w: f32, h: f32) -> Vec<(f32, f32)> {
|
||||
vec![(cx, 0.0), (cx - dx, cy - dy), (0.0, cy), (cx - dx, cy + dy), (cx, h), (cx + dx, cy + dy), (w, cy), (cx + dx, cy - dy)]
|
||||
}
|
||||
ShapeKind::Triangle => vec![(cx, 0.0), (cx / 2.0, cy), (0.0, h), (cx, h), (w, h), (cx + cx / 2.0, cy)],
|
||||
// The corners, the legs' midpoints and the hypotenuse's.
|
||||
ShapeKind::RightTriangle => vec![(0.0, 0.0), (0.0, cy), (0.0, h), (cx, h), (w, h), (cx, cy)],
|
||||
ShapeKind::Line | ShapeKind::StraightConnector | ShapeKind::ElbowConnector | ShapeKind::CurvedConnector => vec![(0.0, 0.0), (w, h)],
|
||||
_ => vec![(cx, 0.0), (0.0, cy), (cx, h), (w, cy)],
|
||||
}
|
||||
@@ -237,6 +259,18 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_radius_follows_the_shorter_side() {
|
||||
let near = |a: f32, b: f32| (a - b).abs() < 1e-4;
|
||||
assert!(near(corner_radius(None, 200.0, 100.0), 16.0));
|
||||
assert!(near(corner_radius(Some(0), 200.0, 100.0), 0.0));
|
||||
assert!(near(corner_radius(Some(3_565), 100.0, 200.0), 3.565));
|
||||
assert!(near(corner_radius(Some(MAX_CORNER), 200.0, 100.0), 50.0));
|
||||
assert!(near(corner_radius(Some(u32::MAX), 200.0, 100.0), 50.0), "capped at a half-circle end");
|
||||
assert!(near(corner_radius(Some(10_000), f32::NAN, 100.0), 0.0));
|
||||
assert!(near(corner_radius(Some(10_000), -5.0, 100.0), 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_connectors_follow_their_shapes() {
|
||||
let a = ShapeExtra { id: 7, ..Default::default() };
|
||||
|
||||
@@ -242,6 +242,10 @@ pub struct ContentControl {
|
||||
/// The run properties of `w:sdtPr` (`w:rPr`), kept verbatim (XML).
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub rpr_xml: String,
|
||||
/// The same run properties, read: the formatting text typed in place of the placeholder
|
||||
/// takes (ECMA-376 §17.5.2.27). `None` when the control names none.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub run_props: Option<crate::props::CharProps>,
|
||||
/// Other `w:sdtPr` children we don't model, kept verbatim (XML) in file order.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub extra: Vec<String>,
|
||||
@@ -490,6 +494,12 @@ impl Document {
|
||||
out
|
||||
}
|
||||
|
||||
/// How many content controls allow form filling (`w:lock` content editing) across every
|
||||
/// story; 0 means a `w:edit="forms"` document has nothing left editable.
|
||||
pub fn fillable_controls(&self) -> usize {
|
||||
self.control_stories().into_iter().map(|story| self.control_ranges(story).iter().filter(|r| r.control.lock.can_edit()).count()).sum()
|
||||
}
|
||||
|
||||
/// The innermost content control holding `p`.
|
||||
pub fn control_at(&self, p: &Pos) -> Option<ControlRange> {
|
||||
let para = self.para_at(p)?;
|
||||
|
||||
+463
-33
@@ -40,11 +40,76 @@ impl Fragment {
|
||||
}
|
||||
found
|
||||
}
|
||||
/// The equation (its linear format and whether it is a display equation) when the fragment
|
||||
/// is that one equation and nothing else but white space and empty paragraphs: a copy of it
|
||||
/// also goes on the system clipboard in the MathML formats that equation editors read (#1297).
|
||||
pub fn sole_equation(&self) -> Option<(&str, bool)> {
|
||||
let mut found = None;
|
||||
for b in &self.blocks {
|
||||
let Block::Para(p) = b else { return None };
|
||||
for c in p.text.chars().filter(|c| !c.is_whitespace()) {
|
||||
if c != crate::para::OBJ || found.is_some() {
|
||||
return None;
|
||||
}
|
||||
// The paragraph's only visible character, so its first object.
|
||||
match p.objects.first() {
|
||||
Some(InlineObject::Equation { linear, display, .. }) => found = Some((linear.as_str(), *display)),
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
/// The text a copy puts on the clipboard: [`Self::plain_text`] with symbol-font characters
|
||||
/// (Wingdings…) as their Unicode equivalents.
|
||||
pub fn clipboard_text(&self) -> String {
|
||||
self.to_document().shown_text(crate::StoryRef::Body)
|
||||
}
|
||||
/// Add `piece` at the end, followed by a paragraph mark unless it already ends with one: how
|
||||
/// Word's Spike collects what it cuts. The piece's text box stories get ids after this
|
||||
/// fragment's, so pieces copied from different documents (or after an id was freed and reused)
|
||||
/// never share one.
|
||||
pub fn append_piece(&mut self, mut piece: Fragment) {
|
||||
let Some(last) = piece.blocks.last() else { return };
|
||||
let ends_with_mark = piece.blocks.len() >= 2 && matches!(last, Block::Para(p) if p.is_empty());
|
||||
if !ends_with_mark {
|
||||
let mut mark = Paragraph::new();
|
||||
if let Block::Para(p) = last {
|
||||
mark.props = p.props.clone();
|
||||
}
|
||||
piece.blocks.push(Block::Para(mark));
|
||||
}
|
||||
let next = self.parts.keys().next_back().map_or(1, |k| k.saturating_add(1));
|
||||
let ids: BTreeMap<u32, u32> = piece.parts.keys().zip(next..=u32::MAX).map(|(old, new)| (*old, new)).collect();
|
||||
let renumber = |b: &mut Block| {
|
||||
each_para_mut(b, 0, &mut |p| {
|
||||
let mut changed = false;
|
||||
for o in p.objects.iter_mut() {
|
||||
for slot in o.text_box_slots() {
|
||||
if let Some(new) = slot.and_then(|old| ids.get(&old)) {
|
||||
*slot = Some(*new);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
p.touch();
|
||||
}
|
||||
})
|
||||
};
|
||||
piece.blocks.iter_mut().for_each(renumber);
|
||||
for (old, mut part) in std::mem::take(&mut piece.parts) {
|
||||
part.blocks.iter_mut().map(Arc::make_mut).for_each(renumber);
|
||||
if let Some(new) = ids.get(&old) {
|
||||
self.parts.insert(*new, part);
|
||||
}
|
||||
}
|
||||
// The empty paragraph after the last mark is where the next piece starts.
|
||||
if self.blocks.len() >= 2 && matches!(self.blocks.last(), Some(Block::Para(p)) if p.is_empty()) {
|
||||
self.blocks.pop();
|
||||
}
|
||||
self.blocks.append(&mut piece.blocks);
|
||||
}
|
||||
pub fn from_text(s: &str) -> Fragment {
|
||||
let lines: Vec<&str> = s.split('\n').collect();
|
||||
Fragment {
|
||||
@@ -54,6 +119,19 @@ impl Fragment {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether block `b`, in a block list the paragraph walk reaches at `depth`, has cells the walk
|
||||
/// skips ([`crate::PARA_WALK_DEPTH`]). Bounded by that depth.
|
||||
fn deeper_than_walk(b: &Block, depth: usize) -> bool {
|
||||
match b {
|
||||
Block::Para(_) => false,
|
||||
Block::Table(t) => t
|
||||
.rows
|
||||
.iter()
|
||||
.flat_map(|r| &r.cells)
|
||||
.any(|c| depth + 1 > crate::PARA_WALK_DEPTH || c.blocks.iter().any(|x| deeper_than_walk(x, depth + 1))),
|
||||
}
|
||||
}
|
||||
|
||||
fn order(a: &Pos, b: &Pos) -> (Pos, Pos) {
|
||||
if a <= b { (a.clone(), b.clone()) } else { (b.clone(), a.clone()) }
|
||||
}
|
||||
@@ -63,37 +141,46 @@ const MAX_BOX_DEPTH: usize = 8;
|
||||
/// Most text box stories one paste may create (hostile documents can fan out).
|
||||
const MAX_PASTED_BOXES: usize = 4096;
|
||||
|
||||
/// Call `f` on every paragraph of a block (tables descended, depth-limited).
|
||||
pub(crate) fn each_para<'a>(b: &'a Block, depth: usize, f: &mut dyn FnMut(&'a Paragraph)) {
|
||||
match b {
|
||||
Block::Para(p) => f(p),
|
||||
Block::Table(t) if depth < 16 => {
|
||||
for cell in t.rows.iter().flat_map(|r| r.cells.iter()) {
|
||||
for cb in &cell.blocks {
|
||||
each_para(cb, depth + 1, f);
|
||||
}
|
||||
/// Call `f` on every paragraph of a block, in document order, however deep its tables nest. The
|
||||
/// walk keeps its own stack instead of recursing, so it is complete: a text box it doesn't reach
|
||||
/// is shown nowhere, which pruning relies on (#1339). `_depth` is unused (callers keep passing it).
|
||||
pub(crate) fn each_para<'a>(b: &'a Block, _depth: usize, f: &mut dyn FnMut(&'a Paragraph)) {
|
||||
// Most blocks are paragraphs, which need no stack.
|
||||
if let Block::Para(p) = b {
|
||||
return f(p);
|
||||
}
|
||||
let mut todo = vec![b];
|
||||
while let Some(b) = todo.pop() {
|
||||
match b {
|
||||
Block::Para(p) => f(p),
|
||||
Block::Table(t) => {
|
||||
// Last first, so they come off the stack in document order.
|
||||
let cells = t.rows.iter().rev().flat_map(|r| r.cells.iter().rev());
|
||||
todo.extend(cells.flat_map(|c| c.blocks.iter().rev()).map(|cb| &**cb));
|
||||
}
|
||||
}
|
||||
Block::Table(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn each_para_mut(b: &mut Block, depth: usize, f: &mut dyn FnMut(&mut Paragraph)) {
|
||||
match b {
|
||||
Block::Para(p) => f(p),
|
||||
Block::Table(t) if depth < 16 => {
|
||||
for cell in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
|
||||
for cb in &mut cell.blocks {
|
||||
each_para_mut(Arc::make_mut(cb), depth + 1, f);
|
||||
}
|
||||
/// [`each_para`], changing the paragraphs.
|
||||
pub(crate) fn each_para_mut(b: &mut Block, _depth: usize, f: &mut dyn FnMut(&mut Paragraph)) {
|
||||
if let Block::Para(p) = b {
|
||||
return f(p);
|
||||
}
|
||||
let mut todo = vec![b];
|
||||
while let Some(b) = todo.pop() {
|
||||
match b {
|
||||
Block::Para(p) => f(p),
|
||||
Block::Table(t) => {
|
||||
let cells = t.rows.iter_mut().rev().flat_map(|r| r.cells.iter_mut().rev());
|
||||
todo.extend(cells.flat_map(|c| c.blocks.iter_mut().rev()).map(Arc::make_mut));
|
||||
}
|
||||
}
|
||||
Block::Table(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a paragraph anywhere in `b` (also inside tables) uses paragraph or character style
|
||||
/// `id` (depth-limited like `each_para`). Table styles are the engine's `table.deleteStyle`'s job.
|
||||
/// `id` (tables to 16 levels deep). Table styles are the engine's `table.deleteStyle`'s job.
|
||||
fn uses_style(b: &Block, id: &str, depth: usize) -> bool {
|
||||
let is = |s: &Option<String>| s.as_deref() == Some(id);
|
||||
match b {
|
||||
@@ -305,10 +392,16 @@ impl Document {
|
||||
if a.story != b.story {
|
||||
return Vec::new();
|
||||
}
|
||||
self.para_paths(a.story).into_iter().filter(|p| *p >= a.path && *p <= b.path).collect()
|
||||
// Only the blocks the range reaches are walked (not every paragraph of the story).
|
||||
let mut out = Vec::new();
|
||||
if let Some(bl) = self.story(a.story) {
|
||||
crate::walk_between(bl, &mut Vec::new(), &mut out, 0, (&a.path.0, &b.path.0));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Delete `a..b`. Returns the position where the deletion collapsed to.
|
||||
/// Delete `a..b`. Returns the position where the deletion collapsed to. On error the document
|
||||
/// is unchanged: both ends are checked before anything is deleted.
|
||||
pub fn delete_range(&mut self, a: &Pos, b: &Pos) -> Result<Pos> {
|
||||
let (a, b) = order(a, b);
|
||||
if a == b {
|
||||
@@ -317,6 +410,14 @@ impl Document {
|
||||
if a.story != b.story {
|
||||
return Err(DocError::Invalid("range spans stories".into()));
|
||||
}
|
||||
// Check both ends before the first edit, so an error never leaves the document half-deleted.
|
||||
for p in [&a, &b] {
|
||||
match self.para(p.story, &p.path) {
|
||||
Some(para) if para.clamp(p.off) == p.off => {}
|
||||
Some(_) => return Err(DocError::BadOffset(p.off)),
|
||||
None => return Err(DocError::BadPath(p.path.to_string())),
|
||||
}
|
||||
}
|
||||
// Content control markers whose partner is outside the range stay (the control is
|
||||
// emptied, not broken).
|
||||
let kept = self.kept_control_markers(&a, &b);
|
||||
@@ -354,18 +455,42 @@ impl Document {
|
||||
pb.clone()
|
||||
};
|
||||
let bl = self.container_mut(a.story, &a.path)?;
|
||||
// Section breaks on the marks in between go with their paragraphs.
|
||||
let between: Vec<_> = bl
|
||||
.get(ia + 1..ib)
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter_map(|b| match &**b {
|
||||
Block::Para(p) => p.section.clone(),
|
||||
Block::Table(_) => None,
|
||||
})
|
||||
.collect();
|
||||
if ib < bl.len() && ia < ib {
|
||||
bl.drain(ia + 1..=ib);
|
||||
}
|
||||
tail.props = ParaProps::default();
|
||||
// The deleted marks include `a`'s, so a section break there goes too: the joined
|
||||
// paragraph ends where `b`'s mark did, and its text takes the following section's
|
||||
// setup, as in Word (#2127).
|
||||
let sect = tail.section.take();
|
||||
let pa = self.para_mut(a.story, &a.path)?;
|
||||
let keep_props = pa.props.clone();
|
||||
pa.append(tail);
|
||||
pa.props = keep_props;
|
||||
let before = std::mem::replace(&mut pa.section, sect);
|
||||
if a.story == crate::StoryRef::Body && a.path.0.len() == 1 {
|
||||
// Nearest first, as "link to previous" resolved them before the delete.
|
||||
let following = self.section_mut(a.path.last());
|
||||
for s in between.iter().rev().chain(before.iter()) {
|
||||
following.absorb(s);
|
||||
}
|
||||
}
|
||||
return Ok(a);
|
||||
}
|
||||
// Different containers (range enters or leaves a table): clear text in every paragraph
|
||||
// inside; remove whole tables that are entirely covered when at the same level as `a`.
|
||||
// inside, then remove the tables it covers whole (below).
|
||||
// Whether `b` is at its paragraph's end, before that paragraph is trimmed.
|
||||
let b_at_end = self.para(b.story, &b.path).is_some_and(|p| p.len() == b.off);
|
||||
let paths = self.paths_between(&a, &b);
|
||||
for p in paths.iter().rev() {
|
||||
let (from, to) = {
|
||||
@@ -376,20 +501,68 @@ impl Document {
|
||||
};
|
||||
self.para_mut(a.story, p)?.delete_except(from, to, &kept_in(p))?;
|
||||
}
|
||||
// Drop top-level tables strictly between a and b at a's level.
|
||||
if a.path.depth() == 0 && b.path.depth() > 0 {
|
||||
let first_b = b.path.0.first().copied().unwrap_or(0) as usize;
|
||||
let ia = a.path.last();
|
||||
let bl = self.container_mut(a.story, &a.path)?;
|
||||
let mut i = first_b;
|
||||
while i > ia + 1 {
|
||||
i -= 1;
|
||||
// Tables the range covers whole, in the innermost container holding both ends, go (#1198,
|
||||
// #1844): those strictly between the ends, the one `a` is in when it starts at that
|
||||
// table's very start, and the one `b` is in when it ends at that table's very end. A range
|
||||
// that is one table and nothing else only empties it (Word's Delete keeps the table).
|
||||
let common = a.path.0.iter().zip(&b.path.0).take_while(|(x, y)| x == y).count();
|
||||
let level = common - common % 3;
|
||||
let (Some(&ia), Some(&ib)) = (a.path.0.get(level), b.path.0.get(level)) else { return Ok(a) };
|
||||
let (ia, ib) = (ia as usize, ib as usize);
|
||||
if ia >= ib {
|
||||
return Ok(a);
|
||||
}
|
||||
let prefix = a.path.0.get(..level).unwrap_or_default().to_vec();
|
||||
let block_path = |i: usize| {
|
||||
let mut v = prefix.clone();
|
||||
v.push(i as u32);
|
||||
Path(v)
|
||||
};
|
||||
// Paragraphs inside the block at index `i` of the container, in document order.
|
||||
let paths = self.para_paths(a.story);
|
||||
let inside = |i: usize| -> Vec<&Path> {
|
||||
let pre = block_path(i);
|
||||
paths.iter().filter(|q| q.0.len() > pre.0.len() && q.0.starts_with(&pre.0)).collect()
|
||||
};
|
||||
// A table with cells deeper than the paragraph walk goes has paragraphs `inside` never
|
||||
// lists, so where it starts or ends isn't known: it is never taken as covered.
|
||||
let walked = |i: usize| self.container(a.story, &block_path(i)).and_then(|bl| bl.get(i)).is_some_and(|b| !deeper_than_walk(b, level / 3));
|
||||
let a_covers = a.path.0.len() > level + 1 && a.off == 0 && walked(ia) && inside(ia).first() == Some(&&a.path);
|
||||
let b_covers = b.path.0.len() > level + 1 && walked(ib) && inside(ib).last() == Some(&&b.path) && b_at_end;
|
||||
let first = if a_covers { ia } else { ia + 1 };
|
||||
let last = if b_covers { ib } else { ib - 1 };
|
||||
let mut removed = 0;
|
||||
{
|
||||
let bl = self.container_mut(a.story, &block_path(ia))?;
|
||||
for i in (first..=last.min(bl.len().saturating_sub(1))).rev() {
|
||||
if matches!(bl.get(i).map(|b| &**b), Some(Block::Table(_))) {
|
||||
bl.remove(i);
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
if removed == 0 {
|
||||
return Ok(a);
|
||||
}
|
||||
// A story or cell always ends with a paragraph.
|
||||
if bl.is_empty() || matches!(bl.last().map(|b| &**b), Some(Block::Table(_))) {
|
||||
bl.push(para_block(Paragraph::new()));
|
||||
}
|
||||
}
|
||||
Ok(a)
|
||||
// The paragraphs left between the ends join, as in a range within one container.
|
||||
let at = if a_covers { Pos { story: a.story, path: block_path(ia), off: 0 } } else { a.clone() };
|
||||
let end = if b_covers {
|
||||
Pos { story: a.story, path: block_path((ib + 1).saturating_sub(removed)), off: 0 }
|
||||
} else {
|
||||
Pos { story: a.story, path: block_path(ib.saturating_sub(removed)), off: 0 }
|
||||
};
|
||||
let paras = |p: &Pos| p.path.0.len() == level + 1 && self.para(p.story, &p.path).is_some();
|
||||
if paras(&at) && paras(&end) && at.path <= end.path {
|
||||
return self.delete_range(&at, &end);
|
||||
}
|
||||
if self.para(at.story, &at.path).is_some() {
|
||||
return Ok(at);
|
||||
}
|
||||
Ok(self.clamp(&at))
|
||||
}
|
||||
|
||||
/// Apply `f` to the character formatting of `a..b`. A collapsed range changes nothing (the
|
||||
@@ -554,7 +727,7 @@ impl Document {
|
||||
let body = crate::TextBody { next: None, ..src.body };
|
||||
*budget -= 1;
|
||||
self.adopt_text_boxes(&mut blocks, parts, depth + 1, budget);
|
||||
let id = self.add_part(PartKind::TextBox, blocks.into_iter().map(Arc::new).collect());
|
||||
let id = self.try_add_part(PartKind::TextBox, blocks.into_iter().map(Arc::new).collect()).ok()?;
|
||||
if let Some(p) = self.parts.get_mut(&id) {
|
||||
p.body = body;
|
||||
}
|
||||
@@ -655,6 +828,27 @@ mod tests {
|
||||
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"]);
|
||||
@@ -666,6 +860,30 @@ mod tests {
|
||||
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"]);
|
||||
@@ -674,6 +892,29 @@ mod tests {
|
||||
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"]);
|
||||
@@ -851,6 +1092,42 @@ mod tests {
|
||||
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(&["é"]);
|
||||
@@ -861,4 +1138,157 @@ mod tests {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
+181
-5
@@ -1,6 +1,7 @@
|
||||
//! Windows-1252 (the byte encoding legacy Word text pieces use) and the other single-byte Windows
|
||||
//! code pages RTF files name with `\ansicpg` or a font's `\fcharset` (Cyrillic 1251, Central
|
||||
//! European 1250, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai).
|
||||
//! European 1250, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai), and Mac OS Roman
|
||||
//! (10000, RTF `\mac`).
|
||||
|
||||
/// Windows-1252 code points for 0x80..=0x9F.
|
||||
const CP1252: [u16; 32] = [
|
||||
@@ -125,11 +126,134 @@ const CP1258: [u16; 128] = [
|
||||
0x20AB, 0x00FF,
|
||||
];
|
||||
|
||||
/// Mac OS Roman (code page 10000, RTF `\mac`) code points for 0x80..=0xFF, with the euro sign at
|
||||
/// 0xDB (Mac OS 8.5 and later).
|
||||
const MAC_ROMAN: [u16; 128] = [
|
||||
0x00C4, 0x00C5, 0x00C7, 0x00C9, 0x00D1, 0x00D6, 0x00DC, 0x00E1, 0x00E0, 0x00E2, 0x00E4, 0x00E3, 0x00E5, 0x00E7, 0x00E9, 0x00E8, 0x00EA, 0x00EB,
|
||||
0x00ED, 0x00EC, 0x00EE, 0x00EF, 0x00F1, 0x00F3, 0x00F2, 0x00F4, 0x00F6, 0x00F5, 0x00FA, 0x00F9, 0x00FB, 0x00FC, 0x2020, 0x00B0, 0x00A2, 0x00A3,
|
||||
0x00A7, 0x2022, 0x00B6, 0x00DF, 0x00AE, 0x00A9, 0x2122, 0x00B4, 0x00A8, 0x2260, 0x00C6, 0x00D8, 0x221E, 0x00B1, 0x2264, 0x2265, 0x00A5, 0x00B5,
|
||||
0x2202, 0x2211, 0x220F, 0x03C0, 0x222B, 0x00AA, 0x00BA, 0x03A9, 0x00E6, 0x00F8, 0x00BF, 0x00A1, 0x00AC, 0x221A, 0x0192, 0x2248, 0x2206, 0x00AB,
|
||||
0x00BB, 0x2026, 0x00A0, 0x00C0, 0x00C3, 0x00D5, 0x0152, 0x0153, 0x2013, 0x2014, 0x201C, 0x201D, 0x2018, 0x2019, 0x00F7, 0x25CA, 0x00FF, 0x0178,
|
||||
0x2044, 0x20AC, 0x2039, 0x203A, 0xFB01, 0xFB02, 0x2021, 0x00B7, 0x201A, 0x201E, 0x2030, 0x00C2, 0x00CA, 0x00C1, 0x00CB, 0x00C8, 0x00CD, 0x00CE,
|
||||
0x00CF, 0x00CC, 0x00D3, 0x00D4, 0xF8FF, 0x00D2, 0x00DA, 0x00DB, 0x00D9, 0x0131, 0x02C6, 0x02DC, 0x00AF, 0x02D8, 0x02D9, 0x02DA, 0x00B8, 0x02DD,
|
||||
0x02DB, 0x02C7,
|
||||
];
|
||||
/// IBM PC (OEM) code page 437 code points for 0x80..=0xFF (the Unicode Consortium's CP437
|
||||
/// mapping file).
|
||||
const CP437: [u16; 128] = [
|
||||
0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x00E0, 0x00E5, 0x00E7, 0x00EA, 0x00EB, 0x00E8, 0x00EF, 0x00EE, 0x00EC, 0x00C4, 0x00C5, 0x00C9, 0x00E6,
|
||||
0x00C6, 0x00F4, 0x00F6, 0x00F2, 0x00FB, 0x00F9, 0x00FF, 0x00D6, 0x00DC, 0x00A2, 0x00A3, 0x00A5, 0x20A7, 0x0192, 0x00E1, 0x00ED, 0x00F3, 0x00FA,
|
||||
0x00F1, 0x00D1, 0x00AA, 0x00BA, 0x00BF, 0x2310, 0x00AC, 0x00BD, 0x00BC, 0x00A1, 0x00AB, 0x00BB, 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561,
|
||||
0x2562, 0x2556, 0x2555, 0x2563, 0x2551, 0x2557, 0x255D, 0x255C, 0x255B, 0x2510, 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x255E, 0x255F,
|
||||
0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x2567, 0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256B, 0x256A, 0x2518,
|
||||
0x250C, 0x2588, 0x2584, 0x258C, 0x2590, 0x2580, 0x03B1, 0x00DF, 0x0393, 0x03C0, 0x03A3, 0x03C3, 0x00B5, 0x03C4, 0x03A6, 0x0398, 0x03A9, 0x03B4,
|
||||
0x221E, 0x03C6, 0x03B5, 0x2229, 0x2261, 0x00B1, 0x2265, 0x2264, 0x2320, 0x2321, 0x00F7, 0x2248, 0x00B0, 0x2219, 0x00B7, 0x221A, 0x207F, 0x00B2,
|
||||
0x25A0, 0x00A0,
|
||||
];
|
||||
|
||||
/// OEM code page 850 (DOS Latin-1, Western European) code points for 0x80..=0xFF (the Unicode
|
||||
/// Consortium's CP850 mapping file).
|
||||
const CP850: [u16; 128] = [
|
||||
0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x00E0, 0x00E5, 0x00E7, 0x00EA, 0x00EB, 0x00E8, 0x00EF, 0x00EE, 0x00EC, 0x00C4, 0x00C5, 0x00C9, 0x00E6,
|
||||
0x00C6, 0x00F4, 0x00F6, 0x00F2, 0x00FB, 0x00F9, 0x00FF, 0x00D6, 0x00DC, 0x00F8, 0x00A3, 0x00D8, 0x00D7, 0x0192, 0x00E1, 0x00ED, 0x00F3, 0x00FA,
|
||||
0x00F1, 0x00D1, 0x00AA, 0x00BA, 0x00BF, 0x00AE, 0x00AC, 0x00BD, 0x00BC, 0x00A1, 0x00AB, 0x00BB, 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x00C1,
|
||||
0x00C2, 0x00C0, 0x00A9, 0x2563, 0x2551, 0x2557, 0x255D, 0x00A2, 0x00A5, 0x2510, 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x00E3, 0x00C3,
|
||||
0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x00A4, 0x00F0, 0x00D0, 0x00CA, 0x00CB, 0x00C8, 0x0131, 0x00CD, 0x00CE, 0x00CF, 0x2518,
|
||||
0x250C, 0x2588, 0x2584, 0x00A6, 0x00CC, 0x2580, 0x00D3, 0x00DF, 0x00D4, 0x00D2, 0x00F5, 0x00D5, 0x00B5, 0x00FE, 0x00DE, 0x00DA, 0x00DB, 0x00D9,
|
||||
0x00FD, 0x00DD, 0x00AF, 0x00B4, 0x00AD, 0x00B1, 0x2017, 0x00BE, 0x00B6, 0x00A7, 0x00F7, 0x00B8, 0x00B0, 0x00A8, 0x00B7, 0x00B9, 0x00B3, 0x00B2,
|
||||
0x25A0, 0x00A0,
|
||||
];
|
||||
|
||||
/// OEM code page 852 (DOS Latin-2, Central European) code points for 0x80..=0xFF (the Unicode Consortium's CP852
|
||||
/// mapping file).
|
||||
const CP852: [u16; 128] = [
|
||||
0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x016F, 0x0107, 0x00E7, 0x0142, 0x00EB, 0x0150, 0x0151, 0x00EE, 0x0179, 0x00C4, 0x0106, 0x00C9, 0x0139,
|
||||
0x013A, 0x00F4, 0x00F6, 0x013D, 0x013E, 0x015A, 0x015B, 0x00D6, 0x00DC, 0x0164, 0x0165, 0x0141, 0x00D7, 0x010D, 0x00E1, 0x00ED, 0x00F3, 0x00FA,
|
||||
0x0104, 0x0105, 0x017D, 0x017E, 0x0118, 0x0119, 0x00AC, 0x017A, 0x010C, 0x015F, 0x00AB, 0x00BB, 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x00C1,
|
||||
0x00C2, 0x011A, 0x015E, 0x2563, 0x2551, 0x2557, 0x255D, 0x017B, 0x017C, 0x2510, 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x0102, 0x0103,
|
||||
0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x00A4, 0x0111, 0x0110, 0x010E, 0x00CB, 0x010F, 0x0147, 0x00CD, 0x00CE, 0x011B, 0x2518,
|
||||
0x250C, 0x2588, 0x2584, 0x0162, 0x016E, 0x2580, 0x00D3, 0x00DF, 0x00D4, 0x0143, 0x0144, 0x0148, 0x0160, 0x0161, 0x0154, 0x00DA, 0x0155, 0x0170,
|
||||
0x00FD, 0x00DD, 0x0163, 0x00B4, 0x00AD, 0x02DD, 0x02DB, 0x02C7, 0x02D8, 0x00A7, 0x00F7, 0x00B8, 0x00B0, 0x00A8, 0x02D9, 0x0171, 0x0158, 0x0159,
|
||||
0x25A0, 0x00A0,
|
||||
];
|
||||
|
||||
/// OEM code page 866 (DOS Cyrillic) code points for 0x80..=0xFF (the Unicode Consortium's CP866
|
||||
/// mapping file).
|
||||
const CP866: [u16; 128] = [
|
||||
0x0410, 0x0411, 0x0412, 0x0413, 0x0414, 0x0415, 0x0416, 0x0417, 0x0418, 0x0419, 0x041A, 0x041B, 0x041C, 0x041D, 0x041E, 0x041F, 0x0420, 0x0421,
|
||||
0x0422, 0x0423, 0x0424, 0x0425, 0x0426, 0x0427, 0x0428, 0x0429, 0x042A, 0x042B, 0x042C, 0x042D, 0x042E, 0x042F, 0x0430, 0x0431, 0x0432, 0x0433,
|
||||
0x0434, 0x0435, 0x0436, 0x0437, 0x0438, 0x0439, 0x043A, 0x043B, 0x043C, 0x043D, 0x043E, 0x043F, 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561,
|
||||
0x2562, 0x2556, 0x2555, 0x2563, 0x2551, 0x2557, 0x255D, 0x255C, 0x255B, 0x2510, 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x255E, 0x255F,
|
||||
0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x2567, 0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256B, 0x256A, 0x2518,
|
||||
0x250C, 0x2588, 0x2584, 0x258C, 0x2590, 0x2580, 0x0440, 0x0441, 0x0442, 0x0443, 0x0444, 0x0445, 0x0446, 0x0447, 0x0448, 0x0449, 0x044A, 0x044B,
|
||||
0x044C, 0x044D, 0x044E, 0x044F, 0x0401, 0x0451, 0x0404, 0x0454, 0x0407, 0x0457, 0x040E, 0x045E, 0x00B0, 0x2219, 0x00B7, 0x221A, 0x2116, 0x00A4,
|
||||
0x25A0, 0x00A0,
|
||||
];
|
||||
|
||||
/// OEM code page 737 (DOS Greek) code points for 0x80..=0xFF (the Unicode Consortium's CP737
|
||||
/// mapping file).
|
||||
const CP737: [u16; 128] = [
|
||||
0x0391, 0x0392, 0x0393, 0x0394, 0x0395, 0x0396, 0x0397, 0x0398, 0x0399, 0x039A, 0x039B, 0x039C, 0x039D, 0x039E, 0x039F, 0x03A0, 0x03A1, 0x03A3,
|
||||
0x03A4, 0x03A5, 0x03A6, 0x03A7, 0x03A8, 0x03A9, 0x03B1, 0x03B2, 0x03B3, 0x03B4, 0x03B5, 0x03B6, 0x03B7, 0x03B8, 0x03B9, 0x03BA, 0x03BB, 0x03BC,
|
||||
0x03BD, 0x03BE, 0x03BF, 0x03C0, 0x03C1, 0x03C3, 0x03C2, 0x03C4, 0x03C5, 0x03C6, 0x03C7, 0x03C8, 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561,
|
||||
0x2562, 0x2556, 0x2555, 0x2563, 0x2551, 0x2557, 0x255D, 0x255C, 0x255B, 0x2510, 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x255E, 0x255F,
|
||||
0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x2567, 0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256B, 0x256A, 0x2518,
|
||||
0x250C, 0x2588, 0x2584, 0x258C, 0x2590, 0x2580, 0x03C9, 0x03AC, 0x03AD, 0x03AE, 0x03CA, 0x03AF, 0x03CC, 0x03CD, 0x03CB, 0x03CE, 0x0386, 0x0388,
|
||||
0x0389, 0x038A, 0x038C, 0x038E, 0x038F, 0x00B1, 0x2265, 0x2264, 0x03AA, 0x03AB, 0x00F7, 0x2248, 0x00B0, 0x2219, 0x00B7, 0x221A, 0x207F, 0x00B2,
|
||||
0x25A0, 0x00A0,
|
||||
];
|
||||
|
||||
/// OEM code page 857 (DOS Turkish; U+FFFD where the byte is unassigned) code points for 0x80..=0xFF (the Unicode Consortium's CP857
|
||||
/// mapping file).
|
||||
const CP857: [u16; 128] = [
|
||||
0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x00E0, 0x00E5, 0x00E7, 0x00EA, 0x00EB, 0x00E8, 0x00EF, 0x00EE, 0x0131, 0x00C4, 0x00C5, 0x00C9, 0x00E6,
|
||||
0x00C6, 0x00F4, 0x00F6, 0x00F2, 0x00FB, 0x00F9, 0x0130, 0x00D6, 0x00DC, 0x00F8, 0x00A3, 0x00D8, 0x015E, 0x015F, 0x00E1, 0x00ED, 0x00F3, 0x00FA,
|
||||
0x00F1, 0x00D1, 0x011E, 0x011F, 0x00BF, 0x00AE, 0x00AC, 0x00BD, 0x00BC, 0x00A1, 0x00AB, 0x00BB, 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x00C1,
|
||||
0x00C2, 0x00C0, 0x00A9, 0x2563, 0x2551, 0x2557, 0x255D, 0x00A2, 0x00A5, 0x2510, 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x00E3, 0x00C3,
|
||||
0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x00A4, 0x00BA, 0x00AA, 0x00CA, 0x00CB, 0x00C8, 0xFFFD, 0x00CD, 0x00CE, 0x00CF, 0x2518,
|
||||
0x250C, 0x2588, 0x2584, 0x00A6, 0x00CC, 0x2580, 0x00D3, 0x00DF, 0x00D4, 0x00D2, 0x00F5, 0x00D5, 0x00B5, 0xFFFD, 0x00D7, 0x00DA, 0x00DB, 0x00D9,
|
||||
0x00EC, 0x00FF, 0x00AF, 0x00B4, 0x00AD, 0x00B1, 0xFFFD, 0x00BE, 0x00B6, 0x00A7, 0x00F7, 0x00B8, 0x00B0, 0x00A8, 0x00B7, 0x00B9, 0x00B3, 0x00B2,
|
||||
0x25A0, 0x00A0,
|
||||
];
|
||||
|
||||
/// The glyphs the OEM code pages show for 0x01..=0x1F (smileys, card suits, arrows), as
|
||||
/// Windows' Alt+numpad entry produces them rather than the control characters.
|
||||
const OEM_GLYPHS: [u16; 31] = [
|
||||
0x263A, 0x263B, 0x2665, 0x2666, 0x2663, 0x2660, 0x2022, 0x25D8, 0x25CB, 0x25D9, 0x2642, 0x2640, 0x266A, 0x266B, 0x263C, 0x25BA, 0x25C4, 0x2195,
|
||||
0x203C, 0x00B6, 0x00A7, 0x25AC, 0x21A8, 0x2191, 0x2193, 0x2192, 0x2190, 0x221F, 0x2194, 0x25B2, 0x25BC,
|
||||
];
|
||||
|
||||
/// The OEM code pages [`oem_byte`] decodes.
|
||||
pub const OEM_CODE_PAGES: [u16; 6] = [437, 737, 850, 852, 857, 866];
|
||||
|
||||
/// One byte of an OEM code page in [`OEM_CODE_PAGES`] (any other code page reads as 437) as a
|
||||
/// char, with the graphic glyphs for 0x01..=0x1F and 0x7F (⌂); 0x00 stays NUL.
|
||||
pub fn oem_byte(code_page: u16, b: u8) -> char {
|
||||
let table: &[u16; 128] = match code_page {
|
||||
737 => &CP737,
|
||||
850 => &CP850,
|
||||
852 => &CP852,
|
||||
857 => &CP857,
|
||||
866 => &CP866,
|
||||
_ => &CP437,
|
||||
};
|
||||
let u = match b {
|
||||
0x01..=0x1F => OEM_GLYPHS.get(usize::from(b - 1)).copied(),
|
||||
0x7F => Some(0x2302),
|
||||
0x80..=0xFF => table.get(usize::from(b - 0x80)).copied(),
|
||||
_ => Some(u16::from(b)),
|
||||
};
|
||||
u.and_then(|u| char::from_u32(u32::from(u))).unwrap_or('\u{FFFD}')
|
||||
}
|
||||
|
||||
/// The single-byte Windows code pages [`windows_byte`] decodes (1252 included).
|
||||
pub const SINGLE_BYTE_CODE_PAGES: [u16; 10] = [874, 1250, 1251, 1252, 1253, 1254, 1255, 1256, 1257, 1258];
|
||||
|
||||
/// One byte of a single-byte Windows code page as a char; a code page outside
|
||||
/// [`SINGLE_BYTE_CODE_PAGES`] reads as Windows-1252.
|
||||
/// Mac OS Roman, RTF's `\mac` character set.
|
||||
pub const MAC_ROMAN_CODE_PAGE: u16 = 10000;
|
||||
|
||||
/// One byte of a single-byte code page as a char: a Windows one in [`SINGLE_BYTE_CODE_PAGES`] or
|
||||
/// Mac OS Roman ([`MAC_ROMAN_CODE_PAGE`]); any other code page reads as Windows-1252.
|
||||
pub fn windows_byte(code_page: u16, b: u8) -> char {
|
||||
let table: &[u16; 128] = match code_page {
|
||||
874 => &CP874,
|
||||
@@ -141,6 +265,7 @@ pub fn windows_byte(code_page: u16, b: u8) -> char {
|
||||
1256 => &CP1256,
|
||||
1257 => &CP1257,
|
||||
1258 => &CP1258,
|
||||
MAC_ROMAN_CODE_PAGE => &MAC_ROMAN,
|
||||
_ => return cp1252(b),
|
||||
};
|
||||
if b < 0x80 {
|
||||
@@ -149,11 +274,25 @@ pub fn windows_byte(code_page: u16, b: u8) -> char {
|
||||
table.get(usize::from(b - 0x80)).and_then(|u| char::from_u32(u32::from(*u))).unwrap_or('\u{FFFD}')
|
||||
}
|
||||
|
||||
/// Whether [`code_page_byte`] decodes `code_page`: a Windows one ([`SINGLE_BYTE_CODE_PAGES`]), an
|
||||
/// OEM (DOS) one ([`OEM_CODE_PAGES`]) or Mac OS Roman. Document formats (RTF) name all three.
|
||||
pub fn is_byte_code_page(code_page: u16) -> bool {
|
||||
SINGLE_BYTE_CODE_PAGES.contains(&code_page) || OEM_CODE_PAGES.contains(&code_page) || code_page == MAC_ROMAN_CODE_PAGE
|
||||
}
|
||||
|
||||
/// A byte of 8-bit document text in `code_page` (see [`is_byte_code_page`]) as a char. Below 0x80
|
||||
/// it is ASCII in every code page (text, so an OEM page's glyphs for control codes don't apply).
|
||||
pub fn code_page_byte(code_page: u16, b: u8) -> char {
|
||||
if b >= 0x80 && OEM_CODE_PAGES.contains(&code_page) { oem_byte(code_page, b) } else { windows_byte(code_page, b) }
|
||||
}
|
||||
|
||||
/// The code page of an RTF or Word font charset (`\fcharset`): 204 Russian → 1251, 238 Eastern
|
||||
/// European → 1250… `None` for the ANSI and default charsets (the document's code page applies),
|
||||
/// symbol fonts and the double-byte East Asian charsets.
|
||||
/// European → 1250, 77 Mac → 10000, 254 PC / 255 OEM → 437… `None` for the ANSI and default charsets (the
|
||||
/// document's code page applies), symbol fonts and the double-byte East Asian charsets.
|
||||
pub fn charset_code_page(charset: i32) -> Option<u16> {
|
||||
Some(match charset {
|
||||
77 => MAC_ROMAN_CODE_PAGE,
|
||||
254 | 255 => 437,
|
||||
161 => 1253,
|
||||
162 => 1254,
|
||||
163 => 1258,
|
||||
@@ -180,6 +319,11 @@ mod tests {
|
||||
assert_eq!(decode(1253, &[0xC1, 0xE2]), "Αβ");
|
||||
assert_eq!(decode(1252, &[0x80, 0xE9]), "ێ");
|
||||
assert_eq!(decode(9999, &[0xE9]), "é");
|
||||
// Mac OS Roman (#1653): accents, the euro sign at 0xDB, typographic quotes.
|
||||
assert_eq!(decode(10000, &[0x80, 0x8E, 0xDB, 0xF1, 0xF2, 0xF3, 0xD2, 0xD3, 0x41]), "Äé€ÒÚÛ“”A");
|
||||
assert_eq!(charset_code_page(77), Some(10000));
|
||||
assert_eq!(charset_code_page(254), Some(437));
|
||||
assert_eq!(charset_code_page(255), Some(437));
|
||||
assert_eq!(charset_code_page(204), Some(1251));
|
||||
assert_eq!(charset_code_page(0), None);
|
||||
for cp in SINGLE_BYTE_CODE_PAGES {
|
||||
@@ -188,4 +332,36 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #1653: one decoder for document text in any code page RTF names: Windows, DOS or Mac.
|
||||
#[test]
|
||||
fn document_text_in_windows_dos_and_mac_code_pages() {
|
||||
let decode = |cp: u16, bytes: &[u8]| bytes.iter().map(|&b| code_page_byte(cp, b)).collect::<String>();
|
||||
assert_eq!(decode(437, &[0x82, 0xA0, 0x41, 0x01]), "éáA\u{1}", "DOS text; ASCII stays ASCII");
|
||||
assert_eq!(decode(850, &[0x9D, 0xE9]), "ØÚ");
|
||||
assert_eq!(decode(10000, &[0x8E]), "é");
|
||||
assert_eq!(decode(1251, &[0xC4]), "Д");
|
||||
for cp in [437, 850, 866, 1250, 1252, 10000] {
|
||||
assert!(is_byte_code_page(cp), "{cp}");
|
||||
}
|
||||
assert!(!is_byte_code_page(932) && !is_byte_code_page(0) && !SINGLE_BYTE_CODE_PAGES.contains(&10000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oem_code_pages() {
|
||||
let decode = |cp: u16, bytes: &[u8]| bytes.iter().map(|&b| oem_byte(cp, b)).collect::<String>();
|
||||
assert_eq!(decode(437, &[0x82, 0xE9, 0x01, 0x7F]), "éΘ☺⌂");
|
||||
assert_eq!(decode(850, &[0x9B, 0xE9]), "øÚ");
|
||||
assert_eq!(decode(852, &[0xA5, 0x88, 0xBE, 0xA9]), "ąłżę");
|
||||
assert_eq!(decode(866, &[0x8F, 0xE0, 0xA8, 0xA2, 0xA5, 0xE2, 0xF0, 0xFC]), "ПриветЁ№");
|
||||
assert_eq!(decode(737, &[0x80, 0x99, 0xE0]), "Αβω");
|
||||
assert_eq!(decode(857, &[0x8D, 0x98, 0xA6, 0x9F]), "ıİĞş");
|
||||
assert_eq!(oem_byte(857, 0xD5), '\u{FFFD}', "unassigned in 857");
|
||||
assert_eq!(decode(9999, &[0xE9]), "Θ", "unknown pages read as 437");
|
||||
for cp in OEM_CODE_PAGES {
|
||||
for b in 0..=255u8 {
|
||||
let _ = oem_byte(cp, b);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ fn scan(story: StoryRef, bl: &Blocks, prefix: &mut Vec<u32>, depth: usize, out:
|
||||
let path = Path(prefix.clone());
|
||||
for off in p.object_offsets() {
|
||||
match p.object_at(off) {
|
||||
Some(InlineObject::FieldStart { instr, locked }) => {
|
||||
Some(InlineObject::FieldStart { instr, locked, .. }) => {
|
||||
open.push((instr.clone(), *locked, Pos::new(story, path.clone(), off)));
|
||||
}
|
||||
Some(InlineObject::FieldEnd) => match open.pop() {
|
||||
@@ -93,6 +93,18 @@ impl Document {
|
||||
self.story(story).map(|bl| scan_story(story, bl).ranges).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// `p` moved past the range-field ends right after it. A click just after a field's result
|
||||
/// lands before its end marker (the marker takes no room); typing or inserting there belongs
|
||||
/// after the field, as in Word, not in its result.
|
||||
pub fn past_field_ends(&self, p: &Pos) -> Pos {
|
||||
let Some(para) = self.para_at(p) else { return p.clone() };
|
||||
let mut off = p.off;
|
||||
while matches!(para.object_at(off), Some(InlineObject::FieldEnd)) {
|
||||
off += OBJ.len_utf8();
|
||||
}
|
||||
Pos { off, ..p.clone() }
|
||||
}
|
||||
|
||||
/// Whether any story holds a range-field marker without its partner.
|
||||
pub fn has_unbalanced_field_ranges(&self) -> bool {
|
||||
self.story_refs().into_iter().any(|s| self.story(s).is_some_and(|bl| !scan_story(s, bl).orphans.is_empty()))
|
||||
|
||||
@@ -24,7 +24,7 @@ pub struct Graphic {
|
||||
pub h: f32,
|
||||
/// Where the graphic was read from, so saving writes the original drawing itself back (not
|
||||
/// only these items). `None` for graphics made some other way.
|
||||
#[serde(skip)]
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source: Option<std::sync::Arc<Embedded>>,
|
||||
/// A SmartArt graphic WordCraft can edit (one it made, or one read back exactly as it wrote
|
||||
/// it): saving writes the diagram parts from it, and the items are drawn from it. See
|
||||
@@ -41,7 +41,7 @@ pub struct Graphic {
|
||||
/// source format and the package parts that markup refers to. Never edited; WordCraft only moves
|
||||
/// and resizes the object around it. The parts themselves (bytes, content type and their own
|
||||
/// relationships) are kept in [`crate::Document::passthrough`] under their part names.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Embedded {
|
||||
/// The object's markup as a standalone XML element with its namespace declarations (DOCX:
|
||||
/// the `a:graphic` of a chart, SmartArt diagram or native ink, an ink `mc:AlternateContent`,
|
||||
@@ -52,7 +52,7 @@ pub struct Embedded {
|
||||
}
|
||||
|
||||
/// One relationship an [`Embedded`] object uses.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct EmbeddedRef {
|
||||
/// The relationship id the markup uses.
|
||||
pub id: String,
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
//! Legacy form fields (`FORMCHECKBOX`, `FORMTEXT`, `FORMDROPDOWN`): what their `w:ffData` says,
|
||||
//! read from the raw XML a [`crate::InlineObject::Field`] keeps in its `form` (ECMA-376 Part 1
|
||||
//! §17.16.17). Only what layout and PDF export need; the XML itself stays the source of truth
|
||||
//! for saving.
|
||||
|
||||
/// Most drop-down entries kept (Word allows 25).
|
||||
pub const MAX_ENTRIES: usize = 64;
|
||||
|
||||
/// A legacy form field's kind and state.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum LegacyForm {
|
||||
/// A check box: `checked` is its state (`w:checked`, else `w:default`), `size` its explicit
|
||||
/// size in points (`w:size`), `None` when it follows the text (`w:sizeAuto`).
|
||||
CheckBox { name: String, checked: bool, size: Option<f32> },
|
||||
/// A text field; its text is the field's result.
|
||||
Text { name: String, max_len: Option<u32> },
|
||||
/// A drop-down list showing entry `selected` (`w:result`, else `w:default`, else the first).
|
||||
DropDown { name: String, entries: Vec<String>, selected: usize },
|
||||
}
|
||||
|
||||
impl LegacyForm {
|
||||
/// The field's definition, or `None` when `ff_data` holds none of the three.
|
||||
pub fn parse(ff_data: &str) -> Option<LegacyForm> {
|
||||
let name = attr_of(ff_data, "w:name").unwrap_or_default();
|
||||
if let Some(cb) = element(ff_data, "w:checkBox") {
|
||||
let default = attr_of(cb, "w:default").is_some_and(|v| on(&v));
|
||||
// `<w:checked/>` with no value means checked.
|
||||
let checked = match tag(cb, "w:checked") {
|
||||
Some(t) => attr_in(t).is_none_or(|v| on(&v)),
|
||||
None => default,
|
||||
};
|
||||
let size =
|
||||
attr_of(cb, "w:size").and_then(|v| v.trim().parse::<f32>().ok()).filter(|v| v.is_finite() && *v > 0.0).map(|v| (v / 2.0).min(1584.0));
|
||||
return Some(LegacyForm::CheckBox { name, checked, size });
|
||||
}
|
||||
if let Some(dd) = element(ff_data, "w:ddList") {
|
||||
let entries: Vec<String> = tags(dd, "w:listEntry").filter_map(attr_in).take(MAX_ENTRIES).collect();
|
||||
let pick = |n: &str| attr_of(dd, n).and_then(|v| v.trim().parse::<usize>().ok());
|
||||
let selected = pick("w:result").or_else(|| pick("w:default")).filter(|i| *i < entries.len()).unwrap_or(0);
|
||||
return Some(LegacyForm::DropDown { name, entries, selected });
|
||||
}
|
||||
if ff_data.contains("<w:textInput") {
|
||||
let max_len =
|
||||
element(ff_data, "w:textInput").and_then(|t| attr_of(t, "w:maxLength")).and_then(|v| v.trim().parse::<u32>().ok()).filter(|n| *n > 0);
|
||||
return Some(LegacyForm::Text { name, max_len });
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The text a drop-down shows (its selected entry); `None` for other kinds or an empty list.
|
||||
pub fn shown_text(&self) -> Option<&str> {
|
||||
match self {
|
||||
LegacyForm::DropDown { entries, selected, .. } => entries.get(*selected).map(String::as_str),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The field's bookmark-style name (`w:name`), possibly empty.
|
||||
pub fn name(&self) -> &str {
|
||||
match self {
|
||||
LegacyForm::CheckBox { name, .. } | LegacyForm::Text { name, .. } | LegacyForm::DropDown { name, .. } => name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on(v: &str) -> bool {
|
||||
!matches!(v.trim(), "0" | "false" | "off")
|
||||
}
|
||||
|
||||
/// The element `name` in `xml` with its content: from its start tag to its end tag, or the
|
||||
/// self-closing tag alone.
|
||||
fn element<'a>(xml: &'a str, name: &str) -> Option<&'a str> {
|
||||
let open = tag(xml, name)?;
|
||||
let start = xml.find(open)?;
|
||||
if open.ends_with("/>") {
|
||||
return Some(open);
|
||||
}
|
||||
let close = format!("</{name}>");
|
||||
let end = xml.get(start..)?.find(&close)? + start + close.len();
|
||||
xml.get(start..end)
|
||||
}
|
||||
|
||||
/// The first start (or empty) tag `<name ...>` in `xml`.
|
||||
fn tag<'a>(xml: &'a str, name: &str) -> Option<&'a str> {
|
||||
tags(xml, name).next()
|
||||
}
|
||||
|
||||
/// Every start (or empty) tag `<name ...>` in `xml`, in order.
|
||||
fn tags<'a>(xml: &'a str, name: &str) -> impl Iterator<Item = &'a str> + use<'a> {
|
||||
let pat = format!("<{name}");
|
||||
let mut from = 0;
|
||||
std::iter::from_fn(move || {
|
||||
loop {
|
||||
let at = from + xml.get(from..)?.find(&pat)?;
|
||||
let after = at + pat.len();
|
||||
from = after;
|
||||
// `<w:checkBox` must not match `<w:checkBoxes`: the name ends at a space, `/` or `>`.
|
||||
if xml.get(after..).and_then(|s| s.chars().next()).is_some_and(|c| c == ' ' || c == '/' || c == '>') {
|
||||
let end = after + xml.get(after..)?.find('>')? + 1;
|
||||
return xml.get(at..end);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The `w:val` of the first tag `name` in `xml`.
|
||||
fn attr_of(xml: &str, name: &str) -> Option<String> {
|
||||
tag(xml, name).and_then(attr_in)
|
||||
}
|
||||
|
||||
/// The `w:val` of a single tag, XML entities resolved.
|
||||
fn attr_in(tag: &str) -> Option<String> {
|
||||
let at = tag.find("w:val=")? + "w:val=".len();
|
||||
let quote = tag.get(at..)?.chars().next()?;
|
||||
if quote != '"' && quote != '\'' {
|
||||
return None;
|
||||
}
|
||||
let rest = tag.get(at + 1..)?;
|
||||
let end = rest.find(quote)?;
|
||||
Some(unescape(rest.get(..end)?))
|
||||
}
|
||||
|
||||
fn unescape(s: &str) -> String {
|
||||
s.replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'").replace("&", "&")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn check_boxes_text_fields_and_drop_downs() {
|
||||
let cb = |inner: &str| format!(r#"<w:ffData><w:name w:val="Box1"/><w:enabled/><w:checkBox>{inner}</w:checkBox></w:ffData>"#);
|
||||
assert_eq!(
|
||||
LegacyForm::parse(&cb(r#"<w:sizeAuto/><w:default w:val="0"/>"#)),
|
||||
Some(LegacyForm::CheckBox { name: "Box1".into(), checked: false, size: None })
|
||||
);
|
||||
assert_eq!(
|
||||
LegacyForm::parse(&cb(r#"<w:sizeAuto/><w:default w:val="1"/>"#)).map(|f| matches!(f, LegacyForm::CheckBox { checked: true, .. })),
|
||||
Some(true)
|
||||
);
|
||||
// `w:checked` wins over `w:default`; without a value it means checked.
|
||||
assert!(matches!(
|
||||
LegacyForm::parse(&cb(r#"<w:default w:val="1"/><w:checked w:val="0"/>"#)),
|
||||
Some(LegacyForm::CheckBox { checked: false, .. })
|
||||
));
|
||||
assert!(matches!(LegacyForm::parse(&cb(r#"<w:default w:val="0"/><w:checked/>"#)), Some(LegacyForm::CheckBox { checked: true, .. })));
|
||||
assert!(matches!(
|
||||
LegacyForm::parse(&cb(r#"<w:size w:val="30"/><w:default w:val="0"/>"#)),
|
||||
Some(LegacyForm::CheckBox { size: Some(15.0), .. })
|
||||
));
|
||||
let dd = r#"<w:ffData><w:name w:val="List1"/><w:ddList><w:result w:val="1"/><w:listEntry w:val="Alpha"/><w:listEntry w:val="B & C"/></w:ddList></w:ffData>"#;
|
||||
let f = LegacyForm::parse(dd).unwrap();
|
||||
assert_eq!(f.shown_text(), Some("B & C"));
|
||||
assert_eq!(f.name(), "List1");
|
||||
let dd0 = r#"<w:ffData><w:ddList><w:listEntry w:val="Only"/></w:ddList></w:ffData>"#;
|
||||
assert_eq!(LegacyForm::parse(dd0).and_then(|f| f.shown_text().map(str::to_string)).as_deref(), Some("Only"));
|
||||
let tx = r#"<w:ffData><w:name w:val="Name1"/><w:textInput><w:maxLength w:val="40"/></w:textInput></w:ffData>"#;
|
||||
assert_eq!(LegacyForm::parse(tx), Some(LegacyForm::Text { name: "Name1".into(), max_len: Some(40) }));
|
||||
assert_eq!(LegacyForm::parse(""), None);
|
||||
assert_eq!(LegacyForm::parse("<w:ffData><w:name w:val=\"x\"/></w:ffData>"), None);
|
||||
}
|
||||
}
|
||||
+240
-13
@@ -24,6 +24,8 @@ pub mod eq;
|
||||
pub mod fields;
|
||||
pub mod freeform;
|
||||
pub mod graphic;
|
||||
pub mod legacy_form;
|
||||
pub mod line_dash;
|
||||
pub mod link;
|
||||
pub mod locale_number;
|
||||
pub mod math;
|
||||
@@ -71,6 +73,27 @@ pub enum DocError {
|
||||
|
||||
pub type Result<T> = std::result::Result<T, DocError>;
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
@@ -289,6 +312,11 @@ pub struct TextBody {
|
||||
/// 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".
|
||||
@@ -322,6 +350,13 @@ pub struct Comment {
|
||||
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`); `date` is local time there.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub date_utc: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
@@ -359,6 +394,40 @@ impl Document {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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";
|
||||
|
||||
/// A bibliography source.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
@@ -420,6 +489,10 @@ pub struct CoreProps {
|
||||
#[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,
|
||||
@@ -433,6 +506,8 @@ pub struct Settings {
|
||||
/// 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,
|
||||
@@ -464,6 +539,17 @@ pub struct Settings {
|
||||
/// 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,
|
||||
/// 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,
|
||||
@@ -480,6 +566,10 @@ pub struct Settings {
|
||||
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.
|
||||
@@ -491,6 +581,24 @@ pub struct Settings {
|
||||
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;
|
||||
|
||||
@@ -503,6 +611,18 @@ 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 {
|
||||
@@ -539,6 +659,7 @@ 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,
|
||||
@@ -547,6 +668,7 @@ impl Default for Settings {
|
||||
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(),
|
||||
@@ -560,12 +682,16 @@ impl Default for Settings {
|
||||
protect_formatting: false,
|
||||
protect_hash: Vec::new(),
|
||||
compat_mode: COMPAT_MODE_CURRENT,
|
||||
roman_fonts: Vec::new(),
|
||||
sum_paragraph_spacing: 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,
|
||||
}
|
||||
@@ -597,11 +723,14 @@ pub struct Document {
|
||||
#[serde(skip_serializing_if = "NoteSeparators::is_empty")]
|
||||
pub endnote_separators: NoteSeparators,
|
||||
/// Embedded media (images) by key.
|
||||
#[serde(skip)]
|
||||
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub media: BTreeMap<String, Arc<Vec<u8>>>,
|
||||
/// Source-format parts we keep verbatim for round-trip (e.g. custom XML), by part name.
|
||||
#[serde(skip)]
|
||||
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub passthrough: BTreeMap<String, Arc<Vec<u8>>>,
|
||||
/// The document's own building blocks (its glossary document), in file order.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub glossary: Vec<BuildingBlock>,
|
||||
}
|
||||
|
||||
impl Default for Document {
|
||||
@@ -629,6 +758,7 @@ impl Document {
|
||||
endnote_separators: NoteSeparators::default(),
|
||||
media: BTreeMap::new(),
|
||||
passthrough: BTreeMap::new(),
|
||||
glossary: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -814,19 +944,24 @@ impl Document {
|
||||
|
||||
/// 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, false)
|
||||
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, true)
|
||||
self.story_text(s, TextMode::Shown)
|
||||
}
|
||||
|
||||
fn story_text(&self, s: StoryRef, shown: bool) -> String {
|
||||
/// [`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, shown);
|
||||
text_of(bl, &mut out, 0, mode);
|
||||
}
|
||||
if out.ends_with('\n') {
|
||||
out.pop();
|
||||
@@ -893,15 +1028,34 @@ impl Document {
|
||||
&mut self.last_section
|
||||
}
|
||||
|
||||
/// Allocate a new part id and insert the part.
|
||||
/// 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 {
|
||||
let id = self.parts.keys().next_back().map(|k| k + 1).unwrap_or(1);
|
||||
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);
|
||||
id
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Count words like Word's status bar (whitespace-separated tokens containing a letter or digit,
|
||||
@@ -1158,6 +1312,18 @@ impl Document {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()
|
||||
@@ -1283,8 +1449,11 @@ pub fn count_words(s: &str) -> usize {
|
||||
s.split(|c: char| c.is_whitespace() || c == para::OBJ).filter(|w| w.chars().any(char::is_alphanumeric)).count()
|
||||
}
|
||||
|
||||
/// How deep in nested tables [`Document::para_paths`] goes: cells below this are skipped.
|
||||
pub(crate) const PARA_WALK_DEPTH: usize = 16;
|
||||
|
||||
fn walk(bl: &Blocks, prefix: &mut Vec<u32>, out: &mut Vec<Path>, depth: usize) {
|
||||
if depth > 16 {
|
||||
if depth > PARA_WALK_DEPTH {
|
||||
return;
|
||||
}
|
||||
for (i, b) in bl.iter().enumerate() {
|
||||
@@ -1307,14 +1476,72 @@ fn walk(bl: &Blocks, prefix: &mut Vec<u32>, out: &mut Vec<Path>, depth: usize) {
|
||||
}
|
||||
}
|
||||
|
||||
fn text_of(bl: &Blocks, out: &mut String, depth: usize, shown: bool) {
|
||||
/// [`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)]
|
||||
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,
|
||||
}
|
||||
|
||||
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(&if shown { p.shown_text() } else { p.plain_text() });
|
||||
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) => {
|
||||
@@ -1322,7 +1549,7 @@ fn text_of(bl: &Blocks, out: &mut String, depth: usize, shown: bool) {
|
||||
let mut cells = Vec::new();
|
||||
for c in &row.cells {
|
||||
let mut s = String::new();
|
||||
text_of(&c.blocks, &mut s, depth + 1, shown);
|
||||
text_of(&c.blocks, &mut s, depth + 1, mode);
|
||||
cells.push(s.trim_end_matches('\n').replace('\n', " "));
|
||||
}
|
||||
out.push_str(&cells.join("\t"));
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
//! DrawingML outline dashes, measured in multiples of the stroke width.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const DASH_PERCENT: u32 = 100_000;
|
||||
pub const MAX_DASH_STOPS: usize = 64;
|
||||
const MIN_DASH_POINTS: f32 = 0.01;
|
||||
const MAX_DASH_POINTS: f32 = 20_000.0;
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum LineDash {
|
||||
#[default]
|
||||
Solid,
|
||||
Dot,
|
||||
Dash,
|
||||
LongDash,
|
||||
DashDot,
|
||||
LongDashDot,
|
||||
LongDashDotDot,
|
||||
SystemDash,
|
||||
SystemDot,
|
||||
SystemDashDot,
|
||||
SystemDashDotDot,
|
||||
/// Dash/space pairs in 100000ths of the line width (`a:ds/@d`, `@sp`).
|
||||
Custom(Vec<(u32, u32)>),
|
||||
}
|
||||
|
||||
impl LineDash {
|
||||
pub fn from_ooxml(value: &str) -> Self {
|
||||
match value {
|
||||
"dot" => Self::Dot,
|
||||
"dash" => Self::Dash,
|
||||
"lgDash" => Self::LongDash,
|
||||
"dashDot" => Self::DashDot,
|
||||
"lgDashDot" => Self::LongDashDot,
|
||||
"lgDashDotDot" => Self::LongDashDotDot,
|
||||
"sysDash" => Self::SystemDash,
|
||||
"sysDot" => Self::SystemDot,
|
||||
"sysDashDot" => Self::SystemDashDot,
|
||||
"sysDashDotDot" => Self::SystemDashDotDot,
|
||||
_ => Self::Solid,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ooxml(&self) -> Option<&'static str> {
|
||||
Some(match self {
|
||||
Self::Solid => "solid",
|
||||
Self::Dot => "dot",
|
||||
Self::Dash => "dash",
|
||||
Self::LongDash => "lgDash",
|
||||
Self::DashDot => "dashDot",
|
||||
Self::LongDashDot => "lgDashDot",
|
||||
Self::LongDashDotDot => "lgDashDotDot",
|
||||
Self::SystemDash => "sysDash",
|
||||
Self::SystemDot => "sysDot",
|
||||
Self::SystemDashDot => "sysDashDot",
|
||||
Self::SystemDashDotDot => "sysDashDotDot",
|
||||
Self::Custom(_) => return None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_solid(&self) -> bool {
|
||||
*self == Self::Solid
|
||||
}
|
||||
|
||||
/// Page-point lengths for the painters. Bound hostile custom lists and tiny spans.
|
||||
pub fn pattern(&self, width: f32) -> Vec<f32> {
|
||||
let preset: &[f32] = match self {
|
||||
Self::Solid => return Vec::new(),
|
||||
Self::Dot => &[1.0, 3.0],
|
||||
Self::Dash => &[4.0, 3.0],
|
||||
Self::LongDash => &[8.0, 3.0],
|
||||
Self::DashDot => &[4.0, 3.0, 1.0, 3.0],
|
||||
Self::LongDashDot => &[8.0, 3.0, 1.0, 3.0],
|
||||
Self::LongDashDotDot => &[8.0, 3.0, 1.0, 3.0, 1.0, 3.0],
|
||||
Self::SystemDash => &[3.0, 1.0],
|
||||
Self::SystemDot => &[1.0, 1.0],
|
||||
Self::SystemDashDot => &[3.0, 1.0, 1.0, 1.0],
|
||||
Self::SystemDashDotDot => &[3.0, 1.0, 1.0, 1.0, 1.0, 1.0],
|
||||
Self::Custom(stops) => {
|
||||
return stops
|
||||
.iter()
|
||||
.take(MAX_DASH_STOPS)
|
||||
.flat_map(|&(d, sp)| [d, sp])
|
||||
.map(|v| dash_points(v as f32 / DASH_PERCENT as f32, width))
|
||||
.collect();
|
||||
}
|
||||
};
|
||||
preset.iter().map(|v| dash_points(*v, width)).collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn dash_points(ratio: f32, width: f32) -> f32 {
|
||||
let width = if width.is_finite() { width.clamp(0.25, 200.0) } else { 0.75 };
|
||||
(ratio * width).clamp(MIN_DASH_POINTS, MAX_DASH_POINTS)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dash_units_follow_line_width() {
|
||||
assert_eq!(LineDash::Dash.pattern(1.0), [4.0, 3.0]);
|
||||
assert_eq!(LineDash::Dash.pattern(2.0), [8.0, 6.0]);
|
||||
assert_eq!(LineDash::Custom(vec![(200_000, 150_000)]).pattern(2.0), [4.0, 3.0]);
|
||||
assert!(LineDash::Solid.pattern(2.0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_shapes_default_to_solid() {
|
||||
let extra: crate::connector::ShapeExtra = serde_json::from_str("{}").unwrap();
|
||||
assert!(extra.dash.is_solid());
|
||||
assert!(!serde_json::to_string(&extra).unwrap().contains("dash"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_dashes_are_bounded() {
|
||||
let pattern = LineDash::Custom(vec![(0, u32::MAX); MAX_DASH_STOPS + 1]).pattern(f32::NAN);
|
||||
assert_eq!(pattern.len(), MAX_DASH_STOPS * 2);
|
||||
assert!(pattern.iter().all(|v| v.is_finite() && *v >= MIN_DASH_POINTS && *v <= MAX_DASH_POINTS));
|
||||
}
|
||||
}
|
||||
+19
-5
@@ -9,14 +9,23 @@ pub fn is_web_or_mail_link(url: &str) -> bool {
|
||||
["http", "https", "mailto"].iter().any(|s| scheme.eq_ignore_ascii_case(s))
|
||||
}
|
||||
|
||||
/// Whether a hyperlink may be written as an `href` (or Markdown destination) in an exported file:
|
||||
/// a web or email link, a bookmark (`#name`) or a relative path. Scripts (`javascript:`,
|
||||
/// `vbscript:`), `data:`, `file:` and every other scheme are refused, read the way a browser reads
|
||||
/// a URL: ASCII control characters and spaces are dropped first, so `java\nscript:` is a script.
|
||||
/// Whether a hyperlink may be written as an `href` (or Markdown destination, RTF `HYPERLINK`
|
||||
/// field, ODT `xlink:href`, LaTeX `\href`) in an exported file: a web or email link, a bookmark
|
||||
/// (`#name`) or a relative path. Scripts (`javascript:`, `vbscript:`), `data:`, `file:` and every
|
||||
/// other scheme are refused, read the way a browser reads a URL: ASCII control characters and
|
||||
/// spaces are dropped first, so `java\nscript:` is a script. A protocol-relative reference
|
||||
/// (`//host/…`) or a UNC path (`\\host\…`, or `//host` once backslashes are read as separators)
|
||||
/// names another machine — following it on Windows leaks the user's NTLM credentials — so it is
|
||||
/// refused too.
|
||||
pub fn is_safe_href(url: &str) -> bool {
|
||||
let cleaned: String = url.chars().filter(|c| !c.is_ascii_control() && *c != ' ').collect();
|
||||
// `//host` (protocol-relative) and `\\host` (UNC), with `\` read as `/` the way Windows does.
|
||||
if cleaned.replace('\\', "/").starts_with("//") {
|
||||
return false;
|
||||
}
|
||||
match scheme(url) {
|
||||
Some(s) => matches!(s.as_str(), "http" | "https" | "mailto"),
|
||||
None => !url.trim().is_empty(),
|
||||
None => !cleaned.is_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +82,11 @@ mod tests {
|
||||
"data:text/html,<script>alert(1)</script>",
|
||||
"file:///etc/passwd",
|
||||
"ms-msdt:/id",
|
||||
// Protocol-relative and UNC references name another machine (NTLM leak on Windows).
|
||||
"//attacker.example/share/x",
|
||||
"\\\\attacker.example\\share\\x",
|
||||
"/\\attacker.example\\x",
|
||||
" //attacker.example/x",
|
||||
"",
|
||||
] {
|
||||
assert!(!is_safe_href(bad), "{bad:?}");
|
||||
|
||||
@@ -111,10 +111,11 @@ pub fn format_number(v: f64, lang: Option<&str>) -> String {
|
||||
if !v.is_finite() {
|
||||
return "0".into();
|
||||
}
|
||||
if v.fract() == 0.0 && v.abs() < 1e15 {
|
||||
if v.fract() == 0.0 && v.abs() < 9.2e18 {
|
||||
return format!("{}", v as i64);
|
||||
}
|
||||
let s = format!("{v:.2}");
|
||||
let s = if s == "-0.00" { "0.00".to_string() } else { s };
|
||||
if decimal_separator(lang) == ',' { s.replace('.', ",") } else { s }
|
||||
}
|
||||
|
||||
@@ -147,6 +148,7 @@ mod tests {
|
||||
assert_eq!(parse_number(".", pl), None);
|
||||
assert_eq!(format_number(5.75, pl), "5,75");
|
||||
assert_eq!(format_number(1350.0, pl), "1350");
|
||||
assert_eq!(format_number(-0.001, pl), "0,00", "a rounded zero has no sign");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -161,6 +163,8 @@ mod tests {
|
||||
assert_eq!(parse_number("1.", en), Some(1.0));
|
||||
assert_eq!(parse_number("0,125", en), None, "never 125");
|
||||
assert_eq!(format_number(5.75, en), "5.75");
|
||||
assert_eq!(format_number(-0.001, en), "0.00", "a rounded zero has no sign");
|
||||
assert_eq!(format_number(-0.0, None), "0");
|
||||
assert_eq!(decimal_separator(Some("de-CH")), '.');
|
||||
assert_eq!(decimal_separator(Some("de-DE")), ',');
|
||||
}
|
||||
@@ -174,4 +178,13 @@ mod tests {
|
||||
}
|
||||
assert_eq!(format_number(f64::NAN, None), "0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_whole_numbers_have_no_decimals() {
|
||||
assert_eq!(format_number(1e15, None), "1000000000000000");
|
||||
assert_eq!(format_number(2.5e15, None), "2500000000000000");
|
||||
assert_eq!(format_number(-1e15, None), "-1000000000000000");
|
||||
assert_eq!(format_number(9.0e18, None), "9000000000000000000");
|
||||
assert_eq!(format_number(1e15 + 0.5, None), "1000000000000000.50");
|
||||
}
|
||||
}
|
||||
|
||||
+299
-21
@@ -340,7 +340,7 @@ fn yes() -> bool {
|
||||
|
||||
/// A row holds an equation-number mark (`#`) in a top-level run.
|
||||
pub fn has_number_mark(row: &[MNode]) -> bool {
|
||||
row.iter().any(|n| matches!(n, MNode::Run(r) if !r.lit && r.text.contains('#')))
|
||||
row.iter().any(|n| matches!(n, MNode::Run(r) if !r.lit && !r.nor && r.text.contains('#')))
|
||||
}
|
||||
|
||||
/// Integral signs (their limits default to sub/sup positions).
|
||||
@@ -470,8 +470,27 @@ fn lin_arg(a: &[MNode], s: &mut String, depth: usize) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
for n in a {
|
||||
for (i, n) in a.iter().enumerate() {
|
||||
lin_node(n, s, depth + 1);
|
||||
// A bare script or limit ends at a space: a letter, digit or period after it is its own item.
|
||||
if matches!(n, MNode::Script { kind, .. } if *kind != ScriptKind::Pre) || matches!(n, MNode::Lim { .. }) {
|
||||
let ends_bare = s.chars().next_back().is_some_and(|c| c.is_alphanumeric() || c == '.');
|
||||
if ends_bare && a.get(i + 1).and_then(first_lin_char).is_some_and(|c| c.is_alphanumeric() || c == '.') {
|
||||
s.push(' ');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The first character `n` is written with, when that is cheap to tell (letters and digits).
|
||||
fn first_lin_char(n: &MNode) -> Option<char> {
|
||||
match n {
|
||||
MNode::Run(r) => r.text.chars().next(),
|
||||
MNode::Func { name, .. } => name.first().and_then(first_lin_char),
|
||||
MNode::Script { kind, base, .. } if *kind != ScriptKind::Pre => base.first().and_then(first_lin_char),
|
||||
MNode::Lim { e, .. } => e.first().and_then(first_lin_char),
|
||||
MNode::Frac { num, .. } => num.first().and_then(first_lin_char),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -499,6 +518,27 @@ fn lin_operand(a: &[MNode], s: &mut String, depth: usize) {
|
||||
}
|
||||
}
|
||||
|
||||
/// A script or limit operand. The parser reads a run of letters and digits up to the next space or
|
||||
/// operator, so `V_dd` is written bare as Word's Linear format shows it (`V_(dd)` reads back the same).
|
||||
fn lin_script(a: &[MNode], s: &mut String, depth: usize) {
|
||||
// Bare only when the parser reads the whole run back, in the same style.
|
||||
if let [MNode::Run(r)] = a
|
||||
&& r.text.chars().nth(1).is_some()
|
||||
{
|
||||
let upright_name = r.sty == Some(MSty::Plain) && crate::math_linear::FUNCTION_NAMES.contains(&r.text.as_str());
|
||||
let number = r.text.starts_with(|c: char| c.is_ascii_digit()) && r.text.chars().all(|c| c.is_alphanumeric() || c == '.');
|
||||
let word = r.text.starts_with(char::is_alphabetic) && r.text.chars().all(char::is_alphanumeric);
|
||||
let upright_other = r.sty == Some(MSty::Plain) && !upright_name;
|
||||
if upright_other || (!number && !word) {
|
||||
s.push('(');
|
||||
s.push_str(&r.text);
|
||||
s.push(')');
|
||||
return;
|
||||
}
|
||||
}
|
||||
lin_operand(a, s, depth);
|
||||
}
|
||||
|
||||
fn lin_node(n: &MNode, s: &mut String, depth: usize) {
|
||||
match n {
|
||||
MNode::Run(r) => s.push_str(&r.text),
|
||||
@@ -515,9 +555,9 @@ fn lin_node(n: &MNode, s: &mut String, depth: usize) {
|
||||
MNode::Script { kind, base, sub, sup } => {
|
||||
if *kind == ScriptKind::Pre {
|
||||
s.push('_');
|
||||
lin_operand(sub, s, depth);
|
||||
lin_script(sub, s, depth);
|
||||
s.push('^');
|
||||
lin_operand(sup, s, depth);
|
||||
lin_script(sup, s, depth);
|
||||
s.push('▒');
|
||||
lin_operand(base, s, depth);
|
||||
return;
|
||||
@@ -525,11 +565,11 @@ fn lin_node(n: &MNode, s: &mut String, depth: usize) {
|
||||
lin_operand(base, s, depth);
|
||||
if matches!(kind, ScriptKind::Sub | ScriptKind::SubSup) {
|
||||
s.push('_');
|
||||
lin_operand(sub, s, depth);
|
||||
lin_script(sub, s, depth);
|
||||
}
|
||||
if matches!(kind, ScriptKind::Sup | ScriptKind::SubSup) {
|
||||
s.push('^');
|
||||
lin_operand(sup, s, depth);
|
||||
lin_script(sup, s, depth);
|
||||
}
|
||||
}
|
||||
MNode::Rad { deg, deg_hide, e } => {
|
||||
@@ -548,11 +588,11 @@ fn lin_node(n: &MNode, s: &mut String, depth: usize) {
|
||||
s.push(*chr);
|
||||
if !sub_hide && !sub.is_empty() {
|
||||
s.push('_');
|
||||
lin_operand(sub, s, depth);
|
||||
lin_script(sub, s, depth);
|
||||
}
|
||||
if !sup_hide && !sup.is_empty() {
|
||||
s.push('^');
|
||||
lin_operand(sup, s, depth);
|
||||
lin_script(sup, s, depth);
|
||||
}
|
||||
s.push('▒');
|
||||
lin_operand(e, s, depth);
|
||||
@@ -592,7 +632,7 @@ fn lin_node(n: &MNode, s: &mut String, depth: usize) {
|
||||
MNode::Lim { upper, e, lim } => {
|
||||
lin_operand(e, s, depth);
|
||||
s.push(if *upper { '┴' } else { '┬' });
|
||||
lin_operand(lim, s, depth);
|
||||
lin_script(lim, s, depth);
|
||||
}
|
||||
MNode::Acc { chr, e } => {
|
||||
lin_operand(e, s, depth);
|
||||
@@ -701,7 +741,7 @@ pub fn math_class(c: char) -> MClass {
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge adjacent runs with the same formatting; recurse into structures.
|
||||
/// Merge adjacent runs with the same formatting; recurse into every structure.
|
||||
pub fn merge_runs(a: Arg) -> Arg {
|
||||
let mut out: Arg = Vec::with_capacity(a.len());
|
||||
for n in a {
|
||||
@@ -722,23 +762,79 @@ pub fn merge_runs(a: Arg) -> Arg {
|
||||
}
|
||||
MNode::Run(r)
|
||||
}
|
||||
MNode::Frac { kind, num, den } => MNode::Frac { kind, num: merge_runs(num), den: merge_runs(den) },
|
||||
MNode::Script { kind, base, sub, sup } => MNode::Script { kind, base: merge_runs(base), sub: merge_runs(sub), sup: merge_runs(sup) },
|
||||
MNode::Rad { deg, deg_hide, e } => MNode::Rad { deg: merge_runs(deg), deg_hide, e: merge_runs(e) },
|
||||
MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e } => {
|
||||
MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub: merge_runs(sub), sup: merge_runs(sup), e: merge_runs(e) }
|
||||
}
|
||||
MNode::Delim { beg, end, sep, grow, shp_match, elems } => {
|
||||
MNode::Delim { beg, end, sep, grow, shp_match, elems: elems.into_iter().map(merge_runs).collect() }
|
||||
}
|
||||
MNode::Func { name, e } => MNode::Func { name: merge_runs(name), e: merge_runs(e) },
|
||||
other => other,
|
||||
other => crate::math_linear::map_args(other, &merge_runs),
|
||||
};
|
||||
out.push(n);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Deepest node (a leaf counts 1) the parsers wrap further. Half of [`MAX_DEPTH`]: serde_json
|
||||
/// stops reading at 128 nested containers and a node costs two (object and array).
|
||||
pub(crate) const WRAP_DEPTH: usize = MAX_DEPTH / 2;
|
||||
|
||||
/// Calls `f` with every argument of `n`.
|
||||
fn each_arg<'a>(n: &'a MNode, f: &mut impl FnMut(&'a [MNode])) {
|
||||
match n {
|
||||
MNode::Run(_) => {}
|
||||
MNode::Frac { num, den, .. } => {
|
||||
f(num);
|
||||
f(den);
|
||||
}
|
||||
MNode::Script { base, sub, sup, .. } => {
|
||||
f(base);
|
||||
f(sub);
|
||||
f(sup);
|
||||
}
|
||||
MNode::Rad { deg, e, .. } => {
|
||||
f(deg);
|
||||
f(e);
|
||||
}
|
||||
MNode::Nary { sub, sup, e, .. } => {
|
||||
f(sub);
|
||||
f(sup);
|
||||
f(e);
|
||||
}
|
||||
MNode::Delim { elems, .. } => elems.iter().for_each(|a| f(a)),
|
||||
MNode::Func { name, e } => {
|
||||
f(name);
|
||||
f(e);
|
||||
}
|
||||
MNode::Lim { e, lim, .. } => {
|
||||
f(e);
|
||||
f(lim);
|
||||
}
|
||||
MNode::Acc { e, .. }
|
||||
| MNode::Bar { e, .. }
|
||||
| MNode::BorderBox { e, .. }
|
||||
| MNode::Boxed { e }
|
||||
| MNode::GroupChr { e, .. }
|
||||
| MNode::Phant { e, .. } => f(e),
|
||||
MNode::EqArr { rows } => rows.iter().for_each(|a| f(a)),
|
||||
MNode::Matrix { rows, .. } => rows.iter().flatten().for_each(|a| f(a)),
|
||||
}
|
||||
}
|
||||
|
||||
/// `n` is already nested too deeply to be wrapped in another structure. The parsers use this so
|
||||
/// `x^1^1^1…` stays shallow (a left-nested tree 10 000 deep overflows the stack in `Clone`,
|
||||
/// `Drop`, `PartialEq` and serde). Iterative, stops at the first too-deep path; costs at most the
|
||||
/// size of `n`, which the parsers' input caps bound.
|
||||
pub(crate) fn too_deep(n: &MNode) -> bool {
|
||||
let mut stack: Vec<(&MNode, usize)> = vec![(n, 1)];
|
||||
while let Some((n, d)) = stack.pop() {
|
||||
if d >= WRAP_DEPTH {
|
||||
return true;
|
||||
}
|
||||
each_arg(n, &mut |a| stack.extend(a.iter().map(|c| (c, d + 1))));
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Any of `a` is too deep to be wrapped ([`too_deep`]).
|
||||
pub(crate) fn any_too_deep(a: &[MNode]) -> bool {
|
||||
a.iter().any(too_deep)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -807,6 +903,188 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
//T1
|
||||
/// Deepest nesting of `a` (a leaf counts 1), without recursion.
|
||||
fn depth_of(a: &[MNode]) -> usize {
|
||||
let mut best = 0;
|
||||
let mut stack: Vec<(&MNode, usize)> = a.iter().map(|n| (n, 1)).collect();
|
||||
while let Some((n, d)) = stack.pop() {
|
||||
best = best.max(d);
|
||||
each_arg(n, &mut |a| stack.extend(a.iter().map(|c| (c, d + 1))));
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// Run `f` on a thread with a stack far below the 8 MB of a main thread (#1456).
|
||||
fn on_small_stack(f: impl FnOnce() + Send + 'static) {
|
||||
std::thread::Builder::new().stack_size(512 * 1024).spawn(f).unwrap().join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deeply_chained_input_stays_shallow() {
|
||||
on_small_stack(|| {
|
||||
let cases: Vec<(&str, Arg)> = vec![
|
||||
("x^1^1…", parse_linear(&format!("x{}", "^1".repeat(9999)))),
|
||||
("a/a/a…", parse_linear(&format!("a{}", "/a".repeat(9999)))),
|
||||
("accents", parse_linear(&format!("x{}", "\u{302}".repeat(9999)))),
|
||||
("limits", parse_linear(&format!("x{}", "┬a".repeat(9000)))),
|
||||
("(a b)^1^1…", parse_linear(&format!("(a b){}", "^1".repeat(9000)))),
|
||||
("latex x^1^1…", crate::math_latex::parse_latex(&format!("x{}", "^1".repeat(9999)))),
|
||||
("latex \\sum…", crate::math_latex::parse_latex(&"\\sum".repeat(5000))),
|
||||
("latex \\sin x^1…", crate::math_latex::parse_latex(&format!("\\sin x{}", "^1".repeat(9000)))),
|
||||
];
|
||||
for (name, n) in cases {
|
||||
assert!(!n.is_empty(), "{name}");
|
||||
assert!(depth_of(&n) <= 70, "{name}: depth {}", depth_of(&n));
|
||||
let copy = n.clone();
|
||||
assert_eq!(copy, n, "{name}");
|
||||
let _ = to_linear(&n);
|
||||
let _ = crate::math_latex::to_latex(&n);
|
||||
let json = serde_json::to_string(&n).unwrap();
|
||||
let back: Arg = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, n, "{name}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_nesting_is_still_built() {
|
||||
// Well below the cap: scripts, fractions and operators still wrap.
|
||||
let n = parse_linear(&format!("x{}", "^1".repeat(10)));
|
||||
assert_eq!(depth_of(&n), 11, "{n:#?}");
|
||||
let n = crate::math_latex::parse_latex(r"\sum_{i}\sum_{j}a_{ij}");
|
||||
assert!(matches!(&n[0], MNode::Nary { e, .. } if matches!(e.first(), Some(MNode::Nary { .. }))), "{n:#?}");
|
||||
}
|
||||
|
||||
//T2
|
||||
#[test]
|
||||
fn latex_text_escapes_what_latex_reads_as_markup() {
|
||||
for text in ["a}b\\c", "{x}", "50% of #1", "a_b&c$d~e^f", "\\", "\\alpha", "\\{"] {
|
||||
let n = vec![MNode::Run(MRun { text: text.into(), nor: true, sty: Some(MSty::Plain), ..Default::default() })];
|
||||
let tex = crate::math_latex::to_latex(&n);
|
||||
assert_eq!(crate::math_latex::parse_latex(&tex), n, "{text:?} → {tex}");
|
||||
}
|
||||
let n = vec![MNode::Run(MRun::new("a\\b"))];
|
||||
assert_eq!(crate::math_latex::parse_latex(&crate::math_latex::to_latex(&n)), n);
|
||||
}
|
||||
|
||||
//T3
|
||||
#[test]
|
||||
fn latex_runs_merge_inside_every_structure() {
|
||||
use crate::math_latex::parse_latex;
|
||||
for (tex, lin) in [(r"\overline{ab}", "¯(ab)"), (r"\underline{ab}", "▁(ab)"), (r"\boxed{ab}", "▭(ab)"), (r"\phantom{ab}", "⟡(ab)")] {
|
||||
assert_eq!(parse_latex(tex), parse_linear(lin), "{tex}");
|
||||
}
|
||||
let n = parse_latex(r"\begin{matrix}a b&c d\\e f&g\end{matrix}");
|
||||
let MNode::Matrix { rows, .. } = &n[0] else { panic!("{n:#?}") };
|
||||
assert!(rows.iter().flatten().all(|c| c.len() == 1), "{rows:#?}");
|
||||
let n = parse_latex(r"\hat{ab}\underbrace{ab}\underset{ab}{cd}");
|
||||
let runs = |a: &Arg| a.iter().filter(|n| matches!(n, MNode::Run(_))).count();
|
||||
assert!(matches!(&n[0], MNode::Acc { e, .. } if runs(e) == 1), "{n:#?}");
|
||||
assert!(matches!(&n[1], MNode::GroupChr { e, .. } if runs(e) == 1), "{n:#?}");
|
||||
assert!(matches!(&n[2], MNode::Lim { e, lim, .. } if runs(e) == 1 && runs(lim) == 1), "{n:#?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_letter_scripts_round_trip() {
|
||||
// Word for Mac 16.113.4 (#1609): a script is the whole run up to a space or operator, and Word's
|
||||
// Linear format shows it bare (`V_dd`); the parenthesised form reads back identically.
|
||||
let script = |kind, sub: &str, sup: &str| MNode::Script { kind, base: vec![run("V")], sub: parse_linear(sub), sup: parse_linear(sup) };
|
||||
for n in [
|
||||
vec![script(ScriptKind::Sub, "dd", "")],
|
||||
vec![script(ScriptKind::Sup, "", "ab")],
|
||||
vec![script(ScriptKind::SubSup, "in", "2")],
|
||||
vec![MNode::Nary {
|
||||
chr: '∑',
|
||||
lim_loc: None,
|
||||
grow: false,
|
||||
sub_hide: false,
|
||||
sup_hide: false,
|
||||
sub: vec![run("ij")],
|
||||
sup: vec![run("n")],
|
||||
e: vec![run("x")],
|
||||
}],
|
||||
vec![MNode::Lim { upper: false, e: vec![run("⟶")], lim: vec![run("yields")] }],
|
||||
] {
|
||||
assert_eq!(parse_linear(&to_linear(&n)), n, "{}", to_linear(&n));
|
||||
}
|
||||
// Numbers and single letters stay bare.
|
||||
assert_eq!(to_linear(&[script(ScriptKind::SubSup, "10", "2.5")]), "V_10^2.5");
|
||||
assert_eq!(to_linear(&[script(ScriptKind::Sub, "d", "")]), "V_d");
|
||||
assert_eq!(to_linear(&[script(ScriptKind::Sub, "dd", "")]), "V_dd");
|
||||
assert_eq!(to_linear(&[script(ScriptKind::Sup, "", "ab")]), "V^ab");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_letter_script_operands_follow_word() {
|
||||
// #1609, Word for Mac 16.113.4: `V_dd` and `V_(dd)` build up the same; `dd` is all subscript.
|
||||
let sub_dd = MNode::Script { kind: ScriptKind::Sub, base: vec![run("V")], sub: vec![run("dd")], sup: vec![] };
|
||||
assert_eq!(parse_linear("V_dd"), vec![sub_dd.clone()]);
|
||||
assert_eq!(parse_linear("V_(dd)"), vec![sub_dd]);
|
||||
let sup_ab = MNode::Script { kind: ScriptKind::Sup, base: vec![run("x")], sub: vec![], sup: vec![run("ab")] };
|
||||
assert_eq!(parse_linear("x^ab"), vec![sup_ab.clone()]);
|
||||
assert_eq!(parse_linear("x^(ab)"), vec![sup_ab]);
|
||||
// Delimiters still end the operand.
|
||||
let both = MNode::Script { kind: ScriptKind::SubSup, base: vec![run("x")], sub: vec![run("i")], sup: vec![run("2")] };
|
||||
assert_eq!(parse_linear("x_i^2"), vec![both]);
|
||||
let a_i = MNode::Script { kind: ScriptKind::Sub, base: vec![run("a")], sub: vec![run("i")], sup: vec![] };
|
||||
assert_eq!(parse_linear("a_i b"), vec![a_i, run("b")]);
|
||||
let both2 = MNode::Script { kind: ScriptKind::SubSup, base: vec![run("x")], sub: vec![run("ij")], sup: vec![run("kl")] };
|
||||
assert_eq!(parse_linear("x_ij^kl"), vec![both2]);
|
||||
// Round trip is Word's bare form.
|
||||
assert_eq!(to_linear(&parse_linear("V_(dd)")), "V_dd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bare_script_does_not_absorb_what_follows() {
|
||||
// #1609 review: `x_i` then `y` must not read back as x with subscript `iy`.
|
||||
let sc =
|
||||
|kind, base: &str, sub: &str, sup: &str| MNode::Script { kind, base: vec![run(base)], sub: parse_linear(sub), sup: parse_linear(sup) };
|
||||
let sin_x = MNode::Func { name: vec![MNode::Run(MRun::plain("sin"))], e: vec![run("x")] };
|
||||
let lim = MNode::Lim { upper: false, e: vec![run("a")], lim: vec![run("n")] };
|
||||
for n in [
|
||||
vec![sc(ScriptKind::Sub, "x", "i", ""), run("y")],
|
||||
vec![sc(ScriptKind::Sub, "a", "i", ""), sc(ScriptKind::Sub, "b", "j", "")],
|
||||
vec![sc(ScriptKind::Sup, "x", "", "2"), run("y")],
|
||||
vec![sc(ScriptKind::Sup, "x", "", "2"), run("3")],
|
||||
vec![sc(ScriptKind::Sub, "x", "i", ""), sin_x],
|
||||
vec![lim.clone(), run("y")],
|
||||
vec![sc(ScriptKind::Sub, "x", "ab", ""), run("y")],
|
||||
vec![sc(ScriptKind::Sub, "x", "i", ""), run(".5")],
|
||||
] {
|
||||
let lin = to_linear(&n);
|
||||
assert_eq!(parse_linear(&lin), n, "{lin}");
|
||||
}
|
||||
assert_eq!(to_linear(&[sc(ScriptKind::Sub, "x", "i", ""), run("y")]), "x_i y");
|
||||
// Nothing extra when the script already ends in a bracket or a symbol follows.
|
||||
assert_eq!(to_linear(&[sc(ScriptKind::Sub, "x", "i", ""), run("+")]), "x_i+");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_function_name_in_a_script_stays_upright() {
|
||||
// Word for Mac (#1609): `x_max` and `x_(max)` both give an upright function name.
|
||||
let up = vec![MNode::Script { kind: ScriptKind::Sub, base: vec![run("x")], sub: vec![MNode::Run(MRun::plain("max"))], sup: vec![] }];
|
||||
assert_eq!(parse_linear("x_max"), up);
|
||||
assert_eq!(parse_linear("x_(max)"), up);
|
||||
assert_eq!(parse_linear(&to_linear(&up)), up, "{}", to_linear(&up));
|
||||
// Other upright text is not a function name: it keeps its parentheses.
|
||||
let other = vec![MNode::Script { kind: ScriptKind::Sub, base: vec![run("x")], sub: vec![MNode::Run(MRun::plain("ab"))], sup: vec![] }];
|
||||
assert_eq!(to_linear(&other), "x_(ab)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn digits_then_letters_continue_a_script() {
|
||||
// Word for Mac (#1609): a script runs to a space or operator, so `x_2a` is subscript "2a".
|
||||
let sub = |t: &str| vec![MNode::Script { kind: ScriptKind::Sub, base: vec![run("x")], sub: vec![run(t)], sup: vec![] }];
|
||||
assert_eq!(parse_linear("x_2a"), sub("2a"));
|
||||
assert_eq!(parse_linear("x_a2"), sub("a2"));
|
||||
assert_eq!(parse_linear("x_αβ"), sub("αβ"));
|
||||
assert_eq!(parse_linear("x_10"), sub("10"));
|
||||
assert_eq!(parse_linear("x_2.5"), sub("2.5"));
|
||||
assert_eq!(to_linear(&sub("2a")), "x_2a");
|
||||
assert_eq!(parse_linear(&to_linear(&sub("2a"))), sub("2a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_linear_never_panics() {
|
||||
for s in ["", "/", "^", "_", "√", "∑", "((((", "))))", "■(", "█(@@&&", "a/", "/b", "^^^__", "√(&)", "∫_^▒", "lim_", "sin"] {
|
||||
|
||||
@@ -362,6 +362,26 @@ pub fn tidy(root: &mut Arg) {
|
||||
go(root, 0);
|
||||
}
|
||||
|
||||
/// Call `f` on every run in the tree.
|
||||
pub fn each_run_mut(root: &mut Arg, f: &mut dyn FnMut(&mut MRun)) {
|
||||
fn go(a: &mut Arg, f: &mut dyn FnMut(&mut MRun), depth: usize) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
for n in a.iter_mut() {
|
||||
if let MNode::Run(r) = n {
|
||||
f(r);
|
||||
}
|
||||
for c in 0..child_count(n) {
|
||||
if let Some(ch) = child_mut(n, c) {
|
||||
go(ch, f, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
go(root, f, 0);
|
||||
}
|
||||
|
||||
fn merge_shallow(a: Arg) -> Arg {
|
||||
let mut out: Arg = Vec::with_capacity(a.len());
|
||||
for n in a {
|
||||
@@ -1179,6 +1199,21 @@ mod tests {
|
||||
assert!(matches!(eq[0], MNode::Frac { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_up_keeps_a_multi_letter_subscript() {
|
||||
// #1609: `dd` typed in a subscript box split into V_d and d on the next space.
|
||||
let mut eq: Arg = Vec::new();
|
||||
let p = insert_nodes(&mut eq, &MathPos::default(), parse_linear("⬚_⬚"));
|
||||
let p = insert_text(&mut eq, &p, "V", TypeStyle::default());
|
||||
let p = move_right(&eq, &p).unwrap();
|
||||
let p = insert_text(&mut eq, &p, "dd", TypeStyle::default());
|
||||
let p = move_right(&eq, &p).unwrap();
|
||||
assert_eq!(p, MathPos::new(vec![], 1));
|
||||
let built = eq.clone();
|
||||
assert_eq!(build_up(&mut eq, &p, false), None);
|
||||
assert_eq!(eq, built, "{}", lin(&eq));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_dissolves_and_removes() {
|
||||
let mut eq = parse_linear("√x");
|
||||
|
||||
+163
-22
@@ -5,7 +5,9 @@
|
||||
//! accents, `\overbrace`/`\underbrace`, `\text`/`\mathrm`/`\mathbf`/`\mathbb`…, function names,
|
||||
//! spacing and every named symbol in [`crate::math_symbols`]. Unknown commands stay as text.
|
||||
|
||||
use crate::math::{Arg, FracKind, MAX_DEPTH, MNode, MRun, MScr, MSty, ScriptKind, is_nary_char, math_class, merge_runs};
|
||||
use crate::math::{
|
||||
Arg, ColJc, FracKind, MAX_DEPTH, MNode, MRun, MScr, MSty, ScriptKind, WRAP_DEPTH, any_too_deep, is_nary_char, math_class, merge_runs, too_deep,
|
||||
};
|
||||
use crate::math_linear::{FUNCTION_NAMES, map_args};
|
||||
use crate::math_symbols::{name_of, symbol};
|
||||
|
||||
@@ -109,7 +111,7 @@ pub fn parse_latex(s: &str) -> Arg {
|
||||
p.i += 1;
|
||||
}
|
||||
let out = merge_runs(out);
|
||||
if out.iter().any(|n| matches!(n, MNode::Run(r) if r.text.contains('#'))) {
|
||||
if out.iter().any(|n| matches!(n, MNode::Run(r) if !r.nor && r.text.contains('#'))) {
|
||||
return vec![MNode::EqArr { rows: vec![out] }];
|
||||
}
|
||||
out
|
||||
@@ -147,8 +149,13 @@ impl Lp {
|
||||
Tok::Sup | Tok::Sub => {
|
||||
self.i += 1;
|
||||
let s = self.arg();
|
||||
let base = out.pop();
|
||||
out.push(script(base, tok == Tok::Sup, s));
|
||||
if out.last().is_some_and(too_deep) {
|
||||
// Too deep to wrap (`x^1^1^1…`): the operator is dropped, the script kept.
|
||||
out.extend(s);
|
||||
} else {
|
||||
let base = out.pop();
|
||||
out.push(script(base, tok == Tok::Sup, s));
|
||||
}
|
||||
}
|
||||
Tok::Cmd(ref c) if c == "limits" || c == "nolimits" || c == "displaystyle" || c == "textstyle" => {
|
||||
self.i += 1;
|
||||
@@ -171,7 +178,7 @@ impl Lp {
|
||||
}
|
||||
self.depth -= 1;
|
||||
// Big operators take the following term as their operand.
|
||||
nary_operands(out)
|
||||
nary_operands(out, self.depth)
|
||||
}
|
||||
|
||||
/// A command argument: a `{group}` or one token.
|
||||
@@ -224,10 +231,14 @@ impl Lp {
|
||||
}
|
||||
self.i += 1;
|
||||
let mut level = 0i32;
|
||||
// The previous token was a control word (`\backslash{}`: the empty braces end its name).
|
||||
let mut after_word = false;
|
||||
while let Some(t) = self.peek().cloned() {
|
||||
self.i += 1;
|
||||
let was_word = std::mem::replace(&mut after_word, matches!(&t, Tok::Cmd(c) if c.chars().all(|c| c.is_ascii_alphabetic())));
|
||||
match t {
|
||||
Tok::Close if level == 0 => break,
|
||||
Tok::Open if was_word && self.peek() == Some(&Tok::Close) => self.i += 1,
|
||||
Tok::Open => {
|
||||
level += 1;
|
||||
s.push('{');
|
||||
@@ -369,9 +380,30 @@ impl Lp {
|
||||
one(MNode::Run(MRun { text: t, nor: true, sty: Some(MSty::Italic), ..Default::default() }))
|
||||
}
|
||||
"operatorname" => {
|
||||
let starred = self.peek() == Some(&Tok::Ch('*'));
|
||||
if starred {
|
||||
self.i += 1;
|
||||
}
|
||||
let t = self.raw_arg();
|
||||
let name = vec![MNode::Run(MRun::plain(t))];
|
||||
self.apply(name)
|
||||
if starred && self.peek() == Some(&Tok::Sub) {
|
||||
self.i += 1;
|
||||
let lim = self.arg();
|
||||
vec![MNode::Lim { upper: false, e: name, lim }]
|
||||
} else {
|
||||
self.apply(name)
|
||||
}
|
||||
}
|
||||
"tag" => {
|
||||
let starred = self.peek() == Some(&Tok::Ch('*'));
|
||||
if starred {
|
||||
self.i += 1;
|
||||
}
|
||||
let t = self.raw_arg();
|
||||
let num_str = if starred { format!("#{t}") } else { format!("# ({t})") };
|
||||
let mut r = MRun::new(num_str);
|
||||
r.lit = false;
|
||||
vec![MNode::Run(r)]
|
||||
}
|
||||
"mathrm" | "rm" => {
|
||||
let a = self.arg();
|
||||
@@ -413,13 +445,15 @@ impl Lp {
|
||||
"underline" => one(MNode::Bar { top: false, e: self.arg() }),
|
||||
"boxed" | "fbox" => one(MNode::BorderBox { hide: [false; 4], strike: [false; 4], e: self.arg() }),
|
||||
"phantom" => one(MNode::Phant { show: false, zero_wid: false, zero_asc: false, zero_desc: false, e: self.arg() }),
|
||||
"overbrace" | "underbrace" | "overparen" | "underparen" => {
|
||||
"overbrace" | "underbrace" | "overparen" | "underparen" | "overbracket" | "underbracket" => {
|
||||
let top = c.starts_with("over");
|
||||
let chr = match c {
|
||||
"overbrace" => '⏞',
|
||||
"underbrace" => '⏟',
|
||||
"overparen" => '⏜',
|
||||
_ => '⏝',
|
||||
"underparen" => '⏝',
|
||||
"overbracket" => '⎴',
|
||||
_ => '⎵',
|
||||
};
|
||||
let e = self.arg();
|
||||
let g = MNode::GroupChr { chr, top, e };
|
||||
@@ -515,6 +549,10 @@ impl Lp {
|
||||
while let Some(t @ (Tok::Sup | Tok::Sub)) = self.peek().cloned() {
|
||||
self.i += 1;
|
||||
let s = self.arg();
|
||||
if any_too_deep(&e) {
|
||||
e.extend(s);
|
||||
continue;
|
||||
}
|
||||
let base = if e.len() == 1 { e.pop() } else { Some(group(std::mem::take(&mut e))) };
|
||||
e.push(script(base, t == Tok::Sup, s));
|
||||
}
|
||||
@@ -525,6 +563,22 @@ impl Lp {
|
||||
}
|
||||
|
||||
fn environment(&mut self, env: &str) -> Arg {
|
||||
let base = env.trim_end_matches('*');
|
||||
let mut col_jc = Vec::new();
|
||||
if base == "array" || base == "tabular" {
|
||||
let _ = self.opt();
|
||||
let spec = self.raw_arg();
|
||||
for ch in spec.chars() {
|
||||
match ch {
|
||||
'l' | 'L' => col_jc.push(ColJc::Left),
|
||||
'r' | 'R' => col_jc.push(ColJc::Right),
|
||||
'c' | 'C' => col_jc.push(ColJc::Center),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
} else if base == "aligned" {
|
||||
let _ = self.opt();
|
||||
}
|
||||
// Rows of `&`-separated cells up to `\end{…}`.
|
||||
let mut rows: Vec<Vec<Arg>> = vec![vec![]];
|
||||
loop {
|
||||
@@ -552,7 +606,6 @@ impl Lp {
|
||||
if rows.len() > 1 && rows.last().is_some_and(|r| r.iter().all(|c| c.is_empty())) {
|
||||
rows.pop();
|
||||
}
|
||||
let base = env.trim_end_matches('*');
|
||||
let delims = match base {
|
||||
"pmatrix" => Some((Some('('), Some(')'))),
|
||||
"bmatrix" => Some((Some('['), Some(']'))),
|
||||
@@ -570,8 +623,8 @@ impl Lp {
|
||||
elems: vec![vec![inner]],
|
||||
};
|
||||
match base {
|
||||
"matrix" | "pmatrix" | "bmatrix" | "Bmatrix" | "vmatrix" | "Vmatrix" | "smallmatrix" | "array" => {
|
||||
let m = MNode::Matrix { rows, col_jc: Vec::new() };
|
||||
"matrix" | "pmatrix" | "bmatrix" | "Bmatrix" | "vmatrix" | "Vmatrix" | "smallmatrix" | "array" | "tabular" => {
|
||||
let m = MNode::Matrix { rows, col_jc };
|
||||
match delims {
|
||||
Some((b, e)) => vec![wrap(m, b, e)],
|
||||
None => vec![m],
|
||||
@@ -629,12 +682,14 @@ fn script(base: Option<MNode>, sup: bool, s: Arg) -> MNode {
|
||||
}
|
||||
|
||||
/// Big operators without an operand take the atoms after them up to a relation or binary operator.
|
||||
fn nary_operands(a: Arg) -> Arg {
|
||||
/// `depth` is how deep the operators already sit; past [`WRAP_DEPTH`] they stay without operand
|
||||
/// (`\sum\sum\sum…` must not nest once per operator).
|
||||
fn nary_operands(a: Arg, depth: usize) -> Arg {
|
||||
let mut out: Arg = Vec::with_capacity(a.len());
|
||||
let mut it = a.into_iter().peekable();
|
||||
while let Some(n) = it.next() {
|
||||
match n {
|
||||
MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e } if e.is_empty() => {
|
||||
MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e } if e.is_empty() && depth < WRAP_DEPTH => {
|
||||
let mut body = Vec::new();
|
||||
while let Some(next) = it.peek() {
|
||||
let stop = match next {
|
||||
@@ -650,8 +705,14 @@ fn nary_operands(a: Arg) -> Arg {
|
||||
body.push(x);
|
||||
}
|
||||
}
|
||||
let body = nary_operands(body);
|
||||
out.push(MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e: merge_runs(body) });
|
||||
let body = merge_runs(nary_operands(body, depth + 1));
|
||||
if any_too_deep(&body) {
|
||||
// No room to wrap the operand: it follows the operator.
|
||||
out.push(MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e });
|
||||
out.extend(body);
|
||||
} else {
|
||||
out.push(MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e: body });
|
||||
}
|
||||
}
|
||||
other => out.push(other),
|
||||
}
|
||||
@@ -725,6 +786,21 @@ fn chars(text: &str, s: &mut String) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Text inside `\text{…}`: only what LaTeX would read as markup is escaped.
|
||||
fn text_chars(text: &str, s: &mut String) {
|
||||
for c in text.chars() {
|
||||
match c {
|
||||
'{' | '}' | '%' | '$' | '#' | '&' | '_' | '~' => {
|
||||
s.push('\\');
|
||||
s.push(c);
|
||||
}
|
||||
// `{}` ends the command name without adding a space (spaces count in text).
|
||||
'\\' => s.push_str("\\backslash{}"),
|
||||
c => s.push(c),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn delim_tex(c: Option<char>) -> String {
|
||||
match c {
|
||||
None => ".".into(),
|
||||
@@ -736,6 +812,17 @@ fn delim_tex(c: Option<char>) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn group_chr_tex(chr: char, top: bool) -> &'static str {
|
||||
match chr {
|
||||
'⏜' => "\\overparen",
|
||||
'⏝' => "\\underparen",
|
||||
'⎴' => "\\overbracket",
|
||||
'⎵' => "\\underbracket",
|
||||
_ if top => "\\overbrace",
|
||||
_ => "\\underbrace",
|
||||
}
|
||||
}
|
||||
|
||||
fn node(n: &MNode, s: &mut String, depth: usize) {
|
||||
match n {
|
||||
MNode::Run(r) => {
|
||||
@@ -764,7 +851,7 @@ fn node(n: &MNode, s: &mut String, depth: usize) {
|
||||
};
|
||||
s.push_str(open);
|
||||
if r.nor {
|
||||
s.push_str(&r.text);
|
||||
text_chars(&r.text, s);
|
||||
} else {
|
||||
chars(&r.text, s);
|
||||
}
|
||||
@@ -839,12 +926,16 @@ fn node(n: &MNode, s: &mut String, depth: usize) {
|
||||
s.push(' ');
|
||||
braced(e, s, depth);
|
||||
}
|
||||
MNode::Delim { beg, end, elems, .. } => {
|
||||
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("\\middle|");
|
||||
s.push_str(&mid);
|
||||
}
|
||||
arg(e, s, depth);
|
||||
}
|
||||
@@ -863,11 +954,10 @@ fn node(n: &MNode, s: &mut String, depth: usize) {
|
||||
s.push('_');
|
||||
braced(lim, s, depth);
|
||||
} else if let [MNode::GroupChr { chr, top, e: inner }] = e.as_slice() {
|
||||
s.push_str(if *top { "\\overbrace" } else { "\\underbrace" });
|
||||
s.push_str(group_chr_tex(*chr, *top));
|
||||
braced(inner, s, depth);
|
||||
s.push(if *upper { '^' } else { '_' });
|
||||
braced(lim, s, depth);
|
||||
let _ = chr;
|
||||
} else {
|
||||
s.push_str(if *upper { "\\overset" } else { "\\underset" });
|
||||
braced(lim, s, depth);
|
||||
@@ -903,8 +993,8 @@ fn node(n: &MNode, s: &mut String, depth: usize) {
|
||||
braced(e, s, depth);
|
||||
}
|
||||
MNode::Boxed { e } => braced(e, s, depth),
|
||||
MNode::GroupChr { top, e, .. } => {
|
||||
s.push_str(if *top { "\\overbrace" } else { "\\underbrace" });
|
||||
MNode::GroupChr { chr, top, e } => {
|
||||
s.push_str(group_chr_tex(*chr, *top));
|
||||
braced(e, s, depth);
|
||||
}
|
||||
MNode::EqArr { rows } => {
|
||||
@@ -996,6 +1086,15 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_character_latex_roundtrip() {
|
||||
for cmd in ["\\overparen{a+b}", "\\underparen{a+b}", "\\overbracket{x}", "\\underbracket{x}"] {
|
||||
let n = parse_latex(cmd);
|
||||
let tex = to_latex(&n);
|
||||
assert_eq!(tex, cmd);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_latex_never_panics() {
|
||||
for s in ["", "\\", "{", "}", "^", "_", "\\frac", "\\sqrt[", "\\left(", "\\right)", "\\begin{matrix", "&&\\\\", "{{{{", "\\end{x}", "a^^b__c"]
|
||||
@@ -1011,4 +1110,46 @@ mod tests {
|
||||
let deep = "\\hat".repeat(5000) + "x";
|
||||
let _ = parse_latex(&deep);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn array_column_specs_and_aligned_optional_args() {
|
||||
let n = parse_latex(r"\begin{array}{lcr} a & b & c \\ d & e & f \end{array}");
|
||||
let MNode::Matrix { rows, col_jc } = &n[0] else { panic!("{n:#?}") };
|
||||
assert_eq!(col_jc, &[ColJc::Left, ColJc::Center, ColJc::Right]);
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert_eq!(crate::math::to_linear(&rows[0][0]), "a");
|
||||
assert_eq!(crate::math::to_linear(&rows[0][1]), "b");
|
||||
assert_eq!(crate::math::to_linear(&rows[0][2]), "c");
|
||||
|
||||
let n = parse_latex(r"\begin{aligned}[t] x &= 1 \\ y &= 2 \end{aligned}");
|
||||
assert!(matches!(&n[0], MNode::EqArr { rows } if rows.len() == 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operatorname_starred_and_tag() {
|
||||
let n = parse_latex(r"\operatorname*{argmin}_{x} f(x)");
|
||||
assert!(matches!(&n[0], MNode::Lim { upper: false, .. }));
|
||||
|
||||
let n = parse_latex(r"E=mc^2 \tag{1}");
|
||||
let MNode::EqArr { rows } = &n[0] else { panic!("{n:#?}") };
|
||||
assert!(crate::math::has_number_mark(&rows[0]));
|
||||
|
||||
let n = parse_latex(r"E=mc^2 \tag*{(A)}");
|
||||
let MNode::EqArr { rows } = &n[0] else { panic!("{n:#?}") };
|
||||
assert!(crate::math::has_number_mark(&rows[0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delim_preserves_custom_separator() {
|
||||
let delim = MNode::Delim {
|
||||
beg: Some('{'),
|
||||
end: Some('}'),
|
||||
sep: Some('‖'),
|
||||
grow: true,
|
||||
shp_match: false,
|
||||
elems: vec![vec![MNode::Run(MRun::new("x"))], vec![MNode::Run(MRun::new("y"))]],
|
||||
};
|
||||
let tex = to_latex(&[delim]);
|
||||
assert!(tex.contains(r"\middle\|"), "Expected \\middle\\|, got: {tex}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//! an operator or space; a parenthesised operand loses its parentheses). `^`/`_` with nothing
|
||||
//! before them are pre-scripts. A top-level `#` numbers the equation (`E=mc^2#(1)`).
|
||||
|
||||
use crate::math::{Arg, FracKind, MAX_DEPTH, MClass, MNode, MRun, ScriptKind, is_nary_char, math_class, merge_runs};
|
||||
use crate::math::{Arg, FracKind, MAX_DEPTH, MClass, MNode, MRun, ScriptKind, any_too_deep, is_nary_char, math_class, merge_runs, too_deep};
|
||||
|
||||
/// Function names set upright and applied to the following operand.
|
||||
pub const FUNCTION_NAMES: &[&str] = &[
|
||||
@@ -135,6 +135,11 @@ impl P {
|
||||
'/' | '⁄' | '∕' | '¦' => {
|
||||
self.i += 1;
|
||||
let start = operand_start(&out);
|
||||
// A numerator that is already deep stays as it is: `/` is text then.
|
||||
if out.get(start..).is_some_and(any_too_deep) {
|
||||
out.push(text(c));
|
||||
continue;
|
||||
}
|
||||
let num = strip_parens(out.drain(start..).collect());
|
||||
let den = self.operand(stops);
|
||||
let kind = match c {
|
||||
@@ -156,20 +161,34 @@ impl P {
|
||||
} else {
|
||||
self.i += 1;
|
||||
let s = self.script_operand(stops);
|
||||
let base = out.pop();
|
||||
out.push(attach_script(base, c == '^', s));
|
||||
if out.last().is_some_and(too_deep) {
|
||||
out.push(text(c));
|
||||
out.extend(s);
|
||||
} else {
|
||||
let base = out.pop();
|
||||
out.push(attach_script(base, c == '^', s));
|
||||
}
|
||||
}
|
||||
}
|
||||
'┬' | '┴' => {
|
||||
self.i += 1;
|
||||
let lim = self.script_operand(stops);
|
||||
let e = out.pop().map(|n| vec![n]).unwrap_or_default();
|
||||
out.push(MNode::Lim { upper: c == '┴', e: strip_parens(e), lim });
|
||||
if out.last().is_some_and(too_deep) {
|
||||
out.push(text(c));
|
||||
out.extend(lim);
|
||||
} else {
|
||||
let e = out.pop().map(|n| vec![n]).unwrap_or_default();
|
||||
out.push(MNode::Lim { upper: c == '┴', e: strip_parens(e), lim });
|
||||
}
|
||||
}
|
||||
c if is_combining(c) => {
|
||||
self.i += 1;
|
||||
let e = out.pop().map(|n| strip_parens(vec![n])).unwrap_or_default();
|
||||
out.push(MNode::Acc { chr: c, e });
|
||||
if out.last().is_some_and(too_deep) {
|
||||
out.push(text(c));
|
||||
} else {
|
||||
let e = out.pop().map(|n| strip_parens(vec![n])).unwrap_or_default();
|
||||
out.push(MNode::Acc { chr: c, e });
|
||||
}
|
||||
}
|
||||
'▒' | '\u{2061}' => self.i += 1,
|
||||
' ' => {
|
||||
@@ -537,31 +556,39 @@ impl P {
|
||||
}
|
||||
out.pop()
|
||||
};
|
||||
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`).
|
||||
let deep = if whole { any_too_deep(out) } else { out.last().is_some_and(too_deep) };
|
||||
self.i += 1;
|
||||
if deep {
|
||||
let s = if matches!(c, '^' | '_' | '┬' | '┴') { self.script_operand(stops) } else { Vec::new() };
|
||||
out.push(text(c));
|
||||
out.extend(s);
|
||||
continue;
|
||||
}
|
||||
match c {
|
||||
'^' | '_' => {
|
||||
self.i += 1;
|
||||
let s = self.script_operand(stops);
|
||||
let b = base(out);
|
||||
out.push(attach_script(b, c == '^', s));
|
||||
}
|
||||
'┬' | '┴' if self.no_limits == 0 => {
|
||||
self.i += 1;
|
||||
'┬' | '┴' => {
|
||||
let lim = self.script_operand(stops);
|
||||
let b = base(out);
|
||||
out.push(MNode::Lim { upper: c == '┴', e: b.map(|n| vec![n]).unwrap_or_default(), lim });
|
||||
}
|
||||
c if is_combining(c) => {
|
||||
self.i += 1;
|
||||
_ => {
|
||||
let b = base(out);
|
||||
out.push(MNode::Acc { chr: c, e: b.map(|n| strip_parens(vec![n])).unwrap_or_default() });
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A script or limit: a parenthesised group, a structure, a number, a signed operand or one
|
||||
/// character.
|
||||
/// A script or limit: a parenthesised group, a structure, a number, a signed operand, a run of
|
||||
/// letters (and digits) or one other character.
|
||||
fn script_operand(&mut self, stops: &[char]) -> Arg {
|
||||
if self.tired() {
|
||||
return self.rest();
|
||||
@@ -585,7 +612,7 @@ impl P {
|
||||
Some(c) if is_prefix(c) || closer(c).is_some() => self.atom(stops),
|
||||
Some(c) if c.is_ascii_digit() => {
|
||||
let start = self.i;
|
||||
while self.peek().is_some_and(|k| k.is_ascii_digit() || k == '.') {
|
||||
while self.peek().is_some_and(|k| k.is_alphanumeric() || k == '.') {
|
||||
self.i += 1;
|
||||
}
|
||||
let t: String = self.c.get(start..self.i).unwrap_or(&[]).iter().collect();
|
||||
@@ -597,10 +624,16 @@ impl P {
|
||||
v.extend(self.script_operand(stops));
|
||||
v
|
||||
}
|
||||
Some(c) if c.is_ascii_alphabetic() => {
|
||||
// A function name as a script (`e^sin x` is rare): one letter.
|
||||
self.i += 1;
|
||||
vec![text(c)]
|
||||
Some(c) if c.is_alphabetic() => {
|
||||
// Word (#1609): the whole run of letters and digits up to a space or operator, so
|
||||
// `V_dd` is V with a subscript `dd`, exactly like `V_(dd)`.
|
||||
let start = self.i;
|
||||
while self.peek().is_some_and(|k| k.is_alphanumeric()) {
|
||||
self.i += 1;
|
||||
}
|
||||
let t: String = self.c.get(start..self.i).unwrap_or(&[]).iter().collect();
|
||||
// A known function name stays upright, as Word builds it.
|
||||
vec![MNode::Run(if FUNCTION_NAMES.contains(&t.as_str()) { MRun::plain(t) } else { MRun::new(t) })]
|
||||
}
|
||||
Some(c) if !stops.contains(&c) && c != ' ' && !is_closer(c) => {
|
||||
self.i += 1;
|
||||
|
||||
@@ -85,6 +85,7 @@ pub const SYMBOLS: &[(&str, char)] = &[
|
||||
("vee", '∨'),
|
||||
("wedge", '∧'),
|
||||
("setminus", '∖'),
|
||||
("backslash", '\\'),
|
||||
("wr", '≀'),
|
||||
("diamond", '⋄'),
|
||||
("dagger", '†'),
|
||||
|
||||
+71
-13
@@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::props::{Align, CharProps};
|
||||
use crate::section::NumFormat;
|
||||
use crate::{DocError, free_id};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -75,6 +76,17 @@ pub struct AbstractNum {
|
||||
pub id: u32,
|
||||
pub name: Option<String>,
|
||||
pub levels: Vec<Level>,
|
||||
/// Word's list identity (`w:nsid`, 8 hex digits), which it uses to match lists across
|
||||
/// documents (paste, compare); kept from the file.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub nsid: Option<String>,
|
||||
/// The list template the definition came from (`w:tmpl`); kept from the file.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tmpl: Option<String>,
|
||||
/// `w:multiLevelType` from the file (`singleLevel`, `multilevel`, `hybridMultilevel`);
|
||||
/// `None`: derived from the levels.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub multi_level_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
@@ -144,6 +156,11 @@ impl ListKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// The bullet a level of a plain bulleted list shows: • ○ ▪ repeating (`level` is zero-based).
|
||||
pub fn default_bullet(level: usize) -> char {
|
||||
['•', '○', '▪'][level % 3]
|
||||
}
|
||||
|
||||
fn bullet_level(i: usize, c: char) -> Level {
|
||||
Level {
|
||||
format: NumFormat::Bullet,
|
||||
@@ -176,8 +193,8 @@ pub fn levels_for(kind: ListKind) -> Vec<Level> {
|
||||
let lvl = |format: NumFormat, text: String| Level { format, text, indent: ind, hanging: 18.0, ..Level::default() };
|
||||
let n = i + 1;
|
||||
match kind {
|
||||
ListKind::Bullet => bullet_level(i, ['•', '○', '▪'][i % 3]),
|
||||
ListKind::BulletChar(c) => bullet_level(i, if i == 0 { c } else { ['•', '○', '▪'][i % 3] }),
|
||||
ListKind::Bullet => bullet_level(i, default_bullet(i)),
|
||||
ListKind::BulletChar(c) => bullet_level(i, if i == 0 { c } else { default_bullet(i) }),
|
||||
ListKind::Numbered => lvl(cyc(NumFormat::Decimal, NumFormat::LowerLetter, NumFormat::LowerRoman), format!("%{n}.")),
|
||||
ListKind::NumberedParen => lvl(cyc(NumFormat::Decimal, NumFormat::LowerLetter, NumFormat::LowerRoman), format!("%{n})")),
|
||||
ListKind::UpperLetter => lvl(cyc(NumFormat::UpperLetter, NumFormat::LowerLetter, NumFormat::LowerRoman), format!("%{n}.")),
|
||||
@@ -219,20 +236,40 @@ impl Numbering {
|
||||
}
|
||||
self.abstract_of(num)?.levels.get(level as usize)
|
||||
}
|
||||
/// Create a new list of `kind`; returns the `num` id.
|
||||
/// Create a new list of `kind`; returns the `num` id. List ids run out only past `u32::MAX`
|
||||
/// lists, more than memory holds; then nothing is added and the id is 0, "no list".
|
||||
pub fn add_list(&mut self, kind: ListKind) -> u32 {
|
||||
let aid = self.abstracts.iter().map(|a| a.id + 1).max().unwrap_or(0);
|
||||
self.abstracts.push(AbstractNum { id: aid, name: None, levels: levels_for(kind) });
|
||||
let nid = self.nums.iter().map(|n| n.id + 1).max().unwrap_or(1).max(1);
|
||||
self.nums.push(Num { id: nid, abstract_id: aid, ..Default::default() });
|
||||
nid
|
||||
self.try_add_list(kind).unwrap_or(0)
|
||||
}
|
||||
/// A new `num` that restarts numbering of an existing list.
|
||||
/// [`Numbering::add_list`], or an error, adding nothing, when list ids are all taken.
|
||||
pub fn try_add_list(&mut self, kind: ListKind) -> crate::Result<u32> {
|
||||
self.try_add_levels(None, levels_for(kind))
|
||||
}
|
||||
/// A new list definition (`name`, `levels`) and a `num` for it; returns the `num` id. Both ids
|
||||
/// are [`free_id`]s, so the largest ids a file can hold don't make them collide with its lists;
|
||||
/// an error, adding nothing, when they are all taken.
|
||||
pub fn try_add_levels(&mut self, name: Option<String>, levels: Vec<Level>) -> crate::Result<u32> {
|
||||
let aid = free_id(self.abstracts.iter().map(|a| a.id).max(), self.abstracts.iter().map(|a| a.id), 0);
|
||||
let (Some(aid), Some(nid)) = (aid, self.free_num_id()) else { return Err(DocError::Invalid("no free list id".into())) };
|
||||
self.abstracts.push(AbstractNum { id: aid, name, levels, ..Default::default() });
|
||||
self.nums.push(Num { id: nid, abstract_id: aid, ..Default::default() });
|
||||
Ok(nid)
|
||||
}
|
||||
/// A `num` id no list has (`num` 0 means "no list"), `None` when they are all taken.
|
||||
fn free_num_id(&self) -> Option<u32> {
|
||||
free_id(self.nums.iter().map(|n| n.id).max(), self.nums.iter().map(|n| n.id), 1)
|
||||
}
|
||||
/// A new `num` that restarts numbering of an existing list (`None`: no such list, or list ids
|
||||
/// are all taken).
|
||||
pub fn restart(&mut self, num: u32) -> Option<u32> {
|
||||
let a = self.num(num)?.abstract_id;
|
||||
let nid = self.nums.iter().map(|n| n.id + 1).max().unwrap_or(1).max(1);
|
||||
let starts = self.abstracts.iter().find(|x| x.id == a).map(|x| x.levels.iter().enumerate().map(|(i, l)| (i as u8, l.start)).collect());
|
||||
self.nums.push(Num { id: nid, abstract_id: a, start_overrides: starts.unwrap_or_default(), ..Default::default() });
|
||||
let source = self.num(num)?;
|
||||
let a = source.abstract_id;
|
||||
let level_overrides = source.level_overrides.clone();
|
||||
let nid = self.free_num_id()?;
|
||||
// Every level starts again at its own effective start, so a level this list defines itself keeps its start.
|
||||
let levels = self.abstracts.iter().find(|x| x.id == a).map_or(0, |x| x.levels.len());
|
||||
let start_overrides = (0..levels).filter_map(|i| self.level(num, i as u8).map(|l| (i as u8, l.start))).collect();
|
||||
self.nums.push(Num { id: nid, abstract_id: a, start_overrides, level_overrides });
|
||||
Some(nid)
|
||||
}
|
||||
/// Find an existing list whose first level matches `kind`, so consecutive bullet clicks reuse it.
|
||||
@@ -256,6 +293,12 @@ pub struct Counters {
|
||||
}
|
||||
|
||||
impl Counters {
|
||||
/// The current value of each level of `num`'s list (0 for a level not counted yet).
|
||||
pub fn values(&self, numbering: &Numbering, num: u32) -> Option<[u32; 9]> {
|
||||
let abs = numbering.abstract_of(num)?;
|
||||
Some(self.state.get(&abs.id).copied().unwrap_or([0; 9]))
|
||||
}
|
||||
|
||||
/// Advance the counter for `num`/`level` and return the label (`"1."`, `"a)"`, `"•"`).
|
||||
pub fn next_label(&mut self, numbering: &Numbering, num: u32, level: u8) -> Option<(String, Level)> {
|
||||
let n = numbering.num(num)?;
|
||||
@@ -432,4 +475,19 @@ mod tests {
|
||||
assert_eq!(c.next_label(&n, b, 0).unwrap().0, "•");
|
||||
assert_eq!(n.find_kind(ListKind::Bullet), Some(b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restart_keeps_the_list_s_own_level_definitions() {
|
||||
let mut n = Numbering::default();
|
||||
let id = n.add_list(ListKind::Numbered);
|
||||
let own = Level { format: NumFormat::UpperRoman, text: "Part %1".into(), indent: 90.0, hanging: 40.0, start: 5, ..Level::default() };
|
||||
if let Some(num) = n.nums.iter_mut().find(|x| x.id == id) {
|
||||
num.level_overrides.push((0, own.clone()));
|
||||
}
|
||||
let r = n.restart(id).unwrap();
|
||||
assert_eq!(n.num(r).unwrap().level_overrides, vec![(0, own.clone())]);
|
||||
assert_eq!(n.level(r, 0), Some(&own));
|
||||
let mut c = Counters::default();
|
||||
assert_eq!(c.next_label(&n, r, 0).unwrap().0, "Part V");
|
||||
}
|
||||
}
|
||||
|
||||
+143
-26
@@ -216,6 +216,10 @@ pub struct Float {
|
||||
/// 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,
|
||||
}
|
||||
|
||||
/// Longest alternative text kept (characters): hostile files and commands are capped.
|
||||
@@ -288,7 +292,11 @@ pub enum ShapeKind {
|
||||
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,
|
||||
Arrow,
|
||||
@@ -312,6 +320,60 @@ pub enum NoteKind {
|
||||
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")]
|
||||
@@ -380,7 +442,7 @@ pub enum InlineObject {
|
||||
crop: [f32; 4],
|
||||
/// The OLE object (embedded or linked file) this picture shows, as read from a file:
|
||||
/// saving writes the object back, not only its picture. See [`crate::graphic::Embedded`].
|
||||
#[serde(skip)]
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
ole: Option<std::sync::Arc<crate::graphic::Embedded>>,
|
||||
},
|
||||
/// A chart or SmartArt diagram (see [`crate::graphic`]).
|
||||
@@ -444,11 +506,20 @@ pub enum InlineObject {
|
||||
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
|
||||
@@ -458,6 +529,9 @@ pub enum InlineObject {
|
||||
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,
|
||||
@@ -533,6 +607,17 @@ pub enum InlineObject {
|
||||
#[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,
|
||||
@@ -662,6 +747,14 @@ impl InlineObject {
|
||||
})
|
||||
.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!(
|
||||
@@ -740,6 +833,7 @@ impl InlineObject {
|
||||
InlineObject::Field { result, .. } => result,
|
||||
InlineObject::Equation { linear, .. } => linear,
|
||||
InlineObject::Ruby { base, .. } => base,
|
||||
InlineObject::PositionalTab { .. } => "\t",
|
||||
InlineObject::Opaque { text, .. } => text,
|
||||
_ => "",
|
||||
}
|
||||
@@ -854,7 +948,7 @@ impl Paragraph {
|
||||
k += 1;
|
||||
continue;
|
||||
}
|
||||
let font = runs.peek().filter(|(r, _)| r.contains(&i)).and_then(|(_, p)| p.font.as_deref());
|
||||
let font = runs.peek().filter(|(r, _)| r.contains(&i)).and_then(|(_, p)| p.shown_font());
|
||||
out.push(font.and_then(|f| crate::symbol_fonts::symbol_char(f, c)).unwrap_or(c));
|
||||
}
|
||||
out
|
||||
@@ -1029,32 +1123,32 @@ impl Paragraph {
|
||||
|
||||
/// Merge adjacent equal runs and drop empty ones.
|
||||
pub fn normalize(&mut self) {
|
||||
// Runs must end on character boundaries and cover the text exactly (never expected, but
|
||||
// files lie): clamp each end to the text, push it past a split character, pad the tail.
|
||||
let n = self.text.len();
|
||||
let mut end = 0usize;
|
||||
let mut out: Vec<Run> = Vec::with_capacity(self.runs.len());
|
||||
for r in self.runs.drain(..) {
|
||||
if r.len == 0 {
|
||||
let mut e = end.saturating_add(r.len).min(n);
|
||||
while !self.text.is_char_boundary(e) {
|
||||
e += 1;
|
||||
}
|
||||
if e == end {
|
||||
continue;
|
||||
}
|
||||
match out.last_mut() {
|
||||
Some(l) if l.props == r.props => l.len += r.len,
|
||||
_ => out.push(r),
|
||||
Some(l) if l.props == r.props => l.len += e - end,
|
||||
_ => out.push(Run { len: e - end, props: r.props }),
|
||||
}
|
||||
end = e;
|
||||
}
|
||||
if end < n {
|
||||
match out.last_mut() {
|
||||
Some(l) => l.len += n - end,
|
||||
None => out.push(Run { len: n, props: self.mark.clone() }),
|
||||
}
|
||||
}
|
||||
self.runs = out;
|
||||
// Repair a run list that doesn't cover the text exactly (never expected, but files lie).
|
||||
let total: usize = self.runs.iter().map(|r| r.len).sum();
|
||||
if total != self.text.len() {
|
||||
if total < self.text.len() {
|
||||
let p = self.runs.last().map(|r| r.props.clone()).unwrap_or_else(|| self.mark.clone());
|
||||
self.runs.push(Run { len: self.text.len() - total, props: p });
|
||||
} else {
|
||||
let mut keep = self.text.len();
|
||||
for r in &mut self.runs {
|
||||
r.len = r.len.min(keep);
|
||||
keep -= r.len;
|
||||
}
|
||||
self.runs.retain(|r| r.len > 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete `a..b` (bytes). Objects inside are removed.
|
||||
@@ -1306,7 +1400,7 @@ impl Paragraph {
|
||||
let mut a = 0;
|
||||
let mut prev_end = false;
|
||||
for (i, c) in t.char_indices() {
|
||||
if i >= off {
|
||||
if i > off {
|
||||
break;
|
||||
}
|
||||
if prev_end && c != ' ' {
|
||||
@@ -1319,7 +1413,9 @@ impl Paragraph {
|
||||
}
|
||||
}
|
||||
let mut b = t.len();
|
||||
let mut ended = false;
|
||||
// A caret on the space right after a sentence's end belongs to that sentence; a terminator
|
||||
// directly followed by text ("Hello.World") doesn't end the sentence the caret is in.
|
||||
let mut ended = t.get(..off).unwrap_or("").ends_with(['.', '!', '?']) && t.get(off..).unwrap_or("").starts_with(' ');
|
||||
for (i, c) in t.get(off..).unwrap_or("").char_indices() {
|
||||
if ended && c != ' ' {
|
||||
b = off + i;
|
||||
@@ -1337,6 +1433,19 @@ impl Paragraph {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sentence_at_sentence_start() {
|
||||
let p = Paragraph::with_text("Hello. World", CharProps::default());
|
||||
assert_eq!(p.sentence_at(0), (0, 7));
|
||||
assert_eq!(p.sentence_at(6), (0, 7));
|
||||
assert_eq!(p.sentence_at(7), (7, 12));
|
||||
assert_eq!(p.sentence_at(8), (7, 12));
|
||||
assert_eq!(p.sentence_at(99), (7, 12));
|
||||
let p = Paragraph::with_text("Hello.World", CharProps::default());
|
||||
assert_eq!(p.sentence_at(6), (6, 11));
|
||||
assert_eq!(p.sentence_at(8), (6, 11));
|
||||
}
|
||||
|
||||
fn bold() -> CharProps {
|
||||
CharProps { bold: Some(true), ..Default::default() }
|
||||
}
|
||||
@@ -1345,8 +1454,12 @@ mod tests {
|
||||
fn text_without_takes_ranges_in_any_order() {
|
||||
// Multi-byte chars at range edges, an object, and ranges unsorted, overlapping, nested and empty.
|
||||
let mut p = Paragraph::with_text("añb€c", CharProps::default());
|
||||
p.insert_object(3, InlineObject::Field { instr: "PAGE".into(), result: "7".into(), locked: false, code: false }, &CharProps::default())
|
||||
.unwrap();
|
||||
p.insert_object(
|
||||
3,
|
||||
InlineObject::Field { instr: "PAGE".into(), result: "7".into(), locked: false, dirty: false, code: false, form: String::new() },
|
||||
&CharProps::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let text = p.text.clone();
|
||||
let naive = |dropped: &[std::ops::Range<usize>]| {
|
||||
let mut out = String::new();
|
||||
@@ -1415,8 +1528,12 @@ mod tests {
|
||||
#[test]
|
||||
fn objects_track_text() {
|
||||
let mut p = Paragraph::with_text("ab", CharProps::default());
|
||||
p.insert_object(1, InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, code: false }, &CharProps::default())
|
||||
.unwrap();
|
||||
p.insert_object(
|
||||
1,
|
||||
InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() },
|
||||
&CharProps::default(),
|
||||
)
|
||||
.unwrap();
|
||||
p.insert_object(0, InlineObject::BookmarkStart { name: "x".into() }, &CharProps::default()).unwrap();
|
||||
assert_eq!(p.objects.len(), 2);
|
||||
assert!(matches!(p.object_at(0), Some(InlineObject::BookmarkStart { .. })));
|
||||
|
||||
+233
-7
@@ -155,6 +155,46 @@ impl Underline {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -286,6 +326,11 @@ pub struct CharProps {
|
||||
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>,
|
||||
@@ -331,6 +376,10 @@ pub struct CharProps {
|
||||
/// 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")]
|
||||
@@ -372,6 +421,10 @@ pub struct CharProps {
|
||||
/// 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>,
|
||||
/// 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>,
|
||||
@@ -386,6 +439,60 @@ pub struct CharProps {
|
||||
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.
|
||||
@@ -440,9 +547,9 @@ impl CharProps {
|
||||
/// 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 {
|
||||
drop_same_fields!(self, base; style, font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight,
|
||||
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,
|
||||
cs, font_cs, size_cs, bold_cs, italic_cs, lang_bidi, link, ins, del);
|
||||
cs, font_cs, size_cs, bold_cs, italic_cs, lang_bidi, east_asian_layout, link, ins, del, ligatures);
|
||||
self
|
||||
}
|
||||
|
||||
@@ -451,9 +558,9 @@ impl CharProps {
|
||||
/// 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) {
|
||||
overlay_fields!(self, patch; style, font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight,
|
||||
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,
|
||||
cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, link, ins, del, fmt_change);
|
||||
cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, east_asian_layout, link, ins, del, fmt_change, ligatures);
|
||||
if let Some(p) = &patch.text_effects {
|
||||
self.text_effects.get_or_insert_with(Default::default).overlay(p);
|
||||
}
|
||||
@@ -465,10 +572,29 @@ impl CharProps {
|
||||
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(), ..Default::default() }
|
||||
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 {
|
||||
@@ -554,6 +680,10 @@ impl TabLeader {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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).
|
||||
@@ -789,6 +919,16 @@ pub struct ParaProps {
|
||||
/// 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")]
|
||||
@@ -829,7 +969,7 @@ impl ParaProps {
|
||||
pub fn overlay(&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, keep_next, keep_lines, page_break_before, widow_control, outline_level, numbering, tabs, shading, borders,
|
||||
suppress_hyphens, suppress_line_numbers, bidi, drop_cap, frame, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn,
|
||||
suppress_hyphens, suppress_line_numbers, bidi, drop_cap, drop_cap_margin, drop_cap_distance, drop_cap_font, frame, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn,
|
||||
snap_to_grid);
|
||||
}
|
||||
pub fn overlaid(mut self, patch: &ParaProps) -> ParaProps {
|
||||
@@ -1074,11 +1214,31 @@ pub struct RowProps {
|
||||
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 {
|
||||
@@ -1103,11 +1263,20 @@ pub struct CellProps {
|
||||
pub vmerge: VMerge,
|
||||
pub shading: Option<Rgb>,
|
||||
pub borders: Option<Borders>,
|
||||
pub valign: VAlign,
|
||||
/// 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.
|
||||
@@ -1118,6 +1287,17 @@ pub struct CellProps {
|
||||
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])))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1166,6 +1346,37 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The 16 `w14:ligatures` values of [MS-DOCX], each to its flags and back.
|
||||
#[test]
|
||||
fn ligature_names() {
|
||||
let l = |standard, contextual, historical, discretional| Ligatures { standard, contextual, historical, discretional };
|
||||
for (name, flags) in [
|
||||
("none", l(false, false, false, false)),
|
||||
("standard", l(true, false, false, false)),
|
||||
("contextual", l(false, true, false, false)),
|
||||
("historical", l(false, false, true, false)),
|
||||
("discretional", l(false, false, false, true)),
|
||||
("standardContextual", l(true, true, false, false)),
|
||||
("standardHistorical", l(true, false, true, false)),
|
||||
("contextualHistorical", l(false, true, true, false)),
|
||||
("standardDiscretional", l(true, false, false, true)),
|
||||
("contextualDiscretional", l(false, true, false, true)),
|
||||
("historicalDiscretional", l(false, false, true, true)),
|
||||
("standardContextualHistorical", l(true, true, true, false)),
|
||||
("standardContextualDiscretional", l(true, true, false, true)),
|
||||
("standardHistoricalDiscretional", l(true, false, true, true)),
|
||||
("contextualHistoricalDiscretional", l(false, true, true, true)),
|
||||
("all", l(true, true, true, true)),
|
||||
] {
|
||||
assert_eq!(Ligatures::from_ooxml(name), Some(flags), "{name}");
|
||||
assert_eq!(flags.ooxml(), name);
|
||||
}
|
||||
assert_eq!(Ligatures::all().count(), 16);
|
||||
for bad in ["", "Standard", "contextualStandard", "standardstandard", "allx"] {
|
||||
assert_eq!(Ligatures::from_ooxml(bad), None, "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_direction_names() {
|
||||
let mut d = TextDirection::Horizontal;
|
||||
@@ -1227,4 +1438,19 @@ mod tests {
|
||||
let back: CharProps = serde_json::from_str(&s).unwrap();
|
||||
assert_eq!(back, p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbol_font_is_optional_in_json_and_survives_font_changes() {
|
||||
// Older documents (no `symFont`) still load.
|
||||
let old: CharProps = serde_json::from_str(r#"{"font":"Arial"}"#).unwrap();
|
||||
assert_eq!(old.sym_font, None);
|
||||
let p = CharProps { font: Some("Arial".into()), sym_font: Some("Wingdings".into()), ..Default::default() };
|
||||
let s = serde_json::to_string(&p).unwrap();
|
||||
assert!(s.contains(r#""symFont":"Wingdings""#), "{s}");
|
||||
assert_eq!(serde_json::from_str::<CharProps>(&s).unwrap(), p);
|
||||
// A font change keeps the symbol's own font; Clear Formatting too.
|
||||
let changed = p.clone().overlaid(&CharProps { font: Some("Times New Roman".into()), ..Default::default() });
|
||||
assert_eq!(changed.sym_font.as_deref(), Some("Wingdings"));
|
||||
assert_eq!(p.cleared().sym_font.as_deref(), Some("Wingdings"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ use crate::styles::{StyleKind, StyleSheet};
|
||||
/// Automatic (HTML-style) paragraph spacing, points: what Word for Windows gives `w:beforeAutospacing`
|
||||
/// and `w:afterAutospacing` at any font size (measured).
|
||||
pub const AUTO_SPACING: f32 = 14.0;
|
||||
/// The largest drop cap distance from the text, in points (22", as Word's dialog allows).
|
||||
pub const MAX_DROP_CAP_DISTANCE: f32 = 1584.0;
|
||||
|
||||
/// Concrete character formatting.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -39,6 +41,10 @@ pub struct ResolvedChar {
|
||||
/// Kerning: the smallest font size, in points, that is kerned (`w:kern`); 0 = never kerned.
|
||||
#[serde(default)]
|
||||
pub kern: f32,
|
||||
/// OpenType ligatures (`w14:ligatures`); `None` = the shaper's default (standard and
|
||||
/// contextual).
|
||||
#[serde(default)]
|
||||
pub ligatures: Option<crate::props::Ligatures>,
|
||||
pub outline: bool,
|
||||
pub shadow: bool,
|
||||
pub emboss: bool,
|
||||
@@ -70,7 +76,8 @@ pub struct ResolvedChar {
|
||||
impl ResolvedChar {
|
||||
fn from(c: &CharProps) -> ResolvedChar {
|
||||
ResolvedChar {
|
||||
font: c.font.clone().unwrap_or_else(|| crate::styles::BODY_FONT.to_string()),
|
||||
// A symbol character is shown in its own font (`w:sym`), whatever the run's font.
|
||||
font: c.shown_font().map(str::to_string).unwrap_or_else(|| crate::styles::BODY_FONT.to_string()),
|
||||
size: c.size.unwrap_or(11.0).clamp(1.0, 1638.0),
|
||||
bold: c.bold.unwrap_or(false),
|
||||
italic: c.italic.unwrap_or(false),
|
||||
@@ -90,6 +97,7 @@ impl ResolvedChar {
|
||||
scale: c.scale.unwrap_or(100.0).clamp(1.0, 600.0),
|
||||
position: c.position.unwrap_or(0.0).clamp(-800.0, 800.0),
|
||||
kern: c.kern.filter(|k| k.is_finite()).unwrap_or(0.0).clamp(0.0, 1638.0),
|
||||
ligatures: c.ligatures,
|
||||
outline: c.outline.unwrap_or(false),
|
||||
shadow: c.shadow.unwrap_or(false),
|
||||
emboss: c.emboss.unwrap_or(false),
|
||||
@@ -172,6 +180,15 @@ pub struct ResolvedPara {
|
||||
pub borders: Option<Borders>,
|
||||
pub bidi: bool,
|
||||
pub drop_cap: u8,
|
||||
/// The drop cap hangs in the margin (see `ParaProps::drop_cap_margin`).
|
||||
#[serde(default)]
|
||||
pub drop_cap_margin: bool,
|
||||
/// The drop cap's distance from the text, in points.
|
||||
#[serde(default)]
|
||||
pub drop_cap_distance: f32,
|
||||
/// The drop cap letter's font family; `None` = the text's.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub drop_cap_font: Option<String>,
|
||||
/// A text frame the paragraph sits in (see `ParaProps::frame`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub frame: Option<crate::props::TextFrame>,
|
||||
@@ -250,6 +267,9 @@ impl ResolvedPara {
|
||||
borders: p.borders.filter(Borders::any_visible),
|
||||
bidi: p.bidi.unwrap_or(false),
|
||||
drop_cap: p.drop_cap.unwrap_or(0).min(10),
|
||||
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),
|
||||
suppress_hyphens: p.suppress_hyphens.unwrap_or(false),
|
||||
suppress_line_numbers: p.suppress_line_numbers.unwrap_or(false),
|
||||
@@ -349,6 +369,8 @@ impl StyleSheet {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Only a character itself has a symbol font of its own (`w:sym`); a style never gives one.
|
||||
cp.sym_font = None;
|
||||
let mut direct = c.clone();
|
||||
direct.style = None;
|
||||
cp.overlay(&direct);
|
||||
@@ -360,6 +382,19 @@ impl StyleSheet {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// #2036: only a character itself (direct formatting) has a symbol font of its own; a
|
||||
/// style never gives one to its text.
|
||||
#[test]
|
||||
fn a_style_never_gives_text_a_symbol_font() {
|
||||
let mut s = StyleSheet::builtin();
|
||||
if let Some(n) = s.get_mut("Normal") {
|
||||
n.chr.sym_font = Some("Wingdings".into());
|
||||
}
|
||||
assert_ne!(s.resolve_char(None, &CharProps::default()).font, "Wingdings");
|
||||
let direct = CharProps { sym_font: Some("Wingdings".into()), ..Default::default() };
|
||||
assert_eq!(s.resolve_char(None, &direct).font, "Wingdings");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heading_resolves() {
|
||||
let s = StyleSheet::builtin();
|
||||
|
||||
@@ -2,7 +2,36 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::props::{Borders, VAlign};
|
||||
use crate::props::Borders;
|
||||
|
||||
/// How a section's text sits on its pages, top to bottom (`w:vAlign`, ECMA-376 §17.6.23,
|
||||
/// ST_VerticalJc). Table cells and text boxes use [`crate::props::VAlign`], which has no
|
||||
/// Justified.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PageVAlign {
|
||||
#[default]
|
||||
Top,
|
||||
Center,
|
||||
/// Justified (`both`): the room left on each page is shared out equally between the page's
|
||||
/// paragraphs and tables, so the text runs from the top margin to the bottom one.
|
||||
#[serde(alias = "both", alias = "justified")]
|
||||
Justify,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
impl PageVAlign {
|
||||
/// From a command or file value: `top|center|justify|bottom` (also `both`, `justified`,
|
||||
/// `middle`); anything else is Top.
|
||||
pub fn parse(v: &str) -> PageVAlign {
|
||||
match v {
|
||||
"center" | "middle" => PageVAlign::Center,
|
||||
"justify" | "justified" | "both" => PageVAlign::Justify,
|
||||
"bottom" => PageVAlign::Bottom,
|
||||
_ => PageVAlign::Top,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -227,7 +256,7 @@ impl NumFormat {
|
||||
NumFormat::ALL.into_iter().find(|f| f.ooxml() == s).unwrap_or(NumFormat::Decimal)
|
||||
}
|
||||
/// Format `n` (1-based). Formats WordCraft doesn't draw yet (Hebrew, Arabic, Hindi, Thai,
|
||||
/// Vietnamese, Russian, text amounts) show decimal, as do values past a sequence's end
|
||||
/// Vietnamese, text amounts) show decimal, as do values past a sequence's end
|
||||
/// (the 21st item of ①…⑳).
|
||||
pub fn format(self, n: u32) -> String {
|
||||
use crate::cjk_numbers as cjk;
|
||||
@@ -237,6 +266,8 @@ impl NumFormat {
|
||||
NumFormat::LowerRoman => roman(n),
|
||||
NumFormat::UpperLetter => letters(n).to_uppercase(),
|
||||
NumFormat::LowerLetter => letters(n),
|
||||
NumFormat::RussianUpper => russian_letters(n).to_uppercase(),
|
||||
NumFormat::RussianLower => russian_letters(n),
|
||||
NumFormat::Ordinal => format!("{n}{}", ordinal_suffix(n)),
|
||||
NumFormat::CardinalText => cardinal_text(n),
|
||||
NumFormat::OrdinalText => ordinal_text(n),
|
||||
@@ -275,8 +306,6 @@ impl NumFormat {
|
||||
| NumFormat::DecimalHalfWidth
|
||||
| NumFormat::Hex
|
||||
| NumFormat::VietnameseCounting
|
||||
| NumFormat::RussianLower
|
||||
| NumFormat::RussianUpper
|
||||
| NumFormat::Hebrew1
|
||||
| NumFormat::Hebrew2
|
||||
| NumFormat::ArabicAlpha
|
||||
@@ -354,6 +383,22 @@ pub fn letters(n: u32) -> String {
|
||||
std::iter::repeat_n(c, k as usize).collect()
|
||||
}
|
||||
|
||||
/// The 29 letters Word counts Russian lists with: а..я without ё, й, ъ and ь (ы stays).
|
||||
const RUSSIAN_LETTERS: [char; 29] =
|
||||
['а', 'б', 'в', 'г', 'д', 'е', 'ж', 'з', 'и', 'к', 'л', 'м', 'н', 'о', 'п', 'р', 'с', 'т', 'у', 'ф', 'х', 'ц', 'ч', 'ш', 'щ', 'ы', 'э', 'ю', 'я'];
|
||||
|
||||
/// Word-style Russian letters: а..я, аа..яя, ааа.. (repeated letter, as [`letters`]).
|
||||
pub fn russian_letters(n: u32) -> String {
|
||||
if n == 0 {
|
||||
return String::new();
|
||||
}
|
||||
let len = RUSSIAN_LETTERS.len() as u32;
|
||||
let n = n.min(len * 30);
|
||||
let k = (n - 1) / len + 1;
|
||||
let c = RUSSIAN_LETTERS.get(((n - 1) % len) as usize).copied().unwrap_or('а');
|
||||
std::iter::repeat_n(c, k as usize).collect()
|
||||
}
|
||||
|
||||
const ONES: [&str; 20] = [
|
||||
"zero",
|
||||
"one",
|
||||
@@ -676,8 +721,16 @@ pub struct SectionProps {
|
||||
pub title_page: bool,
|
||||
pub page_num_start: Option<u32>,
|
||||
pub page_num_format: NumFormat,
|
||||
/// The file carries a `w:pgNumType` element that sets nothing else (no start, decimal
|
||||
/// format), so saving writes it back rather than dropping it.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub page_num_set: bool,
|
||||
pub line_numbers: Option<LineNumbering>,
|
||||
pub valign: VAlign,
|
||||
pub valign: PageVAlign,
|
||||
/// Form protection of the section (`w:formProt`, ECMA-376 §17.6.6): `Some` when the file
|
||||
/// states it. Kept so saving writes it back; editing does not act on it.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub form_prot: Option<bool>,
|
||||
pub page_borders: Option<Borders>,
|
||||
/// Text direction / bidi section.
|
||||
pub rtl: bool,
|
||||
@@ -685,6 +738,9 @@ pub struct SectionProps {
|
||||
/// sections turn the page's text area (headers and footers stay horizontal).
|
||||
#[serde(skip_serializing_if = "is_horizontal")]
|
||||
pub text_direction: crate::props::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,
|
||||
/// Tracked change of the section's properties (`w:sectPrChange`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fmt_change: Option<Box<crate::props::PropChange<SectionProps>>>,
|
||||
@@ -718,11 +774,14 @@ impl Default for SectionProps {
|
||||
title_page: false,
|
||||
page_num_start: None,
|
||||
page_num_format: NumFormat::Decimal,
|
||||
page_num_set: false,
|
||||
line_numbers: None,
|
||||
valign: VAlign::Top,
|
||||
valign: PageVAlign::Top,
|
||||
form_prot: None,
|
||||
page_borders: None,
|
||||
rtl: false,
|
||||
text_direction: crate::props::TextDirection::Horizontal,
|
||||
text_direction_set: false,
|
||||
fmt_change: None,
|
||||
doc_grid: None,
|
||||
footnote_pr: NoteProps::default(),
|
||||
@@ -736,6 +795,16 @@ fn is_horizontal(d: &crate::props::TextDirection) -> bool {
|
||||
}
|
||||
|
||||
impl SectionProps {
|
||||
/// Join the section before this one into it, as when the break between them is deleted:
|
||||
/// this section's setup stays, as in Word, and the headers and footers it leaves out (linked
|
||||
/// to the previous section) are taken from `before`, so its text keeps showing them.
|
||||
pub fn absorb(&mut self, before: &SectionProps) {
|
||||
for (own, prev) in [(&mut self.headers, &before.headers), (&mut self.footers, &before.footers)] {
|
||||
own.default = own.default.or(prev.default);
|
||||
own.first = own.first.or(prev.first);
|
||||
own.even = own.even.or(prev.even);
|
||||
}
|
||||
}
|
||||
/// Width of the text area, points.
|
||||
pub fn text_width(&self) -> f32 {
|
||||
(self.page_w - self.margin_left - self.margin_right - self.gutter).max(18.0)
|
||||
@@ -831,6 +900,8 @@ mod tests {
|
||||
assert_eq!(NumFormat::ChineseCountingThousand.format(101), "一百零一");
|
||||
assert_eq!(NumFormat::DecimalEnclosedCircle.format(3), "③");
|
||||
assert_eq!(NumFormat::Hebrew1.format(3), "3", "formats not drawn yet show decimal");
|
||||
assert_eq!(NumFormat::RussianLower.format(0), "");
|
||||
assert_eq!(NumFormat::RussianUpper.format(u32::MAX).chars().count(), 30);
|
||||
for f in NumFormat::ALL {
|
||||
assert_eq!(NumFormat::from_ooxml(f.ooxml()), f, "{f:?} round-trips");
|
||||
assert!(f == NumFormat::Bullet || f == NumFormat::None || !f.format(u32::MAX).is_empty(), "{f:?}");
|
||||
@@ -838,6 +909,17 @@ mod tests {
|
||||
assert_eq!(NumFormat::from_ooxml("klingon"), NumFormat::Decimal, "unknown formats are decimal");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn russian_letters_follow_word() {
|
||||
// Word's own labels for a russianLower list: no ё, й, ъ or ь, then doubled letters.
|
||||
let labels: Vec<String> = (1..=31).map(|n| NumFormat::RussianLower.format(n)).collect();
|
||||
assert_eq!(labels.join(" "), "а б в г д е ж з и к л м н о п р с т у ф х ц ч ш щ ы э ю я аа бб");
|
||||
assert_eq!(NumFormat::RussianLower.format(59), "ааа");
|
||||
assert_eq!(NumFormat::RussianLower.format(100), "нннн");
|
||||
assert_eq!(NumFormat::RussianUpper.format(26), "Ы");
|
||||
assert_eq!(NumFormat::RussianUpper.format(36), "ЖЖ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn landscape_swaps() {
|
||||
let mut s = SectionProps { margin_left: 50.0, ..Default::default() };
|
||||
|
||||
@@ -68,13 +68,14 @@ impl SmartArtLayout {
|
||||
}
|
||||
}
|
||||
|
||||
/// The layout an id names (any ASCII case; `list` is the basic list).
|
||||
/// The layout an id names (any ASCII case, ignoring hyphens, underscores and spaces;
|
||||
/// `list` is the basic list; `basic process` or `process` is process, etc.).
|
||||
pub fn from_id(id: &str) -> Option<SmartArtLayout> {
|
||||
let id = id.trim();
|
||||
if id.eq_ignore_ascii_case("list") {
|
||||
let norm = id.chars().filter(|c| !c.is_whitespace() && *c != '-' && *c != '_').collect::<String>();
|
||||
if norm.eq_ignore_ascii_case("list") {
|
||||
return Some(SmartArtLayout::BasicList);
|
||||
}
|
||||
SmartArtLayout::ALL.into_iter().find(|l| l.id().eq_ignore_ascii_case(id))
|
||||
SmartArtLayout::ALL.into_iter().find(|l| l.id().eq_ignore_ascii_case(&norm) || l.label().replace(' ', "").eq_ignore_ascii_case(&norm))
|
||||
}
|
||||
|
||||
/// The name shown for it.
|
||||
@@ -159,7 +160,8 @@ impl SmartArtColors {
|
||||
}
|
||||
|
||||
pub fn from_id(id: &str) -> Option<SmartArtColors> {
|
||||
SmartArtColors::ALL.into_iter().find(|c| c.id().eq_ignore_ascii_case(id.trim()))
|
||||
let norm = id.chars().filter(|c| !c.is_whitespace() && *c != '-' && *c != '_').collect::<String>();
|
||||
SmartArtColors::ALL.into_iter().find(|c| c.id().eq_ignore_ascii_case(&norm) || c.label().replace(' ', "").eq_ignore_ascii_case(&norm))
|
||||
}
|
||||
|
||||
/// The name shown for it.
|
||||
@@ -287,8 +289,9 @@ impl SmartArtSpec {
|
||||
|
||||
/// `s` as item text: control characters as spaces, ends trimmed, at most [`MAX_TEXT`] characters.
|
||||
pub fn clean_text(s: &str) -> String {
|
||||
let s: String = s.chars().take(MAX_TEXT * 4).map(|c| if c.is_control() { ' ' } else { c }).collect();
|
||||
s.trim().chars().take(MAX_TEXT).collect::<String>().trim_end().to_string()
|
||||
let trimmed = s.trim_start_matches(|c: char| c.is_whitespace() || c.is_control());
|
||||
let s: String = trimmed.chars().take(MAX_TEXT).map(|c| if c.is_control() { ' ' } else { c }).collect();
|
||||
s.trim_end().to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -319,6 +322,13 @@ mod tests {
|
||||
assert_eq!(s.descendants(5), 6..6);
|
||||
assert_eq!(s.descendants(99), 0..0);
|
||||
assert_eq!(SmartArtLayout::from_id("VENN"), Some(SmartArtLayout::Venn));
|
||||
assert_eq!(SmartArtLayout::from_id("basic-list"), Some(SmartArtLayout::BasicList));
|
||||
assert_eq!(SmartArtLayout::from_id("Basic Process"), Some(SmartArtLayout::Process));
|
||||
assert_eq!(SmartArtLayout::from_id("basic_cycle"), Some(SmartArtLayout::Cycle));
|
||||
assert_eq!(SmartArtLayout::from_id("list"), Some(SmartArtLayout::BasicList));
|
||||
assert_eq!(SmartArtColors::from_id("colorful"), Some(SmartArtColors::Colorful));
|
||||
assert_eq!(SmartArtColors::from_id("Accent 2"), Some(SmartArtColors::Accent2));
|
||||
assert_eq!(SmartArtColors::from_id("accent-4"), Some(SmartArtColors::Accent4));
|
||||
assert_eq!(clean_text(&format!("{}Valid Text", " ".repeat(1500))), "Valid Text");
|
||||
}
|
||||
}
|
||||
|
||||
+354
-2
@@ -3,7 +3,12 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::props::{Align, Border, BorderStyle, Borders, CharProps, LineSpacing, ParaProps, Rgb, TabAlign, TabLeader, TabStop, TextColor, Underline};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::props::{
|
||||
Align, Border, BorderStyle, Borders, CharProps, LineSpacing, ParaProps, Rgb, TabAlign, TabLeader, TabStop, TableLook, TextColor, Underline,
|
||||
VAlign,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -41,6 +46,10 @@ pub struct Style {
|
||||
/// (`w:locked`).
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub locked: bool,
|
||||
/// The default style of its kind (`w:default`), whatever its id: localized documents call
|
||||
/// the default character, table and list styles something other than the English ids.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub default: bool,
|
||||
/// Table style: conditional formatting for header row, banding etc.
|
||||
pub table: Option<TableStyleParts>,
|
||||
}
|
||||
@@ -84,6 +93,166 @@ pub struct TableStyleParts {
|
||||
pub col_band_fill: Option<Rgb>,
|
||||
pub col_band_chr: CharProps,
|
||||
pub col_band_borders: Option<Borders>,
|
||||
/// Per-part formatting the named fields above don't carry: every field of the parts without
|
||||
/// one (even bands, corner cells), and the cell text alignment of every part. Read and write
|
||||
/// parts through [`TableStyleParts::part`] / [`TableStyleParts::set_part`].
|
||||
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub regions: BTreeMap<TablePart, PartFormat>,
|
||||
}
|
||||
|
||||
/// A conditional formatting part of a table style (`w:tblStylePr/@w:type`, ECMA-376 §17.7.6).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum TablePart {
|
||||
WholeTable,
|
||||
FirstRow,
|
||||
LastRow,
|
||||
FirstCol,
|
||||
LastCol,
|
||||
Band1Vert,
|
||||
Band2Vert,
|
||||
Band1Horz,
|
||||
Band2Horz,
|
||||
NeCell,
|
||||
NwCell,
|
||||
SeCell,
|
||||
SwCell,
|
||||
}
|
||||
|
||||
impl TablePart {
|
||||
/// Every part, lowest precedence first (ECMA-376 §17.7.6: whole table, bands, first/last
|
||||
/// column, first/last row, corner cells).
|
||||
pub const ALL: [TablePart; 13] = [
|
||||
TablePart::WholeTable,
|
||||
TablePart::Band1Vert,
|
||||
TablePart::Band2Vert,
|
||||
TablePart::Band1Horz,
|
||||
TablePart::Band2Horz,
|
||||
TablePart::FirstCol,
|
||||
TablePart::LastCol,
|
||||
TablePart::FirstRow,
|
||||
TablePart::LastRow,
|
||||
TablePart::NeCell,
|
||||
TablePart::NwCell,
|
||||
TablePart::SeCell,
|
||||
TablePart::SwCell,
|
||||
];
|
||||
/// The `w:type` value.
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
TablePart::WholeTable => "wholeTable",
|
||||
TablePart::FirstRow => "firstRow",
|
||||
TablePart::LastRow => "lastRow",
|
||||
TablePart::FirstCol => "firstCol",
|
||||
TablePart::LastCol => "lastCol",
|
||||
TablePart::Band1Vert => "band1Vert",
|
||||
TablePart::Band2Vert => "band2Vert",
|
||||
TablePart::Band1Horz => "band1Horz",
|
||||
TablePart::Band2Horz => "band2Horz",
|
||||
TablePart::NeCell => "neCell",
|
||||
TablePart::NwCell => "nwCell",
|
||||
TablePart::SeCell => "seCell",
|
||||
TablePart::SwCell => "swCell",
|
||||
}
|
||||
}
|
||||
pub fn from_ooxml(s: &str) -> Option<TablePart> {
|
||||
TablePart::ALL.into_iter().find(|p| p.ooxml() == s)
|
||||
}
|
||||
/// A part spanning whole rows (its left/right edges are the table's sides, `inside_v` the
|
||||
/// edges between its cells).
|
||||
pub fn is_row(self) -> bool {
|
||||
matches!(self, TablePart::FirstRow | TablePart::LastRow | TablePart::Band1Horz | TablePart::Band2Horz)
|
||||
}
|
||||
/// A part spanning whole columns (its top/bottom edges are the table's, `between` the edges
|
||||
/// between its cells).
|
||||
pub fn is_column(self) -> bool {
|
||||
matches!(self, TablePart::FirstCol | TablePart::LastCol | TablePart::Band1Vert | TablePart::Band2Vert)
|
||||
}
|
||||
fn bit(self) -> u16 {
|
||||
1 << (TablePart::ALL.iter().position(|p| *p == self).unwrap_or(0) as u16)
|
||||
}
|
||||
}
|
||||
|
||||
/// The table style parts one cell is in (always the whole table).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct PartSet(u16);
|
||||
|
||||
impl PartSet {
|
||||
/// The parts of the cell at row `ri` of `nrows`, grid column `g` spanning `span` of `ncols`,
|
||||
/// in a table with options `look` and row bands of `band_size` rows.
|
||||
pub fn of_cell(look: &TableLook, band_size: u32, (ri, nrows): (usize, usize), (g, span, ncols): (usize, usize, usize)) -> PartSet {
|
||||
let header = look.header_row && ri == 0;
|
||||
let total = look.total_row && ri + 1 == nrows && nrows > 1;
|
||||
let first_col = look.first_column && g == 0;
|
||||
let last_col = look.last_column && g.saturating_add(span) >= ncols && ncols > 1;
|
||||
let size = band_size.clamp(1, 1000) as usize;
|
||||
let row_band = look.banded_rows && !header;
|
||||
let odd_row = (ri.saturating_sub(usize::from(look.header_row)) / size).is_multiple_of(2);
|
||||
let col_band = look.banded_columns && !first_col;
|
||||
let odd_col = g.saturating_sub(usize::from(look.first_column)).is_multiple_of(2);
|
||||
let mut bits = TablePart::WholeTable.bit();
|
||||
for (on, p) in [
|
||||
(col_band && odd_col, TablePart::Band1Vert),
|
||||
(col_band && !odd_col, TablePart::Band2Vert),
|
||||
(row_band && odd_row, TablePart::Band1Horz),
|
||||
(row_band && !odd_row, TablePart::Band2Horz),
|
||||
(first_col, TablePart::FirstCol),
|
||||
(last_col, TablePart::LastCol),
|
||||
(header, TablePart::FirstRow),
|
||||
(total, TablePart::LastRow),
|
||||
(header && last_col, TablePart::NeCell),
|
||||
(header && first_col, TablePart::NwCell),
|
||||
(total && last_col, TablePart::SeCell),
|
||||
(total && first_col, TablePart::SwCell),
|
||||
] {
|
||||
if on {
|
||||
bits |= p.bit();
|
||||
}
|
||||
}
|
||||
PartSet(bits)
|
||||
}
|
||||
pub fn contains(self, p: TablePart) -> bool {
|
||||
self.0 & p.bit() != 0
|
||||
}
|
||||
/// The parts, lowest precedence first.
|
||||
pub fn iter(self) -> impl Iterator<Item = TablePart> {
|
||||
TablePart::ALL.into_iter().filter(move |p| self.contains(*p))
|
||||
}
|
||||
}
|
||||
|
||||
/// What one table style part gives its cells.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct PartFormat {
|
||||
pub chr: CharProps,
|
||||
/// Cell shading.
|
||||
pub fill: Option<Rgb>,
|
||||
/// Cell borders (the whole table's are the table borders).
|
||||
pub borders: Option<Borders>,
|
||||
/// Horizontal alignment of the cells' paragraphs (`w:pPr/w:jc`).
|
||||
pub align: Option<Align>,
|
||||
/// Vertical alignment in the cells (`w:tcPr/w:vAlign`).
|
||||
pub v_align: Option<VAlign>,
|
||||
}
|
||||
|
||||
impl PartFormat {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
*self == PartFormat::default()
|
||||
}
|
||||
/// What `patch` sets wins; borders merge edge by edge.
|
||||
pub fn overlay(&mut self, patch: &PartFormat) {
|
||||
self.chr.overlay(&patch.chr);
|
||||
self.fill = patch.fill.or(self.fill);
|
||||
self.borders = match (self.borders, patch.borders) {
|
||||
(Some(mut b), Some(p)) => {
|
||||
b.overlay(&p);
|
||||
Some(b)
|
||||
}
|
||||
(b, p) => p.or(b),
|
||||
};
|
||||
self.align = patch.align.or(self.align);
|
||||
self.v_align = patch.v_align.or(self.v_align);
|
||||
}
|
||||
}
|
||||
|
||||
impl TableStyleParts {
|
||||
@@ -120,6 +289,125 @@ impl TableStyleParts {
|
||||
self.col_band_fill = patch.col_band_fill.or(self.col_band_fill);
|
||||
self.col_band_chr.overlay(&patch.col_band_chr);
|
||||
self.col_band_borders = merge(self.col_band_borders, patch.col_band_borders);
|
||||
for (k, f) in &patch.regions {
|
||||
self.regions.entry(*k).or_default().overlay(f);
|
||||
}
|
||||
}
|
||||
|
||||
/// Part `p`'s formatting. The whole table's text formatting is the style's own
|
||||
/// ([`Style::table_part`] adds it); its borders are the table borders.
|
||||
pub fn part(&self, p: TablePart) -> PartFormat {
|
||||
let extra = self.regions.get(&p).cloned().unwrap_or_default();
|
||||
let named = |chr: &CharProps, fill: Option<Rgb>, borders: Option<Borders>| PartFormat {
|
||||
chr: chr.clone(),
|
||||
fill,
|
||||
borders,
|
||||
align: extra.align,
|
||||
v_align: extra.v_align,
|
||||
};
|
||||
match p {
|
||||
TablePart::WholeTable => {
|
||||
PartFormat { chr: CharProps::default(), fill: self.fill, borders: self.borders, align: None, v_align: extra.v_align }
|
||||
}
|
||||
TablePart::FirstRow => named(&self.header_chr, self.header_fill, self.header_borders),
|
||||
TablePart::LastRow => {
|
||||
// The built-in top rule is the region's top edge unless its borders set one.
|
||||
let borders = match (self.total_borders, self.total_border_top) {
|
||||
(Some(mut b), top) => {
|
||||
b.top = b.top.or(top);
|
||||
Some(b)
|
||||
}
|
||||
(None, top) => top.map(|t| Borders { top: Some(t), ..Borders::default() }),
|
||||
};
|
||||
named(&self.total_chr, self.total_fill, borders)
|
||||
}
|
||||
TablePart::FirstCol => named(&self.first_col_chr, self.first_col_fill, self.first_col_borders),
|
||||
TablePart::LastCol => named(&self.last_col_chr, self.last_col_fill, self.last_col_borders),
|
||||
TablePart::Band1Vert => named(&self.col_band_chr, self.col_band_fill, self.col_band_borders),
|
||||
TablePart::Band1Horz => named(&self.band_chr, self.band_fill, self.band_borders),
|
||||
_ => extra,
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace part `p`'s formatting (for the whole table only its fill, borders and vertical
|
||||
/// alignment: [`Style::set_table_part`] stores the rest).
|
||||
pub fn set_part(&mut self, p: TablePart, f: PartFormat) {
|
||||
let align_only = PartFormat { align: f.align, v_align: f.v_align, ..PartFormat::default() };
|
||||
let PartFormat { chr, fill, borders, .. } = f.clone();
|
||||
let rest = match p {
|
||||
TablePart::WholeTable => {
|
||||
self.fill = fill;
|
||||
self.borders = borders;
|
||||
PartFormat { v_align: f.v_align, ..PartFormat::default() }
|
||||
}
|
||||
TablePart::FirstRow => {
|
||||
(self.header_chr, self.header_fill, self.header_borders) = (chr, fill, borders);
|
||||
align_only
|
||||
}
|
||||
TablePart::LastRow => {
|
||||
(self.total_chr, self.total_fill) = (chr, fill);
|
||||
self.total_border_top = borders.and_then(|b| b.top);
|
||||
self.total_borders = borders.map(|b| Borders { top: None, ..b }).filter(|b| *b != Borders::default());
|
||||
align_only
|
||||
}
|
||||
TablePart::FirstCol => {
|
||||
(self.first_col_chr, self.first_col_fill, self.first_col_borders) = (chr, fill, borders);
|
||||
align_only
|
||||
}
|
||||
TablePart::LastCol => {
|
||||
(self.last_col_chr, self.last_col_fill, self.last_col_borders) = (chr, fill, borders);
|
||||
align_only
|
||||
}
|
||||
TablePart::Band1Vert => {
|
||||
(self.col_band_chr, self.col_band_fill, self.col_band_borders) = (chr, fill, borders);
|
||||
align_only
|
||||
}
|
||||
TablePart::Band1Horz => {
|
||||
(self.band_chr, self.band_fill, self.band_borders) = (chr, fill, borders);
|
||||
align_only
|
||||
}
|
||||
_ => f,
|
||||
};
|
||||
if rest.is_empty() {
|
||||
self.regions.remove(&p);
|
||||
} else {
|
||||
self.regions.insert(p, rest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Style {
|
||||
/// Table style part `p` as this style alone sets it (the whole table's text formatting and
|
||||
/// paragraph alignment are the style's own).
|
||||
pub fn table_part(&self, p: TablePart) -> PartFormat {
|
||||
let mut f = self.table.as_ref().map(|t| t.part(p)).unwrap_or_default();
|
||||
if p == TablePart::WholeTable {
|
||||
f.chr = self.chr.clone();
|
||||
f.align = self.para.align;
|
||||
}
|
||||
f
|
||||
}
|
||||
/// Replace part `p` of this table style.
|
||||
pub fn set_table_part(&mut self, p: TablePart, f: PartFormat) {
|
||||
if p == TablePart::WholeTable {
|
||||
self.chr = f.chr.clone();
|
||||
self.para.align = f.align;
|
||||
}
|
||||
let mut t = self.table.take().unwrap_or_default();
|
||||
t.set_part(p, f);
|
||||
self.table = (t != TableStyleParts::default()).then_some(t);
|
||||
}
|
||||
}
|
||||
|
||||
impl TableStyleProps {
|
||||
/// Part `p` with the based-on chain merged (see [`Style::table_part`]).
|
||||
pub fn part(&self, p: TablePart) -> PartFormat {
|
||||
let mut f = self.parts.part(p);
|
||||
if p == TablePart::WholeTable {
|
||||
f.chr = self.chr.clone();
|
||||
f.align = self.para.align;
|
||||
}
|
||||
f
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +430,10 @@ pub struct StyleSheet {
|
||||
/// the application; nonempty `default_para` is always written after spacing edits.
|
||||
pub default_para_present: bool,
|
||||
pub styles: Vec<Style>,
|
||||
/// The file's `w:latentStyles` (behaviour of built-in styles not defined in the document:
|
||||
/// gallery, priority, visibility), as XML, written back as is.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub latent_styles: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for StyleSheet {
|
||||
@@ -168,7 +460,13 @@ fn color(c: Rgb) -> Option<TextColor> {
|
||||
|
||||
impl StyleSheet {
|
||||
pub fn empty() -> Self {
|
||||
StyleSheet { default_chr: CharProps::default(), default_para: ParaProps::default(), default_para_present: true, styles: Vec::new() }
|
||||
StyleSheet {
|
||||
default_chr: CharProps::default(),
|
||||
default_para: ParaProps::default(),
|
||||
default_para_present: true,
|
||||
styles: Vec::new(),
|
||||
latent_styles: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The built-in style set used for new documents.
|
||||
@@ -184,6 +482,7 @@ impl StyleSheet {
|
||||
},
|
||||
default_para_present: true,
|
||||
styles: Vec::new(),
|
||||
latent_styles: None,
|
||||
};
|
||||
let mut add = |st: Style| s.styles.push(st);
|
||||
add(Style { id: "Normal".into(), name: "Normal".into(), priority: Some(0), quick: true, ..p_style() });
|
||||
@@ -483,6 +782,33 @@ impl StyleSheet {
|
||||
None => self.styles.push(st),
|
||||
}
|
||||
}
|
||||
/// For a file from a producer that never writes `w:qFormat` (LibreOffice): give it Word's
|
||||
/// usual Quick Styles gallery. Each built-in style whose id or name matches a quick style of
|
||||
/// [`StyleSheet::builtin`] becomes quick (taking the built-in's priority when it has none), and
|
||||
/// the quick built-ins the file lacks are added with their linked styles. Existing ids and
|
||||
/// names are never overwritten. Callers use it only when no style in the file is quick.
|
||||
pub fn add_default_gallery(&mut self) {
|
||||
let builtin = StyleSheet::builtin();
|
||||
for q in builtin.styles.iter().filter(|s| s.quick && s.kind != StyleKind::Table) {
|
||||
let found =
|
||||
self.styles.iter().position(|s| s.id == q.id).or_else(|| self.styles.iter().position(|s| s.name.eq_ignore_ascii_case(&q.name)));
|
||||
if let Some(st) = found.and_then(|i| self.styles.get_mut(i)) {
|
||||
if st.builtin && st.kind == q.kind {
|
||||
st.quick = true;
|
||||
st.priority = st.priority.or(q.priority);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for sid in std::iter::once(q.id.as_str()).chain(q.linked.as_deref()) {
|
||||
if let Some(b) = builtin.get(sid)
|
||||
&& self.get(sid).is_none()
|
||||
&& self.find(&b.name).is_none()
|
||||
{
|
||||
self.styles.push(b.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Remove style `id` and return it. Styles based on it are rebased onto its own base, and
|
||||
/// `next` / `linked` references to it are dropped. Text that uses it is the document's to
|
||||
/// retarget ([`crate::Document::restyle`]).
|
||||
@@ -605,6 +931,32 @@ mod tests {
|
||||
assert!(g.iter().any(|s| s.id == "Heading1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_gallery_marks_and_adds_quick_builtins() {
|
||||
let b = StyleSheet::builtin();
|
||||
let mut s = StyleSheet::empty();
|
||||
let plain = |id: &str, name: &str| Style { id: id.into(), name: name.into(), builtin: true, ..Default::default() };
|
||||
s.styles.push(plain("Normal", "Normal"));
|
||||
s.styles.push(plain("Heading1", "heading 1"));
|
||||
s.styles.push(Style { priority: Some(7), ..plain("Heading2", "Heading 2") });
|
||||
s.styles.push(Style { builtin: false, ..plain("Heading3", "Heading 3") });
|
||||
s.styles.push(Style { para: ParaProps { space_after: Some(99.0), ..Default::default() }, ..plain("Custom1", "Subtitle") });
|
||||
s.add_default_gallery();
|
||||
let ids: Vec<&str> = s.gallery().iter().map(|x| x.id.as_str()).collect();
|
||||
for id in ["Normal", "NoSpacing", "Heading1", "Heading2", "Title"] {
|
||||
assert!(ids.contains(&id), "{id} in {ids:?}");
|
||||
}
|
||||
let mut bare = StyleSheet::empty();
|
||||
bare.add_default_gallery();
|
||||
assert!(bare.get("Heading1").is_some_and(|x| x.quick) && bare.get("Title").is_some(), "missing quick builtins added");
|
||||
assert_eq!(s.get("Heading2").and_then(|x| x.priority), Some(7), "file priority kept");
|
||||
assert_eq!(s.get("Heading1").and_then(|x| x.priority), b.get("Heading1").and_then(|x| x.priority));
|
||||
assert!(!s.get("Heading3").is_some_and(|x| x.quick), "custom style untouched");
|
||||
assert!(s.get("Subtitle").is_none(), "a style with that name exists: not duplicated");
|
||||
assert!(s.get("Custom1").is_some_and(|x| x.quick), "found by name only, built in: quick");
|
||||
assert_eq!(s.get("Custom1").and_then(|x| x.para.space_after), Some(99.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn older_serialized_styles_keep_explicit_defaults() {
|
||||
let mut value = serde_json::to_value(StyleSheet::empty()).unwrap();
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
/// The symbol fonts this module knows, by lowercase family name.
|
||||
fn table(family: &str) -> Option<&'static [u32; 224]> {
|
||||
let f = family.trim();
|
||||
if f.eq_ignore_ascii_case("symbol") {
|
||||
if f.eq_ignore_ascii_case("symbol") || f.eq_ignore_ascii_case("symbol (pcl6)") {
|
||||
Some(&SYMBOL)
|
||||
} else if f.eq_ignore_ascii_case("wingdings") {
|
||||
Some(&WINGDINGS)
|
||||
@@ -243,6 +243,21 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pcl6_symbol_encoding() {
|
||||
for family in ["Symbol (PCL6)", " symbol (pcl6) "] {
|
||||
assert!(is_symbol_font(family));
|
||||
for code in 0x20..=0xFF {
|
||||
let c = char::from_u32(code).unwrap();
|
||||
assert_eq!(symbol_char(family, c), symbol_char("Symbol", c));
|
||||
let pua = char::from_u32(0xF000 + code).unwrap();
|
||||
assert_eq!(symbol_char(family, pua), symbol_char("Symbol", pua));
|
||||
}
|
||||
assert_eq!(symbol_char(family, '\u{F0B3}'), Some('≥'));
|
||||
}
|
||||
assert!(!is_symbol_font("Symbol (unknown)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbol_uses_the_adobe_symbol_encoding() {
|
||||
assert_eq!(symbol_char("Symbol", '\u{F061}'), Some('α'));
|
||||
|
||||
+144
-23
@@ -127,9 +127,27 @@ impl Table {
|
||||
}
|
||||
|
||||
pub fn delete_row(&mut self, r: usize) {
|
||||
if r < self.rows.len() {
|
||||
self.rows.remove(r);
|
||||
if r >= self.rows.len() {
|
||||
return;
|
||||
}
|
||||
// A vertical merge that starts in this row now starts in the row below.
|
||||
let starts: Vec<usize> = (0..self.rows.get(r).map_or(0, |row| row.cells.len()))
|
||||
.filter(|&c| self.rows.get(r).and_then(|row| row.cells.get(c)).is_some_and(|x| x.props.vmerge == VMerge::Restart))
|
||||
.map(|c| self.grid_col(r, c))
|
||||
.collect();
|
||||
for g in starts {
|
||||
let continues = |t: &Self, row: usize| {
|
||||
t.cell_at_grid(row, g).and_then(|i| t.rows.get(row)?.cells.get(i)).is_some_and(|x| x.props.vmerge == VMerge::Continue)
|
||||
};
|
||||
if !continues(self, r + 1) {
|
||||
continue;
|
||||
}
|
||||
let more = continues(self, r + 2);
|
||||
if let Some(c) = self.cell_at_grid(r + 1, g).and_then(|i| self.rows.get_mut(r + 1)?.cells.get_mut(i)) {
|
||||
c.props.vmerge = if more { VMerge::Restart } else { VMerge::None };
|
||||
}
|
||||
}
|
||||
self.rows.remove(r);
|
||||
}
|
||||
|
||||
/// Insert a grid column at grid index `g` (cells spanning it widen instead).
|
||||
@@ -256,28 +274,15 @@ impl Table {
|
||||
}
|
||||
}
|
||||
|
||||
/// Split cell `c` of row `r` into `n` cells horizontally. When the cell spans fewer grid
|
||||
/// columns than that, the grid is refined inside the cell only: the new borders divide it
|
||||
/// Split cell `c` of row `r` into `n` cells horizontally. Where the cell's grid lines don't
|
||||
/// fall on the equal parts, the grid is refined inside the cell only: the new borders divide it
|
||||
/// into equal parts, and the cells of other rows widen over the new grid columns, so their
|
||||
/// extent and the table's width stay as they were.
|
||||
pub fn split_cell(&mut self, r: usize, c: usize, n: usize) {
|
||||
/// extent and the table's width stay as they were. Returns the parts' spans, left to right.
|
||||
pub fn split_cell(&mut self, r: usize, c: usize, n: usize) -> Vec<usize> {
|
||||
let n = n.clamp(1, MAX_COLS);
|
||||
let Some(span) = self.rows.get(r).and_then(|row| row.cells.get(c)).map(Cell::span) else { return };
|
||||
let Some(span) = self.rows.get(r).and_then(|row| row.cells.get(c)).map(Cell::span) else { return Vec::new() };
|
||||
if n <= 1 {
|
||||
return;
|
||||
}
|
||||
if span >= n {
|
||||
let Some(row) = self.rows.get_mut(r) else { return };
|
||||
let Some(cell) = row.cells.get_mut(c) else { return };
|
||||
let w = cell.props.width.map(|w| w / n as f32);
|
||||
cell.props.width = w;
|
||||
cell.props.span = (span - (n - 1)) as u32;
|
||||
for k in 1..n {
|
||||
let mut nc = Cell::empty();
|
||||
nc.props.width = w;
|
||||
row.cells.insert(c + k, nc);
|
||||
}
|
||||
return;
|
||||
return vec![span];
|
||||
}
|
||||
let g = self.grid_col(r, c);
|
||||
let mut end = g.saturating_add(span);
|
||||
@@ -286,7 +291,7 @@ impl Table {
|
||||
self.grid.push(72.0);
|
||||
}
|
||||
if self.grid.len() < end {
|
||||
return;
|
||||
return vec![span];
|
||||
}
|
||||
for w in self.grid.get_mut(g..end).into_iter().flatten() {
|
||||
if !(w.is_finite() && *w > 0.0) {
|
||||
@@ -341,7 +346,7 @@ impl Table {
|
||||
// The cell now spans `g..end`: cut it at the new lines.
|
||||
let grid = self.grid.clone();
|
||||
let width = |a: usize, b: usize| grid.get(a..b).map(|x| x.iter().sum::<f32>());
|
||||
let Some(row) = self.rows.get_mut(r) else { return };
|
||||
let Some(row) = self.rows.get_mut(r) else { return Vec::new() };
|
||||
let mut a = g;
|
||||
let mut parts = Vec::with_capacity(lines.len() + 1);
|
||||
for &b in lines.iter().chain(std::iter::once(&end)) {
|
||||
@@ -363,6 +368,122 @@ impl Table {
|
||||
row.cells.insert((c + k).min(row.cells.len()), nc);
|
||||
}
|
||||
}
|
||||
parts.iter().map(|&(a, b)| b - a).collect()
|
||||
}
|
||||
|
||||
/// The rows cell `c` of row `r` covers: (first row, count). A vertically merged cell runs from
|
||||
/// its `Restart` cell down through the `Continue` cells that start at the same grid column.
|
||||
pub fn vertical_extent(&self, r: usize, c: usize) -> Option<(usize, usize)> {
|
||||
let g = self.grid_col(r, c);
|
||||
let at = |rr: usize| -> Option<VMerge> {
|
||||
let ci = self.cell_at_grid(rr, g)?;
|
||||
if self.grid_col(rr, ci) != g {
|
||||
return None;
|
||||
}
|
||||
Some(self.rows.get(rr)?.cells.get(ci)?.props.vmerge)
|
||||
};
|
||||
let mut top = r;
|
||||
if self.rows.get(r)?.cells.get(c)?.props.vmerge == VMerge::Continue {
|
||||
while let Some(up) = top.checked_sub(1).and_then(at) {
|
||||
if up == VMerge::None {
|
||||
break;
|
||||
}
|
||||
top -= 1;
|
||||
if up == VMerge::Restart {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut k = 1;
|
||||
if at(top) != Some(VMerge::None) {
|
||||
while at(top + k) == Some(VMerge::Continue) {
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
Some((top, k))
|
||||
}
|
||||
|
||||
/// Split Cells: cut cell `c` of row `r` into `cols` × `rows` cells. A cell one row high gets
|
||||
/// `rows - 1` new rows under it, over which the row's other cells merge down; a vertically
|
||||
/// merged cell is regrouped instead, so `rows` must divide its height. The content stays in
|
||||
/// the first part. Returns false (with the table unchanged) when the split can't be made.
|
||||
pub fn split_cell_grid(&mut self, r: usize, c: usize, cols: usize, rows: usize) -> bool {
|
||||
let (cols, rows) = (cols.clamp(1, MAX_COLS), rows.clamp(1, MAX_ROWS));
|
||||
let Some((top, k)) = self.vertical_extent(r, c) else { return false };
|
||||
let g = self.grid_col(r, c);
|
||||
let Some(ci) = self.cell_at_grid(top, g) else { return false };
|
||||
if (k > 1 && k % rows != 0) || (k == 1 && self.rows.len().saturating_add(rows - 1) > MAX_ROWS) {
|
||||
return false;
|
||||
}
|
||||
let height = if k > 1 { k } else { rows };
|
||||
if k > 1 && rows > 1 {
|
||||
let q = k / rows;
|
||||
for i in 0..k {
|
||||
if let Some(cj) = self.cell_at_grid(top + i, g)
|
||||
&& let Some(cell) = self.rows.get_mut(top + i).and_then(|row| row.cells.get_mut(cj))
|
||||
{
|
||||
cell.props.vmerge = if q == 1 {
|
||||
VMerge::None
|
||||
} else if i % q == 0 {
|
||||
VMerge::Restart
|
||||
} else {
|
||||
VMerge::Continue
|
||||
};
|
||||
}
|
||||
}
|
||||
} else if k == 1 && rows > 1 {
|
||||
let Some(src) = self.rows.get_mut(top) else { return false };
|
||||
let mut like = Row { props: src.props.clone(), cells: Vec::with_capacity(src.cells.len()), controls: ControlWrap::default() };
|
||||
like.props.header = false;
|
||||
for (i, cell) in src.cells.iter_mut().enumerate() {
|
||||
let mut nc = Cell::empty();
|
||||
nc.props = cell.props.clone();
|
||||
if i == ci {
|
||||
cell.props.vmerge = VMerge::None;
|
||||
nc.props.vmerge = VMerge::None;
|
||||
} else {
|
||||
// The row's other cells merge down over the new rows.
|
||||
if cell.props.vmerge == VMerge::None {
|
||||
cell.props.vmerge = VMerge::Restart;
|
||||
}
|
||||
nc.props.vmerge = VMerge::Continue;
|
||||
}
|
||||
like.cells.push(nc);
|
||||
}
|
||||
for _ in 1..rows {
|
||||
self.rows.insert(top + 1, like.clone());
|
||||
}
|
||||
}
|
||||
if cols > 1 {
|
||||
let spans = self.split_cell(top, ci, cols);
|
||||
let total: usize = spans.iter().sum();
|
||||
let vmerge = self.rows.get(top).and_then(|row| row.cells.get(ci)).map(|cell| cell.props.vmerge).unwrap_or_default();
|
||||
if let Some(row) = self.rows.get_mut(top) {
|
||||
for cell in row.cells.iter_mut().skip(ci + 1).take(spans.len().saturating_sub(1)) {
|
||||
cell.props.vmerge = vmerge;
|
||||
}
|
||||
}
|
||||
// The cells under it, which the new grid lines widened, are cut the same way.
|
||||
let grid = self.grid.clone();
|
||||
let width = |a: usize, b: usize| grid.get(a..b).map(|x| x.iter().sum::<f32>());
|
||||
for rr in top + 1..top + height {
|
||||
let Some(cj) = self.cell_at_grid(rr, g).filter(|&cj| self.grid_col(rr, cj) == g) else { continue };
|
||||
let Some(row) = self.rows.get_mut(rr) else { continue };
|
||||
let Some(cell) = row.cells.get_mut(cj).filter(|cell| cell.span() == total) else { continue };
|
||||
let mut a = g;
|
||||
let mut parts = Vec::with_capacity(spans.len());
|
||||
for (n, &span) in spans.iter().enumerate() {
|
||||
let mut part = if n == 0 { cell.clone() } else { Cell::empty() };
|
||||
part.props.vmerge = cell.props.vmerge;
|
||||
part.props.span = span as u32;
|
||||
part.props.width = width(a, a + span);
|
||||
a += span;
|
||||
parts.push(part);
|
||||
}
|
||||
row.cells.splice(cj..=cj, parts);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub fn total_width(&self) -> f32 {
|
||||
|
||||
+119
-1
@@ -90,6 +90,46 @@ fn parts_and_media() {
|
||||
assert_ne!(k1, k3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_ids_skip_taken_ones_instead_of_overflowing() {
|
||||
assert_eq!(free_id(None, std::iter::empty(), 1), Some(1));
|
||||
assert_eq!(free_id(Some(7), [3, 7].into_iter(), 1), Some(8));
|
||||
assert_eq!(free_id(Some(0), [0].into_iter(), 1), Some(1));
|
||||
// A file used the largest id: the lowest free one, not a wrapped 0 or a taken id.
|
||||
assert_eq!(free_id(Some(u32::MAX), [1, 2, 4, u32::MAX].into_iter(), 1), Some(3));
|
||||
assert_eq!(free_id(Some(u32::MAX), [u32::MAX, 1, 0, 1].into_iter(), 0), Some(2));
|
||||
// Every id from `min` up taken: none, rather than a duplicate.
|
||||
assert_eq!(free_id(Some(u32::MAX), [u32::MAX, u32::MAX - 1, u32::MAX - 2].into_iter(), u32::MAX - 2), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parts_and_lists_after_the_largest_ids_take_free_ones() {
|
||||
// A file can carry any ids, 0 and u32::MAX included; new ones must not overflow or replace them.
|
||||
let mut d = Document::new();
|
||||
for id in [0, 1, u32::MAX] {
|
||||
let blocks = vec![para_block(Paragraph::with_text(&id.to_string(), CharProps::default()))];
|
||||
d.parts.insert(id, Part { kind: PartKind::Header, blocks, ..Default::default() });
|
||||
}
|
||||
let parts = d.parts.clone();
|
||||
assert_eq!(d.try_add_part(PartKind::Comment, Vec::new()), Ok(2));
|
||||
assert_eq!(d.add_part(PartKind::Footer, Vec::new()), 3);
|
||||
assert!(parts.iter().all(|(k, p)| d.parts.get(k) == Some(p)));
|
||||
|
||||
let n = &mut d.numbering;
|
||||
n.add_list(ListKind::Bullet);
|
||||
n.add_list(ListKind::Bullet);
|
||||
n.abstracts[1].id = u32::MAX;
|
||||
n.nums[1].id = u32::MAX;
|
||||
n.nums[1].abstract_id = u32::MAX;
|
||||
let lists = n.clone();
|
||||
let num = n.try_add_list(ListKind::Numbered).unwrap();
|
||||
assert_eq!((num, n.num(num).map(|x| x.abstract_id)), (2, Some(1)));
|
||||
assert_eq!(n.abstract_of(num).map(|a| &a.levels), Some(&numbering::levels_for(ListKind::Numbered)));
|
||||
assert_eq!(n.restart(num), Some(3));
|
||||
assert_eq!(n.num(3).map(|x| x.abstract_id), Some(1));
|
||||
assert_eq!((&n.abstracts[..2], &n.nums[..2]), (&lists.abstracts[..], &lists.nums[..]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clone_is_shallow() {
|
||||
let d = Document::from_text("a\nb");
|
||||
@@ -241,6 +281,84 @@ fn prune_text_boxes_drops_only_unshown_ones() {
|
||||
assert_eq!(d.prune_text_boxes(), 0);
|
||||
}
|
||||
|
||||
/// `blocks` in the only cell of `levels` tables, each nested in the next.
|
||||
fn nested_tables(levels: usize, blocks: Blocks) -> Blocks {
|
||||
(0..levels).fold(blocks, |inner, _| {
|
||||
let mut t = Table::new(1, 1, 100.0);
|
||||
t.rows[0].cells[0].blocks = inner;
|
||||
vec![Arc::new(Block::Table(t))]
|
||||
})
|
||||
}
|
||||
|
||||
/// A paragraph showing text box story `story` (drawing id `id`).
|
||||
fn box_para(story: u32, id: u32) -> Blocks {
|
||||
let shape = InlineObject::Shape {
|
||||
kind: para::ShapeKind::TextBox,
|
||||
w: 50.0,
|
||||
h: 20.0,
|
||||
fill: None,
|
||||
stroke: None,
|
||||
stroke_width: 0.0,
|
||||
float: Default::default(),
|
||||
story: Some(story),
|
||||
freeform: None,
|
||||
effects: Default::default(),
|
||||
extra: connector::ShapeExtra { id, ..Default::default() },
|
||||
};
|
||||
let mut p = Paragraph::with_text("cell", CharProps::default());
|
||||
p.insert_object(0, shape, &CharProps::default()).unwrap();
|
||||
vec![para_block(p)]
|
||||
}
|
||||
|
||||
#[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
|
||||
// for unshown and the next edit pruned its story.
|
||||
let para_of = |t: &str| vec![para_block(Paragraph::with_text(t, CharProps::default()))];
|
||||
let mut d = Document::from_text("body");
|
||||
let at_17 = d.add_part(PartKind::TextBox, para_of("17 tables down"));
|
||||
let at_40 = d.add_part(PartKind::TextBox, para_of("40 tables down"));
|
||||
let inner = d.add_part(PartKind::TextBox, para_of("deep in a box"));
|
||||
let outer = d.add_part(PartKind::TextBox, nested_tables(18, box_para(inner, 0)));
|
||||
let in_header = d.add_part(PartKind::TextBox, para_of("deep in a header"));
|
||||
let hdr = d.add_part(PartKind::Header, nested_tables(20, box_para(in_header, 0)));
|
||||
let unshown = d.add_part(PartKind::TextBox, para_of("shown nowhere"));
|
||||
d.body.extend(nested_tables(17, box_para(at_17, 0)));
|
||||
d.body.extend(nested_tables(40, box_para(at_40, 7)));
|
||||
d.body.extend(box_para(outer, 0));
|
||||
assert_eq!(d.prune_text_boxes(), 1);
|
||||
assert!(!d.parts.contains_key(&unshown));
|
||||
let kept: Vec<u32> = d.parts.keys().copied().collect();
|
||||
assert_eq!(kept, vec![at_17, at_40, inner, outer, in_header, hdr]);
|
||||
assert_eq!(d.prune_text_boxes(), 0);
|
||||
// Counted in reading order, the nested box after its own; new drawing ids above the deep one.
|
||||
let part = StoryRef::Part;
|
||||
assert_eq!(d.counted_stories(), vec![StoryRef::Body, part(at_17), part(at_40), part(outer), part(inner)]);
|
||||
assert_eq!(d.next_shape_id(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_deeply_nested_table_is_copied_with_its_text_boxes() {
|
||||
// A copy takes the box 17 tables down along, so pasting it into another document shows its
|
||||
// text, not the other document's box that happens to have the same id.
|
||||
let para_of = |t: &str| vec![para_block(Paragraph::with_text(t, CharProps::default()))];
|
||||
let mut d = Document::from_text("body");
|
||||
let deep = d.add_part(PartKind::TextBox, para_of("deep box"));
|
||||
d.body.extend(nested_tables(17, box_para(deep, 0)));
|
||||
let frag = d.body_fragment();
|
||||
assert_eq!(frag.parts.keys().copied().collect::<Vec<_>>(), vec![deep]);
|
||||
let mut e = Document::new();
|
||||
let own = e.add_part(PartKind::TextBox, para_of("its own box"));
|
||||
assert_eq!(own, deep, "the same id in both documents");
|
||||
e.body = box_para(own, 0);
|
||||
let end = e.end_of(StoryRef::Body);
|
||||
e.insert_fragment(&end, &frag).unwrap();
|
||||
let mut shown = Vec::new();
|
||||
e.objects_in_reading_order(&mut |o| shown.extend(o.text_box()));
|
||||
let texts: Vec<String> = shown.iter().map(|id| e.plain_text(StoryRef::Part(*id))).collect();
|
||||
assert_eq!(texts, vec!["its own box", "deep box"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_in_text_box_follow_nested_boxes_once() {
|
||||
let shape = |story| InlineObject::Shape {
|
||||
@@ -273,7 +391,7 @@ fn notes_in_text_box_follow_nested_boxes_once() {
|
||||
}
|
||||
|
||||
fn fs(instr: &str) -> InlineObject {
|
||||
InlineObject::FieldStart { instr: instr.into(), locked: false }
|
||||
InlineObject::FieldStart { instr: instr.into(), locked: false, dirty: false }
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+20
-9
@@ -59,8 +59,8 @@ impl WrapPolygon {
|
||||
|
||||
/// A shape's outline in unit coordinates (0..1 across a `w` × `h` frame), flattened to straight
|
||||
/// lines. `None` for shapes whose outline is their rectangle (rectangles, text boxes, lines,
|
||||
/// freeforms).
|
||||
pub fn shape_outline(kind: ShapeKind, w: f32, h: f32) -> Option<Vec<(f32, f32)>> {
|
||||
/// freeforms). `corner` is a rounded rectangle's (see [`crate::connector::ShapeExtra::corner`]).
|
||||
pub fn shape_outline(kind: ShapeKind, w: f32, h: f32, corner: Option<u32>) -> Option<Vec<(f32, f32)>> {
|
||||
use std::f32::consts::{FRAC_PI_2, PI, TAU};
|
||||
let pos = |v: f32| if v.is_finite() { v.clamp(0.0, 1.0e6) } else { 0.0 };
|
||||
let (w, h) = (pos(w), pos(h));
|
||||
@@ -73,10 +73,12 @@ pub fn shape_outline(kind: ShapeKind, w: f32, h: f32) -> Option<Vec<(f32, f32)>>
|
||||
| ShapeKind::StraightConnector
|
||||
| ShapeKind::ElbowConnector
|
||||
| ShapeKind::CurvedConnector => return None,
|
||||
ShapeKind::Ellipse => (0..48).map(|i| (i as f32) * TAU / 48.0).map(|a| (0.5 + 0.5 * a.cos(), 0.5 + 0.5 * a.sin())).collect(),
|
||||
ShapeKind::Ellipse | ShapeKind::FlowchartOr => {
|
||||
(0..48).map(|i| (i as f32) * TAU / 48.0).map(|a| (0.5 + 0.5 * a.cos(), 0.5 + 0.5 * a.sin())).collect()
|
||||
}
|
||||
ShapeKind::RoundedRectangle => {
|
||||
// Corner radius as drawn: 16% of the shorter side.
|
||||
let r = 0.16 * w.min(h);
|
||||
// Corner radius as drawn.
|
||||
let r = crate::connector::corner_radius(corner, w, h);
|
||||
let (rx, ry) = if w > 0.0 && h > 0.0 { (r / w, r / h) } else { (0.0, 0.0) };
|
||||
let mut pts = Vec::new();
|
||||
for (cx, cy, a0) in [(1.0 - rx, 1.0 - ry, 0.0), (rx, 1.0 - ry, FRAC_PI_2), (rx, ry, PI), (1.0 - rx, ry, PI + FRAC_PI_2)] {
|
||||
@@ -88,6 +90,7 @@ pub fn shape_outline(kind: ShapeKind, w: f32, h: f32) -> Option<Vec<(f32, f32)>>
|
||||
pts
|
||||
}
|
||||
ShapeKind::Triangle => vec![(0.5, 0.0), (1.0, 1.0), (0.0, 1.0)],
|
||||
ShapeKind::RightTriangle => vec![(0.0, 0.0), (1.0, 1.0), (0.0, 1.0)],
|
||||
ShapeKind::Diamond => vec![(0.5, 0.0), (1.0, 0.5), (0.5, 1.0), (0.0, 0.5)],
|
||||
ShapeKind::Arrow => {
|
||||
// As drawn: the head is half the height long, the shaft 40% of the height thick.
|
||||
@@ -217,18 +220,26 @@ mod tests {
|
||||
ShapeKind::Ellipse,
|
||||
ShapeKind::RoundedRectangle,
|
||||
ShapeKind::Triangle,
|
||||
ShapeKind::RightTriangle,
|
||||
ShapeKind::Diamond,
|
||||
ShapeKind::Arrow,
|
||||
ShapeKind::Star,
|
||||
ShapeKind::Heart,
|
||||
] {
|
||||
for (w, h) in [(100.0, 50.0), (0.0, 0.0), (f32::NAN, 10.0)] {
|
||||
let pts = shape_outline(kind, w, h).unwrap();
|
||||
assert!(pts.len() >= 3 && pts.len() <= MAX_DERIVED_POINTS, "{kind:?}");
|
||||
assert!(pts.iter().all(|(x, y)| (-0.001..=1.001).contains(x) && (-0.001..=1.001).contains(y)), "{kind:?}");
|
||||
for corner in [None, Some(0), Some(3_565), Some(u32::MAX)] {
|
||||
let pts = shape_outline(kind, w, h, corner).unwrap();
|
||||
assert!(pts.len() >= 3 && pts.len() <= MAX_DERIVED_POINTS, "{kind:?}");
|
||||
assert!(pts.iter().all(|(x, y)| (-0.001..=1.001).contains(x) && (-0.001..=1.001).contains(y)), "{kind:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(shape_outline(ShapeKind::Rectangle, 10.0, 10.0).is_none());
|
||||
assert!(shape_outline(ShapeKind::Rectangle, 10.0, 10.0, None).is_none());
|
||||
// The end of the bottom-right corner: in the frame's corner when the corner is square,
|
||||
// 16% of the height (8 of 100 wide) in from it by default.
|
||||
let corner_pt = |corner| shape_outline(ShapeKind::RoundedRectangle, 100.0, 50.0, corner).unwrap()[6];
|
||||
assert!(corner_pt(Some(0)).0 > 0.999 && corner_pt(Some(0)).1 > 0.999, "{:?}", corner_pt(Some(0)));
|
||||
assert!((corner_pt(None).0 - 0.92).abs() < 0.001, "{:?}", corner_pt(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -14,6 +14,7 @@ wordcraft-geom = { workspace = true }
|
||||
cfb = { workspace = true }
|
||||
image = { workspace = true }
|
||||
miniz_oxide = { workspace = true }
|
||||
sha1 = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
//! Password-protected Word 97-2003 files ([MS-DOC] §2.2.6, [MS-OFFCRYPTO] §2.3.5-2.3.7).
|
||||
//!
|
||||
//! Three schemes exist for `.doc`:
|
||||
//!
|
||||
//! * **XOR obfuscation** (`fObfuscated`): a 32-bit password verifier sits in `FibBase.lKey` and
|
||||
//! the streams are XORed with a 16-byte array derived from the password (method 2).
|
||||
//! * **RC4** (header version 1.1): `EncryptionHeader` at the start of the Table stream; the key
|
||||
//! per 512-byte block comes from MD5.
|
||||
//! * **RC4 CryptoAPI** (header version 2/3/4 . 2): like RC4 but with a SHA-1 key derivation, a
|
||||
//! key size of 40-128 bits and a variable-size header.
|
||||
//!
|
||||
//! In all three the WordDocument stream (past its first 68 bytes), the Table stream (past its
|
||||
//! header, for RC4) and the whole Data stream are transformed. The password is checked against
|
||||
//! the stored verifier before anything is decrypted, so a wrong password is reported and never
|
||||
//! yields a garbled document. Everything is written from the public specifications.
|
||||
|
||||
use sha1::{Digest, Sha1};
|
||||
|
||||
use crate::DocbinError;
|
||||
|
||||
/// Bytes at the start of the WordDocument stream that are stored untransformed.
|
||||
pub(crate) const FIB_PLAIN: usize = 68;
|
||||
/// RC4 re-keys every this many bytes of a stream.
|
||||
const BLOCK: usize = 512;
|
||||
/// Longest password considered (the specifications' limit for the RC4 schemes).
|
||||
const MAX_PASSWORD_CHARS: usize = 255;
|
||||
/// XOR obfuscation uses at most this many single-byte password characters.
|
||||
const MAX_XOR_PASSWORD: usize = 15;
|
||||
/// `FibBase.flags`: the document is encrypted (`fEncrypted`) / obfuscated (`fObfuscated`).
|
||||
pub(crate) const F_ENCRYPTED: u16 = 0x0100;
|
||||
pub(crate) const F_OBFUSCATED: u16 = 0x8000;
|
||||
/// `EncryptionHeaderFlags`: `fCryptoAPI`, `fExternal`, `fAES`.
|
||||
const H_CRYPTO_API: u32 = 0x04;
|
||||
const H_EXTERNAL: u32 = 0x10;
|
||||
const H_AES: u32 = 0x20;
|
||||
|
||||
fn unsupported(what: &str) -> DocbinError {
|
||||
DocbinError::Encryption(what.into())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Primitives
|
||||
|
||||
/// MD5 ([RFC 1321]); the RC4 scheme is defined on it.
|
||||
pub(crate) fn md5(data: &[u8]) -> [u8; 16] {
|
||||
const S: [u32; 64] = [
|
||||
7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16,
|
||||
23, 4, 11, 16, 23, 4, 11, 16, 23, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21,
|
||||
];
|
||||
// K[i] = floor(2^32 * abs(sin(i + 1))), as the RFC defines it.
|
||||
let k: Vec<u32> = (0..64u32).map(|i| (f64::from(i + 1).sin().abs() * 4_294_967_296.0) as u32).collect();
|
||||
let (mut a0, mut b0, mut c0, mut d0) = (0x6745_2301u32, 0xefcd_ab89u32, 0x98ba_dcfeu32, 0x1032_5476u32);
|
||||
let mut msg = Vec::with_capacity(data.len() + 72);
|
||||
msg.extend_from_slice(data);
|
||||
msg.push(0x80);
|
||||
while msg.len() % 64 != 56 {
|
||||
msg.push(0);
|
||||
}
|
||||
msg.extend_from_slice(&(data.len() as u64).wrapping_mul(8).to_le_bytes());
|
||||
for chunk in msg.as_chunks::<64>().0 {
|
||||
let mut m = [0u32; 16];
|
||||
let words = chunk.as_chunks::<4>().0;
|
||||
for (w, b) in m.iter_mut().zip(words) {
|
||||
*w = u32::from_le_bytes(*b);
|
||||
}
|
||||
let (mut a, mut b, mut c, mut d) = (a0, b0, c0, d0);
|
||||
for i in 0..64usize {
|
||||
let (f, g) = match i / 16 {
|
||||
0 => ((b & c) | (!b & d), i),
|
||||
1 => ((d & b) | (!d & c), (5 * i + 1) % 16),
|
||||
2 => (b ^ c ^ d, (3 * i + 5) % 16),
|
||||
_ => (c ^ (b | !d), (7 * i) % 16),
|
||||
};
|
||||
let f = f.wrapping_add(a).wrapping_add(k[i]).wrapping_add(m[g]);
|
||||
a = d;
|
||||
d = c;
|
||||
c = b;
|
||||
b = b.wrapping_add(f.rotate_left(S[i]));
|
||||
}
|
||||
a0 = a0.wrapping_add(a);
|
||||
b0 = b0.wrapping_add(b);
|
||||
c0 = c0.wrapping_add(c);
|
||||
d0 = d0.wrapping_add(d);
|
||||
}
|
||||
let mut out = [0u8; 16];
|
||||
let (cells, _) = out.as_chunks_mut::<4>();
|
||||
for (o, v) in cells.iter_mut().zip([a0, b0, c0, d0]) {
|
||||
*o = v.to_le_bytes();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn sha1(parts: &[&[u8]]) -> Vec<u8> {
|
||||
let mut h = Sha1::new();
|
||||
for p in parts {
|
||||
h.update(p);
|
||||
}
|
||||
h.finalize().to_vec()
|
||||
}
|
||||
|
||||
/// The RC4 stream cipher.
|
||||
pub(crate) struct Rc4 {
|
||||
s: [u8; 256],
|
||||
i: u8,
|
||||
j: u8,
|
||||
}
|
||||
|
||||
impl Rc4 {
|
||||
pub(crate) fn new(key: &[u8]) -> Rc4 {
|
||||
let mut s = [0u8; 256];
|
||||
for (n, v) in s.iter_mut().enumerate() {
|
||||
*v = n as u8;
|
||||
}
|
||||
let kl = key.len().max(1);
|
||||
let mut j = 0u8;
|
||||
for i in 0..256usize {
|
||||
let k = key.get(i % kl).copied().unwrap_or(0);
|
||||
j = j.wrapping_add(s[i]).wrapping_add(k);
|
||||
s.swap(i, usize::from(j));
|
||||
}
|
||||
Rc4 { s, i: 0, j: 0 }
|
||||
}
|
||||
|
||||
fn next(&mut self) -> u8 {
|
||||
self.i = self.i.wrapping_add(1);
|
||||
self.j = self.j.wrapping_add(self.s[usize::from(self.i)]);
|
||||
self.s.swap(usize::from(self.i), usize::from(self.j));
|
||||
self.s[usize::from(self.s[usize::from(self.i)].wrapping_add(self.s[usize::from(self.j)]))]
|
||||
}
|
||||
|
||||
pub(crate) fn apply(&mut self, data: &mut [u8]) {
|
||||
for b in data {
|
||||
*b ^= self.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn utf16le(password: &str) -> Vec<u8> {
|
||||
password.encode_utf16().take(MAX_PASSWORD_CHARS).flat_map(u16::to_le_bytes).collect()
|
||||
}
|
||||
|
||||
fn u16_at(d: &[u8], at: usize) -> Option<u16> {
|
||||
Some(u16::from_le_bytes(d.get(at..at.checked_add(2)?)?.try_into().ok()?))
|
||||
}
|
||||
|
||||
fn u32_at(d: &[u8], at: usize) -> Option<u32> {
|
||||
Some(u32::from_le_bytes(d.get(at..at.checked_add(4)?)?.try_into().ok()?))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// XOR obfuscation (method 1 key/verifier pieces, method 2 array and transform)
|
||||
|
||||
const PAD_ARRAY: [u8; 15] = [0xBB, 0xFF, 0xFF, 0xBA, 0xFF, 0xFF, 0xB9, 0x80, 0x00, 0xBE, 0x0F, 0x00, 0xBF, 0x0F, 0x00];
|
||||
const INITIAL_CODE: [u16; 15] =
|
||||
[0xE1F0, 0x1D0F, 0xCC9C, 0x84C0, 0x110C, 0x0E10, 0xF1CE, 0x313E, 0x1872, 0xE139, 0xD40F, 0x84F9, 0x280C, 0xA96A, 0x4EC3];
|
||||
const XOR_MATRIX: [u16; 105] = [
|
||||
0xAEFC, 0x4DD9, 0x9BB2, 0x2745, 0x4E8A, 0x9D14, 0x2A09, 0x7B61, 0xF6C2, 0xFDA5, 0xEB6B, 0xC6F7, 0x9DCF, 0x2BBF, 0x4563, 0x8AC6, 0x05AD, 0x0B5A,
|
||||
0x16B4, 0x2D68, 0x5AD0, 0x0375, 0x06EA, 0x0DD4, 0x1BA8, 0x3750, 0x6EA0, 0xDD40, 0xD849, 0xA0B3, 0x5147, 0xA28E, 0x553D, 0xAA7A, 0x44D5, 0x6F45,
|
||||
0xDE8A, 0xAD35, 0x4A4B, 0x9496, 0x390D, 0x721A, 0xEB23, 0xC667, 0x9CEF, 0x29FF, 0x53FE, 0xA7FC, 0x5FD9, 0x47D3, 0x8FA6, 0x0F6D, 0x1EDA, 0x3DB4,
|
||||
0x7B68, 0xF6D0, 0xB861, 0x60E3, 0xC1C6, 0x93AD, 0x377B, 0x6EF6, 0xDDEC, 0x45A0, 0x8B40, 0x06A1, 0x0D42, 0x1A84, 0x3508, 0x6A10, 0xAA51, 0x4483,
|
||||
0x8906, 0x022D, 0x045A, 0x08B4, 0x1168, 0x76B4, 0xED68, 0xCAF1, 0x85C3, 0x1BA7, 0x374E, 0x6E9C, 0x3730, 0x6E60, 0xDCC0, 0xA9A1, 0x4363, 0x86C6,
|
||||
0x1DAD, 0x3331, 0x6662, 0xCCC4, 0x89A9, 0x0373, 0x06E6, 0x0DCC, 0x1021, 0x2042, 0x4084, 0x8108, 0x1231, 0x2462, 0x48C4,
|
||||
];
|
||||
|
||||
/// `CreateXorKey_Method1`. `pw` is 1 to 15 bytes.
|
||||
fn xor_key(pw: &[u8]) -> u16 {
|
||||
let mut key = INITIAL_CODE.get(pw.len().wrapping_sub(1)).copied().unwrap_or(0);
|
||||
let mut current = 0x68i32;
|
||||
for &ch in pw.iter().rev() {
|
||||
let mut ch = ch;
|
||||
for _ in 0..7 {
|
||||
if ch & 0x40 != 0
|
||||
&& let Some(m) = usize::try_from(current).ok().and_then(|c| XOR_MATRIX.get(c))
|
||||
{
|
||||
key ^= m;
|
||||
}
|
||||
ch <<= 1;
|
||||
current -= 1;
|
||||
}
|
||||
}
|
||||
key
|
||||
}
|
||||
|
||||
/// `CreatePasswordVerifier_Method1`.
|
||||
fn verifier_method1(pw: &[u8]) -> u16 {
|
||||
let mut v = 0u16;
|
||||
for &b in pw.iter().rev().chain(std::iter::once(&(pw.len() as u8))) {
|
||||
let i1 = u16::from(v & 0x4000 != 0);
|
||||
let i2 = v.wrapping_mul(2) & 0x7FFF;
|
||||
v = (i1 | i2) ^ u16::from(b);
|
||||
}
|
||||
v ^ 0xCE4B
|
||||
}
|
||||
|
||||
/// `CreatePasswordVerifier_Method2`: the 32-bit value stored in `FibBase.lKey`.
|
||||
pub(crate) fn xor_verifier(pw: &[u8]) -> u32 {
|
||||
(u32::from(xor_key(pw)) << 16) | u32::from(verifier_method1(pw))
|
||||
}
|
||||
|
||||
/// `CreateXorArray_Method2`.
|
||||
pub(crate) fn xor_array(pw: &[u8]) -> [u8; 16] {
|
||||
let key = (xor_verifier(pw) >> 16) as u16;
|
||||
let (high, low) = ((key >> 8) as u8, key as u8);
|
||||
let mut arr = [0u8; 16];
|
||||
for (i, slot) in arr.iter_mut().enumerate() {
|
||||
*slot = pw.get(i).copied().unwrap_or_else(|| PAD_ARRAY.get(i.saturating_sub(pw.len())).copied().unwrap_or(0));
|
||||
}
|
||||
let (pairs, _) = arr.as_chunks_mut::<2>();
|
||||
for pair in pairs {
|
||||
pair[0] = (pair[0] ^ low).rotate_right(1);
|
||||
pair[1] = (pair[1] ^ high).rotate_right(1);
|
||||
}
|
||||
arr
|
||||
}
|
||||
|
||||
/// The single-byte forms of a password the XOR scheme tries: the system-codepage form (Latin-1
|
||||
/// here) and the "low byte, or high byte when the low one is zero" form. Each is cut to 15.
|
||||
fn xor_candidates(password: &str) -> Vec<Vec<u8>> {
|
||||
let mut out: Vec<Vec<u8>> = Vec::new();
|
||||
let latin: Option<Vec<u8>> = password.chars().take(MAX_XOR_PASSWORD).map(|c| u8::try_from(u32::from(c)).ok()).collect();
|
||||
if let Some(l) = latin {
|
||||
out.push(l);
|
||||
}
|
||||
let folded: Vec<u8> = password.encode_utf16().take(MAX_XOR_PASSWORD).map(|u| if u & 0xFF == 0 { (u >> 8) as u8 } else { u as u8 }).collect();
|
||||
if !out.contains(&folded) {
|
||||
out.push(folded);
|
||||
}
|
||||
out.retain(|p| !p.is_empty());
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The cipher
|
||||
|
||||
/// How the streams of one file are transformed, once the password has been verified.
|
||||
pub(crate) enum Cipher {
|
||||
Xor { array: [u8; 16] },
|
||||
Rc4 { base: Vec<u8>, crypto_api: bool, key_len: usize, table_skip: usize },
|
||||
}
|
||||
|
||||
impl Cipher {
|
||||
/// Check `password` against the file's verifier and set up the cipher. `flags` and `lkey` come
|
||||
/// from the FIB base, `table` is the (still encrypted) Table stream.
|
||||
pub(crate) fn open(flags: u16, lkey: u32, table: &[u8], password: &str) -> Result<Cipher, DocbinError> {
|
||||
if flags & F_OBFUSCATED != 0 {
|
||||
for pw in xor_candidates(password) {
|
||||
if xor_verifier(&pw) == lkey {
|
||||
return Ok(Cipher::Xor { array: xor_array(&pw) });
|
||||
}
|
||||
}
|
||||
return Err(DocbinError::WrongPassword);
|
||||
}
|
||||
let short = || DocbinError::Malformed("the encryption header is truncated".into());
|
||||
let major = u16_at(table, 0).ok_or_else(short)?;
|
||||
let minor = u16_at(table, 2).ok_or_else(short)?;
|
||||
match (major, minor) {
|
||||
(1, 1) => Self::open_rc4(table, password),
|
||||
(2..=4, 2) => Self::open_crypto_api(table, password),
|
||||
_ => Err(unsupported(&format!("unknown encryption version {major}.{minor}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn open_rc4(table: &[u8], password: &str) -> Result<Cipher, DocbinError> {
|
||||
let short = || DocbinError::Malformed("the encryption header is truncated".into());
|
||||
let salt = table.get(4..20).ok_or_else(short)?;
|
||||
let enc_verifier = table.get(20..36).ok_or_else(short)?;
|
||||
let enc_hash = table.get(36..52).ok_or_else(short)?;
|
||||
let h0 = md5(&utf16le(password));
|
||||
let mut buf = Vec::with_capacity(336);
|
||||
for _ in 0..16 {
|
||||
buf.extend_from_slice(&h0[..5]);
|
||||
buf.extend_from_slice(salt);
|
||||
}
|
||||
let base = md5(&buf)[..5].to_vec();
|
||||
let cipher = Cipher::Rc4 { base, crypto_api: false, key_len: 16, table_skip: 52 };
|
||||
let mut rc4 = Rc4::new(&cipher.block_key(0));
|
||||
let mut verifier = [0u8; 16];
|
||||
verifier.copy_from_slice(enc_verifier);
|
||||
let mut hash = [0u8; 16];
|
||||
hash.copy_from_slice(enc_hash);
|
||||
rc4.apply(&mut verifier);
|
||||
rc4.apply(&mut hash);
|
||||
if md5(&verifier) != hash {
|
||||
return Err(DocbinError::WrongPassword);
|
||||
}
|
||||
Ok(cipher)
|
||||
}
|
||||
|
||||
fn open_crypto_api(table: &[u8], password: &str) -> Result<Cipher, DocbinError> {
|
||||
let short = || DocbinError::Malformed("the encryption header is truncated".into());
|
||||
let hflags = u32_at(table, 4).ok_or_else(short)?;
|
||||
if hflags & H_EXTERNAL != 0 {
|
||||
return Err(unsupported("the document uses an external encryption provider"));
|
||||
}
|
||||
if hflags & H_AES != 0 {
|
||||
return Err(unsupported("AES-encrypted .doc files are not supported"));
|
||||
}
|
||||
if hflags & H_CRYPTO_API == 0 {
|
||||
return Err(unsupported("unknown encryption header"));
|
||||
}
|
||||
let hsize = usize::try_from(u32_at(table, 8).ok_or_else(short)?).unwrap_or(usize::MAX);
|
||||
// EncryptionHeader: flags, sizeExtra, algId, algIdHash, keySize, providerType, 2 reserved
|
||||
// words and the CSP name; it starts at byte 12.
|
||||
if hsize < 32 || hsize > table.len() {
|
||||
return Err(DocbinError::Malformed("the encryption header has a bad size".into()));
|
||||
}
|
||||
let alg_id = u32_at(table, 20).ok_or_else(short)?;
|
||||
let alg_hash = u32_at(table, 24).ok_or_else(short)?;
|
||||
if !matches!(alg_id, 0 | 0x6801) {
|
||||
return Err(unsupported(&format!("encryption algorithm 0x{alg_id:X}")));
|
||||
}
|
||||
if !matches!(alg_hash, 0 | 0x8004) {
|
||||
return Err(unsupported(&format!("hash algorithm 0x{alg_hash:X}")));
|
||||
}
|
||||
let mut bits = u32_at(table, 28).ok_or_else(short)?;
|
||||
if bits == 0 {
|
||||
bits = 40;
|
||||
}
|
||||
if !(40..=128).contains(&bits) || bits % 8 != 0 {
|
||||
return Err(unsupported(&format!("a {bits}-bit key")));
|
||||
}
|
||||
let key_len = (bits / 8) as usize;
|
||||
// EncryptionVerifier: saltSize, salt[16], encryptedVerifier[16], verifierHashSize,
|
||||
// encryptedVerifierHash[20].
|
||||
let v = 12 + hsize;
|
||||
if u32_at(table, v) != Some(16) {
|
||||
return Err(DocbinError::Malformed("the encryption salt has a bad size".into()));
|
||||
}
|
||||
let salt = table.get(v + 4..v + 20).ok_or_else(short)?;
|
||||
let enc_verifier = table.get(v + 20..v + 36).ok_or_else(short)?;
|
||||
if u32_at(table, v + 36) != Some(20) {
|
||||
return Err(unsupported("a verifier hash that is not SHA-1"));
|
||||
}
|
||||
let enc_hash = table.get(v + 40..v + 60).ok_or_else(short)?;
|
||||
let mut pw = utf16le(password);
|
||||
let base = sha1(&[salt, &pw]);
|
||||
pw.fill(0);
|
||||
let cipher = Cipher::Rc4 { base, crypto_api: true, key_len, table_skip: v + 60 };
|
||||
let mut rc4 = Rc4::new(&cipher.block_key(0));
|
||||
let mut verifier = enc_verifier.to_vec();
|
||||
let mut hash = enc_hash.to_vec();
|
||||
rc4.apply(&mut verifier);
|
||||
rc4.apply(&mut hash);
|
||||
if sha1(&[&verifier]) != hash {
|
||||
return Err(DocbinError::WrongPassword);
|
||||
}
|
||||
Ok(cipher)
|
||||
}
|
||||
|
||||
/// The RC4 key of stream block `block`.
|
||||
fn block_key(&self, block: u32) -> Vec<u8> {
|
||||
match self {
|
||||
Cipher::Xor { .. } => Vec::new(),
|
||||
Cipher::Rc4 { base, crypto_api: false, .. } => md5(&[base.as_slice(), &block.to_le_bytes()].concat()).to_vec(),
|
||||
Cipher::Rc4 { base, crypto_api: true, key_len, .. } => {
|
||||
let mut k = sha1(&[base, &block.to_le_bytes()]);
|
||||
k.truncate(*key_len);
|
||||
// A 40-bit key is zero-padded to 128 bits.
|
||||
if *key_len == 5 {
|
||||
k.resize(16, 0);
|
||||
}
|
||||
k
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decrypt `data` in place; its first `skip` bytes are stored untransformed.
|
||||
fn decrypt(&self, data: &mut [u8], skip: usize) {
|
||||
match self {
|
||||
Cipher::Xor { array } => {
|
||||
for (i, b) in data.iter_mut().enumerate().skip(skip) {
|
||||
let a = array[i % 16];
|
||||
// Zero bytes, and bytes equal to the array element, were left as they were.
|
||||
if *b != 0 && *b != a {
|
||||
*b ^= a;
|
||||
}
|
||||
}
|
||||
}
|
||||
Cipher::Rc4 { .. } => {
|
||||
for (n, chunk) in data.chunks_mut(BLOCK).enumerate() {
|
||||
let Ok(block) = u32::try_from(n) else { return };
|
||||
let mut rc4 = Rc4::new(&self.block_key(block));
|
||||
let start = n * BLOCK;
|
||||
// The keystream runs over the untransformed bytes too.
|
||||
let plain = skip.saturating_sub(start).min(chunk.len());
|
||||
let head = chunk.get(..plain).map(<[u8]>::to_vec).unwrap_or_default();
|
||||
rc4.apply(chunk);
|
||||
if let Some(dst) = chunk.get_mut(..plain) {
|
||||
dst.copy_from_slice(&head);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn decrypt_word(&self, data: &mut [u8]) {
|
||||
self.decrypt(data, FIB_PLAIN);
|
||||
}
|
||||
|
||||
pub(crate) fn decrypt_table(&self, data: &mut [u8]) {
|
||||
let skip = match self {
|
||||
Cipher::Xor { .. } => 0,
|
||||
Cipher::Rc4 { table_skip, .. } => *table_skip,
|
||||
};
|
||||
self.decrypt(data, skip);
|
||||
}
|
||||
|
||||
pub(crate) fn decrypt_data(&self, data: &mut [u8]) {
|
||||
self.decrypt(data, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn hex(b: &[u8]) -> String {
|
||||
b.iter().map(|x| format!("{x:02x}")).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn md5_matches_the_rfc_1321_vectors() {
|
||||
assert_eq!(hex(&md5(b"")), "d41d8cd98f00b204e9800998ecf8427e");
|
||||
assert_eq!(hex(&md5(b"a")), "0cc175b9c0f1b6a831c399e269772661");
|
||||
assert_eq!(hex(&md5(b"abc")), "900150983cd24fb0d6963f7d28e17f72");
|
||||
assert_eq!(hex(&md5(b"message digest")), "f96b697d7cb7938d525a2f31aaf161d0");
|
||||
assert_eq!(
|
||||
hex(&md5(b"12345678901234567890123456789012345678901234567890123456789012345678901234567890")),
|
||||
"57edf4a22be3c955ac49da2e2107b67a"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rc4_matches_a_published_vector() {
|
||||
let mut d = b"Plaintext".to_vec();
|
||||
Rc4::new(b"Key").apply(&mut d);
|
||||
assert_eq!(hex(&d), "bbf316e8d940af0ad3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xor_candidates_cut_to_fifteen_and_skip_empty() {
|
||||
assert!(xor_candidates("").is_empty());
|
||||
assert_eq!(xor_candidates("abcdefghijklmnopqrst")[0].len(), 15);
|
||||
// Not representable as single bytes: only the folded form remains.
|
||||
assert_eq!(xor_candidates("\u{4e2d}").len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -37,16 +37,22 @@ fn plc(table: &[u8], (fc, lcb): (u32, u32), cb: usize) -> Vec<(u32, &[u8])> {
|
||||
|
||||
/// The text box stories of a `PlcftxbxTxt` (or `PlcfHdrtxbxTxt`): (shape spid, story CP range
|
||||
/// relative to the text box subdocument).
|
||||
///
|
||||
/// The CPs of a well-formed PLC ascend, so the stories are disjoint. A story starting before
|
||||
/// the end of the last one kept (a hostile table pointing many text boxes at the same text,
|
||||
/// each read into its own part) is dropped: every subdocument CP becomes at most one story.
|
||||
pub(crate) fn textbox_stories(table: &[u8], pair: Option<(u32, u32)>) -> Vec<(u32, (u32, u32))> {
|
||||
let Some(pair) = pair else { return Vec::new() };
|
||||
let Some(blob) = table.get(pair.0 as usize..(pair.0 as usize).saturating_add(pair.1 as usize)) else { return Vec::new() };
|
||||
let mut out = Vec::new();
|
||||
let mut kept_to = 0u32;
|
||||
for (i, (cp, ftxbxs)) in plc(table, pair, CB_FTXBXS).iter().enumerate() {
|
||||
// The story runs to the next entry's CP (the PLC's last CP for the last entry).
|
||||
let Some(end) = blob.get((i + 1) * 4..(i + 1) * 4 + 4).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes) else { continue };
|
||||
let Some(lid) = ftxbxs.get(14..18).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes) else { continue };
|
||||
if end > *cp && lid != 0 {
|
||||
if end > *cp && *cp >= kept_to && lid != 0 {
|
||||
out.push((lid, (*cp, end)));
|
||||
kept_to = end;
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -180,7 +186,8 @@ fn leaf(sp: &Sp, w: f32, h: f32, float: Float, b: &Build, pics: &mut Pictures) -
|
||||
2 => ShapeKind::RoundedRectangle,
|
||||
3 => ShapeKind::Ellipse,
|
||||
4 => ShapeKind::Diamond,
|
||||
5 | 6 => ShapeKind::Triangle,
|
||||
5 => ShapeKind::Triangle,
|
||||
6 => ShapeKind::RightTriangle,
|
||||
12 | 187 => ShapeKind::Star,
|
||||
13 | 66 | 67 | 68 | 69 => ShapeKind::Arrow,
|
||||
20 | 32 => ShapeKind::Line,
|
||||
|
||||
@@ -80,10 +80,33 @@ impl SubDocs {
|
||||
}
|
||||
}
|
||||
|
||||
/// What the unencrypted head of a password-protected file says (see [`peek_encryption`]).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct Protection {
|
||||
pub(crate) flags: u16,
|
||||
/// `FibBase.lKey`: the XOR verifier, or the size of the RC4 header in the Table stream.
|
||||
pub(crate) lkey: u32,
|
||||
pub(crate) which_tbl_stm: bool,
|
||||
}
|
||||
|
||||
/// Is the WordDocument stream that of an encrypted Word 97-2003 file? Only the first 68 bytes are
|
||||
/// stored in the clear; this reads nothing else. `None` for anything else, including streams that
|
||||
/// are too short or have a foreign magic (the full parse reports those).
|
||||
pub(crate) fn peek_encryption(word: &[u8]) -> Option<Protection> {
|
||||
if word.len() < crate::crypt::FIB_PLAIN || word.get(0..2)? != W_IDENT.to_le_bytes() {
|
||||
return None;
|
||||
}
|
||||
let flags = u16::from_le_bytes([*word.get(OFF_FLAGS)?, *word.get(OFF_FLAGS + 1)?]);
|
||||
if flags & crate::crypt::F_ENCRYPTED == 0 {
|
||||
return None;
|
||||
}
|
||||
let lkey = u32::from_le_bytes([*word.get(0x0E)?, *word.get(0x0F)?, *word.get(0x10)?, *word.get(0x11)?]);
|
||||
Some(Protection { flags, lkey, which_tbl_stm: flags & 0x0200 != 0 })
|
||||
}
|
||||
|
||||
/// The parsed FIB.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Fib {
|
||||
pub(crate) encrypted: bool,
|
||||
/// Selects 1Table (true) or 0Table (false).
|
||||
pub(crate) which_tbl_stm: bool,
|
||||
pub(crate) ccp: SubDocs,
|
||||
@@ -157,6 +180,6 @@ impl Fib {
|
||||
pairs.push(u32le(blob_off + i * 4));
|
||||
}
|
||||
|
||||
Ok(Fib { encrypted: flags & 0x0100 != 0, which_tbl_stm: flags & 0x0200 != 0, ccp: sub, pairs })
|
||||
Ok(Fib { which_tbl_stm: flags & 0x0200 != 0, ccp: sub, pairs })
|
||||
}
|
||||
}
|
||||
|
||||
+72
-18
@@ -8,12 +8,14 @@
|
||||
//! capped), so hostile input yields an error or a best-effort document, never a panic.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod crypt;
|
||||
mod drawing;
|
||||
mod fib;
|
||||
mod fkp;
|
||||
mod fmt;
|
||||
mod list;
|
||||
mod media;
|
||||
mod metadata;
|
||||
mod notes;
|
||||
mod officeart;
|
||||
mod piece;
|
||||
@@ -43,6 +45,10 @@ use table::ParaOut;
|
||||
const MAX_STREAM: u64 = 1 << 30;
|
||||
/// Most header/footer parts we create (matches the docx reader's cap).
|
||||
const MAX_PARTS: usize = 50_000;
|
||||
/// Hard ceiling on characters materialised across every story walk of one document (body,
|
||||
/// headers, notes, text boxes), whatever the file's size. The working budget is tied to the
|
||||
/// WordDocument stream (see [`read`]); this caps even a maximal stream.
|
||||
const MAX_TOTAL_CHARS: u64 = 64 << 20;
|
||||
/// `sprmCPicLocation`: the fc of a picture in the Data stream.
|
||||
const C_PIC_LOCATION: u16 = 0x6A03;
|
||||
/// `sprmPIlvl` / `sprmPIlfo`: the paragraph's list level and list.
|
||||
@@ -58,9 +64,16 @@ pub enum DocbinError {
|
||||
/// The compound file is readable but is not a Word 97-2003 document.
|
||||
#[error("not a Word 97-2003 document: {0}")]
|
||||
NotWord(String),
|
||||
/// The document is password-protected (XOR obfuscation or RC4 encryption).
|
||||
#[error("encrypted or password-protected .doc files are not supported")]
|
||||
Encrypted,
|
||||
/// The document is password-protected and no password was given.
|
||||
#[error("this document is protected with a password")]
|
||||
PasswordRequired,
|
||||
/// The password does not open the document.
|
||||
#[error("the password is incorrect")]
|
||||
WrongPassword,
|
||||
/// The document is encrypted in a way WordCraft can't read (AES, an external provider, an
|
||||
/// unknown header).
|
||||
#[error("encrypted document: {0}")]
|
||||
Encryption(String),
|
||||
/// The input exceeds a safety limit (stream size, piece count…).
|
||||
#[error("limit exceeded: {0}")]
|
||||
Limit(String),
|
||||
@@ -69,25 +82,51 @@ pub enum DocbinError {
|
||||
Malformed(String),
|
||||
}
|
||||
|
||||
/// Read a `.doc` (Word 97-2003 binary) file into a [`Document`].
|
||||
/// Is this `.doc` password-protected (XOR obfuscation, RC4 or RC4 CryptoAPI)? False for
|
||||
/// anything that is not a readable Word 97-2003 file.
|
||||
pub fn is_encrypted(bytes: &[u8]) -> bool {
|
||||
let Ok(mut comp) = cfb::CompoundFile::open(std::io::Cursor::new(bytes)) else { return false };
|
||||
stream(&mut comp, "WordDocument").is_some_and(|w| fib::peek_encryption(&w).is_some())
|
||||
}
|
||||
|
||||
/// Read a `.doc` (Word 97-2003 binary) file into a [`Document`]. A password-protected file
|
||||
/// gives [`DocbinError::PasswordRequired`]; see [`read_with_password`].
|
||||
pub fn read(bytes: &[u8]) -> Result<Document, DocbinError> {
|
||||
read_with_password(bytes, None)
|
||||
}
|
||||
|
||||
/// [`read`] for a file that may be password-protected: it is checked against the file's stored
|
||||
/// verifier ([`DocbinError::WrongPassword`] when it does not match) and then decrypted. The
|
||||
/// password is ignored for an unprotected file.
|
||||
pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Document, DocbinError> {
|
||||
let mut comp = cfb::CompoundFile::open(std::io::Cursor::new(bytes)).map_err(|e| DocbinError::Container(e.to_string()))?;
|
||||
let word = stream(&mut comp, "WordDocument").ok_or_else(|| DocbinError::NotWord("no WordDocument stream".into()))?;
|
||||
let mut word = stream(&mut comp, "WordDocument").ok_or_else(|| DocbinError::NotWord("no WordDocument stream".into()))?;
|
||||
if word.len() as u64 > MAX_STREAM {
|
||||
return Err(DocbinError::Limit(format!("WordDocument stream is {} bytes", word.len())));
|
||||
}
|
||||
let fib = fib::Fib::parse(&word)?;
|
||||
if fib.encrypted {
|
||||
return Err(DocbinError::Encrypted);
|
||||
}
|
||||
let enc = fib::peek_encryption(&word);
|
||||
// The FIB names the Table stream (0Table or 1Table); fall back to whichever exists,
|
||||
// since files written by other writers sometimes carry the wrong bit.
|
||||
let named = if fib.which_tbl_stm { "1Table" } else { "0Table" };
|
||||
let other = if fib.which_tbl_stm { "0Table" } else { "1Table" };
|
||||
let table = stream(&mut comp, named).or_else(|| stream(&mut comp, other)).unwrap_or_default();
|
||||
let which = if let Some(e) = enc { e.which_tbl_stm } else { fib::Fib::parse(&word)?.which_tbl_stm };
|
||||
let named = if which { "1Table" } else { "0Table" };
|
||||
let other = if which { "0Table" } else { "1Table" };
|
||||
let mut table = stream(&mut comp, named).or_else(|| stream(&mut comp, other)).unwrap_or_default();
|
||||
if table.len() as u64 > MAX_STREAM {
|
||||
return Err(DocbinError::Limit(format!("Table stream is {} bytes", table.len())));
|
||||
}
|
||||
let mut data = stream(&mut comp, "Data").unwrap_or_default();
|
||||
if data.len() as u64 > MAX_STREAM {
|
||||
return Err(DocbinError::Limit(format!("Data stream is {} bytes", data.len())));
|
||||
}
|
||||
if let Some(e) = enc {
|
||||
// Verify the password first; only then is anything decrypted.
|
||||
let password = password.ok_or(DocbinError::PasswordRequired)?;
|
||||
let cipher = crypt::Cipher::open(e.flags, e.lkey, &table, password)?;
|
||||
cipher.decrypt_word(&mut word);
|
||||
cipher.decrypt_table(&mut table);
|
||||
cipher.decrypt_data(&mut data);
|
||||
}
|
||||
let fib = fib::Fib::parse(&word)?;
|
||||
|
||||
// The piece table locates every character in the WordDocument stream.
|
||||
let (fc_clx, lcb_clx) =
|
||||
@@ -109,12 +148,10 @@ pub fn read(bytes: &[u8]) -> Result<Document, DocbinError> {
|
||||
None => Bins::parse(&[], 0, 0)?,
|
||||
};
|
||||
|
||||
let data = stream(&mut comp, "Data").unwrap_or_default();
|
||||
if data.len() as u64 > MAX_STREAM {
|
||||
return Err(DocbinError::Limit(format!("Data stream is {} bytes", data.len())));
|
||||
}
|
||||
|
||||
let mut doc = Document::new();
|
||||
if let Some(core) = metadata::read(&mut comp) {
|
||||
doc.core = core;
|
||||
}
|
||||
doc.body.clear();
|
||||
doc.styles = fmt::stylesheet(&raw_styles, &fonts);
|
||||
doc.numbering = list::parse(&table, &fib, &fonts);
|
||||
@@ -129,6 +166,12 @@ pub fn read(bytes: &[u8]) -> Result<Document, DocbinError> {
|
||||
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();
|
||||
// Every materialised character comes from at least one byte of the WordDocument stream, so a
|
||||
// legitimate document's total text (body plus every header, footnote and text box, each read
|
||||
// once) cannot exceed its stream. A hostile piece or story table that aliases the same bytes
|
||||
// many times can, so the walk is held to twice the stream's size — generous for real files,
|
||||
// tight against aliasing — and never past the absolute ceiling.
|
||||
let budget = std::cell::Cell::new((word.len() as u64).saturating_mul(2).min(MAX_TOTAL_CHARS));
|
||||
|
||||
let ctx = WalkCtx {
|
||||
word: &word,
|
||||
@@ -143,6 +186,7 @@ pub fn read(bytes: &[u8]) -> Result<Document, DocbinError> {
|
||||
marks: &[],
|
||||
drawn: &[],
|
||||
sect_ends: &[],
|
||||
budget: &budget,
|
||||
};
|
||||
|
||||
// Drawings: text box stories become parts first, then the floating shapes and pictures
|
||||
@@ -283,6 +327,10 @@ struct WalkCtx<'a> {
|
||||
/// Where sections end (main-document CPs, sorted): the section mark (0x0C) just before
|
||||
/// each ends the section's last paragraph, it isn't a page break inside it.
|
||||
sect_ends: &'a [u32],
|
||||
/// Characters still allowed across every story walk of this document. Story ranges (headers,
|
||||
/// footnotes, text boxes) are hostile and can each span a whole subdocument, so thousands of
|
||||
/// them could otherwise materialise the same text thousands of times; this bounds the total.
|
||||
budget: &'a std::cell::Cell<u64>,
|
||||
}
|
||||
|
||||
/// State while assembling one paragraph from per-CP formatting.
|
||||
@@ -356,6 +404,12 @@ fn walk(ctx: &WalkCtx, cp_start: u32, cp_end: u32, pics: &mut media::Pictures) -
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// Every real character counts against the document-wide budget; once it is spent, stop
|
||||
// (a hostile file with thousands of story ranges over the same text can't multiply on).
|
||||
if ctx.budget.get() == 0 {
|
||||
break;
|
||||
}
|
||||
ctx.budget.set(ctx.budget.get() - 1);
|
||||
// A section mark (0x0C) at a section's end is that section's last paragraph mark.
|
||||
let section_mark = c == '\u{C}' && ctx.sect_ends.binary_search(&cp.saturating_add(1)).is_ok();
|
||||
match c {
|
||||
@@ -521,7 +575,7 @@ fn finish_field(pb: &mut ParaBuild, f: FieldBuild) {
|
||||
return;
|
||||
}
|
||||
pb.push('\u{FFFC}', CharProps::default());
|
||||
pb.objects.push(InlineObject::Field { instr, result: result_text, locked: false, code: false });
|
||||
pb.objects.push(InlineObject::Field { instr, result: result_text, locked: false, dirty: false, code: false, form: String::new() });
|
||||
}
|
||||
|
||||
/// `HYPERLINK "url"` / `HYPERLINK url \l "bookmark"` → the model link target
|
||||
|
||||
@@ -85,6 +85,7 @@ pub(crate) fn parse(table: &[u8], fib: &Fib, fonts: &[String]) -> Numbering {
|
||||
id: i as u32,
|
||||
name: None,
|
||||
levels: def.levels.iter().enumerate().map(|(j, l)| level_of(l, j, fonts)).collect(),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
if let Some((fc, lcb)) = fib.pair(pair::PLF_LFO)
|
||||
@@ -224,7 +225,7 @@ fn parse_plf_lfo(table: &[u8], fc: u32, defs: &[ListDef], numbering: &mut Number
|
||||
}
|
||||
}
|
||||
let id = numbering.abstracts.len() as u32;
|
||||
numbering.abstracts.push(AbstractNum { id, name: None, levels });
|
||||
numbering.abstracts.push(AbstractNum { id, name: None, levels, ..Default::default() });
|
||||
id
|
||||
};
|
||||
numbering.nums.push(Num { id: (i + 1) as u32, abstract_id, start_overrides, ..Default::default() });
|
||||
|
||||
+32
-19
@@ -19,12 +19,13 @@ const MAX_PICTURES: usize = 5_000;
|
||||
pub(crate) const MAX_MEDIA_BYTES: usize = 256 << 20;
|
||||
|
||||
/// The document's pictures, keyed by their location in the Data stream: anchors that share a
|
||||
/// `fc_pic` share one media entry, and the total bytes copied are capped, so a file with
|
||||
/// thousands of anchors on one huge picture costs one copy, not thousands. Pictures of the
|
||||
/// drawing layer's BLIP store are shared the same way, by store index.
|
||||
/// `fc_pic` share one media entry (or one failure), and the total bytes copied are capped, so a
|
||||
/// file with thousands of anchors on one huge picture costs one copy, not thousands. Pictures of
|
||||
/// the drawing layer's BLIP store are shared the same way, by store index.
|
||||
pub(crate) struct Pictures {
|
||||
/// fc_pic → the inline picture already materialised.
|
||||
by_fc: HashMap<u32, Inline>,
|
||||
/// fc_pic → the inline picture already materialised (`None`: unreadable or over the cap,
|
||||
/// not retried).
|
||||
by_fc: HashMap<u32, Option<Inline>>,
|
||||
/// BLIP store index → media key (`None`: unreadable, not retried).
|
||||
by_bse: HashMap<u32, Option<String>>,
|
||||
/// The media entries, merged into the document at the end.
|
||||
@@ -68,24 +69,34 @@ impl Pictures {
|
||||
/// count/byte cap is reached.
|
||||
pub(crate) fn get(&mut self, data: &[u8], fc_pic: u32) -> Option<Inline> {
|
||||
if let Some(hit) = self.by_fc.get(&fc_pic) {
|
||||
return Some(hit.clone());
|
||||
return hit.clone();
|
||||
}
|
||||
if self.by_fc.len().saturating_add(self.by_bse.len()) >= MAX_PICTURES {
|
||||
return None;
|
||||
}
|
||||
let pic = match locate(data, fc_pic)? {
|
||||
let pic = self.read(data, fc_pic);
|
||||
self.by_fc.insert(fc_pic, pic.clone());
|
||||
pic
|
||||
}
|
||||
|
||||
/// Read the inline picture at `fc_pic`: nothing is decoded or copied beyond what is left of
|
||||
/// the byte cap, and nothing at all once it is used up.
|
||||
fn read(&mut self, data: &[u8], fc_pic: u32) -> Option<Inline> {
|
||||
let room = self.limit.saturating_sub(self.total);
|
||||
if room == 0 {
|
||||
return None;
|
||||
}
|
||||
match locate(data, fc_pic, room)? {
|
||||
Found::Range(pic) => {
|
||||
let bytes = data.get(pic.range.clone())?.to_vec();
|
||||
let bytes = data.get(pic.range.clone()).filter(|b| b.len() <= room)?.to_vec();
|
||||
let key = self.insert(bytes, pic.ext, false)?;
|
||||
Inline { key, w: pic.w, h: pic.h, crop: [0.0; 4], alt: String::new() }
|
||||
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)?;
|
||||
Inline { key, w, h, crop: props.crop, alt: props.alt }
|
||||
Some(Inline { key, w, h, crop: props.crop, alt: props.alt })
|
||||
}
|
||||
};
|
||||
self.by_fc.insert(fc_pic, pic.clone());
|
||||
Some(pic)
|
||||
}
|
||||
}
|
||||
|
||||
/// The media key of BLIP store entry `pib` (1-based), decoding it the first time; `delay`
|
||||
@@ -148,8 +159,9 @@ enum Found {
|
||||
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).
|
||||
fn locate(data: &[u8], fc_pic: u32) -> Option<Found> {
|
||||
/// Locate the picture at `fc_pic` in the Data stream ([MS-DOC] §2.9.192 PICFAndOfficeArtData);
|
||||
/// a shape's picture is decoded only up to `room` bytes.
|
||||
fn locate(data: &[u8], fc_pic: u32, room: usize) -> Option<Found> {
|
||||
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;
|
||||
@@ -168,7 +180,7 @@ fn locate(data: &[u8], fc_pic: u32) -> Option<Found> {
|
||||
if mm == MM_SHAPEFILE {
|
||||
at = at.checked_add(1 + *pic.get(at)? as usize)?;
|
||||
}
|
||||
let (blip, props) = inline_shape(pic.get(at..end)?, data)?;
|
||||
let (blip, props) = inline_shape(pic.get(at..end)?, data, room)?;
|
||||
// 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);
|
||||
@@ -185,14 +197,15 @@ fn locate(data: &[u8], fc_pic: u32) -> Option<Found> {
|
||||
}
|
||||
|
||||
/// An `OfficeArtInlineSpContainer`: the picture shape, then the `OfficeArtFBSE` records its
|
||||
/// `pib` indexes (BLIPs embedded, or at `foDelay` in the Data stream).
|
||||
fn inline_shape(buf: &[u8], data: &[u8]) -> Option<(officeart::Blip, officeart::Props)> {
|
||||
/// `pib` indexes (BLIPs embedded, or at `foDelay` in the Data stream). `room` caps the bytes
|
||||
/// the BLIP decodes to.
|
||||
fn inline_shape(buf: &[u8], data: &[u8], room: usize) -> Option<(officeart::Blip, officeart::Props)> {
|
||||
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, MAX_MEDIA_BYTES)?;
|
||||
let blip = officeart::fbse_blip(fbse, data, room)?;
|
||||
Some((blip, sp.props))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
//! Optional document text properties from [MS-OLEPS] §2.21 and §2.25.1.
|
||||
//! Broken metadata must not prevent opening the document or allocate from unchecked sizes.
|
||||
|
||||
use std::io::{Read, Seek};
|
||||
|
||||
use wordcraft_doc::CoreProps;
|
||||
|
||||
const MAX_STREAM: u64 = 2 << 20;
|
||||
const MAX_STRING: usize = 64 << 10;
|
||||
const SUMMARY_FMTID: &[u8] = &[0xe0, 0x85, 0x9f, 0xf2, 0xf9, 0x4f, 0x68, 0x10, 0xab, 0x91, 0x08, 0x00, 0x2b, 0x27, 0xb3, 0xd9];
|
||||
|
||||
pub(crate) fn read<F: Read + Seek>(comp: &mut cfb::CompoundFile<F>) -> Option<CoreProps> {
|
||||
let stream = comp.open_stream("/\u{5}SummaryInformation").ok()?;
|
||||
if stream.len() > MAX_STREAM {
|
||||
return None;
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
stream.take(MAX_STREAM + 1).read_to_end(&mut bytes).ok()?;
|
||||
if bytes.len() as u64 > MAX_STREAM {
|
||||
return None;
|
||||
}
|
||||
parse(&bytes)
|
||||
}
|
||||
|
||||
fn u32_at(bytes: &[u8], offset: usize) -> Option<u32> {
|
||||
Some(u32::from_le_bytes(bytes.get(offset..offset.checked_add(4)?)?.try_into().ok()?))
|
||||
}
|
||||
|
||||
fn parse(bytes: &[u8]) -> Option<CoreProps> {
|
||||
if bytes.get(..2)? != [0xfe, 0xff] || bytes.get(28..44)? != SUMMARY_FMTID || u32_at(bytes, 24)? < 1 {
|
||||
return None;
|
||||
}
|
||||
if u16::from_le_bytes(bytes.get(2..4)?.try_into().ok()?) > 1 {
|
||||
return None;
|
||||
}
|
||||
let offset = u32_at(bytes, 44)? as usize;
|
||||
if offset < 48 || !offset.is_multiple_of(4) {
|
||||
return None;
|
||||
}
|
||||
let size = u32_at(bytes, offset)? as usize;
|
||||
let set = bytes.get(offset..offset.checked_add(size)?)?;
|
||||
let count = u32_at(set, 4)? as usize;
|
||||
if count > 4096 {
|
||||
return None;
|
||||
}
|
||||
let table_end = 8 + count * 8;
|
||||
let table = set.get(8..table_end)?;
|
||||
// Offsets are relative to the property set. Bound each value by the next property so a
|
||||
// forged string length cannot read another property's bytes as part of the title.
|
||||
let property = |id: u32| -> Option<&[u8]> {
|
||||
let entry = table.as_chunks::<8>().0.iter().find(|entry| u32_at(entry.as_slice(), 0) == Some(id))?.as_slice();
|
||||
let start = u32_at(entry, 4)? as usize;
|
||||
if start < table_end || !start.is_multiple_of(4) {
|
||||
return None;
|
||||
}
|
||||
let end = table
|
||||
.as_chunks::<8>()
|
||||
.0
|
||||
.iter()
|
||||
.filter_map(|e| u32_at(e, 4))
|
||||
.map(|n| n as usize)
|
||||
.filter(|n| *n > start)
|
||||
.min()
|
||||
.unwrap_or(set.len())
|
||||
.min(set.len());
|
||||
set.get(start..end)
|
||||
};
|
||||
let codepage = property(1)
|
||||
.and_then(|cp| {
|
||||
let vt = u32_at(cp, 0)?;
|
||||
if vt == 2 || vt == 3 { Some(u16::from_le_bytes(cp.get(4..6)?.try_into().ok()?)) } else { None }
|
||||
})
|
||||
.unwrap_or(1252);
|
||||
let get = |id| property(id).and_then(|value| text(value, codepage)).unwrap_or_default();
|
||||
let get_filetime = |id| property(id).and_then(filetime_iso).unwrap_or_default();
|
||||
Some(CoreProps {
|
||||
title: get(2),
|
||||
subject: get(3),
|
||||
creator: get(4),
|
||||
keywords: get(5),
|
||||
description: get(6),
|
||||
last_modified_by: get(8),
|
||||
revision: get(9).parse().unwrap_or_default(),
|
||||
created: get_filetime(12),
|
||||
modified: get_filetime(13),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn civil(days: i64) -> (i64, u32, u32) {
|
||||
let z = days + 719_468;
|
||||
let era = (if z >= 0 { z } else { z - 146_096 }) / 146_097;
|
||||
let doe = (z - era * 146_097) as u32;
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
||||
let y = yoe as i64 + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = y + i64::from(m <= 2);
|
||||
(y, m, d)
|
||||
}
|
||||
|
||||
fn filetime_iso(value: &[u8]) -> Option<String> {
|
||||
let kind = u32_at(value, 0)?;
|
||||
if kind != 64 {
|
||||
return None;
|
||||
}
|
||||
let ft_low = u32_at(value, 4)? as u64;
|
||||
let ft_high = u32_at(value, 8)? as u64;
|
||||
let ft = (ft_high << 32) | ft_low;
|
||||
if ft == 0 {
|
||||
return None;
|
||||
}
|
||||
const EPOCH_DIFFERENCE: u64 = 116_444_736_000_000_000;
|
||||
let unix_100ns = ft.checked_sub(EPOCH_DIFFERENCE)?;
|
||||
let unix_secs = unix_100ns / 10_000_000;
|
||||
let days = (unix_secs / 86_400) as i64;
|
||||
let secs_of_day = (unix_secs % 86_400) as u32;
|
||||
let (h, mi, s) = (secs_of_day / 3600, (secs_of_day / 60) % 60, secs_of_day % 60);
|
||||
let (y, m, d) = civil(days);
|
||||
Some(format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z"))
|
||||
}
|
||||
|
||||
fn text(value: &[u8], codepage: u16) -> Option<String> {
|
||||
let kind = u32_at(value, 0)?;
|
||||
let count = u32_at(value, 4)? as usize;
|
||||
let size = match kind {
|
||||
30 => count, // CodePageString length is in bytes, even for code page 1200.
|
||||
31 => count.checked_mul(2)?, // UnicodeString length is in UTF-16 units.
|
||||
_ => return None,
|
||||
};
|
||||
if size > MAX_STRING {
|
||||
return None;
|
||||
}
|
||||
let bytes = value.get(8..8usize.checked_add(size)?)?;
|
||||
if kind == 31 || codepage == 1200 {
|
||||
if !bytes.len().is_multiple_of(2) {
|
||||
return None;
|
||||
}
|
||||
let units = bytes.as_chunks::<2>().0.iter().map(|b| u16::from_le_bytes([b[0], b[1]])).take_while(|u| *u != 0);
|
||||
Some(char::decode_utf16(units).map(|c| c.unwrap_or(char::REPLACEMENT_CHARACTER)).collect())
|
||||
} else {
|
||||
let bytes = bytes.split(|b| *b == 0).next().unwrap_or_default();
|
||||
if codepage == 65001 {
|
||||
Some(String::from_utf8_lossy(bytes).into_owned())
|
||||
} else if wordcraft_doc::encoding::SINGLE_BYTE_CODE_PAGES.contains(&codepage) {
|
||||
Some(bytes.iter().map(|b| wordcraft_doc::encoding::windows_byte(codepage, *b)).collect())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,11 @@ const MAX: usize = 10_000;
|
||||
/// Parse the note reference/story PLCs. `ref_pair`/`txt_pair` are the FIB pair indices
|
||||
/// (`(2, 3)` for footnotes, `(46, 47)` for endnotes); story ranges are relative to the
|
||||
/// note subdocument.
|
||||
///
|
||||
/// The story CPs of a well-formed PLC ascend, so the stories are disjoint. A hostile table
|
||||
/// that steps back would make many notes read the same text again (each one materialised
|
||||
/// as its own part), so a story starting before the end of the last one kept is dropped:
|
||||
/// every subdocument CP becomes at most one note.
|
||||
pub(crate) fn parse_notes(table: &[u8], fib: &crate::fib::Fib, ref_pair: usize, txt_pair: usize) -> Vec<(u32, (u32, u32))> {
|
||||
let refs = fib.pair(ref_pair).and_then(|(fc, lcb)| plc_of(table, fc, lcb, 2)).unwrap_or_default();
|
||||
let txts = fib.pair(txt_pair).and_then(|(fc, lcb)| plc_of(table, fc, lcb, 0)).unwrap_or_default();
|
||||
@@ -14,12 +19,15 @@ pub(crate) fn parse_notes(table: &[u8], fib: &crate::fib::Fib, ref_pair: usize,
|
||||
return Vec::new();
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut kept_to = 0u32;
|
||||
// The reference PLC's aFtnIdx order matches the story order; pair them by index.
|
||||
for (i, &cp) in refs.iter().enumerate() {
|
||||
if let (Some(a), Some(b)) = (txts.get(i), txts.get(i + 1))
|
||||
if let (Some(&a), Some(&b)) = (txts.get(i), txts.get(i + 1))
|
||||
&& b > a
|
||||
&& a >= kept_to
|
||||
{
|
||||
out.push((cp, (*a, *b)));
|
||||
out.push((cp, (a, b)));
|
||||
kept_to = b;
|
||||
}
|
||||
}
|
||||
out
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
//! and record counts are capped, and decompressed metafiles are size-limited.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
|
||||
use image::ImageEncoder;
|
||||
|
||||
use wordcraft_doc::props::Rgb;
|
||||
|
||||
@@ -138,7 +141,7 @@ pub(crate) fn blip(rec: &Rec, limit: usize) -> Option<Blip> {
|
||||
rt::BLIP_PNG => Some(Blip { bytes: data.to_vec(), ext: "png", placeholder: false }),
|
||||
rt::BLIP_JPEG | rt::BLIP_JPEG_CMYK => Some(Blip { bytes: data.to_vec(), ext: "jpeg", placeholder: false }),
|
||||
rt::BLIP_TIFF => Some(Blip { bytes: data.to_vec(), ext: "tiff", placeholder: false }),
|
||||
_ => dib_to_png(data),
|
||||
_ => dib_to_png(data, limit),
|
||||
}
|
||||
}
|
||||
rt::BLIP_EMF | rt::BLIP_WMF | rt::BLIP_PICT => {
|
||||
@@ -175,7 +178,7 @@ pub(crate) fn blip(rec: &Rec, limit: usize) -> Option<Blip> {
|
||||
/// A device-independent bitmap (BITMAPINFOHEADER + palette + pixels) as a PNG: the DIB gets
|
||||
/// the 14-byte BMP file header it lacks, is decoded with size limits and re-encoded. When the
|
||||
/// bitmap can't be decoded the BMP bytes are kept (and render as a grey placeholder).
|
||||
fn dib_to_png(dib: &[u8]) -> Option<Blip> {
|
||||
fn dib_to_png(dib: &[u8], limit: usize) -> Option<Blip> {
|
||||
let header = u32_at(dib, 0)? as usize;
|
||||
if !(12..=124).contains(&header) {
|
||||
return None;
|
||||
@@ -193,7 +196,7 @@ fn dib_to_png(dib: &[u8]) -> Option<Blip> {
|
||||
// BI_BITFIELDS masks follow a 40-byte header.
|
||||
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())?;
|
||||
let file_size = 14usize.checked_add(dib.len()).filter(|&size| size <= limit)?;
|
||||
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());
|
||||
@@ -205,17 +208,43 @@ fn dib_to_png(dib: &[u8]) -> Option<Blip> {
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_image_width = Some(MAX_DIB_SIDE);
|
||||
limits.max_image_height = Some(MAX_DIB_SIDE);
|
||||
limits.max_alloc = Some(256 << 20);
|
||||
limits.max_alloc = Some((256 << 20).min(limit as u64));
|
||||
reader.limits(limits);
|
||||
let mut png = Vec::new();
|
||||
let mut png = PictureWriter { bytes: Vec::new(), limit };
|
||||
match reader.decode() {
|
||||
Ok(img) if img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).is_ok() => {
|
||||
Some(Blip { bytes: png, ext: "png", placeholder: false })
|
||||
Ok(img)
|
||||
if image::codecs::png::PngEncoder::new(&mut png).write_image(img.as_bytes(), img.width(), img.height(), img.color().into()).is_ok() =>
|
||||
{
|
||||
Some(Blip { bytes: png.bytes, ext: "png", placeholder: false })
|
||||
}
|
||||
_ => Some(Blip { bytes: bmp, ext: "bmp", placeholder: true }),
|
||||
}
|
||||
}
|
||||
|
||||
/// Cap encoded bytes while the PNG encoder writes, before the buffer can grow.
|
||||
struct PictureWriter {
|
||||
bytes: Vec<u8>,
|
||||
limit: usize,
|
||||
}
|
||||
|
||||
impl Write for PictureWriter {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
let Some(end) = self.bytes.len().checked_add(buf.len()).filter(|&end| end <= self.limit) else {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "picture exceeds remaining media budget"));
|
||||
};
|
||||
if end > self.bytes.capacity() {
|
||||
let new_capacity = self.bytes.capacity().saturating_mul(2).max(end).min(self.limit);
|
||||
self.bytes.try_reserve_exact(new_capacity - self.bytes.len()).map_err(std::io::Error::other)?;
|
||||
}
|
||||
self.bytes.extend_from_slice(buf);
|
||||
Ok(buf.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Shapes.
|
||||
|
||||
|
||||
@@ -171,6 +171,11 @@ fn apply_section(s: &mut SectionProps, prl: &Prl) {
|
||||
/// `[cps[i], cps[i+1])`. Stories 0-5 are footnote/endnote separators; every section then owns
|
||||
/// six stories in the order even header, odd (default) header, even footer, odd (default)
|
||||
/// footer, first header, first footer. The CPs are relative to the header subdocument.
|
||||
///
|
||||
/// The CPs of a well-formed PLC ascend, so the stories are disjoint. A story starting before
|
||||
/// the end of an earlier one (a hostile table pointing many sections at the same text, each
|
||||
/// read into its own part) is left empty, like a slot linked to the previous section's:
|
||||
/// every subdocument CP becomes at most one story.
|
||||
pub(crate) fn header_stories(table: &[u8], fib: &Fib) -> Vec<(u32, u32)> {
|
||||
let Some((fc, lcb)) = fib.pair(pair::PLCF_HDD) else { return Vec::new() };
|
||||
let Some(plc) = table.get(fc as usize..(fc as usize).saturating_add(lcb as usize)) else { return Vec::new() };
|
||||
@@ -178,9 +183,12 @@ pub(crate) fn header_stories(table: &[u8], fib: &Fib) -> Vec<(u32, u32)> {
|
||||
let n = (lcb as usize / 4).min(6 + 6 * MAX_SECTIONS + 1);
|
||||
let cp_at = |i: usize| -> Option<u32> { plc.get(i * 4..i * 4 + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])) };
|
||||
let mut stories = Vec::with_capacity(n);
|
||||
let mut kept_to = 0u32;
|
||||
for i in 0..n.saturating_sub(1) {
|
||||
if let (Some(a), Some(b)) = (cp_at(i), cp_at(i + 1)) {
|
||||
stories.push((a, b.max(a)));
|
||||
let b = if a < kept_to { a } else { b.max(a) };
|
||||
kept_to = kept_to.max(b);
|
||||
stories.push((a, b));
|
||||
}
|
||||
}
|
||||
stories
|
||||
|
||||
+122
-33
@@ -28,6 +28,15 @@ const T_TABLE_BORDERS_80: u16 = 0xD605;
|
||||
const T_ROW_HEIGHT: u16 = 0x9407;
|
||||
const T_DEF_TABLE: u16 = 0xD608;
|
||||
const T_DEF_TABLE_SHD_80: u16 = 0xD609;
|
||||
/// `sprmTDefTableShd` / `Shd2nd` / `Shd3rd`: 10-byte `Shd`s for cells 0-21, 22-43, 44-62.
|
||||
const T_DEF_TABLE_SHD: u16 = 0xD612;
|
||||
const T_DEF_TABLE_SHD_2ND: u16 = 0xD616;
|
||||
const T_DEF_TABLE_SHD_3RD: u16 = 0xD60C;
|
||||
/// `sprmTDefTableShdRaw` / `Raw2nd` / `Raw3rd`: the same `Shd` arrays over the same cell
|
||||
/// ranges, which Word 2007+ writes next to the above.
|
||||
const T_DEF_TABLE_SHD_RAW: u16 = 0xD670;
|
||||
const T_DEF_TABLE_SHD_RAW_2ND: u16 = 0xD671;
|
||||
const T_DEF_TABLE_SHD_RAW_3RD: u16 = 0xD672;
|
||||
const T_CANT_SPLIT_90: u16 = 0x3403;
|
||||
const T_MERGE: u16 = 0x5624;
|
||||
const T_VERT_MERGE: u16 = 0xD62B;
|
||||
@@ -49,8 +58,10 @@ pub(crate) struct RowInfo {
|
||||
pub(crate) cell_mark: bool,
|
||||
/// Table depth from `sprmPItap` (0 or 1 supported; deeper tables degrade to paragraphs).
|
||||
pub(crate) depth: u32,
|
||||
/// Column widths (points) from `sprmTDefTable`.
|
||||
pub(crate) grid: Vec<f32>,
|
||||
/// Cell-edge positions (points) from `sprmTDefTable` — [MS-DOC] defines them per row,
|
||||
/// so each row keeps its own; ascending in well-formed rows, the first edge usually 0
|
||||
/// (the row origin). Rows without a `sprmTDefTable` keep this empty.
|
||||
pub(crate) edges: Vec<f32>,
|
||||
height: Option<f32>,
|
||||
height_rule: HeightRule,
|
||||
header: bool,
|
||||
@@ -62,8 +73,11 @@ pub(crate) struct RowInfo {
|
||||
merges: Vec<(u8, u8)>,
|
||||
/// Per-cell overrides keyed by (pre-merge) cell index.
|
||||
vmerge: Vec<(u8, VMerge)>,
|
||||
shading: Vec<(u8, Rgb)>,
|
||||
/// Later entries win: the `Shd` arrays override the `Shd80` fallback before them, and an
|
||||
/// automatic `Shd` (`None`) clears it.
|
||||
shading: Vec<(u8, Option<Rgb>)>,
|
||||
widths: Vec<(u8, f32)>,
|
||||
width_pcts: Vec<(u8, f32)>,
|
||||
valign: Vec<(u8, VAlign)>,
|
||||
}
|
||||
|
||||
@@ -74,6 +88,24 @@ impl RowInfo {
|
||||
pub(crate) fn height(&self) -> Option<f32> {
|
||||
self.height
|
||||
}
|
||||
|
||||
/// The fill cell `i` ends up with (the last shading entry for it wins), for tests.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn shading_of(&self, i: u8) -> Option<Rgb> {
|
||||
self.shading.iter().rfind(|(k, _)| *k == i).and_then(|(_, s)| *s)
|
||||
}
|
||||
|
||||
/// The width cell `i` ends up with, for tests.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn width_of(&self, i: u8) -> Option<f32> {
|
||||
self.widths.iter().rfind(|(k, _)| *k == i).map(|(_, w)| *w)
|
||||
}
|
||||
|
||||
/// The width percentage cell `i` ends up with, for tests.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn width_pct_of(&self, i: u8) -> Option<f32> {
|
||||
self.width_pcts.iter().rfind(|(k, _)| *k == i).map(|(_, w)| *w)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn decode(papx: &[u8]) -> RowInfo {
|
||||
@@ -87,7 +119,7 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo {
|
||||
P_IN_TABLE => r.in_table = prl.operand.first().copied().unwrap_or(0) != 0,
|
||||
P_TTP => r.ttp = prl.operand.first().copied().unwrap_or(0) != 0,
|
||||
P_ITAP => r.depth = i32_of(&prl).unwrap_or(0).max(0) as u32,
|
||||
T_DEF_TABLE => r.grid = def_table_grid(prl.operand),
|
||||
T_DEF_TABLE => r.edges = def_table_edges(prl.operand),
|
||||
T_TABLE_HEADER => r.header = prl.operand.first().copied().unwrap_or(0) != 0,
|
||||
T_CANT_SPLIT | T_CANT_SPLIT_90 => r.cant_split = prl.operand.first().copied().unwrap_or(0) != 0,
|
||||
T_ROW_HEIGHT => {
|
||||
@@ -135,8 +167,8 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo {
|
||||
if let Some(rgb) = prl.operand.get(3..).and_then(shd_rgb)
|
||||
&& let [a, b, ..] = prl.operand.get(1..3).unwrap_or(&[])
|
||||
{
|
||||
for c in *a..*b {
|
||||
r.shading.push((c, rgb));
|
||||
for c in *a..(*b).min(MAX_COLS as u8) {
|
||||
r.shading.push((c, Some(rgb)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,18 +176,44 @@ 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, rgb));
|
||||
r.shading.push((i as u8, Some(rgb)));
|
||||
}
|
||||
}
|
||||
}
|
||||
T_DEF_TABLE_SHD | T_DEF_TABLE_SHD_RAW | T_DEF_TABLE_SHD_2ND | T_DEF_TABLE_SHD_RAW_2ND | T_DEF_TABLE_SHD_3RD | T_DEF_TABLE_SHD_RAW_3RD => {
|
||||
// Each array covers its own cell range (0-21, 22-43, 44-62); extra entries
|
||||
// are ignored so an over-long array can't override the next range.
|
||||
let (first, lim) = match prl.op {
|
||||
T_DEF_TABLE_SHD_2ND | T_DEF_TABLE_SHD_RAW_2ND => (22, 44),
|
||||
T_DEF_TABLE_SHD_3RD | T_DEF_TABLE_SHD_RAW_3RD => (44, MAX_COLS),
|
||||
_ => (0, 22),
|
||||
};
|
||||
let cells = prl.operand.get(1..).unwrap_or(&[]);
|
||||
let raw = matches!(prl.op, T_DEF_TABLE_SHD_RAW | T_DEF_TABLE_SHD_RAW_2ND | T_DEF_TABLE_SHD_RAW_3RD);
|
||||
for (i, b) in cells.as_chunks::<10>().0.iter().take(lim - first).enumerate() {
|
||||
// In the Raw arrays a nil Shd (ipat 0xFFFF) means "as the table style
|
||||
// shades it", so it leaves any earlier fill alone.
|
||||
if raw && b.get(8..10) == Some(&[0xFF, 0xFF]) {
|
||||
continue;
|
||||
}
|
||||
r.shading.push(((first + i) as u8, shd_rgb(b)));
|
||||
}
|
||||
}
|
||||
T_CELL_WIDTH => {
|
||||
// cb, itcFirst, itcLim, ftsWidth (3 = twips), wWidth.
|
||||
if let [a, b, fts, w0, w1, ..] = prl.operand
|
||||
&& *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));
|
||||
// Operand: cb (1 byte), itcFirst (1 byte), itcLim (1 byte), ftsWidth (1 byte), wWidth (2 bytes).
|
||||
// [MS-DOC] §2.9.290 sprmTCellWidth (0xD635).
|
||||
// ftsWidth: 3 = twips (dxa), 2 = percentage (50ths of a percent).
|
||||
if let [_, a, b, fts, w0, w1, ..] = prl.operand {
|
||||
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));
|
||||
}
|
||||
} 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -218,14 +276,17 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
|
||||
if let Some((_, m)) = info.vmerge.iter().find(|(k, _)| *k == i) {
|
||||
c.props.vmerge = *m;
|
||||
}
|
||||
if let Some((_, w)) = info.widths.iter().find(|(k, _)| *k == i) {
|
||||
if let Some((_, w)) = info.widths.iter().rfind(|(k, _)| *k == i) {
|
||||
c.props.width = Some(*w);
|
||||
}
|
||||
if let Some((_, s)) = info.shading.iter().find(|(k, _)| *k == i) {
|
||||
c.props.shading = Some(*s);
|
||||
if let Some((_, pct)) = info.width_pcts.iter().rfind(|(k, _)| *k == i) {
|
||||
c.props.width_pct = Some(*pct);
|
||||
}
|
||||
if let Some((_, v)) = info.valign.iter().find(|(k, _)| *k == i) {
|
||||
c.props.valign = *v;
|
||||
if let Some((_, s)) = info.shading.iter().rfind(|(k, _)| *k == i) {
|
||||
c.props.shading = *s;
|
||||
}
|
||||
if let Some((_, v)) = info.valign.iter().rfind(|(k, _)| *k == i) {
|
||||
c.props.valign = Some(*v);
|
||||
}
|
||||
}
|
||||
for &(a, b) in info.merges.iter().take(MAX_COLS) {
|
||||
@@ -250,7 +311,7 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
|
||||
height_rule: info.height_rule,
|
||||
header: info.header,
|
||||
cant_split: info.cant_split,
|
||||
fmt_change: None,
|
||||
..Default::default()
|
||||
},
|
||||
cells,
|
||||
controls: Default::default(),
|
||||
@@ -295,6 +356,13 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
|
||||
if !is_table {
|
||||
if open {
|
||||
flush_table(&mut out, &mut rows, &mut cells, &mut cell, &grid, &props, depth_seen > 1);
|
||||
grid.clear();
|
||||
// Table props are per-table (first row carrying the sprm wins within the
|
||||
// table): reset the accumulator so one table's borders, align, indent and
|
||||
// cell margins never leak into the next (the repro's first table wrote
|
||||
// Word's default 108-twip gap, and every later table — including the
|
||||
// change history with its own 40-twip gap — inherited 5.4 pt margins).
|
||||
props = TableProps::default();
|
||||
open = false;
|
||||
depth_seen = 0;
|
||||
}
|
||||
@@ -305,8 +373,9 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
|
||||
open = true;
|
||||
}
|
||||
depth_seen = depth_seen.max(info.depth);
|
||||
if !info.grid.is_empty() && grid.is_empty() {
|
||||
grid = info.grid.clone();
|
||||
if grid.is_empty() {
|
||||
// Column widths from the table's first `sprmTDefTable`.
|
||||
grid = info.edges.windows(2).take(MAX_COLS).map(|w| (w[1] - w[0]).max(0.0)).collect();
|
||||
}
|
||||
if info.borders.is_some() && props.borders.is_none() {
|
||||
props.borders = info.borders;
|
||||
@@ -318,10 +387,25 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
|
||||
props.indent = info.indent;
|
||||
}
|
||||
if info.cell_mark {
|
||||
// A row-end (TTP) mark terminates the row. Word writes rows as N content
|
||||
// cells each ended by their own cell mark, followed by an empty row-end mark
|
||||
// that carries the row's TDefTable (the 3GPP change history: 8 content marks
|
||||
// + an empty TTP whose TDefTable defines itcMac = 8). When that mark ends no
|
||||
// content of its own and the TDefTable already has a column per flushed cell,
|
||||
// it is a pure row terminator, not the end of a phantom (N+1)th cell — such a
|
||||
// cell has no column (width None) and shifts every real cell's span one grid
|
||||
// column over. A TTP mark with content, or one short of the TDefTable's
|
||||
// columns, still ends a real cell (rows without a TDefTable keep today's
|
||||
// shape: the count is unknowable, so the mark always flushes).
|
||||
let row_end_only = info.ttp && p.para.text.is_empty() && cell.is_empty() && !info.edges.is_empty() && cells.len() + 1 >= info.edges.len();
|
||||
// ...but a row-end mark with no cells before it still makes a (one-cell) row.
|
||||
let row_end_only = row_end_only && !cells.is_empty();
|
||||
if !p.para.text.is_empty() {
|
||||
cell.push(para_block(p.para));
|
||||
}
|
||||
flush_cell(&mut cells, &mut cell);
|
||||
if !row_end_only {
|
||||
flush_cell(&mut cells, &mut cell);
|
||||
}
|
||||
if info.ttp {
|
||||
let row = build_row(std::mem::take(&mut cells), &info);
|
||||
rows.push(row);
|
||||
@@ -336,21 +420,23 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
|
||||
out
|
||||
}
|
||||
|
||||
/// Column widths (points) from a `sprmTDefTable` operand: cb u16, itcMac u8, then itcMac+1
|
||||
/// twip boundaries followed by the TC80s (skipped).
|
||||
fn def_table_grid(operand: &[u8]) -> Vec<f32> {
|
||||
/// Cell-edge positions (points) from a `sprmTDefTable` operand: cb u16, itcMac u8, then
|
||||
/// itcMac+1 twip boundaries followed by the TC80s (skipped). Edges are returned as stored:
|
||||
/// per-row, in operand order; sorting and capping happen when the table grid is assembled.
|
||||
fn def_table_edges(operand: &[u8]) -> Vec<f32> {
|
||||
let itc = operand.get(2).copied().unwrap_or(0) as usize;
|
||||
if itc == 0 || itc > MAX_COLS {
|
||||
if itc == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut edges = Vec::with_capacity(itc + 1);
|
||||
// itcMac is a u8, so this allocation stays tiny; the loop stops at the operand's end.
|
||||
let mut edges = Vec::with_capacity((itc + 1).min(256));
|
||||
for i in 0..=itc {
|
||||
match operand.get(3 + i * 2..3 + i * 2 + 2) {
|
||||
Some(b) => edges.push(i16::from_le_bytes([b[0], b[1]]) as f32 / 20.0),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
edges.windows(2).map(|w| (w[1] - w[0]).max(0.0)).collect()
|
||||
edges
|
||||
}
|
||||
|
||||
/// Table borders from `sprmTTableBorders80`: cb (0x18) then six Brc80s in the order top,
|
||||
@@ -381,7 +467,8 @@ fn brc80(b: &[u8]) -> Option<Border> {
|
||||
}
|
||||
|
||||
/// Background colour from a 10-byte `Shd`: cvFore, cvBack (COLORREFs), ipat. Solid patterns
|
||||
/// show the foreground; other patterns are approximated by their background.
|
||||
/// show the foreground; clear (0) and the other patterns show the background (Word writes a
|
||||
/// plain cell fill as clear with cvBack); nil (0xFFFF) and automatic colours are no fill.
|
||||
fn shd_rgb(b: &[u8]) -> Option<Rgb> {
|
||||
let cv = |b: &[u8]| -> Option<Rgb> {
|
||||
match b {
|
||||
@@ -392,19 +479,21 @@ fn shd_rgb(b: &[u8]) -> Option<Rgb> {
|
||||
};
|
||||
let ipat = b.get(8..10).map(|x| u16::from_le_bytes([x[0], x[1]]))?;
|
||||
match ipat {
|
||||
0 | 0xFFFF => None,
|
||||
0xFFFF => None,
|
||||
1 => cv(b.get(0..4)?),
|
||||
_ => cv(b.get(4..8)?),
|
||||
}
|
||||
}
|
||||
|
||||
/// Background colour from a 2-byte `Shd80` (icoFore, icoBack, ipat bit-fields).
|
||||
/// Background colour from a 2-byte `Shd80` (icoFore, icoBack, ipat bit-fields). As in
|
||||
/// [`shd_rgb`], solid shows the foreground and clear (0) the background, which is how Word
|
||||
/// 97 writes a plain fill; ico 0 is automatic (no fill).
|
||||
fn shd80_rgb(b: &[u8]) -> Option<Rgb> {
|
||||
let v = u16::from_le_bytes([*b.first()?, *b.get(1)?]);
|
||||
let fore = v & 0x1F;
|
||||
let back = (v >> 5) & 0x1F;
|
||||
let ipat = v >> 10;
|
||||
if ipat == 0 || (fore == 0x1F && back == 0x1F) {
|
||||
if fore == 0x1F && back == 0x1F {
|
||||
return None;
|
||||
}
|
||||
fmt::ico(if ipat == 1 { fore as u8 } else { back as u8 })
|
||||
|
||||
+426
-2
@@ -8,6 +8,8 @@ use proptest::prelude::*;
|
||||
use super::read;
|
||||
|
||||
mod drawing;
|
||||
mod encrypted;
|
||||
mod metadata;
|
||||
|
||||
/// Offset in the WordDocument stream where test text pieces are placed (past the FIB).
|
||||
const TEXT_FC: u32 = 0x600;
|
||||
@@ -171,10 +173,10 @@ fn word_6_95_rejected() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypted_rejected() {
|
||||
fn encrypted_asks_for_a_password() {
|
||||
let spec = FibSpec { flags: 0x1100, ..Default::default() };
|
||||
let f = cfb_file(&[("WordDocument", &fib_bytes(&spec))]);
|
||||
assert!(matches!(read(&f), Err(super::DocbinError::Encrypted)));
|
||||
assert!(matches!(read(&f), Err(super::DocbinError::PasswordRequired)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1500,6 +1502,92 @@ fn huge_story_ranges_are_clamped_to_their_subdocument() {
|
||||
assert!(!doc.parts.values().any(|p| p.kind == wordcraft_doc::PartKind::Endnote));
|
||||
}
|
||||
|
||||
/// The text of each note a paragraph references, in order.
|
||||
fn note_texts(doc: &wordcraft_doc::Document, p: &wordcraft_doc::Paragraph) -> Vec<String> {
|
||||
p.objects
|
||||
.iter()
|
||||
.filter_map(|o| match o {
|
||||
wordcraft_doc::para::InlineObject::NoteRef { id, .. } => doc.parts.get(id),
|
||||
_ => None,
|
||||
})
|
||||
.map(|part| part.blocks.iter().filter_map(|b| b.as_para()).map(|para| para.text.as_str()).collect::<Vec<_>>().join("\n"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_stories_pointing_at_the_same_text_are_read_once() {
|
||||
// 2000 footnote references whose PlcffndTxt steps back to CP 0 after every story
|
||||
// ([0, 10, 0, 10, …]): every other one was read into its own part, a copy of the whole
|
||||
// footnote subdocument each (1001 parts here; a large story times thousands of
|
||||
// references in a hostile file).
|
||||
let n = 2000u32;
|
||||
let text: &'static str = Box::leak(format!("A{}\r", "\u{2}".repeat(n as usize)).into_boxed_str());
|
||||
let ccp = text.chars().count() as u32;
|
||||
let refs: Vec<u32> = (1..=n).chain([ccp]).collect();
|
||||
let spec = StructSpec {
|
||||
text,
|
||||
note_stories: vec!["Note body\r"],
|
||||
extra_pairs: vec![(2, plc(&refs, &1u16.to_le_bytes().repeat(n as usize))), (3, plc(&[0u32, 10].repeat(n as usize / 2 + 1), &[]))],
|
||||
..structured_spec(text, ccp)
|
||||
};
|
||||
let doc = read(&struct_doc(&spec)).expect("opens");
|
||||
assert_eq!(doc.parts.values().filter(|p| p.kind == wordcraft_doc::PartKind::Footnote).count(), 1);
|
||||
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
|
||||
assert_eq!(note_texts(&doc, p0), vec!["Note body".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_stories_stepping_back_are_dropped_and_ordered_ones_kept() {
|
||||
// Story CPs [0, 5, 10, 3, 10] for four footnotes and four endnotes: the first two
|
||||
// stories are in order and read (the control); the third runs backwards and the fourth
|
||||
// starts inside the first, so the third and fourth references get no note.
|
||||
let text = "a\u{2}b\u{2}c\u{2}d\u{2}e\u{2}f\u{2}g\u{2}h\u{2}\r";
|
||||
let ccp = text.chars().count() as u32;
|
||||
let stories = plc(&[0, 5, 10, 3, 10], &[]);
|
||||
let spec = StructSpec {
|
||||
text,
|
||||
note_stories: vec!["Aaaa\rBbbb\r"],
|
||||
endnote_stories: vec!["Cccc\rDddd\r"],
|
||||
extra_pairs: vec![
|
||||
(2, plc(&[1, 3, 5, 7, ccp], &1u16.to_le_bytes().repeat(4))),
|
||||
(3, stories.clone()),
|
||||
(46, plc(&[9, 11, 13, 15, ccp], &1u16.to_le_bytes().repeat(4))),
|
||||
(47, stories),
|
||||
],
|
||||
..structured_spec(text, ccp)
|
||||
};
|
||||
let doc = read(&struct_doc(&spec)).expect("opens");
|
||||
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
|
||||
assert_eq!(note_texts(&doc, p0), ["Aaaa", "Bbbb", "Cccc", "Dddd"].map(String::from));
|
||||
assert_eq!(doc.parts.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_stories_pointing_at_the_same_text_are_read_once() {
|
||||
// Two sections whose odd headers both claim the header subdocument's [0, 5) (Plcfhdd
|
||||
// steps back to CP 0 inside the second section's slots): the second used to become a
|
||||
// second copy of the text. Its slot is now empty, which links it to the first section's.
|
||||
let text = "a\rb\r";
|
||||
let ccp = text.chars().count() as u32;
|
||||
// Six empty separator stories, then each section's six slots; the odd header is the
|
||||
// second slot.
|
||||
let hdd = plc(&[0, 0, 0, 0, 0, 0, 0, 0, 5, 5, 5, 5, 5, 0, 5, 5, 5, 5, 5], &[]);
|
||||
let spec = StructSpec {
|
||||
text,
|
||||
header_stories: vec!["Head\r"],
|
||||
sections: vec![(2, Vec::new()), (ccp, Vec::new())],
|
||||
papx_entries: vec![(0, 0, Vec::new()), (2, 0, Vec::new())],
|
||||
extra_pairs: vec![(11, hdd)],
|
||||
..structured_spec(text, ccp)
|
||||
};
|
||||
let doc = read(&struct_doc(&spec)).expect("opens");
|
||||
assert_eq!(doc.parts.values().filter(|p| p.kind == wordcraft_doc::PartKind::Header).count(), 1);
|
||||
let first = doc.body.first().and_then(|b| b.as_para()).and_then(|p| p.section.as_ref()).expect("first section");
|
||||
let header = first.headers.default.and_then(|id| doc.parts.get(&id)).expect("first section's header");
|
||||
assert!(header.blocks.iter().any(|b| b.as_para().is_some_and(|p| p.text == "Head")), "header: {:?}", header.blocks);
|
||||
assert_eq!(doc.last_section.headers.default, None);
|
||||
}
|
||||
|
||||
/// A document whose piece table is given raw (cps + one Pcd per piece), with `text` bytes
|
||||
/// at TEXT_FC and the FIB claiming `ccp_text` main-document characters.
|
||||
fn raw_piece_doc(cps: &[u32], fcs: &[u32], text: &[u8], ccp_text: u32) -> Vec<u8> {
|
||||
@@ -1637,6 +1725,31 @@ fn picture_bytes_are_capped_per_document() {
|
||||
const { assert!(crate::media::MAX_MEDIA_BYTES <= 256 << 20) };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_inline_pictures_are_not_read_again() {
|
||||
// A picture over the cap was located and copied again for every anchor on it.
|
||||
let big = picf_with_png(1200);
|
||||
let small = picf_with_png(300);
|
||||
let data: Vec<u8> = [big.clone(), small.clone()].concat();
|
||||
let mut pics = crate::media::Pictures::default();
|
||||
pics.limit = 1000;
|
||||
assert!(pics.get(&data, 0).is_none(), "over the cap");
|
||||
// The failure is remembered: that place isn't read again, whatever it holds now.
|
||||
assert!(pics.get(&small, 0).is_none());
|
||||
// The rejected picture took nothing off the cap, and anchors sharing a picture share it.
|
||||
let at = big.len() as u32;
|
||||
let fits = pics.get(&data, at).expect("fits");
|
||||
assert_eq!(pics.get(&data, at).map(|p| p.key), Some(fits.key));
|
||||
assert_eq!(pics.media.len(), 1);
|
||||
// Filling the cap exactly works; after that, a place never tried before isn't read.
|
||||
let rest = picf_with_png(700);
|
||||
let rest_at = data.len() as u32;
|
||||
let tail: Vec<u8> = [data, rest.clone(), small].concat();
|
||||
assert!(pics.get(&tail, rest_at).is_some(), "700 more bytes fill the cap");
|
||||
assert!(pics.get(&tail, rest_at + rest.len() as u32).is_none(), "the cap is used up");
|
||||
assert_eq!(pics.media.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_height_of_i16_min_does_not_overflow() {
|
||||
// sprmTDyaRowHeight = -32768: negating the i16 overflowed (a debug-build panic).
|
||||
@@ -1906,3 +2019,314 @@ fn section_marks_end_paragraphs() {
|
||||
let doc = read(&struct_doc(&spec)).expect("opens");
|
||||
assert_eq!(body_texts(&doc), ["a\u{C}b"]);
|
||||
}
|
||||
|
||||
// #1380: Word 2000+ tables. Word writes each row as its cell marks plus a separate row-end
|
||||
// (TTP) mark, cell shading as 10-byte `Shd`s in `sprmTDefTableShd`/`sprmTDefTableShdRaw` (the
|
||||
// `Shd80` palette copy in `sprmTDefTableShd80` is only a fallback), and a horizontally
|
||||
// merged row as a row with fewer, wider cells in its own `sprmTDefTable`.
|
||||
|
||||
/// A `sprmTDefTable` Prl with `edges` twip boundaries and no TC80s (cb counts the bytes
|
||||
/// after it, plus one; Word may store fewer TC80s than cells).
|
||||
fn t_def_edges(edges: &[i16]) -> Vec<u8> {
|
||||
let mut body = vec![(edges.len() as u8).saturating_sub(1)];
|
||||
for e in edges {
|
||||
body.extend_from_slice(&e.to_le_bytes());
|
||||
}
|
||||
let mut v = (0xD608u16).to_le_bytes().to_vec();
|
||||
v.extend_from_slice(&((body.len() + 1) as u16).to_le_bytes());
|
||||
v.extend_from_slice(&body);
|
||||
v
|
||||
}
|
||||
|
||||
/// A variable-length table sprm whose operand is cb then `body`.
|
||||
fn sprm_cb(op: u16, body: &[u8]) -> Vec<u8> {
|
||||
let mut v = op.to_le_bytes().to_vec();
|
||||
v.push(body.len() as u8);
|
||||
v.extend_from_slice(body);
|
||||
v
|
||||
}
|
||||
|
||||
/// `n` copies of a 10-byte `Shd`: cvFore auto, cvBack = `back`, ipat 0 (clear), the way
|
||||
/// Word writes a plain cell fill.
|
||||
fn shd_clear(back: [u8; 3], n: usize) -> Vec<u8> {
|
||||
let one = [0, 0, 0, 0xFF, back[0], back[1], back[2], 0, 0, 0];
|
||||
one.repeat(n)
|
||||
}
|
||||
|
||||
/// Word-shaped 5-column table: header row, a shaded body row, a one-cell legend row.
|
||||
fn word_table_doc(row1: Vec<u8>, row2: Vec<u8>, row3: Vec<u8>) -> wordcraft_doc::Document {
|
||||
let text = "a\u{7}b\u{7}c\u{7}d\u{7}e\u{7}\u{7}f\u{7}g\u{7}h\u{7}i\u{7}j\u{7}\u{7}legend\u{7}\u{7}tail\r";
|
||||
let in_tbl = grpprl(&[(0x2416, &[0x01]), (0x6649, &[1, 0, 0, 0])]);
|
||||
let ttp = |extra: Vec<u8>| [grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01]), (0x6649, &[1, 0, 0, 0])]), extra].concat();
|
||||
let spec = StructSpec {
|
||||
text,
|
||||
header_stories: vec![],
|
||||
sections: vec![(text.chars().count() as u32, Vec::new())],
|
||||
chpx_runs: vec![(0, Vec::new())],
|
||||
papx_entries: vec![
|
||||
(0, 0, in_tbl.clone()), // a..e cell marks
|
||||
(10, 0, ttp(row1)), // row 1 end mark
|
||||
(11, 0, in_tbl.clone()),
|
||||
(21, 0, ttp(row2)),
|
||||
(22, 0, in_tbl),
|
||||
(29, 0, ttp(row3)),
|
||||
(30, 0, Vec::new()), // tail
|
||||
],
|
||||
plf_lst: Vec::new(),
|
||||
plf_lfo: Vec::new(),
|
||||
note_stories: vec![],
|
||||
endnote_stories: vec![],
|
||||
extra_pairs: Vec::new(),
|
||||
data_stream: Vec::new(),
|
||||
};
|
||||
read(&struct_doc(&spec)).expect("opens")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_table_row_end_mark_and_cell_shading() {
|
||||
let edges5: [i16; 6] = [0, 1440, 2880, 4320, 5760, 7200];
|
||||
// Row 1: the Shd80 fallback first (icoBack 9 = navy, clear), then the exact fill, dark
|
||||
// blue 1C3A70, in sprmTDefTableShd (0xD612) and sprmTDefTableShdRaw (0xD670), as Word
|
||||
// 365 writes it. The exact colours must beat the palette colour.
|
||||
let shd80: Vec<u8> = [0x20u8, 0x01].repeat(5);
|
||||
let row1 = [
|
||||
t_def_edges(&edges5),
|
||||
sprm_cb(0xD609, &shd80),
|
||||
sprm_cb(0xD612, &shd_clear([0x1C, 0x3A, 0x70], 5)),
|
||||
sprm_cb(0xD670, &shd_clear([0x1C, 0x3A, 0x70], 5)),
|
||||
]
|
||||
.concat();
|
||||
// Row 2: a Shd80 yellow fill (icoBack 7) on every cell, then sprmTDefTableShd with light
|
||||
// blue E8F0FA for cells 0-3 and an automatic Shd for cell 4, which clears its fill.
|
||||
let shd80_yellow: Vec<u8> = [0xE0u8, 0x00].repeat(5);
|
||||
let shd2 = [shd_clear([0xE8, 0xF0, 0xFA], 4), vec![0, 0, 0, 0xFF, 0, 0, 0, 0xFF, 0, 0]].concat();
|
||||
let row2 = [t_def_edges(&edges5), sprm_cb(0xD609, &shd80_yellow), sprm_cb(0xD612, &shd2)].concat();
|
||||
// Row 3: one cell from the first edge to the last, filled FFF2CC.
|
||||
let row3 = [t_def_edges(&[0, 7200]), sprm_cb(0xD612, &shd_clear([0xFF, 0xF2, 0xCC], 1))].concat();
|
||||
let doc = word_table_doc(row1, row2, row3);
|
||||
let tbl = doc.body.iter().find_map(|b| b.as_table()).expect("table");
|
||||
assert_eq!(tbl.grid.len(), 5, "grid columns");
|
||||
assert_eq!(tbl.rows.len(), 3);
|
||||
let counts: Vec<usize> = tbl.rows.iter().map(|r| r.cells.len()).collect();
|
||||
assert_eq!(counts, [5, 5, 1], "cells per row (the row-end mark is not a cell)");
|
||||
let shd = |r: usize| tbl.rows[r].cells.iter().map(|c| c.props.shading).collect::<Vec<_>>();
|
||||
assert_eq!(shd(0), vec![Some(wordcraft_doc::Rgb(0x1C, 0x3A, 0x70)); 5], "exact fill beats the Shd80 palette colour");
|
||||
let light = Some(wordcraft_doc::Rgb(0xE8, 0xF0, 0xFA));
|
||||
assert_eq!(shd(1), vec![light, light, light, light, None], "automatic Shd clears the Shd80 fill");
|
||||
assert_eq!(shd(2), vec![Some(wordcraft_doc::Rgb(0xFF, 0xF2, 0xCC))], "legend fill");
|
||||
let legend = tbl.rows[2].cells[0].blocks.iter().filter_map(|b| b.as_para()).map(|p| p.text.clone()).collect::<String>();
|
||||
assert_eq!(legend, "legend");
|
||||
let tail = doc.body.last().and_then(|b| b.as_para()).expect("tail para");
|
||||
assert_eq!(tail.text, "tail");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word97_shd80_clear_fill_shows_its_background() {
|
||||
// Word 97 writes only sprmTDefTableShd80: a plain fill is pattern clear (ipat 0) with
|
||||
// the colour in icoBack. Cells: icoBack 7 (yellow), icoBack 16 (silver), auto (none),
|
||||
// solid (ipat 1) with icoFore 2 (blue).
|
||||
let shd80: Vec<u8> = [0xE0u16, 16 << 5, 0, (1 << 10) | 2].iter().flat_map(|v| v.to_le_bytes()).collect();
|
||||
let papx = sprm_cb(0xD609, &shd80);
|
||||
let row = crate::table::decode(&papx);
|
||||
assert_eq!(row.shading_of(0), Some(wordcraft_doc::Rgb(255, 255, 0)));
|
||||
assert_eq!(row.shading_of(1), Some(wordcraft_doc::Rgb(192, 192, 192)));
|
||||
assert_eq!(row.shading_of(2), None);
|
||||
assert_eq!(row.shading_of(3), Some(wordcraft_doc::Rgb(0, 0, 255)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shd_arrays_keep_to_their_cell_ranges() {
|
||||
// sprmTDefTableShd and sprmTDefTableShdRaw cover cells 0-21; their 2nd and 3rd forms
|
||||
// cover 22-43 and 44-62. An over-long first array must not reach cell 22.
|
||||
let red = shd_clear([0xFF, 0, 0], 1);
|
||||
let green = shd_clear([0, 0xFF, 0], 1);
|
||||
let blue = shd_clear([0, 0, 0xFF], 1);
|
||||
let over_long = shd_clear([0x11, 0x22, 0x33], 23);
|
||||
for (first, second, third) in [(0xD612u16, 0xD616u16, 0xD60Cu16), (0xD670, 0xD671, 0xD672)] {
|
||||
let papx = [sprm_cb(second, &green), sprm_cb(third, &blue), sprm_cb(first, &over_long)].concat();
|
||||
let row = crate::table::decode(&papx);
|
||||
assert_eq!(row.shading_of(21), Some(wordcraft_doc::Rgb(0x11, 0x22, 0x33)), "{first:04X}");
|
||||
assert_eq!(row.shading_of(22), Some(wordcraft_doc::Rgb(0, 0xFF, 0)), "{second:04X} keeps cell 22");
|
||||
assert_eq!(row.shading_of(44), Some(wordcraft_doc::Rgb(0, 0, 0xFF)), "{third:04X} starts at cell 44");
|
||||
let papx = sprm_cb(first, &red);
|
||||
assert_eq!(crate::table::decode(&papx).shading_of(0), Some(wordcraft_doc::Rgb(0xFF, 0, 0)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_table_shading_and_spans_are_safe() {
|
||||
// SHD arrays longer than the row, odd lengths, empty ones; itcMac past 63; edges that
|
||||
// run backwards or past the grid; merges past the row's end.
|
||||
// (Kept small: the fixture's single PAPX page holds every row's grpprl.)
|
||||
let long = shd_clear([1, 2, 3], 6);
|
||||
let rows: Vec<(Vec<u8>, Vec<u8>, Vec<u8>)> = vec![
|
||||
([sprm_cb(0xD612, &long), sprm_cb(0xD616, &long)].concat(), sprm_cb(0xD60C, &long), sprm_cb(0xD670, &long)),
|
||||
(sprm_cb(0xD612, &[1, 2, 3, 4, 5, 6, 7]), sprm_cb(0xD670, &[]), sprm_cb(0xD672, &long[..13])),
|
||||
(
|
||||
{
|
||||
let mut v = (0xD608u16).to_le_bytes().to_vec();
|
||||
v.extend_from_slice(&[0xFF, 0x00, 200]);
|
||||
v.extend_from_slice(&[0x10; 24]);
|
||||
v
|
||||
},
|
||||
t_def_edges(&[7200, 0, -5, 30000]),
|
||||
t_def_edges(&[-32000, 32000]),
|
||||
),
|
||||
(t_def_edges(&[0]), t_def_edges(&[]), [t_def_edges(&[0, 100, i16::MAX]), sprm_cb(0xD612, &long)].concat()),
|
||||
(grpprl(&[(0x5624, &[0, 200])]), grpprl(&[(0x5624, &[200, 3])]), grpprl(&[(0x5624, &[1, 255])])),
|
||||
];
|
||||
for (r1, r2, r3) in rows {
|
||||
let doc = word_table_doc(r1, r2, r3);
|
||||
let text = doc.plain_text(wordcraft_doc::StoryRef::Body);
|
||||
assert!(text.contains("legend") && text.contains("tail"), "{text:?}");
|
||||
if let Some(t) = doc.body.iter().find_map(|b| b.as_table()) {
|
||||
assert!(t.grid.len() <= 63);
|
||||
for r in &t.rows {
|
||||
assert!(r.cells.iter().all(|c| c.props.span <= 63));
|
||||
}
|
||||
}
|
||||
}
|
||||
// A row that is only a row-end mark (no cell marks before it), with an out-of-range
|
||||
// merge and a SHD array, then a normal row.
|
||||
// The second variant's sprmTDefTable is truncated to a single edge (itcMac 3), which
|
||||
// must not turn the lone row-end mark into a row with no cells.
|
||||
let text = "\u{7}a\u{7}\u{7}tail\r";
|
||||
let ttp = |extra: Vec<u8>| [grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01]), (0x6649, &[1, 0, 0, 0])]), extra].concat();
|
||||
let one_edge = {
|
||||
let mut v = (0xD608u16).to_le_bytes().to_vec();
|
||||
v.extend_from_slice(&4u16.to_le_bytes()); // cb: itcMac + one edge, plus one
|
||||
v.extend_from_slice(&[3, 0, 0]);
|
||||
v
|
||||
};
|
||||
for lone in [[t_def_edges(&[0, 1440]), grpprl(&[(0x5624, &[0, 9])]), sprm_cb(0xD612, &long)].concat(), one_edge] {
|
||||
let spec = one_section(
|
||||
text,
|
||||
Vec::new(),
|
||||
vec![
|
||||
(0, 0, ttp(lone)),
|
||||
(1, 0, grpprl(&[(0x2416, &[0x01]), (0x6649, &[1, 0, 0, 0])])),
|
||||
(3, 0, ttp(t_def_edges(&[0, 1440]))),
|
||||
(4, 0, Vec::new()),
|
||||
],
|
||||
);
|
||||
let doc = read(&struct_doc(&spec)).expect("opens");
|
||||
let text = doc.plain_text(wordcraft_doc::StoryRef::Body);
|
||||
assert!(text.contains('a') && text.contains("tail"), "{text:?}");
|
||||
for t in doc.body.iter().filter_map(|b| b.as_table()) {
|
||||
assert!(t.rows.iter().all(|r| !r.cells.is_empty()), "no zero-cell rows");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nil_raw_shading_keeps_the_earlier_colour() {
|
||||
// In sprmTDefTableShdRaw (and its 2nd/3rd forms) a nil Shd (ipat 0xFFFF) means "as the
|
||||
// table style shades it", so it must not override sprmTDefTableShd; automatic clears.
|
||||
let blue = shd_clear([0, 0, 0xFF], 1);
|
||||
let red = shd_clear([0xFF, 0, 0], 1);
|
||||
let nil = vec![0, 0, 0, 0xFF, 0, 0, 0, 0xFF, 0xFF, 0xFF];
|
||||
let auto = vec![0, 0, 0, 0xFF, 0, 0, 0, 0xFF, 0, 0];
|
||||
let papx = [sprm_cb(0xD612, &[blue.clone(), blue.clone(), blue.clone()].concat()), sprm_cb(0xD670, &[nil.clone(), red, auto].concat())].concat();
|
||||
let row = crate::table::decode(&papx);
|
||||
assert_eq!(row.shading_of(0), Some(wordcraft_doc::Rgb(0, 0, 0xFF)), "nil keeps blue");
|
||||
assert_eq!(row.shading_of(1), Some(wordcraft_doc::Rgb(0xFF, 0, 0)));
|
||||
assert_eq!(row.shading_of(2), None, "automatic clears");
|
||||
for (first, raw) in [(0xD616u16, 0xD671u16), (0xD60C, 0xD672)] {
|
||||
let papx = [sprm_cb(first, &blue), sprm_cb(raw, &nil)].concat();
|
||||
let cell = if first == 0xD616 { 22 } else { 44 };
|
||||
assert_eq!(crate::table::decode(&papx).shading_of(cell), Some(wordcraft_doc::Rgb(0, 0, 0xFF)), "{raw:04X}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_table_gets_its_own_grid() {
|
||||
// #1380: a 5-column table, a paragraph, then a 3-column table. The second table's grid
|
||||
// comes from its own rows, not the first table's, and its borders, alignment and indent
|
||||
// are its own too (the first table's centred, bordered and indented).
|
||||
let text = "a\u{7}b\u{7}c\u{7}d\u{7}e\u{7}\u{7}between\rx\u{7}y\u{7}z\u{7}\u{7}tail\r";
|
||||
let in_tbl = grpprl(&[(0x2416, &[0x01]), (0x6649, &[1, 0, 0, 0])]);
|
||||
let ttp = |edges: &[i16]| [grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01]), (0x6649, &[1, 0, 0, 0])]), t_def_edges(edges)].concat();
|
||||
let table1_props = {
|
||||
let mut borders = (0xD605u16).to_le_bytes().to_vec();
|
||||
borders.push(0x18);
|
||||
for _ in 0..6 {
|
||||
borders.extend_from_slice(&[4, 1, 1, 0]);
|
||||
}
|
||||
[borders, grpprl(&[(0x5400, &1u16.to_le_bytes()), (0x9601, &720i16.to_le_bytes())])].concat()
|
||||
};
|
||||
let spec = StructSpec {
|
||||
text,
|
||||
header_stories: vec![],
|
||||
sections: vec![(text.chars().count() as u32, Vec::new())],
|
||||
chpx_runs: vec![(0, Vec::new())],
|
||||
papx_entries: vec![
|
||||
(0, 0, in_tbl.clone()),
|
||||
(10, 0, [ttp(&[0, 1440, 2880, 4320, 5760, 7200]), table1_props].concat()),
|
||||
(11, 0, Vec::new()), // "between"
|
||||
(19, 0, in_tbl),
|
||||
(25, 0, ttp(&[0, 2000, 4000, 7200])),
|
||||
(26, 0, Vec::new()), // tail
|
||||
],
|
||||
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 tables: Vec<_> = doc.body.iter().filter_map(|b| b.as_table()).collect();
|
||||
assert_eq!(tables.len(), 2);
|
||||
assert_eq!(tables[0].grid.len(), 5);
|
||||
assert_eq!(tables[1].grid, vec![100.0, 100.0, 160.0], "second table's own grid");
|
||||
assert_eq!(tables[1].rows[0].cells.iter().map(|c| c.span()).collect::<Vec<_>>(), [1, 1, 1]);
|
||||
assert!(tables[0].props.borders.is_some() && tables[0].props.align.is_some() && tables[0].props.indent.is_some());
|
||||
assert_eq!(tables[1].props.borders, None, "borders reset");
|
||||
assert_eq!(tables[1].props.align, None, "alignment reset");
|
||||
assert_eq!(tables[1].props.indent, None, "indent reset");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sprm_t_cell_width_twips_and_percentage() {
|
||||
// [MS-DOC] §2.9.290 sprmTCellWidth (0xD635):
|
||||
// cb (1 byte), itcFirst (1 byte), itcLim (1 byte), ftsWidth (1 byte), wWidth (2 bytes).
|
||||
// ftsWidth = 3 (twips): 1440 twips = 72.0 pt.
|
||||
let w_twips = [0u8, 2, 3, 0xA0, 0x05]; // cell 0..2, fts=3, 1440 dxa
|
||||
// ftsWidth = 2 (percentage in 50ths of a percent): 2500 = 50.0%.
|
||||
let w_pct = [2u8, 3, 2, 0xC4, 0x09]; // cell 2..3, fts=2, 2500 (50%)
|
||||
let papx = [sprm_cb(0xD635, &w_twips), sprm_cb(0xD635, &w_pct)].concat();
|
||||
|
||||
let row = crate::table::decode(&papx);
|
||||
assert_eq!(row.width_of(0), Some(72.0));
|
||||
assert_eq!(row.width_of(1), Some(72.0));
|
||||
assert_eq!(row.width_pct_of(2), Some(50.0));
|
||||
|
||||
// End-to-end document test: row cells receive the preferred widths.
|
||||
let text = "a\u{7}b\u{7}c\u{7}\u{7}\r";
|
||||
let in_tbl = grpprl(&[(0x2416, &[0x01]), (0x6649, &[1, 0, 0, 0])]);
|
||||
let ttp = [
|
||||
grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01]), (0x6649, &[1, 0, 0, 0])]),
|
||||
t_def_edges(&[0, 1440, 2880, 4320]),
|
||||
sprm_cb(0xD635, &w_twips),
|
||||
sprm_cb(0xD635, &w_pct),
|
||||
]
|
||||
.concat();
|
||||
let spec = StructSpec {
|
||||
text,
|
||||
header_stories: vec![],
|
||||
sections: vec![(text.chars().count() as u32, Vec::new())],
|
||||
chpx_runs: vec![(0, Vec::new())],
|
||||
papx_entries: vec![(0, 0, in_tbl), (6, 0, ttp), (7, 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 table = doc.body[0].as_table().expect("table block");
|
||||
assert_eq!(table.rows[0].cells[0].props.width, Some(72.0));
|
||||
assert_eq!(table.rows[0].cells[1].props.width, Some(72.0));
|
||||
assert_eq!(table.rows[0].cells[2].props.width_pct, Some(50.0));
|
||||
}
|
||||
|
||||
@@ -182,6 +182,36 @@ fn shapes_keep_kind_colours_wrap_and_text_box_text() {
|
||||
assert_eq!(part.blocks.first().and_then(|b| b.as_para()).map(|p| p.text.as_str()), Some("Inside"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_boxes_pointing_at_the_same_story_text_read_it_once() {
|
||||
// Two text boxes whose PlcftxbxTxt stories are both [0, 7) (the table steps back to CP 0
|
||||
// in between): each was read into its own part, so a hostile table could copy one story
|
||||
// thousands of times. The second story is dropped and its box stays empty.
|
||||
let tb = |spid: u32| sp(&[fsp(202, spid, 0xA00), fopt(&[(0x01BF, 1 << 20)], &[]), rec(0, 0, 0xF00D, &0x0001_0000u32.to_le_bytes())]);
|
||||
let art = art_content(&[], &[tb(1027), tb(1028)]);
|
||||
let flags = spa_flags(0, 0, 1, false);
|
||||
let spas = plc(&[1, 2, 4], &[spa(1027, [0, 0, 2880, 720], flags), spa(1028, [0, 0, 2880, 720], flags)].concat());
|
||||
// FTXBXS: cTxbx, cReusable, fReusable, reserved, lid, txidUndo.
|
||||
let ftxbxs = |lid: u32| {
|
||||
[1i32.to_le_bytes().as_slice(), &0i32.to_le_bytes(), &0i16.to_le_bytes(), &0i32.to_le_bytes(), &lid.to_le_bytes(), &0i32.to_le_bytes()]
|
||||
.concat()
|
||||
};
|
||||
let txbx = plc(&[0, 7, 0, 7], &[ftxbxs(1027), ftxbxs(1), ftxbxs(1028)].concat());
|
||||
let text = "A\u{8}\u{8}B\r";
|
||||
let ccp = text.chars().count() as u32;
|
||||
// As above: the endnote slot holds the text box subdocument, then the FIB is patched.
|
||||
let spec =
|
||||
StructSpec { text, endnote_stories: vec!["Inside\r"], extra_pairs: vec![(40, spas), (50, art), (56, txbx)], ..structured_spec(text, ccp) };
|
||||
let doc = read(&patch_word(&struct_doc(&spec), &[(0x60, 0), (0x64, 7)])).expect("opens");
|
||||
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
|
||||
let stories: Vec<Option<u32>> =
|
||||
p0.objects.iter().filter_map(|o| if let InlineObject::Shape { story, .. } = o { Some(*story) } else { None }).collect();
|
||||
let [Some(first), None] = stories[..] else { panic!("stories: {stories:?}") };
|
||||
let part = doc.parts.get(&first).expect("text box story part");
|
||||
assert_eq!(part.blocks.first().and_then(|b| b.as_para()).map(|p| p.text.as_str()), Some("Inside"));
|
||||
assert_eq!(doc.parts.len(), 1);
|
||||
}
|
||||
|
||||
/// Overwrite little-endian u32s in the WordDocument stream of a compound file.
|
||||
fn patch_word(file: &[u8], at: &[(usize, u32)]) -> Vec<u8> {
|
||||
use std::io::Read;
|
||||
@@ -274,3 +304,30 @@ proptest! {
|
||||
let _ = read(&struct_doc(&spec));
|
||||
}
|
||||
}
|
||||
|
||||
// A 1×1 24-bit DIB, including its padded four-byte row.
|
||||
fn one_pixel_dib() -> Vec<u8> {
|
||||
let mut dib = [40u32, 1, 1].iter().flat_map(|v| v.to_le_bytes()).collect::<Vec<_>>();
|
||||
dib.extend_from_slice(&1u16.to_le_bytes());
|
||||
dib.extend_from_slice(&24u16.to_le_bytes());
|
||||
dib.extend_from_slice(&[0u8; 24]);
|
||||
dib.extend_from_slice(&[30, 60, 90, 0]);
|
||||
dib
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dib_conversion_caps_header_and_encoded_bytes() {
|
||||
let dib = one_pixel_dib();
|
||||
let record = rec(0, 0x7A8, officeart::rt::BLIP_DIB, &[[0u8; 16].as_slice(), &[0xFF], &dib].concat());
|
||||
let (record, _) = officeart::record(&record).unwrap();
|
||||
let bmp_size = 14 + dib.len();
|
||||
assert!(officeart::blip(&record, bmp_size - 1).is_none(), "the BMP header also needs room");
|
||||
let fallback = officeart::blip(&record, bmp_size).expect("the BMP fallback fits exactly");
|
||||
assert_eq!((fallback.ext, fallback.placeholder, fallback.bytes.len()), ("bmp", true, bmp_size));
|
||||
let img = image::load_from_memory(&fallback.bytes).unwrap().to_rgb8();
|
||||
assert_eq!((img.width(), img.height(), img.get_pixel(0, 0).0), (1, 1, [90, 60, 30]));
|
||||
let normal = officeart::blip(&record, 512).expect("normal conversion fits");
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
//! Password-protected files. The fixtures are encrypted here with a small encryptor written from
|
||||
//! [MS-DOC] §2.2.6 and [MS-OFFCRYPTO] §2.3.5-2.3.7; no file produced by Word is used.
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
use sha1::{Digest, Sha1};
|
||||
|
||||
use super::{Piece, body_texts, cfb_file, text_doc};
|
||||
use crate::DocbinError;
|
||||
use crate::crypt::{F_ENCRYPTED, F_OBFUSCATED, Rc4, md5, xor_array, xor_verifier};
|
||||
use crate::{is_encrypted, read, read_with_password};
|
||||
|
||||
const TEXT: &str = "Secret \u{20ac} plans";
|
||||
/// Where the piece table's pair lives in the FIB: `fcClx` of pair 33.
|
||||
const CLX_PAIR: usize = 0x9A + 33 * 8;
|
||||
|
||||
fn plain() -> (Vec<u8>, Vec<u8>) {
|
||||
let f = text_doc(&[Piece { compressed: false, text: format!("{TEXT}\r") }], &[], 0);
|
||||
let mut comp = cfb::CompoundFile::open(std::io::Cursor::new(f)).unwrap();
|
||||
let mut get = |n: &str| {
|
||||
let mut v = Vec::new();
|
||||
comp.open_stream(format!("/{n}")).unwrap().read_to_end(&mut v).unwrap();
|
||||
v
|
||||
};
|
||||
(get("WordDocument"), get("0Table"))
|
||||
}
|
||||
|
||||
fn rewrap(word: &[u8], table: &[u8]) -> Vec<u8> {
|
||||
cfb_file(&[("WordDocument", word), ("0Table", table)])
|
||||
}
|
||||
|
||||
fn put16(v: &mut [u8], at: usize, x: u16) {
|
||||
v[at..at + 2].copy_from_slice(&x.to_le_bytes());
|
||||
}
|
||||
|
||||
fn put32(v: &mut [u8], at: usize, x: u32) {
|
||||
v[at..at + 4].copy_from_slice(&x.to_le_bytes());
|
||||
}
|
||||
|
||||
fn utf16(s: &str) -> Vec<u8> {
|
||||
s.encode_utf16().flat_map(u16::to_le_bytes).collect()
|
||||
}
|
||||
|
||||
fn sha1(parts: &[&[u8]]) -> Vec<u8> {
|
||||
let mut h = Sha1::new();
|
||||
for p in parts {
|
||||
h.update(p);
|
||||
}
|
||||
h.finalize().to_vec()
|
||||
}
|
||||
|
||||
/// Encrypt (= RC4 is symmetric) `data` in 512-byte blocks; the first `skip` bytes stay as they are
|
||||
/// but the keystream still runs over them.
|
||||
fn rc4_stream(data: &mut [u8], skip: usize, key: &dyn Fn(u32) -> Vec<u8>) {
|
||||
for (n, chunk) in data.chunks_mut(512).enumerate() {
|
||||
let orig = chunk.to_vec();
|
||||
Rc4::new(&key(n as u32)).apply(chunk);
|
||||
let keep = skip.saturating_sub(n * 512).min(chunk.len());
|
||||
chunk[..keep].copy_from_slice(&orig[..keep]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Put `header` in front of the table and set the FIB's encryption fields.
|
||||
fn assemble(mut word: Vec<u8>, table: &[u8], header: &[u8], flags: u16, lkey: u32) -> (Vec<u8>, Vec<u8>) {
|
||||
let fl = u16::from_le_bytes([word[0x0A], word[0x0B]]) | flags;
|
||||
put16(&mut word, 0x0A, fl);
|
||||
put32(&mut word, 0x0E, lkey);
|
||||
let fc = u32::from_le_bytes(word[CLX_PAIR..CLX_PAIR + 4].try_into().unwrap());
|
||||
put32(&mut word, CLX_PAIR, fc + header.len() as u32);
|
||||
([word].concat(), [header, table].concat())
|
||||
}
|
||||
|
||||
fn xor_file(pw: &[u8]) -> Vec<u8> {
|
||||
let (word, table) = plain();
|
||||
let (mut word, mut table) = assemble(word, &table, &[], F_ENCRYPTED | F_OBFUSCATED, xor_verifier(pw));
|
||||
let arr = xor_array(pw);
|
||||
for (data, skip) in [(&mut word, 68), (&mut table, 0)] {
|
||||
for (i, b) in data.iter_mut().enumerate().skip(skip) {
|
||||
if *b != 0 && *b ^ arr[i % 16] != 0 {
|
||||
*b ^= arr[i % 16];
|
||||
}
|
||||
}
|
||||
}
|
||||
rewrap(&word, &table)
|
||||
}
|
||||
|
||||
fn rc4_header(pw: &str) -> (Vec<u8>, Box<dyn Fn(u32) -> Vec<u8>>) {
|
||||
let salt = [7u8; 16];
|
||||
let h0 = md5(&utf16(pw));
|
||||
let h1 = md5(&(0..16).flat_map(|_| [&h0[..5], &salt[..]].concat()).collect::<Vec<u8>>());
|
||||
let trunc = h1[..5].to_vec();
|
||||
let key: Box<dyn Fn(u32) -> Vec<u8>> = Box::new(move |b| md5(&[trunc.as_slice(), &b.to_le_bytes()].concat()).to_vec());
|
||||
let verifier = *b"0123456789abcdef";
|
||||
let mut enc = [verifier.to_vec(), md5(&verifier).to_vec()].concat();
|
||||
Rc4::new(&key(0)).apply(&mut enc);
|
||||
([&[1, 0, 1, 0][..], &salt, &enc].concat(), key)
|
||||
}
|
||||
|
||||
fn capi_header(pw: &str, bits: u32, hflags: u32) -> (Vec<u8>, Box<dyn Fn(u32) -> Vec<u8>>) {
|
||||
let salt = [9u8; 16];
|
||||
let h0 = sha1(&[&salt, &utf16(pw)]);
|
||||
let n = (bits / 8) as usize;
|
||||
let key: Box<dyn Fn(u32) -> Vec<u8>> = Box::new(move |b| {
|
||||
let mut k = sha1(&[&h0, &b.to_le_bytes()])[..n].to_vec();
|
||||
if n == 5 {
|
||||
k.resize(16, 0);
|
||||
}
|
||||
k
|
||||
});
|
||||
let verifier = *b"fedcba9876543210";
|
||||
let mut enc = [verifier.to_vec(), sha1(&[&verifier])].concat();
|
||||
Rc4::new(&key(0)).apply(&mut enc);
|
||||
let mut body = Vec::new();
|
||||
for x in [hflags, 0, 0x6801, 0x8004, bits, 1, 0, 0] {
|
||||
body.extend_from_slice(&x.to_le_bytes());
|
||||
}
|
||||
body.extend_from_slice(&utf16("Test RC4 provider\0"));
|
||||
let mut h = vec![3, 0, 2, 0];
|
||||
h.extend_from_slice(&hflags.to_le_bytes());
|
||||
h.extend_from_slice(&(body.len() as u32).to_le_bytes());
|
||||
h.extend_from_slice(&body);
|
||||
h.extend_from_slice(&16u32.to_le_bytes());
|
||||
h.extend_from_slice(&salt);
|
||||
h.extend_from_slice(&enc[..16]);
|
||||
h.extend_from_slice(&20u32.to_le_bytes());
|
||||
h.extend_from_slice(&enc[16..]);
|
||||
(h, key)
|
||||
}
|
||||
|
||||
fn rc4_file_with(header: Vec<u8>, key: &dyn Fn(u32) -> Vec<u8>) -> Vec<u8> {
|
||||
let (word, table) = plain();
|
||||
let (mut word, mut table) = assemble(word, &table, &header, F_ENCRYPTED, header.len() as u32);
|
||||
rc4_stream(&mut word, 68, key);
|
||||
rc4_stream(&mut table, header.len(), key);
|
||||
rewrap(&word, &table)
|
||||
}
|
||||
|
||||
fn rc4_file(pw: &str) -> Vec<u8> {
|
||||
let (h, k) = rc4_header(pw);
|
||||
rc4_file_with(h, &*k)
|
||||
}
|
||||
|
||||
fn capi_file(pw: &str, bits: u32) -> Vec<u8> {
|
||||
let (h, k) = capi_header(pw, bits, 0x04);
|
||||
rc4_file_with(h, &*k)
|
||||
}
|
||||
|
||||
fn check(f: &[u8], pw: &str, wrong: &str) {
|
||||
assert!(is_encrypted(f));
|
||||
assert_eq!(read(f).unwrap_err(), DocbinError::PasswordRequired);
|
||||
assert_eq!(read_with_password(f, Some(wrong)).unwrap_err(), DocbinError::WrongPassword);
|
||||
assert_eq!(read_with_password(f, Some("")).unwrap_err(), DocbinError::WrongPassword);
|
||||
let doc = read_with_password(f, Some(pw)).unwrap();
|
||||
assert_eq!(body_texts(&doc), [TEXT]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xor_obfuscation_round_trip() {
|
||||
check(&xor_file(b"hunter2"), "hunter2", "hunter3");
|
||||
// Longer than 15 characters: only the first 15 count, as in the format.
|
||||
let long = xor_file(b"abcdefghijklmno");
|
||||
assert_eq!(body_texts(&read_with_password(&long, Some("abcdefghijklmnopqrst")).unwrap()), [TEXT]);
|
||||
// A non-ASCII (Latin-1) password.
|
||||
check(&xor_file(&[b'p', 0xE4, b's', b's']), "p\u{e4}ss", "pass");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rc4_round_trip() {
|
||||
check(&rc4_file("correct horse"), "correct horse", "Correct horse");
|
||||
check(&rc4_file("p\u{e4}ssw\u{f6}rd \u{1f511}"), "p\u{e4}ssw\u{f6}rd \u{1f511}", "password");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rc4_crypto_api_round_trip() {
|
||||
check(&capi_file("battery staple", 128), "battery staple", "battery stapl");
|
||||
// 40-bit keys are zero-padded to 128 bits.
|
||||
check(&capi_file("battery staple", 40), "battery staple", "x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unencrypted_files_ignore_the_password() {
|
||||
let f = text_doc(&[Piece { compressed: false, text: "Plain\r".into() }], &[], 0);
|
||||
assert!(!is_encrypted(&f));
|
||||
assert_eq!(body_texts(&read_with_password(&f, Some("whatever")).unwrap()), ["Plain"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tampered_verifier_is_a_wrong_password_never_a_garbled_document() {
|
||||
let (h, k) = rc4_header("pw");
|
||||
let mut bad = h.clone();
|
||||
bad[40] ^= 1; // inside the encrypted verifier hash
|
||||
assert_eq!(read_with_password(&rc4_file_with(bad, &*k), Some("pw")).unwrap_err(), DocbinError::WrongPassword);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_variants_say_so() {
|
||||
// AES and external providers, an unknown header version.
|
||||
for hflags in [0x04 | 0x20, 0x10] {
|
||||
let (h, k) = capi_header("pw", 128, hflags);
|
||||
let e = read_with_password(&rc4_file_with(h, &*k), Some("pw")).unwrap_err();
|
||||
assert!(matches!(e, DocbinError::Encryption(_)), "{e:?}");
|
||||
}
|
||||
let (mut h, k) = rc4_header("pw");
|
||||
h[0] = 9;
|
||||
assert!(matches!(read_with_password(&rc4_file_with(h, &*k), Some("pw")), Err(DocbinError::Encryption(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_headers_are_errors_never_panics() {
|
||||
for f in [rc4_header("pw"), capi_header("pw", 128, 0x04)] {
|
||||
let (h, k) = f;
|
||||
let full = rc4_file_with(h.clone(), &*k);
|
||||
let (word, table) = {
|
||||
let mut comp = cfb::CompoundFile::open(std::io::Cursor::new(&full)).unwrap();
|
||||
let mut get = |n: &str| {
|
||||
let mut v = Vec::new();
|
||||
comp.open_stream(format!("/{n}")).unwrap().read_to_end(&mut v).unwrap();
|
||||
v
|
||||
};
|
||||
(get("WordDocument"), get("0Table"))
|
||||
};
|
||||
// Truncated everywhere in and around the header: an error, not a panic.
|
||||
for n in 0..h.len() + 8 {
|
||||
let r = read_with_password(&rewrap(&word, &table[..n.min(table.len())]), Some("pw"));
|
||||
assert!(r.is_err(), "length {n}");
|
||||
}
|
||||
// Every header byte garbled.
|
||||
for at in 0..h.len() {
|
||||
let mut t = table.clone();
|
||||
t[at] ^= 0xFF;
|
||||
let _ = read_with_password(&rewrap(&word, &t), Some("pw"));
|
||||
}
|
||||
}
|
||||
// A header size claiming more than the stream holds.
|
||||
let (mut h, k) = capi_header("pw", 128, 0x04);
|
||||
h[8..12].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
assert!(read_with_password(&rc4_file_with(h, &*k), Some("pw")).is_err());
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
//! OLE property sets constructed from MS-OLEPS, exercised through the public DOC reader.
|
||||
use super::{Piece, body_texts, text_doc};
|
||||
use std::io::{Cursor, Seek, SeekFrom, Write};
|
||||
|
||||
fn summary(cp: u16, values: &[(u32, u32, Vec<u8>)]) -> Vec<u8> {
|
||||
let mut props = vec![(1, [2u32.to_le_bytes().as_slice(), &cp.to_le_bytes(), &[0, 0]].concat())];
|
||||
for (id, kind, bytes) in values {
|
||||
let size = if *kind == 31 { bytes.len() / 2 } else { bytes.len() } as u32;
|
||||
let mut value = [kind.to_le_bytes().as_slice(), &size.to_le_bytes(), bytes].concat();
|
||||
while !value.len().is_multiple_of(4) {
|
||||
value.push(0);
|
||||
}
|
||||
props.push((*id, value));
|
||||
}
|
||||
let mut section = vec![0; 8 + 8 * props.len()];
|
||||
section[4..8].copy_from_slice(&(props.len() as u32).to_le_bytes());
|
||||
for (i, (id, value)) in props.iter().enumerate() {
|
||||
section[8 + i * 8..12 + i * 8].copy_from_slice(&id.to_le_bytes());
|
||||
let offset = section.len() as u32;
|
||||
section[12 + i * 8..16 + i * 8].copy_from_slice(&offset.to_le_bytes());
|
||||
section.extend_from_slice(value);
|
||||
}
|
||||
let size = section.len() as u32;
|
||||
section[..4].copy_from_slice(&size.to_le_bytes());
|
||||
let mut header = vec![0; 48];
|
||||
header[..2].copy_from_slice(&[0xfe, 0xff]);
|
||||
header[24..28].copy_from_slice(&1u32.to_le_bytes());
|
||||
header[28..44].copy_from_slice(&[0xe0, 0x85, 0x9f, 0xf2, 0xf9, 0x4f, 0x68, 0x10, 0xab, 0x91, 0x08, 0, 0x2b, 0x27, 0xb3, 0xd9]);
|
||||
header[44..48].copy_from_slice(&48u32.to_le_bytes());
|
||||
header.extend(section);
|
||||
header
|
||||
}
|
||||
|
||||
fn open_with_metadata(bytes: &[u8], template: bool) -> wordcraft_doc::Document {
|
||||
let file = text_doc(&[Piece { compressed: false, text: "Body stays readable\r".into() }], &[], 0);
|
||||
let mut cfb = cfb::CompoundFile::open(Cursor::new(file)).unwrap();
|
||||
cfb.create_stream("/\u{5}SummaryInformation").unwrap().write_all(bytes).unwrap();
|
||||
if template {
|
||||
let mut stream = cfb.open_stream("/WordDocument").unwrap();
|
||||
stream.seek(SeekFrom::Start(0x0a)).unwrap();
|
||||
stream.write_all(&0x1001u16.to_le_bytes()).unwrap();
|
||||
}
|
||||
let doc = crate::read(&cfb.into_inner().into_inner()).unwrap();
|
||||
assert_eq!(body_texts(&doc), ["Body stays readable"]);
|
||||
doc
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_and_dot_import_summary_text_properties() {
|
||||
let data = summary(
|
||||
1252,
|
||||
&[
|
||||
(2, 30, b"R\xe9sum\xe9\0".to_vec()),
|
||||
(3, 30, b"Subject\0".to_vec()),
|
||||
(4, 30, b"Writer\0".to_vec()),
|
||||
(5, 30, b"one;two\0".to_vec()),
|
||||
(6, 30, b"Description\0".to_vec()),
|
||||
(8, 30, b"Editor\0".to_vec()),
|
||||
(9, 30, b"12\0".to_vec()),
|
||||
],
|
||||
);
|
||||
for template in [false, true] {
|
||||
let c = open_with_metadata(&data, template).core;
|
||||
assert_eq!(c.title, "Résumé");
|
||||
assert_eq!(c.subject, "Subject");
|
||||
assert_eq!(c.creator, "Writer");
|
||||
assert_eq!(c.keywords, "one;two");
|
||||
assert_eq!(c.description, "Description");
|
||||
assert_eq!(c.last_modified_by, "Editor");
|
||||
assert_eq!(c.revision, 12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_handles_codepages_and_utf16_length_units() {
|
||||
let wide: Vec<u8> = "Unicode ä 文 😀\0".encode_utf16().flat_map(u16::to_le_bytes).collect();
|
||||
for kind in [30, 31] {
|
||||
assert_eq!(open_with_metadata(&summary(1200, &[(2, kind, wide.clone())]), false).core.title, "Unicode ä 文 😀");
|
||||
}
|
||||
assert_eq!(open_with_metadata(&summary(65001, &[(2, 30, "UTF-8 ä\0".as_bytes().to_vec())]), false).core.title, "UTF-8 ä");
|
||||
assert_eq!(open_with_metadata(&summary(1251, &[(2, 30, vec![0xcf, 0xf0, 0xe8, 0xe2, 0xe5, 0xf2, 0])]), false).core.title, "Привет");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_or_oversized_summary_does_not_break_doc_open() {
|
||||
let valid = summary(1252, &[(2, 30, b"Title\0".to_vec())]);
|
||||
for n in [0, 2, 24, 44, 47, 55, valid.len() - 5] {
|
||||
assert!(open_with_metadata(&valid[..n], false).core.title.is_empty());
|
||||
}
|
||||
for (at, value) in [(44, u32::MAX), (48, u32::MAX), (52, u32::MAX), (68, 4), (84, u32::MAX)] {
|
||||
let mut bad = valid.clone();
|
||||
bad[at..at + 4].copy_from_slice(&value.to_le_bytes());
|
||||
assert!(open_with_metadata(&bad, false).core.title.is_empty(), "offset {at}");
|
||||
}
|
||||
let huge = summary(1252, &[(2, 30, vec![b'x'; (64 << 10) + 1])]);
|
||||
assert!(open_with_metadata(&huge, false).core.title.is_empty());
|
||||
assert!(open_with_metadata(&vec![0; (2 << 20) + 1], false).core.title.is_empty());
|
||||
}
|
||||
|
||||
fn custom_summary(props: &[(u32, Vec<u8>)], num_property_sets: u32) -> Vec<u8> {
|
||||
let mut section = vec![0; 8 + 8 * props.len()];
|
||||
section[4..8].copy_from_slice(&(props.len() as u32).to_le_bytes());
|
||||
for (i, (id, value)) in props.iter().enumerate() {
|
||||
section[8 + i * 8..12 + i * 8].copy_from_slice(&id.to_le_bytes());
|
||||
let offset = section.len() as u32;
|
||||
section[12 + i * 8..16 + i * 8].copy_from_slice(&offset.to_le_bytes());
|
||||
section.extend_from_slice(value);
|
||||
}
|
||||
let size = section.len() as u32;
|
||||
section[..4].copy_from_slice(&size.to_le_bytes());
|
||||
let mut header = vec![0; 28 + 20 * num_property_sets as usize];
|
||||
header[..2].copy_from_slice(&[0xfe, 0xff]);
|
||||
header[24..28].copy_from_slice(&num_property_sets.to_le_bytes());
|
||||
// First property set FMTID and offset
|
||||
header[28..44].copy_from_slice(&[0xe0, 0x85, 0x9f, 0xf2, 0xf9, 0x4f, 0x68, 0x10, 0xab, 0x91, 0x08, 0, 0x2b, 0x27, 0xb3, 0xd9]);
|
||||
let first_offset = header.len() as u32;
|
||||
header[44..48].copy_from_slice(&first_offset.to_le_bytes());
|
||||
if num_property_sets > 1 {
|
||||
// Dummy second property set header
|
||||
header[48..64].copy_from_slice(&[1; 16]);
|
||||
let second_offset = (first_offset + size) as u32;
|
||||
header[64..68].copy_from_slice(&second_offset.to_le_bytes());
|
||||
}
|
||||
header.extend(section);
|
||||
header
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_defaults_codepage_and_accepts_vti4_and_num_property_sets() {
|
||||
let title_prop = [30u32.to_le_bytes().as_slice(), &7u32.to_le_bytes(), b"Default\0"].concat();
|
||||
// 1. Missing PID_CODEPAGE (property 1) should default to 1252 per MS-OLEPS.
|
||||
let no_cp = custom_summary(&[(2, title_prop.clone())], 1);
|
||||
assert_eq!(open_with_metadata(&no_cp, false).core.title, "Default");
|
||||
|
||||
// 2. PID_CODEPAGE as VT_I4 (type 3).
|
||||
let cp_vti4 = [3u32.to_le_bytes().as_slice(), &1252u32.to_le_bytes()].concat();
|
||||
let with_vti4 = custom_summary(&[(1, cp_vti4), (2, title_prop.clone())], 1);
|
||||
assert_eq!(open_with_metadata(&with_vti4, false).core.title, "Default");
|
||||
|
||||
// 3. Stream with NumPropertySets = 2.
|
||||
let multi_sets = custom_summary(&[(2, title_prop)], 2);
|
||||
assert_eq!(open_with_metadata(&multi_sets, false).core.title, "Default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_parses_filetime_created_and_modified_dates() {
|
||||
// 2024-03-15T12:30:45Z = 133549794450000000 in 100ns since 1601-01-01
|
||||
let ft_val: u64 = 133_549_794_450_000_000;
|
||||
let ft_low = (ft_val & 0xFFFFFFFF) as u32;
|
||||
let ft_high = (ft_val >> 32) as u32;
|
||||
let filetime_bytes = [64u32.to_le_bytes().as_slice(), &ft_low.to_le_bytes(), &ft_high.to_le_bytes()].concat();
|
||||
|
||||
let stream = custom_summary(
|
||||
&[
|
||||
(12, filetime_bytes.clone()), // PIDSI_CREATE_DTM
|
||||
(13, filetime_bytes), // PIDSI_LASTSAVE_DTM
|
||||
],
|
||||
1,
|
||||
);
|
||||
|
||||
let core = open_with_metadata(&stream, false).core;
|
||||
assert_eq!(core.created, "2024-03-15T12:30:45Z");
|
||||
assert_eq!(core.modified, "2024-03-15T12:30:45Z");
|
||||
}
|
||||
@@ -15,6 +15,7 @@ quick-xml = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
unicode-bidi = { workspace = true }
|
||||
cfb = { workspace = true }
|
||||
aes = { workspace = true }
|
||||
sha1 = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
//! Word bibliography sources: a `b:Sources` document — the custom XML part of a document, or
|
||||
//! Word's own `Sources.xml` master list, imported through Manage Sources (#2640).
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use wordcraft_doc::Source;
|
||||
|
||||
use crate::DocxError;
|
||||
use crate::xml::El;
|
||||
|
||||
/// Bibliography sources kept from one file (Word's own source manager holds far fewer).
|
||||
pub const MAX_SOURCES: usize = 10_000;
|
||||
/// Characters kept of a bibliography's citation style name.
|
||||
pub const MAX_STYLE_NAME: usize = 64;
|
||||
|
||||
/// A `b:Sources` document read into the document model: its sources in file order, and the
|
||||
/// citation style it names (`b:Sources/@StyleName`), when it has one.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Sources {
|
||||
pub sources: Vec<Source>,
|
||||
pub style: Option<String>,
|
||||
}
|
||||
|
||||
/// The bibliography namespace Word's own `Sources.xml` files may carry instead of OOXML's (the
|
||||
/// VBA `Source.XML` format, `schemas.microsoft.com/office/word/2004/10/bibliography`).
|
||||
const WORD_2004_BIBLIOGRAPHY: &str = "http://schemas.microsoft.com/office/word/2004/10/bibliography";
|
||||
|
||||
/// Read a Word bibliography sources file — Word's `Sources.xml`, or any `b:Sources` document in
|
||||
/// one of the bibliography namespaces (ECMA-376's, or Word's own 2004/10) — into [`Sources`].
|
||||
pub fn read_sources(bytes: &[u8]) -> Result<Sources, DocxError> {
|
||||
// `parse_ns` maps Word's 2004/10 namespace onto the canonical `b` prefix as well, so both
|
||||
// formats read; the root check then refuses an un-namespaced or unrelated `<Sources>`.
|
||||
let root = crate::xml::parse_ns(bytes, &[("b", WORD_2004_BIBLIOGRAPHY)])?;
|
||||
if root.name != "b:Sources" {
|
||||
return Err(DocxError::NotWord(format!("root element is {}", root.name)));
|
||||
}
|
||||
Ok(parse(&root, &mut Seen::default(), MAX_SOURCES))
|
||||
}
|
||||
|
||||
/// Bibliography sources already read: tags, and (title, author) of untagged ones.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Seen {
|
||||
tags: HashSet<String>,
|
||||
untagged: HashSet<(String, String)>,
|
||||
}
|
||||
|
||||
/// The fresh sources and style of a parsed `b:Sources` root element: `seen` carries the tags and
|
||||
/// untagged (title, author) keys already taken, so repeated parts collapse into one source, and
|
||||
/// at most `room` fresh sources are built (later ones are left unread, bounding hostile files).
|
||||
pub(crate) fn parse(root: &El, seen: &mut Seen, room: usize) -> Sources {
|
||||
let style = root
|
||||
.attrs
|
||||
.iter()
|
||||
.find(|(k, _)| k.rsplit(':').next() == Some("StyleName"))
|
||||
.map(|(_, v)| v.trim())
|
||||
.filter(|name| !name.is_empty())
|
||||
.map(|name| name.chars().take(MAX_STYLE_NAME).collect());
|
||||
let child_text = |el: &El, local: &str| el.els().find(|e| e.local() == local).map(|e| e.text()).unwrap_or_default();
|
||||
let parse_persons = |parent: &El| -> String {
|
||||
let mut names = Vec::new();
|
||||
let person_els: Vec<&El> = {
|
||||
let in_lists: Vec<&El> =
|
||||
parent.els().filter(|e| e.local() == "NameList").flat_map(|n| n.els().filter(|e| e.local() == "Person")).collect();
|
||||
if in_lists.is_empty() { parent.els().filter(|e| e.local() == "Person").collect() } else { in_lists }
|
||||
};
|
||||
for name_el in person_els {
|
||||
let last = child_text(name_el, "Last");
|
||||
let first = child_text(name_el, "First");
|
||||
let middle = child_text(name_el, "Middle");
|
||||
let name = if !first.is_empty() && !middle.is_empty() {
|
||||
format!("{last}, {first} {middle}")
|
||||
} else if !first.is_empty() {
|
||||
format!("{last}, {first}")
|
||||
} else {
|
||||
last
|
||||
};
|
||||
if !name.is_empty() {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
names.join("; ")
|
||||
};
|
||||
|
||||
let mut out = Sources { sources: Vec::new(), style };
|
||||
for src_el in root.els().filter(|e| e.local() == "Source") {
|
||||
if out.sources.len() >= room {
|
||||
log::warn!("docx: more than {MAX_SOURCES} bibliography sources; the rest are dropped");
|
||||
break;
|
||||
}
|
||||
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") {
|
||||
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 ed = a.els().find(|e| e.local() == "Editor").map(parse_persons).unwrap_or_default();
|
||||
(auth, ed)
|
||||
}
|
||||
None => (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");
|
||||
let year = child_text(src_el, "Year");
|
||||
let month = child_text(src_el, "Month");
|
||||
let day = child_text(src_el, "Day");
|
||||
let city = child_text(src_el, "City");
|
||||
let publisher = child_text(src_el, "Publisher");
|
||||
let journal = child_text(src_el, "JournalName");
|
||||
let edition = child_text(src_el, "Edition");
|
||||
let volume = child_text(src_el, "Volume");
|
||||
let issue = child_text(src_el, "Issue");
|
||||
let pages = child_text(src_el, "Pages");
|
||||
let url = child_text(src_el, "URL");
|
||||
let accessed = match child_text(src_el, "Accessed") {
|
||||
a if !a.is_empty() => a,
|
||||
_ => {
|
||||
let y = child_text(src_el, "YearAccessed");
|
||||
let m = child_text(src_el, "MonthAccessed");
|
||||
let d = child_text(src_el, "DayAccessed");
|
||||
if !y.is_empty() && !m.is_empty() && !d.is_empty() {
|
||||
format!("{y}-{m}-{d}")
|
||||
} else if !y.is_empty() && !m.is_empty() {
|
||||
format!("{y}-{m}")
|
||||
} else {
|
||||
y
|
||||
}
|
||||
}
|
||||
};
|
||||
let doi = match child_text(src_el, "DOI") {
|
||||
d if !d.is_empty() => d,
|
||||
_ => child_text(src_el, "StandardNumber"),
|
||||
};
|
||||
|
||||
// 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() {
|
||||
continue;
|
||||
}
|
||||
let fresh = if tag.is_empty() {
|
||||
seen.untagged.insert((title.clone(), if author.is_empty() { editor.clone() } else { author.clone() }))
|
||||
} else {
|
||||
seen.tags.insert(tag.clone())
|
||||
};
|
||||
if !fresh {
|
||||
continue;
|
||||
}
|
||||
out.sources.push(Source {
|
||||
tag,
|
||||
kind,
|
||||
author,
|
||||
title,
|
||||
year,
|
||||
city,
|
||||
publisher,
|
||||
journal,
|
||||
volume,
|
||||
pages,
|
||||
url,
|
||||
editor,
|
||||
edition,
|
||||
issue,
|
||||
month,
|
||||
day,
|
||||
accessed,
|
||||
doi,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const WORD_SOURCES: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<b:Sources xmlns:b="http://schemas.openxmlformats.org/officeDocument/2006/bibliography"
|
||||
xmlns="http://schemas.openxmlformats.org/officeDocument/2006/bibliography"
|
||||
SelectedStyle="\MLA.XSL" StyleName="MLA">
|
||||
<b:Source>
|
||||
<b:Tag>Riv21</b:Tag>
|
||||
<b:SourceType>Book</b:SourceType>
|
||||
<b:Author>
|
||||
<b:Author>
|
||||
<b:NameList>
|
||||
<b:Person><b:Last>Rivera</b:Last><b:First>Alex</b:First></b:Person>
|
||||
<b:Person><b:Last>Chen</b:Last><b:First>Mei</b:First><b:Middle>L.</b:Middle></b:Person>
|
||||
</b:NameList>
|
||||
</b:Author>
|
||||
</b:Author>
|
||||
<b:Title>Shared Spaces</b:Title>
|
||||
<b:Year>2021</b:Year>
|
||||
<b:City>Portland</b:City>
|
||||
<b:Publisher>Harbor Books</b:Publisher>
|
||||
</b:Source>
|
||||
<b:Source>
|
||||
<b:Tag>Web19</b:Tag>
|
||||
<b:SourceType>InternetSite</b:SourceType>
|
||||
<b:Author>
|
||||
<b:Author>
|
||||
<b:NameList>
|
||||
<b:Person><b:Last>Okafor</b:Last><b:First>Ada</b:First></b:Person>
|
||||
</b:NameList>
|
||||
</b:Author>
|
||||
</b:Author>
|
||||
<b:Title>Dockyards of the north</b:Title>
|
||||
<b:Year>2019</b:Year>
|
||||
<b:URL>https://example.org/docks</b:URL>
|
||||
<b:YearAccessed>2024</b:YearAccessed>
|
||||
<b:MonthAccessed>May</b:MonthAccessed>
|
||||
<b:DayAccessed>17</b:DayAccessed>
|
||||
<b:StandardNumber>10.1234/x</b:StandardNumber>
|
||||
</b:Source>
|
||||
<b:Source>
|
||||
<b:Tag>NoDate</b:Tag>
|
||||
<b:SourceType>JournalArticle</b:SourceType>
|
||||
<b:Title>Untitled notes</b:Title>
|
||||
<b:JournalName>Field Notes</b:JournalName>
|
||||
<b:Volume>4</b:Volume>
|
||||
</b:Source>
|
||||
<b:Source>
|
||||
<b:Tag>Riv21</b:Tag>
|
||||
<b:SourceType>Book</b:SourceType>
|
||||
<b:Title>A duplicate of Riv21</b:Title>
|
||||
<b:Year>2021</b:Year>
|
||||
</b:Source>
|
||||
<b:Source>
|
||||
<b:SourceType>Book</b:SourceType>
|
||||
</b:Source>
|
||||
</b:Sources>"#;
|
||||
|
||||
/// A Word `Sources.xml` (`b:Sources`): its style, the `b:Source` elements with their names,
|
||||
/// kinds and fallback fields; a repeated tag and an entry with no content at all are dropped.
|
||||
#[test]
|
||||
fn a_word_sources_file_reads_into_sources() {
|
||||
let f = read_sources(WORD_SOURCES.as_bytes()).unwrap();
|
||||
assert_eq!(f.style.as_deref(), Some("MLA"), "the citation style comes from StyleName");
|
||||
assert_eq!(
|
||||
f.sources.len(),
|
||||
3,
|
||||
"the repeated tag and the empty b:Source are dropped: {:?}",
|
||||
f.sources.iter().map(|s| &s.tag).collect::<Vec<_>>()
|
||||
);
|
||||
let book = &f.sources[0];
|
||||
assert_eq!((book.tag.as_str(), book.kind.as_str()), ("Riv21", "book"));
|
||||
assert_eq!(book.author, "Rivera, Alex; Chen, Mei L.");
|
||||
assert_eq!(book.title, "Shared Spaces", "the first source of a repeated tag wins");
|
||||
assert_eq!(
|
||||
(book.title.as_str(), book.year.as_str(), book.city.as_str(), book.publisher.as_str()),
|
||||
("Shared Spaces", "2021", "Portland", "Harbor Books")
|
||||
);
|
||||
let web = &f.sources[1];
|
||||
assert_eq!(web.kind, "website");
|
||||
assert_eq!(web.author, "Okafor, Ada");
|
||||
assert_eq!(web.accessed, "2024-May-17", "assemble the accessed date from its parts");
|
||||
assert_eq!(web.doi, "10.1234/x", "StandardNumber stands in for a missing DOI");
|
||||
let notes = &f.sources[2];
|
||||
assert_eq!((notes.kind.as_str(), notes.journal.as_str(), notes.volume.as_str()), ("article", "Field Notes", "4"));
|
||||
}
|
||||
|
||||
/// Anything whose root isn't `b:Sources` is refused, not read as an empty bibliography.
|
||||
#[test]
|
||||
fn a_file_that_is_not_b_sources_is_an_error() {
|
||||
assert!(read_sources(b"<html><body>hi</body></html>").is_err());
|
||||
assert!(matches!(read_sources(b"not xml at all"), Err(DocxError::Xml(_))));
|
||||
assert!(read_sources(b"<Sources><Source><Tag>A</Tag></Source></Sources>").is_err(), "no namespace");
|
||||
assert!(read_sources(br#"<o:Sources xmlns:o="urn:example:other"><o:Source/></o:Sources>"#).is_err(), "an unrelated namespace");
|
||||
}
|
||||
|
||||
/// 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]
|
||||
fn word_2004_bibliography_namespace_is_accepted() {
|
||||
let xml = br#"<b:Sources xmlns:b="http://schemas.microsoft.com/office/word/2004/10/bibliography" StyleName="MLA">
|
||||
<b:Source><b:Tag>Riv21</b:Tag><b:SourceType>Book</b:SourceType><b:Title>Shared Spaces</b:Title><b:Year>2021</b:Year></b:Source>
|
||||
</b:Sources>"#;
|
||||
let f = read_sources(xml).unwrap();
|
||||
assert_eq!(f.style.as_deref(), Some("MLA"));
|
||||
assert_eq!(f.sources.len(), 1);
|
||||
assert_eq!(f.sources[0].tag, "Riv21");
|
||||
}
|
||||
}
|
||||
+101
-18
@@ -5,7 +5,9 @@
|
||||
//!
|
||||
//! Reading unpacks the parts into an in-memory [`Package`] and hands it to the DOCX reader;
|
||||
//! writing runs the DOCX writer and lays its parts out in one file. Bounded like the zip reader:
|
||||
//! part count, part size, total size and base64 length are capped.
|
||||
//! every part counts against the part count, part size and total size limits whether it's kept
|
||||
//! or not (a repeated name), data is checked before it's copied, and nesting and elements are
|
||||
//! capped as the XML tree reader caps a part's.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
@@ -92,8 +94,24 @@ struct Part {
|
||||
data: Option<Data>,
|
||||
}
|
||||
|
||||
/// Longest base64 text of one part (the size limit, encoded; whitespace isn't kept).
|
||||
const MAX_BASE64: u64 = (MAX_PART / 3 + 1) * 4;
|
||||
/// Deepest element nesting read: a part's inline XML starts below the package, part and data
|
||||
/// elements and may nest as deep as the XML tree reader takes ([`xml::MAX_DEPTH`] open elements
|
||||
/// and an empty one inside the deepest).
|
||||
const MAX_NESTING: usize = 3 + xml::MAX_DEPTH + 1;
|
||||
|
||||
/// Most elements in one child of the package: a part, its data element and as many as the XML
|
||||
/// tree reader takes in a part ([`xml::MAX_ELEMENTS`]).
|
||||
const MAX_PART_ELEMENTS: usize = 2 + xml::MAX_ELEMENTS;
|
||||
|
||||
/// Longest base64 text of a part that decodes to at most `bytes` (whitespace isn't kept).
|
||||
fn max_base64(bytes: u64) -> u64 {
|
||||
(bytes / 3).saturating_add(1).saturating_mul(4)
|
||||
}
|
||||
|
||||
/// Bytes the next part may have: its own size limit, or what's left of the package's.
|
||||
fn room(total: u64) -> u64 {
|
||||
MAX_PART.min(MAX_TOTAL.saturating_sub(total))
|
||||
}
|
||||
|
||||
/// Unpack a Flat OPC file into its parts, with a `[Content_Types].xml` made from the parts'
|
||||
/// content types (unless the file carries one).
|
||||
@@ -103,6 +121,9 @@ pub(crate) fn unpack(bytes: &[u8]) -> Result<Package, DocxError> {
|
||||
let mut r = NsReader::from_reader(src);
|
||||
r.config_mut().check_end_names = true;
|
||||
let mut depth = 0usize;
|
||||
// Children of the package (parts or anything else), and elements in the current one.
|
||||
let mut entries = 0usize;
|
||||
let mut elements = 0usize;
|
||||
let mut files: BTreeMap<String, Vec<u8>> = BTreeMap::new();
|
||||
let mut types: Vec<(String, String)> = Vec::new();
|
||||
let mut total: u64 = 0;
|
||||
@@ -118,15 +139,26 @@ pub(crate) fn unpack(bytes: &[u8]) -> Result<Package, DocxError> {
|
||||
Event::Start(ref e) | Event::Empty(ref e) => {
|
||||
let empty = matches!(ev, Event::Empty(_));
|
||||
depth += 1;
|
||||
if depth > MAX_NESTING {
|
||||
return Err(DocxError::Limit("XML nesting too deep".into()));
|
||||
}
|
||||
if depth == 2 {
|
||||
entries += 1;
|
||||
elements = 0;
|
||||
if entries > MAX_ENTRIES {
|
||||
return Err(DocxError::Limit(format!("more than {MAX_ENTRIES} parts")));
|
||||
}
|
||||
}
|
||||
elements += 1;
|
||||
if elements > MAX_PART_ELEMENTS {
|
||||
return Err(DocxError::Limit("too many XML elements".into()));
|
||||
}
|
||||
let local = e.local_name().as_ref().to_vec();
|
||||
match depth {
|
||||
1 if !(in_pkg && local == b"package") => {
|
||||
return Err(DocxError::NotWord("the root element isn't a Flat OPC pkg:package".into()));
|
||||
}
|
||||
2 if in_pkg && local == b"part" => {
|
||||
if files.len() + usize::from(part.is_some()) >= MAX_ENTRIES {
|
||||
return Err(DocxError::Limit(format!("more than {MAX_ENTRIES} parts")));
|
||||
}
|
||||
let name = attr(e, b"name").unwrap_or_default();
|
||||
let content_type = attr(e, b"contentType").unwrap_or_default();
|
||||
part = Some(Part { name, content_type, data: None });
|
||||
@@ -167,7 +199,12 @@ pub(crate) fn unpack(bytes: &[u8]) -> Result<Package, DocxError> {
|
||||
&& let Some(Data::Xml(start)) = p.data
|
||||
{
|
||||
p.data = None;
|
||||
let xml = with_scope(src.get(start..before).unwrap_or_default(), &scope);
|
||||
let frag = src.get(start..before).unwrap_or_default();
|
||||
// Refused before it's copied when it can't fit.
|
||||
if frag.len() as u64 > room(total) {
|
||||
return Err(DocxError::Limit(format!("part {} is larger than the size limit", p.name)));
|
||||
}
|
||||
let xml = with_scope(frag, &scope);
|
||||
store(&p.name, &p.content_type, xml, &mut files, &mut types, &mut total)?;
|
||||
}
|
||||
if depth == 2
|
||||
@@ -183,15 +220,18 @@ pub(crate) fn unpack(bytes: &[u8]) -> Result<Package, DocxError> {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Event::Text(ref t) if depth == 3 => {
|
||||
if let Some(Part { data: Some(Data::Binary(b64)), .. }) = part.as_mut() {
|
||||
let s = std::str::from_utf8(t.as_ref()).map_err(|e| DocxError::Xml(e.to_string()))?;
|
||||
b64.extend(s.chars().filter(|c| !c.is_ascii_whitespace()));
|
||||
if b64.len() as u64 > MAX_BASE64 {
|
||||
return Err(DocxError::Limit("a binary part is larger than the size limit".into()));
|
||||
Event::Text(ref t) if depth == 3 => add_base64(&mut part, t, total)?,
|
||||
Event::CData(ref t) if depth == 3 => add_base64(&mut part, t, total)?,
|
||||
Event::GeneralRef(ref g) if depth == 3 => match g.resolve_char_ref() {
|
||||
// A character reference stands for its character (`A` is an `A`).
|
||||
Ok(Some(c)) => add_base64(&mut part, c.encode_utf8(&mut [0; 4]).as_bytes(), total)?,
|
||||
// An entity isn't base64: binary data holding one is broken, not shorter.
|
||||
_ => {
|
||||
if let Some(Part { name, data: Some(Data::Binary(_)), .. }) = &part {
|
||||
return Err(DocxError::Xml(format!("part {name} has invalid base64 data")));
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
Event::Eof => break,
|
||||
_ => {}
|
||||
}
|
||||
@@ -225,7 +265,21 @@ fn finish_part(p: Part, files: &mut BTreeMap<String, Vec<u8>>, types: &mut Vec<(
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a part, within the size limits; a repeated or empty name is ignored.
|
||||
/// Text (or CDATA) inside a part's `pkg:binaryData`: its base64 without whitespace, checked
|
||||
/// against the size limits before it's copied.
|
||||
fn add_base64(part: &mut Option<Part>, text: &[u8], total: u64) -> Result<(), DocxError> {
|
||||
let Some(Part { data: Some(Data::Binary(b64)), .. }) = part else { return Ok(()) };
|
||||
let s = std::str::from_utf8(text).map_err(|e| DocxError::Xml(e.to_string()))?;
|
||||
let more = s.bytes().filter(|b| !b.is_ascii_whitespace()).count();
|
||||
if b64.len().saturating_add(more) as u64 > max_base64(room(total)) {
|
||||
return Err(DocxError::Limit("a binary part is larger than the size limit".into()));
|
||||
}
|
||||
b64.extend(s.chars().filter(|c| !c.is_ascii_whitespace()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add a part, within the size limits; a repeated or empty name isn't stored but counts against
|
||||
/// them all the same (as a repeated zip entry does).
|
||||
fn store(
|
||||
name: &str,
|
||||
content_type: &str,
|
||||
@@ -235,9 +289,6 @@ fn store(
|
||||
total: &mut u64,
|
||||
) -> Result<(), DocxError> {
|
||||
let name = name.trim_start_matches('/');
|
||||
if name.is_empty() || files.contains_key(name) {
|
||||
return Ok(());
|
||||
}
|
||||
let len = bytes.len() as u64;
|
||||
if len > MAX_PART {
|
||||
return Err(DocxError::Limit(format!("part {name} is larger than the size limit")));
|
||||
@@ -246,6 +297,9 @@ fn store(
|
||||
if *total > MAX_TOTAL {
|
||||
return Err(DocxError::Limit("the package is larger than the size limit".into()));
|
||||
}
|
||||
if name.is_empty() || files.contains_key(name) {
|
||||
return Ok(());
|
||||
}
|
||||
if !content_type.is_empty() && !content_type.contains(['\r', '\n']) {
|
||||
types.push((name.to_string(), content_type.to_string()));
|
||||
}
|
||||
@@ -408,4 +462,33 @@ mod tests {
|
||||
assert_eq!(with_scope(own, &scope), own.to_vec());
|
||||
assert_eq!(with_scope(b"no element", &scope), b"no element".to_vec());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nesting_is_capped_where_the_xml_tree_reader_stops() {
|
||||
let inline = |levels: usize| format!("{}<b/>{}", "<a>".repeat(levels), "</a>".repeat(levels));
|
||||
let package = |levels: usize| {
|
||||
format!(
|
||||
"<pkg:package xmlns:pkg=\"{PKG_NS}\"><pkg:part pkg:name=\"/a.xml\"><pkg:xmlData>{}</pkg:xmlData></pkg:part></pkg:package>",
|
||||
inline(levels)
|
||||
)
|
||||
};
|
||||
assert!(xml::parse(inline(xml::MAX_DEPTH).as_bytes()).is_ok());
|
||||
assert!(unpack(package(xml::MAX_DEPTH).as_bytes()).is_ok());
|
||||
assert!(matches!(xml::parse(inline(xml::MAX_DEPTH + 1).as_bytes()), Err(DocxError::Limit(_))));
|
||||
assert!(matches!(unpack(package(xml::MAX_DEPTH + 1).as_bytes()), Err(DocxError::Limit(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_and_nameless_parts_count_against_the_size_limits() {
|
||||
let (mut files, mut types, mut total) = (BTreeMap::new(), Vec::new(), 0u64);
|
||||
for name in ["/a.bin", "/a.bin", ""] {
|
||||
store(name, "", vec![0; 10], &mut files, &mut types, &mut total).unwrap();
|
||||
}
|
||||
assert_eq!((files.len(), total), (1, 30));
|
||||
// Near the package limit, what's left of it is all the next part may have.
|
||||
total = MAX_TOTAL - 5;
|
||||
assert_eq!(room(total), 5);
|
||||
assert_eq!(max_base64(5), 8);
|
||||
assert!(matches!(store("/b.bin", "", vec![0; 6], &mut files, &mut types, &mut total), Err(DocxError::Limit(_))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
//! Fonts a file embeds through its font table (#1171).
|
||||
//!
|
||||
//! The reader keeps the font table and its font parts as opaque passthrough parts, for writing
|
||||
//! back. [`embedded_fonts`] hands their font files to whoever lays the document out, undoing the
|
||||
//! obfuscation Word applies to them (ECMA-376 Part 1, 17.8.1).
|
||||
|
||||
use wordcraft_doc::Document;
|
||||
|
||||
use crate::package::{EMBEDDED_PARTS, EmbeddedManifest, FONT_TABLE_PART};
|
||||
|
||||
/// Most font files taken from one document.
|
||||
const MAX_FONTS: usize = 64;
|
||||
|
||||
/// The font table's references to embedded font files, one per style.
|
||||
const EMBEDS: [&str; 4] = ["w:embedRegular", "w:embedBold", "w:embedItalic", "w:embedBoldItalic"];
|
||||
|
||||
/// A font file a document embeds, and the name its font table gives the font: the name the text
|
||||
/// asks for, which a subset Word embeds doesn't carry inside (#2240).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct EmbeddedFont {
|
||||
/// `w:font/@w:name`.
|
||||
pub name: String,
|
||||
/// The font file, de-obfuscated.
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
/// The font files `doc`'s font table embeds, de-obfuscated, in table order. Not checked to be
|
||||
/// fonts: that is up to the font database. Empty for a document read from anything but a package
|
||||
/// with a font table.
|
||||
pub fn embedded_fonts(doc: &Document) -> Vec<EmbeddedFont> {
|
||||
let Some(table) = doc.passthrough.get(FONT_TABLE_PART).and_then(|b| crate::xml::parse(b).ok()) else {
|
||||
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 {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
let embeds = table
|
||||
.children("w:font")
|
||||
.flat_map(|f| f.els().filter(|e| EMBEDS.contains(&e.name.as_str())).map(move |e| (f.attr("w:name").unwrap_or(""), e)));
|
||||
for (name, embed) in embeds {
|
||||
if out.len() >= MAX_FONTS {
|
||||
log::warn!("docx: more than {MAX_FONTS} embedded fonts; the rest are not loaded");
|
||||
break;
|
||||
}
|
||||
let Some(rel) = embed.attr("r:id").and_then(|id| part.rels.iter().find(|r| r.id == id && !r.external)) else {
|
||||
continue;
|
||||
};
|
||||
let Some(bytes) = doc.passthrough.get(&rel.target) else {
|
||||
continue;
|
||||
};
|
||||
// No key: the file is stored as is, as with the all-zero key.
|
||||
let key = match embed.attr("w:fontKey") {
|
||||
Some(k) => match font_key(k) {
|
||||
Some(key) => key,
|
||||
None => {
|
||||
log::warn!("docx: embedded font {} has an unreadable key {k:?}", rel.target);
|
||||
continue;
|
||||
}
|
||||
},
|
||||
None => [0; 16],
|
||||
};
|
||||
let mut data = bytes.to_vec();
|
||||
for (i, b) in data.iter_mut().take(32).enumerate() {
|
||||
*b ^= key.get(i % 16).copied().unwrap_or(0);
|
||||
}
|
||||
out.push(EmbeddedFont { name: name.to_string(), data });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The obfuscation key of a `w:fontKey` GUID (`{XXXXXXXX-XXXX-…}`): its 16 bytes, reversed.
|
||||
fn font_key(guid: &str) -> Option<[u8; 16]> {
|
||||
let digits: Vec<u8> = guid.bytes().filter(|c| !matches!(c, b'{' | b'}' | b'-')).collect();
|
||||
if digits.len() != 32 {
|
||||
return None;
|
||||
}
|
||||
let mut key = [0u8; 16];
|
||||
for (i, pair) in digits.chunks(2).enumerate() {
|
||||
let byte = u8::from_str_radix(std::str::from_utf8(pair).ok()?, 16).ok()?;
|
||||
*key.get_mut(15 - i)? = byte;
|
||||
}
|
||||
Some(key)
|
||||
}
|
||||
@@ -7,10 +7,12 @@
|
||||
//! error or a best-effort document, never a panic.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod bibliography;
|
||||
mod chart_spec;
|
||||
mod crypt;
|
||||
mod custom;
|
||||
mod flat;
|
||||
mod fonts;
|
||||
mod package;
|
||||
mod read;
|
||||
mod smart_art;
|
||||
@@ -20,12 +22,14 @@ mod wordml2003_write;
|
||||
mod write;
|
||||
mod xml;
|
||||
|
||||
pub use bibliography::{MAX_SOURCES, Sources, read_sources};
|
||||
pub use chart_spec::{chart_items, chart_xml};
|
||||
pub use crypt::{
|
||||
DEFAULT_SPIN_COUNT, MAX_PASSWORD_CHARS, PasswordCheck, check_password, check_protection_password, decrypt, encrypt, encrypt_with_spin_count,
|
||||
is_encrypted, protection_hash,
|
||||
};
|
||||
pub use flat::{XmlKind, sniff_xml, write as write_flat};
|
||||
pub use fonts::{EmbeddedFont, embedded_fonts};
|
||||
pub use read::read;
|
||||
pub use smart_art::smart_art_items;
|
||||
pub use wordml2003_write::write as write_wordml2003;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::io::{Cursor, Read, Write};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::DocxError;
|
||||
use crate::xml::{self, El};
|
||||
@@ -30,6 +31,8 @@ pub mod rt {
|
||||
pub const ENDNOTES: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes";
|
||||
pub const COMMENTS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments";
|
||||
pub const COMMENTS_EX: &str = "http://schemas.microsoft.com/office/2011/relationships/commentsExtended";
|
||||
pub const COMMENTS_IDS: &str = "http://schemas.microsoft.com/office/2016/09/relationships/commentsIds";
|
||||
pub const COMMENTS_EXTENSIBLE: &str = "http://schemas.microsoft.com/office/2018/08/relationships/commentsExtensible";
|
||||
pub const HEADER: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/header";
|
||||
pub const FOOTER: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer";
|
||||
pub const IMAGE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image";
|
||||
@@ -42,6 +45,38 @@ pub mod rt {
|
||||
pub const DIAGRAM_QUICK_STYLE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagramQuickStyle";
|
||||
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";
|
||||
}
|
||||
|
||||
/// Content type of a glossary document part (ECMA-376 Part 1 §11.3.8).
|
||||
pub const GLOSSARY_CONTENT_TYPE: &str = "application/vnd.openxmlformats-officedocument.wordprocessingml.document.glossary+xml";
|
||||
/// `Document::passthrough` key: a fingerprint of `Document::glossary` as read, so a save can tell
|
||||
/// an untouched glossary (written back verbatim) from an edited one (written from the model).
|
||||
/// Not a package part: WordCraft's own bookkeeping.
|
||||
pub const GLOSSARY_READ: &str = "wordcraft:glossary.read";
|
||||
/// Most building blocks read from one glossary.
|
||||
pub const MAX_GLOSSARY_ENTRIES: usize = 10_000;
|
||||
/// The glossary entry types `w:type` takes (ST_DocPartType); others aren't kept.
|
||||
pub const GLOSSARY_TYPES: [&str; 7] = ["none", "normal", "autoExp", "toolbar", "speller", "formFld", "bbPlcHdr"];
|
||||
/// The insertion behaviours `w:behavior` takes (ST_DocPartBehavior); others aren't kept.
|
||||
pub const GLOSSARY_BEHAVIORS: [&str; 3] = ["content", "p", "pg"];
|
||||
|
||||
/// A fingerprint of `glossary` (FNV-1a over its debug form): stable across runs and builds that
|
||||
/// keep the model's shape, and cheap (nothing is allocated).
|
||||
pub fn glossary_fingerprint(glossary: &[wordcraft_doc::BuildingBlock]) -> String {
|
||||
struct Fnv(u64);
|
||||
impl std::fmt::Write for Fnv {
|
||||
fn write_str(&mut self, s: &str) -> std::fmt::Result {
|
||||
for b in s.bytes() {
|
||||
self.0 = (self.0 ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
let mut h = Fnv(0xcbf2_9ce4_8422_2325);
|
||||
// Writing into `Fnv` never fails.
|
||||
let _ = std::fmt::Write::write_fmt(&mut h, format_args!("{glossary:?}"));
|
||||
format!("{:016x}", h.0)
|
||||
}
|
||||
|
||||
/// `Document::passthrough` key for a macro project, kept as opaque bytes (never parsed or run).
|
||||
@@ -58,9 +93,26 @@ pub const MAX_VBA_RELATED: usize = 64;
|
||||
/// types and own relationships, as [`EmbeddedManifest`] lines. Each part's bytes are kept under its
|
||||
/// part name. Not a package part: WordCraft's own bookkeeping.
|
||||
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";
|
||||
/// 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";
|
||||
/// The original `word/settings.xml` when it holds settings the model doesn't (merged into on
|
||||
/// save by `write::settings_merge`). Not a package part: WordCraft's own bookkeeping.
|
||||
pub const SETTINGS_ORIG: &str = "wordcraft:settings.orig";
|
||||
/// Extended properties (`docProps/app.xml`) the model doesn't hold but a save keeps
|
||||
/// ([`APP_KEPT_PROPS`]): `name\tvalue` lines. Not a package part: WordCraft's own bookkeeping.
|
||||
pub const APP_KEPT: &str = "wordcraft:app.kept";
|
||||
/// The `docProps/app.xml` properties carried over from the file. Counts (pages, lines,
|
||||
/// characters, editing time) and the producing application's version are not: they describe
|
||||
/// the file Word saved, not the one WordCraft writes.
|
||||
pub const APP_KEPT_PROPS: &[&str] =
|
||||
&["Template", "Manager", "Company", "HyperlinkBase", "ScaleCrop", "LinksUpToDate", "SharedDoc", "HyperlinksChanged"];
|
||||
/// Parts WordCraft doesn't model, carried verbatim: `owner\ttype\tpart` lines (owner `P` for
|
||||
/// the package, `D` for the main document), with their bytes and relationships in the
|
||||
/// [`EmbeddedManifest`]. Not a package part: WordCraft's own bookkeeping.
|
||||
pub const UNMODELLED_ROOTS: &str = "wordcraft:unmodelled.roots";
|
||||
/// Most parts we carry along for a file's embedded objects.
|
||||
pub const MAX_EMBEDDED_PARTS: usize = 4096;
|
||||
/// Most relationships we keep for one such part.
|
||||
@@ -135,9 +187,10 @@ pub fn rel_is(t: &str, full: &str) -> bool {
|
||||
t == full || tail(t) == tail(full)
|
||||
}
|
||||
|
||||
/// The decompressed parts of a package (bounded).
|
||||
/// The decompressed parts of a package (bounded). Shared, so a part the document keeps as it is (a
|
||||
/// picture, an embedded object) is handed over rather than copied.
|
||||
pub struct Package {
|
||||
files: BTreeMap<String, Vec<u8>>,
|
||||
files: BTreeMap<String, Arc<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl Package {
|
||||
@@ -179,7 +232,7 @@ impl Package {
|
||||
return Err(DocxError::Limit(format!("part {name} decompresses past the size limit")));
|
||||
}
|
||||
total = total.saturating_add(buf.len() as u64);
|
||||
files.insert(name, buf);
|
||||
files.insert(name, Arc::new(buf));
|
||||
}
|
||||
Ok(Package { files })
|
||||
}
|
||||
@@ -187,16 +240,22 @@ impl Package {
|
||||
/// A package from parts already in memory (a Flat OPC file, a converted document), by part
|
||||
/// name without the leading `/`.
|
||||
pub fn from_parts(files: BTreeMap<String, Vec<u8>>) -> Package {
|
||||
Package { files }
|
||||
Package { files: files.into_iter().map(|(name, bytes)| (name, Arc::new(bytes))).collect() }
|
||||
}
|
||||
|
||||
/// Part bytes by name (leading `/` ignored; case-insensitive fallback).
|
||||
pub fn get(&self, name: &str) -> Option<&[u8]> {
|
||||
self.get_shared(name).map(|v| v.as_slice())
|
||||
}
|
||||
|
||||
/// [`Package::get`], as the shared bytes themselves: for a part the document keeps whole, which
|
||||
/// then costs no copy (a picture document's pictures used to be held twice while it opened).
|
||||
pub fn get_shared(&self, name: &str) -> Option<&Arc<Vec<u8>>> {
|
||||
let n = name.trim_start_matches('/');
|
||||
if let Some(v) = self.files.get(n) {
|
||||
return Some(v);
|
||||
}
|
||||
self.files.iter().find(|(k, _)| k.eq_ignore_ascii_case(n)).map(|(_, v)| v.as_slice())
|
||||
self.files.iter().find(|(k, _)| k.eq_ignore_ascii_case(n)).map(|(_, v)| v)
|
||||
}
|
||||
|
||||
pub fn xml(&self, name: &str) -> Result<Option<El>, DocxError> {
|
||||
@@ -381,6 +440,27 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_document_shares_its_pictures_with_the_package() {
|
||||
// Each picture was copied out of the package, so a picture document held its pictures
|
||||
// twice while it opened, and left the package's copies behind as freed memory.
|
||||
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" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture""#;
|
||||
let document = format!(
|
||||
r#"<w:document {ns}><w:body><w:p><w:r><w:drawing><wp:inline><wp:extent cx="635000" cy="635000"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:blipFill><a:blip r:embed="rIdImg"/></pic:blipFill></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p></w:body></w:document>"#
|
||||
);
|
||||
let rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdImg" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/pic.png"/></Relationships>"#;
|
||||
let z = zip_of(&[
|
||||
("word/document.xml", document.into_bytes()),
|
||||
("word/_rels/document.xml.rels", rels.as_bytes().to_vec()),
|
||||
("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 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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_targets() {
|
||||
assert_eq!(resolve("word", "media/image1.png"), "word/media/image1.png");
|
||||
|
||||
@@ -79,7 +79,8 @@ pub(crate) fn color(e: &El, theme: &[Rgb]) -> Option<Rgb> {
|
||||
/// A preset colour (`a:prstClr/@val`, ECMA-376 Part 1 §20.1.10.48): the common ones; others are
|
||||
/// unknown (`None`).
|
||||
pub(crate) fn preset_color(name: &str) -> Option<Rgb> {
|
||||
Some(match name {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
Some(match lower.as_str() {
|
||||
"black" => Rgb(0, 0, 0),
|
||||
"white" => Rgb(0xFF, 0xFF, 0xFF),
|
||||
"red" => Rgb(0xFF, 0, 0),
|
||||
@@ -91,8 +92,8 @@ pub(crate) fn preset_color(name: &str) -> Option<Rgb> {
|
||||
"magenta" | "fuchsia" => Rgb(0xFF, 0, 0xFF),
|
||||
"gray" | "grey" => Rgb(0x80, 0x80, 0x80),
|
||||
"silver" => Rgb(0xC0, 0xC0, 0xC0),
|
||||
"ltGray" | "ltGrey" => Rgb(0xD3, 0xD3, 0xD3),
|
||||
"dkGray" | "dkGrey" => Rgb(0xA9, 0xA9, 0xA9),
|
||||
"ltgray" | "ltgrey" | "lightgray" | "lightgrey" => Rgb(0xD3, 0xD3, 0xD3),
|
||||
"dkgray" | "dkgrey" | "darkgray" | "darkgrey" => Rgb(0xA9, 0xA9, 0xA9),
|
||||
"maroon" => Rgb(0x80, 0, 0),
|
||||
"navy" => Rgb(0, 0, 0x80),
|
||||
"olive" => Rgb(0x80, 0x80, 0),
|
||||
@@ -101,6 +102,18 @@ pub(crate) fn preset_color(name: &str) -> Option<Rgb> {
|
||||
"orange" => Rgb(0xFF, 0xA5, 0),
|
||||
"brown" => Rgb(0xA5, 0x2A, 0x2A),
|
||||
"pink" => Rgb(0xFF, 0xC0, 0xCB),
|
||||
"gold" => Rgb(0xFF, 0xD7, 0),
|
||||
"darkblue" | "dkblue" => Rgb(0, 0, 0x8B),
|
||||
"darkred" | "dkred" => Rgb(0x8B, 0, 0),
|
||||
"darkgreen" | "dkgreen" => Rgb(0, 0x64, 0),
|
||||
"lightblue" | "ltblue" => Rgb(0xAD, 0xD8, 0xE6),
|
||||
"coral" => Rgb(0xFF, 0x7F, 0x50),
|
||||
"salmon" => Rgb(0xFA, 0x80, 0x72),
|
||||
"tomato" => Rgb(0xFF, 0x63, 0x47),
|
||||
"violet" => Rgb(0xEE, 0x82, 0xEE),
|
||||
"indigo" => Rgb(0x4B, 0, 0x82),
|
||||
"beige" => Rgb(0xF5, 0xF5, 0xDC),
|
||||
"ivory" => Rgb(0xFF, 0xFF, 0xF0),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -241,4 +254,16 @@ mod tests {
|
||||
let b = el("a:srgbClr", r#"val="808080""#, r#"<a:lumOff val="50000"/><a:lumMod val="50000"/>"#);
|
||||
assert_ne!(color(&a, &theme), color(&b, &theme));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preset_colors_are_case_insensitive_and_include_common_names() {
|
||||
assert_eq!(preset_color("red"), Some(Rgb(255, 0, 0)));
|
||||
assert_eq!(preset_color("Red"), Some(Rgb(255, 0, 0)));
|
||||
assert_eq!(preset_color("darkBlue"), Some(Rgb(0, 0, 0x8B)));
|
||||
assert_eq!(preset_color("dkBlue"), Some(Rgb(0, 0, 0x8B)));
|
||||
assert_eq!(preset_color("lightGray"), Some(Rgb(0xD3, 0xD3, 0xD3)));
|
||||
assert_eq!(preset_color("ltGray"), Some(Rgb(0xD3, 0xD3, 0xD3)));
|
||||
assert_eq!(preset_color("gold"), Some(Rgb(0xFF, 0xD7, 0)));
|
||||
assert_eq!(preset_color("unknownColor"), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,48 @@ use crate::package::{
|
||||
};
|
||||
use crate::xml::{El, MAX_DEPTH, NAMESPACES, esc};
|
||||
|
||||
/// Relationship types (last path segment) the reader models or the writer produces itself, so
|
||||
/// they are never carried verbatim: carrying them would duplicate or contradict the written part.
|
||||
/// Types the main document refers to by id (headers, pictures, objects…) are listed too.
|
||||
pub const HANDLED_RELS: &[&str] = &[
|
||||
"officeDocument",
|
||||
"core-properties",
|
||||
"extended-properties",
|
||||
"custom-properties",
|
||||
"styles",
|
||||
"numbering",
|
||||
"settings",
|
||||
"theme",
|
||||
"fontTable",
|
||||
"footnotes",
|
||||
"endnotes",
|
||||
"comments",
|
||||
"commentsExtended",
|
||||
"commentsIds",
|
||||
"commentsExtensible",
|
||||
"customXml",
|
||||
"vbaProject",
|
||||
"header",
|
||||
"footer",
|
||||
"image",
|
||||
"hyperlink",
|
||||
"oleObject",
|
||||
"package",
|
||||
"chart",
|
||||
"chartEx",
|
||||
"diagramData",
|
||||
"diagramDrawing",
|
||||
"diagramLayout",
|
||||
"diagramQuickStyle",
|
||||
"diagramColors",
|
||||
"video",
|
||||
"audio",
|
||||
"media",
|
||||
"control",
|
||||
"subDocument",
|
||||
"ink",
|
||||
];
|
||||
|
||||
/// Most relationship ids one object's markup may use.
|
||||
const MAX_IDS: usize = 64;
|
||||
|
||||
@@ -35,6 +77,12 @@ impl Reader<'_> {
|
||||
if !self.doc.sources.is_empty() && self.xml(&rel.target).ok().flatten().is_some_and(|r| r.local() == "Sources") {
|
||||
continue;
|
||||
}
|
||||
// Native ink shares the custom XML relationship type, but its part belongs to the
|
||||
// drawing that refers to it: kept with it, or dropped with it (no orphan part).
|
||||
let pkg = self.pkg;
|
||||
if self.content_types.get_or_insert_with(|| ContentTypes::read(pkg)).of(&rel.target) == Some("application/inkml+xml") {
|
||||
continue;
|
||||
}
|
||||
if self.keep_part_tree(&rel.target) {
|
||||
roots.push_str(&format!("{owner}\t{}\t{}\n", rel.kind, rel.target));
|
||||
}
|
||||
@@ -45,6 +93,42 @@ impl Reader<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the parts the package and the main document relate to that WordCraft neither models
|
||||
/// nor writes itself (web settings, styles-with-effects, add-in task panes, the thumbnail, a
|
||||
/// glossary…), with every part they reach, so saving carries them unchanged. Skipped: the
|
||||
/// relationship types the writer produces or the reader models ([`HANDLED_RELS`]) and any
|
||||
/// relationship the main document's markup refers to by id (its target goes with the
|
||||
/// element, or not at all).
|
||||
pub(super) fn keep_unmodelled(&mut self, package: &Rels, document: &Rels) {
|
||||
let pkg = self.pkg;
|
||||
let main = pkg.get(&self.main).unwrap_or_default();
|
||||
let referenced = |id: &str| {
|
||||
let needle = format!("\"{id}\"");
|
||||
main.windows(needle.len()).any(|w| w == needle.as_bytes())
|
||||
};
|
||||
let mut roots = String::new();
|
||||
// One glossary per main document (its first relationship; see `read_glossary`).
|
||||
let mut glossary = false;
|
||||
for (owner, rels) in [("P", package), ("D", document)] {
|
||||
for rel in rels.list.iter().filter(|r| !r.external).take(MAX_EMBEDDED_RELS) {
|
||||
let tail = rel.kind.rsplit('/').next().unwrap_or("");
|
||||
if HANDLED_RELS.contains(&tail)
|
||||
|| (owner == "D" && referenced(&rel.id))
|
||||
|| (owner == "D" && tail == "glossaryDocument" && std::mem::replace(&mut glossary, true))
|
||||
|| ![&rel.kind, &rel.target].iter().all(|s| manifest_field_ok(s))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if self.keep_part_tree(&rel.target) {
|
||||
roots.push_str(&format!("{owner}\t{}\t{}\n", rel.kind, rel.target));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !roots.is_empty() {
|
||||
self.doc.passthrough.insert(crate::package::UNMODELLED_ROOTS.into(), Arc::new(roots.into_bytes()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep `el` (an object's `a:graphic` or `w:object`, read with `rels`) and the parts it refers
|
||||
/// to, so the object can be written back. `extra` names relationships the object's parts use
|
||||
/// by id although its markup doesn't (a SmartArt drawing, named in the diagram's data part).
|
||||
@@ -118,14 +202,14 @@ impl Reader<'_> {
|
||||
log::warn!("docx: more than {MAX_EMBEDDED_PARTS} parts for embedded objects; the rest are dropped");
|
||||
return false;
|
||||
}
|
||||
let Some(bytes) = self.pkg.get(part) else {
|
||||
let Some(bytes) = self.pkg.get_shared(part) else {
|
||||
log::warn!("docx: part {part} of an embedded object is missing");
|
||||
return false;
|
||||
};
|
||||
let pkg = self.pkg;
|
||||
let ct = self.content_types.get_or_insert_with(|| ContentTypes::read(pkg)).of(part).unwrap_or("application/octet-stream");
|
||||
let content_type = if manifest_field_ok(ct) { ct.to_string() } else { "application/octet-stream".to_string() };
|
||||
self.doc.passthrough.entry(part.to_string()).or_insert_with(|| Arc::new(bytes.to_vec()));
|
||||
self.doc.passthrough.entry(part.to_string()).or_insert_with(|| bytes.clone());
|
||||
self.embedded.parts.insert(part.to_string(), crate::package::EmbeddedPart { content_type, rels: Vec::new() });
|
||||
true
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Freeform shapes (DrawingML custom geometry, `a:custGeom`) and ink strokes drawn with them.
|
||||
//! Freeform shapes (DrawingML custom geometry, `a:custGeom`; legacy VML paths) and ink strokes
|
||||
//! drawn with them.
|
||||
|
||||
use wordcraft_doc::freeform::{FreePath, Freeform, InkTool, MAX_PATHS, MAX_POINTS};
|
||||
|
||||
@@ -7,6 +8,10 @@ use crate::xml::El;
|
||||
|
||||
/// Straight pieces a Bézier curve is flattened into.
|
||||
const CURVE_STEPS: usize = 8;
|
||||
/// Points read from one path segment. A conformant `a:lnTo`/`a:moveTo` has one, `a:quadBezTo`
|
||||
/// two and `a:cubicBezTo` three; the cap bounds the de Casteljau control polygon so a segment
|
||||
/// stuffed with points can't make curve flattening quadratic in the point count.
|
||||
const MAX_SEG_POINTS: usize = 8;
|
||||
/// Largest path coordinate kept, in EMUs.
|
||||
const MAX_EMU: f64 = 1.0e12;
|
||||
|
||||
@@ -41,7 +46,7 @@ pub fn cust_geom(sppr: &El, w: f32, h: f32) -> Option<Freeform> {
|
||||
if cur.len() >= MAX_POINTS || done.len() >= MAX_PATHS {
|
||||
break;
|
||||
}
|
||||
let pts: Vec<[f64; 2]> = seg.children("a:pt").filter_map(pt).collect();
|
||||
let pts: Vec<[f64; 2]> = seg.children("a:pt").filter_map(pt).take(MAX_SEG_POINTS).collect();
|
||||
match seg.local() {
|
||||
"moveTo" => {
|
||||
if !cur.is_empty() {
|
||||
@@ -65,7 +70,10 @@ pub fn cust_geom(sppr: &El, w: f32, h: f32) -> Option<Freeform> {
|
||||
}
|
||||
}
|
||||
if !cur.is_empty() {
|
||||
done.push((cur, false));
|
||||
// A path drawn back to where it started is closed, though it has no `a:close` (#743:
|
||||
// Word's cover pages), so it's filled.
|
||||
let back = cur.len() > 2 && cur.first() == cur.last();
|
||||
done.push((cur, back));
|
||||
}
|
||||
for (pts, closed) in done.into_iter().take(MAX_PATHS.saturating_sub(paths.len())) {
|
||||
paths.push(FreePath { pts: pts.into_iter().take(MAX_POINTS).map(scale).collect(), closed: closed && !fill_none });
|
||||
@@ -75,6 +83,229 @@ pub fn cust_geom(sppr: &El, w: f32, h: f32) -> Option<Freeform> {
|
||||
(!f.paths.is_empty()).then_some(f)
|
||||
}
|
||||
|
||||
/// Largest VML path coordinate kept (coordinate-space units).
|
||||
const MAX_VML: f64 = 1.0e12;
|
||||
/// Characters of a VML `path` attribute read; the rest is ignored.
|
||||
const MAX_VML_PATH: usize = 1 << 20;
|
||||
/// Parameters of a VML path kept, all commands together.
|
||||
const MAX_VML_PARAMS: usize = MAX_POINTS * 8;
|
||||
|
||||
/// A legacy VML freeform (`v:shape` with a `path`, ECMA-376 Part 4 §14.1.2.19).
|
||||
pub struct VmlPath {
|
||||
pub freeform: Freeform,
|
||||
/// `nf` in the path: no fill.
|
||||
pub no_fill: bool,
|
||||
/// `ns` in the path: no outline.
|
||||
pub no_stroke: bool,
|
||||
}
|
||||
|
||||
/// The VML path commands that are two letters long.
|
||||
const VML_TWO: [&str; 11] = ["nf", "ns", "ae", "al", "at", "ar", "wa", "wr", "qx", "qy", "qb"];
|
||||
|
||||
/// The VML `path` of a shape, mapped onto its `w` × `h` frame (points) through its `coordsize`
|
||||
/// (default `1000,1000`) and `coordorigin` (default `0,0`). Commands: `m`/`t` move (absolute /
|
||||
/// relative), `l`/`r` line, `c`/`v` cubic Bézier (flattened), `x` close, `e` end, `nf`/`ns` no
|
||||
/// fill / no outline; arcs (`at`, `ar`, `wa`, `wr`) and quadrants (`qx`, `qy`) are drawn straight
|
||||
/// to their end, `qb` straight to its last point, and ellipses (`ae`, `al`) are skipped. An empty
|
||||
/// parameter is 0. `None` when the path has no points, or uses formulas (`@n`, from `v:formulas`,
|
||||
/// which aren't evaluated): such a shape isn't drawn rather than drawn wrong.
|
||||
pub fn vml_path(path: &str, coordsize: Option<&str>, coordorigin: Option<&str>, w: f32, h: f32) -> Option<VmlPath> {
|
||||
if path.contains('@') {
|
||||
return None;
|
||||
}
|
||||
let pair = |v: Option<&str>| -> Option<(f64, f64)> {
|
||||
let v = v?;
|
||||
let (a, b) = v.split_once(',').unwrap_or((v, ""));
|
||||
let n = |s: &str| int(s).map(|i| (i as f64).clamp(-MAX_VML, MAX_VML));
|
||||
Some((n(a)?, n(b).unwrap_or(0.0)))
|
||||
};
|
||||
let size = |v: f64| if v > 0.0 { v } else { 1000.0 };
|
||||
let (cw, ch) = pair(coordsize).map_or((1000.0, 1000.0), |(a, b)| (size(a), size(b)));
|
||||
let (ox, oy) = pair(coordorigin).unwrap_or((0.0, 0.0));
|
||||
let (sx, sy) = (w as f64 / cw, h as f64 / ch);
|
||||
let scale = |[x, y]: [f64; 2]| -> [f32; 2] { [((x - ox) * sx) as f32, ((y - oy) * sy) as f32] };
|
||||
|
||||
let mut d = Draw::default();
|
||||
let (mut no_fill, mut no_stroke) = (false, false);
|
||||
for (cmd, params) in vml_commands(path) {
|
||||
if d.full() {
|
||||
break;
|
||||
}
|
||||
let pts: Vec<[f64; 2]> = params.as_chunks::<2>().0.to_vec();
|
||||
match cmd.as_str() {
|
||||
"m" | "t" => {
|
||||
for (i, &p) in pts.iter().enumerate() {
|
||||
let p = if cmd == "t" { d.rel(p) } else { p };
|
||||
if i == 0 {
|
||||
d.move_to(p);
|
||||
} else {
|
||||
d.line_to(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
"l" | "r" | "qx" | "qy" => {
|
||||
for &p in &pts {
|
||||
let p = if cmd == "r" { d.rel(p) } else { p };
|
||||
d.line_to(p);
|
||||
}
|
||||
}
|
||||
"c" | "v" => {
|
||||
for c in pts.as_chunks::<3>().0 {
|
||||
let start = d.at;
|
||||
let ctrl: Vec<[f64; 2]> = std::iter::once(start).chain(c.iter().map(|&p| if cmd == "v" { d.rel(p) } else { p })).collect();
|
||||
for i in 1..=CURVE_STEPS {
|
||||
d.line_to(bezier(&ctrl, i as f64 / CURVE_STEPS as f64));
|
||||
}
|
||||
}
|
||||
}
|
||||
"qb" => {
|
||||
if let Some(&p) = pts.last() {
|
||||
d.line_to(p);
|
||||
}
|
||||
}
|
||||
// Bounding box, then start and end: `at`/`wa` draw a line to the start, `ar`/`wr` move there.
|
||||
"at" | "ar" | "wa" | "wr" => {
|
||||
for [_, _, s, e] in pts.as_chunks::<4>().0 {
|
||||
if matches!(cmd.as_str(), "ar" | "wr") {
|
||||
d.move_to(*s);
|
||||
} else {
|
||||
d.line_to(*s);
|
||||
}
|
||||
d.line_to(*e);
|
||||
}
|
||||
}
|
||||
"x" => d.end(true),
|
||||
"e" => d.end(false),
|
||||
"nf" => no_fill = true,
|
||||
"ns" => no_stroke = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
d.end(false);
|
||||
let paths = d.done.into_iter().map(|(pts, closed)| FreePath { pts: pts.into_iter().map(scale).collect(), closed }).collect();
|
||||
let freeform = Freeform { w, h, paths, alpha: 1.0, ink: None, order: None }.sanitized();
|
||||
(!freeform.paths.is_empty()).then_some(VmlPath { freeform, no_fill, no_stroke })
|
||||
}
|
||||
|
||||
/// The pen while a VML path is drawn: where it is, where the subpath began, the subpath being
|
||||
/// drawn and those done (closed or not).
|
||||
#[derive(Default)]
|
||||
struct Draw {
|
||||
at: [f64; 2],
|
||||
start: [f64; 2],
|
||||
cur: Vec<[f64; 2]>,
|
||||
done: Vec<(Vec<[f64; 2]>, bool)>,
|
||||
}
|
||||
|
||||
impl Draw {
|
||||
fn full(&self) -> bool {
|
||||
self.done.len() >= MAX_PATHS
|
||||
}
|
||||
|
||||
fn rel(&self, [x, y]: [f64; 2]) -> [f64; 2] {
|
||||
[(self.at[0] + x).clamp(-MAX_VML, MAX_VML), (self.at[1] + y).clamp(-MAX_VML, MAX_VML)]
|
||||
}
|
||||
|
||||
fn move_to(&mut self, p: [f64; 2]) {
|
||||
self.end(false);
|
||||
self.at = p;
|
||||
self.start = p;
|
||||
self.cur.push(p);
|
||||
}
|
||||
|
||||
fn line_to(&mut self, p: [f64; 2]) {
|
||||
if self.cur.is_empty() {
|
||||
self.cur.push(self.at);
|
||||
self.start = self.at;
|
||||
}
|
||||
if self.cur.len() < MAX_POINTS {
|
||||
self.cur.push(p);
|
||||
}
|
||||
self.at = p;
|
||||
}
|
||||
|
||||
/// Finish the subpath being drawn, closed back to its start or not.
|
||||
fn end(&mut self, closed: bool) {
|
||||
if !self.cur.is_empty() && !self.full() {
|
||||
self.done.push((std::mem::take(&mut self.cur), closed));
|
||||
}
|
||||
self.cur.clear();
|
||||
if closed {
|
||||
self.at = self.start;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The commands of a VML path with their parameters: a command is a letter (or one of
|
||||
/// [`VML_TWO`]) followed by numbers separated by commas or spaces. An empty parameter (nothing
|
||||
/// before a comma) is 0, and so is a formula reference (`@n`, not expected here: see
|
||||
/// [`vml_path`]); fractions are dropped. Anything else is skipped.
|
||||
fn vml_commands(path: &str) -> Vec<(String, Vec<f64>)> {
|
||||
let mut out: Vec<(String, Vec<f64>)> = Vec::new();
|
||||
let mut chars = path.chars().take(MAX_VML_PATH).peekable();
|
||||
// Whether a number was read since the command or the last comma.
|
||||
let mut has_num = true;
|
||||
let mut kept = 0usize;
|
||||
let mut push = |out: &mut Vec<(String, Vec<f64>)>, v: f64| {
|
||||
if kept < MAX_VML_PARAMS
|
||||
&& let Some((_, p)) = out.last_mut()
|
||||
{
|
||||
p.push(v);
|
||||
kept += 1;
|
||||
}
|
||||
};
|
||||
while let Some(c) = chars.next() {
|
||||
if c.is_ascii_alphabetic() {
|
||||
if !has_num {
|
||||
push(&mut out, 0.0);
|
||||
}
|
||||
has_num = false;
|
||||
let lower = c.to_ascii_lowercase();
|
||||
let mut cmd = lower.to_string();
|
||||
if let Some(&n) = chars.peek() {
|
||||
let two: String = [lower, n.to_ascii_lowercase()].iter().collect();
|
||||
if VML_TWO.contains(&two.as_str()) {
|
||||
chars.next();
|
||||
cmd = two;
|
||||
}
|
||||
}
|
||||
out.push((cmd, Vec::new()));
|
||||
} else if c == ',' {
|
||||
if !has_num {
|
||||
push(&mut out, 0.0);
|
||||
}
|
||||
has_num = false;
|
||||
} else if matches!(c, '@' | '-' | '+' | '.') || c.is_ascii_digit() {
|
||||
let mut v = c.to_digit(10).map_or(0.0, f64::from);
|
||||
let mut frac = c == '.';
|
||||
while let Some(&n) = chars.peek() {
|
||||
if n == '.' {
|
||||
frac = true;
|
||||
} else if n.is_ascii_digit() {
|
||||
if !frac {
|
||||
v = (v * 10.0 + n.to_digit(10).map_or(0.0, f64::from)).min(MAX_VML);
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
chars.next();
|
||||
}
|
||||
let v = match c {
|
||||
'@' => 0.0,
|
||||
'-' => -v,
|
||||
_ => v,
|
||||
};
|
||||
has_num = true;
|
||||
push(&mut out, v);
|
||||
}
|
||||
// Whitespace and anything else only separate numbers.
|
||||
}
|
||||
if !has_num {
|
||||
push(&mut out, 0.0);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The pen of an ink stroke written by WordCraft, from its drawing's name (`Ink Pen 3`,
|
||||
/// `Ink Highlighter 4`).
|
||||
pub fn ink_tool(name: &str) -> Option<InkTool> {
|
||||
@@ -115,3 +346,36 @@ fn bezier(c: &[[f64; 2]], t: f64) -> [f64; 2] {
|
||||
}
|
||||
p.first().copied().unwrap_or([0.0, 0.0])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sppr(seg: &str) -> crate::xml::El {
|
||||
let xml = format!(
|
||||
"<wps:spPr xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\" \
|
||||
xmlns:wps=\"http://schemas.microsoft.com/office/word/2010/wordprocessingShape\">\
|
||||
<a:custGeom><a:pathLst><a:path w=\"100\" h=\"100\">\
|
||||
<a:moveTo><a:pt x=\"0\" y=\"0\"/></a:moveTo>{seg}</a:path></a:pathLst></a:custGeom></wps:spPr>"
|
||||
);
|
||||
crate::xml::parse(xml.as_bytes()).expect("parse spPr")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bezier_segment_with_many_points_is_bounded() {
|
||||
// One cubicBezTo stuffed with 50 000 points must not make de Casteljau quadratic: the
|
||||
// control polygon is capped, so flattening stays a handful of points and returns at once.
|
||||
let pts = "<a:pt x=\"1\" y=\"1\"/>".repeat(50_000);
|
||||
let sppr = sppr(&format!("<a:cubicBezTo>{pts}</a:cubicBezTo>"));
|
||||
let f = cust_geom(&sppr, 100.0, 100.0).expect("a path");
|
||||
assert!(f.paths.iter().all(|p| p.pts.len() <= 1 + CURVE_STEPS), "{:?}", f.paths.iter().map(|p| p.pts.len()).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_conformant_cubic_still_flattens_to_a_curve() {
|
||||
let sppr = sppr("<a:cubicBezTo><a:pt x=\"0\" y=\"100\"/><a:pt x=\"100\" y=\"100\"/><a:pt x=\"100\" y=\"0\"/></a:cubicBezTo>");
|
||||
let f = cust_geom(&sppr, 100.0, 100.0).expect("a path");
|
||||
assert_eq!(f.paths.len(), 1);
|
||||
assert!(f.paths[0].pts.len() >= 2 && f.paths[0].pts.len() <= 1 + CURVE_STEPS);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
//! The glossary document (ECMA-376 Part 1 §17.12): the document's own building blocks (Quick
|
||||
//! Parts, AutoText…), read into `Document::glossary`.
|
||||
//!
|
||||
//! The glossary part and everything it reaches are also kept verbatim (`keep_unmodelled`), with a
|
||||
//! fingerprint of the entries as read ([`GLOSSARY_READ`]): a save writes the kept part while the
|
||||
//! entries are unchanged, so whatever the model doesn't hold survives, and writes the glossary from
|
||||
//! the model once they change.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::{Blocks, BuildingBlock};
|
||||
|
||||
use super::Reader;
|
||||
use super::story::StoryCtx;
|
||||
use crate::package::{
|
||||
ContentTypes, GLOSSARY_BEHAVIORS, GLOSSARY_CONTENT_TYPE, GLOSSARY_READ, GLOSSARY_TYPES, MAX_GLOSSARY_ENTRIES, Rels, glossary_fingerprint, rel_is,
|
||||
rt,
|
||||
};
|
||||
use crate::xml::El;
|
||||
|
||||
/// Longest name, gallery, category, description, guid or behaviour value kept (characters).
|
||||
const MAX_FIELD: usize = 1024;
|
||||
|
||||
/// The known values of `parent`'s `tag` children, each once, in file order.
|
||||
fn listed(parent: Option<&El>, tag: &str, known: &[&str]) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for v in parent.into_iter().flat_map(|p| p.children(tag)).filter_map(|e| e.attr("w:val")) {
|
||||
if known.contains(&v) && !out.iter().any(|o| o == v) {
|
||||
out.push(v.to_string());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl Reader<'_> {
|
||||
/// Read the glossary the main document relates to (`document`: its relationships). The first
|
||||
/// internal glossary relationship counts; one pointing at the main document, a part that isn't a
|
||||
/// glossary, or a missing one gives no entries. Entries without a name or a body are skipped.
|
||||
pub(super) fn read_glossary(&mut self, document: &Rels) {
|
||||
// The first internal one, as `keep_unmodelled` keeps.
|
||||
let Some(rel) = document.list.iter().find(|r| !r.external && rel_is(&r.kind, rt::GLOSSARY)) else { return };
|
||||
let mut entries = Vec::new();
|
||||
if !rel.target.eq_ignore_ascii_case(&self.main) {
|
||||
let pkg = self.pkg;
|
||||
let ct = self.content_types.get_or_insert_with(|| ContentTypes::read(pkg)).of(&rel.target).map(str::to_string);
|
||||
let root = match self.xml(&rel.target) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
log::warn!("docx: ignoring unreadable glossary {}: {e}", rel.target);
|
||||
None
|
||||
}
|
||||
};
|
||||
// Without content types (a converted package) the root element alone decides.
|
||||
if let Some(root) = root.filter(|r| r.name == "w:glossaryDocument" && ct.as_deref().is_none_or(|t| t == GLOSSARY_CONTENT_TYPE)) {
|
||||
let rels = self.pkg.rels(&rel.target);
|
||||
entries = self.doc_parts(&root, &rels);
|
||||
}
|
||||
}
|
||||
self.doc.glossary = entries;
|
||||
let print = glossary_fingerprint(&self.doc.glossary);
|
||||
self.doc.passthrough.insert(GLOSSARY_READ.into(), Arc::new(print.into_bytes()));
|
||||
}
|
||||
|
||||
/// The building blocks of a `w:glossaryDocument`, read with the glossary's relationships.
|
||||
fn doc_parts(&mut self, root: &El, rels: &Rels) -> Vec<BuildingBlock> {
|
||||
// The glossary's notes, comments, bookmarks, permissions and moves are its own: what the
|
||||
// main document's ids mean doesn't apply to it, and its ids mustn't leak into the body's.
|
||||
let footnotes = std::mem::take(&mut self.footnotes);
|
||||
let endnotes = std::mem::take(&mut self.endnotes);
|
||||
let comments = std::mem::take(&mut self.comment_map);
|
||||
let ended = std::mem::take(&mut self.comments_ended);
|
||||
let bookmarks = std::mem::take(&mut self.bookmarks);
|
||||
let perms = std::mem::take(&mut self.perms);
|
||||
let moves = std::mem::take(&mut self.moves);
|
||||
let mut out = Vec::new();
|
||||
for part in root.child("w:docParts").into_iter().flat_map(|d| d.children("w:docPart")) {
|
||||
if out.len() >= MAX_GLOSSARY_ENTRIES {
|
||||
log::warn!("docx: glossary has more than {MAX_GLOSSARY_ENTRIES} entries; the rest are dropped");
|
||||
break;
|
||||
}
|
||||
let pr = part.child("w:docPartPr");
|
||||
let val = |e: Option<&El>| e.and_then(|e| e.attr("w:val")).unwrap_or("").chars().take(MAX_FIELD).collect::<String>();
|
||||
let name = val(pr.and_then(|p| p.child("w:name")));
|
||||
let Some(body) = part.child("w:docPartBody").filter(|_| !name.is_empty()) else { continue };
|
||||
let category = pr.and_then(|p| p.child("w:category"));
|
||||
let mut sc = StoryCtx::default();
|
||||
let mut blocks = Blocks::new();
|
||||
self.read_blocks(&mut sc, body, rels, &mut blocks, 0);
|
||||
self.flush_pending(&mut sc, &mut blocks);
|
||||
if blocks.is_empty() {
|
||||
continue;
|
||||
}
|
||||
out.push(BuildingBlock {
|
||||
name,
|
||||
gallery: val(category.and_then(|c| c.child("w:gallery"))),
|
||||
category: val(category.and_then(|c| c.child("w:name"))),
|
||||
description: val(pr.and_then(|p| p.child("w:description"))),
|
||||
guid: val(pr.and_then(|p| p.child("w:guid"))),
|
||||
style: val(pr.and_then(|p| p.child("w:style"))),
|
||||
types: listed(pr.and_then(|p| p.child("w:types")), "w:type", &GLOSSARY_TYPES),
|
||||
behaviors: listed(pr.and_then(|p| p.child("w:behaviors")), "w:behavior", &GLOSSARY_BEHAVIORS),
|
||||
blocks,
|
||||
});
|
||||
}
|
||||
self.footnotes = footnotes;
|
||||
self.endnotes = endnotes;
|
||||
self.comment_map = comments;
|
||||
self.comments_ended = ended;
|
||||
self.bookmarks = bookmarks;
|
||||
self.perms = perms;
|
||||
self.moves = moves;
|
||||
out
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ use wordcraft_doc::math::{
|
||||
Arg, BrkBin, BrkBinSub, ColJc, FracKind, LimLoc, MAX_DEPTH, MNode, MRun, MScr, MSty, Math, MathJc, MathProps, ScriptKind, merge_runs,
|
||||
};
|
||||
use wordcraft_doc::props::Rgb;
|
||||
use wordcraft_doc::symbol_fonts;
|
||||
|
||||
use crate::xml::El;
|
||||
|
||||
@@ -12,6 +13,20 @@ pub fn read_omath(e: &El, jc: MathJc) -> Math {
|
||||
Math { nodes: merge_runs(arg(e, 0)), omml: e.to_xml(), jc, linear: false }
|
||||
}
|
||||
|
||||
/// A colour every math run shares, taken off the runs: it is the equation's own colour, as
|
||||
/// [`write_equation`](crate::write::math::write_equation) spreads it (#1467). The source OMML
|
||||
/// is kept: the writer replaces it only when the colour changes.
|
||||
pub fn lift_color(math: &mut Math) -> Option<Rgb> {
|
||||
let (mut shared, mut uniform) = (None, true);
|
||||
wordcraft_doc::math_edit::each_run_mut(&mut math.nodes, &mut |r| match shared {
|
||||
None => shared = Some(r.color),
|
||||
Some(c) => uniform &= c == r.color,
|
||||
});
|
||||
let c = shared.flatten().filter(|_| uniform)?;
|
||||
wordcraft_doc::math_edit::each_run_mut(&mut math.nodes, &mut |r| r.color = None);
|
||||
Some(c)
|
||||
}
|
||||
|
||||
/// `m:oMathParaPr/m:jc`.
|
||||
pub fn para_jc(para: &El) -> MathJc {
|
||||
match para.child("m:oMathParaPr").and_then(|p| p.child("m:jc")).and_then(|j| j.attr("m:val")) {
|
||||
@@ -218,12 +233,21 @@ fn node(k: &El, depth: usize, out: &mut Arg) {
|
||||
e: sub_arg(k, "m:e", d),
|
||||
});
|
||||
}
|
||||
// Tracked insertions show; deletions don't.
|
||||
"w:ins" | "w:moveTo" | "w:smartTag" | "w:customXml" | "m:oMath" if depth <= MAX_DEPTH => {
|
||||
// Tracked insertions show; deletions don't. Hyperlinks, simple fields and direction
|
||||
// wrappers (EG_PContentMath, §22.1.2.77) keep their runs (#1273).
|
||||
"w:ins" | "w:moveTo" | "w:smartTag" | "w:customXml" | "w:hyperlink" | "w:fldSimple" | "w:dir" | "w:bdo" | "m:oMath" if depth <= MAX_DEPTH => {
|
||||
for c in k.els() {
|
||||
node(c, d, out);
|
||||
}
|
||||
}
|
||||
// Markup compatibility: the math choice (we read math), else the fallback.
|
||||
"mc:AlternateContent" if depth <= MAX_DEPTH => {
|
||||
if let Some(c) = k.child("mc:Choice").or_else(|| k.child("mc:Fallback")) {
|
||||
for c in c.els() {
|
||||
node(c, d, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
"w:sdt" => {
|
||||
if let Some(c) = k.child("w:sdtContent") {
|
||||
for c in c.els() {
|
||||
@@ -235,13 +259,24 @@ fn node(k: &El, depth: usize, out: &mut Arg) {
|
||||
}
|
||||
}
|
||||
|
||||
/// `m:t` (and `w:t`) text of a run.
|
||||
/// `m:t` (and `w:t`) text of a run, with symbol characters (`w:sym`) and special hyphens.
|
||||
/// Text in a symbol font (Symbol, Wingdings…) becomes its Unicode equivalent: the math font has
|
||||
/// no glyphs at those private-use codes (#1273).
|
||||
fn run_text(r: &El) -> String {
|
||||
let font = r.child("w:rPr").and_then(|p| p.child("w:rFonts")).and_then(|f| f.attr("w:ascii").or_else(|| f.attr("w:hAnsi"))).unwrap_or("");
|
||||
let mut s = String::new();
|
||||
for c in r.els() {
|
||||
match c.name.as_str() {
|
||||
"m:t" | "w:t" => s.push_str(&c.text()),
|
||||
"m:t" | "w:t" => s.push_str(&symbol_fonts::map_text(font, &c.text())),
|
||||
"w:tab" => s.push('\t'),
|
||||
"w:noBreakHyphen" => s.push('\u{2011}'),
|
||||
"w:sym" => {
|
||||
let ch = c.attr("w:char").and_then(|h| u32::from_str_radix(h.trim(), 16).ok()).and_then(char::from_u32).filter(|c| *c as u32 >= 0x20);
|
||||
if let Some(ch) = ch {
|
||||
let f = c.attr("w:font").unwrap_or(font);
|
||||
s.push(symbol_fonts::symbol_char(f, ch).unwrap_or(ch));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -294,7 +329,10 @@ fn wrpr(pr: Option<&El>, out: &mut MRun) {
|
||||
out.color = Some(c);
|
||||
}
|
||||
if out.nor {
|
||||
if let Some(f) = pr.child("w:rFonts").and_then(|f| f.attr("w:ascii").or_else(|| f.attr("w:hAnsi"))) {
|
||||
// Symbol-font text was mapped to Unicode: it takes the text font.
|
||||
if let Some(f) =
|
||||
pr.child("w:rFonts").and_then(|f| f.attr("w:ascii").or_else(|| f.attr("w:hAnsi"))).filter(|f| !symbol_fonts::is_symbol_font(f))
|
||||
{
|
||||
out.font = Some(f.to_string());
|
||||
}
|
||||
let bold = pr.child("w:b").is_some_and(crate::units::on_off);
|
||||
@@ -350,4 +388,109 @@ mod tests {
|
||||
assert!(matches!(m.nodes[2], MNode::Nary { chr: '∫', .. }));
|
||||
assert!(matches!(&m.nodes[3], MNode::Matrix { rows, .. } if rows.len() == 1 && rows[0].len() == 2));
|
||||
}
|
||||
|
||||
/// Every run's text in the tree, in reading order.
|
||||
fn texts(m: &mut Math) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
wordcraft_doc::math_edit::each_run_mut(&mut m.nodes, &mut |r| out.push(r.text.clone()));
|
||||
out
|
||||
}
|
||||
|
||||
fn t(s: &str) -> String {
|
||||
format!("<m:r><m:t>{s}</m:t></m:r>")
|
||||
}
|
||||
|
||||
/// #1273: one equation using every Office Math element of ECMA-376 §22.1 (each argument
|
||||
/// holding a distinct marker) keeps every argument's text: nothing is silently dropped.
|
||||
#[test]
|
||||
fn every_omml_element_keeps_its_content() {
|
||||
let parts = [
|
||||
format!("<m:acc><m:accPr><m:chr m:val=\"̃\"/></m:accPr><m:e>{}</m:e></m:acc>", t("A1")),
|
||||
format!("<m:bar><m:barPr><m:pos m:val=\"top\"/></m:barPr><m:e>{}</m:e></m:bar>", t("B1")),
|
||||
format!("<m:box><m:boxPr><m:opEmu m:val=\"1\"/></m:boxPr><m:e>{}</m:e></m:box>", t("C1")),
|
||||
format!("<m:borderBox><m:borderBoxPr><m:strikeH/></m:borderBoxPr><m:e>{}</m:e></m:borderBox>", t("D1")),
|
||||
format!("<m:d><m:dPr><m:begChr m:val=\"[\"/></m:dPr><m:e>{}</m:e><m:e>{}</m:e></m:d>", t("E1"), t("E2")),
|
||||
format!("<m:eqArr><m:e>{}</m:e><m:e>{}</m:e></m:eqArr>", t("F1"), t("F2")),
|
||||
format!("<m:f><m:fPr><m:type m:val=\"skw\"/></m:fPr><m:num>{}</m:num><m:den>{}</m:den></m:f>", t("G1"), t("G2")),
|
||||
format!("<m:func><m:fName>{}</m:fName><m:e>{}</m:e></m:func>", t("H1"), t("H2")),
|
||||
format!("<m:groupChr><m:groupChrPr><m:chr m:val=\"⏞\"/><m:pos m:val=\"top\"/></m:groupChrPr><m:e>{}</m:e></m:groupChr>", t("I1")),
|
||||
format!("<m:limLow><m:e>{}</m:e><m:lim>{}</m:lim></m:limLow>", t("J1"), t("J2")),
|
||||
format!("<m:limUpp><m:e>{}</m:e><m:lim>{}</m:lim></m:limUpp>", t("K1"), t("K2")),
|
||||
format!("<m:m><m:mr><m:e>{}</m:e><m:e>{}</m:e></m:mr><m:mr><m:e>{}</m:e><m:e/></m:mr></m:m>", t("L1"), t("L2"), t("L3")),
|
||||
format!(
|
||||
"<m:nary><m:naryPr><m:chr m:val=\"∑\"/></m:naryPr><m:sub>{}</m:sub><m:sup>{}</m:sup><m:e>{}</m:e></m:nary>",
|
||||
t("M1"),
|
||||
t("M2"),
|
||||
t("M3")
|
||||
),
|
||||
format!("<m:phant><m:phantPr><m:show m:val=\"0\"/></m:phantPr><m:e>{}</m:e></m:phant>", t("N1")),
|
||||
format!("<m:rad><m:deg>{}</m:deg><m:e>{}</m:e></m:rad>", t("O1"), t("O2")),
|
||||
format!("<m:sPre><m:sub>{}</m:sub><m:sup>{}</m:sup><m:e>{}</m:e></m:sPre>", t("P1"), t("P2"), t("P3")),
|
||||
format!("<m:sSub><m:e>{}</m:e><m:sub>{}</m:sub></m:sSub>", t("Q1"), t("Q2")),
|
||||
format!("<m:sSubSup><m:e>{}</m:e><m:sub>{}</m:sub><m:sup>{}</m:sup></m:sSubSup>", t("R1"), t("R2"), t("R3")),
|
||||
format!("<m:sSup><m:e>{}</m:e><m:sup>{}</m:sup></m:sSup>", t("S1"), t("S2")),
|
||||
// Run-level wrappers allowed in math (EG_PContentMath): hyperlinks, simple fields,
|
||||
// tracked insertions, content controls, markup compatibility.
|
||||
format!("<w:hyperlink w:anchor=\"x\">{}</w:hyperlink>", t("T1")),
|
||||
format!("<w:fldSimple w:instr=\"QUOTE\">{}</w:fldSimple>", t("T2")),
|
||||
format!("<w:ins w:id=\"1\" w:author=\"a\">{}</w:ins>", t("T3")),
|
||||
format!("<w:sdt><w:sdtContent>{}</w:sdtContent></w:sdt>", t("T4")),
|
||||
format!(
|
||||
"<mc:AlternateContent><mc:Choice Requires=\"m\">{}</mc:Choice><mc:Fallback><w:r><w:t>picture</w:t></w:r></mc:Fallback></mc:AlternateContent>",
|
||||
t("T5")
|
||||
),
|
||||
"<w:r><w:t>T6</w:t></w:r>".to_string(),
|
||||
];
|
||||
let x = format!(
|
||||
r#"<m:oMath xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math" xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006">{}</m:oMath>"#,
|
||||
parts.concat()
|
||||
);
|
||||
let e = crate::xml::parse(x.as_bytes()).unwrap();
|
||||
let mut m = read_omath(&e, MathJc::CenterGroup);
|
||||
let got = texts(&mut m).concat();
|
||||
let markers = [
|
||||
"A1", "B1", "C1", "D1", "E1", "E2", "F1", "F2", "G1", "G2", "H1", "H2", "I1", "J1", "J2", "K1", "K2", "L1", "L2", "L3", "M1", "M2", "M3",
|
||||
"N1", "O1", "O2", "P1", "P2", "P3", "Q1", "Q2", "R1", "R2", "R3", "S1", "S2", "T1", "T2", "T3", "T4", "T5", "T6",
|
||||
];
|
||||
for k in markers {
|
||||
assert!(got.contains(k), "{k} dropped from {got}");
|
||||
}
|
||||
assert!(!got.contains("picture"), "the math choice wins over the fallback: {got}");
|
||||
}
|
||||
|
||||
/// #1273: deleted text stays out; symbol characters and symbol-font text become Unicode.
|
||||
#[test]
|
||||
fn symbols_and_deletions_in_math() {
|
||||
let e = omath(
|
||||
r#"<m:r><m:t>x</m:t></m:r><w:del w:id="1" w:author="a"><m:r><m:t>gone</m:t></m:r></w:del><m:r><w:sym w:font="Symbol" w:char="F061"/></m:r><m:r><w:rPr><w:rFonts w:ascii="Symbol" w:hAnsi="Symbol"/></w:rPr><m:t></m:t><w:noBreakHyphen/></m:r><w:r><w:rPr><w:rFonts w:ascii="Symbol"/></w:rPr><w:t></w:t></w:r>"#,
|
||||
);
|
||||
let mut m = read_omath(&e, MathJc::CenterGroup);
|
||||
let got = texts(&mut m).concat();
|
||||
assert_eq!(got, "xαβ\u{2011}γ");
|
||||
let mut font = None;
|
||||
wordcraft_doc::math_edit::each_run_mut(&mut m.nodes, &mut |r| {
|
||||
if r.nor {
|
||||
font = Some(r.font.clone());
|
||||
}
|
||||
});
|
||||
assert_eq!(font, Some(None), "mapped symbol text takes the text font");
|
||||
}
|
||||
|
||||
/// Hostile nesting through the wrappers is bounded (no stack overflow, no panic).
|
||||
#[test]
|
||||
fn deeply_nested_wrappers_are_bounded() {
|
||||
// Deeper than the math limit, within the XML parser's own depth limit.
|
||||
let n = 120;
|
||||
let inner = format!("{}{}{}", "<w:hyperlink>".repeat(n), t("deep"), "</w:hyperlink>".repeat(n));
|
||||
let e = omath(&inner);
|
||||
let _ = read_omath(&e, MathJc::CenterGroup);
|
||||
let inner = format!(
|
||||
"{}{}{}",
|
||||
"<mc:AlternateContent xmlns:mc=\"http://schemas.openxmlformats.org/markup-compatibility/2006\"><mc:Choice Requires=\"m\">".repeat(n),
|
||||
t("deep"),
|
||||
"</mc:Choice></mc:AlternateContent>".repeat(n)
|
||||
);
|
||||
let e = omath(&inner);
|
||||
let _ = read_omath(&e, MathJc::CenterGroup);
|
||||
}
|
||||
}
|
||||
|
||||
+210
-192
@@ -5,7 +5,8 @@ pub(crate) mod diagram;
|
||||
mod drawing_color;
|
||||
mod embed;
|
||||
mod freeform;
|
||||
mod math;
|
||||
mod glossary;
|
||||
pub(crate) mod math;
|
||||
mod props;
|
||||
mod sdt;
|
||||
mod shape_style;
|
||||
@@ -20,35 +21,34 @@ 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::{Style, StyleKind, StyleSheet, TableStyleParts};
|
||||
use wordcraft_doc::{Block, Blocks, Comment, Document, InlineObject, Paragraph, PartKind, Revision, RevisionKind, para_block};
|
||||
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;
|
||||
|
||||
/// 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;
|
||||
/// Bibliography sources kept from a file (Word's own source manager holds far fewer).
|
||||
const MAX_SOURCES: usize = 10_000;
|
||||
/// Custom XML parts looked at for bibliography sources.
|
||||
const MAX_BIBLIOGRAPHY_PARTS: usize = 64;
|
||||
/// Characters kept of a bibliography's citation style name.
|
||||
const MAX_STYLE_NAME: usize = 64;
|
||||
|
||||
/// Bibliography sources already read: tags, and (title, author) of untagged ones.
|
||||
#[derive(Default)]
|
||||
struct BibSeen {
|
||||
tags: HashSet<String>,
|
||||
untagged: HashSet<(String, String)>,
|
||||
}
|
||||
|
||||
/// Longest attached-template path we keep (bytes); a longer one is not a real path.
|
||||
const MAX_TEMPLATE_PATH: usize = 32 * 1024;
|
||||
@@ -62,6 +62,8 @@ pub(crate) struct Reader<'p> {
|
||||
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
|
||||
@@ -139,6 +141,7 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
|
||||
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,
|
||||
@@ -164,6 +167,7 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
|
||||
// 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);
|
||||
@@ -197,7 +201,9 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
|
||||
}
|
||||
if let Some(p) = part(rt::COMMENTS) {
|
||||
let ex = part(rt::COMMENTS_EX);
|
||||
lenient("comments", r.read_comments(&p, ex.as_deref()));
|
||||
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()));
|
||||
}
|
||||
// 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
|
||||
@@ -217,7 +223,7 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
|
||||
bib_parts.push(path);
|
||||
}
|
||||
}
|
||||
let mut seen_sources = BibSeen::default();
|
||||
let mut seen_sources = Seen::default();
|
||||
for path in &bib_parts {
|
||||
if r.doc.sources.len() >= MAX_SOURCES {
|
||||
break;
|
||||
@@ -228,6 +234,17 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
|
||||
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();
|
||||
@@ -243,6 +260,9 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
|
||||
|
||||
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)
|
||||
{
|
||||
@@ -252,12 +272,14 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
|
||||
// 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(&v).filter(|b| !b.is_empty())
|
||||
&& let Some(b) = pkg.get_shared(&v).filter(|b| !b.is_empty())
|
||||
{
|
||||
r.doc.passthrough.insert(VBA_PROJECT_PART.into(), Arc::new(b.to_vec()));
|
||||
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_rels, &rels);
|
||||
if !r.embedded.parts.is_empty() {
|
||||
let manifest = r.embedded.to_bytes();
|
||||
r.doc.passthrough.insert(EMBEDDED_PARTS.into(), Arc::new(manifest));
|
||||
@@ -418,9 +440,17 @@ fn toc_field_to_heading(body: &mut Blocks) {
|
||||
}
|
||||
let Some((field, props, end)) = body[i].as_para().and_then(|p| {
|
||||
let (field, end) = match p.object_at(0) {
|
||||
Some(InlineObject::FieldStart { instr, locked }) if is_toc(instr) => {
|
||||
(InlineObject::Field { instr: instr.clone(), result: String::new(), locked: *locked, code: false }, Some(toc_range_end(body, i)?))
|
||||
}
|
||||
Some(InlineObject::FieldStart { instr, locked, dirty }) if is_toc(instr) => (
|
||||
InlineObject::Field {
|
||||
instr: instr.clone(),
|
||||
result: String::new(),
|
||||
locked: *locked,
|
||||
dirty: *dirty,
|
||||
code: false,
|
||||
form: String::new(),
|
||||
},
|
||||
Some(toc_range_end(body, i)?),
|
||||
),
|
||||
Some(f @ InlineObject::Field { result, .. }) if result.is_empty() && has_toc(f) => (f.clone(), None),
|
||||
_ => return None,
|
||||
};
|
||||
@@ -539,7 +569,7 @@ impl Reader<'_> {
|
||||
log::warn!("docx: not carrying VBA-related part {path:?}");
|
||||
continue;
|
||||
}
|
||||
let Some(bytes) = self.pkg.get(path) else {
|
||||
let Some(bytes) = self.pkg.get_shared(path) else {
|
||||
log::warn!("docx: VBA-related part {path} is missing; keeping the project without it");
|
||||
continue;
|
||||
};
|
||||
@@ -549,13 +579,26 @@ impl Reader<'_> {
|
||||
}
|
||||
kept += 1;
|
||||
manifest.push_str(&format!("{}\t{path}\t{ct}\n", rel.kind));
|
||||
self.doc.passthrough.entry(path.to_string()).or_insert_with(|| Arc::new(bytes.to_vec()));
|
||||
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"`).
|
||||
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();
|
||||
}
|
||||
|
||||
fn xml(&self, path: &str) -> Result<Option<El>, DocxError> {
|
||||
self.pkg.xml(path)
|
||||
}
|
||||
@@ -591,7 +634,7 @@ impl Reader<'_> {
|
||||
if let Some(k) = self.media_by_path.get(&rel.target) {
|
||||
return Some(k.clone());
|
||||
}
|
||||
let bytes = self.pkg.get(&rel.target)?;
|
||||
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 };
|
||||
@@ -604,7 +647,7 @@ impl Reader<'_> {
|
||||
None => format!("{base}_{n}"),
|
||||
};
|
||||
}
|
||||
self.doc.media.insert(key.clone(), Arc::new(bytes.to_vec()));
|
||||
self.doc.media.insert(key.clone(), bytes.clone());
|
||||
self.media_by_path.insert(rel.target.clone(), key.clone());
|
||||
Some(key)
|
||||
}
|
||||
@@ -623,7 +666,7 @@ impl Reader<'_> {
|
||||
if let Some(id) = self.hf_by_path.get(path) {
|
||||
return Some(*id);
|
||||
}
|
||||
if self.doc.parts.len() >= MAX_PARTS {
|
||||
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.
|
||||
@@ -636,8 +679,11 @@ impl Reader<'_> {
|
||||
};
|
||||
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, 0);
|
||||
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()
|
||||
@@ -680,6 +726,9 @@ impl Reader<'_> {
|
||||
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");
|
||||
}
|
||||
self.pc.major_font = self.doc.settings.major_font.clone();
|
||||
self.pc.minor_font = self.doc.settings.minor_font.clone();
|
||||
@@ -728,6 +777,8 @@ impl Reader<'_> {
|
||||
}
|
||||
|
||||
fn read_styles(&mut self, path: &str) -> Result<(), DocxError> {
|
||||
/// Largest `w:latentStyles` kept (Word's has ~380 exceptions, ~40 KB).
|
||||
const MAX_LATENT_STYLES_XML: usize = 1 << 20;
|
||||
let Some(root) = self.xml(path)? else { return Ok(()) };
|
||||
// The default paragraph style must be called `Normal` in our model.
|
||||
let has_normal = root.children("w:style").any(|s| s.attr("w:styleId") == Some("Normal"));
|
||||
@@ -752,12 +803,11 @@ impl Reader<'_> {
|
||||
if sheet.default_chr.size.is_none() {
|
||||
sheet.default_chr.size = Some(10.0);
|
||||
}
|
||||
// Built-in styles' behaviour (gallery, priority, visibility), written back as is.
|
||||
sheet.latent_styles = root.child("w:latentStyles").map(|l| l.to_xml()).filter(|x| x.len() <= MAX_LATENT_STYLES_XML);
|
||||
for s in root.children("w:style").take(10_000) {
|
||||
let Some(raw_id) = s.attr("w:styleId").filter(|i| !i.is_empty()) else { continue };
|
||||
let id = self.pc.style_id(raw_id);
|
||||
if sheet.get(&id).is_some() {
|
||||
continue;
|
||||
}
|
||||
let kind = match s.attr("w:type").unwrap_or("paragraph") {
|
||||
"character" => StyleKind::Character,
|
||||
"table" => StyleKind::Table,
|
||||
@@ -778,6 +828,15 @@ impl Reader<'_> {
|
||||
hidden: flag(s, "w:semiHidden").unwrap_or(false) || flag(s, "w:hidden").unwrap_or(false),
|
||||
builtin: !s.attr("w:customStyle").is_some_and(|v| v == "1" || v == "true"),
|
||||
locked: flag(s, "w:locked").unwrap_or(false),
|
||||
// Only where the writer couldn't tell from the id (it flags the English ids).
|
||||
default: s.attr("w:default").is_some_and(|v| v == "1" || v == "true")
|
||||
&& !matches!(
|
||||
(kind, id.as_str()),
|
||||
(StyleKind::Paragraph, "Normal")
|
||||
| (StyleKind::Character, "DefaultParagraphFont")
|
||||
| (StyleKind::Table, "TableNormal")
|
||||
| (StyleKind::Numbering, "NoList")
|
||||
),
|
||||
..Default::default()
|
||||
};
|
||||
if let Some(p) = s.child("w:pPr") {
|
||||
@@ -790,7 +849,13 @@ impl Reader<'_> {
|
||||
let parts = self.table_style_parts(s, &mut st);
|
||||
st.table = (parts != TableStyleParts::default()).then_some(parts);
|
||||
}
|
||||
sheet.styles.push(st);
|
||||
// A style defined twice (some generators write their own Heading 1 after a
|
||||
// placeholder one) is the last definition, whole: Word drops the earlier one rather
|
||||
// than merging the two (observed with synthetic documents).
|
||||
match sheet.styles.iter_mut().find(|s| s.id == id) {
|
||||
Some(earlier) => *earlier = st,
|
||||
None => sheet.styles.push(st),
|
||||
}
|
||||
}
|
||||
// Styles that give only a list level take the list from the style they are based on
|
||||
// (Heading 3: level 3 of Heading 2's outline list).
|
||||
@@ -811,6 +876,10 @@ impl Reader<'_> {
|
||||
if sheet.default_para.numbering.is_some_and(|n| n.num == props::LEVEL_ONLY) {
|
||||
sheet.default_para.numbering = None;
|
||||
}
|
||||
// A producer that never writes `w:qFormat` (LibreOffice) still gets Word's usual gallery.
|
||||
if !sheet.styles.iter().any(|s| s.quick) {
|
||||
sheet.add_default_gallery();
|
||||
}
|
||||
self.doc.styles = sheet;
|
||||
Ok(())
|
||||
}
|
||||
@@ -828,56 +897,38 @@ impl Reader<'_> {
|
||||
t.band_size = tp.child_val("w:tblStyleRowBandSize").and_then(u32_of).filter(|n| *n > 0).map(|n| n.min(1000));
|
||||
}
|
||||
for c in s.children("w:tblStylePr").take(64) {
|
||||
let fill = c.child("w:tcPr").and_then(|p| p.child("w:shd")).and_then(|e| self.pc.shd_fill(e));
|
||||
let cell_borders = c.child("w:tcPr").and_then(|p| p.child("w:tcBorders")).map(props::borders);
|
||||
let chr = c.child("w:rPr").map(|r| self.pc.rpr(r)).unwrap_or_default();
|
||||
match c.attr("w:type") {
|
||||
Some("wholeTable") => {
|
||||
if let Some(p) = c.child("w:pPr") {
|
||||
st.para.overlay(&self.pc.ppr(p).0);
|
||||
}
|
||||
st.chr.overlay(&chr);
|
||||
if fill.is_some() {
|
||||
t.fill = fill;
|
||||
}
|
||||
if let Some(b) = c.child("w:tblPr").and_then(|p| p.child("w:tblBorders")) {
|
||||
t.borders = Some(props::borders(b));
|
||||
}
|
||||
let Some(part) = c.attr("w:type").and_then(TablePart::from_ooxml) else { continue };
|
||||
let tc = c.child("w:tcPr");
|
||||
let ppr = c.child("w:pPr").map(|p| self.pc.ppr(p).0);
|
||||
let f = PartFormat {
|
||||
chr: c.child("w:rPr").map(|r| self.pc.rpr(r)).unwrap_or_default(),
|
||||
fill: tc.and_then(|p| p.child("w:shd")).and_then(|e| self.pc.shd_fill(e)),
|
||||
borders: tc.and_then(|p| p.child("w:tcBorders")).map(props::borders),
|
||||
align: ppr.as_ref().and_then(|p| p.align),
|
||||
v_align: tc.and_then(|p| p.child_val("w:vAlign")).map(|v| match v {
|
||||
"center" => VAlign::Center,
|
||||
"bottom" => VAlign::Bottom,
|
||||
_ => VAlign::Top,
|
||||
}),
|
||||
};
|
||||
if part == TablePart::WholeTable {
|
||||
// The whole table's paragraph and run properties are the style's own; its
|
||||
// borders the table borders.
|
||||
if let Some(p) = ppr {
|
||||
st.para.overlay(&p);
|
||||
}
|
||||
Some("firstRow") => {
|
||||
t.header_fill = fill;
|
||||
t.header_chr = chr;
|
||||
t.header_borders = cell_borders;
|
||||
st.chr.overlay(&f.chr);
|
||||
if f.fill.is_some() {
|
||||
t.fill = f.fill;
|
||||
}
|
||||
Some("band1Horz") => {
|
||||
t.band_fill = fill;
|
||||
t.band_chr = chr;
|
||||
t.band_borders = cell_borders;
|
||||
if let Some(b) = c.child("w:tblPr").and_then(|p| p.child("w:tblBorders")) {
|
||||
t.borders = Some(props::borders(b));
|
||||
}
|
||||
Some("firstCol") => {
|
||||
t.first_col_chr = chr;
|
||||
t.first_col_fill = fill;
|
||||
t.first_col_borders = cell_borders;
|
||||
if f.v_align.is_some() {
|
||||
t.regions.insert(part, PartFormat { v_align: f.v_align, ..PartFormat::default() });
|
||||
}
|
||||
Some("lastCol") => {
|
||||
t.last_col_chr = chr;
|
||||
t.last_col_fill = fill;
|
||||
t.last_col_borders = cell_borders;
|
||||
}
|
||||
Some("lastRow") => {
|
||||
t.total_chr = chr;
|
||||
t.total_fill = fill;
|
||||
// The top rule is kept on its own (as built-in styles have it); the other edges
|
||||
// are the region's borders.
|
||||
t.total_border_top = cell_borders.and_then(|b| b.top);
|
||||
t.total_borders = cell_borders.map(|b| wordcraft_doc::props::Borders { top: None, ..b }).filter(|b| *b != Default::default());
|
||||
}
|
||||
Some("band1Vert") => {
|
||||
t.col_band_fill = fill;
|
||||
t.col_band_chr = chr;
|
||||
t.col_band_borders = cell_borders;
|
||||
}
|
||||
_ => {}
|
||||
} else {
|
||||
t.set_part(part, f);
|
||||
}
|
||||
}
|
||||
t
|
||||
@@ -899,7 +950,19 @@ impl Reader<'_> {
|
||||
if let Some(link) = a.child_val("w:numStyleLink") {
|
||||
links.push((id, link.to_string()));
|
||||
}
|
||||
abstracts.push(AbstractNum { id, name: a.child_val("w:name").map(str::to_string), levels });
|
||||
let hex = |v: &str| v.len() == 8 && v.bytes().all(|b| b.is_ascii_hexdigit());
|
||||
abstracts.push(AbstractNum {
|
||||
id,
|
||||
name: a.child_val("w:name").map(str::to_string),
|
||||
levels,
|
||||
nsid: a.child_val("w:nsid").filter(|v| hex(v)).map(str::to_ascii_uppercase),
|
||||
tmpl: a.child_val("w:tmpl").filter(|v| hex(v)).map(str::to_ascii_uppercase),
|
||||
multi_level_type: a
|
||||
.child_val("w:multiLevelType")
|
||||
// `multilevel` is what the writer derives for the nine levels read here.
|
||||
.filter(|v| matches!(*v, "singleLevel" | "hybridMultilevel"))
|
||||
.map(str::to_string),
|
||||
});
|
||||
}
|
||||
let mut nums = Vec::new();
|
||||
for n in root.children("w:num").take(10_000) {
|
||||
@@ -981,6 +1044,9 @@ impl Reader<'_> {
|
||||
|
||||
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() {
|
||||
@@ -997,6 +1063,7 @@ impl Reader<'_> {
|
||||
"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")
|
||||
@@ -1008,8 +1075,10 @@ impl Reader<'_> {
|
||||
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);
|
||||
}
|
||||
"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);
|
||||
@@ -1049,6 +1118,16 @@ impl Reader<'_> {
|
||||
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());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -1125,7 +1204,23 @@ impl Reader<'_> {
|
||||
*slot = Some(blocks);
|
||||
}
|
||||
|
||||
fn read_comments(&mut self, path: &str, ext: Option<&str>) -> Result<(), DocxError> {
|
||||
/// The extended properties a save carries over ([`crate::package::APP_KEPT_PROPS`]).
|
||||
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()));
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -1165,6 +1260,7 @@ impl Reader<'_> {
|
||||
parent: None,
|
||||
resolved: false,
|
||||
part,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
self.comment_map.insert(file_id, id);
|
||||
@@ -1182,131 +1278,53 @@ impl Reader<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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) = entries.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)) = (entries.get_mut(&cid), date) {
|
||||
c.date_utc = Some(date);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.doc.comments = entries;
|
||||
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`.
|
||||
fn read_bibliography(&mut self, path: &str, seen: &mut BibSeen) -> Result<(), DocxError> {
|
||||
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(());
|
||||
}
|
||||
if self.doc.settings.bib_style.is_none()
|
||||
&& let Some(name) = root.attrs.iter().find(|(k, _)| k.rsplit(':').next() == Some("StyleName")).map(|(_, v)| v.trim())
|
||||
&& !name.is_empty()
|
||||
{
|
||||
self.doc.settings.bib_style = Some(name.chars().take(MAX_STYLE_NAME).collect());
|
||||
}
|
||||
let child_text = |el: &El, local: &str| el.els().find(|e| e.local() == local).map(|e| e.text()).unwrap_or_default();
|
||||
let parse_persons = |parent: &El| -> String {
|
||||
let mut names = Vec::new();
|
||||
let person_els: Vec<&El> = {
|
||||
let in_lists: Vec<&El> =
|
||||
parent.els().filter(|e| e.local() == "NameList").flat_map(|n| n.els().filter(|e| e.local() == "Person")).collect();
|
||||
if in_lists.is_empty() { parent.els().filter(|e| e.local() == "Person").collect() } else { in_lists }
|
||||
};
|
||||
for name_el in person_els {
|
||||
let last = child_text(name_el, "Last");
|
||||
let first = child_text(name_el, "First");
|
||||
let middle = child_text(name_el, "Middle");
|
||||
let name = if !first.is_empty() && !middle.is_empty() {
|
||||
format!("{last}, {first} {middle}")
|
||||
} else if !first.is_empty() {
|
||||
format!("{last}, {first}")
|
||||
} else {
|
||||
last
|
||||
};
|
||||
if !name.is_empty() {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
names.join("; ")
|
||||
};
|
||||
|
||||
for src_el in root.els().filter(|e| e.local() == "Source") {
|
||||
if self.doc.sources.len() >= MAX_SOURCES {
|
||||
log::warn!("docx: more than {MAX_SOURCES} bibliography sources; the rest are dropped");
|
||||
break;
|
||||
}
|
||||
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") {
|
||||
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 ed = a.els().find(|e| e.local() == "Editor").map(parse_persons).unwrap_or_default();
|
||||
(auth, ed)
|
||||
}
|
||||
None => (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");
|
||||
let year = child_text(src_el, "Year");
|
||||
let month = child_text(src_el, "Month");
|
||||
let day = child_text(src_el, "Day");
|
||||
let city = child_text(src_el, "City");
|
||||
let publisher = child_text(src_el, "Publisher");
|
||||
let journal = child_text(src_el, "JournalName");
|
||||
let edition = child_text(src_el, "Edition");
|
||||
let volume = child_text(src_el, "Volume");
|
||||
let issue = child_text(src_el, "Issue");
|
||||
let pages = child_text(src_el, "Pages");
|
||||
let url = child_text(src_el, "URL");
|
||||
let accessed = match child_text(src_el, "Accessed") {
|
||||
a if !a.is_empty() => a,
|
||||
_ => {
|
||||
let y = child_text(src_el, "YearAccessed");
|
||||
let m = child_text(src_el, "MonthAccessed");
|
||||
let d = child_text(src_el, "DayAccessed");
|
||||
if !y.is_empty() && !m.is_empty() && !d.is_empty() {
|
||||
format!("{y}-{m}-{d}")
|
||||
} else if !y.is_empty() && !m.is_empty() {
|
||||
format!("{y}-{m}")
|
||||
} else {
|
||||
y
|
||||
}
|
||||
}
|
||||
};
|
||||
let doi = match child_text(src_el, "DOI") {
|
||||
d if !d.is_empty() => d,
|
||||
_ => child_text(src_el, "StandardNumber"),
|
||||
};
|
||||
|
||||
// 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() {
|
||||
continue;
|
||||
}
|
||||
let fresh = if tag.is_empty() {
|
||||
seen.untagged.insert((title.clone(), if author.is_empty() { editor.clone() } else { author.clone() }))
|
||||
} else {
|
||||
seen.tags.insert(tag.clone())
|
||||
};
|
||||
if fresh {
|
||||
self.doc.sources.push(wordcraft_doc::Source {
|
||||
tag,
|
||||
kind,
|
||||
author,
|
||||
title,
|
||||
year,
|
||||
city,
|
||||
publisher,
|
||||
journal,
|
||||
volume,
|
||||
pages,
|
||||
url,
|
||||
editor,
|
||||
edition,
|
||||
issue,
|
||||
month,
|
||||
day,
|
||||
accessed,
|
||||
doi,
|
||||
});
|
||||
}
|
||||
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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -4,16 +4,17 @@ use std::collections::HashMap;
|
||||
|
||||
use wordcraft_doc::para::cap_alt;
|
||||
use wordcraft_doc::props::{
|
||||
Align, Border, BorderStyle, Borders, CellProps, CharProps, HeightRule, Highlight, LineSpacing, NumRef, ParaProps, Rgb, RowProps, TabAlign,
|
||||
TabLeader, TabStop, TableFloat, TableLook, TableProps, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign,
|
||||
Align, Border, BorderStyle, Borders, CellProps, CharProps, CombineBrackets, EastAsianLayout, HeightRule, Highlight, LineSpacing, NumRef,
|
||||
ParaProps, Rgb, RowProps, TabAlign, TabLeader, TabStop, TableFloat, TableLook, TableProps, TextColor, TextDirection, Underline, VAlign, VMerge,
|
||||
VertAlign,
|
||||
};
|
||||
use wordcraft_doc::section::{
|
||||
Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, MAX_CHAR_SPACE, MAX_GRID_PITCH, NotePos, NoteProps, NoteRestart, NumFormat,
|
||||
SectionProps, SectionStart,
|
||||
PageVAlign, SectionProps, SectionStart,
|
||||
};
|
||||
|
||||
use super::drawing_color::adjust_lum;
|
||||
use crate::units::{int, measure, on_off, tw, u32_of};
|
||||
use crate::units::{int, line_multiple, measure, on_off, tw, u32_of};
|
||||
|
||||
/// A text frame (`w:framePr` without a drop cap, ECMA-376 §17.3.1.11), sizes and offsets in
|
||||
/// points (clamped), names kept as OOXML writes them.
|
||||
@@ -43,6 +44,9 @@ use crate::xml::El;
|
||||
pub struct PropCtx {
|
||||
pub major_font: String,
|
||||
pub minor_font: String,
|
||||
/// The theme's complex-script fonts (`a:cs`), which the `…Bidi` theme slots name.
|
||||
pub major_cs: String,
|
||||
pub minor_cs: String,
|
||||
/// The theme's colour scheme (dk1, lt1, dk2, lt2, accent1..6, hlink, folHlink); empty when the
|
||||
/// package has none, and then `w:themeColor` falls back to the fixed colour.
|
||||
pub theme_colors: Vec<Rgb>,
|
||||
@@ -82,7 +86,10 @@ impl PropCtx {
|
||||
}
|
||||
|
||||
fn theme_font(&self, t: &str) -> Option<String> {
|
||||
let f = if t.starts_with("major") { &self.major_font } else { &self.minor_font };
|
||||
let (latin, cs) = if t.starts_with("major") { (&self.major_font, &self.major_cs) } else { (&self.minor_font, &self.minor_cs) };
|
||||
// Measured in Word: `minorBidi`/`majorBidi` name the complex-script font, and an empty one
|
||||
// names nothing, so the font beside it or the inherited one applies.
|
||||
let f = if t.ends_with("Bidi") { cs } else { latin };
|
||||
(!f.is_empty()).then(|| f.clone())
|
||||
}
|
||||
|
||||
@@ -201,11 +208,8 @@ impl PropCtx {
|
||||
"w:szCs" => c.size_cs = k.attr("w:val").and_then(|v| measure(v, 2.0)).filter(|v| *v > 0.0).map(|v| v.clamp(1.0, 1638.0)),
|
||||
"w:highlight" => c.highlight = k.attr("w:val").map(Highlight::from_ooxml),
|
||||
"w:u" => {
|
||||
if let Some(v) = k.attr("w:val") {
|
||||
c.underline = Some(Underline::from_ooxml(v));
|
||||
} else {
|
||||
c.underline = Some(Underline::Single);
|
||||
}
|
||||
// w:val has no default in the schema: without it Word draws no underline
|
||||
c.underline = k.attr("w:val").map(Underline::from_ooxml);
|
||||
c.underline_color =
|
||||
self.theme_color(k, "w:themeColor", "w:themeTint", "w:themeShade").or_else(|| k.attr("w:color").and_then(Rgb::parse));
|
||||
}
|
||||
@@ -218,10 +222,15 @@ impl PropCtx {
|
||||
_ => VertAlign::Baseline,
|
||||
})
|
||||
}
|
||||
"w:eastAsianLayout" => {
|
||||
let l = east_asian_layout(k);
|
||||
c.east_asian_layout = (l != EastAsianLayout::default()).then_some(l);
|
||||
}
|
||||
"w:lang" => {
|
||||
c.lang = k.attr("w:val").filter(|v| !v.is_empty() && v.len() < 64).map(str::to_string);
|
||||
c.lang_bidi = k.attr("w:bidi").filter(|v| !v.is_empty() && v.len() < 64).map(str::to_string);
|
||||
}
|
||||
"w14:ligatures" => c.ligatures = k.attr("w14:val").and_then(wordcraft_doc::props::Ligatures::from_ooxml),
|
||||
n if n.starts_with("w14:") => {
|
||||
let fx = c.text_effects.get_or_insert_with(Default::default);
|
||||
super::wordart::read(k, &self.theme, fx);
|
||||
@@ -260,8 +269,11 @@ impl PropCtx {
|
||||
"w:autoSpaceDN" => p.auto_space_dn = Some(on_off(k)),
|
||||
"w:snapToGrid" => p.snap_to_grid = Some(on_off(k)),
|
||||
"w:framePr" => {
|
||||
if matches!(k.attr("w:dropCap"), Some("drop") | Some("margin")) {
|
||||
if let Some(kind @ ("drop" | "margin")) = k.attr("w:dropCap") {
|
||||
p.drop_cap = Some(k.attr("w:lines").and_then(u32_of).unwrap_or(3).clamp(1, 10) as u8);
|
||||
p.drop_cap_margin = (kind == "margin").then_some(true);
|
||||
p.drop_cap_distance =
|
||||
tw(k, "w:hSpace").filter(|d| d.is_finite() && *d > 0.0).map(|d| d.min(wordcraft_doc::resolve::MAX_DROP_CAP_DISTANCE));
|
||||
} else {
|
||||
p.frame = Some(text_frame(k));
|
||||
}
|
||||
@@ -306,7 +318,8 @@ impl PropCtx {
|
||||
p.line_spacing = match rule.as_str() {
|
||||
"exact" => measure(line, 20.0).map(|v| LineSpacing::Exactly(v.abs())),
|
||||
"atleast" => measure(line, 20.0).map(|v| LineSpacing::AtLeast(v.abs())),
|
||||
_ => measure(line, 240.0).map(|v| LineSpacing::Multiple(v.abs().clamp(0.06, 132.0))),
|
||||
// Strict OOXML writes the height with a unit (`13.90pt`, #2329).
|
||||
_ => line_multiple(line).map(|v| LineSpacing::Multiple(v.abs().clamp(0.06, 132.0))),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -366,14 +379,18 @@ fn tab_stop(t: &El) -> Option<TabStop> {
|
||||
"clear" => TabAlign::Clear,
|
||||
_ => TabAlign::Left,
|
||||
};
|
||||
let leader = match t.attr("w:leader").unwrap_or("none") {
|
||||
Some(TabStop { pos, align, leader: tab_leader(t.attr("w:leader").unwrap_or("none")) })
|
||||
}
|
||||
|
||||
/// A tab stop's or positional tab's `w:leader` (`ST_TabTlc`, `ST_PTabLeader`).
|
||||
pub(crate) fn tab_leader(v: &str) -> TabLeader {
|
||||
match v {
|
||||
"dot" => TabLeader::Dot,
|
||||
"hyphen" => TabLeader::Hyphen,
|
||||
"underscore" | "heavy" => TabLeader::Underscore,
|
||||
"middleDot" => TabLeader::MiddleDot,
|
||||
_ => TabLeader::None,
|
||||
};
|
||||
Some(TabStop { pos, align, leader })
|
||||
}
|
||||
}
|
||||
|
||||
/// `w:shd/@w:fill` (a solid fill colour; `auto` = none), ignoring theme fills ([`PropCtx::shd_fill`]).
|
||||
@@ -416,7 +433,11 @@ pub fn borders(e: &El) -> Borders {
|
||||
|
||||
/// Margins element (`w:tblCellMar`, `w:tcMar`) → [top, left, bottom, right].
|
||||
pub fn margins(e: &El) -> [f32; 4] {
|
||||
let get = |names: &[&str]| names.iter().find_map(|n| e.child(n)).and_then(|c| tw(c, "w:w")).unwrap_or(0.0).clamp(0.0, 1584.0);
|
||||
margin_sides(e).map(|side| side.unwrap_or(0.0))
|
||||
}
|
||||
|
||||
fn margin_sides(e: &El) -> [Option<f32>; 4] {
|
||||
let get = |names: &[&str]| names.iter().find_map(|n| e.child(n)).and_then(|c| tw(c, "w:w")).map(|v| v.clamp(0.0, 1584.0));
|
||||
[get(&["w:top"]), get(&["w:left", "w:start"]), get(&["w:bottom"]), get(&["w:right", "w:end"])]
|
||||
}
|
||||
|
||||
@@ -510,12 +531,24 @@ pub fn trpr(e: &El) -> RowProps {
|
||||
}
|
||||
"w:tblHeader" => r.header = on_off(k),
|
||||
"w:cantSplit" => r.cant_split = on_off(k),
|
||||
"w:gridBefore" => r.grid_before = k.attr("w:val").and_then(u32_of).unwrap_or(0).min(63),
|
||||
"w:gridAfter" => r.grid_after = k.attr("w:val").and_then(u32_of).unwrap_or(0).min(63),
|
||||
"w:wBefore" => r.w_before = gap_width(k),
|
||||
"w:wAfter" => r.w_after = gap_width(k),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// `w:wBefore`/`w:wAfter` in points: only a width in twips (`dxa`) is kept.
|
||||
fn gap_width(k: &El) -> Option<f32> {
|
||||
match k.attr("w:type") {
|
||||
None | Some("dxa") => tw(k, "w:w").filter(|v| *v >= 0.0).map(|v| v.min(crate::units::MAX_LEN_PT)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `w:tcPr`; returns the props and whether the cell is a legacy `hMerge` continuation.
|
||||
pub fn tcpr(pc: &PropCtx, e: &El) -> (CellProps, bool) {
|
||||
let mut c = CellProps { span: 1, ..Default::default() };
|
||||
@@ -539,14 +572,24 @@ pub fn tcpr(pc: &PropCtx, e: &El) -> (CellProps, bool) {
|
||||
"w:shd" => c.shading = pc.shd_fill(k),
|
||||
"w:noWrap" => c.no_wrap = on_off(k),
|
||||
"w:hideMark" => c.hide_mark = on_off(k),
|
||||
"w:tcMar" => c.margins = Some(margins(k)),
|
||||
"w:textDirection" => c.text_direction = TextDirection::from_ooxml(k.attr("w:val").unwrap_or("")),
|
||||
"w:tcMar" => {
|
||||
let sides = margin_sides(k);
|
||||
if sides.iter().all(Option::is_some) {
|
||||
c.margins = Some(sides.map(|side| side.unwrap_or(0.0)));
|
||||
} else {
|
||||
c.partial_margins = Some(sides);
|
||||
}
|
||||
}
|
||||
"w:textDirection" => {
|
||||
c.text_direction = TextDirection::from_ooxml(k.attr("w:val").unwrap_or(""));
|
||||
c.text_direction_set = k.attr("w:val") == Some("lrTb");
|
||||
}
|
||||
"w:vAlign" => {
|
||||
c.valign = match k.attr("w:val") {
|
||||
c.valign = Some(match k.attr("w:val") {
|
||||
Some("center") => VAlign::Center,
|
||||
Some("bottom") => VAlign::Bottom,
|
||||
_ => VAlign::Top,
|
||||
}
|
||||
})
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -629,14 +672,19 @@ pub fn sectpr(e: &El) -> (SectionProps, Vec<HfRef>) {
|
||||
}
|
||||
"w:footnotePr" => s.footnote_pr = note_pr(k, true),
|
||||
"w:endnotePr" => s.endnote_pr = note_pr(k, true),
|
||||
"w:formProt" => s.form_prot = Some(on_off(k)),
|
||||
"w:titlePg" => s.title_page = on_off(k),
|
||||
"w:bidi" => s.rtl = on_off(k),
|
||||
"w:textDirection" => s.text_direction = wordcraft_doc::props::TextDirection::from_ooxml(k.attr("w:val").unwrap_or("")),
|
||||
"w:textDirection" => {
|
||||
s.text_direction = wordcraft_doc::props::TextDirection::from_ooxml(k.attr("w:val").unwrap_or(""));
|
||||
s.text_direction_set = k.attr("w:val") == Some("lrTb");
|
||||
}
|
||||
"w:pgNumType" => {
|
||||
s.page_num_start = k.attr("w:start").and_then(u32_of);
|
||||
if let Some(f) = k.attr("w:fmt") {
|
||||
s.page_num_format = NumFormat::from_ooxml(f);
|
||||
}
|
||||
s.page_num_set = s.page_num_start.is_none() && s.page_num_format == NumFormat::Decimal;
|
||||
}
|
||||
"w:lnNumType" => {
|
||||
let mut l = LineNumbering::default();
|
||||
@@ -656,9 +704,10 @@ pub fn sectpr(e: &El) -> (SectionProps, Vec<HfRef>) {
|
||||
}
|
||||
"w:vAlign" => {
|
||||
s.valign = match k.attr("w:val") {
|
||||
Some("center") | Some("both") => VAlign::Center,
|
||||
Some("bottom") => VAlign::Bottom,
|
||||
_ => VAlign::Top,
|
||||
Some("center") => PageVAlign::Center,
|
||||
Some("both") => PageVAlign::Justify,
|
||||
Some("bottom") => PageVAlign::Bottom,
|
||||
_ => PageVAlign::Top,
|
||||
}
|
||||
}
|
||||
"w:pgBorders" => s.page_borders = Some(borders(k)),
|
||||
@@ -715,3 +764,22 @@ fn table_float(e: &El) -> TableFloat {
|
||||
overlap: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// `w:eastAsianLayout` (ISO/IEC 29500-1 §17.3.2.10). Attributes the schema doesn't allow (an id
|
||||
/// out of `xsd:int`, an unknown bracket, an on/off that isn't one) are left unset.
|
||||
fn east_asian_layout(e: &El) -> EastAsianLayout {
|
||||
let flag = |name: &str| {
|
||||
e.attr(name).and_then(|v| match v.trim() {
|
||||
"1" | "true" | "on" => Some(true),
|
||||
"0" | "false" | "off" => Some(false),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
EastAsianLayout {
|
||||
id: e.attr("w:id").and_then(int).and_then(|v| i32::try_from(v).ok()),
|
||||
combine: flag("w:combine"),
|
||||
combine_brackets: e.attr("w:combineBrackets").and_then(|v| CombineBrackets::from_ooxml(v.trim())),
|
||||
vert: flag("w:vert"),
|
||||
vert_compress: flag("w:vertCompress"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,9 @@ pub(crate) fn preset_kind(prst: &str) -> ShapeKind {
|
||||
"ellipse" | "wedgeEllipseCallout" | "cloudCallout" | "cloud" | "flowChartConnector" | "donut" | "flowChartOnlineStorage" => {
|
||||
ShapeKind::Ellipse
|
||||
}
|
||||
"triangle" | "rtTriangle" | "flowChartExtract" | "flowChartMerge" => ShapeKind::Triangle,
|
||||
"flowChartOr" => ShapeKind::FlowchartOr,
|
||||
"triangle" | "flowChartExtract" | "flowChartMerge" => ShapeKind::Triangle,
|
||||
"rtTriangle" => ShapeKind::RightTriangle,
|
||||
"diamond" | "flowChartDecision" => ShapeKind::Diamond,
|
||||
"line" | "lineInv" => ShapeKind::Line,
|
||||
"straightConnector1" => ShapeKind::StraightConnector,
|
||||
@@ -44,6 +46,65 @@ pub(crate) fn preset_kind(prst: &str) -> ShapeKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// The shape kind drawn for a legacy VML preset shape type `spt` (`o:spt`, the MSOSPT numbers of
|
||||
/// [MS-ODRAW] §2.4.24): that of the DrawingML preset of the same shape, through [`preset_kind`],
|
||||
/// so a preset WordCraft has no shape for is drawn as the nearest one; None for shapes with no
|
||||
/// preset geometry (freeforms, WordArt text, picture frames) and numbers MSOSPT doesn't define.
|
||||
pub(crate) fn vml_preset_kind(spt: u32) -> Option<ShapeKind> {
|
||||
let prst = match spt {
|
||||
// A text box's shapetype: a text box even without text, as DrawingML's `txBox`.
|
||||
202 => return Some(ShapeKind::TextBox),
|
||||
// Not primitive (freeform), WordArt text shapes, picture frame, host control.
|
||||
0 | 24..=31 | 75 | 136..=175 | 201 => return None,
|
||||
// Line and straight connector: corner to corner of the shape's box.
|
||||
20 | 32 => "line",
|
||||
2 => "roundRect",
|
||||
3 => "ellipse",
|
||||
4 => "diamond",
|
||||
5 => "triangle",
|
||||
6 => "rtTriangle",
|
||||
12 => "star5",
|
||||
13 => "rightArrow",
|
||||
21 => "plaque",
|
||||
23 => "donut",
|
||||
33 => "bentConnector2",
|
||||
34 => "bentConnector3",
|
||||
35 => "bentConnector4",
|
||||
36 => "bentConnector5",
|
||||
37 => "curvedConnector2",
|
||||
38 => "curvedConnector3",
|
||||
39 => "curvedConnector4",
|
||||
40 => "curvedConnector5",
|
||||
58 => "star8",
|
||||
59 => "star16",
|
||||
60 => "star32",
|
||||
62 => "wedgeRoundRectCallout",
|
||||
63 => "wedgeEllipseCallout",
|
||||
66 => "leftArrow",
|
||||
67 => "downArrow",
|
||||
68 => "upArrow",
|
||||
69 => "leftRightArrow",
|
||||
70 => "upDownArrow",
|
||||
74 => "heart",
|
||||
92 => "star24",
|
||||
93 => "stripedRightArrow",
|
||||
94 => "notchedRightArrow",
|
||||
106 => "cloudCallout",
|
||||
110 => "flowChartDecision",
|
||||
116 => "flowChartTerminator",
|
||||
120 => "flowChartConnector",
|
||||
127 => "flowChartExtract",
|
||||
128 => "flowChartMerge",
|
||||
130 => "flowChartOnlineStorage",
|
||||
176 => "flowChartAlternateProcess",
|
||||
187 => "star4",
|
||||
// Every other preset is drawn as its box, as `preset_kind` does.
|
||||
1..=201 => "rect",
|
||||
_ => return None,
|
||||
};
|
||||
Some(preset_kind(prst))
|
||||
}
|
||||
|
||||
/// A shape's fill and outline.
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
pub(crate) struct ShapePaint {
|
||||
@@ -84,18 +145,63 @@ pub(crate) fn shape_paint(sppr: Option<&El>, style: Option<&El>, theme: &[Rgb])
|
||||
ShapePaint { fill, stroke, stroke_width }
|
||||
}
|
||||
|
||||
/// A VML colour (`strokecolor`, `fillcolor`): `#rrggbb` or `#rgb` (Word follows it with the
|
||||
/// theme slot, as in `#4472c4 [3204]`), or a colour name.
|
||||
/// Explicit DrawingML dash stops override the solid default.
|
||||
pub(crate) fn line_dash(ln: Option<&El>) -> wordcraft_doc::line_dash::LineDash {
|
||||
use wordcraft_doc::line_dash::{LineDash, MAX_DASH_STOPS};
|
||||
let Some(ln) = ln else { return LineDash::Solid };
|
||||
if let Some(custom) = ln.child("a:custDash") {
|
||||
let stops: Vec<_> =
|
||||
custom.children("a:ds").take(MAX_DASH_STOPS).filter_map(|e| Some((dash_ratio(e.attr("d")?)?, dash_ratio(e.attr("sp")?)?))).collect();
|
||||
return if stops.is_empty() { LineDash::Solid } else { LineDash::Custom(stops) };
|
||||
}
|
||||
ln.child("a:prstDash").and_then(|e| e.attr("val")).map(LineDash::from_ooxml).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Strict OOXML may spell percentages with a percent sign instead of integer units.
|
||||
fn dash_ratio(value: &str) -> Option<u32> {
|
||||
const PERCENT_UNITS: f64 = 1000.0;
|
||||
let value = value.trim();
|
||||
let Some(percent) = value.strip_suffix('%') else { return value.parse().ok() };
|
||||
let units = percent.parse::<f64>().ok()? * PERCENT_UNITS;
|
||||
if !units.is_finite() || !(0.0..=f64::from(u32::MAX)).contains(&units) {
|
||||
return None;
|
||||
}
|
||||
Some(units.round() as u32)
|
||||
}
|
||||
|
||||
/// A VML colour (`strokecolor`, `fillcolor`): `#rrggbb`, `rrggbb`, `#rgb` or `rgb` (Word follows it
|
||||
/// with the theme slot, as in `#4472c4 [3204]` or `4472c4 [3204]`), an `rgb(...)` function, or a
|
||||
/// colour name.
|
||||
pub(crate) fn vml_color(v: &str) -> Option<Rgb> {
|
||||
let v = v.split_whitespace().next()?;
|
||||
if let Some(hex) = v.strip_prefix('#') {
|
||||
if hex.len() == 3 && hex.is_ascii() {
|
||||
let long: String = hex.chars().flat_map(|c| [c, c]).collect();
|
||||
return Rgb::parse(&long);
|
||||
let s = v.trim();
|
||||
if let Some(inner) = s.strip_prefix("rgb(").or_else(|| s.strip_prefix("rgba(")) {
|
||||
let nums: Vec<u8> = inner
|
||||
.trim_end_matches(')')
|
||||
.split(',')
|
||||
.take(3)
|
||||
.filter_map(|x| {
|
||||
let x = x.trim();
|
||||
if let Some(p) = x.strip_suffix('%') {
|
||||
p.trim().parse::<f32>().ok().map(|f| (f.clamp(0.0, 100.0) * 2.55).round() as u8)
|
||||
} else {
|
||||
x.parse::<f32>().ok().map(|f| f.clamp(0.0, 255.0).round() as u8)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if let [r, g, b] = nums[..] {
|
||||
return Some(Rgb(r, g, b));
|
||||
}
|
||||
}
|
||||
let tok = s.split_whitespace().next()?;
|
||||
let hex = tok.strip_prefix('#').unwrap_or(tok);
|
||||
if hex.len() == 3 && hex.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
let long: String = hex.chars().flat_map(|c| [c, c]).collect();
|
||||
return Rgb::parse(&long);
|
||||
}
|
||||
if hex.len() == 6 && hex.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Rgb::parse(hex);
|
||||
}
|
||||
super::drawing_color::preset_color(v)
|
||||
super::drawing_color::preset_color(tok)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -122,6 +228,11 @@ mod tests {
|
||||
assert_eq!(p.fill, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flowchart_or_keeps_its_spokes() {
|
||||
assert_eq!(preset_kind("flowChartOr"), ShapeKind::FlowchartOr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_paint_wins_over_the_style() {
|
||||
let sp = el(r#"<wps:spPr xmlns:wps="w" xmlns:a="a"><a:noFill/><a:ln><a:solidFill><a:srgbClr val="EE0000"/></a:solidFill></a:ln></wps:spPr>"#);
|
||||
@@ -166,11 +277,36 @@ mod tests {
|
||||
assert_eq!(preset_kind("flowChartDecision"), ShapeKind::Diamond);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vml_presets_map_like_drawingml_presets() {
|
||||
assert_eq!(vml_preset_kind(13), Some(ShapeKind::Arrow));
|
||||
assert_eq!(vml_preset_kind(12), Some(ShapeKind::Star));
|
||||
assert_eq!(vml_preset_kind(32), Some(ShapeKind::Line));
|
||||
assert_eq!(vml_preset_kind(202), Some(ShapeKind::TextBox));
|
||||
// The nearest shape, as `preset_kind` gives it: left arrow, right triangle, four-point
|
||||
// star, rounded callout, elbow connector, a bevel (its box).
|
||||
assert_eq!(vml_preset_kind(66), Some(ShapeKind::Arrow));
|
||||
assert_eq!(vml_preset_kind(6), Some(ShapeKind::RightTriangle));
|
||||
assert_eq!(vml_preset_kind(187), Some(ShapeKind::Star));
|
||||
assert_eq!(vml_preset_kind(62), Some(ShapeKind::RoundedRectangle));
|
||||
assert_eq!(vml_preset_kind(34), Some(ShapeKind::ElbowConnector));
|
||||
assert_eq!(vml_preset_kind(84), Some(ShapeKind::Rectangle));
|
||||
// No preset geometry: freeform, WordArt text, picture frame, host control, out of range.
|
||||
for spt in [0, 24, 31, 136, 175, 75, 201, 203, u32::MAX] {
|
||||
assert_eq!(vml_preset_kind(spt), None, "{spt}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vml_colours() {
|
||||
assert_eq!(vml_color("#ffc000 [3207]"), Some(Rgb(0xFF, 0xC0, 0)));
|
||||
assert_eq!(vml_color("ffc000 [3207]"), Some(Rgb(0xFF, 0xC0, 0)));
|
||||
assert_eq!(vml_color("#f00"), Some(Rgb(0xFF, 0, 0)));
|
||||
assert_eq!(vml_color("f00"), Some(Rgb(0xFF, 0, 0)));
|
||||
assert_eq!(vml_color("black"), Some(Rgb(0, 0, 0)));
|
||||
assert_eq!(vml_color("Red"), Some(Rgb(0xFF, 0, 0)));
|
||||
assert_eq!(vml_color("rgb(255, 128, 0)"), Some(Rgb(255, 128, 0)));
|
||||
assert_eq!(vml_color("rgb(100%, 0%, 0%)"), Some(Rgb(255, 0, 0)));
|
||||
assert_eq!(vml_color("#é"), None);
|
||||
assert_eq!(vml_color(""), None);
|
||||
}
|
||||
|
||||
+603
-70
File diff suppressed because it is too large
Load Diff
@@ -34,6 +34,17 @@ pub fn measure(s: &str, per_pt: f32) -> Option<f32> {
|
||||
pt.is_finite().then_some(pt.clamp(-MAX_LEN_PT, MAX_LEN_PT))
|
||||
}
|
||||
|
||||
/// `w:line` under `lineRule="auto"`: the multiple of single line spacing, in 240ths (240 =
|
||||
/// single). Without a unit the value already is that — the transitional form. A universal
|
||||
/// measure is a height (strict OOXML writes `w:line="13.90pt"`, ISO 29500
|
||||
/// `ST_SignedTwipsMeasure`), so it goes through twips as Word reads it: × 20 ÷ 240 (13.90 pt =
|
||||
/// 278 twips, the 1.1583 multiple Word renders).
|
||||
pub fn line_multiple(s: &str) -> Option<f32> {
|
||||
let t = s.trim();
|
||||
let measured = t.ends_with(|c: char| c.is_ascii_alphabetic());
|
||||
measure(t, 240.0).map(|v| if measured { v * 20.0 / 240.0 } else { v })
|
||||
}
|
||||
|
||||
/// Plain integer (accepts decimals, rounds; clamps into i64 range).
|
||||
pub fn int(s: &str) -> Option<i64> {
|
||||
let t = s.trim();
|
||||
@@ -98,6 +109,16 @@ mod tests {
|
||||
assert_eq!(measure("1e400", 20.0), None);
|
||||
assert_eq!(measure("99999999999999", 20.0), Some(MAX_LEN_PT));
|
||||
assert_eq!(measure("x", 20.0), None);
|
||||
// `w:line` auto: the multiple in 240ths without a unit, through twips with one (#2329).
|
||||
assert_eq!(line_multiple("240"), Some(1.0));
|
||||
assert_eq!(line_multiple("276"), Some(1.15));
|
||||
assert_eq!(line_multiple("0"), Some(0.0));
|
||||
let strict = line_multiple("13.90pt").expect("strict line height");
|
||||
assert!((strict - 278.0 / 240.0).abs() < 1e-5, "{strict}");
|
||||
let inches = line_multiple("1.2in").expect("inches");
|
||||
assert!((inches - 7.2).abs() < 1e-5, "{inches}");
|
||||
assert_eq!(line_multiple("junk"), None);
|
||||
assert_eq!(line_multiple(""), None);
|
||||
assert_eq!(int("12.6"), Some(13));
|
||||
assert_eq!(u32_of("-5"), Some(0));
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user