mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-15 10:28:19 -06:00
Compare commits
778
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae022437c8 | ||
|
|
bc0e51ee4d | ||
|
|
ed66198574 | ||
|
|
e9750c5cb7 | ||
|
|
3d5a153e3d | ||
|
|
52d17a91df | ||
|
|
323cf78232 | ||
|
|
42a5df5f94 | ||
|
|
f8c66d0692 | ||
|
|
aba890e8a6 | ||
|
|
0cc92c9744 | ||
|
|
b02baeba43 | ||
|
|
3c77297b83 | ||
|
|
60415e055c | ||
|
|
0db15a1d5e | ||
|
|
33afe69094 | ||
|
|
669d5df0b1 | ||
|
|
d1a36fd0c6 | ||
|
|
beae4b82fa | ||
|
|
514085ca44 | ||
|
|
fbd6c7f133 | ||
|
|
a4b898f820 | ||
|
|
388c8e51ff | ||
|
|
4857eee142 | ||
|
|
513a109688 | ||
|
|
eae8363e89 | ||
|
|
953f43baed | ||
|
|
acf5eb8727 | ||
|
|
bcacf7466f | ||
|
|
1f984892ea | ||
|
|
4216089a23 | ||
|
|
0b2a83a352 | ||
|
|
c37baf764d | ||
|
|
ef3626dbb6 | ||
|
|
69ea44776d | ||
|
|
c537ed96b9 | ||
|
|
b5fa2f9ed4 | ||
|
|
89afefba6d | ||
|
|
8e4a487074 | ||
|
|
0294f81d83 | ||
|
|
2bda4e5a41 | ||
|
|
fab8793ce7 | ||
|
|
12ce548607 | ||
|
|
7539fb81cb | ||
|
|
2e185a6a74 | ||
|
|
3f7bff9204 | ||
|
|
d30a8392f9 | ||
|
|
b8f232ca2e | ||
|
|
15c5b68da6 | ||
|
|
1c3839aa16 | ||
|
|
f392c14f6b | ||
|
|
6bf96e65e4 | ||
|
|
48d9c2a46c | ||
|
|
d54fb78544 | ||
|
|
a45e37b90d | ||
|
|
aa6deaee03 | ||
|
|
869350d4c7 | ||
|
|
139aa7a51b | ||
|
|
76d5f13a03 | ||
|
|
b71f257f1b | ||
|
|
d87e6f7930 | ||
|
|
ed09a2158f | ||
|
|
11318f9226 | ||
|
|
0196647dc6 | ||
|
|
10e5bb8bce | ||
|
|
c8e64d72cc | ||
|
|
98619947d7 | ||
|
|
664b033071 | ||
|
|
1a8fd899d3 | ||
|
|
2d286fe252 | ||
|
|
ab90b7142e | ||
|
|
02f8022e57 | ||
|
|
afcb2ce46e | ||
|
|
4671e47b50 | ||
|
|
cd4e63849e | ||
|
|
a1d4495cfb | ||
|
|
07f977372d | ||
|
|
cc27f29a01 | ||
|
|
06c2ae6f9d | ||
|
|
a58b41a716 | ||
|
|
d86d2b69f8 | ||
|
|
a8db235e05 | ||
|
|
4706420e94 | ||
|
|
ae7ae89eb2 | ||
|
|
1327d20055 | ||
|
|
9dba000e86 | ||
|
|
7013411a2f | ||
|
|
411a4915a1 | ||
|
|
639b8133ea | ||
|
|
60619abe4d | ||
|
|
c99c42eaa9 | ||
|
|
ff0856570d | ||
|
|
215485dd9a | ||
|
|
98b258188d | ||
|
|
d6c68cbfbb | ||
|
|
1c3256ab65 | ||
|
|
fa43ecc4bf | ||
|
|
81fa25dda7 | ||
|
|
0c488934e0 | ||
|
|
278aae7235 | ||
|
|
51901610fc | ||
|
|
e0659e45ce | ||
|
|
ad5e1a65fc | ||
|
|
bf426c1804 | ||
|
|
b67d3b8735 | ||
|
|
e3fef2a9c3 | ||
|
|
28af39b610 | ||
|
|
060768f1ed | ||
|
|
45db128c15 | ||
|
|
118eb8af1f | ||
|
|
30c1279fd8 | ||
|
|
f936270317 | ||
|
|
0fab74aa38 | ||
|
|
aef086976b | ||
|
|
92e0e67f3a | ||
|
|
22f01131d8 | ||
|
|
860426adda | ||
|
|
7458a18468 | ||
|
|
49e777cce5 | ||
|
|
a34c94e4c1 | ||
|
|
a42c9eaab3 | ||
|
|
12869b8e02 | ||
|
|
8548b33be1 | ||
|
|
abd5a6e957 | ||
|
|
8dac5c7802 | ||
|
|
2922fe566b | ||
|
|
43f94a5dca | ||
|
|
7095a4eaf1 | ||
|
|
d40d1b2718 | ||
|
|
05f781cfda | ||
|
|
8a97427f51 | ||
|
|
92eac2aa7c | ||
|
|
29a5abf3e7 | ||
|
|
bd9e726cda | ||
|
|
15b608660f | ||
|
|
52fbc7f068 | ||
|
|
d75364067d | ||
|
|
d102b2f08b | ||
|
|
8dc3f0cd5f | ||
|
|
f1b1077327 | ||
|
|
7b8c0ba5eb | ||
|
|
0b61f8b426 | ||
|
|
0a5d5d29f8 | ||
|
|
37e891544d | ||
|
|
bce47689ad | ||
|
|
6272c9a4da | ||
|
|
172e56f12e | ||
|
|
5ad865831f | ||
|
|
b66122f718 | ||
|
|
642f5dd6bf | ||
|
|
0b7cfa86fe | ||
|
|
85eb8bb92e | ||
|
|
52d1668507 | ||
|
|
d590dc7848 | ||
|
|
4b0d399599 | ||
|
|
fda8758ec9 | ||
|
|
c5fe6c6816 | ||
|
|
438f82977a | ||
|
|
3e62a60400 | ||
|
|
72a7afd9b5 | ||
|
|
36ed94a09e | ||
|
|
0655e651fb | ||
|
|
0e3f4dd315 | ||
|
|
e01a88b0be | ||
|
|
ae257ba784 | ||
|
|
0495fbb789 | ||
|
|
e7e9bc2759 | ||
|
|
be75ee90e3 | ||
|
|
dbbc43a89a | ||
|
|
480511eaa5 | ||
|
|
aa262cf7d8 | ||
|
|
2d97c8443e | ||
|
|
d5d2b22dac | ||
|
|
2794ae44e9 | ||
|
|
5125de6f1f | ||
|
|
59030b1bb5 | ||
|
|
4050f1bf8b | ||
|
|
02e74a5d9c | ||
|
|
d5a1c0e181 | ||
|
|
69d2a2e131 | ||
|
|
661a790540 | ||
|
|
04217419b6 | ||
|
|
4afff61f10 | ||
|
|
e76293a564 | ||
|
|
e0d8312fbe | ||
|
|
523e319424 | ||
|
|
735c2a5382 | ||
|
|
7592d1440e | ||
|
|
fde39ab061 | ||
|
|
d34e341d75 | ||
|
|
535b675e8a | ||
|
|
c8618d3e05 | ||
|
|
a891d861b7 | ||
|
|
85434d2c20 | ||
|
|
833c0154aa | ||
|
|
0a3fb5828e | ||
|
|
7634553900 | ||
|
|
cd4f755dcf | ||
|
|
c4d5f70c1d | ||
|
|
de4ca29309 | ||
|
|
b8a7e2acdb | ||
|
|
f0fb31c0c9 | ||
|
|
4cd6ffa564 | ||
|
|
52934ef110 | ||
|
|
3291be0fbf | ||
|
|
5dfb3acf15 | ||
|
|
4259dd1b2e | ||
|
|
e344bb4243 | ||
|
|
4a507de3d6 | ||
|
|
5d139d0f0e | ||
|
|
d07f50dd8b | ||
|
|
87e08051bf | ||
|
|
5c0a62a736 | ||
|
|
855a0fe9bd | ||
|
|
5f1db14954 | ||
|
|
b016ecc26c | ||
|
|
625f598b64 | ||
|
|
fa791d29b2 | ||
|
|
796b39dde6 | ||
|
|
33fa77a892 | ||
|
|
efd476cbc7 | ||
|
|
d3fcf10bcc | ||
|
|
b9936609dc | ||
|
|
7152143e45 | ||
|
|
c40719f431 | ||
|
|
6d0bd73127 | ||
|
|
cd81d5da49 | ||
|
|
b7c2fe8dff | ||
|
|
586f85df96 | ||
|
|
6367400215 | ||
|
|
9655b2da50 | ||
|
|
66e169c343 | ||
|
|
1c6c7c732e | ||
|
|
bd8bc96cb5 | ||
|
|
7830333c34 | ||
|
|
e349439bb6 | ||
|
|
4c8c1f1268 | ||
|
|
caeb87c754 | ||
|
|
0f5a701171 | ||
|
|
677e610886 | ||
|
|
d2a2709e14 | ||
|
|
050b6b0da3 | ||
|
|
6b163b33dc | ||
|
|
4ba444a342 | ||
|
|
4821a13ee0 | ||
|
|
dba2b3b5e9 | ||
|
|
beab4e8e9c | ||
|
|
852e67d587 | ||
|
|
9b6e9564a2 | ||
|
|
c9090ba95a | ||
|
|
c606533853 | ||
|
|
eb100edfd6 | ||
|
|
c38dc17ba9 | ||
|
|
5997aef2f9 | ||
|
|
73356ed82a | ||
|
|
6d3b51fab1 | ||
|
|
750e26657e | ||
|
|
f7e060ac3f | ||
|
|
b61aee61ba | ||
|
|
bc0f691c13 | ||
|
|
cac121fd89 | ||
|
|
ea8c45a4ca | ||
|
|
4774749a05 | ||
|
|
20a10a5f33 | ||
|
|
e53cae3482 | ||
|
|
7855a2d814 | ||
|
|
871401509c | ||
|
|
4ce92e8afd | ||
|
|
746577406c | ||
|
|
0131d5c3aa | ||
|
|
fa59ed0e8a | ||
|
|
b29f3a8a2e | ||
|
|
e7668ae53e | ||
|
|
7aea5f8cab | ||
|
|
3b6547de8f | ||
|
|
e0dd821d4c | ||
|
|
e4310ec5c7 | ||
|
|
7c3a5ccd34 | ||
|
|
ecbabc327c | ||
|
|
34b4caf317 | ||
|
|
8ab285e61d | ||
|
|
490437fa87 | ||
|
|
fa153b932c | ||
|
|
4925e06391 | ||
|
|
5c2e5ce0d0 | ||
|
|
274d40fc4d | ||
|
|
570c39e287 | ||
|
|
595cddaaef | ||
|
|
f1b5f0b4de | ||
|
|
ddb1d37de3 | ||
|
|
d98a335ae8 | ||
|
|
bbc6d94dbe | ||
|
|
d0498254a2 | ||
|
|
9fd9ce54b7 | ||
|
|
d94bbc8fe0 | ||
|
|
da914ce674 | ||
|
|
18a26b41ce | ||
|
|
9f9190f0d7 | ||
|
|
1c7c4e66e0 | ||
|
|
2d6da7a326 | ||
|
|
30a439899b | ||
|
|
875a62043a | ||
|
|
7ca1dd1802 | ||
|
|
729cd0a5fc | ||
|
|
8c46f4fc1b | ||
|
|
2a0a20aec5 | ||
|
|
247c7feaab | ||
|
|
8833c604e0 | ||
|
|
cad8ede1e8 | ||
|
|
565032feb1 | ||
|
|
beca3f3485 | ||
|
|
5c5c35899c | ||
|
|
ef35d79cb8 | ||
|
|
3d79fff0ad | ||
|
|
a951642cd0 | ||
|
|
dc388cfaab | ||
|
|
ce2961041b | ||
|
|
aeb2e550ac | ||
|
|
502256d125 | ||
|
|
615597bda1 | ||
|
|
60535464b2 | ||
|
|
ca21cf0ece | ||
|
|
035982e34e | ||
|
|
ae0692ee66 | ||
|
|
4a48a58496 | ||
|
|
9a9246915c | ||
|
|
a5b1518a10 | ||
|
|
4e334166e5 | ||
|
|
8df01e481f | ||
|
|
4ac7292d62 | ||
|
|
67c9b8ac31 | ||
|
|
8c57b0775c | ||
|
|
28925fac8c | ||
|
|
35e960c850 | ||
|
|
9d5924c8b7 | ||
|
|
2fb91eece2 | ||
|
|
c21d5af2a6 | ||
|
|
ed92cb5838 | ||
|
|
ec3e14f98b | ||
|
|
83bef65a1d | ||
|
|
e919982fa2 | ||
|
|
2f49c8ad2e | ||
|
|
334494a39f | ||
|
|
db8d944c5c | ||
|
|
21a4e2702f | ||
|
|
0c54f94602 | ||
|
|
7834197fbc | ||
|
|
d4bbe8ba9a | ||
|
|
1397669dd5 | ||
|
|
737bab404f | ||
|
|
9c5b54a1a1 | ||
|
|
5a8e6e4894 | ||
|
|
22989a77da | ||
|
|
7f63cab7d4 | ||
|
|
5f9bb3f111 | ||
|
|
7a8819c296 | ||
|
|
c0ab7e29f9 | ||
|
|
fede86cd1a | ||
|
|
c230e2b13e | ||
|
|
122c85bd09 | ||
|
|
475ca5420b | ||
|
|
12a840f653 | ||
|
|
94cbdeb4cb | ||
|
|
107996dd24 | ||
|
|
7ac4a838c6 | ||
|
|
96286f0e96 | ||
|
|
5a4c45e79a | ||
|
|
401e7c9169 | ||
|
|
3daa6b1e78 | ||
|
|
e4ba85346d | ||
|
|
ebdca4feb8 | ||
|
|
44eb54eef9 | ||
|
|
75a905802c | ||
|
|
097cddbd03 | ||
|
|
2c17b04f9a | ||
|
|
7814450d0a | ||
|
|
951b255b80 | ||
|
|
37a627c61e | ||
|
|
0ff06b9d86 | ||
|
|
6f552abf8e | ||
|
|
5f87d7461b | ||
|
|
6a50c1e374 | ||
|
|
4123bdc09f | ||
|
|
b3da7a4d36 | ||
|
|
5e4c92bf20 | ||
|
|
61a1808f0f | ||
|
|
208facfb92 | ||
|
|
811a847e86 | ||
|
|
c9ecb1a14b | ||
|
|
c38cd8c407 | ||
|
|
991a21dee6 | ||
|
|
cbe6a9d5f7 | ||
|
|
c419985c54 | ||
|
|
1c7b5013fa | ||
|
|
d5495ea4f8 | ||
|
|
3f555bc85d | ||
|
|
650a97b623 | ||
|
|
78d71fa19c | ||
|
|
4932017711 | ||
|
|
0802b4fe70 | ||
|
|
75fc50fb77 | ||
|
|
1f026078be | ||
|
|
bf7715785c | ||
|
|
573b929ad6 | ||
|
|
5c427807d8 | ||
|
|
006d9e4b5b | ||
|
|
8218dea5bd | ||
|
|
938e13e9c7 | ||
|
|
1787766d6f | ||
|
|
92ecb40105 | ||
|
|
63ceaf5b80 | ||
|
|
69945e0ccb | ||
|
|
2df2bfc30e | ||
|
|
7c6341cf50 | ||
|
|
6d22e36196 | ||
|
|
958867b7e8 | ||
|
|
e3b000b650 | ||
|
|
604ac6ddb2 | ||
|
|
3c7aa5df90 | ||
|
|
87cfbac0ee | ||
|
|
b2734c28f4 | ||
|
|
ed231fd027 | ||
|
|
06d699dcb7 | ||
|
|
560565cdd7 | ||
|
|
1350f62bf0 | ||
|
|
8b9f726ea7 | ||
|
|
688cce4731 | ||
|
|
b6afa0ba3c | ||
|
|
6ec85141d5 | ||
|
|
2d6dff96cc | ||
|
|
d2f504e29a | ||
|
|
e847b2e5e3 | ||
|
|
fd1c5efe6c | ||
|
|
655b4312d3 | ||
|
|
773ef21f9f | ||
|
|
53bccd5442 | ||
|
|
ba58e70cc8 | ||
|
|
6f62716f08 | ||
|
|
56c85834f1 | ||
|
|
c26b952e83 | ||
|
|
57b03d9af8 | ||
|
|
ea420c467c | ||
|
|
5f0c08b68d | ||
|
|
08afab50b6 | ||
|
|
cd6b497c8a | ||
|
|
1340705fa6 | ||
|
|
98da959ceb | ||
|
|
96e63ee96b | ||
|
|
d9e2b92e41 | ||
|
|
d97e2f3a29 | ||
|
|
03fba6cb10 | ||
|
|
ea25e5dd3a | ||
|
|
c6f54f6a32 | ||
|
|
610233de3d | ||
|
|
68512842aa | ||
|
|
3e9e36796c | ||
|
|
3122c16e38 | ||
|
|
5709fa3626 | ||
|
|
3b37679f7c | ||
|
|
afc01f8328 | ||
|
|
fa37c9adbe | ||
|
|
26c662961d | ||
|
|
29f99006e0 | ||
|
|
9e391e97f4 | ||
|
|
2113f52db4 | ||
|
|
26a8182e91 | ||
|
|
b52f3ea68d | ||
|
|
d64ef7c3f9 | ||
|
|
bff0d1d925 | ||
|
|
f4dfd34373 | ||
|
|
0ce44996ec | ||
|
|
8fb57e1f6b | ||
|
|
9819939d98 | ||
|
|
94b89d95d6 | ||
|
|
098cf86dc8 | ||
|
|
c45945a2ea | ||
|
|
efb5071526 | ||
|
|
ae151ff341 | ||
|
|
e99e28aebd | ||
|
|
969df57ea2 | ||
|
|
682ce4318a | ||
|
|
570408b04b | ||
|
|
ddf88c39df | ||
|
|
69b457f155 | ||
|
|
a591879294 | ||
|
|
54988ddfda | ||
|
|
4abae255e0 | ||
|
|
4edb16a946 | ||
|
|
7f6d9fbb97 | ||
|
|
d746e11f65 | ||
|
|
d6fb543e31 | ||
|
|
0592a70d7e | ||
|
|
2158d7388f | ||
|
|
67b3bbfdb0 | ||
|
|
ae5c65b9dd | ||
|
|
1f8f9e8e2c | ||
|
|
cf23ca73c9 | ||
|
|
eb8d195188 | ||
|
|
fdbc3322b3 | ||
|
|
c73e9e0e87 | ||
|
|
a6ba7321db | ||
|
|
1eff715f10 | ||
|
|
ca2cf4e0fd | ||
|
|
6f6559aacb | ||
|
|
faac2c42bd | ||
|
|
08dd0906ca | ||
|
|
7160f05014 | ||
|
|
6601aeaf09 | ||
|
|
fdbe410704 | ||
|
|
75ee5e6a43 | ||
|
|
48ff99e52c | ||
|
|
e635c8bd96 | ||
|
|
cef4338947 | ||
|
|
a330e4c503 | ||
|
|
5063000781 | ||
|
|
0b6e052d73 | ||
|
|
45cfc55116 | ||
|
|
a3b50342f9 | ||
|
|
ce4c401c4a | ||
|
|
b8ae10e453 | ||
|
|
7b25253286 | ||
|
|
ffa309c266 | ||
|
|
8d5485c8bd | ||
|
|
bc74bc23f5 | ||
|
|
7cdc8d85a4 | ||
|
|
f4f9d28d99 | ||
|
|
e17ad54b15 | ||
|
|
9e2f6ad330 | ||
|
|
6f125461dd | ||
|
|
31a37bcf00 | ||
|
|
2025c48e9b | ||
|
|
83b319e7c3 | ||
|
|
90898f454b | ||
|
|
807fe91724 | ||
|
|
be2666da15 | ||
|
|
959770fa13 | ||
|
|
adfcbcbf7c | ||
|
|
8f65b867c0 | ||
|
|
2f08e8bb01 | ||
|
|
3164c33c28 | ||
|
|
6a62c6142f | ||
|
|
48c60a51b1 | ||
|
|
7be387dabc | ||
|
|
5cdf2608bf | ||
|
|
078945e10c | ||
|
|
56188b97f4 | ||
|
|
bc5d9f9fa5 | ||
|
|
e2b439ed17 | ||
|
|
1dd4e64191 | ||
|
|
ab02c85701 | ||
|
|
7848757954 | ||
|
|
ce516b04f6 | ||
|
|
78bd6b2b72 | ||
|
|
693ccff3b2 | ||
|
|
cb7de0227f | ||
|
|
2b826a288c | ||
|
|
2036ad2e99 | ||
|
|
7c9d301541 | ||
|
|
04ff4e3a54 | ||
|
|
2877514d9e | ||
|
|
27b7f34425 | ||
|
|
4c0bb99016 | ||
|
|
2bc5874da0 | ||
|
|
b114209d2b | ||
|
|
0b6b88f414 | ||
|
|
1e9c11fb1c | ||
|
|
3d464f3ebe | ||
|
|
f683e308c6 | ||
|
|
5f7f89c187 | ||
|
|
72e9593de9 | ||
|
|
6dff74990e | ||
|
|
6925818110 | ||
|
|
b87c80cdc7 | ||
|
|
8506d9da54 | ||
|
|
45b852ad4a | ||
|
|
7eee06a1ab | ||
|
|
b36e0b9fe9 | ||
|
|
ca1f584ae0 | ||
|
|
7378ac5063 | ||
|
|
7e967d4a3a | ||
|
|
16f0b6ed25 | ||
|
|
f5038a12e3 | ||
|
|
275037932f | ||
|
|
1bbced51a4 | ||
|
|
135ce2229d | ||
|
|
3cc5185b1e | ||
|
|
9de2227af2 | ||
|
|
7d0ba2be63 | ||
|
|
f69b003fe1 | ||
|
|
c9046233a0 | ||
|
|
d2b7465b1e | ||
|
|
86b1681b54 | ||
|
|
fe5d3c92d2 | ||
|
|
694ef8da96 | ||
|
|
5570236b70 | ||
|
|
27160e8821 | ||
|
|
5374375301 | ||
|
|
c6bb7a2f57 | ||
|
|
bb5dc4bcaa | ||
|
|
aea3b4c7d7 | ||
|
|
74da7e0985 | ||
|
|
422b59f3bc | ||
|
|
88a5fff9d5 | ||
|
|
f217aafa53 | ||
|
|
767594b034 | ||
|
|
f9d13f06dd | ||
|
|
836bf6553b | ||
|
|
e15a86bd55 | ||
|
|
1554cfad65 | ||
|
|
c6ff6a602b | ||
|
|
2718ca1b6b | ||
|
|
9dd0b51f23 | ||
|
|
488db37516 | ||
|
|
f719ff2f74 | ||
|
|
4c83e8a817 | ||
|
|
507e7b8050 | ||
|
|
cbc19809b6 | ||
|
|
3745441899 | ||
|
|
ce57478d15 | ||
|
|
21849ab7c0 | ||
|
|
3d70bf1bb1 | ||
|
|
6a30527aa4 | ||
|
|
4a752321d6 | ||
|
|
384ff126c6 | ||
|
|
64248032b2 | ||
|
|
e533073ceb | ||
|
|
a82d35bbff | ||
|
|
e4bac37236 | ||
|
|
bc50c6f7f9 | ||
|
|
084d26a3c8 | ||
|
|
1dbcf073a4 | ||
|
|
4959ac5f63 | ||
|
|
3402e9b644 | ||
|
|
9436271273 | ||
|
|
8be9e15cd0 | ||
|
|
e9762b11bf | ||
|
|
0e7dc8da53 | ||
|
|
837d2b0748 | ||
|
|
4e1b6a71ab | ||
|
|
220bcf947a | ||
|
|
7d27d8299a | ||
|
|
71dba44a0a | ||
|
|
3a22b72132 | ||
|
|
f342b6ff1f | ||
|
|
95669b9eb9 | ||
|
|
88ac0d7174 | ||
|
|
3b20ea46de | ||
|
|
b2ec73926f | ||
|
|
7f90fd0bf1 | ||
|
|
4524bc6ba9 | ||
|
|
49a3cd223b | ||
|
|
061d40d5eb | ||
|
|
05a37f6fe2 | ||
|
|
d95cb46da5 | ||
|
|
8c44eb1b98 | ||
|
|
933bccf052 | ||
|
|
9aa3d93ecf | ||
|
|
bffb6e6705 | ||
|
|
6bfd55158c | ||
|
|
a449c59232 | ||
|
|
a82d509793 | ||
|
|
4bfd84514c | ||
|
|
e34840a78a | ||
|
|
066af3b620 | ||
|
|
d0f273bbfd | ||
|
|
c40d5159b7 | ||
|
|
841dd538dc | ||
|
|
541483ce0b | ||
|
|
364495deb5 | ||
|
|
c85d454e09 | ||
|
|
1c1ee15cff | ||
|
|
1a950a3631 | ||
|
|
0c859e334d | ||
|
|
f4ff1f842d | ||
|
|
d8899ed345 | ||
|
|
c10df4e7bc | ||
|
|
2a82cc4a22 | ||
|
|
5ce611091e | ||
|
|
878e5e3457 | ||
|
|
3cfa9ca721 | ||
|
|
ff4377bf7e | ||
|
|
71aa12c076 | ||
|
|
cfa99b45ce | ||
|
|
e11943f818 | ||
|
|
33070c575c | ||
|
|
86a1b000ef | ||
|
|
b6d89388c8 | ||
|
|
914e1d5872 | ||
|
|
ba62c2255e | ||
|
|
0ea610d0ff | ||
|
|
a4e9b07701 | ||
|
|
2fb626e548 | ||
|
|
827fc6e07a | ||
|
|
e2b92cde84 | ||
|
|
678af4368d | ||
|
|
ed3c3ea038 | ||
|
|
4940d07dad | ||
|
|
dc2b990602 | ||
|
|
92683b6796 | ||
|
|
d858066cf7 | ||
|
|
006b6480ed | ||
|
|
402ee4893c | ||
|
|
d8afe869f4 | ||
|
|
21bba8e7de | ||
|
|
d5de454641 | ||
|
|
11fce726e3 | ||
|
|
ed97087907 | ||
|
|
2b2398ae55 | ||
|
|
d7c7889c82 | ||
|
|
0b1704677d | ||
|
|
ec1cb78a20 | ||
|
|
8b06dd8666 | ||
|
|
76a7929821 | ||
|
|
28b5cba00c | ||
|
|
14526d0d81 | ||
|
|
97eea6e12f | ||
|
|
8b679452eb | ||
|
|
fd27713d20 | ||
|
|
c488558a1c | ||
|
|
addf03751d | ||
|
|
8578d99ccd | ||
|
|
77bfbd6f11 | ||
|
|
38253611d2 | ||
|
|
876ce9ab48 | ||
|
|
0a23c4febe | ||
|
|
308e2e0a5d | ||
|
|
65f36f9b8a | ||
|
|
f50a5cc5e9 | ||
|
|
12582bb529 | ||
|
|
badd0c5ef9 | ||
|
|
2f46f194a1 | ||
|
|
6bc7810c8c | ||
|
|
852ae36d08 | ||
|
|
f9c6ddba3a | ||
|
|
00d4e7a291 | ||
|
|
bfbb3d05a4 | ||
|
|
9b6d326730 | ||
|
|
212df61bc7 | ||
|
|
0abe06e236 | ||
|
|
843bf26ae8 | ||
|
|
44055678c5 | ||
|
|
9ea1d93a8d | ||
|
|
ab3ad718a0 | ||
|
|
83864cc7fd | ||
|
|
ad7eea67c1 | ||
|
|
be492ddc98 | ||
|
|
e8c852d490 | ||
|
|
7a3c3b40af | ||
|
|
5288fed0ea | ||
|
|
1e306ed5ab | ||
|
|
9e25e978e5 | ||
|
|
3248047b76 | ||
|
|
5edcd6765b | ||
|
|
80bf6ed96b | ||
|
|
665af07277 | ||
|
|
2e7852fa4d | ||
|
|
3ee5840d6d | ||
|
|
ee649d03da | ||
|
|
19094d4f3a | ||
|
|
fb3d600428 | ||
|
|
7b1c0402b0 | ||
|
|
365b8c003e | ||
|
|
1bec25c02d | ||
|
|
c3703ebc73 | ||
|
|
ede933dcc2 | ||
|
|
d955ff2874 | ||
|
|
4388fba5b2 | ||
|
|
bd320e65a5 | ||
|
|
3445b7226d | ||
|
|
f2d7a4fe7d | ||
|
|
25e4986998 | ||
|
|
c341669dbd | ||
|
|
a4d28e2586 | ||
|
|
a912aacf73 | ||
|
|
256d5d084d | ||
|
|
f0c1b64bc8 | ||
|
|
0164ac86fa | ||
|
|
12a1316f10 |
+5
-1
@@ -1 +1,5 @@
|
||||
craft-i18n.rev text eol=lf
|
||||
# Text lives in the repository with LF. A Windows checkout once committed 50 files with CRLF
|
||||
# (#6537) and `cargo fmt --check` still passed, so the gate could not see it. Normalise on add.
|
||||
* text=auto
|
||||
|
||||
craft-i18n.rev text eol=lf
|
||||
+20
-1
@@ -7,11 +7,30 @@ updates:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
# Consolidate updates into a single PR rather than one PR per crate
|
||||
# Minor and patch updates share one PR. A breaking bump gets its own PR so one API change
|
||||
# doesn't hold the safe updates back (#2973). For cargo, Dependabot counts a 0.y bump of a
|
||||
# 0.x crate as major, as Cargo does. A dependency goes to the first group it matches, so the
|
||||
# crate families that must move together come before the catch-all.
|
||||
groups:
|
||||
egui:
|
||||
patterns:
|
||||
- "egui"
|
||||
- "egui_*"
|
||||
- "eframe"
|
||||
- "winit"
|
||||
fonts:
|
||||
patterns:
|
||||
- "skrifa"
|
||||
- "harfrust"
|
||||
hayro:
|
||||
patterns:
|
||||
- "hayro-*"
|
||||
cargo-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
name: House rules
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- AGENTS.md
|
||||
- CLAUDE.md
|
||||
- .github/workflows/house-rules.yml
|
||||
pull_request:
|
||||
paths:
|
||||
- AGENTS.md
|
||||
- CLAUDE.md
|
||||
- .github/workflows/house-rules.yml
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: house-rules-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
house-rules:
|
||||
uses: storytold/craft-common-actions/.github/workflows/house-rules.yml@main
|
||||
with:
|
||||
rust: true
|
||||
@@ -27,6 +27,10 @@ jobs:
|
||||
name: Windows installation policy (no app build)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -30,66 +30,31 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# The print host and UI tests on Linux, Windows and macOS, through the suite's shared gate: it
|
||||
# checks out craft-i18n (craft-i18n.rev) and craft-fonts, and installs the Ubuntu packages through
|
||||
# install-apt-packages, which falls back to mirrors outside ubuntu.com (as on 11 Nov 2026).
|
||||
native:
|
||||
name: Print (${{ matrix.os }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-24.04, windows-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: '0'
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# The interface catalogs and craft-proof's Language data come from craft-i18n at the commit in
|
||||
# craft-i18n.rev, so these tests build with the same data as the gate (ci.yml).
|
||||
- name: Check out craft-i18n
|
||||
uses: storytold/craft-common-actions/actions/checkout-craft-i18n@main
|
||||
|
||||
name: Print
|
||||
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
|
||||
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
|
||||
with:
|
||||
os: '["ubuntu-24.04","windows-latest","macos-latest"]'
|
||||
timeout-minutes: 90
|
||||
# The catalogs include CJK interface languages, so the UI tests need the suite's fonts, as in
|
||||
# the gate (same commit as ci.yml and release.yml).
|
||||
- name: Check out craft-fonts
|
||||
uses: storytold/craft-common-actions/actions/checkout-craft-fonts@main
|
||||
with:
|
||||
ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
|
||||
- name: Build with craft-fonts
|
||||
shell: bash
|
||||
run: |
|
||||
echo "CRAFT_FONTS_DIR=$GITHUB_WORKSPACE/craft-fonts" >> "$GITHUB_ENV"
|
||||
echo "CRAFT_FONTS_REQUIRED=1" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install Linux build and software-rendering dependencies
|
||||
if: runner.os == 'Linux'
|
||||
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 libvulkan1 mesa-vulkan-drivers
|
||||
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Formatting
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Native app and UI Clippy
|
||||
run: cargo clippy -p wordcraft -p wordcraft-ui-egui --all-targets --locked -- -D warnings
|
||||
|
||||
- name: Native app and UI tests
|
||||
run: cargo test -p wordcraft -p wordcraft-ui-egui --locked
|
||||
|
||||
- name: Browser compatibility
|
||||
if: runner.os == 'Linux'
|
||||
run: cargo check -p wordcraft-web --target wasm32-unknown-unknown --locked
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
packages: >-
|
||||
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev
|
||||
libxi-dev libgtk-3-dev libfontconfig1-dev libfreetype-dev libvulkan1 mesa-vulkan-drivers
|
||||
# No nextest: the tests run with cargo test, as before.
|
||||
tools: ""
|
||||
# Its own cache, apart from ci.yml's gate, which builds the whole workspace.
|
||||
cache-version: v1-printing
|
||||
command: |
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy -p wordcraft -p wordcraft-ui-egui --all-targets --locked -- -D warnings
|
||||
cargo test -p wordcraft -p wordcraft-ui-egui --locked
|
||||
# Browser compatibility, once.
|
||||
if [ "$RUNNER_OS" = Linux ]; then
|
||||
cargo check -p wordcraft-web --target wasm32-unknown-unknown --locked
|
||||
fi
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
# A nightly dry run on main: every release build except macOS, nothing signed or released
|
||||
# (craft-common-actions release.yml `dry-run`). Finds release-only breakage before release day.
|
||||
schedule:
|
||||
- cron: "0 3 * * *"
|
||||
push:
|
||||
branches: [release]
|
||||
workflow_dispatch:
|
||||
@@ -18,7 +22,14 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# Regenerates contributors/contributors.json for the release commit; every build bundles it.
|
||||
credits:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
uses: storytold/craft-common-actions/.github/workflows/refresh-contributors.yml@main
|
||||
release:
|
||||
needs: credits
|
||||
# Only this job publishes the GitHub release, so only it gets write access.
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -26,11 +37,13 @@ jobs:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
|
||||
with:
|
||||
dry-run: ${{ github.event_name == 'schedule' }}
|
||||
app-name: wordcraft
|
||||
app-title: WordCraft
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
web-craft-fonts: true
|
||||
version: ${{ inputs.version }}
|
||||
contributors-artifact: ${{ needs.credits.outputs.artifact-name }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
@@ -46,3 +59,4 @@ jobs:
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }}
|
||||
|
||||
@@ -1,10 +1,62 @@
|
||||
<!-- craft-house-rules:start core source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=540e6cea75d12d0710c3dca89658328e81e508ecef102446ca5bb95b7c0b92bb -->
|
||||
## House rules
|
||||
|
||||
These apply in every Craft repository. A later rule may be stricter, not weaker. The source is `standards/house-rules-core.md` in `storytold/craft-contrib`. Propose changes there, not in a copy.
|
||||
|
||||
1. **A person is accountable.** Whoever runs an agent answers for what it opens and for its review comments.
|
||||
2. **Check, then claim.** Before writing code, read the issue comments, search pull requests for `#N` and the keywords, and check recent commits on `main`. Comment on the issue first. Release a claim you will not finish.
|
||||
3. **Verify, don't guess.** Open a definition before using it. Confirm the cause in the code, even when someone names a location.
|
||||
4. **Keep fixes narrow and tested.** Fix what the issue describes, where the bug is. Add a test that fails without the fix.
|
||||
5. **Review the diff before opening a pull request.** Identifiers exist as used, the test fails without the fix, nothing unrelated changed, and the description is true.
|
||||
6. **Look for a better pull request.** If another fixes the same thing, close yours when it is better or equal, and say why.
|
||||
7. **Report only what ran.** Say what you tested and what you could not run. Without a local build or test, say so and rely on CI before marking ready.
|
||||
8. **`main` changes through a pull request.** Do not push to it. Branch protection enforces this. There is no exception.
|
||||
9. **Stop and ask only about** licensing outside the allowlist, publishing, releases, a new remote, secrets, spending money, destructive git, and anything else hard to undo. Otherwise keep working. `plan/` is a gitignored notebook, not rules. Without it, do not ask for it and do not stop.
|
||||
10. **Keep secrets and personal details out** of the repository, logs and requests.
|
||||
11. **CI stays shared.** Workflows call `storytold/craft-common-actions`. Do not change CI in a feature or fix pull request. If CI cannot cover a change, open an issue there.
|
||||
12. **Guards say why.** A check that looks removable says what it prevents. Read that before changing it.
|
||||
13. **Instructions must be readable by everyone.** No notes about one machine, and no links to files a reader cannot open. `craftrules` is retired. Do not link to it.
|
||||
14. **Keep pull requests moving.** Concurrent pull requests are fine; unanswered actionable feedback is not. Respond in 7 days, a reminder at 14, closure eligible at 30, from when an actionable request waits on the author. Suggestions and unconfirmed bot findings do not start the clock. A fix, explanation or agreed plan resets it; unrelated activity does not. Waiting on a reviewer or maintainer pauses it and does not count against the author. A maintainer decides closure; it is never automatic. Reuse search results, do not poll, and stop at a rate limit.
|
||||
15. **No agent attribution.** A pull request does not say an agent wrote it. A repository rule may not require that.
|
||||
<!-- craft-house-rules:end -->
|
||||
<!-- craft-house-rules:start rust source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=387e0ccb4b49e567f60951d7d6fff7305054c89f43ad812b9993dc7c38528013 -->
|
||||
## Rust Craft repositories
|
||||
|
||||
These apply to native Craft apps and to shared Rust libraries. Select them by repository type. A repository that merely contains some Rust does not take this block. Rules later in a repository's file may be stricter, but not weaker. The source is `standards/house-rules-rust.md` in [storytold/craft-contrib](https://github.com/storytold/craft-contrib).
|
||||
|
||||
1. **Never crash.** Non-test code does not panic and has no `unsafe`, except where the repository file names an exception. Return errors, treat input as hostile, and bound recursion and allocations. New production crate roots copy the `deny` attribute from an existing crate: `clippy::unwrap_used`, `clippy::expect_used`, `clippy::panic`, `clippy::unimplemented`, `clippy::todo`, `clippy::unreachable`. Every crash fix comes with a test that panicked before the fix.
|
||||
2. **Clean room.** Learn from observed behaviour and public specifications. Do not copy another product's code, assets, strings or artwork. Check a dependency's licence before adding it.
|
||||
3. **Assets and fonts.** Every asset is original or suitably licensed, and is recorded in `ATTRIBUTION.md` in the same change. Font files live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), not in the app repository.
|
||||
4. **Rust only for native Craft apps.** No handwritten JavaScript or TypeScript, and no Tauri, Electron or webview shell, in those apps. Shared tooling follows its documented technical scope and maintainer-approved exceptions. Craft PR Review's existing Tauri and TypeScript UI is one such exception. It does not relax this rule for native apps. Preserve any existing web build.
|
||||
5. **Time goes through `chrono`.** Do not hand-roll calendar conversion. For dates beyond what `chrono` can represent, follow [standards/time.md](https://github.com/storytold/craft-contrib/blob/main/standards/time.md).
|
||||
6. **Contributor credits.** Add or edit only your own person's entry in `contributors/people.toml`, and only after asking them. Never guess a name or hand-edit `contributors.json`.
|
||||
|
||||
## Licence allowlist
|
||||
|
||||
Dependencies and assets use SPDX identifiers. An expression is allowed when every identifier is in this list. A repository may name a shorter list. It may not add an identifier here by editing its own copy of these rules.
|
||||
|
||||
- `ISC`
|
||||
- `MIT`
|
||||
- `BSD-2-Clause`
|
||||
- `BSD-3-Clause`
|
||||
- `Apache-2.0`
|
||||
- `OFL-1.1`
|
||||
- `Ubuntu-font-1.0`
|
||||
- `Bitstream-Vera`
|
||||
- `CC0-1.0`
|
||||
- `CC-BY-4.0`
|
||||
- `CC-BY-SA-4.0`
|
||||
- `LicenseRef-Public-Domain`
|
||||
|
||||
`LicenseRef-Storyteller-Trademark` is not in this list. It covers Storyteller brand marks only, where a repository already says so.
|
||||
<!-- craft-house-rules:end -->
|
||||
|
||||
# WordCraft — instructions for agents
|
||||
|
||||
WordCraft is a clean-room, open-source, Rust-native word processor targeting Microsoft Word parity — and going further on speed, openness and agent control. It runs natively on macOS, Windows, Linux and BSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../pdfcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign).
|
||||
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. `plan/` is gitignored (local only); if it's missing, start from `ROADMAP.md`, `docs/gaps.md` and `docs/roadmap.md` (Current focus).
|
||||
2. Follow the autonomous operation protocol (`plan/execution-plan.md` §5). Don't stop to ask unless a decision is genuinely the owner's (licensing, publishing, pushing to new remotes, secrets).
|
||||
Read [`ROADMAP.md`](ROADMAP.md), [`docs/gaps.md`](docs/gaps.md) and [`docs/roadmap.md`](docs/roadmap.md). `plan/` is an optional gitignored notebook. Do not ask for it and do not stop when it is absent.
|
||||
|
||||
## Never crash
|
||||
People trust WordCraft with their writing; a crash loses their work. **This outranks feature work**: never ship a feature through a panic path, and fix a crash before building on top of it.
|
||||
@@ -28,7 +80,7 @@ People trust WordCraft with their writing; a crash loses their work. **This outr
|
||||
- Never read, disassemble or copy anything inside the Word bundle (file names/listings only). Never commit files produced by Word. Never copy Microsoft wording beyond feature names.
|
||||
- File formats come from public specs: ECMA-376 / ISO 29500 (OOXML), the published RTF spec, OASIS ODF 1.3. Never copy GPL/LGPL/AGPL code (LibreOffice, AbiWord, pandoc…).
|
||||
|
||||
## Architecture (see `plan/architecture.md`)
|
||||
## Architecture
|
||||
| Layer | Crate | Job |
|
||||
|---|---|---|
|
||||
| L0 | `geom` | units, rects, measurement parsing |
|
||||
@@ -44,7 +96,6 @@ People trust WordCraft with their writing; a crash loses their work. **This outr
|
||||
- **Everything is a command.** User-visible behaviour = a `CommandSpec` in `crates/engine/src/cmd/*.rs` (id, label, ribbon location, shortcut, params doc, `enabled`, `run`) + tests. UI-only commands (`ui.*`) live in `crates/ui-egui/src/lib.rs`. The ribbon, shortcuts, command search, CLI, control channel and MCP all dispatch by id. Programmatic calls never open dialogs.
|
||||
- **Parity is measured:** `crates/engine/src/catalog.rs` lists Word's ribbon/menu features with command ids. `cargo xtask parity` writes `docs/parity-checklist.md`; a test enforces a floor that only rises. Implement missing ids to raise it.
|
||||
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd. The UI is thin and reads `Session` state; colours come from `theme::Tokens`.
|
||||
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math). A crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
|
||||
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
||||
|
||||
## Quality gates
|
||||
@@ -85,3 +136,5 @@ Progress docs: `ROADMAP.md` (stage, headline numbers, progress log), `docs/targe
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
|
||||
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
|
||||
|
||||
New workspace crates include `workspace = true` under `[lints]` in `Cargo.toml`. Production crate roots declare the crate-level lint attributes before modules; crate-level `//!` documentation may precede them.
|
||||
|
||||
@@ -1,71 +1 @@
|
||||
# WordCraft — instructions for agents
|
||||
|
||||
WordCraft is a clean-room, open-source, Rust-native word processor targeting Microsoft Word parity — and going further on speed, openness and agent control. It runs natively on macOS, Windows, Linux and BSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../pdfcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign).
|
||||
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. `plan/` is gitignored (local only); if it's missing, start from `ROADMAP.md`, `docs/gaps.md` and `docs/roadmap.md` (Current focus).
|
||||
2. Follow the autonomous operation protocol (`plan/execution-plan.md` §5). Don't stop to ask unless a decision is genuinely the owner's (licensing, publishing, pushing to new remotes, secrets).
|
||||
|
||||
## Never crash
|
||||
People trust WordCraft with their writing; a crash loses their work. **This outranks feature work**: never ship a feature through a panic path, and fix a crash before building on top of it.
|
||||
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
|
||||
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature returns an error or is disabled; it never panics.
|
||||
- **Input-derived numbers are hostile** (files, commands, MCP/control params): `get()` instead of `[i]`, slice strings only at char boundaries, checked/saturating arithmetic, clamp sizes, cap allocations, bound recursion.
|
||||
- **Last-resort guard:** `Session::run` turns an escaped panic into an error and restores the document. It's a safety net, not a licence.
|
||||
- **Prove it:** every crash fix lands with a regression test. `crates/engine/src/tests.rs::hostile_params_never_panic` runs every command with junk params — keep it green.
|
||||
- Production crate roots carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`; new crates start with it and add `[lints]` with `workspace = true` to their `Cargo.toml`. Tests may `unwrap` (`clippy.toml`).
|
||||
|
||||
## Assets: absolutely no Microsoft (or Adobe/Avid/Autodesk) iconography or images
|
||||
**This rule is non-negotiable.** Breaking it is the worst mistake you can make in this repo.
|
||||
- Never copy, trace, screenshot-crop or recreate icons, images, templates, themes, clip art, fonts, cursors, sounds or any other asset from Microsoft Word/Office or any Microsoft, Adobe, Avid or Autodesk product. Not even "temporarily".
|
||||
- Every asset must be **original** (drawn in code — prefer this: `crates/ui-egui/src/icons.rs`), **public domain / CC0**, **OSI open source**, or **Creative Commons that allows redistribution**, or licensed open source by a contributor who made it.
|
||||
- **Every asset file has a row in [`ATTRIBUTION.md`](ATTRIBUTION.md)** (author, source, licence). `cargo xtask assets` (part of `cargo xtask ci`) fails otherwise. Add the row in the same commit as the file.
|
||||
- Fonts: new font files go to [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts), never this repo; it's an optional build input (`CRAFT_FONTS_DIR`). Spelling word lists and hyphenation patterns live in [`storytold/craft-i18n`](https://github.com/storytold/craft-i18n) `language-data/`, never this repo; they are embedded from `CRAFT_I18N_DIR` (pinned by `craft-i18n.rev`, which CI checks out). Without it the app builds, but English spelling falls back to an installed Hunspell English or the built-in Moby word list (with no American/British variant table) and pattern hyphenation is off (Bulgarian, Turkish and Russian still hyphenate by rule), so proofing tests need it. Documents asking for proprietary fonts (Calibri, Aptos, Cambria…) are rendered with installed system fonts or metric-compatible open substitutes (`crates/fonts/src/word.rs`) — never bundle proprietary fonts.
|
||||
- Templates, sample documents and themes are our own text and colours (`crates/engine/src/sample.rs`, `cmd/design.rs`). Demo images are public domain, with sources recorded.
|
||||
|
||||
## Clean room
|
||||
- Microsoft Word is installed on the dev machine and may be **observed** black-box: run it, use its UI with synthetic documents, take screenshots **of its window only** (by window id — the machine runs other work; never capture the whole desktop) stored only under `plan/word/screenshots/` (gitignored, never committed).
|
||||
- Never read, disassemble or copy anything inside the Word bundle (file names/listings only). Never commit files produced by Word. Never copy Microsoft wording beyond feature names.
|
||||
- File formats come from public specs: ECMA-376 / ISO 29500 (OOXML), the published RTF spec, OASIS ODF 1.3. Never copy GPL/LGPL/AGPL code (LibreOffice, AbiWord, pandoc…).
|
||||
|
||||
## Architecture (see `plan/architecture.md`)
|
||||
| Layer | Crate | Job |
|
||||
|---|---|---|
|
||||
| L0 | `geom` | units, rects, measurement parsing |
|
||||
| L1 | `doc`, `fonts` | document model + edit primitives; font DB, shaping, Word font substitution |
|
||||
| L2 | `layout`, `docx`, `formats` | line breaking, pagination, tables, headers/footers, hit testing; OOXML I/O; TXT/MD/HTML/RTF/ODT |
|
||||
| L3 | `render`, `pdf` | vello_cpu rasteriser; krilla PDF |
|
||||
| L4 | `engine` | Session, undo, **command registry**, Word feature catalog, samples, I/O dispatch |
|
||||
| L5 | `mcp` | MCP server (headless or bridged to the app) |
|
||||
| L6 | `ui-egui` | Word-style egui front end (swappable) |
|
||||
| apps | `apps/wordcraft`, `apps/wordcraft-cli`, `apps/wordcraft-web` | binaries |
|
||||
| external | `craft-proof` (craft-libs, used as `wordcraft-proof`, sits at L1) | proofing: spelling, grammar, hyphenation; its word lists and patterns are craft-i18n's `language-data/` (`CRAFT_I18N_DIR`) |
|
||||
|
||||
- **Everything is a command.** User-visible behaviour = a `CommandSpec` in `crates/engine/src/cmd/*.rs` (id, label, ribbon location, shortcut, params doc, `enabled`, `run`) + tests. UI-only commands (`ui.*`) live in `crates/ui-egui/src/lib.rs`. The ribbon, shortcuts, command search, CLI, control channel and MCP all dispatch by id. Programmatic calls never open dialogs.
|
||||
- **Parity is measured:** `crates/engine/src/catalog.rs` lists Word's ribbon/menu features with command ids. `cargo xtask parity` writes `docs/parity-checklist.md`; a test enforces a floor that only rises. Implement missing ids to raise it.
|
||||
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd. The UI is thin and reads `Session` state; colours come from `theme::Tokens`.
|
||||
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
||||
|
||||
## Quality gates
|
||||
Before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). Commit after every arc of work that builds, with a task id in the message (`M2.1: line spacing dialog`).
|
||||
|
||||
## Typing behaves like Word
|
||||
Typing, Enter, Backspace, Tab, lists, AutoCorrect and AutoFormat follow Word's observed behaviour in [`docs/typing-parity.md`](docs/typing-parity.md), pinned by `crates/engine/src/tests/typing.rs` (keys go through the same commands as the keyboard). Change behaviour, doc and tests together; a typing fix lands with a test there. Compare headlessly (`ui_shot`); don't drive GUIs with keystrokes on the shared machine.
|
||||
|
||||
## Running and looking at the app
|
||||
- `cargo run --release -p wordcraft -- --sample --control 7981` (sample document + control channel).
|
||||
- Drive it with JSON lines on `127.0.0.1:7981`, each with the window's key from `~/.config/wordcraft/control-key.local-7981` (macOS: `~/Library/Application Support/WordCraft/`), e.g. `{"id":1,"key":"…","method":"engine.execute","params":{"command":"text.insert","params":{"text":"Hello"}}}` then `{"id":2,"key":"…","method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`; docs: `docs/control-protocol.md` (Keys).
|
||||
- **Headless window screenshots** (no focus stealing, works with a locked screen): `cargo run --release -p wordcraft-ui-egui --example ui_shot -- script.jsonl` (see the example's header).
|
||||
- **For UI work, look at the result** (screenshot, read the PNG) and compare with Word side by side.
|
||||
- CLI: `wordcraft-cli convert in.docx out.pdf`, `wordcraft-cli run --template sample --cmd 'select.text={"text":"Studio"}' --cmd format.bold --save out.docx`, `wordcraft-cli commands`, `wordcraft-cli parity`.
|
||||
- MCP: `wordcraft-cli mcp` (headless) or `wordcraft-cli mcp --connect 127.0.0.1:7981` (drives the running app). See `docs/mcp.md`.
|
||||
- Shell gotcha: `mv`/`cp` may be aliased interactive — use `/bin/mv -f` / `/bin/cp -f`.
|
||||
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done (disk).
|
||||
|
||||
## Roadmap
|
||||
Progress docs: `ROADMAP.md` (stage, headline numbers, progress log), `docs/target-app-parity.md` (the assessment), `docs/gaps.md` (ranked work list), `docs/roadmap.md` (milestones, Current focus), `docs/architecture.md`, and the parity checklists `docs/file-format-parity.md`, `docs/layout-parity.md`, `docs/typing-parity.md`, `docs/ui-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. When work lands, update the gap, the parity doc and the progress log, and bump each touched doc's status line and revision history.
|
||||
|
||||
## CI workflows
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
|
||||
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
|
||||
@AGENTS.md
|
||||
|
||||
Generated
+592
-130
File diff suppressed because it is too large
Load Diff
+31
-11
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.15.0"
|
||||
version = "0.18.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -24,10 +24,11 @@ wordcraft-pdf = { path = "crates/pdf" }
|
||||
# Proofing is craft-libs' craft-proof (it was crates/proof); its Language data is craft-i18n's
|
||||
# language-data/, embedded from CRAFT_I18N_DIR (craft-i18n.rev). The package keeps the
|
||||
# `wordcraft-proof` name here so `wordcraft_proof::` paths are unchanged.
|
||||
# craft-proof is storytold/craft-libs main at the merge of #497 (flag-based compounding), which
|
||||
# also has #496 (review follow-ups, en-AU) and the merged #482–#490/#495 stack; the font store's
|
||||
# craft-proof is storytold/craft-libs main at the merge of #590 (ca-ES-valencia reads the ca-valencia
|
||||
# pack), after #589 (French elision suggestions first) and #497 (flag-based compounding), which also
|
||||
# has #496 (review follow-ups, en-AU) and the merged #482–#490/#495 stack; the font store's
|
||||
# `language-pack` kind is #486 (CP4.1).
|
||||
wordcraft-proof = { package = "craft-proof", git = "https://github.com/storytold/craft-libs", rev = "47c9f62530db72bc5c3831c86653b47b20c640be", version = "=0.1.0" }
|
||||
wordcraft-proof = { package = "craft-proof", git = "https://github.com/storytold/craft-libs", rev = "01315d9c92b5b0e69082d35ca56099461a1efd45", version = "=0.1.0" }
|
||||
craft-font-store = { git = "https://github.com/storytold/craft-libs", rev = "762a06a4c6b48b522b7d4de84a87917ef90265e6", version = "=0.1.0" }
|
||||
craft-font-catalog = { git = "https://github.com/storytold/craft-libs", rev = "762a06a4c6b48b522b7d4de84a87917ef90265e6", version = "=0.1.0" }
|
||||
wordcraft-engine = { path = "crates/engine" }
|
||||
@@ -35,7 +36,8 @@ wordcraft-mcp = { path = "crates/mcp" }
|
||||
wordcraft-zotero = { path = "crates/zotero" }
|
||||
wordcraft-control-key = { path = "crates/control-key" }
|
||||
wordcraft-ui-egui = { path = "crates/ui-egui" }
|
||||
wordcraft-metafile = { path = "crates/metafile" }
|
||||
craft-codepage = { git = "https://github.com/storytold/craft-libs", rev = "41fb227542efb8a7748caa1c659f271d8605303c", version = "=0.1.0" }
|
||||
craft-metafile = { git = "https://github.com/storytold/craft-libs", rev = "41fb227542efb8a7748caa1c659f271d8605303c", version = "=0.1.0" }
|
||||
|
||||
# foundation
|
||||
kurbo = { version = "0.13", features = ["serde"] }
|
||||
@@ -65,12 +67,28 @@ wuff = "0.2.9"
|
||||
# `forbid(unsafe_code)` in its default build, no_std-capable (builds for wasm), BSD-3-Clause/MIT.
|
||||
brotli-decompressor = "5"
|
||||
vello_cpu = "0.2"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false, features = ["write"] }
|
||||
image = { version = "0.25", default-features = false }
|
||||
craft-image-codecs = { git = "https://github.com/storytold/craft-libs", rev = "41fb227542efb8a7748caa1c659f271d8605303c", version = "=0.1.0" }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", version = "=0.1.0", default-features = false, features = ["write"] }
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
|
||||
quick-xml = "0.41"
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] }
|
||||
craft-wordmodel = { git = "https://github.com/storytold/craft-libs", rev = "41fb227542efb8a7748caa1c659f271d8605303c", version = "=0.1.0" }
|
||||
craft-docx = { git = "https://github.com/storytold/craft-libs", rev = "41fb227542efb8a7748caa1c659f271d8605303c", version = "=0.1.0" }
|
||||
craft-rtf = { git = "https://github.com/storytold/craft-libs", rev = "41fb227542efb8a7748caa1c659f271d8605303c", version = "=0.1.0", features = ["doc"] }
|
||||
craft-doc-binary = { git = "https://github.com/storytold/craft-libs", rev = "41fb227542efb8a7748caa1c659f271d8605303c", version = "=0.1.0" }
|
||||
craft-flow = { git = "https://github.com/storytold/craft-libs", rev = "41fb227542efb8a7748caa1c659f271d8605303c", version = "=0.1.0" }
|
||||
craft-odf = { git = "https://github.com/storytold/craft-libs", rev = "41fb227542efb8a7748caa1c659f271d8605303c", version = "=0.1.0", features = ["text"] }
|
||||
craft-markdown = { git = "https://github.com/storytold/craft-libs", rev = "41fb227542efb8a7748caa1c659f271d8605303c", version = "=0.1.0" }
|
||||
craft-html = { git = "https://github.com/storytold/craft-libs", rev = "41fb227542efb8a7748caa1c659f271d8605303c", version = "=0.1.0", features = ["flow"] }
|
||||
craft-latex = { git = "https://github.com/storytold/craft-libs", rev = "41fb227542efb8a7748caa1c659f271d8605303c", version = "=0.1.0" }
|
||||
craft-math = { git = "https://github.com/storytold/craft-libs", rev = "41fb227542efb8a7748caa1c659f271d8605303c", version = "=0.1.0" }
|
||||
craft-mime = { git = "https://github.com/storytold/craft-libs", rev = "41fb227542efb8a7748caa1c659f271d8605303c", version = "=0.1.0", features = ["detect"] }
|
||||
craft-sheet = { git = "https://github.com/storytold/craft-libs", rev = "41fb227542efb8a7748caa1c659f271d8605303c", version = "=0.1.0" }
|
||||
craft-ods = { git = "https://github.com/storytold/craft-libs", rev = "41fb227542efb8a7748caa1c659f271d8605303c", version = "=0.1.0" }
|
||||
craft-xls = { git = "https://github.com/storytold/craft-libs", rev = "41fb227542efb8a7748caa1c659f271d8605303c", version = "=0.1.0" }
|
||||
craft-xlsx = { git = "https://github.com/storytold/craft-libs", rev = "41fb227542efb8a7748caa1c659f271d8605303c", version = "=0.1.0" }
|
||||
craft-xml = { git = "https://github.com/storytold/craft-libs", rev = "41fb227542efb8a7748caa1c659f271d8605303c", version = "=0.1.0" }
|
||||
craft-zip = { git = "https://github.com/storytold/craft-libs", rev = "41fb227542efb8a7748caa1c659f271d8605303c", version = "=0.1.0" }
|
||||
# zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates.
|
||||
miniz_oxide = "0.9"
|
||||
cfb = "0.15"
|
||||
@@ -91,6 +109,8 @@ zeroize = "1"
|
||||
# dependency of other crates.
|
||||
libc = "0.2"
|
||||
|
||||
# time
|
||||
|
||||
# ui
|
||||
egui = "0.37"
|
||||
egui_extras = { version = "0.37", default-features = false }
|
||||
@@ -131,7 +151,7 @@ debug = 0
|
||||
|
||||
# Required by craft-pdf: the backend rejects writers that failed during unwinding.
|
||||
[patch.crates-io]
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b" }
|
||||
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
# WordCraft
|
||||
|
||||
A clean-room, Rust-native word processor. These are the words its interface, docs and code use for
|
||||
the same things.
|
||||
|
||||
## Languages and proofing
|
||||
|
||||
**Interface language**:
|
||||
The language of WordCraft's own menus, buttons and messages.
|
||||
_Avoid_: Display language, UI locale
|
||||
|
||||
**Proofing language**:
|
||||
The language a piece of text is marked as, which decides how its spelling, grammar and hyphenation
|
||||
are checked. A language is told apart from another by its tag (language, script, region, variant),
|
||||
never by the name a list shows for it.
|
||||
_Avoid_: Text language, editing language, document language
|
||||
|
||||
**Language pack**:
|
||||
A downloadable bundle of a language's spelling dictionary and hyphenation patterns.
|
||||
_Avoid_: Dictionary download, language data (that is the embedded data)
|
||||
|
||||
**Language list**:
|
||||
The proofing languages offered in Set Proofing Language, Review › Language and the status bar's
|
||||
menu: the languages WordCraft names, plus those of every installed (and, in Set Proofing Language,
|
||||
every available) Language pack, each once.
|
||||
_Avoid_: Language menu (that is only one of the places it is shown)
|
||||
|
||||
**Your languages**:
|
||||
The proofing languages Options › Language lists: each English variant in use (the default proofing
|
||||
language and any in the open document), every language with an installed
|
||||
Language pack, and every proofing language used in the open document.
|
||||
_Avoid_: Authoring languages, preferred languages
|
||||
|
||||
**Default proofing language**:
|
||||
The proofing language new blank documents get, set with Set as default; without one, the interface
|
||||
language.
|
||||
_Avoid_: Preferred language
|
||||
|
||||
**Proofing status**:
|
||||
Where a language's proofing comes from: built in, an installed Language pack, your own dictionary,
|
||||
a system dictionary, available to install, or not available.
|
||||
|
||||
**Custom dictionary**:
|
||||
The words you added with Add to Dictionary, accepted in every language.
|
||||
_Avoid_: User dictionary (in the interface)
|
||||
|
||||
**Dictionaries folder**:
|
||||
A folder of Hunspell dictionaries you supply, used before the system's (an installed Language pack
|
||||
comes after it).
|
||||
_Avoid_: Custom dictionaries
|
||||
|
||||
## Export
|
||||
|
||||
**Strict export**:
|
||||
An export that writes nothing, and reports why, when a character or picture the output is meant
|
||||
to show would be missing from it. Text the output doesn't show (hidden text, deletions without
|
||||
markup) isn't checked.
|
||||
_Avoid_: Safe mode, validated export
|
||||
|
||||
**Missing glyph**:
|
||||
A character the output draws that no face in the font fallback chain can draw, so it would appear
|
||||
as an empty box.
|
||||
_Avoid_: Tofu, box, notdef character
|
||||
|
||||
**Missing picture**:
|
||||
A picture the document refers to that the output cannot include: its part is absent, its data
|
||||
cannot be decoded, its format is unsupported, or it is linked from outside the file.
|
||||
_Avoid_: Broken image, dropped image
|
||||
@@ -124,7 +124,7 @@ Every screenshot below is WordCraft itself, rendered offscreen by its own UI tes
|
||||
| **References** | Table of contents, footnotes and endnotes, citations and bibliography (APA, MLA, Chicago, IEEE), captions, table of figures, cross-references, index, table of authorities |
|
||||
| **Review** | Spelling and grammar with suggestions (English and Polish, by each run's language), thesaurus, word count, comments in margin balloons or a pane, track changes, accept/reject, compare documents, restrict editing, accessibility checker, document inspector |
|
||||
| **Mailings** | Mail merge from CSV, merge fields, address block, greeting line, rules, preview, finish to a document; envelopes and labels |
|
||||
| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe, Русский, Italiano, Deutsch, Polski, Čeština, Magyar, Français, Български, Tiếng Việt, 한국어, Svenska, Hrvatski, Nederlands, Dansk, Română, العربية (mirrored) or Slovenščina (follows the system language by default) |
|
||||
| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe, Русский, Italiano, Deutsch, Polski, Čeština, Magyar, Français, Български, Tiếng Việt, 한국어, Svenska, Hrvatski, Nederlands, Dansk, Română, العربية (mirrored) Slovenščina or Bosanski (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
|
||||
@@ -157,6 +157,7 @@ Command line:
|
||||
|
||||
```sh
|
||||
wordcraft-cli convert report.docx report.pdf # docx, pdf, odt, rtf, html, md, tex, txt, png
|
||||
wordcraft-cli convert report.docx report.pdf --strict # exit 3, no file, if a character or picture would be left out
|
||||
wordcraft-cli text report.docx # plain text
|
||||
wordcraft-cli inspect report.docx # structure as JSON
|
||||
wordcraft-cli run --template sample \
|
||||
@@ -164,6 +165,40 @@ wordcraft-cli run --template sample \
|
||||
wordcraft-cli run --script job.json # the same from a file: commands, then save to docx and pdf
|
||||
```
|
||||
|
||||
`convert --strict` (a `.pdf` or `.png` output only) and `render --strict` (a PNG of one page) refuse a silently incomplete render: they exit 3
|
||||
and write nothing if the output would draw a character no font has (an empty box) or leave out a
|
||||
picture. Exit codes are 0 (written), 1 (unreadable input, usage error, or `--strict` with another
|
||||
output type, checked before the input is read) and 3 (strict refusal). On exit 3, stderr has one
|
||||
`wordcraft-cli: strict: …` line per kind, then one JSON line:
|
||||
|
||||
```sh
|
||||
wordcraft-cli convert in.docx out.pdf --strict
|
||||
# wordcraft-cli: strict: 1 character cannot be drawn: U+2D30 (×3)
|
||||
# wordcraft-cli: strict: 1 picture(s) left out: word/media/image2.bin (header2.xml): unsupported format
|
||||
# {"schema":1,"strict":"failed","missing_glyphs":[{"cp":"U+2D30","count":3}],"missing_pictures":[{"part":"word/media/image2.bin","source_part":"word/header2.xml","reason":"unsupported_format"}],"truncated":false,"total_missing_glyphs":1,"total_missing_pictures":1,"glyphs_truncated":false,"pictures_truncated":false}
|
||||
```
|
||||
|
||||
The JSON line (schema 1, also what `file.strictReport` returns) has `schema`, `strict`
|
||||
(`"passed"` or `"failed"`), `missing_glyphs` (entries `{"cp", "count"}`, sorted by code point),
|
||||
`missing_pictures` (entries `{"part", "source_part", "reason"}`: the picture's part or target, the
|
||||
story part it sits in such as `word/header2.xml`, and a reason code; `source_part` is also
|
||||
`background` (the page picture), `body`, `header`, `footer`, `footnote`, `endnote`, `comment` or
|
||||
`text box` where the part name is not known (a document not opened from a DOCX), or a chunk path such as
|
||||
`word/afchunk1.docx > word/header1.xml` for a picture inside an embedded DOCX chunk), `truncated`, the two totals
|
||||
`total_missing_glyphs` and `total_missing_pictures`, and `glyphs_truncated` and
|
||||
`pictures_truncated` for each list. The reason codes are `missing_part`, `external_target`,
|
||||
`link_only`, `undecodable` and `unsupported_format`; the stderr lines use plain words for them. When a list is cut, the stderr line gives the total
|
||||
(`300 characters cannot be drawn (first 256 listed)`).
|
||||
Each list stops at 256 entries separately; `truncated` is true when either was cut, and the totals
|
||||
count every distinct character and picture found, those past the cap included. Only characters the output draws count (not hidden text, or deleted text
|
||||
without `--markup`), and a character a fallback font draws is fine. Text inside a WMF or EMF picture counts too, shaped in the font the picture names with no fallback. A Private Use Area character
|
||||
fails only if no installed font maps it. The result depends on the fonts installed on the host
|
||||
(see #5624 for a fixed font set). An external picture target is never fetched: it is reported, not
|
||||
loaded. Pictures dropped when a DOCX is opened (a missing part, an external target, or a `link only` picture with no embedded copy) are reported for DOCX input only. They have no page, so a PNG of any page is also refused for a missing or external picture anywhere in the document, unused headers included. `render --strict` checks only the page it
|
||||
writes (`--page`; `--scale` doesn't matter), prints nothing on stdout when it refuses, and reports the
|
||||
same way. The engine takes `strict: true` on `file.save`, `file.saveAs`, `file.exportPdf` and
|
||||
`file.exportPng`, and `file.strictReport {markup?, page?}` returns the report without writing.
|
||||
|
||||
Web: `cd apps/wordcraft-web && trunk serve`, then open <http://127.0.0.1:8771/?sample>.
|
||||
|
||||
## For agents: CLI and MCP
|
||||
@@ -299,15 +334,31 @@ adapter. The log file records the choice (lines starting with
|
||||
RISC-V (riscv64): `wordcraft-<ver>-linux-riscv64.tar.gz`, a tarball cross-compiled for glibc 2.39+
|
||||
(Ubuntu 24.04, Debian 13 and newer).
|
||||
|
||||
**Read Aloud** (Review › Read Aloud) speaks through a speech program installed on the system: it
|
||||
tries `espeak-ng`, then `espeak`, and when neither is installed it talks to Speech Dispatcher.
|
||||
The `.rpm` package recommends `espeak-ng`; the `.deb` recommends `espeak-ng` or
|
||||
`speech-dispatcher`, so the package manager installs one of the two by default. With the
|
||||
AppImage or the tarball, install one yourself if neither is there (`sudo apt install espeak-ng`,
|
||||
`sudo dnf install espeak-ng`, `sudo pacman -S espeak-ng`). The Flatpak doesn't speak yet: it has
|
||||
no audio access and doesn't include `espeak-ng`, `espeak` or Speech Dispatcher.
|
||||
|
||||
**Wayland:** on a Wayland desktop the app runs through XWayland when it is available, because
|
||||
winit (the windowing library) supports dragging files into the window only on X11. To use the
|
||||
native Wayland backend instead (for example for sharper text with fractional scaling, at the cost
|
||||
of file drag-and-drop), start it with `WORDCRAFT_WAYLAND=1`:
|
||||
of file drag-and-drop), choose **File › Options › Advanced › Linux display server › Wayland**; it
|
||||
applies the next time WordCraft starts, and **X11 (XWayland)** or **Automatic** switches back. The
|
||||
`WORDCRAFT_WAYLAND` environment variable overrides the setting: `1` for native Wayland, `0` for
|
||||
XWayland:
|
||||
|
||||
```sh
|
||||
WORDCRAFT_WAYLAND=1 wordcraft
|
||||
```
|
||||
|
||||
XWayland needs the X11 libraries, which the packages only recommend and the tarball doesn't
|
||||
install: libX11, libX11-xcb, libXcursor, libXi and libxkbcommon-x11 (on Debian and Ubuntu,
|
||||
`libx11-6`, `libx11-xcb1`, `libxcursor1`, `libxi6` and `libxkbcommon-x11-0`). Without them the app
|
||||
opens on native Wayland, whatever the setting says, and logs which ones are missing.
|
||||
|
||||
**Arch Linux (AUR).** Community members maintain three AUR packages; the project doesn't publish
|
||||
to the AUR itself. Install one of them:
|
||||
| Package | What it installs | Choose it when |
|
||||
|
||||
+342
-4
File diff suppressed because one or more lines are too long
@@ -21,3 +21,9 @@ wordcraft-mcp = { workspace = true }
|
||||
wordcraft-zotero = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sys-locale = "0.3"
|
||||
|
||||
[dev-dependencies]
|
||||
wordcraft-fonts = { workspace = true }
|
||||
wordcraft-geom = { workspace = true, features = ["image"] }
|
||||
image = { workspace = true }
|
||||
craft-zip = { workspace = true }
|
||||
|
||||
+216
-57
@@ -11,23 +11,30 @@ use std::path::{Path, PathBuf};
|
||||
use std::process::ExitCode;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_engine::Session;
|
||||
use wordcraft_engine::{CmdError, Session};
|
||||
|
||||
const USAGE: &str = "\
|
||||
wordcraft-cli — WordCraft from the command line
|
||||
|
||||
USAGE:
|
||||
wordcraft-cli convert <in> <out> [--markup] [--update-fields] [--format wordml2003|flatopc]
|
||||
wordcraft-cli convert <in> <out> [--markup] [--update-fields] [--strict] [--format wordml2003|flatopc]
|
||||
convert between formats (docx, doc [read], pdf, odt, rtf, html, md, tex, txt, json, png,
|
||||
xml); --update-fields refreshes unlocked fields before saving;
|
||||
xml); --update-fields refreshes unlocked fields before saving
|
||||
(if protection blocks updates, they are skipped with a warning);
|
||||
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
|
||||
tracked changes in pdf/png, a comment list in html/md/txt;
|
||||
--strict (pdf or png output only) writes nothing and exits 3
|
||||
if a character can't be drawn or a picture is missing or
|
||||
undecodable; the report is on stderr, with one JSON line
|
||||
wordcraft-cli info <file> pages, words, paragraphs, properties (JSON)
|
||||
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 render <file> <out.png> [--page N] [--scale S] [--strict]
|
||||
--strict writes nothing and exits 3 if a character on the
|
||||
page can't be drawn or a picture is missing or undecodable
|
||||
(report on stderr, as for convert --strict)
|
||||
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
|
||||
@@ -77,6 +84,14 @@ fn wants_help(cmd: &str, rest: &[String]) -> bool {
|
||||
SUBCOMMANDS.contains(&cmd) && rest.iter().any(|a| a == "--help" || a == "-h")
|
||||
}
|
||||
|
||||
/// A failure whose message may carry text from a file or from the engine's reading of one (a
|
||||
/// picture's part name, a parse error quoting the input): control, bidi and invisible characters
|
||||
/// are escaped, so it can't forge or spoof a line (the CLI's own messages, which have line breaks
|
||||
/// on purpose, are not routed through here). Backslashes stay: messages quote Windows paths.
|
||||
fn engine_error(e: impl fmt::Display) -> Failure {
|
||||
Failure::Error(escape_text(&e.to_string(), false))
|
||||
}
|
||||
|
||||
/// The error every subcommand gives when a required path is not there.
|
||||
fn missing_argument() -> Failure {
|
||||
format!("missing argument\n\n{USAGE}").into()
|
||||
@@ -85,11 +100,11 @@ fn missing_argument() -> Failure {
|
||||
/// 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> {
|
||||
fn open(path: &Path, password: Option<&str>) -> Result<Session, Failure> {
|
||||
// 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 (doc, encrypted) = wordcraft_engine::io::open_path_in(path, password, ®ion).map_err(engine_error)?;
|
||||
let mut s = session(doc);
|
||||
s.path = Some(path.into());
|
||||
if encrypted {
|
||||
@@ -98,8 +113,8 @@ fn open(path: &Path, password: Option<&str>) -> Result<Session, String> {
|
||||
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`.
|
||||
/// The commands that open a file, and which positional argument it is, excluding option values.
|
||||
/// `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,
|
||||
@@ -108,8 +123,14 @@ const OPENS_FILE: &[(&str, usize)] = &[("convert", 0), ("info", 0), ("text", 0),
|
||||
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 specs = match cmd {
|
||||
"convert" => CONVERT_OPTIONS,
|
||||
"render" => RENDER_OPTIONS,
|
||||
"zotero" => ZOTERO_OPTIONS,
|
||||
_ => PASSWORD_OPTIONS,
|
||||
};
|
||||
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),
|
||||
Some((_, n)) => positional_indices(args.get(1..).unwrap_or_default(), specs).get(*n).map(|i| i + 1),
|
||||
None if cmd == "run" => args.iter().position(|a| a == "--file").map(|i| i + 1),
|
||||
None => None,
|
||||
};
|
||||
@@ -145,8 +166,13 @@ fn password_from(args: &[String]) -> Result<Option<String>, String> {
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// path. Uses the same option specifications as [`validate_options`].
|
||||
fn positionals<'a>(args: &'a [String], specs: &[OptionSpec]) -> Vec<&'a String> {
|
||||
positional_indices(args, specs).into_iter().filter_map(|i| args.get(i)).collect()
|
||||
}
|
||||
|
||||
/// Keep the original indices too, so [`text_args`] can open a positional path by its native name.
|
||||
fn positional_indices(args: &[String], specs: &[OptionSpec]) -> Vec<usize> {
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while let Some(arg) = args.get(i) {
|
||||
@@ -156,7 +182,7 @@ fn positionals<'a>(args: &'a [String], specs: &[OptionSpec]) -> Vec<&'a String>
|
||||
i += 2;
|
||||
} else {
|
||||
if !arg.starts_with("--") {
|
||||
out.push(arg);
|
||||
out.push(i);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
@@ -187,7 +213,7 @@ fn listed_text(doc: &wordcraft_doc::Document) -> Result<String, String> {
|
||||
/// The command also checks whether the open document is protected: this name determines which
|
||||
/// named editing exceptions the session can use, so the flag must not bypass that check.
|
||||
fn set_author(s: &mut Session, name: &str) -> Result<(), String> {
|
||||
s.run("file.setAuthor", &json!({"name": name})).map(|_| ()).map_err(|e| format!("--author: {e}"))
|
||||
s.run("file.setAuthor", &json!({"name": name})).map(|_| ()).map_err(|e| escape_text(&format!("--author: {e}"), false))
|
||||
}
|
||||
|
||||
fn arg_value(args: &[String], name: &str) -> Option<String> {
|
||||
@@ -299,10 +325,12 @@ 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: "--strict", takes_value: false },
|
||||
OptionSpec { name: "--password", takes_value: true },
|
||||
OptionSpec { name: "--password-file", takes_value: true },
|
||||
];
|
||||
const RENDER_OPTIONS: &[OptionSpec] = &[
|
||||
OptionSpec { name: "--strict", takes_value: false },
|
||||
OptionSpec { name: "--page", takes_value: true },
|
||||
OptionSpec { name: "--scale", takes_value: true },
|
||||
OptionSpec { name: "--password", takes_value: true },
|
||||
@@ -339,6 +367,9 @@ enum Failure {
|
||||
/// Whoever read stdout closed it (`wordcraft-cli commands --json | head`): there is nobody left
|
||||
/// to write to, so stop quietly and exit 0 like other command-line tools.
|
||||
StdoutClosed,
|
||||
/// `convert --strict` refused: the text for stderr (the report's lines, then its JSON line).
|
||||
/// Exit status 3.
|
||||
Strict(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for Failure {
|
||||
@@ -346,6 +377,7 @@ impl fmt::Display for Failure {
|
||||
match self {
|
||||
Failure::Error(e) => f.write_str(e),
|
||||
Failure::StdoutClosed => f.write_str("stdout closed"),
|
||||
Failure::Strict(report) => f.write_str(report),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -391,11 +423,104 @@ fn valid_connect_addr(addr: &str) -> bool {
|
||||
!host.is_empty() && port.parse::<u16>().is_ok()
|
||||
}
|
||||
|
||||
/// A failed `--strict` export: a strict refusal becomes [`Failure::Strict`] carrying what stderr
|
||||
/// shows (each line of the report as `wordcraft-cli: …`, then its JSON on one line, from
|
||||
/// `file.strictReport` for `page` of a page image, else the PDF); any other error is an ordinary
|
||||
/// failure.
|
||||
fn refusal(s: &mut Session, e: wordcraft_engine::CmdError, markup: bool, page: Option<u64>) -> Failure {
|
||||
if !matches!(&e, wordcraft_engine::CmdError::Failed(m) if m.starts_with("strict: ")) {
|
||||
return engine_error(e);
|
||||
}
|
||||
let mut params = json!({"markup": markup});
|
||||
if let Some(page) = page {
|
||||
params["page"] = json!(page);
|
||||
}
|
||||
Failure::Strict(match s.run("file.strictReport", ¶ms).map_err(|e| e.to_string()) {
|
||||
Ok(json) => {
|
||||
let mut text = String::new();
|
||||
for line in report_lines(&json) {
|
||||
text += &format!("wordcraft-cli: {}\n", escape_peer(&line));
|
||||
}
|
||||
text + &json_line_safe(&json.to_string())
|
||||
}
|
||||
Err(e) => format!("wordcraft-cli: strict: refused, and the report could not be made: {}", escape_text(&e, false)),
|
||||
})
|
||||
}
|
||||
|
||||
/// The report's human lines, from its JSON (the engine's `StrictReport::lines`).
|
||||
fn report_lines(json: &Value) -> Vec<String> {
|
||||
wordcraft_engine::strict::lines_from_json(json)
|
||||
}
|
||||
|
||||
/// Explicitly update unlocked fields in a session before saving the document.
|
||||
///
|
||||
/// If field updates are disabled by document protection (`CmdError::Disabled`), logs a warning to
|
||||
/// stderr and continues conversion rather than aborting. Other command failures are converted to
|
||||
/// [`Failure`] via [`engine_error`].
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `s` - The active document [`Session`] whose unlocked fields should be refreshed.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `Err(Failure)` if the command fails with an error other than `CmdError::Disabled`.
|
||||
fn update_fields(s: &mut Session) -> Result<(), Failure> {
|
||||
if let Err(e) = s.run("references.updateFields", &json!({})) {
|
||||
if matches!(e, CmdError::Disabled(_)) {
|
||||
eprintln!("wordcraft-cli: warning: {e}");
|
||||
} else {
|
||||
return Err(engine_error(e));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `convert <in> <out>`: `rest` is the command line after `convert`, `open` opens the input.
|
||||
fn run_convert(rest: &[String], open: impl Fn(&str, Option<&str>) -> Result<Session, Failure>) -> Result<(), Failure> {
|
||||
validate_options("convert", rest, CONVERT_OPTIONS)?;
|
||||
// `--format wordml2003` (or `flatopc`) picks the kind of `.xml` file; its value isn't a path.
|
||||
let format = match find_option_arg(rest, "--format") {
|
||||
OptionArg::Absent => None,
|
||||
OptionArg::Duplicate => return Err("convert: duplicate --format option".into()),
|
||||
OptionArg::MissingValue => return Err("convert: --format requires a value".into()),
|
||||
OptionArg::Value(value) => Some(value),
|
||||
};
|
||||
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 strict = rest.iter().any(|a| a == "--strict");
|
||||
// Before the input is opened: a wrong output type is a usage error, not a file error.
|
||||
let to_png = o.to_ascii_lowercase().ends_with(".png");
|
||||
if strict && !(to_png || o.to_ascii_lowercase().ends_with(".pdf")) {
|
||||
return Err("convert: --strict needs a .pdf or .png output".into());
|
||||
}
|
||||
let password = password_from(rest)?;
|
||||
let mut s = open(&i, password.as_deref())?;
|
||||
if rest.iter().any(|a| a == "--update-fields") {
|
||||
update_fields(&mut s)?;
|
||||
}
|
||||
let r = if to_png {
|
||||
s.run("file.exportPng", &json!({"path": o, "page": 1, "markup": markup, "strict": strict}))
|
||||
} else {
|
||||
let mut params = json!({"path": o, "markup": markup, "strict": strict});
|
||||
if let Some(f) = format {
|
||||
params["format"] = json!(f);
|
||||
}
|
||||
s.run("file.save", ¶ms)
|
||||
};
|
||||
if let Err(e) = r {
|
||||
return Err(if strict { refusal(&mut s, e, markup, to_png.then_some(1)) } else { engine_error(e) });
|
||||
}
|
||||
eprintln!("wrote {o}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_run_script(rest: &[String], script_path: Option<&Path>) -> Result<Option<script::Script>, Failure> {
|
||||
match arg_value(rest, "--script") {
|
||||
Some(path) if !path.starts_with("--") => {
|
||||
let source = script_path.unwrap_or(Path::new(&path));
|
||||
Ok(Some(script::load(source)?))
|
||||
Ok(Some(script::load(source).map_err(engine_error)?))
|
||||
}
|
||||
_ if rest.iter().any(|arg| arg == "--script") => Err("run: --script requires a value".into()),
|
||||
_ => Ok(None),
|
||||
@@ -427,37 +552,13 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
||||
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 = 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 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 {
|
||||
let mut params = json!({"path": o, "markup": markup});
|
||||
if let Some(f) = format {
|
||||
params["format"] = json!(f);
|
||||
}
|
||||
s.run("file.save", ¶ms)
|
||||
};
|
||||
r.map_err(|e| e.to_string())?;
|
||||
eprintln!("wrote {o}");
|
||||
Ok(())
|
||||
}
|
||||
"convert" => run_convert(&rest, open),
|
||||
"info" => {
|
||||
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();
|
||||
let info = serde_json::to_string_pretty(&s.run("file.info", &json!({})).map_err(engine_error)?).unwrap_or_default();
|
||||
write_stdout(|o| writeln!(o, "{info}"))
|
||||
}
|
||||
"text" => {
|
||||
@@ -473,7 +574,7 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
||||
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();
|
||||
let tree = serde_json::to_string_pretty(&s.run("document.inspect", &json!({})).map_err(engine_error)?).unwrap_or_default();
|
||||
write_stdout(|o| writeln!(o, "{tree}"))
|
||||
}
|
||||
"render" => {
|
||||
@@ -487,7 +588,17 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
||||
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())?;
|
||||
let strict = rest.iter().any(|a| a == "--strict");
|
||||
let mut params = json!({"path": o, "page": page, "scale": scale, "strict": strict});
|
||||
if strict {
|
||||
// The check and its report must see the page as it is written.
|
||||
params["markup"] = json!(false);
|
||||
}
|
||||
let r = match s.run("file.exportPng", ¶ms) {
|
||||
Ok(r) => r,
|
||||
Err(e) if strict => return Err(refusal(&mut s, e, false, Some(page))),
|
||||
Err(e) => return Err(engine_error(e)),
|
||||
};
|
||||
write_stdout(|o| writeln!(o, "{r}"))
|
||||
}
|
||||
"run" => {
|
||||
@@ -506,8 +617,8 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
||||
let mut s = session(wordcraft_doc::Document::new());
|
||||
let first = s.document_id();
|
||||
let params = t.map_or_else(|| json!({}), |t| json!({"template": t}));
|
||||
s.run("file.new", ¶ms).map_err(|e| e.to_string())?;
|
||||
s.close_document(first).map_err(|e| e.to_string())?;
|
||||
s.run("file.new", ¶ms).map_err(engine_error)?;
|
||||
s.close_document(first).map_err(engine_error)?;
|
||||
s
|
||||
}
|
||||
};
|
||||
@@ -516,7 +627,7 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
||||
}
|
||||
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))?;
|
||||
let r = s.run(id, params).map_err(|e| engine_error(format!("command {} ({id}): {e}", n + 1)))?;
|
||||
if print {
|
||||
write_stdout(|o| writeln!(o, "{id}: {r}"))?;
|
||||
}
|
||||
@@ -526,10 +637,12 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
||||
if rest.get(i).map(String::as_str) == Some("--cmd") {
|
||||
let spec = rest.get(i + 1).ok_or("--cmd needs a value")?;
|
||||
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}"))?),
|
||||
Some((id, p)) => {
|
||||
(id.to_string(), serde_json::from_str::<Value>(p).map_err(|e| engine_error(format!("{id}: bad JSON params: {e}")))?)
|
||||
}
|
||||
None => (spec.clone(), json!({})),
|
||||
};
|
||||
let r = s.run(&id, ¶ms).map_err(|e| format!("{id}: {e}"))?;
|
||||
let r = s.run(&id, ¶ms).map_err(|e| engine_error(format!("{id}: {e}")))?;
|
||||
if print {
|
||||
write_stdout(|o| writeln!(o, "{id}: {r}"))?;
|
||||
}
|
||||
@@ -549,7 +662,7 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
||||
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)?;
|
||||
wordcraft_engine::io::save_bytes(out, &s.doc).map_err(engine_error)?;
|
||||
}
|
||||
}
|
||||
for out in outputs {
|
||||
@@ -565,7 +678,7 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
||||
} else {
|
||||
s.run("file.save", &json!({"path": out}))
|
||||
};
|
||||
r.map_err(|e| e.to_string())?;
|
||||
r.map_err(engine_error)?;
|
||||
eprintln!("wrote {out}");
|
||||
}
|
||||
Ok(())
|
||||
@@ -664,10 +777,12 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
||||
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}"))?),
|
||||
Some((id, p)) => {
|
||||
(id.to_string(), serde_json::from_str::<Value>(p).map_err(|e| engine_error(format!("{id}: bad JSON params: {e}")))?)
|
||||
}
|
||||
None => (spec.clone(), json!({})),
|
||||
};
|
||||
s.run(&id, ¶ms).map_err(|e| format!("{id}: {e}"))?;
|
||||
s.run(&id, ¶ms).map_err(|e| engine_error(format!("{id}: {e}")))?;
|
||||
}
|
||||
let mut opts = wordcraft_zotero::client::Options::default();
|
||||
if let Some(p) = arg_value(&rest, "--port") {
|
||||
@@ -684,13 +799,13 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
||||
}
|
||||
r
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
.map_err(engine_error)?;
|
||||
for a in &host.alerts {
|
||||
eprintln!("Zotero: {}", escape_peer(a));
|
||||
}
|
||||
eprintln!("{} calls, {}", out.calls, if out.completed { "completed" } else { "ended without completing (cancelled?)" });
|
||||
if let Some(o) = arg_value(&rest, "--save") {
|
||||
s.run("file.save", &json!({"path": o})).map_err(|e| e.to_string())?;
|
||||
s.run("file.save", &json!({"path": o})).map_err(engine_error)?;
|
||||
eprintln!("wrote {o}");
|
||||
}
|
||||
Ok(())
|
||||
@@ -714,22 +829,62 @@ fn clip(s: &str) -> String {
|
||||
/// overrides are shown as escapes (`\\`, `\n`, `\u{1b}`), so it can't end its line, drive the
|
||||
/// terminal or pass for other output.
|
||||
fn escape_peer(s: &str) -> String {
|
||||
escape_text(s, true)
|
||||
}
|
||||
|
||||
/// Whether `c` must not reach a terminal as itself: a control character, a line or paragraph
|
||||
/// separator, a bidi control, or one that shows nothing (soft hyphen, zero-width characters, word
|
||||
/// joiners, the byte order mark, tag characters), which can hide or reorder what a line says.
|
||||
fn is_unsafe_char(c: char) -> bool {
|
||||
c.is_control()
|
||||
|| matches!(
|
||||
c,
|
||||
'\u{00ad}'
|
||||
| '\u{061c}'
|
||||
| '\u{200b}'..='\u{200f}'
|
||||
| '\u{2028}'..='\u{202e}'
|
||||
| '\u{2060}'..='\u{2064}'
|
||||
| '\u{2066}'..='\u{2069}'
|
||||
| '\u{feff}'
|
||||
| '\u{e0000}'..='\u{e007f}'
|
||||
)
|
||||
}
|
||||
|
||||
/// [`escape_peer`], with backslashes escaped only when `backslash` (error messages quote Windows
|
||||
/// paths, and a backslash can't forge or hide anything).
|
||||
fn escape_text(s: &str, backslash: bool) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\\' if backslash => out.push_str("\\\\"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
c if c.is_control() || matches!(c, '\u{061c}' | '\u{200e}' | '\u{200f}' | '\u{2028}'..='\u{202e}' | '\u{2066}'..='\u{2069}') => {
|
||||
out.extend(c.escape_unicode())
|
||||
}
|
||||
c if is_unsafe_char(c) => out.extend(c.escape_unicode()),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A JSON line safe to print: serde escapes control characters below U+0020 but not the other
|
||||
/// characters [`is_unsafe_char`] names, which a crafted file could put in a report. They become
|
||||
/// `\uXXXX` escapes (a surrogate pair above U+FFFF), so the line parses to the same value and
|
||||
/// can't drive or spoof a terminal.
|
||||
fn json_line_safe(json: &str) -> String {
|
||||
let mut out = String::with_capacity(json.len());
|
||||
for c in json.chars() {
|
||||
if is_unsafe_char(c) {
|
||||
for unit in c.encode_utf16(&mut [0u16; 2]) {
|
||||
out.push_str(&format!("\\u{unit:04x}"));
|
||||
}
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn parity_markdown(p: &Value) -> String {
|
||||
let mut s = String::from(
|
||||
"# WordCraft feature parity\n\nGenerated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry.\n\n",
|
||||
@@ -770,6 +925,10 @@ fn main() -> ExitCode {
|
||||
eprintln!("wordcraft-cli: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
Err(Failure::Strict(report)) => {
|
||||
eprintln!("{report}");
|
||||
ExitCode::from(3)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,22 @@ fn unknown_options_are_rejected_before_file_access() {
|
||||
assert_eq!(run(&args(&["zotero", "refresh", "missing.docx", "--prot", "1"])), Err("zotero: unknown option --prot".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_rejects_malformed_format_options_before_file_access() {
|
||||
for (options, error) in [
|
||||
(vec!["--format"], "convert: --format requires a value"),
|
||||
(vec!["--format", "--markup"], "convert: --format requires a value"),
|
||||
(vec!["--format", "flatopc", "--format", "wordml2003"], "convert: duplicate --format option"),
|
||||
] {
|
||||
let mut argv = args(&["convert", "missing.docx", "out.xml"]);
|
||||
argv.extend(args(&options));
|
||||
assert_eq!(run(&argv), Err(error.into()), "{options:?}");
|
||||
}
|
||||
// A valid single value reaches file access, rather than a format-option error.
|
||||
let error = run(&args(&["convert", "missing.docx", "out.xml", "--format", "flatopc"])).unwrap_err().to_string();
|
||||
assert!(error.starts_with("missing.docx:"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_or_h_after_a_subcommand_asks_for_the_usage() {
|
||||
for cmd in SUBCOMMANDS {
|
||||
@@ -195,6 +211,10 @@ fn only_the_opened_file_may_be_other_than_unicode() {
|
||||
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!["render".into(), "--page".into(), "2".into(), latin1(), "out.png".into()]), Some(PathBuf::from(latin1())));
|
||||
assert_eq!(ok(vec!["convert".into(), "--format".into(), "flatopc".into(), latin1(), "out.xml".into()]), Some(PathBuf::from(latin1())));
|
||||
assert_eq!(ok(vec!["info".into(), "--password".into(), "secret".into(), latin1()]), Some(PathBuf::from(latin1())));
|
||||
assert_eq!(ok(vec!["zotero".into(), "--port".into(), "23119".into(), "refresh".into(), latin1()]), 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
|
||||
@@ -212,6 +232,7 @@ fn only_the_opened_file_may_be_other_than_unicode() {
|
||||
for (raw, n) in [
|
||||
(vec!["zotero".into(), latin1(), "in.docx".into()], 2),
|
||||
(vec!["render".into(), "in.docx".into(), latin1()], 3),
|
||||
(vec!["render".into(), "--page".into(), latin1(), "in.docx".into(), "out.png".into()], 3),
|
||||
(vec!["run".into(), "--save".into(), latin1()], 3),
|
||||
(vec!["commands".into(), latin1()], 2),
|
||||
] {
|
||||
@@ -543,3 +564,19 @@ fn script_parse_resolves_paths_against_the_base() {
|
||||
assert_eq!(s.save, vec!["o.docx".to_string()]);
|
||||
assert_eq!(s.commands.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invisible_characters_are_escaped_and_json_lines_still_parse_back() {
|
||||
let hidden = "a\u{ad}b\u{200b}c\u{200d}d\u{2060}e\u{2064}f\u{feff}g\u{e0041}h\u{e007f}i\u{85}j\u{202e}k";
|
||||
let shown = escape_peer(hidden);
|
||||
assert!(!shown.chars().any(is_unsafe_char), "{shown}");
|
||||
assert!(shown.contains("\\u{e0041}") && shown.contains("\\u{ad}"), "{shown}");
|
||||
// Errors keep a Windows path readable.
|
||||
assert_eq!(escape_text("C:\\dir\\a.docx", false), "C:\\dir\\a.docx");
|
||||
|
||||
let value = json!({"part": hidden, "plain": "ünïcode ok"});
|
||||
let line = json_line_safe(&value.to_string());
|
||||
assert!(!line.chars().any(is_unsafe_char), "{line}");
|
||||
assert!(line.contains("\\ud800") || line.contains("\\udb40"), "{line}");
|
||||
assert_eq!(serde_json::from_str::<Value>(&line).unwrap(), value);
|
||||
}
|
||||
|
||||
@@ -47,3 +47,65 @@ fn convert_refresh_is_opt_in() {
|
||||
}
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// Converting a protected document with `--update-fields` skips updating fields with a warning,
|
||||
/// retains the cached field result, and preserves the protection mode on output.
|
||||
#[test]
|
||||
fn convert_protected_document_with_update_fields_skips_with_warning() {
|
||||
let dir = std::env::temp_dir().join(format!("wc-convert-fields-prot-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
for (mode, expected_warning) in [("readOnly", "protected"), ("forms", "protected"), ("comments", "comments")] {
|
||||
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();
|
||||
doc.insert_object(
|
||||
&Pos::body(0, 0),
|
||||
InlineObject::Field {
|
||||
instr: "PAGEREF target".into(),
|
||||
result: "104".into(),
|
||||
locked: false,
|
||||
dirty: true,
|
||||
code: false,
|
||||
form: String::new(),
|
||||
},
|
||||
&Default::default(),
|
||||
)
|
||||
.unwrap();
|
||||
doc.settings.protection = Some(mode.into());
|
||||
let input = dir.join(format!("in_{mode}.json"));
|
||||
std::fs::write(&input, serde_json::to_vec(&doc).unwrap()).unwrap();
|
||||
let docx = dir.join(format!("in_{mode}.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));
|
||||
|
||||
let output = dir.join(format!("out_{mode}.json"));
|
||||
let _ = std::fs::remove_file(&output);
|
||||
|
||||
let mut cmd = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli"));
|
||||
cmd.arg("convert").arg(&docx).arg(&output).arg("--update-fields");
|
||||
let run = cmd.output().unwrap();
|
||||
let stderr = String::from_utf8_lossy(&run.stderr);
|
||||
assert!(run.status.success(), "exit status was failure for mode {mode}: {stderr}");
|
||||
assert!(stderr.contains("warning:"), "stderr missing warning for mode {mode}: {stderr}");
|
||||
assert!(stderr.contains(expected_warning), "stderr missing '{expected_warning}' for mode {mode}: {stderr}");
|
||||
assert!(output.exists(), "output file was not created for mode {mode}");
|
||||
|
||||
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();
|
||||
// Result should remain "104" (cached) because the field could not be updated under protection
|
||||
assert_eq!(results, vec!["104"], "field result was unexpectedly modified for mode {mode}");
|
||||
assert_eq!(out.settings.protection.as_deref(), Some(mode), "protection mode was not preserved for mode {mode}");
|
||||
}
|
||||
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
@@ -6,4 +6,5 @@ mod help;
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
mod non_utf8_args;
|
||||
mod picture_then_typing;
|
||||
mod strict;
|
||||
mod text_tracked;
|
||||
|
||||
@@ -53,6 +53,15 @@ fn the_file_a_command_opens_may_have_any_name() {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_option_before_the_input_keeps_its_native_name() {
|
||||
let (dir, file) = folder("option-first");
|
||||
let saved = dir.join("out.xml");
|
||||
ok(&cli(&["convert".into(), "--format".into(), "flatopc".into(), file, saved.clone().into()]));
|
||||
assert!(std::fs::read_to_string(saved).unwrap().contains("Bonjour"));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_other_such_argument_is_an_error_naming_it() {
|
||||
let (dir, file) = folder("error");
|
||||
|
||||
@@ -0,0 +1,493 @@
|
||||
//! `convert --strict` (#6005): exit 3 and a report when a PDF or PNG would silently lack a
|
||||
//! character or a picture, nothing written; exit 0 otherwise.
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output};
|
||||
|
||||
use wordcraft_doc::Document;
|
||||
use wordcraft_fonts::FontDb;
|
||||
|
||||
const NS: &str = 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""#;
|
||||
const REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
|
||||
const RELS_NS: &str = "http://schemas.openxmlformats.org/package/2006/relationships";
|
||||
|
||||
fn dir(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("wc-cli-strict-{name}-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn convert(input: &Path, output: &Path, strict: bool) -> Output {
|
||||
let mut cmd = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli"));
|
||||
cmd.arg("convert").arg(input).arg(output);
|
||||
if strict {
|
||||
cmd.arg("--strict");
|
||||
}
|
||||
cmd.output().unwrap()
|
||||
}
|
||||
|
||||
fn stderr(o: &Output) -> String {
|
||||
String::from_utf8_lossy(&o.stderr).into_owned()
|
||||
}
|
||||
|
||||
/// The report's JSON line: the last line of stderr.
|
||||
fn report_json(o: &Output) -> serde_json::Value {
|
||||
let text = stderr(o);
|
||||
let last = text.lines().last().unwrap_or_default();
|
||||
serde_json::from_str(last).unwrap_or_else(|e| panic!("last stderr line is not JSON ({e}): {text}"))
|
||||
}
|
||||
|
||||
fn json_input(d: &Path, text: &str) -> PathBuf {
|
||||
let path = d.join("in.json");
|
||||
std::fs::write(&path, serde_json::to_vec(&Document::from_text(text)).unwrap()).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
/// Built without craft-fonts, the host's own fonts decide what is drawable: skip, as the layout
|
||||
/// tests do.
|
||||
fn without_fonts() -> bool {
|
||||
let missing = option_env!("CRAFT_FONTS_DIR").is_none_or(str::is_empty);
|
||||
if missing {
|
||||
eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a checkout)");
|
||||
}
|
||||
missing
|
||||
}
|
||||
|
||||
/// A code point in unassigned plane 4 that no face of the host covers.
|
||||
fn uncovered() -> char {
|
||||
let db = FontDb::global();
|
||||
let primary = wordcraft_fonts::word::resolve(&Document::default().settings.minor_font, false, false).face;
|
||||
(0x40000u32..0x4FFF0)
|
||||
.filter_map(char::from_u32)
|
||||
.find(|c| !primary.covers(*c) && db.fallback_styled(*c, primary.id(), false, false).is_none() && db.fallback_for(*c, primary.id()).is_none())
|
||||
.expect("the host has faces covering all of plane 4")
|
||||
}
|
||||
|
||||
fn png() -> Vec<u8> {
|
||||
wordcraft_geom::picture::encode_format(
|
||||
&image::DynamicImage::from(image::RgbaImage::from_pixel(4, 4, image::Rgba([20, 90, 200, 255])).clone()),
|
||||
image::ImageFormat::Png,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn pic(rid: &str) -> String {
|
||||
format!(
|
||||
r#"<w:r><w:drawing><wp:inline><wp:extent cx="508000" cy="508000"/><wp:docPr id="1" name="P"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="0" name="p"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="{rid}"/></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="508000" cy="508000"/></a:xfrm><a:prstGeom prst="rect"/></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#
|
||||
)
|
||||
}
|
||||
|
||||
/// A DOCX whose header holds a picture of `header_media` (`word/media/image2.bin`).
|
||||
fn docx_with_header_picture(header_media: &[u8]) -> Vec<u8> {
|
||||
let doc = format!(
|
||||
r#"<w:document {NS}><w:body><w:p><w:r><w:t>text</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rIdH"/></w:sectPr></w:body></w:document>"#
|
||||
);
|
||||
let drels = format!(r#"<Relationships xmlns="{RELS_NS}"><Relationship Id="rIdH" Type="{REL}/header" Target="header2.xml"/></Relationships>"#);
|
||||
let hrels = format!(r#"<Relationships xmlns="{RELS_NS}"><Relationship Id="rIdI" Type="{REL}/image" Target="media/image2.bin"/></Relationships>"#);
|
||||
let hdr = format!(r#"<w:hdr {NS}><w:p>{}</w:p></w:hdr>"#, pic("rIdI"));
|
||||
package(
|
||||
&doc,
|
||||
&drels,
|
||||
&[("word/header2.xml", hdr.as_bytes()), ("word/_rels/header2.xml.rels", hrels.as_bytes()), ("word/media/image2.bin", header_media)],
|
||||
)
|
||||
}
|
||||
|
||||
/// A DOCX whose body picture points outside the file, at `target`.
|
||||
fn docx_with_external_picture(target: &str) -> Vec<u8> {
|
||||
let doc = format!(r#"<w:document {NS}><w:body><w:p>{}<w:r><w:t>text</w:t></w:r></w:p></w:body></w:document>"#, pic("rIdX"));
|
||||
let drels = format!(
|
||||
r#"<Relationships xmlns="{RELS_NS}"><Relationship Id="rIdX" Type="{REL}/image" Target="{target}" TargetMode="External"/></Relationships>"#
|
||||
);
|
||||
package(&doc, &drels, &[])
|
||||
}
|
||||
|
||||
fn package(document: &str, document_rels: &str, extra: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
let rels = format!(
|
||||
r#"<Relationships xmlns="{RELS_NS}"><Relationship Id="rId1" Type="{REL}/officeDocument" Target="word/document.xml"/></Relationships>"#
|
||||
);
|
||||
let types = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="png" ContentType="image/png"/><Default Extension="bin" ContentType="application/octet-stream"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#;
|
||||
let mut zip = craft_zip::Writer::new();
|
||||
let mut parts: Vec<(&str, &[u8])> = vec![
|
||||
("[Content_Types].xml", types.as_bytes()),
|
||||
("_rels/.rels", rels.as_bytes()),
|
||||
("word/document.xml", document.as_bytes()),
|
||||
("word/_rels/document.xml.rels", document_rels.as_bytes()),
|
||||
];
|
||||
parts.extend_from_slice(extra);
|
||||
for (name, bytes) in parts {
|
||||
zip.add(name, bytes, craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
zip.finish().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multilingual_names_pass_strict() {
|
||||
if without_fonts() {
|
||||
return;
|
||||
}
|
||||
let names = "Łukasz Żmijewski\nGheorghe Ștefănescu\nKrejčí\nŞahın Gül\nLlŷr Grŵn\n王小明\n김민수\nสมชาย ใจดี\nराम शर्मा";
|
||||
let d = dir("names");
|
||||
let input = json_input(&d, names);
|
||||
for out in ["names.pdf", "names.png"] {
|
||||
let out = d.join(out);
|
||||
let r = convert(&input, &out, true);
|
||||
assert_eq!(r.status.code(), Some(0), "{}", stderr(&r));
|
||||
assert!(out.exists());
|
||||
}
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_uncovered_code_point_fails_strict_with_a_report_and_no_file() {
|
||||
let c = uncovered();
|
||||
let cp = format!("U+{:04X}", c as u32);
|
||||
let d = dir("uncovered");
|
||||
let input = json_input(&d, &format!("a{c}b{c}{c}"));
|
||||
for out in ["out.pdf", "out.png"] {
|
||||
let out = d.join(out);
|
||||
let r = convert(&input, &out, true);
|
||||
assert_eq!(r.status.code(), Some(3), "{}", stderr(&r));
|
||||
let text = stderr(&r);
|
||||
assert!(text.contains(&format!("wordcraft-cli: strict: 1 character cannot be drawn: {cp} (×3)")), "{text}");
|
||||
let json = report_json(&r);
|
||||
assert_eq!(json["schema"], 1);
|
||||
assert_eq!(json["strict"], "failed");
|
||||
assert_eq!(json["missing_glyphs"][0]["cp"], cp.as_str());
|
||||
assert_eq!(json["missing_glyphs"][0]["count"], 3);
|
||||
assert!(!out.exists(), "{} was written", out.display());
|
||||
eprintln!("{text}");
|
||||
// Without --strict the same input converts, as before.
|
||||
let r = convert(&input, &out, false);
|
||||
assert_eq!(r.status.code(), Some(0), "{}", stderr(&r));
|
||||
assert!(out.exists());
|
||||
std::fs::remove_file(&out).unwrap();
|
||||
}
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_real_png_in_a_header_passes_strict() {
|
||||
let d = dir("header-png");
|
||||
let input = d.join("in.docx");
|
||||
std::fs::write(&input, docx_with_header_picture(&png())).unwrap();
|
||||
for out in ["out.pdf", "out.png"] {
|
||||
let out = d.join(out);
|
||||
let r = convert(&input, &out, true);
|
||||
assert_eq!(r.status.code(), Some(0), "{}", stderr(&r));
|
||||
assert!(out.exists());
|
||||
}
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_corrupt_header_picture_fails_strict_naming_the_part_and_the_header() {
|
||||
let d = dir("header-corrupt");
|
||||
let input = d.join("in.docx");
|
||||
std::fs::write(&input, docx_with_header_picture(b"corrupt bytes, not a picture")).unwrap();
|
||||
for out in ["out.pdf", "out.png"] {
|
||||
let out = d.join(out);
|
||||
let r = convert(&input, &out, true);
|
||||
assert_eq!(r.status.code(), Some(3), "{}", stderr(&r));
|
||||
let text = stderr(&r);
|
||||
assert!(text.contains("word/media/image2.bin (header2.xml): unsupported format"), "{text}");
|
||||
let json = report_json(&r);
|
||||
assert_eq!(json["strict"], "failed");
|
||||
assert_eq!(
|
||||
json["missing_pictures"][0],
|
||||
serde_json::json!({"part": "word/media/image2.bin", "source_part": "word/header2.xml", "reason": "unsupported_format"})
|
||||
);
|
||||
assert!(!out.exists());
|
||||
eprintln!("{text}");
|
||||
}
|
||||
// Without --strict the grey box is drawn, as before.
|
||||
let out = d.join("lenient.pdf");
|
||||
assert_eq!(convert(&input, &out, false).status.code(), Some(0));
|
||||
assert!(out.exists());
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_external_picture_target_is_never_fetched_and_is_reported_under_strict() {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
listener.set_nonblocking(true).unwrap();
|
||||
let target = format!("http://{}/canary.png", listener.local_addr().unwrap());
|
||||
let d = dir("external");
|
||||
let input = d.join("in.docx");
|
||||
std::fs::write(&input, docx_with_external_picture(&target)).unwrap();
|
||||
|
||||
let lenient = convert(&input, &d.join("lenient.pdf"), false);
|
||||
assert_eq!(lenient.status.code(), Some(0), "{}", stderr(&lenient));
|
||||
let out = d.join("strict.pdf");
|
||||
let strict = convert(&input, &out, true);
|
||||
assert_eq!(strict.status.code(), Some(3), "{}", stderr(&strict));
|
||||
let json = report_json(&strict);
|
||||
assert_eq!(json["missing_pictures"][0]["reason"], "external_target");
|
||||
assert!(stderr(&strict).contains("external target"));
|
||||
assert!(!out.exists());
|
||||
|
||||
match listener.accept() {
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
|
||||
Ok((_, from)) => panic!("the converter connected to the external target from {from}"),
|
||||
Err(e) => panic!("{e}"),
|
||||
}
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_input_exits_1_with_the_usual_message_with_and_without_strict() {
|
||||
let d = dir("bad-input");
|
||||
let good = docx_with_header_picture(&png());
|
||||
let truncated = d.join("truncated.docx");
|
||||
std::fs::write(&truncated, &good[..good.len() / 2]).unwrap();
|
||||
let not_zip = d.join("not-zip.docx");
|
||||
std::fs::write(¬_zip, b"this is plainly not a zip archive").unwrap();
|
||||
for input in [&truncated, ¬_zip] {
|
||||
let plain = convert(input, &d.join("a.pdf"), false);
|
||||
let strict = convert(input, &d.join("b.pdf"), true);
|
||||
assert_eq!(plain.status.code(), Some(1), "{}", stderr(&plain));
|
||||
assert_eq!(strict.status.code(), Some(1), "{}", stderr(&strict));
|
||||
assert_eq!(stderr(&plain), stderr(&strict));
|
||||
assert!(stderr(&plain).starts_with("wordcraft-cli: "), "{}", stderr(&plain));
|
||||
assert!(!d.join("a.pdf").exists() && !d.join("b.pdf").exists());
|
||||
}
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_with_another_output_type_exits_1_before_the_input_is_read() {
|
||||
let d = dir("wrong-output");
|
||||
let missing = d.join("does-not-exist.docx");
|
||||
for out in ["out.docx", "out.txt", "out.odt"] {
|
||||
let r = convert(&missing, &d.join(out), true);
|
||||
assert_eq!(r.status.code(), Some(1));
|
||||
let text = stderr(&r);
|
||||
assert!(text.contains("--strict needs a .pdf or .png output"), "{text}");
|
||||
assert!(!d.join(out).exists());
|
||||
}
|
||||
// Without --strict the same command fails on the missing input.
|
||||
let r = convert(&missing, &d.join("out.docx"), false);
|
||||
assert_eq!(r.status.code(), Some(1));
|
||||
assert!(!stderr(&r).contains("--strict"));
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
fn render(input: &Path, output: &Path, args: &[&str]) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).arg("render").arg(input).arg(output).args(args).output().unwrap()
|
||||
}
|
||||
|
||||
/// Page 1 has `a{c}b`, page 2 plain text.
|
||||
fn two_page_input(d: &Path, text: &str) -> PathBuf {
|
||||
let mut doc = Document::from_text(&format!("{text}\nSecond page"));
|
||||
doc.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(1)).unwrap().props.page_break_before = Some(true);
|
||||
let path = d.join("two.json");
|
||||
std::fs::write(&path, serde_json::to_vec(&doc).unwrap()).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_strict_refuses_an_uncovered_character_on_the_page_with_no_stdout_or_file() {
|
||||
let c = uncovered();
|
||||
let cp = format!("U+{:04X}", c as u32);
|
||||
let d = dir("render-glyph");
|
||||
let input = two_page_input(&d, &format!("a{c}b"));
|
||||
let out = d.join("p1.png");
|
||||
let r = render(&input, &out, &["--strict"]);
|
||||
assert_eq!(r.status.code(), Some(3), "{}", stderr(&r));
|
||||
assert!(r.stdout.is_empty(), "{}", String::from_utf8_lossy(&r.stdout));
|
||||
assert!(!out.exists());
|
||||
let text = stderr(&r);
|
||||
assert!(text.contains(&format!("wordcraft-cli: strict: 1 character cannot be drawn: {cp} (×1)")), "{text}");
|
||||
assert_eq!(report_json(&r)["missing_glyphs"][0]["cp"], cp.as_str());
|
||||
eprintln!("{text}");
|
||||
|
||||
// The character is not on page 2, and --scale does not matter to the check.
|
||||
let out2 = d.join("p2.png");
|
||||
let r = render(&input, &out2, &["--page", "2", "--strict", "--scale", "0.5"]);
|
||||
assert_eq!(r.status.code(), Some(0), "{}", stderr(&r));
|
||||
assert!(out2.exists());
|
||||
assert!(String::from_utf8_lossy(&r.stdout).contains("\"path\""));
|
||||
// And the same page 1 without --strict renders, with the usual stdout.
|
||||
let out3 = d.join("lenient.png");
|
||||
let r = render(&input, &out3, &[]);
|
||||
assert_eq!(r.status.code(), Some(0), "{}", stderr(&r));
|
||||
assert!(out3.exists());
|
||||
let json: serde_json::Value = serde_json::from_slice(&r.stdout).unwrap();
|
||||
assert_eq!(json["path"], out3.to_string_lossy().as_ref());
|
||||
assert!(json["width"].as_u64().is_some() && json["bytes"].as_u64().is_some());
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_strict_refuses_a_corrupt_header_picture_and_bad_input_still_exits_1() {
|
||||
let d = dir("render-pic");
|
||||
let input = d.join("in.docx");
|
||||
std::fs::write(&input, docx_with_header_picture(b"corrupt bytes, not a picture")).unwrap();
|
||||
let out = d.join("out.png");
|
||||
let r = render(&input, &out, &["--strict"]);
|
||||
assert_eq!(r.status.code(), Some(3), "{}", stderr(&r));
|
||||
assert!(r.stdout.is_empty());
|
||||
assert!(stderr(&r).contains("word/media/image2.bin (header2.xml)"), "{}", stderr(&r));
|
||||
assert!(!out.exists());
|
||||
|
||||
let bad = d.join("bad.docx");
|
||||
std::fs::write(&bad, b"this is plainly not a zip archive").unwrap();
|
||||
let (plain, strict) = (render(&bad, &out, &[]), render(&bad, &out, &["--strict"]));
|
||||
assert_eq!((plain.status.code(), strict.status.code()), (Some(1), Some(1)));
|
||||
assert_eq!(stderr(&plain), stderr(&strict));
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_picture_target_with_a_newline_and_a_bidi_override_cannot_forge_a_report_line() {
|
||||
let d = dir("escape");
|
||||
let input = d.join("in.docx");
|
||||
// XML character references put a newline and U+202E in the target.
|
||||
std::fs::write(&input, docx_with_external_picture("http://example.invalid/a wordcraft-cli: strict: forged‮gnp.png")).unwrap();
|
||||
let out = d.join("out.pdf");
|
||||
let r = convert(&input, &out, true);
|
||||
assert_eq!(r.status.code(), Some(3), "{}", stderr(&r));
|
||||
let text = stderr(&r);
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
// One human line for the picture, then the JSON line.
|
||||
assert_eq!(lines.len(), 2, "{text}");
|
||||
assert!(lines[0].starts_with("wordcraft-cli: strict: 1 picture(s) left out: "), "{text}");
|
||||
assert!(lines[0].contains("\\nwordcraft-cli: strict: forged\\u{202e}gnp.png"), "{text}");
|
||||
assert!(!text.contains('\u{202e}'), "the raw bidi override reached the terminal");
|
||||
// The JSON keeps the raw value, escaped by the JSON encoder.
|
||||
let json = report_json(&r);
|
||||
let part = json["missing_pictures"][0]["part"].as_str().unwrap();
|
||||
assert!(part.contains('\n') && part.contains('\u{202e}'), "{part:?}");
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_with_strict_cannot_forge_an_error_line_either() {
|
||||
let d = dir("run-escape");
|
||||
let input = d.join("in.docx");
|
||||
std::fs::write(&input, docx_with_external_picture("http://example.invalid/a wordcraft-cli: strict: forged[2J‮gnp.png")).unwrap();
|
||||
let out = d.join("out.pdf");
|
||||
let cmd = format!("file.exportPdf={}", serde_json::json!({"path": out, "strict": true}));
|
||||
let r = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).arg("run").arg("--file").arg(&input).arg("--cmd").arg(cmd).output().unwrap();
|
||||
assert_eq!(r.status.code(), Some(1));
|
||||
let text = stderr(&r);
|
||||
assert_eq!(text.lines().count(), 1, "{text}");
|
||||
assert!(text.contains("\\nwordcraft-cli: strict: forged") && text.contains("\\u{202e}"), "{text}");
|
||||
assert!(!text.contains('\u{1b}') && !text.contains('\u{202e}'), "{text:?}");
|
||||
assert!(!out.exists());
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_errors_keep_their_line_breaks() {
|
||||
// Errors go to stderr with the usage after a blank line (`missing argument`, `unknown command`).
|
||||
for args in [&["frobnicate"][..], &["convert"][..]] {
|
||||
let r = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).args(args).output().unwrap();
|
||||
let text = stderr(&r);
|
||||
assert_eq!(r.status.code(), Some(1), "{args:?}: {text}");
|
||||
assert!(text.lines().count() > 3, "{args:?}: {text}");
|
||||
assert!(text.lines().any(|l| l == "USAGE:"), "{args:?}: {text}");
|
||||
assert!(!text.contains("\\n"), "{args:?}: {text}");
|
||||
}
|
||||
// No arguments at all prints the usage as help, on stdout.
|
||||
let r = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).output().unwrap();
|
||||
let all = format!("{}{}", String::from_utf8_lossy(&r.stdout), stderr(&r));
|
||||
assert!(all.lines().count() > 3 && all.lines().any(|l| l == "USAGE:"), "{all}");
|
||||
}
|
||||
|
||||
/// A minimal EMF (88-byte header, 100 mm square frame) with one EMR_EXTTEXTOUTW record showing
|
||||
/// `text` in the default font.
|
||||
fn emf_with_text(text: &str) -> Vec<u8> {
|
||||
let units: Vec<u16> = text.encode_utf16().collect();
|
||||
let mut rec = vec![0u8; 76];
|
||||
rec[0..4].copy_from_slice(&84u32.to_le_bytes());
|
||||
rec[36..40].copy_from_slice(&10i32.to_le_bytes());
|
||||
rec[40..44].copy_from_slice(&40i32.to_le_bytes());
|
||||
rec[44..48].copy_from_slice(&(units.len() as u32).to_le_bytes());
|
||||
rec[48..52].copy_from_slice(&76u32.to_le_bytes());
|
||||
rec.extend(units.iter().flat_map(|u| u.to_le_bytes()));
|
||||
while !rec.len().is_multiple_of(4) {
|
||||
rec.push(0);
|
||||
}
|
||||
let len = rec.len() as u32;
|
||||
rec[4..8].copy_from_slice(&len.to_le_bytes());
|
||||
let eof: Vec<u8> = [14u32.to_le_bytes(), 20u32.to_le_bytes(), [0; 4], [0; 4], 20u32.to_le_bytes()].concat();
|
||||
let total = 88 + rec.len() + eof.len();
|
||||
let mut h = vec![0u8; 88];
|
||||
h[0..4].copy_from_slice(&1u32.to_le_bytes());
|
||||
h[4..8].copy_from_slice(&88u32.to_le_bytes());
|
||||
h[8..24].copy_from_slice(&[0i32, 0, 100, 100].iter().flat_map(|v| v.to_le_bytes()).collect::<Vec<u8>>());
|
||||
h[24..40].copy_from_slice(&[0i32, 0, 10000, 10000].iter().flat_map(|v| v.to_le_bytes()).collect::<Vec<u8>>());
|
||||
h[40..44].copy_from_slice(&0x464D_4520u32.to_le_bytes());
|
||||
h[44..48].copy_from_slice(&0x0001_0000u32.to_le_bytes());
|
||||
h[48..52].copy_from_slice(&(total as u32).to_le_bytes());
|
||||
h[52..56].copy_from_slice(&2u32.to_le_bytes());
|
||||
h[56..58].copy_from_slice(&1u16.to_le_bytes());
|
||||
h[72..76].copy_from_slice(&1000i32.to_le_bytes());
|
||||
h[76..80].copy_from_slice(&1000i32.to_le_bytes());
|
||||
h[80..84].copy_from_slice(&250i32.to_le_bytes());
|
||||
h[84..88].copy_from_slice(&250i32.to_le_bytes());
|
||||
[h, rec, eof].concat()
|
||||
}
|
||||
|
||||
/// The text a metafile picture draws is checked like the page's own: a character no face covers
|
||||
/// refuses the export from `convert` and `render`, naming the code point, and nothing is written.
|
||||
#[test]
|
||||
fn a_character_in_a_metafile_pictures_text_fails_strict_like_the_pages_own() {
|
||||
if without_fonts() {
|
||||
return;
|
||||
}
|
||||
let c = uncovered();
|
||||
let cp = format!("U+{:04X}", c as u32);
|
||||
let d = dir("emf-text");
|
||||
let emf = emf_with_text(&format!("ab{c}"));
|
||||
let input = d.join("in.docx");
|
||||
std::fs::write(&input, docx_with_header_picture(&emf)).unwrap();
|
||||
for out in ["out.pdf", "out.png"] {
|
||||
let out = d.join(out);
|
||||
let r = convert(&input, &out, true);
|
||||
assert_eq!(r.status.code(), Some(3), "{}", stderr(&r));
|
||||
assert_eq!(report_json(&r)["missing_glyphs"][0]["cp"], cp.as_str(), "{}", stderr(&r));
|
||||
assert!(!out.exists(), "{} was written", out.display());
|
||||
// Without --strict it converts, the character a hollow box.
|
||||
let r = convert(&input, &out, false);
|
||||
assert_eq!(r.status.code(), Some(0), "{}", stderr(&r));
|
||||
std::fs::remove_file(&out).unwrap();
|
||||
}
|
||||
let out = d.join("p1.png");
|
||||
let r = render(&input, &out, &["--strict"]);
|
||||
assert_eq!(r.status.code(), Some(3), "{}", stderr(&r));
|
||||
assert!(r.stdout.is_empty() && !out.exists());
|
||||
assert_eq!(report_json(&r)["missing_glyphs"][0]["cp"], cp.as_str());
|
||||
// Text the faces cover passes.
|
||||
std::fs::write(&input, docx_with_header_picture(&emf_with_text("abc"))).unwrap();
|
||||
let r = convert(&input, &d.join("ok.pdf"), true);
|
||||
assert_eq!(r.status.code(), Some(0), "{}", stderr(&r));
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
/// The page raster leaves out a metafile page background (it only draws raster ones), so a PNG
|
||||
/// would silently lack it: strict refuses that output, and a PDF, which draws it as vector, passes.
|
||||
#[test]
|
||||
fn a_metafile_page_background_fails_strict_for_png_but_not_for_pdf() {
|
||||
if without_fonts() {
|
||||
return;
|
||||
}
|
||||
let d = dir("emf-background");
|
||||
let mut doc = Document::from_text("text");
|
||||
doc.media.insert("bg.emf".into(), std::sync::Arc::new(emf_with_text("abc")));
|
||||
doc.settings.page_picture = Some(wordcraft_doc::PagePicture { media: "bg.emf".into(), fill_type: "frame".into(), title: String::new() });
|
||||
let input = d.join("in.json");
|
||||
std::fs::write(&input, serde_json::to_vec(&doc).unwrap()).unwrap();
|
||||
let png = d.join("out.png");
|
||||
let r = convert(&input, &png, true);
|
||||
assert_eq!(r.status.code(), Some(3), "{}", stderr(&r));
|
||||
let json = report_json(&r);
|
||||
assert_eq!(json["missing_pictures"][0]["part"], "bg.emf", "{}", stderr(&r));
|
||||
assert_eq!(json["missing_pictures"][0]["source_part"], "background");
|
||||
assert_eq!(json["missing_pictures"][0]["reason"], "unsupported_format");
|
||||
assert!(!png.exists());
|
||||
let pdf = d.join("out.pdf");
|
||||
let r = convert(&input, &pdf, true);
|
||||
assert_eq!(r.status.code(), Some(0), "{}", stderr(&r));
|
||||
assert!(pdf.exists());
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
@@ -16,6 +16,10 @@ path = "src/main.rs"
|
||||
[dependencies]
|
||||
wordcraft-engine = { workspace = true }
|
||||
wordcraft-ui-egui = { workspace = true }
|
||||
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
|
||||
# The page command API (storytold/craft-libs#413): window.wordcraft over the control channel.
|
||||
craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
|
||||
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
|
||||
egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@@ -29,4 +33,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", "Storage", "MessageEvent", "UrlSearchParams"] }
|
||||
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", "MessageEvent", "UrlSearchParams", "CustomEvent", "CustomEventInit"] }
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
//! `window.wordcraft`: the page command API (`craft-web-api`, storytold/craft-libs#413) over the
|
||||
//! app's own control channel, the one the desktop control server drives (`docs/control-protocol.md`).
|
||||
//!
|
||||
//! The crate owns the JavaScript side (`request`, `execute`, `commands`, `docInspect`, `uiInspect`,
|
||||
//! `screenshot`, as promises with a timeout). This module is WordCraft's part: a [`Request`] that
|
||||
//! queues a [`ControlRequest`] for the app, which answers it on its next frame, and waits for the
|
||||
//! reply. WordCraft's control request has no abandon state, so a call the page gave up on still runs
|
||||
//! when the app reaches it, as it does for a desktop client that timed out.
|
||||
|
||||
use std::rc::Rc;
|
||||
use std::sync::mpsc::{Receiver, TryRecvError, channel};
|
||||
|
||||
use craft_web_api::methods::Methods;
|
||||
use craft_web_api::page::{PageApi, Request};
|
||||
use serde_json::{Value, json};
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
use wordcraft_ui_egui::ControlRequest;
|
||||
|
||||
/// The app id in `window.<app>`.
|
||||
const APP: &str = "wordcraft";
|
||||
|
||||
/// How often a waiting call looks for the app's reply, in milliseconds (about a frame).
|
||||
const POLL_MS: i32 = 16;
|
||||
|
||||
/// Put `window.wordcraft` on the page and return the queue the app drains each frame (hand it to
|
||||
/// `WordApp::with_control`). An existing `window.wordcraft` keeps the functions it already has.
|
||||
pub fn install(ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let (queue, requests) = channel::<ControlRequest>();
|
||||
let request: Request = Rc::new(move |method, params| {
|
||||
let (control, reply) = ControlRequest::new(method, params);
|
||||
if queue.send(control).is_err() {
|
||||
return Box::pin(async { json!({"ok": false, "error": "the app is not running"}) });
|
||||
}
|
||||
ctx.request_repaint();
|
||||
Box::pin(wait(reply))
|
||||
});
|
||||
if let Err(e) = Rc::new(PageApi::new(APP, Methods::default(), request)).install() {
|
||||
log::warn!("couldn't install window.{APP}: {e:?}");
|
||||
}
|
||||
requests
|
||||
}
|
||||
|
||||
/// The app's reply, polled about once a frame.
|
||||
async fn wait(reply: Receiver<Value>) -> Value {
|
||||
loop {
|
||||
match reply.try_recv() {
|
||||
Ok(reply) => return reply,
|
||||
Err(TryRecvError::Empty) => sleep(POLL_MS).await,
|
||||
Err(TryRecvError::Disconnected) => return json!({"ok": false, "error": "the app dropped the request"}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn sleep(ms: i32) {
|
||||
let promise = js_sys::Promise::new(&mut |resolve, _| {
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms);
|
||||
}
|
||||
});
|
||||
let _ = JsFuture::from(promise).await;
|
||||
}
|
||||
+65
-107
@@ -1,14 +1,20 @@
|
||||
//! The `postMessage` bridge to a same-origin parent page (`?host=parent`), for pages that embed
|
||||
//! WordCraft in an `<iframe>` and keep its files (a Nextcloud app, a personal file server). The
|
||||
//! same messages as GridCraft's and PdfCraft's bridges; each is a plain object with a `type`:
|
||||
//! Host modes: a page that runs WordCraft (as an `<iframe>`, or in its own document) opens
|
||||
//! documents in it and stores what it saves instead of the browser downloading. The messages and
|
||||
//! the two transports are `craft-web-host`'s (storytold/craft-libs), the same contract as the
|
||||
//! other Craft apps' bridges, with the `wordcraft:` prefix. This module is WordCraft's part of it.
|
||||
//!
|
||||
//! - `wordcraft:ready` (to the parent): `{ version }`, sent once the app is running and
|
||||
//! listening; send a file now.
|
||||
//! - `wordcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or
|
||||
//! `Uint8Array`. It opens like a document picked with File › Open or dropped on the window.
|
||||
//! - `wordcraft:save` (to the parent): `{ name, saveAs, bytes }` (a transferred `ArrayBuffer`) for
|
||||
//! every file WordCraft would otherwise download. The parent stores it and reports failures
|
||||
//! itself. `saveAs` says whether the file becomes the document's file:
|
||||
//! - `?host=parent` (an `<iframe>`, such as a Nextcloud app) talks to the same-origin parent over
|
||||
//! `postMessage`; `data-host="page"` on the canvas (the page's own document, no iframe) uses
|
||||
//! DOM `CustomEvent`s on the canvas instead. `?host=parent` wins when both are present. With no
|
||||
//! parent, or a parent on another origin, there is no bridge: the normal app with downloads.
|
||||
//! - `wordcraft:ready` once the app is running and listening; send a file now. WordCraft reads
|
||||
//! bytes, so it says `file: false` and a host sends `bytes`.
|
||||
//! - `wordcraft:open` (from the host): `{ name, bytes }`. It opens like a document picked with
|
||||
//! File › Open or dropped on the window; a file over [`MAX_OPEN_BYTES`] is refused before it is
|
||||
//! copied.
|
||||
//! - `wordcraft:save` (to the host): `{ name, saveAs, bytes }` for every file WordCraft would
|
||||
//! otherwise download. The host stores it and reports failures itself. `saveAs` says whether
|
||||
//! the file becomes the document's file:
|
||||
//! - Save: `false`. It writes the opened file under its own name and format, as the desktop
|
||||
//! app does. A name the parent never sent (an untitled document, an imported .doc saved as
|
||||
//! .docx) is a new file: `true`, as for Save As.
|
||||
@@ -17,70 +23,57 @@
|
||||
//! is now that file; the next Save sends its name with `false`.
|
||||
//! - Save As or Export to a format that doesn't (PDF, Markdown, plain text), Export as PDF and
|
||||
//! Export Page as PNG: `false`. They are copies; the document's file stays the same.
|
||||
//! - `wordcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.
|
||||
//! - `wordcraft:failed` (to the parent): `{ error }` when the app couldn't start.
|
||||
//! - `wordcraft:dirty` whenever unsaved changes appear or go away, `wordcraft:failed` when the
|
||||
//! app couldn't start.
|
||||
//!
|
||||
//! Only the parent window on the page's own origin is heard or answered. In host mode the parent
|
||||
//! guards leaving the page, and `?author=` names the user for comments and tracked changes.
|
||||
//! In both host modes the host sends the document to edit, so no sample opens, saves are handed
|
||||
//! to the host rather than downloaded, the host guards leaving the page, and `?author=` names the
|
||||
//! user for comments and tracked changes.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use wasm_bindgen::JsCast as _;
|
||||
use wasm_bindgen::JsValue;
|
||||
use wasm_bindgen::closure::Closure;
|
||||
use craft_web_host::web::{FileData, Host, Message};
|
||||
use wordcraft_ui_egui::{Inbox, Services, WordApp};
|
||||
|
||||
/// The app id in the host messages (`wordcraft:ready`).
|
||||
const APP: &str = "wordcraft";
|
||||
|
||||
/// The name a document gets when the host sent none (the crate says `document` for a blank name).
|
||||
const DEFAULT_NAME: &str = "Document.docx";
|
||||
|
||||
/// The largest document taken from the host page, refused before it is copied.
|
||||
const MAX_OPEN_BYTES: u64 = 512 << 20;
|
||||
|
||||
/// The longest user name taken from `?author=`.
|
||||
const MAX_AUTHOR_CHARS: usize = 200;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Bridge {
|
||||
window: web_sys::Window,
|
||||
parent: web_sys::Window,
|
||||
origin: String,
|
||||
host: Rc<Host>,
|
||||
}
|
||||
|
||||
impl Bridge {
|
||||
/// `None` when the page isn't framed (there is no parent to talk to), or when the parent is
|
||||
/// on another origin: messages only go to this origin, so the browser would drop every save
|
||||
/// without an error while the document showed as saved.
|
||||
pub fn new() -> Option<Self> {
|
||||
let window = web_sys::window()?;
|
||||
let parent = window.parent().ok().flatten()?;
|
||||
if js_sys::Object::is(&parent, &window) {
|
||||
return None;
|
||||
}
|
||||
let origin = window.location().origin().ok()?;
|
||||
// An opaque origin (a `file://` page in some browsers) can't be a `postMessage` target.
|
||||
if origin == "null" {
|
||||
return None;
|
||||
}
|
||||
// Reading another origin's location throws, which is `None` too.
|
||||
if parent.location().origin().ok()? != origin {
|
||||
return None;
|
||||
}
|
||||
Some(Self { window, parent, origin })
|
||||
/// The bridge for this page, or `None` when WordCraft isn't hosted: no `?host=parent` and no
|
||||
/// `data-host="page"` on the canvas, an unframed `?host=parent` page (it stays the normal app
|
||||
/// with downloads), or a parent on another origin.
|
||||
pub fn detect(canvas: &web_sys::HtmlCanvasElement) -> Option<Self> {
|
||||
Some(Self { host: Rc::new(Host::connect(APP, canvas)?) })
|
||||
}
|
||||
|
||||
/// Hand saved and exported files to the parent instead of downloading them, report unsaved
|
||||
/// Hand saved and exported files to the host instead of downloading them, report unsaved
|
||||
/// changes to it, and hear the files it sends.
|
||||
pub fn connect(&self, services: &mut Services, inbox: Inbox, ctx: egui::Context) {
|
||||
self.listen(inbox, ctx);
|
||||
let bridge = self.clone();
|
||||
services.host_save = Some(Box::new(move |name: &str, bytes: &[u8], save_as: bool| bridge.post_save(name, bytes, save_as)));
|
||||
// Anything else that would download goes to the parent too.
|
||||
// Anything else that would download goes to the host too.
|
||||
let bridge = self.clone();
|
||||
services.download = Some(Box::new(move |name: &str, bytes: &[u8]| bridge.post_save(name, bytes, false)));
|
||||
// The browser's save picker is never used, so no save finishes later.
|
||||
services.async_saves = None;
|
||||
let bridge = self.clone();
|
||||
let reported: Rc<Cell<Option<bool>>> = Rc::default();
|
||||
services.on_dirty = Some(Box::new(move |dirty: bool| {
|
||||
if reported.get() != Some(dirty) && bridge.send(&[("type", "wordcraft:dirty".into()), ("dirty", dirty.into())]) {
|
||||
reported.set(Some(dirty));
|
||||
}
|
||||
}));
|
||||
// The crate does not repeat a state it already reported.
|
||||
let host = self.host.clone();
|
||||
services.on_dirty = Some(Box::new(move |dirty: bool| host.post_dirty(dirty)));
|
||||
}
|
||||
|
||||
/// `?author=`: the host's name for the user signs comments and tracked changes.
|
||||
@@ -92,78 +85,43 @@ impl Bridge {
|
||||
}
|
||||
|
||||
pub fn post_ready(&self) {
|
||||
self.send(&[("type", "wordcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]);
|
||||
let _ = self.host.post_ready(env!("CARGO_PKG_VERSION"), false);
|
||||
}
|
||||
|
||||
/// The app couldn't start (no WebGPU or WebGL2, for example): let the parent say so.
|
||||
/// The app couldn't start (no WebGPU or WebGL2, for example): let the host say so.
|
||||
pub fn post_failed(&self, error: &str) {
|
||||
self.send(&[("type", "wordcraft:failed".into()), ("error", error.into())]);
|
||||
let _ = self.host.post_failed(error, None);
|
||||
}
|
||||
|
||||
fn post_save(&self, path: &str, bytes: &[u8], save_as: bool) -> Result<(), String> {
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
|
||||
let buffer = js_sys::Uint8Array::from(bytes).buffer();
|
||||
let message = message(&[
|
||||
("type", "wordcraft:save".into()),
|
||||
("name", name.as_str().into()),
|
||||
("saveAs", save_as.into()),
|
||||
("bytes", buffer.clone().into()),
|
||||
])
|
||||
.ok_or_else(|| "couldn't build the message".to_string())?;
|
||||
self.parent
|
||||
.post_message_with_transfer(&message, &self.origin, &js_sys::Array::of1(&buffer))
|
||||
.map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))
|
||||
self.host.post_save(&name, bytes, None, save_as)
|
||||
}
|
||||
|
||||
/// Hear `wordcraft:open` from the parent and queue the file like a dropped one.
|
||||
/// Hear `wordcraft:open` and queue the file like a dropped one.
|
||||
fn listen(&self, inbox: Inbox, ctx: egui::Context) {
|
||||
let parent = self.parent.clone();
|
||||
let origin = self.origin.clone();
|
||||
let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {
|
||||
if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) {
|
||||
return;
|
||||
self.host.listen(move |message| {
|
||||
if let Message::Open { name, data } = message {
|
||||
deliver_open(&inbox, &ctx, &name, &data);
|
||||
}
|
||||
let data = event.data();
|
||||
if string(&data, "type").as_deref() != Some("wordcraft:open") {
|
||||
return;
|
||||
}
|
||||
let name = string(&data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "Document.docx".to_string());
|
||||
let Some(bytes) = bytes(&data) else { return };
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
|
||||
ctx.request_repaint();
|
||||
});
|
||||
if self.window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() {
|
||||
// The listener lives as long as the page.
|
||||
on_message.forget();
|
||||
}
|
||||
}
|
||||
|
||||
/// Post a message to the parent; false if it couldn't be sent.
|
||||
fn send(&self, fields: &[(&str, JsValue)]) -> bool {
|
||||
message(fields).is_some_and(|m| self.parent.post_message(&m, &self.origin).is_ok())
|
||||
}
|
||||
}
|
||||
|
||||
fn message(fields: &[(&str, JsValue)]) -> Option<js_sys::Object> {
|
||||
let m = js_sys::Object::new();
|
||||
for (key, value) in fields {
|
||||
js_sys::Reflect::set(&m, &JsValue::from_str(key), value).ok()?;
|
||||
}
|
||||
Some(m)
|
||||
}
|
||||
|
||||
fn string(data: &JsValue, key: &str) -> Option<String> {
|
||||
js_sys::Reflect::get(data, &JsValue::from_str(key)).ok()?.as_string()
|
||||
}
|
||||
|
||||
/// `bytes` as an `ArrayBuffer` or `Uint8Array`.
|
||||
fn bytes(data: &JsValue) -> Option<Vec<u8>> {
|
||||
let bytes = js_sys::Reflect::get(data, &JsValue::from_str("bytes")).ok()?;
|
||||
if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {
|
||||
Some(array.to_vec())
|
||||
} else if bytes.is_instance_of::<js_sys::ArrayBuffer>() {
|
||||
Some(js_sys::Uint8Array::new(&bytes).to_vec())
|
||||
} else {
|
||||
None
|
||||
/// The file an open message carries, pushed onto the inbox for the next frame, like a dropped
|
||||
/// file. A host's file is untrusted input: an oversized one is refused before it is copied into
|
||||
/// the app's memory (the data's own length, not the host's claim). The crate has cleaned the name
|
||||
/// (and cut it to 255 characters); a blank one comes as `document` and becomes `Document.docx`.
|
||||
fn deliver_open(inbox: &Inbox, ctx: &egui::Context, name: &str, data: &FileData) {
|
||||
let name = if name == "document" { DEFAULT_NAME } else { name }.to_string();
|
||||
let FileData::Bytes(array) = data else {
|
||||
log::warn!("host file {name} came as a File; WordCraft reads bytes only; not opened");
|
||||
return;
|
||||
};
|
||||
if data.size() > MAX_OPEN_BYTES as f64 {
|
||||
log::warn!("host file is {} bytes, over the {} MiB host open limit; not opened", data.size(), MAX_OPEN_BYTES >> 20);
|
||||
return;
|
||||
}
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, array.to_vec()));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
@@ -9,8 +9,10 @@
|
||||
//! Preferences (theme, interface size, editing options…) are kept in `localStorage` (`prefs`).
|
||||
//! URL flags: `?webgl` forces WebGL2; `?sample` opens the sample document.
|
||||
//! `?host=parent` (inside an `<iframe>`) talks to a same-origin parent page instead: it sends the
|
||||
//! document to open and stores what WordCraft saves (`host`), and `&author=` names the user for
|
||||
//! comments and tracked changes.
|
||||
//! document to open and stores what WordCraft saves (`host`). A page can also host WordCraft in
|
||||
//! its own document (no iframe) with `data-host="page"` on the canvas, talking over DOM
|
||||
//! `CustomEvent`s on the canvas. `&author=` names the user for comments and tracked changes in
|
||||
//! either host mode.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
@@ -18,6 +20,8 @@ mod prefs;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod save_fallback;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod api;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod host;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -10,7 +10,7 @@ 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", "mht", "mhtml", "tex", "json", "xml"];
|
||||
&["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "ott", "fodt", "rtf", "txt", "md", "html", "htm", "mht", "mhtml", "tex", "json", "xml"];
|
||||
const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "wmf", "emf"];
|
||||
const RECIPIENT_EXTS: &[&str] = wordcraft_engine::cmd::mailings::RECIPIENT_EXTENSIONS;
|
||||
mod font_store;
|
||||
@@ -28,9 +28,12 @@ pub fn start() {
|
||||
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
|
||||
return;
|
||||
};
|
||||
// `?host=parent`: a same-origin page framing WordCraft opens documents in it and stores
|
||||
// what it saves (`host`). That page guards leaving it, so this one doesn't.
|
||||
let bridge = if query_param("host").as_deref() == Some("parent") { crate::host::Bridge::new() } else { None };
|
||||
// Host modes (`craft-web-host`): `?host=parent`, a same-origin page framing WordCraft, opens documents in
|
||||
// it and stores what it saves (`host`); unframed there is no parent, so that URL on its
|
||||
// own stays the normal app with downloads. `data-host="page"` on the canvas opts a page
|
||||
// in instead: it runs WordCraft in its own document and talks over DOM `CustomEvent`s
|
||||
// on the canvas (`host`). The host guards leaving the page, so this one doesn't.
|
||||
let bridge = crate::host::Bridge::detect(&canvas);
|
||||
let app_bridge = bridge.clone();
|
||||
let dirty = Rc::new(Cell::new(false));
|
||||
let saves = AsyncSaves::default();
|
||||
@@ -73,7 +76,8 @@ pub fn start() {
|
||||
if let Some(bridge) = &app_bridge {
|
||||
bridge.connect(&mut services, inbox.clone(), cc.egui_ctx.clone());
|
||||
}
|
||||
let mut app = WordApp::new(session, services);
|
||||
let control = crate::api::install(cc.egui_ctx.clone());
|
||||
let mut app = WordApp::new(session, services).with_control(control);
|
||||
// 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;
|
||||
@@ -655,6 +659,8 @@ fn mime_for(name: &str) -> &'static str {
|
||||
Some("pdf") => "application/pdf",
|
||||
Some("docx") => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
Some("odt") => "application/vnd.oasis.opendocument.text",
|
||||
Some("ott") => "application/vnd.oasis.opendocument.text-template",
|
||||
Some("fodt") => "application/vnd.oasis.opendocument.text-flat-xml",
|
||||
Some("rtf") => "application/rtf",
|
||||
Some("html") => "text/html",
|
||||
Some("md" | "txt") => "text/plain",
|
||||
|
||||
@@ -22,6 +22,8 @@ wordcraft-control-key = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard", "wayland", "x11", "links"] }
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
tempfile = "3"
|
||||
log = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
|
||||
@@ -38,13 +40,14 @@ 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 the `NSAccessibility` bindings and, to fit the title bar around the
|
||||
# traffic lights, the window, button and view ones.
|
||||
# traffic lights, the window, button and view ones; and `NSPasteboard` for the clipboard's RTF.
|
||||
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"] }
|
||||
# the build through arboard, at this version; this adds only the NSString bindings, and NSData
|
||||
# for the clipboard's RTF (`public.rtf`).
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSString", "NSData"] }
|
||||
# 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"
|
||||
@@ -58,6 +61,14 @@ zbus = { version = "5", default-features = false, features = ["async-io", "block
|
||||
# types that arboard doesn't read, and the formats offered. Already in the build through arboard's
|
||||
# `wayland-data-control` feature, at this version.
|
||||
wl-clipboard-rs = "0.9"
|
||||
# Another program's RTF on X11 (`text/rtf`), which arboard doesn't read. Already in the build
|
||||
# through arboard, at this version.
|
||||
x11rb = "0.13"
|
||||
# Check that the libraries winit's X11 backend opens are installed before choosing XWayland (see
|
||||
# `x11_libraries` in main.rs). Both are already in the build through winit's `x11` feature, at
|
||||
# these versions and with this feature.
|
||||
x11-dl = "2.21"
|
||||
xkbcommon-dl = { version = "0.4.2", features = ["x11"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
# The upstream default-apps button opens Windows Settings; printing uses craft-print instead.
|
||||
@@ -73,7 +84,9 @@ windows = { version = "0.62", features = ["UI_ViewManagement", "System_UserProfi
|
||||
clipboard-win = { version = "5.4", features = ["std"] }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
# Pinned apart from craft-ui, which doesn't depend on it: `printer_queues`, the printer list that
|
||||
# asks no driver (#5710, storytold/craft-libs#618), and `submit_cancellable` (storytold/craft-libs#622).
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "a3e0b5a9440d18f19422a9dca13516747b7d3181" }
|
||||
rfd = { workspace = true }
|
||||
# Waits for an `rfd::AsyncFileDialog` on a background thread (the dialogs don't block the UI, #94);
|
||||
# rfd itself already depends on it.
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
//! An AppImage's environment inherited from another program (#5653).
|
||||
//!
|
||||
//! The AppImage runtime gives the program it starts `APPIMAGE` (the image's file), `APPDIR` (where
|
||||
//! it is mounted), `ARGV0` and `OWD`, and whatever that program starts inherits them. So an AppImage
|
||||
//! app that opens a document through `xdg-open` starts an installed WordCraft with *its* `APPIMAGE`,
|
||||
//! and the self-update, which trusts that variable, replaces the other app's file with WordCraft.
|
||||
//!
|
||||
//! This copy is in the image only when its executable is under `APPDIR`. Otherwise the variables
|
||||
//! are another program's, and the process starts over without them, before anything reads them.
|
||||
//! (Removing them in place is `unsafe` under this edition, and `exec` keeps the process id, the
|
||||
//! arguments and the standard streams.)
|
||||
|
||||
use std::ffi::OsStr;
|
||||
use std::io::Write;
|
||||
use std::os::unix::process::CommandExt;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
/// What the AppImage runtime sets; the same list as the documented workaround on the issue.
|
||||
const RUNTIME_VARS: [&str; 4] = ["APPIMAGE", "APPDIR", "ARGV0", "OWD"];
|
||||
|
||||
/// Whether the executable runs from the image mounted at `appdir`. Anything unclear counts as not:
|
||||
/// the update then goes through the package or the plain file this copy really is, and at worst
|
||||
/// says it can't, where the other answer would overwrite a file that isn't WordCraft's.
|
||||
fn runs_from_image(exe: Option<&Path>, appdir: Option<&OsStr>) -> bool {
|
||||
let (Some(exe), Some(appdir)) = (exe, appdir) else { return false };
|
||||
let appdir = Path::new(appdir);
|
||||
if !appdir.is_absolute() {
|
||||
return false;
|
||||
}
|
||||
let Ok(appdir) = appdir.canonicalize() else { return false };
|
||||
// No image mounts at the root, where every program on the machine would count as inside it.
|
||||
appdir.parent().is_some() && exe.starts_with(&appdir)
|
||||
}
|
||||
|
||||
/// Starts this process over without another program's AppImage variables; returns only when there
|
||||
/// are none to drop (`None`) or starting over failed. Call it first, before any other thread starts.
|
||||
pub(crate) fn drop_inherited() -> Option<std::io::Error> {
|
||||
let appimage = std::env::var_os("APPIMAGE")?;
|
||||
let exe = std::env::current_exe().ok();
|
||||
if runs_from_image(exe.as_deref(), std::env::var_os("APPDIR").as_deref()) {
|
||||
return None;
|
||||
}
|
||||
// The logger isn't up yet, and wouldn't survive `exec`. Unlike `eprintln!`, never panics.
|
||||
let _ = writeln!(std::io::stderr(), "wordcraft: ignoring the AppImage {:?} inherited from another program", Path::new(&appimage));
|
||||
let mut args = std::env::args_os();
|
||||
// This file, whatever has happened to its path since it started.
|
||||
let mut again = Command::new("/proc/self/exe");
|
||||
if let Some(arg0) = args.next() {
|
||||
again.arg0(arg0);
|
||||
}
|
||||
again.args(args);
|
||||
for var in RUNTIME_VARS {
|
||||
again.env_remove(var);
|
||||
}
|
||||
Some(again.exec())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::runs_from_image;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// A folder of its own for one test, removed when it ends.
|
||||
struct Tmp(PathBuf);
|
||||
|
||||
impl Tmp {
|
||||
/// An empty folder named after `test` and this process, made afresh.
|
||||
fn new(test: &str) -> Tmp {
|
||||
let dir = std::env::temp_dir().join(format!("wordcraft-appimage-{test}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
Tmp(dir)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Tmp {
|
||||
/// Removes the folder and what the test left in it.
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// A mount with the executable inside it, by real path (`/tmp` may be a link).
|
||||
fn mount(root: &Path, name: &str) -> (PathBuf, PathBuf) {
|
||||
let dir = root.join(name);
|
||||
std::fs::create_dir_all(dir.join("usr/bin")).unwrap();
|
||||
let dir = dir.canonicalize().unwrap();
|
||||
let exe = dir.join("usr/bin/wordcraft");
|
||||
(dir, exe)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_executable_in_the_mount_is_the_image() {
|
||||
let tmp = Tmp::new("own");
|
||||
let (dir, exe) = mount(&tmp.0, ".mount_WordCr");
|
||||
assert!(runs_from_image(Some(&exe), Some(dir.as_os_str())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_to_the_mount_is_the_same_mount() {
|
||||
let tmp = Tmp::new("link");
|
||||
let (dir, exe) = mount(&tmp.0, ".mount_WordCr");
|
||||
let link = tmp.0.join("link");
|
||||
std::os::unix::fs::symlink(&dir, &link).unwrap();
|
||||
assert!(runs_from_image(Some(&exe), Some(link.as_os_str())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_installed_executable_is_not_in_another_programs_mount() {
|
||||
let tmp = Tmp::new("other");
|
||||
let (other, _) = mount(&tmp.0, ".mount_other");
|
||||
assert!(!runs_from_image(Some(Path::new("/usr/bin/wordcraft")), Some(other.as_os_str())));
|
||||
}
|
||||
|
||||
/// Paths compare by component: one mount's name can start with another's.
|
||||
#[test]
|
||||
fn a_mount_whose_name_starts_the_same_is_another_mount() {
|
||||
let tmp = Tmp::new("prefix");
|
||||
let (other, _) = mount(&tmp.0, ".mount_ab");
|
||||
let (_, exe) = mount(&tmp.0, ".mount_abc");
|
||||
assert!(!runs_from_image(Some(&exe), Some(other.as_os_str())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anything_unclear_is_not_the_image() {
|
||||
let tmp = Tmp::new("unclear");
|
||||
let (dir, exe) = mount(&tmp.0, ".mount_WordCr");
|
||||
// No `APPDIR`, one that's gone (the other program has exited) or empty.
|
||||
assert!(!runs_from_image(Some(&exe), None));
|
||||
assert!(!runs_from_image(Some(&exe), Some(tmp.0.join("gone").as_os_str())));
|
||||
assert!(!runs_from_image(Some(&exe), Some("".as_ref())));
|
||||
// The root, and a relative path that happens to lead here.
|
||||
assert!(!runs_from_image(Some(&exe), Some("/".as_ref())));
|
||||
assert!(!runs_from_image(Some(&exe), Some(".".as_ref())));
|
||||
// No executable path at all.
|
||||
assert!(!runs_from_image(None, Some(dir.as_os_str())));
|
||||
}
|
||||
}
|
||||
@@ -36,14 +36,15 @@ impl AppleEvents {
|
||||
|
||||
/// Open the documents and act on the quit requests that arrived since the last frame. A document
|
||||
/// opens like File › Open: beside the open ones, in place of an untouched blank one, or, when it
|
||||
/// is already open, by showing it. Quitting closes the window, which asks about every document
|
||||
/// is already open, by showing it; a Word template starts a new document based on it, as Word's
|
||||
/// default verb for one does (#3433). Quitting closes the window, which asks about every document
|
||||
/// with unsaved changes.
|
||||
pub fn poll(inbox: &Inbox, app: &mut WordApp, ctx: &egui::Context) {
|
||||
for e in inbox.drain() {
|
||||
match e {
|
||||
Event::Open(paths) => {
|
||||
for p in paths.iter().filter_map(|p| p.to_str()) {
|
||||
match app.open_file(p, None) {
|
||||
match app.open_from_platform(p, None) {
|
||||
// Leave Backstage, as opening from the Open dialog does.
|
||||
Ok(_) => app.ui.backstage = false,
|
||||
Err(e) => {
|
||||
|
||||
+537
-18
@@ -18,6 +18,11 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! RTF goes beside them where we can add it: Windows ("Rich Text Format") and macOS
|
||||
//! (`public.rtf`) after arboard's HTML, Wayland (`text/rtf`) in one offer with the text and HTML.
|
||||
//! On X11 arboard owns the selection and serves only its own targets, so a copy there carries text
|
||||
//! and HTML only; reading another program's RTF works on all four.
|
||||
|
||||
use wordcraft_engine::cmd::paste::MAX_PASTE_BYTES;
|
||||
use wordcraft_ui_egui::paste_picture::ClipboardPicture;
|
||||
@@ -31,31 +36,43 @@ 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, 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 {
|
||||
/// Put a copy on the clipboard: `text`, with `html` and `rtf` beside it where the platform takes
|
||||
/// them (an empty one isn't offered), and `mathml` (a copy of one equation alone; empty for any
|
||||
/// other copy) in the platform's MathML formats. `false` when the text and HTML 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.
|
||||
/// RTF or MathML that can't be added is logged: the HTML still carries the copy.
|
||||
pub fn set_rich(text: &str, html: &str, rtf: &str, mathml: &str) -> bool {
|
||||
// Wayland: one offer with the text, HTML and RTF (no MathML formats there; see above).
|
||||
if platform::set_rich(text, html, rtf) {
|
||||
return true;
|
||||
}
|
||||
if !platform::can_set() {
|
||||
return false;
|
||||
}
|
||||
let set = set_html_text(html, text);
|
||||
if set && !rtf.is_empty() {
|
||||
platform::add_rtf(rtf);
|
||||
}
|
||||
if set && !mathml.is_empty() && mathml.len() <= MAX_PASTE_BYTES {
|
||||
platform::add_mathml(mathml);
|
||||
}
|
||||
set
|
||||
}
|
||||
|
||||
/// Write HTML and plain text to the system clipboard via the cached writer slot, recording ownership.
|
||||
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() {
|
||||
*slot = open();
|
||||
}
|
||||
match slot.as_mut().map(|c| c.set().html(html, Some(text))) {
|
||||
Some(Ok(())) => true,
|
||||
let set = slot.as_mut().map(|c| if html.is_empty() { c.set_text(text) } else { c.set().html(html, Some(text)) });
|
||||
match set {
|
||||
Some(Ok(())) => {
|
||||
platform::record_own();
|
||||
true
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
log::warn!("clipboard: {e}");
|
||||
*slot = None;
|
||||
@@ -66,6 +83,74 @@ fn set_html_text(html: &str, text: &str) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// Put a copied picture on the clipboard: its pixels, with HTML and text beside it where the
|
||||
/// platform takes them. `false` when it couldn't be set.
|
||||
pub fn set_image(image: &wordcraft_ui_egui::system_clipboard::CopyImage, html: &str, text: &str) -> bool {
|
||||
if platform::set_image(image, html, text) {
|
||||
return true;
|
||||
}
|
||||
if !platform::can_set() {
|
||||
return false;
|
||||
}
|
||||
let set = set_image_arboard(image);
|
||||
if set {
|
||||
platform::after_set_image(image, html, text);
|
||||
}
|
||||
set
|
||||
}
|
||||
|
||||
/// Write image pixels to the clipboard through arboard.
|
||||
fn set_image_arboard(image: &wordcraft_ui_egui::system_clipboard::CopyImage) -> bool {
|
||||
WRITER.with(|w| {
|
||||
let Ok(mut slot) = w.try_borrow_mut() else { return false };
|
||||
if slot.is_none() {
|
||||
*slot = open();
|
||||
}
|
||||
let img = arboard::ImageData { width: image.width, height: image.height, bytes: std::borrow::Cow::Borrowed(&image.rgba) };
|
||||
let set = slot.as_mut().map(|c| c.set().image(img));
|
||||
match set {
|
||||
Some(Ok(())) => {
|
||||
platform::record_own();
|
||||
true
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
log::warn!("clipboard image: {e}");
|
||||
*slot = None;
|
||||
false
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the system clipboard still holds WordCraft's last copy.
|
||||
pub fn is_own() -> bool {
|
||||
platform::is_own()
|
||||
}
|
||||
|
||||
/// Wrap HTML in the Windows CF_HTML header format required by clipboard readers.
|
||||
///
|
||||
/// Offsets are byte offsets from the start of the payload; `EndHTML` excludes the trailing NUL.
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
fn wrap_cf_html(ctn: &str) -> Vec<u8> {
|
||||
let h_version = "Version:0.9";
|
||||
let h_start_html = "\r\nStartHTML:";
|
||||
let h_end_html = "\r\nEndHTML:";
|
||||
let h_start_frag = "\r\nStartFragment:";
|
||||
let h_end_frag = "\r\nEndFragment:";
|
||||
let c_start_frag = "\r\n<html>\r\n<body>\r\n<!--StartFragment-->\r\n";
|
||||
let c_end_frag = "\r\n<!--EndFragment-->\r\n</body>\r\n</html>\0";
|
||||
let h_len = h_version.len() + h_start_html.len() + 10 + h_end_html.len() + 10 + h_start_frag.len() + 10 + h_end_frag.len() + 10;
|
||||
let n_start_html = h_len + 2;
|
||||
let n_start_frag = h_len + c_start_frag.len();
|
||||
let n_end_frag = n_start_frag + ctn.len();
|
||||
let n_end_html = n_end_frag + c_end_frag.len() - 1;
|
||||
let header = format!(
|
||||
"{h_version}{h_start_html}{n_start_html:010}{h_end_html}{n_end_html:010}{h_start_frag}{n_start_frag:010}{h_end_frag}{n_end_frag:010}{c_start_frag}{ctn}{c_end_frag}"
|
||||
);
|
||||
header.into_bytes()
|
||||
}
|
||||
|
||||
/// The clipboard's text.
|
||||
pub fn text() -> Option<String> {
|
||||
open()?.get_text().ok()
|
||||
@@ -76,8 +161,8 @@ pub fn html() -> Option<String> {
|
||||
open()?.get().html().ok().filter(|h| h.len() <= MAX_PASTE_BYTES)
|
||||
}
|
||||
|
||||
/// The clipboard's RTF: Windows ("Rich Text Format") and Wayland (`text/rtf`); not offered by
|
||||
/// arboard, so read here.
|
||||
/// The clipboard's RTF: Windows ("Rich Text Format"), macOS (`public.rtf`), Wayland and X11
|
||||
/// (`text/rtf`); not offered by arboard, so read here.
|
||||
pub fn rtf() -> Option<String> {
|
||||
let bytes = platform::rtf()?;
|
||||
if bytes.len() > MAX_PASTE_BYTES {
|
||||
@@ -95,8 +180,8 @@ pub fn formats() -> Vec<String> {
|
||||
|
||||
/// The picture on the clipboard: copied files, else image pixels, else (Wayland) an encoded
|
||||
/// picture in a format arboard doesn't decode. A picture that is there but can't be read says
|
||||
/// why (#833). The UI checks the sizes before inserting anything; on Windows they are checked
|
||||
/// from the header before anything is decoded (#3623).
|
||||
/// why (#833). The UI checks the sizes before inserting anything; on Windows and Wayland they are
|
||||
/// checked from the header before anything is decoded (#3623).
|
||||
pub fn picture() -> Option<ClipboardPicture> {
|
||||
let mut clipboard = match arboard::Clipboard::new() {
|
||||
Ok(c) => c,
|
||||
@@ -123,6 +208,10 @@ fn decoded_picture(_: &mut arboard::Clipboard) -> Option<ClipboardPicture> {
|
||||
/// The picture arboard decodes from the clipboard, else an encoded one read past it.
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn decoded_picture(clipboard: &mut arboard::Clipboard) -> Option<ClipboardPicture> {
|
||||
#[cfg(all(unix, not(any(target_os = "macos", target_os = "android", target_os = "emscripten"))))]
|
||||
if let Some(picture) = platform::picture() {
|
||||
return Some(picture);
|
||||
}
|
||||
match clipboard.get_image() {
|
||||
Ok(image) => Some(ClipboardPicture::Pixels { width: image.width, height: image.height, rgba: image.bytes.into_owned() }),
|
||||
Err(arboard::Error::ContentNotAvailable) => platform::encoded_picture().map(ClipboardPicture::Encoded),
|
||||
@@ -147,6 +236,7 @@ mod platform {
|
||||
use std::io::Read;
|
||||
|
||||
use wl_clipboard_rs::paste::{ClipboardType, MimeType, Seat, get_contents, get_mime_types_ordered};
|
||||
use wordcraft_ui_egui::paste_picture::ClipboardPicture;
|
||||
|
||||
fn wayland() -> bool {
|
||||
std::env::var_os("WAYLAND_DISPLAY").is_some()
|
||||
@@ -167,6 +257,39 @@ mod platform {
|
||||
get_mime_types_ordered(ClipboardType::Regular, Seat::Unspecified).unwrap_or_default()
|
||||
}
|
||||
|
||||
const OWNER_MIME: &str = "application/x-wordcraft-owner";
|
||||
|
||||
/// Process-unique ownership token placed into Wayland clipboard offers.
|
||||
fn owner_token() -> [u8; 16] {
|
||||
static TOKEN: std::sync::OnceLock<[u8; 16]> = std::sync::OnceLock::new();
|
||||
*TOKEN.get_or_init(|| {
|
||||
let mut b = [0u8; 16];
|
||||
let _ = getrandom::fill(&mut b);
|
||||
b
|
||||
})
|
||||
}
|
||||
|
||||
static X11_OWNER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
||||
|
||||
/// Record clipboard ownership for X11 selection tracking.
|
||||
pub fn record_own() {
|
||||
if !wayland() {
|
||||
let win = x11::owner_window().unwrap_or(0);
|
||||
X11_OWNER.store(win, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether WordCraft still owns the Wayland offer or X11 selection.
|
||||
pub fn is_own() -> bool {
|
||||
if wayland() {
|
||||
if formats().iter().any(|m| m == OWNER_MIME) { read(OWNER_MIME, 16).is_some_and(|b| b == owner_token()) } else { false }
|
||||
} else {
|
||||
let current = x11::owner_window().unwrap_or(0);
|
||||
let recorded = X11_OWNER.load(std::sync::atomic::Ordering::Relaxed);
|
||||
recorded != 0 && current == recorded
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether arboard sets the clipboard natively: X11, or Wayland with data-control.
|
||||
pub fn can_set() -> bool {
|
||||
!wayland()
|
||||
@@ -176,15 +299,83 @@ mod platform {
|
||||
)
|
||||
}
|
||||
|
||||
/// Put the copy on a Wayland clipboard in one offer: the text (with the usual plain-text
|
||||
/// aliases), HTML and RTF, served from a background thread until something else is copied.
|
||||
/// `false` outside Wayland (X11 goes through arboard) or without the data-control protocol.
|
||||
pub fn set_rich(text: &str, html: &str, rtf: &str) -> bool {
|
||||
use wl_clipboard_rs::copy::{MimeSource, MimeType, Options, Source};
|
||||
|
||||
if !wayland() {
|
||||
return false;
|
||||
}
|
||||
let offer = |mime: &str, data: &str| MimeSource { source: Source::Bytes(data.as_bytes().into()), mime_type: MimeType::Specific(mime.into()) };
|
||||
// The text first: the plain-text aliases serve the first text type offered.
|
||||
let mut sources = vec![offer("text/plain;charset=utf-8", text)];
|
||||
for (mime, data) in [("text/html", html), ("text/rtf", rtf)] {
|
||||
if !data.is_empty() {
|
||||
sources.push(offer(mime, data));
|
||||
}
|
||||
}
|
||||
sources.push(MimeSource { source: Source::Bytes(owner_token().to_vec().into()), mime_type: MimeType::Specific(OWNER_MIME.into()) });
|
||||
match Options::new().copy_multi(sources) {
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
log::warn!("clipboard: {e}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Put a copied picture on the Wayland clipboard alongside HTML, text, and ownership token.
|
||||
pub fn set_image(image: &wordcraft_ui_egui::system_clipboard::CopyImage, html: &str, text: &str) -> bool {
|
||||
use wl_clipboard_rs::copy::{MimeSource, MimeType, Options, Source};
|
||||
|
||||
if !wayland() {
|
||||
return false;
|
||||
}
|
||||
let Ok(png) = wordcraft_ui_egui::paste_picture::png_from_rgba(image.width, image.height, image.rgba.clone()) else {
|
||||
return false;
|
||||
};
|
||||
let offer = |mime: &str, data: &str| MimeSource { source: Source::Bytes(data.as_bytes().into()), mime_type: MimeType::Specific(mime.into()) };
|
||||
let mut sources = vec![MimeSource { source: Source::Bytes(png.into()), mime_type: MimeType::Specific("image/png".into()) }];
|
||||
if !text.is_empty() {
|
||||
sources.push(offer("text/plain;charset=utf-8", text));
|
||||
}
|
||||
if !html.is_empty() {
|
||||
sources.push(offer("text/html", html));
|
||||
}
|
||||
sources.push(MimeSource { source: Source::Bytes(owner_token().to_vec().into()), mime_type: MimeType::Specific(OWNER_MIME.into()) });
|
||||
match Options::new().copy_multi(sources) {
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
log::warn!("clipboard image: {e}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Additional formats to register after setting image via arboard on Unix.
|
||||
pub fn after_set_image(_image: &wordcraft_ui_egui::system_clipboard::CopyImage, _html: &str, _text: &str) {}
|
||||
|
||||
/// Wayland copies carry their RTF already; X11 ones can't (see the module docs).
|
||||
pub fn add_rtf(_rtf: &str) {}
|
||||
|
||||
/// RTF types a clipboard may offer, best first.
|
||||
const RTF_MIMES: [&str; 3] = ["text/rtf", "application/rtf", "text/richtext"];
|
||||
|
||||
/// The RTF type to ask for among those `offered`.
|
||||
fn rtf_target(offered: &[String]) -> Option<&'static str> {
|
||||
RTF_MIMES.into_iter().find(|m| offered.iter().any(|o| o == m))
|
||||
}
|
||||
|
||||
/// 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;
|
||||
return std::env::var_os("DISPLAY").is_some().then(|| x11::rtf(super::MAX_PASTE_BYTES)).flatten();
|
||||
}
|
||||
let offered = formats();
|
||||
let mime = ["text/rtf", "application/rtf", "text/richtext"].into_iter().find(|m| offered.iter().any(|o| o == m))?;
|
||||
let mime = rtf_target(&formats())?;
|
||||
read(mime, super::MAX_PASTE_BYTES as u64)
|
||||
}
|
||||
|
||||
@@ -196,6 +387,149 @@ mod platform {
|
||||
let mime = super::PICTURE_MIMES.into_iter().find(|m| offered.iter().any(|o| o == m))?;
|
||||
read(mime, wordcraft_ui_egui::paste_picture::MAX_FILE_BYTES)
|
||||
}
|
||||
|
||||
/// Whether a property read of `len` bytes, with `bytes_after` left unread, is the whole value
|
||||
/// and at most `cap` bytes.
|
||||
fn complete(len: usize, bytes_after: u32, cap: usize) -> bool {
|
||||
bytes_after == 0 && len <= cap
|
||||
}
|
||||
|
||||
/// Reading another program's RTF on X11, which arboard doesn't offer: ask the CLIPBOARD
|
||||
/// selection's owner for its targets, then for its RTF, into a property of a hidden window.
|
||||
mod x11 {
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use x11rb::connection::Connection;
|
||||
use x11rb::protocol::Event;
|
||||
use x11rb::protocol::xproto::{Atom, AtomEnum, ConnectionExt as _, CreateWindowAux, GetPropertyReply, Window, WindowClass};
|
||||
use x11rb::rust_connection::RustConnection;
|
||||
|
||||
/// How long a paste waits for the selection's owner, in all.
|
||||
const TIMEOUT: Duration = Duration::from_secs(1);
|
||||
/// Most targets looked at.
|
||||
const MAX_TARGETS: usize = 128;
|
||||
|
||||
struct Reader {
|
||||
conn: RustConnection,
|
||||
win: Window,
|
||||
clipboard: Atom,
|
||||
prop: Atom,
|
||||
deadline: Instant,
|
||||
}
|
||||
|
||||
fn atom(conn: &RustConnection, name: &str) -> Option<Atom> {
|
||||
conn.intern_atom(false, name.as_bytes()).ok()?.reply().ok().map(|r| r.atom)
|
||||
}
|
||||
|
||||
impl Reader {
|
||||
fn new() -> Option<Self> {
|
||||
let (conn, screen) = x11rb::connect(None).ok()?;
|
||||
let root = conn.setup().roots.get(screen)?.root;
|
||||
let win = conn.generate_id().ok()?;
|
||||
conn.create_window(0, win, root, 0, 0, 1, 1, 0, WindowClass::INPUT_ONLY, x11rb::COPY_FROM_PARENT, &CreateWindowAux::new()).ok()?;
|
||||
let clipboard = atom(&conn, "CLIPBOARD")?;
|
||||
let prop = atom(&conn, "WORDCRAFT_PASTE")?;
|
||||
Some(Self { conn, win, clipboard, prop, deadline: Instant::now() + TIMEOUT })
|
||||
}
|
||||
|
||||
/// The selection converted to `target`, at most `cap` bytes; `None` when the owner
|
||||
/// refuses or doesn't answer in time. A longer value is `None` when `whole`, else
|
||||
/// its first `cap` bytes.
|
||||
fn convert(&self, target: Atom, cap: usize, whole: bool) -> Option<GetPropertyReply> {
|
||||
self.conn.convert_selection(self.win, self.clipboard, target, self.prop, x11rb::CURRENT_TIME).ok()?;
|
||||
self.conn.flush().ok()?;
|
||||
loop {
|
||||
// Checked every time round, so a stream of other events can't keep us here.
|
||||
if Instant::now() >= self.deadline {
|
||||
return None;
|
||||
}
|
||||
match self.conn.poll_for_event().ok()? {
|
||||
Some(Event::SelectionNotify(e)) if e.requestor == self.win => {
|
||||
if e.property == x11rb::NONE {
|
||||
return None;
|
||||
}
|
||||
break;
|
||||
}
|
||||
Some(_) => {}
|
||||
None => std::thread::sleep(Duration::from_millis(5)),
|
||||
}
|
||||
}
|
||||
// In 32-bit units; for a whole value one more than the cap, so a larger one shows
|
||||
// as `bytes_after`.
|
||||
let units = u32::try_from(cap / 4 + usize::from(whole)).unwrap_or(u32::MAX);
|
||||
let r = self.conn.get_property(true, self.win, self.prop, AtomEnum::ANY, 0, units).ok()?.reply().ok()?;
|
||||
(!whole || super::complete(r.value.len(), r.bytes_after, cap)).then_some(r)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Reader {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.conn.destroy_window(self.win);
|
||||
let _ = self.conn.flush();
|
||||
}
|
||||
}
|
||||
|
||||
/// The CLIPBOARD selection's RTF, at most `cap` bytes. Copies too large to send at once
|
||||
/// arrive in pieces (INCR), which aren't read: `None` then.
|
||||
pub fn rtf(cap: usize) -> Option<Vec<u8>> {
|
||||
let r = Reader::new()?;
|
||||
// The first targets only: a longer list is cut short, not refused.
|
||||
let targets = r.convert(atom(&r.conn, "TARGETS")?, MAX_TARGETS.saturating_mul(4), false)?;
|
||||
let cookies: Vec<_> = targets.value32()?.take(MAX_TARGETS).filter_map(|a| r.conn.get_atom_name(a).ok()).collect();
|
||||
let names: Vec<String> =
|
||||
cookies.into_iter().filter_map(|c| c.reply().ok()).map(|n| String::from_utf8_lossy(&n.name).into_owned()).collect();
|
||||
let target = atom(&r.conn, super::rtf_target(&names)?)?;
|
||||
let incr = atom(&r.conn, "INCR")?;
|
||||
let reply = r.convert(target, cap, true)?;
|
||||
(reply.type_ != incr).then_some(reply.value)
|
||||
}
|
||||
|
||||
/// Query X11 for the current window id owning the CLIPBOARD selection.
|
||||
pub fn owner_window() -> Option<Window> {
|
||||
let (conn, _) = x11rb::connect(None).ok()?;
|
||||
let clipboard = atom(&conn, "CLIPBOARD")?;
|
||||
let reply = conn.get_selection_owner(clipboard).ok()?.reply().ok()?;
|
||||
(reply.owner != x11rb::NONE).then_some(reply.owner)
|
||||
}
|
||||
}
|
||||
|
||||
/// The Wayland clipboard's picture, read encoded under the paste's byte cap and refused from
|
||||
/// its header when a paste can't take its size, before anything decodes it. arboard decodes
|
||||
/// and converts the PNG to RGBA with no size limits. `None` when Wayland offers no picture
|
||||
/// (or isn't reachable): arboard is asked then, which also covers X11.
|
||||
pub fn picture() -> Option<ClipboardPicture> {
|
||||
if !wayland() {
|
||||
return None;
|
||||
}
|
||||
let offered = formats();
|
||||
let mime = super::PICTURE_MIMES.into_iter().find(|m| offered.iter().any(|o| o == m))?;
|
||||
Some(match read(mime, wordcraft_ui_egui::paste_picture::MAX_FILE_BYTES) {
|
||||
Some(bytes) => wordcraft_ui_egui::paste_picture::encoded_picture(bytes),
|
||||
None => ClipboardPicture::Unreadable("the clipboard picture is larger than 200 MB, or couldn't be read".into()),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rtf_target_prefers_text_rtf() {
|
||||
let names = |n: &[&str]| n.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
assert_eq!(rtf_target(&names(&["TARGETS", "text/richtext", "application/rtf", "text/rtf"])), Some("text/rtf"));
|
||||
assert_eq!(rtf_target(&names(&["text/richtext", "UTF8_STRING"])), Some("text/richtext"));
|
||||
assert_eq!(rtf_target(&names(&["text/html", "text/rtf;charset=x"])), None);
|
||||
assert_eq!(rtf_target(&[]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn property_reads_must_be_whole_and_capped() {
|
||||
assert!(complete(10, 0, 10));
|
||||
assert!(!complete(11, 0, 10), "over the cap");
|
||||
assert!(!complete(8, 4, 10), "more left to read");
|
||||
assert!(complete(0, 0, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -247,10 +581,84 @@ mod platform {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
const OWNER_FORMAT: &str = "WordCraft Copy Owner";
|
||||
|
||||
/// Process-unique ownership token placed into Windows clipboard marker.
|
||||
fn owner_token() -> [u8; 16] {
|
||||
static TOKEN: std::sync::OnceLock<[u8; 16]> = std::sync::OnceLock::new();
|
||||
*TOKEN.get_or_init(|| {
|
||||
let mut b = [0u8; 16];
|
||||
let _ = getrandom::fill(&mut b);
|
||||
b
|
||||
})
|
||||
}
|
||||
|
||||
/// Record clipboard ownership for Windows clipboard.
|
||||
pub fn record_own() {
|
||||
let Some(format) = raw::register_format(OWNER_FORMAT) else { return };
|
||||
let Ok(_open) = Clipboard::new_attempts(10) else { return };
|
||||
let _ = raw::set_without_clear(format.get(), &owner_token());
|
||||
}
|
||||
|
||||
/// Whether WordCraft still owns the Windows clipboard contents.
|
||||
pub fn is_own() -> bool {
|
||||
let Some(format) = raw::register_format(OWNER_FORMAT) else { return false };
|
||||
let Ok(_open) = Clipboard::new_attempts(10) else { return false };
|
||||
if !raw::is_format_avail(format.get()) {
|
||||
return false;
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
if raw::get_vec(format.get(), &mut out).is_err() {
|
||||
return false;
|
||||
}
|
||||
out == owner_token()
|
||||
}
|
||||
|
||||
/// Windows supports clipboard setting via arboard and clipboard_win.
|
||||
pub fn can_set() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// arboard sets the text and HTML.
|
||||
pub fn set_rich(_text: &str, _html: &str, _rtf: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Windows sets the primary image through arboard and supplements via [`after_set_image`].
|
||||
pub fn set_image(_image: &wordcraft_ui_egui::system_clipboard::CopyImage, _html: &str, _text: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Add PNG and CF_HTML formats alongside arboard's image, then mark ownership.
|
||||
pub fn after_set_image(image: &wordcraft_ui_egui::system_clipboard::CopyImage, html: &str, _text: &str) {
|
||||
if let Ok(png) = wordcraft_ui_egui::paste_picture::png_from_rgba(image.width, image.height, image.rgba.clone())
|
||||
&& let Some(format) = raw::register_format("PNG")
|
||||
&& let Ok(_open) = Clipboard::new_attempts(10)
|
||||
{
|
||||
let _ = raw::set_without_clear(format.get(), &png);
|
||||
}
|
||||
if !html.is_empty()
|
||||
&& let Some(format) = raw::register_format("HTML Format")
|
||||
&& let Ok(_open) = Clipboard::new_attempts(10)
|
||||
{
|
||||
let cf = super::wrap_cf_html(html);
|
||||
let _ = raw::set_without_clear(format.get(), &cf);
|
||||
}
|
||||
record_own();
|
||||
}
|
||||
|
||||
/// Add "Rich Text Format" to what arboard just set, NUL-terminated as is usual.
|
||||
pub fn add_rtf(rtf: &str) {
|
||||
let Some(format) = raw::register_format("Rich Text Format") else { return };
|
||||
let Ok(_open) = Clipboard::new_attempts(10) else { return };
|
||||
let mut bytes = Vec::with_capacity(rtf.len().saturating_add(1));
|
||||
bytes.extend_from_slice(rtf.as_bytes());
|
||||
bytes.push(0);
|
||||
if let Err(e) = raw::set_without_clear(format.get(), &bytes) {
|
||||
log::warn!("clipboard RTF: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
@@ -276,24 +684,76 @@ const MATHML_FORMATS: [&str; 2] = ["MathML", "MathML Presentation"];
|
||||
#[cfg(target_os = "macos")]
|
||||
mod platform {
|
||||
use objc2_app_kit::NSPasteboard;
|
||||
use objc2_foundation::NSString;
|
||||
use objc2_foundation::{NSData, NSString};
|
||||
|
||||
/// RTF's pasteboard type (`NSPasteboardTypeRTF`, whose extern static needs unsafe to read).
|
||||
const RTF: &str = "public.rtf";
|
||||
|
||||
/// Formats offered on macOS pasteboard.
|
||||
pub fn formats() -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Read RTF data from macOS pasteboard.
|
||||
pub fn rtf() -> Option<Vec<u8>> {
|
||||
None
|
||||
let data = NSPasteboard::generalPasteboard().dataForType(&NSString::from_str(RTF))?;
|
||||
(data.len() <= super::MAX_PASTE_BYTES).then(|| data.to_vec())
|
||||
}
|
||||
|
||||
/// Read encoded picture data from macOS pasteboard.
|
||||
pub fn encoded_picture() -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
|
||||
static LAST_CHANGE_COUNT: std::sync::atomic::AtomicIsize = std::sync::atomic::AtomicIsize::new(-1);
|
||||
|
||||
/// Record pasteboard changeCount on macOS to verify ownership.
|
||||
pub fn record_own() {
|
||||
let count = NSPasteboard::generalPasteboard().changeCount();
|
||||
LAST_CHANGE_COUNT.store(count, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Whether WordCraft still owns the macOS pasteboard.
|
||||
pub fn is_own() -> bool {
|
||||
let count = NSPasteboard::generalPasteboard().changeCount();
|
||||
let recorded = LAST_CHANGE_COUNT.load(std::sync::atomic::Ordering::Relaxed);
|
||||
recorded >= 0 && count == recorded
|
||||
}
|
||||
|
||||
/// Whether macOS supports setting the pasteboard.
|
||||
pub fn can_set() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// arboard sets the text and HTML.
|
||||
pub fn set_rich(_text: &str, _html: &str, _rtf: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// macOS sets the primary image through arboard and supplements via [`after_set_image`].
|
||||
pub fn set_image(_image: &wordcraft_ui_egui::system_clipboard::CopyImage, _html: &str, _text: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Set supplemental PNG and HTML representations on macOS pasteboard.
|
||||
pub fn after_set_image(image: &wordcraft_ui_egui::system_clipboard::CopyImage, html: &str, _text: &str) {
|
||||
let board = NSPasteboard::generalPasteboard();
|
||||
if let Ok(png) = wordcraft_ui_egui::paste_picture::png_from_rgba(image.width, image.height, image.rgba.clone()) {
|
||||
let _ = board.setData_forType(Some(&NSData::with_bytes(&png)), &NSString::from_str("public.png"));
|
||||
}
|
||||
if !html.is_empty() {
|
||||
let _ = board.setString_forType(&NSString::from_str(html), &NSString::from_str("public.html"));
|
||||
}
|
||||
record_own();
|
||||
}
|
||||
|
||||
/// Add RTF to the pasteboard item arboard just wrote (it cleared the pasteboard first).
|
||||
pub fn add_rtf(rtf: &str) {
|
||||
if !NSPasteboard::generalPasteboard().setData_forType(Some(&NSData::with_bytes(rtf.as_bytes())), &NSString::from_str(RTF)) {
|
||||
log::warn!("clipboard: the RTF couldn't be added");
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
@@ -313,21 +773,80 @@ mod platform {
|
||||
all(unix, not(any(target_os = "macos", target_os = "android", target_os = "emscripten")))
|
||||
)))]
|
||||
mod platform {
|
||||
/// Formats on the clipboard.
|
||||
pub fn formats() -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// The RTF bytes on the clipboard.
|
||||
pub fn rtf() -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Encoded picture bytes on the clipboard.
|
||||
pub fn encoded_picture() -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether the fallback platform can set the clipboard.
|
||||
pub fn can_set() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Record clipboard ownership on the fallback platform.
|
||||
pub fn record_own() {}
|
||||
|
||||
/// Whether WordCraft owns the clipboard on the fallback platform.
|
||||
pub fn is_own() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Set rich text on the fallback platform.
|
||||
pub fn set_rich(_text: &str, _html: &str, _rtf: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Set image on the fallback platform.
|
||||
pub fn set_image(_image: &wordcraft_ui_egui::system_clipboard::CopyImage, _html: &str, _text: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Post-image set hook on the fallback platform.
|
||||
pub fn after_set_image(_image: &wordcraft_ui_egui::system_clipboard::CopyImage, _html: &str, _text: &str) {}
|
||||
|
||||
/// Add RTF to the clipboard on the fallback platform.
|
||||
pub fn add_rtf(_rtf: &str) {}
|
||||
|
||||
/// Add MathML to the clipboard on the fallback platform.
|
||||
pub fn add_mathml(_mathml: &str) {}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cf_html_tests {
|
||||
use super::wrap_cf_html;
|
||||
|
||||
fn field(data: &[u8], name: &str) -> usize {
|
||||
let at = data.windows(name.len()).position(|w| w == name.as_bytes()).expect("field") + name.len();
|
||||
std::str::from_utf8(&data[at..at + 10]).expect("offset digits").parse().expect("offset")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cf_html_offsets_delimit_the_fragment_bytes() {
|
||||
let fragment = "<p>Olá, 世界 — café</p>";
|
||||
let data = wrap_cf_html(fragment);
|
||||
|
||||
let start_html = field(&data, "StartHTML:");
|
||||
let end_html = field(&data, "EndHTML:");
|
||||
let start_fragment = field(&data, "StartFragment:");
|
||||
let end_fragment = field(&data, "EndFragment:");
|
||||
|
||||
assert!(start_html < start_fragment, "HTML starts before its fragment");
|
||||
assert!(start_fragment < end_fragment, "fragment has a positive length");
|
||||
assert!(end_fragment < end_html, "fragment ends inside the document");
|
||||
assert_eq!(&data[start_html..start_html + "<html>".len()], b"<html>");
|
||||
assert_eq!(&data[start_fragment..end_fragment], fragment.as_bytes());
|
||||
assert_eq!(end_fragment - start_fragment, fragment.len(), "offsets count UTF-8 bytes");
|
||||
assert_eq!(&data[end_html..], b"\0", "EndHTML excludes the trailing NUL");
|
||||
assert_eq!(end_html, data.len() - 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -21,6 +22,38 @@ const MAX_LINE: usize = 1 << 20;
|
||||
/// A connection that sends no authorised request in this time is closed.
|
||||
const FIRST_REQUEST: Duration = Duration::from_secs(30);
|
||||
|
||||
/// How long a caller waits for the UI to answer a request. A request still queued after this
|
||||
/// is cancelled and never runs.
|
||||
const REPLY_WAIT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Connections that have not yet sent an authorised request are limited to this many; more are
|
||||
/// closed at once. Each one holds a thread until its first request or [`FIRST_REQUEST`].
|
||||
const MAX_UNAUTHENTICATED: usize = 64;
|
||||
|
||||
/// Counts one unauthenticated connection until it authenticates or ends.
|
||||
struct Admission(Option<Arc<AtomicUsize>>);
|
||||
|
||||
impl Admission {
|
||||
fn enter(count: &Arc<AtomicUsize>, max: usize) -> Option<Self> {
|
||||
if count.fetch_add(1, Ordering::SeqCst) >= max {
|
||||
count.fetch_sub(1, Ordering::SeqCst);
|
||||
return None;
|
||||
}
|
||||
Some(Self(Some(count.clone())))
|
||||
}
|
||||
fn release(&mut self) {
|
||||
if let Some(c) = self.0.take() {
|
||||
c.fetch_sub(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Admission {
|
||||
fn drop(&mut self) {
|
||||
self.release();
|
||||
}
|
||||
}
|
||||
|
||||
/// The key file of a running control server; [`KeyFile::remove`] it when the window closes.
|
||||
pub struct KeyFile(PathBuf);
|
||||
|
||||
@@ -37,6 +70,15 @@ impl KeyFile {
|
||||
/// cannot be bound or there is no random key (the control channel is off); the key file is
|
||||
/// `None` when it could not be written (requests are then refused).
|
||||
pub fn start(port: u16, ctx: egui::Context, settings: Option<&Path>) -> Option<(Receiver<ControlRequest>, Option<KeyFile>)> {
|
||||
start_with(port, ctx, settings, |listener, ctx, key| serve_on(listener, ctx, key, FIRST_REQUEST))
|
||||
}
|
||||
|
||||
fn start_with(
|
||||
port: u16,
|
||||
ctx: egui::Context,
|
||||
settings: Option<&Path>,
|
||||
serve: impl FnOnce(TcpListener, egui::Context, String) -> std::io::Result<Receiver<ControlRequest>>,
|
||||
) -> Option<(Receiver<ControlRequest>, Option<KeyFile>)> {
|
||||
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
@@ -67,11 +109,21 @@ pub fn start(port: u16, ctx: egui::Context, settings: Option<&Path>) -> Option<(
|
||||
None
|
||||
}
|
||||
};
|
||||
let rx = match serve(listener, ctx, key) {
|
||||
Ok(rx) => rx,
|
||||
Err(e) => {
|
||||
if let Some(f) = &key_file {
|
||||
f.remove();
|
||||
}
|
||||
log::error!("control server off: cannot start the accept thread ({e})");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
match &key_file {
|
||||
Some(f) => log::info!("control server listening on 127.0.0.1:{port}, key in {}", f.path().display()),
|
||||
None => log::info!("control server listening on 127.0.0.1:{port}"),
|
||||
}
|
||||
Some((serve_on(listener, ctx, key, FIRST_REQUEST), key_file))
|
||||
Some((rx, key_file))
|
||||
}
|
||||
|
||||
/// A new key: 32 random bytes from the operating system, as hex.
|
||||
@@ -103,16 +155,47 @@ fn keys_match(given: &str, key: &str) -> bool {
|
||||
!b.is_empty() && a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
|
||||
}
|
||||
|
||||
fn serve_on(listener: TcpListener, ctx: egui::Context, key: String, first: Duration) -> Receiver<ControlRequest> {
|
||||
fn serve_on(listener: TcpListener, ctx: egui::Context, key: String, first: Duration) -> std::io::Result<Receiver<ControlRequest>> {
|
||||
serve_with(listener, ctx, key, Limits { first, wait: REPLY_WAIT, max_unauthenticated: MAX_UNAUTHENTICATED })
|
||||
}
|
||||
|
||||
/// The time and count budgets of a server (small ones in tests).
|
||||
#[derive(Clone, Copy)]
|
||||
struct Limits {
|
||||
first: Duration,
|
||||
wait: Duration,
|
||||
max_unauthenticated: usize,
|
||||
}
|
||||
|
||||
fn serve_with(listener: TcpListener, ctx: egui::Context, key: String, limits: Limits) -> std::io::Result<Receiver<ControlRequest>> {
|
||||
serve_with_spawner(listener, ctx, key, limits, |accept| std::thread::Builder::new().name("control-accept".into()).spawn(accept).map(|_| ()))
|
||||
}
|
||||
|
||||
fn serve_with_spawner(
|
||||
listener: TcpListener,
|
||||
ctx: egui::Context,
|
||||
key: String,
|
||||
limits: Limits,
|
||||
spawn: impl FnOnce(Box<dyn FnOnce() + Send>) -> std::io::Result<()>,
|
||||
) -> std::io::Result<Receiver<ControlRequest>> {
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
let key: Arc<str> = key.into();
|
||||
std::thread::spawn(move || {
|
||||
let unauthenticated = Arc::new(AtomicUsize::new(0));
|
||||
let accept = move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let Some(admission) = Admission::enter(&unauthenticated, limits.max_unauthenticated) else {
|
||||
log::warn!("control server: too many unauthenticated connections, one closed");
|
||||
continue;
|
||||
};
|
||||
let (tx, ctx, key) = (tx.clone(), ctx.clone(), key.clone());
|
||||
std::thread::spawn(move || serve(stream, tx, ctx, &key, first));
|
||||
// A thread that cannot start closes this connection only (the guard releases its slot).
|
||||
if let Err(e) = std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, tx, ctx, &key, limits, admission)) {
|
||||
log::warn!("control server: no thread for a connection: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
rx
|
||||
};
|
||||
spawn(Box::new(accept))?;
|
||||
Ok(rx)
|
||||
}
|
||||
|
||||
/// The read side of a connection with an optional overall deadline: each read waits only for
|
||||
@@ -175,11 +258,11 @@ fn discard_rest_of_line(r: &mut BufReader<Timed>) {
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context, key: &str, first: Duration) {
|
||||
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context, key: &str, limits: Limits, mut admission: Admission) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
// One overall deadline from accept to the first authorised request; after it, none.
|
||||
let mut reader = BufReader::new(Timed { s: read, deadline: Some(Instant::now() + first) });
|
||||
let mut reader = BufReader::new(Timed { s: read, deadline: Some(Instant::now() + limits.first) });
|
||||
let mut authorised = false;
|
||||
// The key an `auth` request gave this connection.
|
||||
let mut conn_key: Option<String> = None;
|
||||
@@ -228,6 +311,7 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context, key:
|
||||
}
|
||||
if !authorised {
|
||||
authorised = true;
|
||||
admission.release();
|
||||
reader.get_mut().set_deadline(None);
|
||||
}
|
||||
if method == "auth" {
|
||||
@@ -237,12 +321,18 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context, key:
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
let (req, rrx, waiter) = ControlRequest::with_waiter(method, params);
|
||||
if tx.send(req).is_err() {
|
||||
break;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
let r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
|
||||
let r = rrx.recv_timeout(limits.wait).unwrap_or_else(|_| {
|
||||
if waiter.cancel() {
|
||||
json!({"ok": false, "error": "timeout before execution: the request was not run and never will be"})
|
||||
} else {
|
||||
json!({"ok": false, "error": "timeout: outcome unknown, the request had started when the wait ended; inspect the document to check"})
|
||||
}
|
||||
});
|
||||
if !reply(r) {
|
||||
break;
|
||||
}
|
||||
@@ -260,7 +350,7 @@ mod tests {
|
||||
fn boot_with(first: Duration) -> (u16, Receiver<ControlRequest>) {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
(port, serve_on(listener, egui::Context::default(), KEY.to_string(), first))
|
||||
(port, serve_on(listener, egui::Context::default(), KEY.to_string(), first).unwrap())
|
||||
}
|
||||
|
||||
fn boot() -> (u16, Receiver<ControlRequest>) {
|
||||
@@ -512,6 +602,40 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_accept_thread_spawn_failure_aborts_start_and_removes_the_key() {
|
||||
let dir = temp_dir("spawn-failure");
|
||||
let path = wordcraft_control_key::key_path(&dir, 0);
|
||||
let mut bound = None;
|
||||
let result = start_with(0, egui::Context::default(), Some(&dir), |listener, ctx, key| {
|
||||
bound = Some(listener.local_addr().unwrap());
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), key, "startup wrote the key before spawning");
|
||||
serve_with_spawner(listener, ctx, key, small(150, 8), |accept| {
|
||||
drop(accept);
|
||||
Err(std::io::Error::other("injected accept thread spawn failure"))
|
||||
})
|
||||
});
|
||||
assert!(result.is_none(), "a server without its accept thread must not report success");
|
||||
assert!(!path.exists(), "failed startup must remove its published key");
|
||||
let _listener = TcpListener::bind(bound.unwrap()).unwrap();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_complete_json_request_at_eof_can_reach_the_ui() {
|
||||
// This is why a bridge write failure before the newline is outcome-unknown.
|
||||
let (port, rx) = boot();
|
||||
let mut c = Conn::open(port);
|
||||
let body = json!({"id": 1, "key": KEY, "method": "engine.execute", "params": {"command": "text.insert", "params": {"text": "once"}}});
|
||||
c.1.write_all(body.to_string().as_bytes()).unwrap();
|
||||
c.1.shutdown(std::net::Shutdown::Write).unwrap();
|
||||
let req = answer(&rx);
|
||||
assert_eq!(req.params["params"]["text"], "once");
|
||||
assert_eq!(c.recv()["ok"], true);
|
||||
assert!(c.closed());
|
||||
assert!(rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stale_key_file_is_replaced() {
|
||||
let dir = temp_dir("stale");
|
||||
@@ -532,4 +656,52 @@ mod tests {
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
fn boot_limits(limits: Limits) -> (u16, Receiver<ControlRequest>) {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
(port, serve_with(listener, egui::Context::default(), KEY.to_string(), limits).unwrap())
|
||||
}
|
||||
|
||||
fn small(wait_ms: u64, max_unauthenticated: usize) -> Limits {
|
||||
Limits { first: FIRST_REQUEST, wait: Duration::from_millis(wait_ms), max_unauthenticated }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_that_timed_out_in_the_queue_never_runs() {
|
||||
let (port, rx) = boot_limits(small(150, 8));
|
||||
let mut c = Conn::open(port);
|
||||
let r = c.call(&format!(r#"{{"id":1,"key":"{KEY}","method":"engine.execute","params":{{"command":"text.insert"}}}}"#));
|
||||
assert_eq!(r["ok"], false);
|
||||
assert!(r["error"].as_str().unwrap_or("").starts_with("timeout before execution"), "{r}");
|
||||
// The UI gets to it late: it must not run it.
|
||||
let req = rx.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
assert!(!req.start(), "a cancelled request was started");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_that_started_before_the_timeout_reports_an_unknown_outcome() {
|
||||
let (port, rx) = boot_limits(small(150, 8));
|
||||
let mut c = Conn::open(port);
|
||||
c.send(&format!(r#"{{"id":1,"key":"{KEY}","method":"engine.execute","params":{{"command":"text.insert"}}}}"#));
|
||||
let req = rx.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
assert!(req.start());
|
||||
let r = c.recv();
|
||||
assert!(r["error"].as_str().unwrap_or("").contains("outcome unknown"), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unauthenticated_connections_are_limited() {
|
||||
let (port, rx) = boot_limits(small(5_000, 2));
|
||||
let mut idle = [Conn::open(port), Conn::open(port)];
|
||||
// Give the server time to count both.
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
assert!(Conn::open(port).closed(), "the third idle connection was admitted");
|
||||
// One authenticates: its slot is free again.
|
||||
assert_eq!(idle[0].call(&format!(r#"{{"id":1,"method":"auth","key":"{KEY}"}}"#))["ok"], true);
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
let mut again = Conn::open(port);
|
||||
assert_eq!(again.call(&format!(r#"{{"id":1,"method":"auth","key":"{KEY}"}}"#))["ok"], true);
|
||||
assert!(rx.try_recv().is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use wordcraft_ui_egui::{FileDialogAnswer, FileDialogRequest};
|
||||
|
||||
@@ -32,6 +34,59 @@ type Request = (FileDialogRequest, Sender<FileDialogAnswer>);
|
||||
/// The `Services::file_dialog` hook.
|
||||
pub type Hook = Box<dyn Fn(FileDialogRequest) -> Receiver<FileDialogAnswer>>;
|
||||
|
||||
/// Cancels the portal Save dialog the launcher is waiting on, if any.
|
||||
///
|
||||
/// The portal's dialog lives outside the app's window, so a portal that stops answering cannot be
|
||||
/// dismissed from WordCraft. Giving the dialog up (File › Open › Browse in WordCraft…) cancels the
|
||||
/// request: the waiting thread closes it and reports a cancellation, and the built-in browser
|
||||
/// opens instead. A no-op where Save As doesn't ask the portal (macOS and Windows).
|
||||
#[derive(Clone, Default)]
|
||||
pub struct CancelHandle {
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
armed: Arc<Mutex<Option<Armed>>>,
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
next_id: Arc<std::sync::atomic::AtomicU64>,
|
||||
}
|
||||
|
||||
/// The token a request installed in [`CancelHandle`], with the identity that owns it.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
struct Armed {
|
||||
id: u64,
|
||||
token: crate::portal_save::CancelToken,
|
||||
}
|
||||
|
||||
impl CancelHandle {
|
||||
/// Cancels the pending portal request, if there is one.
|
||||
pub fn cancel(&self) {
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
{
|
||||
let mut slot = self.armed.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(armed) = slot.take() {
|
||||
armed.token.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Arms the handle for the portal request about to wait, returning its token and identity.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn arm(&self) -> Armed {
|
||||
let id = self.next_id.fetch_add(1, std::sync::atomic::Ordering::Relaxed).wrapping_add(1);
|
||||
let token = crate::portal_save::CancelToken::new();
|
||||
*self.armed.lock().unwrap_or_else(|e| e.into_inner()) = Some(Armed { id, token: token.clone() });
|
||||
Armed { id, token }
|
||||
}
|
||||
|
||||
/// Disarms the handle once the request with `id` has answered, leaving a newer request's
|
||||
/// token alone: an abandoned request finishing late must not make the next one uncancellable.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn disarm(&self, id: u64) {
|
||||
let mut slot = self.armed.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if slot.as_ref().is_some_and(|armed| armed.id == id) {
|
||||
*slot = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A picker's answer, waited for off the UI thread.
|
||||
type Picking = Pin<Box<dyn Future<Output = Vec<rfd::FileHandle>> + Send>>;
|
||||
|
||||
@@ -39,6 +94,7 @@ type Picking = Pin<Box<dyn Future<Output = Vec<rfd::FileHandle>> + Send>>;
|
||||
pub struct Launcher {
|
||||
queue: Receiver<Request>,
|
||||
ctx: egui::Context,
|
||||
cancel: CancelHandle,
|
||||
}
|
||||
|
||||
/// The hook for [`Services::file_dialog`](wordcraft_ui_egui::Services::file_dialog) and the
|
||||
@@ -51,10 +107,15 @@ pub fn hook(ctx: &egui::Context) -> (Hook, Launcher) {
|
||||
let _ = queue_tx.send((req, answer));
|
||||
rx
|
||||
});
|
||||
(hook, Launcher { queue, ctx: ctx.clone() })
|
||||
(hook, Launcher { queue, ctx: ctx.clone(), cancel: CancelHandle::default() })
|
||||
}
|
||||
|
||||
impl Launcher {
|
||||
/// A handle that cancels the portal Save dialog the launcher is waiting on, if any.
|
||||
pub fn cancel_handle(&self) -> CancelHandle {
|
||||
self.cancel.clone()
|
||||
}
|
||||
|
||||
/// Show the queued dialogs with the window as their parent, so the portal (or the system)
|
||||
/// keeps them on top of it. Call after every pass that may ask for one.
|
||||
pub fn show(&self, parent: &eframe::Frame) {
|
||||
@@ -63,26 +124,90 @@ impl Launcher {
|
||||
let picking = picker(rfd::AsyncFileDialog::new().set_parent(parent), &req);
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
let unparented = picker(rfd::AsyncFileDialog::new(), &req);
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
let parent_id = {
|
||||
use eframe::wgpu::rwh::HasWindowHandle;
|
||||
crate::portal_save::parent_id(parent.window_handle().ok().map(|h| h.as_raw()))
|
||||
};
|
||||
// Armed before the thread starts, so giving the dialog up cannot race its start.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
let portal_arm = matches!(&req, FileDialogRequest::Save { .. }).then(|| self.cancel.arm());
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
let portal_id = portal_arm.as_ref().map(|armed| armed.id);
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
let cancel_handle = self.cancel.clone();
|
||||
let ctx = self.ctx.clone();
|
||||
let waiting = std::thread::Builder::new().name("file dialog".into()).spawn(move || {
|
||||
// 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 => {
|
||||
// Only the portal folds a failure into "cancelled" (#2999).
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
let picked = pick(picking, unparented, TOO_QUICK);
|
||||
{
|
||||
if let FileDialogRequest::Save { name, dir } = &req {
|
||||
let cancel = portal_arm.as_ref().map(|armed| armed.token.clone());
|
||||
let portal_req = crate::portal_save::SaveRequest {
|
||||
parent: parent_id,
|
||||
name: Some(name.clone()),
|
||||
suggested: Some(name.clone()),
|
||||
folder: dir.clone().map(std::path::PathBuf::from),
|
||||
filters: save_filters(name),
|
||||
current: Some(0),
|
||||
cancel,
|
||||
timeout: None,
|
||||
};
|
||||
let portal_res = zbus::blocking::Connection::session()
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|conn| crate::portal_save::save_as(&conn, portal_req));
|
||||
match portal_res {
|
||||
Ok(Some(path)) => Ok(vec![path.to_string_lossy().into_owned()]),
|
||||
Ok(None) => Ok(Vec::new()),
|
||||
Err(e) if crate::portal_save::is_cancelled_error(&e) => Ok(Vec::new()),
|
||||
Err(e) if crate::portal_save::is_ambiguous_filter_error(&e) => {
|
||||
log::warn!("portal save format ambiguous ({e}); cancelling save without explicit format choice");
|
||||
Ok(Vec::new())
|
||||
}
|
||||
Err(e) => {
|
||||
// Giving the dialog up cancels the request. A portal that then
|
||||
// fails with another error must not open the rfd dialog, which
|
||||
// nothing would be waiting for.
|
||||
if portal_arm.as_ref().is_some_and(|armed| armed.token.is_cancelled()) {
|
||||
log::info!("portal save dialog given up ({e})");
|
||||
Ok(Vec::new())
|
||||
} else {
|
||||
log::info!("portal save dialog unavailable ({e}), using rfd");
|
||||
let picked = pick(picking, unparented, TOO_QUICK);
|
||||
picked.map(|f| f.iter().map(|f| f.path().to_string_lossy().to_string()).collect())
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Only the portal folds a failure into "cancelled" (#2999).
|
||||
let picked = pick(picking, unparented, TOO_QUICK);
|
||||
picked.map(|f| f.iter().map(|f| f.path().to_string_lossy().to_string()).collect())
|
||||
}
|
||||
}
|
||||
#[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 picked: Result<_, String> = Ok(pollster::block_on(picking));
|
||||
picked.map(|f| f.iter().map(|f| f.path().to_string_lossy().to_string()).collect())
|
||||
}
|
||||
}
|
||||
};
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
if let Some(armed) = &portal_arm {
|
||||
cancel_handle.disarm(armed.id);
|
||||
}
|
||||
let _ = answer.send(picked);
|
||||
ctx.request_repaint();
|
||||
});
|
||||
// The answer's sender went with the closure, so the app sees the dialog as cancelled.
|
||||
if let Err(e) = waiting {
|
||||
log::error!("couldn't wait for the file dialog: {e}");
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
if let Some(id) = portal_id {
|
||||
self.cancel.disarm(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -209,6 +334,8 @@ fn save_exts(name: &str) -> Vec<String> {
|
||||
.extension()
|
||||
.and_then(std::ffi::OsStr::to_str)
|
||||
.map(str::to_ascii_lowercase)
|
||||
// A .markdown file is under Markdown's one entry in the list.
|
||||
.map(|e| if e == "markdown" { "md".to_string() } else { e })
|
||||
.filter(|e| wordcraft_engine::io::SAVE_EXTS.contains(&e.as_str()))
|
||||
.unwrap_or_else(|| "docx".to_string());
|
||||
let mut exts: Vec<String> = Vec::with_capacity(wordcraft_engine::io::SAVE_EXTS.len());
|
||||
@@ -220,8 +347,9 @@ fn save_exts(name: &str) -> Vec<String> {
|
||||
/// 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.
|
||||
/// On Linux and the BSDs, [`crate::portal_save`] asks the portal directly over D-Bus and gives a
|
||||
/// name the dialog left alone the chosen type's extension (#6180). When falling back to `rfd`, the
|
||||
/// extension in the name wins, as `rfd` doesn't report which entry the user picked.
|
||||
fn save_filters(name: &str) -> Vec<(String, Vec<String>)> {
|
||||
save_exts(name).into_iter().map(|e| (format!("{} (*.{e})", format_name(&e)), vec![e])).collect()
|
||||
}
|
||||
@@ -239,6 +367,8 @@ fn format_name(ext: &str) -> String {
|
||||
"html" => "Web Page".into(),
|
||||
"rtf" => "Rich Text Format".into(),
|
||||
"odt" => "OpenDocument Text".into(),
|
||||
"ott" => "OpenDocument Text Template".into(),
|
||||
"fodt" => "Flat OpenDocument Text".into(),
|
||||
"tex" => "LaTeX".into(),
|
||||
"png" => "PNG Image".into(),
|
||||
"json" => "JSON".into(),
|
||||
@@ -270,6 +400,37 @@ mod tests {
|
||||
assert_eq!(pick(async { vec![] }, slow_cancel(), quick), Ok(vec![]), "the second dialog cancelled for real");
|
||||
}
|
||||
|
||||
/// Giving the portal Save dialog up cancels the request the launcher armed, and a request
|
||||
/// that has already answered is not affected by a later cancel.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
#[test]
|
||||
fn cancelling_a_pending_portal_request_marks_its_token() {
|
||||
let handle = super::CancelHandle::default();
|
||||
let armed = handle.arm();
|
||||
assert!(!armed.token.is_cancelled());
|
||||
handle.cancel();
|
||||
assert!(armed.token.is_cancelled());
|
||||
|
||||
let answered = handle.arm();
|
||||
handle.disarm(answered.id);
|
||||
handle.cancel();
|
||||
assert!(!answered.token.is_cancelled(), "an answered request is no longer cancelled");
|
||||
}
|
||||
|
||||
/// A request that finishes late must not disarm the token of a request started after it:
|
||||
/// otherwise the newer Save dialog would become uncancellable.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
#[test]
|
||||
fn a_late_answer_keeps_the_newer_requests_token() {
|
||||
let handle = super::CancelHandle::default();
|
||||
let abandoned = handle.arm();
|
||||
let newer = handle.arm();
|
||||
handle.disarm(abandoned.id);
|
||||
handle.cancel();
|
||||
assert!(newer.token.is_cancelled(), "the newer request is still armed");
|
||||
assert!(!abandoned.token.is_cancelled(), "only the armed request is cancelled");
|
||||
}
|
||||
|
||||
/// #1399: the Save dialog's type list has one named entry per format, the suggested one first,
|
||||
/// every savable extension exactly once.
|
||||
#[test]
|
||||
@@ -299,6 +460,8 @@ mod tests {
|
||||
assert_eq!(save_exts("novel.docx")[0], "docx");
|
||||
assert_eq!(save_exts("report.odt")[0], "odt");
|
||||
assert_eq!(save_exts("annual report.pdf")[0], "pdf");
|
||||
assert_eq!(save_exts("notes.md")[0], "md");
|
||||
assert_eq!(save_exts("notes.markdown")[0], "md");
|
||||
// A suggested name always has an extension; anything unreadable falls back to Word's.
|
||||
assert_eq!(save_exts("no extension")[0], "docx");
|
||||
assert_eq!(save_exts("mystery.rtf2")[0], "docx");
|
||||
|
||||
@@ -15,9 +15,25 @@ use eframe::{
|
||||
/// 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.
|
||||
///
|
||||
/// `x86_64` is the build's architecture. An x86_64 build also runs on Apple silicon under Rosetta
|
||||
/// (#5199), so it skips only when the machine itself isn't Apple silicon.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
pub fn skips_indirect_validation(macos: bool, intel: bool) -> bool {
|
||||
macos && intel
|
||||
pub fn skips_indirect_validation(macos: bool, x86_64: bool) -> bool {
|
||||
// Short-circuits: only x86_64 macOS builds ask the system.
|
||||
macos && x86_64 && !runs_on_apple_silicon()
|
||||
}
|
||||
|
||||
/// Whether the machine is Apple silicon, also when this process is translated by Rosetta:
|
||||
/// `hw.optional.arm64` describes the hardware, not the process. Assumes not (an Intel Mac) when
|
||||
/// the system can't tell.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
fn runs_on_apple_silicon() -> bool {
|
||||
cfg!(target_os = "macos")
|
||||
&& std::process::Command::new("/usr/sbin/sysctl")
|
||||
.args(["-n", "hw.optional.arm64"])
|
||||
.output()
|
||||
.is_ok_and(|out| out.status.success() && out.stdout.trim_ascii() == b"1")
|
||||
}
|
||||
|
||||
/// Leaves wgpu's indirect-call validation out of the instance eframe creates when `skip`.
|
||||
@@ -181,7 +197,8 @@ mod tests {
|
||||
#[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));
|
||||
// An x86_64 build on Apple silicon (Rosetta) isn't an Intel Mac (#5199).
|
||||
assert_eq!(skips_indirect_validation(true, true), !host_is_apple_silicon());
|
||||
// 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}");
|
||||
@@ -205,6 +222,36 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the machine running the tests is Apple silicon, whatever this build's architecture.
|
||||
fn host_is_apple_silicon() -> bool {
|
||||
cfg!(target_os = "macos")
|
||||
&& std::process::Command::new("/usr/sbin/sysctl")
|
||||
.args(["-n", "hw.optional.arm64"])
|
||||
.output()
|
||||
.is_ok_and(|out| out.status.success() && out.stdout.trim_ascii() == b"1")
|
||||
}
|
||||
|
||||
/// #5199: an x86_64 build running under Rosetta on Apple silicon isn't an Intel Mac and keeps
|
||||
/// wgpu's indirect-call validation.
|
||||
#[test]
|
||||
fn x86_64_builds_on_apple_silicon_keep_indirect_validation() {
|
||||
if !host_is_apple_silicon() {
|
||||
return;
|
||||
}
|
||||
// What main.rs passes from an x86_64 build.
|
||||
assert!(!skips_indirect_validation(true, true), "Rosetta build on Apple silicon must keep validation");
|
||||
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, skips_indirect_validation(true, true));
|
||||
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, "indirect-call validation kept");
|
||||
}
|
||||
|
||||
fn configured_backends(backends: Backends) -> Backends {
|
||||
let mut options = NativeOptions::default();
|
||||
configure_backends(&mut options, backends);
|
||||
|
||||
+232
-38
@@ -10,6 +10,8 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod animations;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod appimage_env;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod apple_events;
|
||||
mod clipboard;
|
||||
@@ -19,6 +21,8 @@ mod file_dialogs;
|
||||
#[cfg(any(target_os = "windows", target_os = "macos", test))]
|
||||
mod graphics;
|
||||
mod logging;
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
mod portal_save;
|
||||
mod print;
|
||||
mod region;
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -27,7 +31,7 @@ mod traffic_lights;
|
||||
use wordcraft_engine::Session;
|
||||
use wordcraft_ui_egui::{
|
||||
Services, UiState, WordApp,
|
||||
window_geometry::{RawWindowState, WindowGeometry, take_rescue},
|
||||
window_geometry::{RawWindowState, WindowGeometry, reachable, take_rescue},
|
||||
};
|
||||
|
||||
/// The app, the restored window geometry (or, with none, the default: no position) until the
|
||||
@@ -54,13 +58,21 @@ impl eframe::App for App {
|
||||
apple_events::poll(&self.5, &mut self.0, ctx);
|
||||
self.0.logic(ctx);
|
||||
self.3.show(frame);
|
||||
let window = self.4;
|
||||
let mut window = self.4;
|
||||
// Only until the off-screen check below has run: it is the one reader.
|
||||
if self.1.is_some() {
|
||||
let (on_a_monitor, primary_monitor_rect) = monitor_geometry(frame, ctx.pixels_per_point());
|
||||
window.on_a_monitor = on_a_monitor;
|
||||
window.primary_monitor_rect = primary_monitor_rect;
|
||||
}
|
||||
// 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)) = self.1.and_then(|g| g.fit_on_monitor(&window)) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(size));
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos));
|
||||
let b = self.0.window_builder().clone().with_inner_size(size);
|
||||
self.0.set_window_builder(b);
|
||||
}
|
||||
// The geometry saved for the next start is the active window's.
|
||||
let prev = self.0.ui.window;
|
||||
@@ -123,9 +135,41 @@ impl eframe::App for App {
|
||||
if let Some(key_file) = &self.2 {
|
||||
key_file.remove();
|
||||
}
|
||||
self.0.start_pending_update();
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a grippable part of the window is on a monitor, and the primary monitor bounds.
|
||||
/// Winit reports physical pixels; egui positions use points. `None` where the platform cannot
|
||||
/// report the window or primary monitor (Wayland, headless).
|
||||
fn monitor_geometry(frame: &eframe::Frame, pixels_per_point: f32) -> (Option<bool>, Option<egui::Rect>) {
|
||||
let Some(w) = frame.winit_window() else {
|
||||
return (None, None);
|
||||
};
|
||||
if !pixels_per_point.is_finite() || pixels_per_point <= 0.0 {
|
||||
return (None, None);
|
||||
}
|
||||
let Some(pos) = w.outer_position().ok() else {
|
||||
return (None, None);
|
||||
};
|
||||
let size = w.outer_size();
|
||||
let physical_rect = |(x, y): (i32, i32), (width, height): (u32, u32)| {
|
||||
egui::Rect::from_min_size(egui::pos2(x as f32, y as f32), egui::vec2(width as f32, height as f32))
|
||||
};
|
||||
let monitors: Vec<egui::Rect> =
|
||||
w.available_monitors().map(|m| physical_rect((m.position().x, m.position().y), (m.size().width, m.size().height))).collect();
|
||||
let on_a_monitor = (!monitors.is_empty()).then(|| reachable(physical_rect((pos.x, pos.y), (size.width, size.height)), &monitors));
|
||||
let primary_monitor_rect = w.primary_monitor().map(|m| {
|
||||
let pos = m.position();
|
||||
let size = m.size();
|
||||
egui::Rect::from_min_size(
|
||||
egui::pos2(pos.x as f32, pos.y as f32) / pixels_per_point,
|
||||
egui::vec2(size.width as f32, size.height as f32) / pixels_per_point,
|
||||
)
|
||||
});
|
||||
(on_a_monitor, primary_monitor_rect)
|
||||
}
|
||||
|
||||
/// Quit the way Command-Q does, on the next turn of the main queue.
|
||||
///
|
||||
/// Called when the window is closing. Doing it on the next turn lets the close
|
||||
@@ -151,6 +195,8 @@ fn schedule_macos_terminate(app: &mut WordApp, key_file: Option<&control_server:
|
||||
if let Some(key_file) = key_file {
|
||||
key_file.remove();
|
||||
}
|
||||
// `terminate:` ends the process without dropping the app, so a ready update's helper starts here.
|
||||
app.start_pending_update();
|
||||
dispatch2::DispatchQueue::main().exec_async(|| {
|
||||
if let Some(mtm) = objc2::MainThreadMarker::new() {
|
||||
objc2_app_kit::NSApplication::sharedApplication(mtm).terminate(None);
|
||||
@@ -216,35 +262,95 @@ fn load_user_fonts() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The saved window geometry, read before the window opens (`load_prefs` runs after).
|
||||
fn saved_window() -> Option<WindowGeometry> {
|
||||
/// The saved preferences, read before the window opens for its geometry and, on Linux and the
|
||||
/// BSDs, the display server (`load_prefs` runs after).
|
||||
fn saved_ui() -> Option<UiState> {
|
||||
if !prefs_enabled() {
|
||||
return None;
|
||||
}
|
||||
let bytes = std::fs::read(prefs_path()?).ok()?;
|
||||
UiState::from_json(&bytes)?.window?.sanitized()
|
||||
UiState::from_json(&bytes)
|
||||
}
|
||||
|
||||
/// Whether the window runs through XWayland (X11) rather than native Wayland, and the log line
|
||||
/// saying why. `WORDCRAFT_WAYLAND` wins (`0`, `false`, `no` or `off` asks for X11, any other value
|
||||
/// for Wayland), then File › Options › Advanced › Linux display server (#2184). Automatic is X11
|
||||
/// whenever there's an X server: winit has no file drag-and-drop on Wayland, so dropping files from
|
||||
/// the file manager showed a "no" cursor. X11 is only forced with an X server (`DISPLAY`), and
|
||||
/// unforced winit picks Wayland when it's there and X11 otherwise, so a choice that isn't
|
||||
/// available falls back to the other instead of failing to start. The X11 libraries are only
|
||||
/// recommended by the packages, so when `x11_libraries` reports some missing (it's asked only when
|
||||
/// X11 would be forced) the app stays on Wayland: forced X11 without them failed to start, and
|
||||
/// winit panicked without libxkbcommon-x11.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn uses_x11(
|
||||
setting: wordcraft_ui_egui::DisplayServer,
|
||||
env: impl Fn(&str) -> Option<std::ffi::OsString>,
|
||||
x11_libraries: impl FnOnce() -> Result<(), String>,
|
||||
) -> (bool, String) {
|
||||
use wordcraft_ui_egui::DisplayServer;
|
||||
let set = |name: &str| env(name).is_some_and(|v| !v.is_empty());
|
||||
let (wanted, by) = match env("WORDCRAFT_WAYLAND") {
|
||||
Some(v) if matches!(v.to_string_lossy().trim().to_ascii_lowercase().as_str(), "0" | "false" | "no" | "off") => {
|
||||
(DisplayServer::X11, "WORDCRAFT_WAYLAND")
|
||||
}
|
||||
Some(_) => (DisplayServer::Wayland, "WORDCRAFT_WAYLAND"),
|
||||
None => (setting, "the setting"),
|
||||
};
|
||||
let missing = if wanted != DisplayServer::Wayland && set("DISPLAY") { x11_libraries().err() } else { None };
|
||||
let x11 = wanted != DisplayServer::Wayland && set("DISPLAY") && missing.is_none();
|
||||
let wayland = set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET");
|
||||
let runs = if x11 {
|
||||
"X11"
|
||||
} else if wayland {
|
||||
"native Wayland"
|
||||
} else if set("DISPLAY") {
|
||||
"X11 (no Wayland)"
|
||||
} else {
|
||||
"neither (no Wayland or X server found)"
|
||||
};
|
||||
let mut why = format!("display: {runs}; {} asks for {}", by, wanted.code());
|
||||
if let Some(missing) = missing {
|
||||
why += &format!("; not X11 because its libraries are missing: {missing}");
|
||||
}
|
||||
(x11, why)
|
||||
}
|
||||
|
||||
/// `Err` naming the libraries winit's X11 backend opens at startup that can't be loaded: libX11,
|
||||
/// libXcursor, libX11-xcb (which brings in libxcb), libXi, and libxkbcommon-x11 for the keyboard.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn x11_libraries() -> Result<(), String> {
|
||||
use x11_dl::{xcursor::Xcursor, xinput2::XInput2, xlib::Xlib, xlib_xcb::Xlib_xcb};
|
||||
let mut missing: Vec<String> = [Xlib::open().err(), Xcursor::open().err(), Xlib_xcb::open().err(), XInput2::open().err()]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|e| e.to_string())
|
||||
.collect();
|
||||
if xkbcommon_dl::x11::xkbcommon_x11_option().is_none() {
|
||||
missing.push("libxkbcommon-x11.so.0 could not be loaded".into());
|
||||
}
|
||||
if missing.is_empty() { Ok(()) } else { Err(missing.join("; ")) }
|
||||
}
|
||||
|
||||
fn save_prefs(app: &WordApp) {
|
||||
if let Err(e) = try_save_prefs(app) {
|
||||
log::warn!("preferences: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves the preferences and says why when it could not. `Ok(false)`: nothing was written
|
||||
/// because preferences are switched off or there is no settings folder.
|
||||
fn try_save_prefs(app: &WordApp) -> Result<bool, String> {
|
||||
if !prefs_enabled() {
|
||||
return;
|
||||
return Ok(false);
|
||||
}
|
||||
if let Some(p) = prefs_path() {
|
||||
if let Some(dir) = p.parent()
|
||||
&& let Err(e) = std::fs::create_dir_all(dir)
|
||||
{
|
||||
log::warn!("preferences: cannot create {}: {e}", dir.display());
|
||||
return;
|
||||
}
|
||||
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}"),
|
||||
}
|
||||
let Some(p) = prefs_path() else { return Ok(false) };
|
||||
if let Some(dir) = p.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?;
|
||||
}
|
||||
let bytes = serde_json::to_vec_pretty(&app.prefs()).map_err(|e| format!("cannot serialize: {e}"))?;
|
||||
write_prefs(&p, &bytes).map_err(|e| format!("cannot save {}: {e}", p.display()))?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Writes `bytes` to a sibling temporary file and renames it over `path`, so a failed or
|
||||
@@ -263,11 +369,13 @@ fn write_prefs(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
/// 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
|
||||
/// (`animations`), and making WordCraft the default app for Word documents (`default_apps`, #295).
|
||||
fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services {
|
||||
fn services(file_dialog: file_dialogs::Hook, cancel_file_dialog: file_dialogs::CancelHandle, ctx: &egui::Context) -> Services {
|
||||
Services {
|
||||
file_dialog: Some(file_dialog),
|
||||
cancel_file_dialog: Some(Box::new(move || cancel_file_dialog.cancel())),
|
||||
printers: Some(print::discover()),
|
||||
print_native: Some(print::submit()),
|
||||
cancel_print: Some(print::cancel()),
|
||||
clipboard_picture: Some(Box::new(clipboard::picture)),
|
||||
make_default_app: default_apps::hook(),
|
||||
// Personal templates live in `Templates` in the settings folder (#2852), made on first save.
|
||||
@@ -275,9 +383,12 @@ fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services {
|
||||
animations_enabled: Some(animations::hook(ctx)),
|
||||
clipboard_text: Some(Box::new(clipboard::text)),
|
||||
clipboard_html: Some(Box::new(clipboard::html)),
|
||||
set_clipboard_html: Some(Box::new(clipboard::set_html)),
|
||||
set_clipboard_rich: Some(Box::new(clipboard::set_rich)),
|
||||
set_clipboard_image: Some(Box::new(clipboard::set_image)),
|
||||
clipboard_is_own: Some(Box::new(clipboard::is_own)),
|
||||
clipboard_rtf: Some(Box::new(clipboard::rtf)),
|
||||
clipboard_formats: Some(Box::new(clipboard::formats)),
|
||||
save_prefs: Some(Box::new(try_save_prefs)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -351,14 +462,15 @@ fn without_launch_args(args: Vec<String>) -> Vec<String> {
|
||||
|
||||
/// Open a file whose name isn't valid Unicode (#1440). `file.open` takes its path as text, so it
|
||||
/// gets the file's bytes under the lossy name, and the document then keeps the real path to save to.
|
||||
/// The bytes are read as `file.open` reads a path: only a regular file, and at most 2 GB.
|
||||
/// The bytes are read as `file.open` reads a path: only a regular file, and at most 2 GB. A Word
|
||||
/// template starts a new document instead ([`WordApp::open_from_platform`]), which has no path.
|
||||
fn open_non_unicode(app: &mut WordApp, path: &std::path::Path) {
|
||||
let name = path.to_string_lossy();
|
||||
let opened = wordcraft_engine::io::read_document(path)
|
||||
.and_then(|bytes| app.run("file.open", serde_json::json!({"path": name, "data": wordcraft_engine::cmd::insert::base64_encode(&bytes)})));
|
||||
.and_then(|bytes| app.open_from_platform(&name, Some(&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(r) if r.get("pending").is_none() && !wordcraft_ui_egui::documents::is_word_template(&name) => app.session.path = Some(path.into()),
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
app.report_open_failure(&name, &e);
|
||||
@@ -370,14 +482,31 @@ fn open_non_unicode(app: &mut WordApp, path: &std::path::Path) {
|
||||
/// geometry, opens requested documents or the Start screen, starts the control server when
|
||||
/// asked, and runs the eframe event loop.
|
||||
fn main() -> eframe::Result {
|
||||
#[cfg(windows)]
|
||||
if let Some(code) = print::isolated::helper_mode() {
|
||||
std::process::exit(code);
|
||||
}
|
||||
// Windows: the update helper is a copy of this program started with `--finish-update`.
|
||||
#[cfg(windows)]
|
||||
if let Some(result) = craft_update::self_update::finish(&wordcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
|
||||
std::process::exit(i32::from(result.is_err()));
|
||||
}
|
||||
// Linux: before anything reads another program's AppImage variables, or a thread starts (#5653).
|
||||
#[cfg(target_os = "linux")]
|
||||
let start_over_failed = appimage_env::drop_inherited();
|
||||
// Still carrying them, this process must never update itself: the update would replace the
|
||||
// other program's file.
|
||||
#[cfg(target_os = "linux")]
|
||||
let updates_unsafe = start_over_failed.is_some();
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let updates_unsafe = false;
|
||||
// First, so the panic hook and every start-up warning are recorded (`logging`).
|
||||
let logger = logging::install();
|
||||
logging::install_panic_hook();
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some(e) = start_over_failed {
|
||||
log::error!("couldn't start again without another program's AppImage variables, so the self-update is off in this window: {e}");
|
||||
}
|
||||
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;
|
||||
@@ -449,25 +578,28 @@ 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).
|
||||
// Intel Macs: Metal's compiler aborts on wgpu's indirect-validation shaders (#2652). An x86_64
|
||||
// build under Rosetta on Apple silicon keeps them (#5199).
|
||||
#[cfg(target_os = "macos")]
|
||||
graphics::configure_indirect_validation(&mut options, graphics::skips_indirect_validation(true, cfg!(target_arch = "x86_64")));
|
||||
load_user_fonts();
|
||||
// Windows opened for further documents start like the first one, before its saved place.
|
||||
let window_builder = options.viewport.clone();
|
||||
let restored = saved_window();
|
||||
let saved = saved_ui();
|
||||
let restored = saved.as_ref().and_then(|ui| ui.window?.sanitized());
|
||||
if let Some(window) = restored {
|
||||
options.viewport = window.apply(options.viewport);
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
// winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file
|
||||
// manager showed a "no" cursor. Run through XWayland when it's there; WORDCRAFT_WAYLAND=1
|
||||
// keeps the native Wayland backend.
|
||||
if std::env::var_os("DISPLAY").is_some() && std::env::var_os("WORDCRAFT_WAYLAND").is_none() {
|
||||
options.event_loop_builder = Some(Box::new(|b| {
|
||||
use winit::platform::x11::EventLoopBuilderExtX11;
|
||||
b.with_x11();
|
||||
}));
|
||||
{
|
||||
let (x11, why) = uses_x11(saved.as_ref().map(|ui| ui.display_server).unwrap_or_default(), |name| std::env::var_os(name), x11_libraries);
|
||||
log::info!("{why}");
|
||||
if x11 {
|
||||
options.event_loop_builder = Some(Box::new(|b| {
|
||||
use winit::platform::x11::EventLoopBuilderExtX11;
|
||||
b.with_x11();
|
||||
}));
|
||||
}
|
||||
}
|
||||
// Registered before the event loop starts, so it catches the Finder event that launched us as
|
||||
// well as later ones. Lives until the event loop returns; the app creator only borrows it.
|
||||
@@ -484,11 +616,16 @@ fn main() -> eframe::Result {
|
||||
let region = region::system();
|
||||
let doc = if sample { wordcraft_engine::sample::sample_document() } else { wordcraft_engine::blank_document(®ion) };
|
||||
let (file_dialog, dialogs) = file_dialogs::hook(&cc.egui_ctx);
|
||||
let cancel_file_dialog = dialogs.cancel_handle();
|
||||
let mut session = Session::new(doc);
|
||||
session.system_region = region;
|
||||
let mut app = WordApp::new(session, services(file_dialog, &cc.egui_ctx));
|
||||
let mut app = WordApp::new(session, services(file_dialog, cancel_file_dialog, &cc.egui_ctx));
|
||||
app.set_startup_update_check(true);
|
||||
load_prefs(&mut app);
|
||||
// After the preferences, so the reason it gives is in the interface language.
|
||||
if updates_unsafe {
|
||||
app.disable_self_update();
|
||||
}
|
||||
load_user_dictionary(&mut app);
|
||||
// Language packs from the suite's font store; an Install click is the person's own.
|
||||
wordcraft_engine::packs::use_font_store(true);
|
||||
@@ -528,8 +665,9 @@ fn main() -> eframe::Result {
|
||||
if files.is_empty() && other_files.is_empty() && !sample && control_port.is_none() {
|
||||
app.start_screen();
|
||||
}
|
||||
// A Word template named here starts a new document based on it (#3433).
|
||||
for f in files {
|
||||
if let Err(e) = app.run("file.open", serde_json::json!({"path": f})) {
|
||||
if let Err(e) = app.open_from_platform(&f, None) {
|
||||
app.report_open_failure(&f, &e);
|
||||
}
|
||||
}
|
||||
@@ -581,6 +719,62 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// #2184: `WORDCRAFT_WAYLAND` beats the setting, Automatic keeps XWayland where there's an X
|
||||
/// server, and a choice that isn't available falls back to the other.
|
||||
#[test]
|
||||
fn the_display_server_follows_the_variable_then_the_setting() {
|
||||
use wordcraft_ui_egui::DisplayServer::{Auto, Wayland, X11};
|
||||
let x11 = |setting, vars: &[(&str, &str)]| {
|
||||
uses_x11(setting, |name| vars.iter().find(|(k, _)| *k == name).map(|(_, v)| std::ffi::OsString::from(v)), || Ok(())).0
|
||||
};
|
||||
let both = [("DISPLAY", ":0"), ("WAYLAND_DISPLAY", "wayland-0")];
|
||||
assert!(x11(Auto, &both), "Automatic keeps XWayland, for file drag-and-drop");
|
||||
assert!(x11(X11, &both));
|
||||
assert!(!x11(Wayland, &both));
|
||||
for (value, wants_x11) in [("1", false), ("", false), ("yes", false), ("0", true), (" Off ", true), ("FALSE", true), ("no", true)] {
|
||||
let vars = [both[0], both[1], ("WORDCRAFT_WAYLAND", value)];
|
||||
for setting in [Auto, Wayland, X11] {
|
||||
assert_eq!(x11(setting, &vars), wants_x11, "WORDCRAFT_WAYLAND={value:?} beats {setting:?}");
|
||||
}
|
||||
}
|
||||
// Without an X server X11 isn't forced: winit connects to Wayland instead of failing.
|
||||
let wayland_only = [("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", "")];
|
||||
assert!(!x11(X11, &wayland_only) && !x11(Auto, &wayland_only));
|
||||
// Without Wayland, Wayland isn't forced either: unforced winit picks X11.
|
||||
let (forced, why) = uses_x11(Wayland, |name| (name == "DISPLAY").then(|| ":0".into()), || Ok(()));
|
||||
assert!(!forced && why.contains("X11 (no Wayland)"), "{why}");
|
||||
}
|
||||
|
||||
/// Without the X11 libraries (the packages only recommend them) X11 isn't forced, whatever asks
|
||||
/// for it: forced X11 failed to start, and winit panicked without libxkbcommon-x11. The check
|
||||
/// runs only when X11 would be forced.
|
||||
#[test]
|
||||
fn missing_x11_libraries_leave_the_app_on_wayland() {
|
||||
use wordcraft_ui_egui::DisplayServer::{Auto, Wayland, X11};
|
||||
let both = [("DISPLAY", ":0"), ("WAYLAND_DISPLAY", "wayland-0")];
|
||||
for (setting, variable) in [(Auto, None), (X11, None), (Wayland, Some("0"))] {
|
||||
let env = |name: &str| {
|
||||
both.iter()
|
||||
.find(|(k, _)| *k == name)
|
||||
.map(|(_, v)| (*v).into())
|
||||
.or_else(|| (name == "WORDCRAFT_WAYLAND").then_some(variable).flatten().map(Into::into))
|
||||
};
|
||||
let (x11, why) = uses_x11(setting, env, || Err("libxkbcommon-x11.so.0 could not be loaded".into()));
|
||||
assert!(!x11, "{setting:?} with WORDCRAFT_WAYLAND={variable:?}: {why}");
|
||||
assert!(why.contains("native Wayland") && why.contains("libxkbcommon-x11"), "{why}");
|
||||
}
|
||||
let checked = std::cell::Cell::new(false);
|
||||
let (x11, _) = uses_x11(
|
||||
Wayland,
|
||||
|name| both.iter().find(|(k, _)| *k == name).map(|(_, v)| (*v).into()),
|
||||
|| {
|
||||
checked.set(true);
|
||||
Ok(())
|
||||
},
|
||||
);
|
||||
assert!(!x11 && !checked.get(), "asked for Wayland, the libraries aren't checked");
|
||||
}
|
||||
|
||||
/// A named pipe or a device under such a name is a warning, not a read: the pipe blocked
|
||||
/// start-up until something wrote to it, and `/dev/zero` read until memory ran out.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,906 @@
|
||||
//! File › Save As through the XDG desktop portal on Linux and BSDs (#6180).
|
||||
//!
|
||||
//! The GTK file chooser behind the portal keeps the typed name when the user picks another file
|
||||
//! type, and `rfd` 0.17 reads only the file name from the portal's answer, so `Document1.docx`
|
||||
//! with PDF picked in the dropdown came back as `Document1.docx` and was saved as a Word document.
|
||||
//! The portal's answer does say which type was picked (`current_filter`), so Save As asks the portal
|
||||
//! itself over D-Bus via `zbus` and gives a name the dialog left alone that type's extension
|
||||
//! ([`with_chosen_type`]). `rfd` stays the fallback (zenity, or no portal), and this can go once an
|
||||
//! `rfd` release reads the picked type (upstream PolyMeilex/rfd#341).
|
||||
//!
|
||||
//! The dialog is attached to the window on X11 (`x11:<xid>`). On Wayland it has no parent: naming the
|
||||
//! window to the portal needs an xdg-foreign handle, which takes `unsafe` code the workspace forbids.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use zbus::blocking::{MessageIterator, Proxy};
|
||||
use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value};
|
||||
|
||||
/// One file type in the dialog: its label and extensions (`("PDF (*.pdf)", ["pdf"])`).
|
||||
pub type Filter = (String, Vec<String>);
|
||||
|
||||
/// Quicker than anyone can see a dialog and dismiss it (#2999).
|
||||
const TOO_QUICK: Duration = Duration::from_millis(300);
|
||||
|
||||
/// A thread-safe cancellation token to signal cancellation of an ongoing save request.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct CancelToken(std::sync::Arc<std::sync::atomic::AtomicBool>);
|
||||
|
||||
impl CancelToken {
|
||||
/// Creates a new cancellation token in the active (not cancelled) state.
|
||||
pub fn new() -> Self {
|
||||
Self(std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)))
|
||||
}
|
||||
|
||||
/// Signals that the active save request should be cancelled.
|
||||
pub fn cancel(&self) {
|
||||
self.0.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Returns `true` if cancellation has been requested.
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
self.0.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for CancelToken {
|
||||
/// Compares two cancellation tokens by their current cancellation status.
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.is_cancelled() == other.is_cancelled()
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for CancelToken {}
|
||||
|
||||
/// A Save dialog request to pass to the desktop portal.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct SaveRequest {
|
||||
/// The portal's parent window identifier (`x11:<xid>`), or empty.
|
||||
pub parent: String,
|
||||
/// The name the dialog shows: the suggested file name (`Document1.docx`).
|
||||
pub name: Option<String>,
|
||||
/// The suggested file's name (`Document1.docx`): a name that comes back with its extension still
|
||||
/// takes the extension of the type picked ([`with_chosen_type`]).
|
||||
pub suggested: Option<String>,
|
||||
/// The initial folder to open in, if any.
|
||||
pub folder: Option<PathBuf>,
|
||||
/// The list of filters offered in the dropdown.
|
||||
pub filters: Vec<Filter>,
|
||||
/// The index of the filter the dialog starts on.
|
||||
pub current: Option<usize>,
|
||||
/// Optional cancellation token allowing the caller to cancel a pending save request.
|
||||
pub cancel: Option<CancelToken>,
|
||||
/// Optional timeout after which a request waiting for a response is considered stalled.
|
||||
pub timeout: Option<Duration>,
|
||||
}
|
||||
|
||||
/// What the portal dialog returned: the path, as typed, and the filter chosen in the dropdown.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Saved {
|
||||
/// The filesystem path chosen in the dialog.
|
||||
pub path: PathBuf,
|
||||
/// The filter chosen in the dropdown, if reported by the portal: `(label, extensions)`.
|
||||
pub filter: Option<Filter>,
|
||||
}
|
||||
|
||||
/// The portal's parent window identifier for `window`: `x11:<xid>` on X11, empty elsewhere.
|
||||
///
|
||||
/// On Wayland, exporting an xdg-foreign handle requires `unsafe` code forbidden by workspace rules
|
||||
/// (`#![forbid(unsafe_code)]`, Rust Rule 1), so Wayland dialogs remain unparented, matching
|
||||
/// PhotoCraft and VectorCraft.
|
||||
pub fn parent_id(window: Option<eframe::wgpu::rwh::RawWindowHandle>) -> String {
|
||||
use eframe::wgpu::rwh::RawWindowHandle;
|
||||
match window {
|
||||
Some(RawWindowHandle::Xlib(h)) => format!("x11:{:x}", h.window),
|
||||
Some(RawWindowHandle::Xcb(h)) => format!("x11:{:x}", h.window.get()),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a filter tuple into the portal's wire format: label and a list of `(0, "*.ext")` glob pairs.
|
||||
fn wire(filter: &Filter) -> (String, Vec<(u32, String)>) {
|
||||
let globs = filter.1.iter().map(|e| (0u32, if e.is_empty() || e == "*" { "*".to_string() } else { format!("*.{e}") })).collect();
|
||||
(filter.0.clone(), globs)
|
||||
}
|
||||
|
||||
/// The `SaveFile` options dictionary for `req`, answering on `token`.
|
||||
fn options<'a>(req: &'a SaveRequest, token: &'a str) -> HashMap<&'static str, Value<'a>> {
|
||||
let mut o: HashMap<&'static str, Value<'a>> = HashMap::from([("handle_token", Value::from(token)), ("modal", Value::from(true))]);
|
||||
if let Some(name) = &req.name {
|
||||
o.insert("current_name", Value::from(name.as_str()));
|
||||
}
|
||||
if let Some(folder) = &req.folder {
|
||||
// A byte string with its terminating NUL, as the portal specifies.
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
let mut bytes = folder.as_os_str().as_bytes().to_vec();
|
||||
bytes.push(0);
|
||||
o.insert("current_folder", Value::from(bytes));
|
||||
}
|
||||
if !req.filters.is_empty() {
|
||||
let wired = req.filters.iter().map(wire).collect::<Vec<_>>();
|
||||
o.insert("filters", Value::from(wired));
|
||||
}
|
||||
if let Some(f) = req.current.and_then(|i| req.filters.get(i)).or_else(|| req.filters.first()) {
|
||||
o.insert("current_filter", Value::from(wire(f)));
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
/// Reads the portal's `Response`: code 0 with the first `uris` entry. 1 is Cancel, and 2 a dialog
|
||||
/// closed some other way (e.g. Esc in GNOME): no file either. When code 2 arrives quicker than
|
||||
/// [`TOO_QUICK`], the portal failed to show its dialog (#2999) and an error is returned so the
|
||||
/// caller can fall back.
|
||||
fn answer(code: u32, results: &HashMap<String, OwnedValue>, elapsed: Duration) -> Result<Option<Saved>, String> {
|
||||
match code {
|
||||
0 => {}
|
||||
1 => return Ok(None),
|
||||
2 if elapsed < TOO_QUICK => return Err("the save dialog closed at once".to_string()),
|
||||
2 => return Ok(None),
|
||||
_ => return Err(format!("the save dialog failed (portal response {code})")),
|
||||
}
|
||||
let uris: Vec<String> = results
|
||||
.get("uris")
|
||||
.ok_or_else(|| "no file in the portal's answer".to_string())?
|
||||
.try_clone()
|
||||
.map_err(|e| e.to_string())?
|
||||
.try_into()
|
||||
.map_err(|e: zbus::zvariant::Error| e.to_string())?;
|
||||
let uri = uris.first().ok_or_else(|| "no file in the portal's answer".to_string())?;
|
||||
let path = file_uri_path(uri).ok_or_else(|| format!("not a local file: {uri}"))?;
|
||||
let filter = results.get("current_filter").and_then(|v| {
|
||||
let val = v.try_clone().ok()?;
|
||||
if let Ok((label, globs)) = <(String, Vec<(u32, String)>)>::try_from(val.clone()) {
|
||||
let mut exts = Vec::new();
|
||||
for (_, g) in globs {
|
||||
let s = g.trim_start_matches('*').trim_start_matches('.');
|
||||
if !s.is_empty() && s != "*" {
|
||||
exts.push(s.to_ascii_lowercase());
|
||||
}
|
||||
}
|
||||
if exts.is_empty() {
|
||||
exts.extend(extract_exts_from_label(&label));
|
||||
}
|
||||
Some((label, exts))
|
||||
} else if let Ok((label, exts)) = <(String, Vec<String>)>::try_from(val.clone()) {
|
||||
Some((label, exts.into_iter().map(|s| s.to_ascii_lowercase()).collect()))
|
||||
} else if let Ok(label) = String::try_from(val) {
|
||||
let exts = extract_exts_from_label(&label);
|
||||
Some((label, exts))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
Ok(Some(Saved { path, filter }))
|
||||
}
|
||||
|
||||
/// Extracts file extensions from a filter label such as `"PDF (*.pdf)"` or `"*.pdf"`.
|
||||
fn extract_exts_from_label(label: &str) -> Vec<String> {
|
||||
let text = if let Some(inside) = label.split('(').nth(1).and_then(|s| s.split(')').next()) { inside } else { label };
|
||||
text.split(|c: char| c == ';' || c == ',' || c.is_whitespace())
|
||||
.filter_map(|part| {
|
||||
let s = part.trim().trim_start_matches('*').trim_start_matches('.');
|
||||
if s.is_empty() || s == "*" || s.contains('/') || s.contains('\\') { None } else { Some(s.to_ascii_lowercase()) }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Converts a `file://` URI from the portal into a local `PathBuf`.
|
||||
/// Returns `None` for non-local URIs or invalid percent-encoding.
|
||||
pub fn file_uri_path(uri: &str) -> Option<PathBuf> {
|
||||
let rest = uri.strip_prefix("file://")?;
|
||||
// Only local files: `file:///…` or `file://localhost/…`.
|
||||
let rest = rest.strip_prefix("localhost").unwrap_or(rest);
|
||||
if !rest.starts_with('/') {
|
||||
return None;
|
||||
}
|
||||
let bytes = rest.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes.get(i) == Some(&b'%') {
|
||||
let hex = bytes.get(i + 1..i + 3).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok())?;
|
||||
out.push(hex);
|
||||
i = i.saturating_add(3);
|
||||
} else if let Some(&b) = bytes.get(i) {
|
||||
out.push(b);
|
||||
i = i.saturating_add(1);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
Some(PathBuf::from(std::ffi::OsString::from_vec(out)))
|
||||
}
|
||||
|
||||
/// Returns the index of the chosen filter in `filters` by matching the picked index, or the first filter
|
||||
/// if only one was offered.
|
||||
fn chosen_type(filters: &[Filter], picked: Option<usize>) -> Option<usize> {
|
||||
match picked {
|
||||
Some(i) if i < filters.len() => Some(i),
|
||||
None if filters.len() == 1 => Some(0),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `path` with the extension of the file type picked in the save dialog (`chosen`),
|
||||
/// where the dialog left the suggested name's extension alone (#6180).
|
||||
///
|
||||
/// The GTK file chooser keeps the typed name when another type is picked. Only a name that
|
||||
/// still has the extension the dialog opened with (`opened_with`, e.g. `Document1.docx` with
|
||||
/// PDF picked) or none gets the type's extension, as PolyMeilex/rfd#341 does. An extension the
|
||||
/// user explicitly typed that WordCraft saves (e.g. `art.txt` with PDF untouched) is kept,
|
||||
/// and so does one of the type's own extensions. Any extension that WordCraft does not save
|
||||
/// (e.g. `scan.2024`) is treated as part of the stem, and the type's extension is added.
|
||||
pub fn with_chosen_type(path: &Path, opened_with: Option<&str>, chosen: &[String]) -> PathBuf {
|
||||
let Some(first) = chosen.first() else { return path.to_path_buf() };
|
||||
let ext = path.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase);
|
||||
let opened_ext = opened_with
|
||||
.and_then(|n| Path::new(n).extension())
|
||||
.and_then(|e| e.to_str())
|
||||
.map(str::to_ascii_lowercase)
|
||||
.filter(|e| wordcraft_engine::io::SAVE_EXTS.contains(&e.as_str()));
|
||||
match ext {
|
||||
Some(ext) if chosen.contains(&ext) => path.to_path_buf(),
|
||||
Some(ext) if opened_ext.as_ref() == Some(&ext) => path.with_extension(first),
|
||||
Some(ext) if wordcraft_engine::io::SAVE_EXTS.contains(&ext.as_str()) => path.to_path_buf(),
|
||||
_ => {
|
||||
let mut name = path.as_os_str().to_owned();
|
||||
name.push(".");
|
||||
name.push(first);
|
||||
PathBuf::from(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if `path` exists as a file, directory, or symlink on disk.
|
||||
fn entry_exists(path: &Path) -> bool {
|
||||
match path.symlink_metadata() {
|
||||
Ok(_) => true,
|
||||
Err(e) => e.kind() != std::io::ErrorKind::NotFound,
|
||||
}
|
||||
}
|
||||
|
||||
/// Closes the active portal request handle by calling `org.freedesktop.portal.Request.Close`.
|
||||
pub fn close_request(conn: &zbus::blocking::Connection, portal: Option<&str>, handle: &OwnedObjectPath) {
|
||||
const DESKTOP: &str = "org.freedesktop.portal.Desktop";
|
||||
const REQUEST: &str = "org.freedesktop.portal.Request";
|
||||
let dest = portal.unwrap_or(DESKTOP);
|
||||
if let Ok(proxy) = Proxy::new(conn, dest, handle.as_str(), REQUEST) {
|
||||
let _ = proxy.call_method("Close", &());
|
||||
}
|
||||
}
|
||||
|
||||
/// RAII guard that closes an open portal request handle if dropped before being disarmed.
|
||||
struct RequestGuard<'a> {
|
||||
conn: &'a zbus::blocking::Connection,
|
||||
portal: Option<String>,
|
||||
handle: Option<OwnedObjectPath>,
|
||||
}
|
||||
|
||||
impl<'a> RequestGuard<'a> {
|
||||
/// Creates a new guard for the given request `handle`.
|
||||
fn new(conn: &'a zbus::blocking::Connection, portal: Option<String>, handle: OwnedObjectPath) -> Self {
|
||||
Self { conn, portal, handle: Some(handle) }
|
||||
}
|
||||
|
||||
/// Disarms the guard when a response has been successfully received, preventing `Close` from being called on drop.
|
||||
fn disarm(&mut self) {
|
||||
self.handle = None;
|
||||
}
|
||||
|
||||
/// Returns a reference to the request handle, if still active.
|
||||
fn handle(&self) -> Option<&OwnedObjectPath> {
|
||||
self.handle.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Drop for RequestGuard<'a> {
|
||||
/// Closes the active portal request handle when dropped unless disarmed.
|
||||
fn drop(&mut self) {
|
||||
if let Some(handle) = self.handle.take() {
|
||||
close_request(self.conn, self.portal.as_deref(), &handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks the XDG desktop portal on `conn` for the `SaveFile` dialog of `req` and waits for its answer.
|
||||
pub fn save_file(conn: &zbus::blocking::Connection, req: &SaveRequest) -> Result<Option<Saved>, String> {
|
||||
const DESKTOP: &str = "org.freedesktop.portal.Desktop";
|
||||
const REQUEST: &str = "org.freedesktop.portal.Request";
|
||||
static NEXT: AtomicU32 = AtomicU32::new(0);
|
||||
let token = format!("wordcraft_save_{}_{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed));
|
||||
|
||||
// Listen before asking, on every request path: a portal older than version 0.9 ignores
|
||||
// `handle_token` and answers on the request path it returns, and its answer can come before
|
||||
// the call's reply. The stream keeps every `Response` from the time it is registered.
|
||||
let dbus = zbus::blocking::fdo::DBusProxy::new(conn).map_err(|e| e.to_string())?;
|
||||
let rule = zbus::MatchRule::builder()
|
||||
.msg_type(zbus::message::Type::Signal)
|
||||
.sender(DESKTOP)
|
||||
.and_then(|b| b.interface(REQUEST))
|
||||
.and_then(|b| b.member("Response"))
|
||||
.map_err(|e| e.to_string())?
|
||||
.build();
|
||||
dbus.add_match_rule(rule).map_err(|e| e.to_string())?;
|
||||
|
||||
let owner_rule = zbus::MatchRule::builder()
|
||||
.msg_type(zbus::message::Type::Signal)
|
||||
.sender("org.freedesktop.DBus")
|
||||
.and_then(|b| b.interface("org.freedesktop.DBus"))
|
||||
.and_then(|b| b.member("NameOwnerChanged"))
|
||||
.map_err(|e| e.to_string())?
|
||||
.build();
|
||||
dbus.add_match_rule(owner_rule).map_err(|e| e.to_string())?;
|
||||
|
||||
let responses = MessageIterator::from(conn);
|
||||
let chooser = Proxy::new(conn, DESKTOP, "/org/freedesktop/portal/desktop", "org.freedesktop.portal.FileChooser").map_err(|e| e.to_string())?;
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
let call = chooser.call_method("SaveFile", &(req.parent.as_str(), "Save As", options(req, &token))).map_err(|e| e.to_string())?;
|
||||
let handle: OwnedObjectPath = call.body().deserialize().map_err(|e| e.to_string())?;
|
||||
let portal = call.header().sender().map(|s| s.as_str().to_string());
|
||||
|
||||
let mut guard = RequestGuard::new(conn, portal.clone(), handle);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let _listener = std::thread::Builder::new().name("portal save listener".into()).spawn(move || {
|
||||
for msg in responses.filter_map(Result::ok) {
|
||||
if tx.send(msg).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let reply = loop {
|
||||
if req.cancel.as_ref().is_some_and(CancelToken::is_cancelled) {
|
||||
return Err(ERR_CANCELLED.to_string());
|
||||
}
|
||||
match rx.recv_timeout(Duration::from_millis(100)) {
|
||||
Ok(m) => {
|
||||
let header = m.header();
|
||||
if header.path().map(|p| p.as_str()) == guard.handle().map(|h| h.as_str()) && header.sender().map(|s| s.as_str()) == portal.as_deref()
|
||||
{
|
||||
break Ok(m);
|
||||
}
|
||||
#[allow(clippy::collapsible_if)]
|
||||
if header.interface().map(|i| i.as_str()) == Some("org.freedesktop.DBus")
|
||||
&& header.member().map(|m| m.as_str()) == Some("NameOwnerChanged")
|
||||
{
|
||||
if let Ok((name, old_owner, "")) = m.body().deserialize::<(&str, &str, &str)>() {
|
||||
if (name == DESKTOP || portal.as_deref() == Some(name)) && (portal.is_none() || portal.as_deref() == Some(old_owner)) {
|
||||
break Err("the desktop portal terminated while the save dialog was open".to_string());
|
||||
}
|
||||
if name.starts_with("org.freedesktop.impl.portal.desktop") {
|
||||
break Err("the desktop portal backend terminated while the save dialog was open".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
|
||||
if req.timeout.is_some_and(|t| start.elapsed() >= t) {
|
||||
break Err("the save dialog request timed out".to_string());
|
||||
}
|
||||
}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
|
||||
break Err("the save dialog closed without an answer".to_string());
|
||||
}
|
||||
}
|
||||
}?;
|
||||
|
||||
guard.disarm();
|
||||
let elapsed = start.elapsed();
|
||||
let (code, results): (u32, HashMap<String, OwnedValue>) = reply.body().deserialize().map_err(|e| e.to_string())?;
|
||||
answer(code, &results, elapsed)
|
||||
}
|
||||
|
||||
/// Returns true if `path` is located in the XDG document portal (`/run/user/<uid>/doc/…`, or
|
||||
/// `/run/flatpak/doc/…` inside a Flatpak sandbox).
|
||||
fn is_document_portal(path: &Path) -> bool {
|
||||
let s = path.to_string_lossy();
|
||||
((s.starts_with("/run/user/") || s.starts_with("/var/run/user/")) && s.contains("/doc/")) || s.starts_with("/run/flatpak/doc/")
|
||||
}
|
||||
|
||||
/// Error message returned when a pending save request was cancelled by the caller.
|
||||
pub const ERR_CANCELLED: &str = "the save dialog request was cancelled";
|
||||
|
||||
/// Returns true if `err` represents a save request cancelled by the caller (not a cancel in the
|
||||
/// dialog itself, which is `Ok(None)`).
|
||||
pub fn is_cancelled_error(err: &str) -> bool {
|
||||
err == ERR_CANCELLED
|
||||
}
|
||||
|
||||
/// Error message returned when the portal dialog does not report the selected filter and
|
||||
/// the saved filename does not explicitly specify a distinct format.
|
||||
pub const ERR_AMBIGUOUS_FILTER: &str = "the save dialog did not report which file type was picked";
|
||||
|
||||
/// Returns true if `err` represents an ambiguous filter error from the desktop portal.
|
||||
pub fn is_ambiguous_filter_error(err: &str) -> bool {
|
||||
err == ERR_AMBIGUOUS_FILTER
|
||||
}
|
||||
|
||||
/// Finds the index of the filter in `filters` matching `saved_filter`, first by exact label,
|
||||
/// then by unambiguous match against the filter's extensions.
|
||||
fn match_filter(filters: &[Filter], saved_filter: Option<&Filter>) -> Option<usize> {
|
||||
let (label, exts) = saved_filter?;
|
||||
if let Some(pos) = filters.iter().position(|(l, _)| l == label) {
|
||||
return Some(pos);
|
||||
}
|
||||
if !exts.is_empty() {
|
||||
let matching: Vec<usize> = filters
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, (_, f_exts))| exts.iter().any(|e| f_exts.iter().any(|fe| fe.eq_ignore_ascii_case(e))))
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
if matching.len() == 1 {
|
||||
return matching.first().copied();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Returns true if the portal did not provide a unique matching filter when multiple filters were offered
|
||||
/// and the user did not explicitly type a filename with a distinct supported format extension.
|
||||
pub fn is_ambiguous_filter(saved: &Saved, req: &SaveRequest) -> bool {
|
||||
if req.filters.len() <= 1 {
|
||||
return false;
|
||||
}
|
||||
if match_filter(&req.filters, saved.filter.as_ref()).is_some() {
|
||||
return false;
|
||||
}
|
||||
let ext = saved.path.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase);
|
||||
let suggested_ext = req
|
||||
.suggested
|
||||
.as_deref()
|
||||
.and_then(|n| Path::new(n).extension())
|
||||
.and_then(|e| e.to_str())
|
||||
.map(str::to_ascii_lowercase)
|
||||
.filter(|e| wordcraft_engine::io::SAVE_EXTS.contains(&e.as_str()));
|
||||
match ext {
|
||||
Some(ext) if wordcraft_engine::io::SAVE_EXTS.contains(&ext.as_str()) => suggested_ext.as_ref() == Some(&ext),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Save As on Linux and BSDs: the portal's dialog, with the file name given the extension of the
|
||||
/// file type picked in the dropdown. When that changes the name to one that already exists on disk,
|
||||
/// the dialog opens again under the new name, so the portal asks before replacing it (it asked only
|
||||
/// about the typed name).
|
||||
pub fn save_as(conn: &zbus::blocking::Connection, mut req: SaveRequest) -> Result<Option<PathBuf>, String> {
|
||||
loop {
|
||||
let Some(saved) = save_file(conn, &req)? else { return Ok(None) };
|
||||
if is_ambiguous_filter(&saved, &req) {
|
||||
return Err(ERR_AMBIGUOUS_FILTER.to_string());
|
||||
}
|
||||
let picked = match_filter(&req.filters, saved.filter.as_ref());
|
||||
let exts: &[String] = match chosen_type(&req.filters, picked) {
|
||||
Some(i) => req.filters.get(i).map(|(_, e)| e.as_slice()).unwrap_or(&[]),
|
||||
None => &[],
|
||||
};
|
||||
let path = with_chosen_type(&saved.path, req.suggested.as_deref(), exts);
|
||||
let needs_reopen = entry_exists(&path) || (path != saved.path && is_document_portal(&saved.path));
|
||||
if path == saved.path || !needs_reopen {
|
||||
return Ok(Some(path));
|
||||
}
|
||||
req.name = path.file_name().map(|n| n.to_string_lossy().into_owned());
|
||||
req.suggested = req.name.clone();
|
||||
req.folder = path.parent().map(Path::to_path_buf);
|
||||
req.current = picked;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// #6180: the file type picked in the save dialog gives the name its extension where the
|
||||
/// dialog left the one it opened with, and only there.
|
||||
#[test]
|
||||
fn the_picked_file_type_gives_the_name_its_extension() {
|
||||
let exts = |e: &[&str]| e.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
let (pdf, docx) = (exts(&["pdf"]), exts(&["docx", "docm"]));
|
||||
let opened = Some("Document1.docx");
|
||||
let cases = [
|
||||
// PDF picked, the name as the dialog opened (or renamed, its extension kept).
|
||||
("/docs/Document1.docx", opened, &pdf, "/docs/Document1.pdf"),
|
||||
("/docs/letter.docx", opened, &pdf, "/docs/letter.pdf"),
|
||||
("/docs/LETTER.DOCX", opened, &pdf, "/docs/LETTER.pdf"),
|
||||
// No extension: takes the picked type's extension.
|
||||
("/docs/report", opened, &pdf, "/docs/report.pdf"),
|
||||
// An extension WordCraft doesn't save is part of the name.
|
||||
("/docs/scan.2024", opened, &pdf, "/docs/scan.2024.pdf"),
|
||||
// An opened extension WordCraft doesn't save (e.g. .5 in Report 1.5) is kept as part of the stem.
|
||||
("/docs/Report 1.5", Some("Report 1.5"), &docx, "/docs/Report 1.5.docx"),
|
||||
// Typed, an extension WordCraft saves is the user's: kept, whatever the type says.
|
||||
("/docs/notes.txt", opened, &pdf, "/docs/notes.txt"),
|
||||
("/docs/page.html", opened, &pdf, "/docs/page.html"),
|
||||
("/docs/file.rtf", opened, &pdf, "/docs/file.rtf"),
|
||||
// Already the type's: kept.
|
||||
("/docs/letter.pdf", opened, &pdf, "/docs/letter.pdf"),
|
||||
("/docs/letter.PDF", opened, &pdf, "/docs/letter.PDF"),
|
||||
// The dialog opened on a .dotx template: that is the extension the type replaces.
|
||||
("/docs/memo.dotx", Some("memo.dotx"), &pdf, "/docs/memo.pdf"),
|
||||
("/docs/doc.docx", Some("memo.dotx"), &pdf, "/docs/doc.docx"),
|
||||
// Word document picked for a file named without extension.
|
||||
("/docs/contract", opened, &docx, "/docs/contract.docx"),
|
||||
];
|
||||
for (typed, opened_with, chosen, want) in cases {
|
||||
assert_eq!(
|
||||
with_chosen_type(Path::new(typed), opened_with, chosen),
|
||||
PathBuf::from(want),
|
||||
"failed for typed={typed}, opened_with={opened_with:?}"
|
||||
);
|
||||
}
|
||||
// No type picked: the name as typed is kept.
|
||||
assert_eq!(with_chosen_type(Path::new("/docs/report.docx"), opened, &[]), PathBuf::from("/docs/report.docx"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_uris_become_local_paths() {
|
||||
assert_eq!(file_uri_path("file:///home/me/Document1.docx"), Some(PathBuf::from("/home/me/Document1.docx")));
|
||||
assert_eq!(file_uri_path("file:///home/me/A%20b%C3%A9.pdf"), Some(PathBuf::from("/home/me/A bé.pdf")));
|
||||
assert_eq!(file_uri_path("file://localhost/tmp/x.pdf"), Some(PathBuf::from("/tmp/x.pdf")));
|
||||
assert_eq!(file_uri_path("file:///tmp/a%ZZ.pdf"), None);
|
||||
assert_eq!(file_uri_path("file:///tmp/a%"), None);
|
||||
assert_eq!(file_uri_path("file:///tmp/a%2"), None);
|
||||
assert_eq!(file_uri_path("sftp://host/x.pdf"), None);
|
||||
assert_eq!(file_uri_path("file://host/x.pdf"), None);
|
||||
assert_eq!(file_uri_path("http://example.com/doc.pdf"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn x11_windows_are_named_to_the_portal() {
|
||||
use eframe::wgpu::rwh::{RawWindowHandle, XlibWindowHandle};
|
||||
assert_eq!(parent_id(Some(RawWindowHandle::Xlib(XlibWindowHandle::new(0x3a0000f)))), "x11:3a0000f");
|
||||
assert_eq!(parent_id(None), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn answer_parses_portal_response_or_errors_on_quick_failure() {
|
||||
let mut results = HashMap::new();
|
||||
let uris: Vec<String> = vec!["file:///tmp/doc.docx".to_string()];
|
||||
results.insert("uris".to_string(), OwnedValue::try_from(Value::from(uris)).unwrap());
|
||||
let globs: Vec<(u32, String)> = vec![(0u32, "*.docx".to_string())];
|
||||
let filter: (String, Vec<(u32, String)>) = ("Word Document (*.docx)".to_string(), globs);
|
||||
results.insert("current_filter".to_string(), OwnedValue::try_from(Value::from(filter)).unwrap());
|
||||
|
||||
// Code 0 with filter
|
||||
let ans = answer(0, &results, Duration::from_millis(1000)).unwrap();
|
||||
assert_eq!(
|
||||
ans,
|
||||
Some(Saved { path: PathBuf::from("/tmp/doc.docx"), filter: Some(("Word Document (*.docx)".to_string(), vec!["docx".to_string()])) })
|
||||
);
|
||||
|
||||
// Code 0 without filter
|
||||
let mut results_no_filter = HashMap::new();
|
||||
results_no_filter.insert("uris".to_string(), results.get("uris").unwrap().clone());
|
||||
let ans_no_filter = answer(0, &results_no_filter, Duration::from_millis(1000)).unwrap();
|
||||
assert_eq!(ans_no_filter, Some(Saved { path: PathBuf::from("/tmp/doc.docx"), filter: None }));
|
||||
|
||||
// Code 1 (Cancel)
|
||||
assert_eq!(answer(1, &results, Duration::from_millis(50)).unwrap(), None);
|
||||
|
||||
// Code 2 quick (< TOO_QUICK)
|
||||
assert!(answer(2, &results, Duration::from_millis(100)).is_err());
|
||||
|
||||
// Code 2 slow (>= TOO_QUICK)
|
||||
assert_eq!(answer(2, &results, Duration::from_millis(500)).unwrap(), None);
|
||||
|
||||
// Code 3 (failure)
|
||||
assert!(answer(3, &results, Duration::from_millis(1000)).is_err());
|
||||
}
|
||||
|
||||
struct KillOnDrop(std::process::Child);
|
||||
|
||||
impl Drop for KillOnDrop {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.0.kill();
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for spawning a mock portal daemon.
|
||||
struct MockPortalConfig {
|
||||
code: u32,
|
||||
delay_reply: Option<Duration>,
|
||||
exit_after_reply: bool,
|
||||
send_response: bool,
|
||||
close_called: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
|
||||
emit_backend_owner_loss: bool,
|
||||
}
|
||||
|
||||
impl Default for MockPortalConfig {
|
||||
fn default() -> Self {
|
||||
Self { code: 0, delay_reply: None, exit_after_reply: false, send_response: true, close_called: None, emit_backend_owner_loss: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns a mock portal daemon that answers `SaveFile` and handles `Close` according to `config`.
|
||||
fn mock_portal_with_config(config: MockPortalConfig) -> Option<(zbus::blocking::Connection, KillOnDrop)> {
|
||||
use std::io::BufRead as _;
|
||||
|
||||
let daemon = std::process::Command::new("dbus-daemon")
|
||||
.args(["--session", "--nofork", "--print-address=1"])
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.ok()?;
|
||||
let mut guard = KillOnDrop(daemon);
|
||||
let mut address = String::new();
|
||||
std::io::BufReader::new(guard.0.stdout.take()?).read_line(&mut address).ok()?;
|
||||
let address = address.trim().to_string();
|
||||
let server =
|
||||
zbus::blocking::connection::Builder::address(address.as_str()).ok()?.name("org.freedesktop.portal.Desktop").ok()?.build().ok()?;
|
||||
let incoming = zbus::blocking::MessageIterator::from(&server);
|
||||
let thread_address = address.clone();
|
||||
std::thread::spawn(move || {
|
||||
for msg in incoming {
|
||||
let Ok(msg) = msg else { break };
|
||||
let header = msg.header();
|
||||
if header.message_type() != zbus::message::Type::MethodCall {
|
||||
continue;
|
||||
}
|
||||
if header.member().map(|m| m.as_str()) == Some("Close") {
|
||||
if let Some(flag) = &config.close_called {
|
||||
flag.store(true, Ordering::Relaxed);
|
||||
}
|
||||
let _ = server.reply(&header, &());
|
||||
continue;
|
||||
}
|
||||
if header.member().map(|m| m.as_str()) != Some("SaveFile") {
|
||||
continue;
|
||||
}
|
||||
let Ok((_parent, _title, options)) = msg.body().deserialize::<(String, String, HashMap<String, OwnedValue>)>() else {
|
||||
break;
|
||||
};
|
||||
let text = |key: &str| options.get(key).and_then(|v| String::try_from(v.try_clone().ok()?).ok());
|
||||
let Some(sender) = header.sender().map(|s| s.as_str().trim_start_matches(':').replace('.', "_")) else { break };
|
||||
let token = text("handle_token").unwrap_or_default();
|
||||
let path = format!("/org/freedesktop/portal/desktop/request/{sender}/{token}");
|
||||
let Ok(handle) = OwnedObjectPath::try_from(path.as_str()) else { break };
|
||||
|
||||
if config.exit_after_reply {
|
||||
let _ = server.reply(&header, &handle);
|
||||
drop(server);
|
||||
break;
|
||||
}
|
||||
|
||||
if config.emit_backend_owner_loss {
|
||||
let _ = server.reply(&header, &handle);
|
||||
let addr = thread_address.clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
if let Ok(backend) = zbus::blocking::connection::Builder::address(addr.as_str())
|
||||
.and_then(|b| b.name("org.freedesktop.impl.portal.desktop.gtk"))
|
||||
.and_then(|b| b.build())
|
||||
{
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
drop(backend);
|
||||
}
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if config.send_response {
|
||||
let to = header.sender().cloned().map(zbus::names::BusName::from);
|
||||
let results: HashMap<&str, Value<'_>> = HashMap::new();
|
||||
let _ = server.emit_signal(to, path.as_str(), "org.freedesktop.portal.Request", "Response", &(config.code, &results));
|
||||
}
|
||||
|
||||
if let Some(delay) = config.delay_reply {
|
||||
std::thread::sleep(delay);
|
||||
}
|
||||
if server.reply(&header, &handle).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
let client = zbus::blocking::connection::Builder::address(address.as_str()).ok()?.build().ok()?;
|
||||
Some((client, guard))
|
||||
}
|
||||
|
||||
/// Spawns a mock portal daemon that answers `SaveFile`.
|
||||
/// If `delay_reply` is set, it emits the Response signal immediately and delays the method
|
||||
/// reply by that duration. If `exit_after_reply` is set, it replies to `SaveFile` and exits
|
||||
/// without sending a `Response`.
|
||||
fn mock_portal(code: u32, delay_reply: Option<Duration>, exit_after_reply: bool) -> Option<(zbus::blocking::Connection, KillOnDrop)> {
|
||||
mock_portal_with_config(MockPortalConfig {
|
||||
code,
|
||||
delay_reply,
|
||||
exit_after_reply,
|
||||
send_response: true,
|
||||
close_called: None,
|
||||
emit_backend_owner_loss: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// A delayed SaveFile method reply with an already-queued response code 2 measures elapsed time
|
||||
/// from before calling SaveFile, so it is treated as a user cancellation rather than a quick
|
||||
/// portal failure that re-opens another dialog.
|
||||
#[test]
|
||||
fn delayed_method_reply_with_queued_cancel_is_not_treated_as_quick_failure() {
|
||||
let Some((client, _daemon)) = mock_portal(2, Some(Duration::from_millis(350)), false) else {
|
||||
eprintln!("no dbus-daemon: skipped");
|
||||
return;
|
||||
};
|
||||
let req = SaveRequest {
|
||||
name: Some("test.docx".to_string()),
|
||||
suggested: Some("test.docx".to_string()),
|
||||
filters: vec![("Word Document (*.docx)".to_string(), vec!["docx".to_string()])],
|
||||
..Default::default()
|
||||
};
|
||||
let res = save_file(&client, &req);
|
||||
assert_eq!(res, Ok(None));
|
||||
}
|
||||
|
||||
/// If the desktop portal terminates while a save dialog is open (e.g. crashing after replying
|
||||
/// to SaveFile), save_file detects NameOwnerChanged and returns an error immediately.
|
||||
#[test]
|
||||
fn portal_terminating_while_open_returns_error() {
|
||||
let Some((client, _daemon)) = mock_portal(0, None, true) else {
|
||||
eprintln!("no dbus-daemon: skipped");
|
||||
return;
|
||||
};
|
||||
let req = SaveRequest {
|
||||
name: Some("test.docx".to_string()),
|
||||
suggested: Some("test.docx".to_string()),
|
||||
filters: vec![("Word Document (*.docx)".to_string(), vec!["docx".to_string()])],
|
||||
..Default::default()
|
||||
};
|
||||
let res = save_file(&client, &req);
|
||||
assert!(res.is_err(), "portal termination returns an error so rfd fallback runs: got {res:?}");
|
||||
}
|
||||
|
||||
/// If the desktop portal backend implementation (e.g. GTK, GNOME, KDE) terminates while the
|
||||
/// dialog is open, save_file detects NameOwnerChanged on the implementation service and returns
|
||||
/// an error immediately so the rfd fallback can run.
|
||||
#[test]
|
||||
fn portal_backend_terminating_while_open_returns_error() {
|
||||
let Some((client, _daemon)) =
|
||||
mock_portal_with_config(MockPortalConfig { emit_backend_owner_loss: true, send_response: false, ..Default::default() })
|
||||
else {
|
||||
eprintln!("no dbus-daemon: skipped");
|
||||
return;
|
||||
};
|
||||
let req = SaveRequest {
|
||||
name: Some("test.docx".to_string()),
|
||||
suggested: Some("test.docx".to_string()),
|
||||
filters: vec![("Word Document (*.docx)".to_string(), vec!["docx".to_string()])],
|
||||
..Default::default()
|
||||
};
|
||||
let res = save_file(&client, &req);
|
||||
assert!(res.is_err(), "backend termination returns an error so rfd fallback runs: got {res:?}");
|
||||
assert!(res.as_ref().unwrap_err().contains("backend terminated"), "expected backend termination error: got {res:?}");
|
||||
}
|
||||
|
||||
/// When a save request is cancelled via `CancelToken`, save_file aborts waiting for the response,
|
||||
/// calls Close on the portal request object path, and returns a cancellation error immediately.
|
||||
#[test]
|
||||
fn save_file_cancellation_closes_request_and_returns_error() {
|
||||
let close_called = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let Some((client, _daemon)) =
|
||||
mock_portal_with_config(MockPortalConfig { send_response: false, close_called: Some(close_called.clone()), ..Default::default() })
|
||||
else {
|
||||
eprintln!("no dbus-daemon: skipped");
|
||||
return;
|
||||
};
|
||||
let cancel = CancelToken::new();
|
||||
let cancel_thread = cancel.clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
cancel_thread.cancel();
|
||||
});
|
||||
let req = SaveRequest {
|
||||
name: Some("test.docx".to_string()),
|
||||
suggested: Some("test.docx".to_string()),
|
||||
filters: vec![("Word Document (*.docx)".to_string(), vec!["docx".to_string()])],
|
||||
cancel: Some(cancel),
|
||||
..Default::default()
|
||||
};
|
||||
let res = save_file(&client, &req);
|
||||
assert!(res.is_err(), "cancelled request returns error: got {res:?}");
|
||||
assert!(res.as_ref().unwrap_err().contains("cancelled"), "expected cancellation error: got {res:?}");
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
assert!(close_called.load(Ordering::Relaxed), "Close must be called on portal request handle");
|
||||
}
|
||||
|
||||
/// When a save request times out waiting for a stalled portal backend response, save_file aborts,
|
||||
/// calls Close on the portal request object path, and returns a timeout error.
|
||||
#[test]
|
||||
fn save_file_timeout_closes_request_and_returns_error() {
|
||||
let close_called = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let Some((client, _daemon)) =
|
||||
mock_portal_with_config(MockPortalConfig { send_response: false, close_called: Some(close_called.clone()), ..Default::default() })
|
||||
else {
|
||||
eprintln!("no dbus-daemon: skipped");
|
||||
return;
|
||||
};
|
||||
let req = SaveRequest {
|
||||
name: Some("test.docx".to_string()),
|
||||
suggested: Some("test.docx".to_string()),
|
||||
filters: vec![("Word Document (*.docx)".to_string(), vec!["docx".to_string()])],
|
||||
timeout: Some(Duration::from_millis(50)),
|
||||
..Default::default()
|
||||
};
|
||||
let res = save_file(&client, &req);
|
||||
assert!(res.is_err(), "timed out request returns error: got {res:?}");
|
||||
assert!(res.as_ref().unwrap_err().contains("timed out"), "expected timeout error: got {res:?}");
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
assert!(close_called.load(Ordering::Relaxed), "Close must be called on portal request handle");
|
||||
}
|
||||
|
||||
/// `entry_exists` detects existing files and dangling symlinks, distinguishing them from
|
||||
/// absent entries.
|
||||
#[test]
|
||||
fn entry_exists_detects_dangling_symlinks() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let target = dir.path().join("nonexistent_target.txt");
|
||||
let link = dir.path().join("dangling_link.txt");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::os::unix::fs::symlink(&target, &link).unwrap();
|
||||
assert!(!link.exists(), "target does not exist so exists() is false");
|
||||
assert!(entry_exists(&link), "symlink entry exists so entry_exists() is true");
|
||||
}
|
||||
assert!(!entry_exists(&target), "nonexistent file does not exist");
|
||||
}
|
||||
|
||||
/// `is_document_portal` identifies paths within the XDG document portal mount, including the
|
||||
/// `/run/flatpak/doc` mount a Flatpak sandbox sees.
|
||||
#[test]
|
||||
fn is_document_portal_detects_portal_fuse_paths() {
|
||||
assert!(is_document_portal(Path::new("/run/user/1000/doc/1234abcd/test.docx")));
|
||||
assert!(is_document_portal(Path::new("/var/run/user/1000/doc/by-app/app/test.docx")));
|
||||
assert!(is_document_portal(Path::new("/run/flatpak/doc/1234abcd/test.docx")));
|
||||
assert!(!is_document_portal(Path::new("/home/user/Documents/test.docx")));
|
||||
assert!(!is_document_portal(Path::new("/tmp/doc/test.docx")));
|
||||
assert!(!is_document_portal(Path::new("/run/flatpak/other/test.docx")));
|
||||
}
|
||||
|
||||
/// When the portal omits `current_filter`, ambiguous saves (unchanged suggested extension or bare name)
|
||||
/// are rejected, while an explicitly typed different format is accepted.
|
||||
#[test]
|
||||
fn ambiguous_filters_require_explicit_format() {
|
||||
let exts = |e: &[&str]| e.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
let filters = vec![("Word Document (*.docx)".into(), exts(&["docx"])), ("PDF (*.pdf)".into(), exts(&["pdf"]))];
|
||||
let req = SaveRequest { filters: filters.clone(), suggested: Some("Document1.docx".into()), ..Default::default() };
|
||||
|
||||
// Reported filter with matching label is not ambiguous.
|
||||
let with_filter = Saved { path: PathBuf::from("/docs/Document1.docx"), filter: Some(("Word Document (*.docx)".into(), exts(&["docx"]))) };
|
||||
assert_eq!(match_filter(&filters, with_filter.filter.as_ref()), Some(0));
|
||||
assert!(!is_ambiguous_filter(&with_filter, &req));
|
||||
|
||||
// Reported filter with unmatched label but unambiguous extension (e.g. KDE *.pdf) is not ambiguous.
|
||||
let kde_pdf_filter = Saved { path: PathBuf::from("/docs/Document1.docx"), filter: Some(("*.pdf".into(), exts(&["pdf"]))) };
|
||||
assert_eq!(match_filter(&filters, kde_pdf_filter.filter.as_ref()), Some(1));
|
||||
assert!(!is_ambiguous_filter(&kde_pdf_filter, &req));
|
||||
|
||||
// Reported filter with unrecognized extensions and unchanged suggested extension is ambiguous.
|
||||
let unknown_filter = Saved { path: PathBuf::from("/docs/Document1.docx"), filter: Some(("Custom (*.xyz)".into(), exts(&["xyz"]))) };
|
||||
assert_eq!(match_filter(&filters, unknown_filter.filter.as_ref()), None);
|
||||
assert!(is_ambiguous_filter(&unknown_filter, &req));
|
||||
|
||||
// Multiple filters matching same extension is ambiguous when label doesn't match.
|
||||
let duplicate_filters = vec![("Plain Text (*.txt)".into(), exts(&["txt"])), ("Text Document (*.txt)".into(), exts(&["txt"]))];
|
||||
let dup_filter = ("*.txt".to_string(), exts(&["txt"]));
|
||||
assert_eq!(match_filter(&duplicate_filters, Some(&dup_filter)), None);
|
||||
|
||||
// Missing filter with unchanged suggested extension is ambiguous.
|
||||
let unchanged = Saved { path: PathBuf::from("/docs/Document1.docx"), filter: None };
|
||||
assert!(is_ambiguous_filter(&unchanged, &req));
|
||||
|
||||
// Missing filter with bare filename is ambiguous.
|
||||
let bare = Saved { path: PathBuf::from("/docs/Document1"), filter: None };
|
||||
assert!(is_ambiguous_filter(&bare, &req));
|
||||
|
||||
// Missing filter but user explicitly typed a different format is NOT ambiguous.
|
||||
let explicit = Saved { path: PathBuf::from("/docs/Document1.pdf"), filter: None };
|
||||
assert!(!is_ambiguous_filter(&explicit, &req));
|
||||
|
||||
// Single filter offered is never ambiguous even if filter is omitted.
|
||||
let single_req =
|
||||
SaveRequest { filters: vec![("PDF (*.pdf)".into(), exts(&["pdf"]))], suggested: Some("Document1.docx".into()), ..Default::default() };
|
||||
assert!(!is_ambiguous_filter(&unchanged, &single_req));
|
||||
}
|
||||
}
|
||||
+116
-20
@@ -1,13 +1,21 @@
|
||||
//! Desktop spooler adapter. Discovery and submission never run on the UI thread.
|
||||
#[cfg(any(windows, test))]
|
||||
pub(crate) mod isolated;
|
||||
|
||||
use wordcraft_ui_egui::printing::{Discover, Duplex, Printer, Settings, Submit};
|
||||
|
||||
/// Returns a discovery callback that enumerates spooler printers on a named worker thread and
|
||||
/// sends the list through a channel, keeping spooler calls off the UI thread.
|
||||
///
|
||||
/// The Print panel needs only names and the default, so the list comes from
|
||||
/// `craft_print::printer_queues`, which asks no printer driver anything: on Windows a networked
|
||||
/// printer in power save can hold a driver's capability query for a long time, and the list must
|
||||
/// not wait on it (#5710). Submission still checks the job's options with the driver.
|
||||
pub fn discover() -> Discover {
|
||||
Box::new(|| {
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
let _ = std::thread::Builder::new().name("print-discovery".into()).spawn(move || {
|
||||
let printers = craft_print::printers()
|
||||
let printers = craft_print::printer_queues()
|
||||
.into_iter()
|
||||
.map(|printer| Printer { name: printer.name, label: printer.description, default: printer.default })
|
||||
.collect();
|
||||
@@ -17,22 +25,59 @@ pub fn discover() -> Discover {
|
||||
})
|
||||
}
|
||||
|
||||
/// A submission worker and how to stop its job: `true` when it will never be sent, `false` when
|
||||
/// it was already being sent.
|
||||
struct Job {
|
||||
worker: std::thread::JoinHandle<()>,
|
||||
cancel: Box<dyn Fn() -> bool + Send>,
|
||||
// Acknowledged helper cancellation permits joining it to reap the process and its input PDF.
|
||||
isolated: bool,
|
||||
}
|
||||
|
||||
// Keep submission workers reachable until normal app shutdown; dropping a JoinHandle would
|
||||
// let process exit end an in-process Windows job or the CUPS hand-off before the spooler has it.
|
||||
static JOBS: std::sync::Mutex<Vec<std::thread::JoinHandle<()>>> = std::sync::Mutex::new(Vec::new());
|
||||
static JOBS: std::sync::Mutex<Vec<Job>> = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
fn jobs() -> std::sync::MutexGuard<'static, Vec<Job>> {
|
||||
JOBS.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// Waits for the jobs already being sent, so the spooler has them before the process exits. A
|
||||
/// job still waiting (on a sleeping printer's driver, say: #5710) is cancelled instead, so it is
|
||||
/// never sent and quitting doesn't wait on the driver.
|
||||
pub fn finish_pending_jobs() {
|
||||
let workers = std::mem::take(&mut *JOBS.lock().unwrap_or_else(std::sync::PoisonError::into_inner));
|
||||
for worker in workers {
|
||||
if worker.join().is_err() {
|
||||
let pending = std::mem::take(&mut *jobs());
|
||||
// Cancel them all first: a job left waiting while another is joined could start sending.
|
||||
let finishing: Vec<_> = pending
|
||||
.into_iter()
|
||||
.filter(|job| {
|
||||
let cancelled = (job.cancel)();
|
||||
job.isolated || !cancelled
|
||||
})
|
||||
.collect();
|
||||
for job in finishing {
|
||||
if job.worker.join().is_err() {
|
||||
log::error!("the print worker stopped unexpectedly during shutdown");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Print page's Cancel: stops the job being submitted (the Print page submits one at a
|
||||
/// time, the latest) unless it has started reaching the spooler. `true` when it will never be
|
||||
/// sent. A job that has finished sending answers `false`, so its receipt is still shown.
|
||||
pub fn cancel() -> Box<dyn Fn() -> bool> {
|
||||
Box::new(|| jobs().last().is_none_or(|job| (job.cancel)()))
|
||||
}
|
||||
|
||||
/// Submit off the UI thread, isolating Windows drivers while retaining CUPS token cancellation.
|
||||
pub fn submit() -> Submit {
|
||||
Box::new(|bytes, settings: Settings, collate, options| {
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
#[cfg(not(windows))]
|
||||
let cancel = craft_print::Cancel::new();
|
||||
#[cfg(windows)]
|
||||
let cancel = isolated::Submission::new();
|
||||
let token = cancel.clone();
|
||||
let worker = std::thread::Builder::new().name("print-submit".into()).spawn(move || {
|
||||
let job = craft_print::Job {
|
||||
printer: Some(settings.printer),
|
||||
@@ -48,12 +93,16 @@ pub fn submit() -> Submit {
|
||||
title: "WordCraft document".into(),
|
||||
options,
|
||||
};
|
||||
let _ = sender.send(craft_print::submit(&bytes, &job).map_err(|error| error.to_string()));
|
||||
#[cfg(not(windows))]
|
||||
let sent = craft_print::submit_cancellable(&bytes, &job, &mut |_, _| {}, &token).map_err(|error| error.to_string());
|
||||
#[cfg(windows)]
|
||||
let sent = token.submit(&bytes, &job);
|
||||
let _ = sender.send(sent);
|
||||
});
|
||||
if let Ok(worker) = worker {
|
||||
let mut jobs = JOBS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
jobs.retain(|job| !job.is_finished());
|
||||
jobs.push(worker);
|
||||
let mut jobs = jobs();
|
||||
jobs.retain(|job| !job.worker.is_finished());
|
||||
jobs.push(Job { worker, cancel: Box::new(move || cancel.cancel()), isolated: cfg!(windows) });
|
||||
}
|
||||
receiver
|
||||
})
|
||||
@@ -62,20 +111,67 @@ pub fn submit() -> Submit {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn shutdown_waits_for_pending_submission_workers() {
|
||||
/// A worker that waits until released, then reports that it finished.
|
||||
fn worker() -> (std::thread::JoinHandle<()>, std::sync::mpsc::Sender<()>, std::sync::mpsc::Receiver<()>) {
|
||||
let (release, wait) = std::sync::mpsc::channel();
|
||||
let (completed, completion) = std::sync::mpsc::channel();
|
||||
let worker = std::thread::spawn(move || {
|
||||
wait.recv().unwrap();
|
||||
completed.send(()).unwrap();
|
||||
let _ = wait.recv();
|
||||
let _ = completed.send(());
|
||||
});
|
||||
JOBS.lock().unwrap().push(worker);
|
||||
let shutdown = std::thread::spawn(finish_pending_jobs);
|
||||
assert!(completion.try_recv().is_err(), "the print worker is still pending");
|
||||
release.send(()).unwrap();
|
||||
shutdown.join().unwrap();
|
||||
completion.recv().unwrap();
|
||||
assert!(JOBS.lock().unwrap().is_empty(), "workers were joined before shutdown returned");
|
||||
(worker, release, completion)
|
||||
}
|
||||
|
||||
/// A cancel action that records when it was asked and answers `stops`.
|
||||
fn asked(stops: bool) -> (Box<dyn Fn() -> bool + Send>, std::sync::Arc<std::sync::atomic::AtomicBool>) {
|
||||
let flag = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let set = flag.clone();
|
||||
let cancel = Box::new(move || {
|
||||
set.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
stops
|
||||
});
|
||||
(cancel, flag)
|
||||
}
|
||||
|
||||
/// Shutdown waits for a job already being sent, so the spooler has it before the process
|
||||
/// exits, but not for one still waiting on a driver (a sleeping printer, #5710): that one is
|
||||
/// cancelled, so it is never sent, and quitting doesn't hang on it. Every job is cancelled
|
||||
/// before any is waited for, so a waiting job can't start sending meanwhile. The Print page's
|
||||
/// Cancel asks only the latest job. One test, because JOBS is global.
|
||||
#[test]
|
||||
fn shutdown_waits_for_jobs_being_sent_and_cancels_the_rest() {
|
||||
let (sending, release_sending, sent) = worker();
|
||||
let (stuck, release_stuck, _) = worker();
|
||||
let (stop_sending, sending_asked) = asked(false);
|
||||
let (stop_stuck, cancelled) = asked(true);
|
||||
// The job being sent comes first, so a one-at-a-time shutdown would join it before
|
||||
// cancelling the waiting one.
|
||||
*JOBS.lock().unwrap() =
|
||||
vec![Job { worker: sending, cancel: stop_sending, isolated: false }, Job { worker: stuck, cancel: stop_stuck, isolated: false }];
|
||||
// Cancel from the Print page asks the latest job only.
|
||||
assert!(cancel()(), "the latest job was still waiting: never sent");
|
||||
assert!(!sending_asked.load(std::sync::atomic::Ordering::SeqCst), "an earlier job isn't the one being submitted");
|
||||
cancelled.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||
let (finished, shutdown) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
finish_pending_jobs();
|
||||
let _ = finished.send(());
|
||||
});
|
||||
let wait = std::time::Duration::from_millis(200);
|
||||
assert!(shutdown.recv_timeout(wait).is_err(), "shutdown waits for the job being sent");
|
||||
assert!(cancelled.load(std::sync::atomic::Ordering::SeqCst), "the waiting job was cancelled before the wait");
|
||||
release_sending.send(()).unwrap();
|
||||
assert!(shutdown.recv_timeout(std::time::Duration::from_secs(10)).is_ok(), "shutdown doesn't wait for the stuck job");
|
||||
sent.recv().unwrap();
|
||||
assert!(JOBS.lock().unwrap().is_empty(), "shutdown took every job");
|
||||
// A job that has already been sent answers false, so the Print page keeps its receipt.
|
||||
let (done, release_done, finished) = worker();
|
||||
release_done.send(()).unwrap();
|
||||
finished.recv().unwrap();
|
||||
*JOBS.lock().unwrap() = vec![Job { worker: done, cancel: Box::new(|| false), isolated: false }];
|
||||
assert!(!cancel()(), "a sent job isn't reported as cancelled");
|
||||
JOBS.lock().unwrap().clear();
|
||||
// The stuck worker was left behind without blocking shutdown; let it end.
|
||||
release_stuck.send(()).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,592 @@
|
||||
//! One Windows driver submission per process: a cancelled driver cannot retain another
|
||||
//! job's provider lock. The child acknowledges its Cancel token before it exits.
|
||||
|
||||
use std::{
|
||||
io::{BufRead, BufReader, Read, Write},
|
||||
path::Path,
|
||||
process::{Child, ChildStdin, Command, Stdio},
|
||||
sync::{Arc, Condvar, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[cfg(windows)]
|
||||
const FLAG: &str = "--wordcraft-print-worker";
|
||||
const PREFIX: &str = "WORDCRAFT_PRINT:";
|
||||
const MAX_MESSAGE: u64 = 64 * 1024;
|
||||
const MAX_PDF: u64 = 512 * 1024 * 1024;
|
||||
const CANCELLED: &str = "the print job was cancelled before anything was sent to the printer";
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
enum Duplex {
|
||||
Off,
|
||||
LongEdge,
|
||||
ShortEdge,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Request {
|
||||
printer: Option<String>,
|
||||
copies: u32,
|
||||
collate: bool,
|
||||
duplex: Option<Duplex>,
|
||||
grayscale: bool,
|
||||
shrink: bool,
|
||||
title: String,
|
||||
options: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl Request {
|
||||
/// Preserve all spooler options in a platform-independent wire representation.
|
||||
fn from_job(job: &craft_print::Job) -> Self {
|
||||
Self {
|
||||
printer: job.printer.clone(),
|
||||
copies: job.copies,
|
||||
collate: job.collate,
|
||||
duplex: job.duplex.map(|d| match d {
|
||||
craft_print::Duplex::Off => Duplex::Off,
|
||||
craft_print::Duplex::LongEdge => Duplex::LongEdge,
|
||||
craft_print::Duplex::ShortEdge => Duplex::ShortEdge,
|
||||
}),
|
||||
grayscale: job.grayscale,
|
||||
shrink: job.scaling == craft_print::Scaling::ShrinkToFit,
|
||||
title: job.title.clone(),
|
||||
options: job.options.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore the job without substituting printer defaults or changing copy semantics.
|
||||
fn job(self) -> craft_print::Job {
|
||||
craft_print::Job {
|
||||
printer: self.printer,
|
||||
copies: self.copies,
|
||||
collate: self.collate,
|
||||
duplex: self.duplex.map(|d| match d {
|
||||
Duplex::Off => craft_print::Duplex::Off,
|
||||
Duplex::LongEdge => craft_print::Duplex::LongEdge,
|
||||
Duplex::ShortEdge => craft_print::Duplex::ShortEdge,
|
||||
}),
|
||||
grayscale: self.grayscale,
|
||||
scaling: if self.shrink { craft_print::Scaling::ShrinkToFit } else { craft_print::Scaling::Exact },
|
||||
title: self.title,
|
||||
options: self.options,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
enum Event {
|
||||
Ready,
|
||||
Cancelled,
|
||||
Sending,
|
||||
Done(Result<String, String>),
|
||||
}
|
||||
|
||||
enum State {
|
||||
Starting,
|
||||
Waking(Arc<Mutex<Child>>),
|
||||
Running(ChildStdin),
|
||||
Sending(ChildStdin),
|
||||
Cancelled,
|
||||
Finished,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Submission(Arc<(Mutex<State>, Condvar)>);
|
||||
|
||||
impl Submission {
|
||||
/// Create one submission whose driver cannot run until the parent grants startup.
|
||||
pub(crate) fn new() -> Self {
|
||||
Self(Arc::new((Mutex::new(State::Starting), Condvar::new())))
|
||||
}
|
||||
|
||||
/// Recover the lifecycle state after poisoning without invoking another driver call.
|
||||
fn state(&self) -> std::sync::MutexGuard<'_, State> {
|
||||
self.0.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// Only an acknowledgement of the child's token can cancel a running helper. A timeout
|
||||
/// is a refusal, never permission to kill a process that may already be sending.
|
||||
pub(crate) fn cancel(&self) -> bool {
|
||||
let mut state = self.state();
|
||||
match &mut *state {
|
||||
State::Starting => {
|
||||
*state = State::Cancelled;
|
||||
return true;
|
||||
}
|
||||
State::Waking(child) => {
|
||||
let _ = child.lock().unwrap_or_else(std::sync::PoisonError::into_inner).kill();
|
||||
*state = State::Cancelled;
|
||||
return true;
|
||||
}
|
||||
State::Cancelled => return true,
|
||||
State::Finished | State::Sending(_) => return false,
|
||||
State::Running(input) => {
|
||||
if input.write_all(b"cancel\n").and_then(|_| input.flush()).is_err() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
let (state, _) = self
|
||||
.0
|
||||
.1
|
||||
.wait_timeout_while(state, Duration::from_secs(1), |s| matches!(s, State::Running(_)))
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
matches!(*state, State::Cancelled)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
/// Submit through this executable's private helper mode and await its final receipt.
|
||||
pub(crate) fn submit(&self, pdf: &[u8], job: &craft_print::Job) -> Result<String, String> {
|
||||
self.submit_with(pdf, job, |directory| {
|
||||
let mut command = Command::new(std::env::current_exe()?);
|
||||
command.arg(FLAG).arg(directory);
|
||||
Ok(command)
|
||||
})
|
||||
}
|
||||
|
||||
/// Publish a terminal state on every result, waking a cancellation caller awaiting an ack.
|
||||
fn submit_with(&self, pdf: &[u8], job: &craft_print::Job, command: impl FnOnce(&Path) -> std::io::Result<Command>) -> Result<String, String> {
|
||||
let result = self.run(pdf, job, command);
|
||||
let mut state = self.state();
|
||||
if !matches!(*state, State::Cancelled) {
|
||||
*state = State::Finished;
|
||||
}
|
||||
self.0.1.notify_all();
|
||||
if matches!(*state, State::Cancelled) { Err(CANCELLED.into()) } else { result }
|
||||
}
|
||||
|
||||
/// Own the private inputs and child until receipt, acknowledged cancellation or failure.
|
||||
fn run(&self, pdf: &[u8], job: &craft_print::Job, command: impl FnOnce(&Path) -> std::io::Result<Command>) -> Result<String, String> {
|
||||
if matches!(*self.state(), State::Cancelled) {
|
||||
return Err(CANCELLED.into());
|
||||
}
|
||||
if pdf.len() as u64 > MAX_PDF {
|
||||
return Err("the print PDF exceeds the 512 MiB helper limit".into());
|
||||
}
|
||||
let request = serde_json::to_vec(&Request::from_job(job)).map_err(error)?;
|
||||
if request.len() as u64 > MAX_MESSAGE {
|
||||
return Err("the print settings exceed the helper message limit".into());
|
||||
}
|
||||
let directory = tempfile::tempdir().map_err(error)?;
|
||||
std::fs::write(directory.path().join("job.json"), request).map_err(error)?;
|
||||
std::fs::write(directory.path().join("input.pdf"), pdf).map_err(error)?;
|
||||
let child =
|
||||
command(directory.path()).map_err(error)?.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().map_err(error)?;
|
||||
let child = Arc::new(Mutex::new(child));
|
||||
let outcome = (|| {
|
||||
let (input, output) = {
|
||||
let mut process = child.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
(process.stdin.take().ok_or("print helper has no input pipe")?, process.stdout.take().ok_or("print helper has no output pipe")?)
|
||||
};
|
||||
{
|
||||
let mut state = self.state();
|
||||
if matches!(*state, State::Cancelled) {
|
||||
return Err(CANCELLED.into());
|
||||
}
|
||||
*state = State::Waking(child.clone());
|
||||
}
|
||||
let mut output = BufReader::new(output);
|
||||
loop {
|
||||
let message = line(&mut output)?.ok_or("the print helper stopped before it was ready")?;
|
||||
let Some((_, json)) = message.split_once(PREFIX) else { continue };
|
||||
if !matches!(serde_json::from_str::<Event>(json).map_err(error)?, Event::Ready) {
|
||||
return Err("the print helper did not acknowledge startup".into());
|
||||
}
|
||||
break;
|
||||
}
|
||||
{
|
||||
let mut state = self.state();
|
||||
if matches!(*state, State::Cancelled) {
|
||||
return Err(CANCELLED.into());
|
||||
}
|
||||
*state = State::Running(input);
|
||||
if let State::Running(input) = &mut *state {
|
||||
input.write_all(b"start\n").and_then(|_| input.flush()).map_err(error)?;
|
||||
}
|
||||
}
|
||||
while let Some(line) = line(&mut output)? {
|
||||
let Some((_, json)) = line.split_once(PREFIX) else { continue };
|
||||
match serde_json::from_str::<Event>(json).map_err(error)? {
|
||||
Event::Ready => return Err("the print helper repeated its startup acknowledgement".into()),
|
||||
Event::Cancelled => {
|
||||
*self.state() = State::Cancelled;
|
||||
self.0.1.notify_all();
|
||||
return Err(CANCELLED.into());
|
||||
}
|
||||
Event::Sending => {
|
||||
let mut state = self.state();
|
||||
let previous = std::mem::replace(&mut *state, State::Finished);
|
||||
*state = match previous {
|
||||
State::Running(input) | State::Sending(input) => State::Sending(input),
|
||||
other => other,
|
||||
};
|
||||
self.0.1.notify_all();
|
||||
}
|
||||
Event::Done(result) => return result,
|
||||
}
|
||||
}
|
||||
Err("the print helper stopped without a receipt".into())
|
||||
})();
|
||||
// A cancelled child has acknowledged that it cannot send. Other outcomes are terminal
|
||||
// receipts or a broken protocol/pipe; reap the process and its private input files.
|
||||
if outcome.is_err() {
|
||||
let _ = child.lock().unwrap_or_else(std::sync::PoisonError::into_inner).kill();
|
||||
}
|
||||
let status = child.lock().unwrap_or_else(std::sync::PoisonError::into_inner).wait().map_err(error)?;
|
||||
if outcome.is_ok() && !status.success() {
|
||||
return Err("the print helper failed after its receipt".into());
|
||||
}
|
||||
outcome
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep helper transport failures distinct from a printer's own error message.
|
||||
fn error(error: impl std::fmt::Display) -> String {
|
||||
format!("print helper: {error}")
|
||||
}
|
||||
|
||||
/// Read at most one byte beyond the cap so oversized files fail without unbounded allocation.
|
||||
fn bounded(mut input: impl Read, limit: u64) -> Result<Vec<u8>, String> {
|
||||
let mut bytes = Vec::new();
|
||||
input.by_ref().take(limit.saturating_add(1)).read_to_end(&mut bytes).map_err(error)?;
|
||||
if bytes.len() as u64 > limit { Err("print helper input exceeds its limit".into()) } else { Ok(bytes) }
|
||||
}
|
||||
|
||||
/// Read one bounded UTF-8 protocol record, distinguishing EOF from an oversized message.
|
||||
fn line(input: &mut impl BufRead) -> Result<Option<String>, String> {
|
||||
let mut bytes = Vec::new();
|
||||
input.take(MAX_MESSAGE + 1).read_until(b'\n', &mut bytes).map_err(error)?;
|
||||
if bytes.len() as u64 > MAX_MESSAGE {
|
||||
return Err("print helper message exceeds its limit".into());
|
||||
}
|
||||
if bytes.is_empty() { Ok(None) } else { String::from_utf8(bytes).map(|s| Some(s.trim_end().to_owned())).map_err(error) }
|
||||
}
|
||||
|
||||
/// Serialize complete events under stdout's lock so the driver and listener cannot interleave.
|
||||
fn emit(event: &Event) -> Result<(), String> {
|
||||
let mut output = std::io::stdout().lock();
|
||||
write!(output, "{PREFIX}").map_err(error)?;
|
||||
serde_json::to_writer(&mut output, event).map_err(error)?;
|
||||
writeln!(output).and_then(|_| output.flush()).map_err(error)
|
||||
}
|
||||
|
||||
/// Remove both plaintext inputs even when one fails; already-removed inputs count as success.
|
||||
fn remove_inputs(directory: &Path) -> Result<(), String> {
|
||||
let mut first_error = None;
|
||||
for name in ["job.json", "input.pdf"] {
|
||||
if let Err(failure) = std::fs::remove_file(directory.join(name))
|
||||
&& failure.kind() != std::io::ErrorKind::NotFound
|
||||
{
|
||||
first_error.get_or_insert_with(|| error(format!("could not remove {name}: {failure}")));
|
||||
}
|
||||
}
|
||||
first_error.map_or(Ok(()), Err)
|
||||
}
|
||||
|
||||
/// Load and erase the private inputs before driver work, while an independent listener can cancel.
|
||||
fn helper(
|
||||
directory: &Path,
|
||||
cancel_request: impl Fn(&craft_print::Cancel) -> bool + Send + 'static,
|
||||
submit: impl FnOnce(&[u8], &craft_print::Job, &craft_print::Cancel) -> Result<String, String>,
|
||||
) -> Result<(), String> {
|
||||
let mut input = BufReader::new(std::io::stdin());
|
||||
emit(&Event::Ready)?;
|
||||
if line(&mut input)?.as_deref() != Some("start") {
|
||||
return Err("print helper requires a start acknowledgement".into());
|
||||
}
|
||||
let cancel = craft_print::Cancel::new();
|
||||
let listener = cancel.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("print-cancel".into())
|
||||
.spawn(move || {
|
||||
loop {
|
||||
match line(&mut input) {
|
||||
Ok(Some(command)) if command == "cancel" => {
|
||||
if cancel_request(&listener) {
|
||||
let _ = emit(&Event::Cancelled);
|
||||
std::process::exit(0);
|
||||
}
|
||||
if emit(&Event::Sending).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
// If the parent disappears, never leave a waiting driver process behind. Sending
|
||||
// jobs finish normally so the OS gets their pages even if the UI has gone away.
|
||||
if cancel_request(&listener) {
|
||||
std::process::exit(0);
|
||||
}
|
||||
})
|
||||
.map_err(error)?;
|
||||
let request: Request =
|
||||
serde_json::from_slice(&bounded(std::fs::File::open(directory.join("job.json")).map_err(error)?, MAX_MESSAGE)?).map_err(error)?;
|
||||
let pdf = bounded(std::fs::File::open(directory.join("input.pdf")).map_err(error)?, MAX_PDF)?;
|
||||
remove_inputs(directory)?;
|
||||
let result = submit(&pdf, &request.job(), &cancel);
|
||||
emit(&Event::Done(result))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
/// Handle only the private first-argument flag, before logging or GUI initialization.
|
||||
pub(crate) fn helper_mode() -> Option<i32> {
|
||||
let mut args = std::env::args_os().skip(1);
|
||||
if args.next().as_deref() != Some(std::ffi::OsStr::new(FLAG)) {
|
||||
return None;
|
||||
}
|
||||
let result = args.next().ok_or_else(|| "print helper directory missing".to_owned()).and_then(|dir| {
|
||||
helper(Path::new(&dir), craft_print::Cancel::cancel, |pdf, job, cancel| {
|
||||
craft_print::submit_cancellable(pdf, job, &mut |_, _| {}, cancel).map_err(error)
|
||||
})
|
||||
});
|
||||
if let Err(message) = &result {
|
||||
let _ = emit(&Event::Done(Err(message.clone())));
|
||||
}
|
||||
Some(i32::from(result.is_err()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A real test child exercises production pipes, token handling and process cleanup.
|
||||
#[test]
|
||||
#[ignore = "launched by the process protocol tests"]
|
||||
fn helper_process() {
|
||||
let Some(directory) = std::env::var_os("WORDCRAFT_TEST_PRINT_DIR") else { return };
|
||||
if std::env::var("WORDCRAFT_TEST_PRINT_MODE").as_deref() == Ok("waking") {
|
||||
std::fs::write(Path::new(&directory).join("waking"), b"starting").unwrap();
|
||||
loop {
|
||||
std::thread::park();
|
||||
}
|
||||
}
|
||||
let result = helper(
|
||||
Path::new(&directory),
|
||||
|token| {
|
||||
if std::env::var("WORDCRAFT_TEST_PRINT_MODE").as_deref() == Ok("sending") { false } else { token.cancel() }
|
||||
},
|
||||
|pdf, job, _token| {
|
||||
assert_eq!(pdf, b"synthetic PDF");
|
||||
assert_eq!(job.printer.as_deref(), Some("test queue"));
|
||||
match std::env::var("WORDCRAFT_TEST_PRINT_MODE").as_deref() {
|
||||
Ok("held") => {
|
||||
let _provider = Mutex::new(());
|
||||
let _held = _provider.lock().unwrap();
|
||||
std::fs::write(Path::new(&directory).join("held"), b"driver entered").unwrap();
|
||||
loop {
|
||||
std::thread::park();
|
||||
}
|
||||
}
|
||||
Ok("crash") => std::process::exit(7),
|
||||
Ok("sending") => {
|
||||
std::fs::write(Path::new(&directory).join("sending"), b"sending").unwrap();
|
||||
while !Path::new(&directory).join("release").exists() {
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
Ok("spooled synthetic job".into())
|
||||
}
|
||||
_ => Ok("spooled synthetic job".into()),
|
||||
}
|
||||
},
|
||||
);
|
||||
std::process::exit(i32::from(result.is_err()));
|
||||
}
|
||||
|
||||
/// Launch just the ignored helper entry, without invoking printer hardware.
|
||||
fn command(directory: &Path, mode: &str) -> std::io::Result<Command> {
|
||||
let mut command = Command::new(std::env::current_exe()?);
|
||||
let name = module_path!().split_once("::").map_or(module_path!(), |(_, name)| name);
|
||||
command.args(["--ignored", "--exact", &format!("{name}::helper_process"), "--nocapture"]);
|
||||
command.env("WORDCRAFT_TEST_PRINT_DIR", directory).env("WORDCRAFT_TEST_PRINT_MODE", mode);
|
||||
Ok(command)
|
||||
}
|
||||
|
||||
/// Give the child a literal printer identity to verify transport.
|
||||
fn job() -> craft_print::Job {
|
||||
craft_print::Job { printer: Some("test queue".into()), ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Cancelling before submission cannot launch a driver process.
|
||||
fn cancelled_start_never_launches_a_helper() {
|
||||
let submission = Submission::new();
|
||||
assert!(submission.cancel());
|
||||
let result = submission.submit_with(b"synthetic PDF", &job(), |_| Err(std::io::Error::other("must not launch")));
|
||||
assert_eq!(result, Err(CANCELLED.into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// A cancellation during creation withholds the start grant and removes private inputs.
|
||||
fn cancellation_during_startup_never_starts_the_driver() {
|
||||
let submission = Submission::new();
|
||||
let worker = submission.clone();
|
||||
let (started, starting) = std::sync::mpsc::channel();
|
||||
let (release, wait) = std::sync::mpsc::channel();
|
||||
let running = std::thread::spawn(move || {
|
||||
worker.submit_with(b"synthetic PDF", &job(), |directory| {
|
||||
started.send(directory.to_path_buf()).unwrap();
|
||||
wait.recv().unwrap();
|
||||
command(directory, "held")
|
||||
})
|
||||
});
|
||||
let directory = starting.recv_timeout(Duration::from_secs(10)).unwrap();
|
||||
assert!(submission.cancel());
|
||||
release.send(()).unwrap();
|
||||
assert_eq!(running.join().unwrap(), Err(CANCELLED.into()));
|
||||
assert!(!directory.exists(), "startup cancellation reaps the child and removes its inputs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// A hung startup has no permission to send and can be killed without a token ack.
|
||||
fn an_unresponsive_starting_process_can_be_stopped_without_an_acknowledgement() {
|
||||
let submission = Submission::new();
|
||||
let worker = submission.clone();
|
||||
let (path_tx, path_rx) = std::sync::mpsc::channel();
|
||||
let running = std::thread::spawn(move || {
|
||||
worker.submit_with(b"synthetic PDF", &job(), |dir| {
|
||||
path_tx.send(dir.to_path_buf()).unwrap();
|
||||
command(dir, "waking")
|
||||
})
|
||||
});
|
||||
let directory = path_rx.recv_timeout(Duration::from_secs(10)).unwrap();
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
while !directory.join("waking").exists() && std::time::Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
assert!(directory.join("waking").exists());
|
||||
assert!(submission.cancel(), "a process without a start grant cannot send anything");
|
||||
assert_eq!(running.join().unwrap(), Err(CANCELLED.into()));
|
||||
assert!(!directory.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Job transport preserves every explicit setting, including Unicode and duplex direction.
|
||||
fn job_options_survive_the_process_boundary() {
|
||||
let expected = craft_print::Job {
|
||||
printer: Some("Office λ".into()),
|
||||
copies: 3,
|
||||
collate: false,
|
||||
duplex: Some(craft_print::Duplex::ShortEdge),
|
||||
grayscale: true,
|
||||
scaling: craft_print::Scaling::ShrinkToFit,
|
||||
title: "A document".into(),
|
||||
options: vec![("media".into(), "iso_a4_210x297mm".into())],
|
||||
};
|
||||
let bytes = serde_json::to_vec(&Request::from_job(&expected)).unwrap();
|
||||
let received: Request = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(received.job(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Retry succeeds immediately after cancellation, before joining the old worker.
|
||||
fn cancelled_driver_does_not_block_the_next_process() {
|
||||
let submission = Submission::new();
|
||||
let worker = submission.clone();
|
||||
let (path_tx, path_rx) = std::sync::mpsc::channel();
|
||||
let running = std::thread::spawn(move || {
|
||||
worker.submit_with(b"synthetic PDF", &job(), |dir| {
|
||||
path_tx.send(dir.to_path_buf()).unwrap();
|
||||
command(dir, "held")
|
||||
})
|
||||
});
|
||||
let directory = path_rx.recv_timeout(Duration::from_secs(10)).unwrap();
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
while !directory.join("held").exists() && std::time::Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
assert!(directory.join("held").exists(), "the child is held inside its driver");
|
||||
assert!(!directory.join("input.pdf").exists(), "the stalled driver retains no plaintext PDF");
|
||||
assert!(!directory.join("job.json").exists(), "loaded settings were erased before driver work");
|
||||
assert!(submission.cancel(), "a stalled driver still acknowledges cancellation");
|
||||
let next = Submission::new();
|
||||
assert_eq!(next.submit_with(b"synthetic PDF", &job(), |dir| command(dir, "ready")), Ok("spooled synthetic job".into()));
|
||||
assert_eq!(running.join().unwrap(), Err(CANCELLED.into()));
|
||||
assert!(!directory.exists(), "the reaped helper's PDF and settings were removed");
|
||||
assert!(!next.cancel(), "a completed job keeps its receipt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// A refused cancellation keeps the sending process alive until its receipt.
|
||||
fn cancellation_refusal_preserves_the_sending_job() {
|
||||
let submission = Submission::new();
|
||||
let worker = submission.clone();
|
||||
let (path_tx, path_rx) = std::sync::mpsc::channel();
|
||||
let running = std::thread::spawn(move || {
|
||||
worker.submit_with(b"synthetic PDF", &job(), |dir| {
|
||||
path_tx.send(dir.to_path_buf()).unwrap();
|
||||
command(dir, "sending")
|
||||
})
|
||||
});
|
||||
let directory = path_rx.recv_timeout(Duration::from_secs(10)).unwrap();
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
while !directory.join("sending").exists() && std::time::Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
assert!(directory.join("sending").exists());
|
||||
assert!(!submission.cancel(), "a sending job refuses cancellation");
|
||||
assert!(matches!(*submission.state(), State::Sending(_)));
|
||||
assert!(!running.is_finished(), "the child is retained until it finishes sending");
|
||||
std::fs::write(directory.join("release"), b"continue").unwrap();
|
||||
assert_eq!(running.join().unwrap(), Ok("spooled synthetic job".into()));
|
||||
assert!(!directory.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Closing the parent's pipe stops a waiting driver and permits parent-side cleanup.
|
||||
fn parent_disconnect_stops_a_waiting_driver() {
|
||||
let submission = Submission::new();
|
||||
let worker = submission.clone();
|
||||
let (path_tx, path_rx) = std::sync::mpsc::channel();
|
||||
let running = std::thread::spawn(move || {
|
||||
worker.submit_with(b"synthetic PDF", &job(), |dir| {
|
||||
path_tx.send(dir.to_path_buf()).unwrap();
|
||||
command(dir, "held")
|
||||
})
|
||||
});
|
||||
let directory = path_rx.recv_timeout(Duration::from_secs(10)).unwrap();
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
while !directory.join("held").exists() && std::time::Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
assert!(directory.join("held").exists());
|
||||
*submission.state() = State::Finished; // Drops the parent's pipe as on process exit.
|
||||
assert_eq!(running.join().unwrap(), Err("the print helper stopped without a receipt".into()));
|
||||
assert!(!directory.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Helper failure removes its input directory; input limits reject oversize data.
|
||||
fn helper_crashes_are_errors_and_inputs_are_bounded() {
|
||||
let submission = Submission::new();
|
||||
let mut inputs = None;
|
||||
assert_eq!(
|
||||
submission.submit_with(b"synthetic PDF", &job(), |dir| {
|
||||
inputs = Some(dir.to_path_buf());
|
||||
command(dir, "crash")
|
||||
}),
|
||||
Err("the print helper stopped without a receipt".into())
|
||||
);
|
||||
assert!(!inputs.unwrap().exists(), "a helper crash removes the PDF and settings");
|
||||
assert_eq!(bounded(&b"abcd"[..], 3), Err("print helper input exceeds its limit".into()));
|
||||
assert_eq!(bounded(&b"abc"[..], 3), Ok(b"abc".to_vec()));
|
||||
}
|
||||
|
||||
/// Cleanup is repeatable and reports real failures while still attempting both inputs.
|
||||
#[test]
|
||||
fn input_cleanup_tolerates_missing_files_and_reports_failures() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
std::fs::write(directory.path().join("job.json"), b"settings").unwrap();
|
||||
std::fs::write(directory.path().join("input.pdf"), b"private PDF").unwrap();
|
||||
assert_eq!(remove_inputs(directory.path()), Ok(()));
|
||||
assert_eq!(remove_inputs(directory.path()), Ok(()));
|
||||
assert!(!directory.path().join("input.pdf").exists());
|
||||
std::fs::create_dir(directory.path().join("job.json")).unwrap();
|
||||
std::fs::write(directory.path().join("input.pdf"), b"private PDF").unwrap();
|
||||
let error = remove_inputs(directory.path()).unwrap_err();
|
||||
assert!(error.contains("could not remove job.json"), "{error}");
|
||||
assert!(!directory.path().join("input.pdf").exists(), "a settings cleanup failure does not retain the PDF");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//! A WordCraft started by an AppImage app inherits that app's `APPIMAGE`, which the self-update
|
||||
//! would take for this copy's file and replace (#5653). The real program, started both ways.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const NOTICE: &str = "ignoring the AppImage";
|
||||
|
||||
/// A folder of its own for one test, removed when it ends.
|
||||
struct Tmp(PathBuf);
|
||||
|
||||
impl Tmp {
|
||||
/// An empty folder named after `test` and this process, made afresh.
|
||||
fn new(test: &str) -> Tmp {
|
||||
let dir = std::env::temp_dir().join(format!("wordcraft-appimage-it-{test}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
Tmp(dir)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Tmp {
|
||||
/// Removes the folder and what the test left in it.
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the real app with `args`, as an AppImage app with `appimage` mounted at `appdir` would
|
||||
/// start it, and waits up to 30 s for it to exit (with no display it can't open a window).
|
||||
fn app(args: &[OsString], appimage: &Path, appdir: &Path) -> Output {
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_wordcraft"))
|
||||
.args(args)
|
||||
.env("APPIMAGE", appimage)
|
||||
.env("APPDIR", appdir)
|
||||
.env("ARGV0", "other-app")
|
||||
.env("OWD", "/")
|
||||
// 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();
|
||||
// A start-over that kept the variables would start over for ever, and end here.
|
||||
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");
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
child.wait_with_output().unwrap()
|
||||
}
|
||||
|
||||
/// The notice comes once: the program it started over as no longer sees the variables.
|
||||
#[test]
|
||||
fn another_programs_variables_are_dropped_once_and_the_program_carries_on() {
|
||||
let tmp = Tmp::new("dropped");
|
||||
let other = tmp.0.join("other-app.AppImage");
|
||||
std::fs::write(&other, b"another program").unwrap();
|
||||
let out = app(&["--version".into()], &other, &tmp.0);
|
||||
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}");
|
||||
assert_eq!(stderr.matches(NOTICE).count(), 1, "stderr: {stderr}");
|
||||
assert!(stderr.contains("other-app.AppImage"), "stderr: {stderr}");
|
||||
assert_eq!(std::fs::read(&other).unwrap(), b"another program");
|
||||
}
|
||||
|
||||
/// Arguments reach the program it starts over as byte for byte, even one that isn't Unicode.
|
||||
#[test]
|
||||
fn starting_over_keeps_the_arguments() {
|
||||
let tmp = Tmp::new("args");
|
||||
let out = app(&["--sample".into(), "--control".into(), OsString::from_vec(b"79\xe981".to_vec())], &tmp.0.join("gone"), &tmp.0);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert_eq!(out.status.code(), Some(2), "stderr: {stderr}");
|
||||
assert!(stderr.contains("argument 3 is not valid Unicode: 79\u{FFFD}81"), "stderr: {stderr}");
|
||||
}
|
||||
|
||||
/// Its own image's variables stay, or the self-update couldn't find the file to replace.
|
||||
#[test]
|
||||
fn its_own_images_variables_stay() {
|
||||
let exe = Path::new(env!("CARGO_BIN_EXE_wordcraft")).canonicalize().unwrap();
|
||||
let mount = exe.parent().unwrap();
|
||||
let out = app(&["--version".into()], &mount.join("WordCraft.AppImage"), mount);
|
||||
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}");
|
||||
assert!(!stderr.contains(NOTICE), "stderr: {stderr}");
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
#[cfg(target_os = "linux")]
|
||||
mod appimage_env;
|
||||
mod control_arg;
|
||||
mod help;
|
||||
#[cfg(unix)]
|
||||
|
||||
@@ -14,6 +14,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.SenuraMalaka]
|
||||
real_name = "Senura Dissanayake"
|
||||
sync_github_name = true
|
||||
|
||||
[people.divyanshu2074]
|
||||
real_name = "Divyanshu Gupta"
|
||||
sync_github_name = true
|
||||
@@ -35,7 +39,9 @@ sync_github_name = false
|
||||
real_name = "Saeed Azarehgar"
|
||||
|
||||
[people.vangel76]
|
||||
sync_github_name = true
|
||||
real_name = "Valentin Klein"
|
||||
display_name = "vangel76"
|
||||
sync_github_name = false
|
||||
|
||||
[people.dkhlapov]
|
||||
real_name = "Dmitrii Khlapov"
|
||||
@@ -160,6 +166,8 @@ sync_github_name = true
|
||||
|
||||
[people.maxm11]
|
||||
real_name = "Max Murphy"
|
||||
display_name = "Max Murphy"
|
||||
sync_github_name = false
|
||||
|
||||
[people.SlavenDj]
|
||||
sync_github_name = true
|
||||
@@ -187,3 +195,15 @@ sync_github_name = true
|
||||
|
||||
[people.dylanmtaylor]
|
||||
sync_github_name = true
|
||||
|
||||
[people.entropybeater]
|
||||
display_name = "entropybeater"
|
||||
|
||||
[people.helenkwok]
|
||||
real_name = "Helen Kwok"
|
||||
|
||||
[people.ashokram-si]
|
||||
real_name = "Ashok Ram"
|
||||
|
||||
[people.benandez]
|
||||
sync_github_name = true
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
059cbf95c2a3eb95d637ca8584faca1129d17f48
|
||||
e56d26244bbac099bfd42e23fb12f78a4be418a7
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! The control channel's key file, one rule for the app (which writes it) and the MCP bridge
|
||||
//! (which reads it): `<settings>/control-key.<instance>`. See `docs/control-protocol.md`, Keys.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
|
||||
@@ -10,11 +10,13 @@ workspace = true
|
||||
|
||||
[features]
|
||||
# Test inputs other crates' tests share (`cfb_guard::repeating_difat_sample`,
|
||||
# `zip_guard::zip64_entry_count_sample`); turned on only as a
|
||||
# dev-dependency, so never built into the app.
|
||||
# `zip_guard::zip64_entry_count_sample`) and the step count of
|
||||
# `work::count`; turned on only as a dev-dependency, so never built into
|
||||
# the app.
|
||||
test-support = []
|
||||
|
||||
[dependencies]
|
||||
craft-codepage = { workspace = true }
|
||||
wordcraft-geom = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
+19
-10
@@ -7,8 +7,9 @@
|
||||
/// makes it allocate gigabytes. Here the DIFAT is walked the way `cfb` walks it (header entries,
|
||||
/// then the DIFAT chain, at most one step per sector; trailing zero and free entries trimmed
|
||||
/// the same way) and the file is refused when it lists one FAT sector twice, or more FAT sectors
|
||||
/// than its sectors need (one FAT sector covers `sector_len / 4` of them, plus one spare), so the
|
||||
/// FAT `cfb` builds is about the size the file needs. Anything else that is wrong is left for
|
||||
/// than the file has sectors — every FAT sector is a sector of the file, so a longer list is
|
||||
/// duplicates or names `cfb` refuses anyway, and the FAT `cfb` builds (one sector of entries per
|
||||
/// listed name) stays within about the file's own size. Anything else that is wrong is left for
|
||||
/// `cfb` to report.
|
||||
pub fn check(bytes: &[u8]) -> Result<(), String> {
|
||||
const MAGIC: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
|
||||
@@ -52,12 +53,14 @@ pub fn check(bytes: &[u8]) -> Result<(), String> {
|
||||
while difat.last() == Some(&FREE) {
|
||||
difat.pop();
|
||||
}
|
||||
// Each listed FAT sector adds a whole sector of entries. A FAT sector holds the entries of
|
||||
// `sector_len / 4` sectors, so a file needs no more FAT sectors than that covers (one spare),
|
||||
// and none of them twice. A free entry in the middle is left for `cfb` to refuse.
|
||||
// Each listed FAT sector adds a whole sector of entries. A real file lists no more FAT
|
||||
// sectors than the file has — every FAT sector is a sector of the file, and none of them
|
||||
// twice (checked below) — so the FAT `cfb` builds stays within about the file's own size.
|
||||
// The #3642 blowup listed 2048 × 127 names in a ~1 MB file. A tighter bound (one FAT sector
|
||||
// per `sector_len / 4` sectors plus a spare) refused real files, whose writers reserve FAT
|
||||
// for growth: an encrypted package of 78 sectors lists 4 and didn't open (#5109).
|
||||
let listed: Vec<u32> = difat.into_iter().filter(|&s| s != FREE).collect();
|
||||
let needed = num_sectors.div_ceil(sector_len / 4).saturating_add(1);
|
||||
if listed.len() > needed {
|
||||
if listed.len() > num_sectors {
|
||||
return Err(format!("the DIFAT lists {} FAT sectors in a file of {num_sectors} sectors", listed.len()));
|
||||
}
|
||||
let mut seen = std::collections::HashSet::with_capacity(listed.len());
|
||||
@@ -123,10 +126,16 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fat_sector_listed_twice_or_more_than_the_file_needs_is_refused() {
|
||||
// Each list is shorter than the file has sectors, which the guard used to accept.
|
||||
fn a_fat_sector_listed_twice_or_more_than_the_file_has_is_refused() {
|
||||
// A duplicate, and a list longer than the file has sectors: every FAT sector is a
|
||||
// sector of the file, so a longer list is one of the two.
|
||||
assert!(super::check(&header_difat(10, &[0, 0])).is_err_and(|e| e.contains("FAT sector 0 twice")));
|
||||
assert!(super::check(&header_difat(10, &[0, 1, 2])).is_err_and(|e| e.contains("3 FAT sectors")));
|
||||
assert!(
|
||||
super::check(&header_difat(10, &[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10])).is_err_and(|e| e.contains("11 FAT sectors in a file of 10 sectors"))
|
||||
);
|
||||
// A writer may reserve FAT for growth: a real encrypted package of 78 sectors lists
|
||||
// 4 FAT sectors and didn't open since the #3642 guard landed (#5109).
|
||||
assert!(super::check(&header_difat(78, &[0, 1, 2, 3])).is_ok());
|
||||
assert!(super::check(&header_difat(10, &[0])).is_ok());
|
||||
assert!(super::check(&header_difat(200, &[0, 1])).is_ok());
|
||||
}
|
||||
|
||||
@@ -213,6 +213,12 @@ pub struct ShapeExtra {
|
||||
/// main colour (the shape's `stroke`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub outline_fill: Option<crate::fill::Fill>,
|
||||
/// An ODF custom shape's geometry as read (its `draw:enhanced-geometry`, equations and
|
||||
/// handles), whose path the shape's freeform was worked out from: saved back as it was, so
|
||||
/// LibreOffice draws its own shape (or a preset WordCraft draws as a rectangle: kept with
|
||||
/// it). Dropped when the shape is made another kind.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub odf_geometry: Option<std::sync::Arc<String>>,
|
||||
/// A horizontal rule (VML `o:hr`: a web page's `<hr>` pasted into Word): it takes its whole
|
||||
/// line, and is drawn across [`HorizontalRule::pct`] of it or at the shape's own width.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -384,7 +390,6 @@ pub fn end_direction(kind: ShapeKind, w: f32, h: f32, at_end: bool) -> (f32, f32
|
||||
(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
ShapeKind::Line => (w, -h),
|
||||
_ => (w, h),
|
||||
};
|
||||
let len = (dx * dx + dy * dy).sqrt();
|
||||
|
||||
@@ -254,6 +254,18 @@ pub struct ContentControl {
|
||||
/// `w:sdtEndPr`, kept verbatim (XML).
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub end_pr_xml: String,
|
||||
/// The size of the form control it came from (an ODF `draw:control`'s `svg:width` and
|
||||
/// `svg:height`), points: kept for saving back as one.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub form_size: Option<[f32; 2]>,
|
||||
/// How far that form control stands raised from the baseline (points, negative lowered:
|
||||
/// an ODF control hanging from the baseline is lowered by its height), kept for saving back.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub form_raise: Option<f32>,
|
||||
/// A form control WordCraft has no kind for (an ODF button, image button, label…): its form
|
||||
/// XML, kept verbatim and saved back as it was. It shows its label, locked.
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub form_xml: String,
|
||||
}
|
||||
|
||||
impl ContentControl {
|
||||
|
||||
@@ -7,7 +7,7 @@ use super::settings::{CoreProps, CustomProp, Settings};
|
||||
use super::text_walk::{TextMode, notes_and_boxes, story_word_count_of, text_of, walk};
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::{BTreeMap, CharProps, Document, InlineObject, Numbering, Paragraph, SectionProps, StyleSheet, Table, edit, numbering, para, section};
|
||||
use super::{BTreeMap, CharProps, Document, InlineObject, Paragraph, SectionProps, StyleSheet, Table, edit, numbering, para, section};
|
||||
|
||||
/// [`Document::media`] by content, so adding media is linear in the bytes added rather than a
|
||||
/// scan of every earlier image (CWE-407). Rebuilt whenever `media` changed size behind its back;
|
||||
@@ -62,8 +62,8 @@ impl Document {
|
||||
body: vec![para_block(Paragraph::new())],
|
||||
last_section: SectionProps::default(),
|
||||
parts: BTreeMap::new(),
|
||||
styles: StyleSheet::builtin(),
|
||||
numbering: Numbering::default(),
|
||||
styles: Arc::new(StyleSheet::builtin()),
|
||||
numbering: Arc::default(),
|
||||
comments: BTreeMap::new(),
|
||||
revisions: Vec::new(),
|
||||
settings: Settings::default(),
|
||||
@@ -76,6 +76,9 @@ impl Document {
|
||||
passthrough: BTreeMap::new(),
|
||||
glossary: Vec::new(),
|
||||
media_index: MediaIndex::default(),
|
||||
kept: Default::default(),
|
||||
missing_pictures: Default::default(),
|
||||
fit_state: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,6 +155,9 @@ impl Document {
|
||||
passthrough,
|
||||
glossary,
|
||||
media_index: _,
|
||||
kept: _,
|
||||
missing_pictures: _,
|
||||
fit_state: _,
|
||||
} = self;
|
||||
let Document {
|
||||
body: other_body,
|
||||
@@ -171,6 +177,9 @@ impl Document {
|
||||
passthrough: other_passthrough,
|
||||
glossary: other_glossary,
|
||||
media_index: _,
|
||||
kept: _,
|
||||
missing_pictures: _,
|
||||
fit_state: _,
|
||||
} = other;
|
||||
same_blocks(body, other_body)
|
||||
&& same_parts(parts, other_parts)
|
||||
@@ -210,7 +219,9 @@ impl Document {
|
||||
if !parent.len().is_multiple_of(3) {
|
||||
return None;
|
||||
}
|
||||
crate::work::steps(1);
|
||||
for ch in parent.chunks(3) {
|
||||
crate::work::steps(1);
|
||||
let [b, r, c] = ch else { return None };
|
||||
let Block::Table(t) = &**bl.get(*b as usize)? else { return None };
|
||||
bl = &t.rows.get(*r as usize)?.cells.get(*c as usize)?.blocks;
|
||||
@@ -734,6 +745,52 @@ impl Document {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Document::objects_mut`] for filling in what an object shows from what it is (a chart's
|
||||
/// drawing from its data), which doesn't change the document: the markup kept as read for
|
||||
/// the paragraphs and tables holding them (see [`crate::kept`]) follows them.
|
||||
pub fn fill_objects(&mut self, wants: &dyn Fn(&InlineObject) -> bool, f: &mut dyn FnMut(&mut InlineObject)) {
|
||||
// The stories (text boxes', notes') as read before, when filling changes one: they stay
|
||||
// as read after it, so the paragraphs showing them keep their markup.
|
||||
let holds = |b: &Arc<Block>| {
|
||||
let mut any = false;
|
||||
edit::each_para(b, 0, &mut |p| any |= p.objects.iter().any(wants));
|
||||
any
|
||||
};
|
||||
let as_read: Vec<u32> = match self.kept.get() {
|
||||
Some(k) if !k.story_fps.is_empty() && self.parts.values().any(|p| p.blocks.iter().any(holds)) => {
|
||||
k.story_fps.iter().filter(|(id, fp)| **fp == crate::kept::story_fingerprint(self, **id)).map(|(id, _)| *id).collect()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let mut moves: Vec<crate::kept::Moved> = Vec::new();
|
||||
let mut stories: Vec<&mut Blocks> = vec![&mut self.body];
|
||||
stories.extend(self.parts.values_mut().map(|p| &mut p.blocks));
|
||||
for blocks in stories {
|
||||
for b in blocks.iter_mut() {
|
||||
let mut any = false;
|
||||
edit::each_para(b, 0, &mut |p| any |= p.objects.iter().any(wants));
|
||||
if !any {
|
||||
continue;
|
||||
}
|
||||
let before = crate::kept::identities(b);
|
||||
edit::each_para_mut(Arc::make_mut(b), 0, &mut |p| {
|
||||
if p.objects.iter().any(wants) {
|
||||
p.objects.iter_mut().filter(|o| wants(o)).for_each(&mut *f);
|
||||
p.touch();
|
||||
}
|
||||
});
|
||||
moves.push((before, crate::kept::identities(b)));
|
||||
}
|
||||
}
|
||||
let refreshed: Vec<(u32, u64)> = as_read.into_iter().map(|id| (id, crate::kept::story_fingerprint(self, id))).collect();
|
||||
if let Some(k) = self.kept.get_mut() {
|
||||
for (before, after) in moves {
|
||||
k.follow(&before, &after);
|
||||
}
|
||||
k.story_fps.extend(refreshed);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
|
||||
+141
-12
@@ -20,6 +20,9 @@ pub struct Fragment {
|
||||
/// gives each box a new story made from these.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub parts: BTreeMap<u32, Part>,
|
||||
/// Media items referenced by objects in this fragment.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty", with = "crate::byte_store")]
|
||||
pub media: BTreeMap<String, Arc<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl Fragment {
|
||||
@@ -30,6 +33,8 @@ impl Fragment {
|
||||
pub fn to_document(&self) -> Document {
|
||||
let mut d = Document::new();
|
||||
d.body = self.blocks.iter().cloned().map(Arc::new).collect();
|
||||
d.parts = self.parts.clone();
|
||||
d.media = self.media.clone();
|
||||
d
|
||||
}
|
||||
/// Whether the fragment holds an equation (in tables too), which a copy also puts on the
|
||||
@@ -228,6 +233,11 @@ fn block_cost(b: &Block) -> (usize, bool) {
|
||||
.saturating_add(p.objects.len().saturating_mul(std::mem::size_of::<InlineObject>()));
|
||||
let objects = p.objects.iter().fold(0usize, |n, o| n.saturating_add(object_cost(o)));
|
||||
cost = cost.saturating_add(p.text.len()).saturating_add(items).saturating_add(objects);
|
||||
// The formatting a clone copies too: style ids, font names and the like in the runs,
|
||||
// the mark and the paragraph's properties.
|
||||
let runs = p.runs.iter().filter(|r| !r.props.is_empty()).fold(0usize, |n, r| n.saturating_add(serialized_len(&r.props)));
|
||||
let props = serialized_len(&p.props).saturating_add(serialized_len(&p.mark)).saturating_add(p.section.as_ref().map_or(0, serialized_len));
|
||||
cost = cost.saturating_add(runs).saturating_add(props);
|
||||
boxes |= box_ids(p).next().is_some();
|
||||
});
|
||||
(cost, boxes)
|
||||
@@ -555,6 +565,44 @@ fn box_ids(p: &Paragraph) -> impl Iterator<Item = u32> + '_ {
|
||||
p.objects.iter().flat_map(InlineObject::text_boxes)
|
||||
}
|
||||
|
||||
/// Remap media references in an inline object.
|
||||
fn remap_media_object(o: &mut InlineObject, map: &BTreeMap<String, String>, depth: usize) {
|
||||
match o {
|
||||
InlineObject::Image { media, .. } => {
|
||||
if let Some(k) = map.get(media.as_str()) {
|
||||
*media = k.clone();
|
||||
}
|
||||
}
|
||||
InlineObject::Graphic { graphic, .. } => {
|
||||
let mapped = |m: &str| map.get(m).filter(|k| k.as_str() != m);
|
||||
if graphic.items.iter().any(|i| matches!(i, crate::graphic::GraphicItem::Image { media, .. } if mapped(media).is_some())) {
|
||||
for i in &mut Arc::make_mut(graphic).items {
|
||||
if let crate::graphic::GraphicItem::Image { media, .. } = i
|
||||
&& let Some(k) = map.get(media.as_str())
|
||||
{
|
||||
*media = k.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
InlineObject::Group { children, .. } if depth < MAX_BOX_DEPTH => {
|
||||
for child in children {
|
||||
remap_media_object(&mut child.obj, map, depth + 1);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remap media references in a block.
|
||||
fn remap_media_block(b: &mut Block, map: &BTreeMap<String, String>) {
|
||||
each_para_mut(b, 0, &mut |p| {
|
||||
for o in &mut p.objects {
|
||||
remap_media_object(o, map, 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Convert notes between footnotes and endnotes: footnotes (`foot`) become endnotes and
|
||||
/// endnotes (`end`) footnotes, both at once to swap them. Each note's kind changes, as do its
|
||||
@@ -604,8 +652,10 @@ impl Document {
|
||||
let mut at = at.into_iter().peekable();
|
||||
let mut start = 0usize;
|
||||
for r in &mut p.runs {
|
||||
crate::work::steps(1);
|
||||
let end = start.saturating_add(r.len);
|
||||
while at.peek().is_some_and(|&o| o < start) {
|
||||
crate::work::steps(1);
|
||||
at.next();
|
||||
}
|
||||
if at.peek().is_some_and(|&o| o < end) {
|
||||
@@ -765,6 +815,10 @@ impl Document {
|
||||
if a.path.parent() == b.path.parent() {
|
||||
// Same container: trim the ends, drop the blocks between, join.
|
||||
let (ia, ib) = (a.path.last(), b.path.last());
|
||||
// Removing an already-empty paragraph must not impose its style or mark formatting
|
||||
// on the following content. Test before trimming: deleting actual heading text is
|
||||
// still an ordinary merge that keeps the first paragraph's properties.
|
||||
let empty_start = self.para(a.story, &a.path).is_some_and(Paragraph::is_empty);
|
||||
// Kept markers of the paragraphs in between move to the join.
|
||||
let mut moved: Vec<(InlineObject, CharProps)> = Vec::new();
|
||||
// `kept` is sorted: one walk of each paragraph's objects and runs (#3614).
|
||||
@@ -812,15 +866,18 @@ impl Document {
|
||||
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();
|
||||
let keep_props = if empty_start { tail.props.clone() } else { pa.props.clone() };
|
||||
let keep_mark = empty_start.then(|| tail.mark.clone());
|
||||
pa.append(tail);
|
||||
pa.props = keep_props;
|
||||
if let Some(mark) = keep_mark {
|
||||
pa.mark = mark;
|
||||
}
|
||||
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.
|
||||
@@ -960,7 +1017,8 @@ impl Document {
|
||||
// Half a content control (one marker without the other) isn't copied.
|
||||
crate::control::balance_blocks(&mut blocks);
|
||||
let parts = self.text_box_parts(&blocks);
|
||||
Fragment { blocks, parts }
|
||||
let media = self.media_of_blocks(&blocks);
|
||||
Fragment { blocks, parts, media }
|
||||
}
|
||||
|
||||
/// A copy of the block at `path` (a whole table, say) as a fragment, with its text boxes.
|
||||
@@ -982,7 +1040,8 @@ impl Document {
|
||||
fn fragment_of(&self, mut blocks: Vec<Block>) -> Fragment {
|
||||
crate::control::balance_blocks(&mut blocks);
|
||||
let parts = self.text_box_parts(&blocks);
|
||||
Fragment { blocks, parts }
|
||||
let media = self.media_of_blocks(&blocks);
|
||||
Fragment { blocks, parts, media }
|
||||
}
|
||||
|
||||
/// The table whose whole text `a..b` covers when the range runs across its cells: from the
|
||||
@@ -1024,7 +1083,8 @@ impl Document {
|
||||
pub fn body_fragment(&self) -> Fragment {
|
||||
let blocks: Vec<Block> = self.body.iter().map(|b| (**b).clone()).collect();
|
||||
let parts = self.text_box_parts(&blocks);
|
||||
Fragment { blocks, parts }
|
||||
let media = self.media_of_blocks(&blocks);
|
||||
Fragment { blocks, parts, media }
|
||||
}
|
||||
|
||||
/// Another document's body as a fragment of this one, to insert: the media, lists, styles,
|
||||
@@ -1049,7 +1109,7 @@ impl Document {
|
||||
let mut default_style = None;
|
||||
if keep_styles {
|
||||
if src.styles.get("Normal").is_none() {
|
||||
src.styles.upsert(crate::styles::Style { id: "Normal".into(), name: "Normal".into(), ..Default::default() });
|
||||
Arc::make_mut(&mut src.styles).upsert(crate::styles::Style { id: "Normal".into(), name: "Normal".into(), ..Default::default() });
|
||||
}
|
||||
// Ids given out here are taken too: two styles never get the same new one.
|
||||
let mut given = std::collections::HashSet::new();
|
||||
@@ -1084,7 +1144,7 @@ impl Document {
|
||||
abs_map.insert(a.id, id);
|
||||
let mut a = crate::numbering::AbstractNum { id, ..a.clone() };
|
||||
a.levels.iter_mut().for_each(|l| restyle(&mut l.style));
|
||||
self.numbering.abstracts.push(a);
|
||||
Arc::make_mut(&mut self.numbering).abstracts.push(a);
|
||||
}
|
||||
let next_num = self.numbering.nums.iter().map(|n| n.id.saturating_add(1)).max().unwrap_or(1).max(1);
|
||||
let mut nums = BTreeMap::new();
|
||||
@@ -1094,7 +1154,7 @@ impl Document {
|
||||
let abstract_id = abs_map.get(&n.abstract_id).copied().unwrap_or(n.abstract_id);
|
||||
let mut n = crate::numbering::Num { id, abstract_id, ..n.clone() };
|
||||
n.level_overrides.iter_mut().for_each(|(_, l)| restyle(&mut l.style));
|
||||
self.numbering.nums.push(n);
|
||||
Arc::make_mut(&mut self.numbering).nums.push(n);
|
||||
}
|
||||
let renumber = |n: &mut Option<crate::props::NumRef>| {
|
||||
if let Some(n) = n.as_mut()
|
||||
@@ -1133,7 +1193,7 @@ impl Document {
|
||||
}
|
||||
if self.styles.get(&st.id).is_none() {
|
||||
renumber(&mut st.para.numbering);
|
||||
self.styles.upsert(st);
|
||||
Arc::make_mut(&mut self.styles).upsert(st);
|
||||
}
|
||||
}
|
||||
let rev = u32::try_from(self.revisions.len()).unwrap_or(u32::MAX);
|
||||
@@ -1193,7 +1253,7 @@ impl Document {
|
||||
}
|
||||
let mut blocks: Vec<Block> = src.body.iter().map(|b| (**b).clone()).collect();
|
||||
blocks.iter_mut().for_each(|b| link.block(b));
|
||||
Fragment { blocks, parts }
|
||||
Fragment { blocks, parts, media: self.media.clone() }
|
||||
}
|
||||
|
||||
/// [`Self::adopt_with`] as blocks to place in a story directly (a reader building one): its
|
||||
@@ -1201,7 +1261,7 @@ impl Document {
|
||||
/// id the reader hasn't given out (ranges it holds aren't in this document yet); it moves past
|
||||
/// the adopted ones.
|
||||
pub fn adopt_blocks(&mut self, src: Document, keep_styles: bool, perms: &mut u32) -> Vec<Block> {
|
||||
let Fragment { mut blocks, parts } = self.adopt_in(src, keep_styles, perms);
|
||||
let Fragment { mut blocks, parts, .. } = self.adopt_in(src, keep_styles, perms);
|
||||
let mut budget = BoxBudget::new();
|
||||
self.adopt_text_boxes(&mut blocks, &parts, 0, &mut budget);
|
||||
blocks
|
||||
@@ -1227,6 +1287,52 @@ impl Document {
|
||||
out
|
||||
}
|
||||
|
||||
/// Media items referenced by objects in `blocks`, nested text box stories included.
|
||||
pub fn media_of_blocks(&self, blocks: &[Block]) -> BTreeMap<String, Arc<Vec<u8>>> {
|
||||
let mut keys = std::collections::BTreeSet::new();
|
||||
/// Collect media keys from an inline object recursively.
|
||||
fn collect_object(o: &InlineObject, keys: &mut std::collections::BTreeSet<String>, depth: usize) {
|
||||
match o {
|
||||
InlineObject::Image { media, .. } => {
|
||||
keys.insert(media.clone());
|
||||
}
|
||||
InlineObject::Graphic { graphic, .. } => {
|
||||
for item in &graphic.items {
|
||||
if let crate::graphic::GraphicItem::Image { media, .. } = item {
|
||||
keys.insert(media.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
InlineObject::Group { children, .. } if depth < MAX_BOX_DEPTH => {
|
||||
for child in children {
|
||||
collect_object(&child.obj, keys, depth + 1);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut collect_para = |p: &Paragraph| {
|
||||
for o in &p.objects {
|
||||
collect_object(o, &mut keys, 0);
|
||||
}
|
||||
};
|
||||
for b in blocks {
|
||||
each_para(b, 0, &mut collect_para);
|
||||
}
|
||||
for part in self.text_box_parts(blocks).values() {
|
||||
for b in &part.blocks {
|
||||
each_para(b, 0, &mut collect_para);
|
||||
}
|
||||
}
|
||||
let mut out = BTreeMap::new();
|
||||
for k in keys {
|
||||
if let Some(data) = self.media.get(&k) {
|
||||
out.insert(k, data.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn copy_blocks(&self, a: &Pos, b: &Pos) -> Vec<Block> {
|
||||
let (a, b) = order(a, b);
|
||||
let mut out = Vec::new();
|
||||
@@ -1375,6 +1481,18 @@ impl Document {
|
||||
|
||||
/// Insert a fragment at `pos`; returns the position after the pasted content.
|
||||
pub fn insert_fragment(&mut self, pos: &Pos, frag: &Fragment) -> Result<Pos> {
|
||||
let mut media_remap = BTreeMap::new();
|
||||
for (k, v) in &frag.media {
|
||||
if let Some(existing) = self.media.get(k) {
|
||||
if existing.as_slice() != v.as_slice() {
|
||||
let ext = k.rsplit_once('.').map_or("png", |(_, ext)| ext);
|
||||
let new_key = self.add_media((**v).clone(), ext);
|
||||
media_remap.insert(k.clone(), new_key);
|
||||
}
|
||||
} else {
|
||||
self.media.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
// Tables that would nest deeper than the limit here become their paragraphs (#1596).
|
||||
let room = MAX_TABLE_DEPTH.saturating_sub(pos.path.depth());
|
||||
let mut blocks = Vec::with_capacity(frag.blocks.len());
|
||||
@@ -1387,9 +1505,20 @@ impl Document {
|
||||
if self.para_at(pos).is_none() {
|
||||
return Err(DocError::BadPath(pos.path.to_string()));
|
||||
}
|
||||
let mut parts = frag.parts.clone();
|
||||
if !media_remap.is_empty() {
|
||||
for b in &mut blocks {
|
||||
remap_media_block(b, &media_remap);
|
||||
}
|
||||
for part in parts.values_mut() {
|
||||
for b in &mut part.blocks {
|
||||
remap_media_block(Arc::make_mut(b), &media_remap);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Pasted text boxes get stories of their own (never shared with the copy's source).
|
||||
let mut budget = BoxBudget::new();
|
||||
self.adopt_text_boxes(&mut blocks, &frag.parts, 0, &mut budget);
|
||||
self.adopt_text_boxes(&mut blocks, &parts, 0, &mut budget);
|
||||
if budget.skipped > 0 {
|
||||
// Once per paste, not once per box (review of #3874).
|
||||
log::warn!(
|
||||
|
||||
+117
-20
@@ -326,7 +326,7 @@ fn fan_out(story: Vec<Arc<Block>>, refs: usize) -> Fragment {
|
||||
}
|
||||
let mut parts = BTreeMap::new();
|
||||
parts.insert(1, crate::Part { kind: PartKind::TextBox, blocks: story, body: Default::default() });
|
||||
Fragment { blocks: vec![Block::Para(p)], parts }
|
||||
Fragment { blocks: vec![Block::Para(p)], parts, ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -415,6 +415,24 @@ fn pasted_box_copies_are_capped_in_bytes() {
|
||||
assert_eq!(got.iter().flatten().count(), added);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasted_box_copies_count_the_formatting_they_copy() {
|
||||
// A story holding a text box and a run whose font name is 1 MiB: each copy clones the
|
||||
// formatting too, so 64 copies used to copy 64 MiB of it past the 32 MiB cap.
|
||||
let props = CharProps { font: Some("f".repeat(1 << 20)), ..CharProps::default() };
|
||||
let mut p = Paragraph::with_text("x", props);
|
||||
p.insert_object(0, box_shape(Some(999)), &CharProps::default()).unwrap();
|
||||
let f = fan_out(vec![para_block(p)], 64);
|
||||
let mut d = doc(&["x"]);
|
||||
let before = d.parts.len();
|
||||
d.insert_fragment(&Pos::body(0, 1), &f).unwrap();
|
||||
let added = d.parts.len() - before;
|
||||
let font = |p: &Paragraph| p.runs.iter().map(|r| r.props.font.as_ref().map_or(0, String::len)).sum::<usize>();
|
||||
let copied: usize = d.parts.values().flat_map(|p| p.blocks.iter()).map(|b| if let Block::Para(p) = &**b { font(p) } else { 0 }).sum();
|
||||
assert!((1..64).contains(&added), "{added}");
|
||||
assert!(copied <= MAX_PASTED_BOX_BYTES, "{copied}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasted_box_copies_count_the_objects_they_copy() {
|
||||
// #3614 review: a story holding a text box and a 1 MiB kept-for-round-trip object costs
|
||||
@@ -645,13 +663,15 @@ fn removing_a_whole_table_keeps_a_control_the_range_only_partly_covers() {
|
||||
|
||||
#[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.
|
||||
// The deepest nesting allowed, each table 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.
|
||||
const { assert!(MAX_TABLE_DEPTH > crate::PARA_WALK_DEPTH) };
|
||||
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 {
|
||||
for _ in 1..MAX_TABLE_DEPTH {
|
||||
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)));
|
||||
@@ -672,19 +692,19 @@ fn adopting_a_document_renumbers_what_it_refers_to() {
|
||||
let mut ours = doc(&["ours"]);
|
||||
let our_note = ours.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("our note", CharProps::default()))]);
|
||||
ours.comments.insert(0, Comment { part: our_note, ..Comment::default() });
|
||||
let our_list = ours.numbering.add_list(crate::numbering::ListKind::Bullet);
|
||||
let our_list = Arc::make_mut(&mut ours.numbering).add_list(crate::numbering::ListKind::Bullet);
|
||||
ours.revisions.push(Revision { kind: RevisionKind::Insert, ..Revision::default() });
|
||||
|
||||
let mut theirs = doc(&["theirs"]);
|
||||
let note = theirs.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("their note", CharProps::default()))]);
|
||||
let said = theirs.add_part(PartKind::Comment, vec![para_block(Paragraph::with_text("their comment", CharProps::default()))]);
|
||||
theirs.comments.insert(0, Comment { author: "Them".into(), part: said, durable_id: Some("0A0B0C0D".into()), ..Comment::default() });
|
||||
let list = theirs.numbering.add_list(crate::numbering::ListKind::Bullet);
|
||||
let list = Arc::make_mut(&mut theirs.numbering).add_list(crate::numbering::ListKind::Bullet);
|
||||
assert_eq!(list, our_list);
|
||||
theirs.revisions.push(Revision { kind: RevisionKind::Delete, ..Revision::default() });
|
||||
let mut style = crate::styles::Style { id: "TheirList".into(), name: "Their List".into(), ..Default::default() };
|
||||
style.para.numbering = Some(crate::props::NumRef { num: list, level: 0 });
|
||||
theirs.styles.upsert(style);
|
||||
Arc::make_mut(&mut theirs.styles).upsert(style);
|
||||
let p = theirs.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
p.props.numbering = Some(crate::props::NumRef { num: list, level: 0 });
|
||||
p.runs[0].props.del = Some(0);
|
||||
@@ -728,11 +748,11 @@ fn a_kept_character_style_leaves_what_it_doesnt_set_to_the_paragraph_style() {
|
||||
use crate::styles::{Style, StyleKind, StyleSheet};
|
||||
let mut ours = doc(&["ours"]);
|
||||
let mut theirs = doc(&["theirs"]);
|
||||
theirs.styles = StyleSheet::empty();
|
||||
theirs.styles.default_chr = CharProps { size: Some(9.0), ..Default::default() };
|
||||
theirs.styles = Arc::new(StyleSheet::empty());
|
||||
Arc::make_mut(&mut theirs.styles).default_chr = CharProps { size: Some(9.0), ..Default::default() };
|
||||
let big = CharProps { size: Some(20.0), ..Default::default() };
|
||||
theirs.styles.upsert(Style { id: "Big".into(), name: "Big".into(), chr: big, ..Default::default() });
|
||||
theirs.styles.upsert(Style { id: "Link".into(), name: "Link".into(), kind: StyleKind::Character, ..Default::default() });
|
||||
Arc::make_mut(&mut theirs.styles).upsert(Style { id: "Big".into(), name: "Big".into(), chr: big, ..Default::default() });
|
||||
Arc::make_mut(&mut theirs.styles).upsert(Style { id: "Link".into(), name: "Link".into(), kind: StyleKind::Character, ..Default::default() });
|
||||
let p = theirs.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
p.props.style = Some("Big".into());
|
||||
p.runs[0].props.style = Some("Link".into());
|
||||
@@ -746,15 +766,15 @@ fn a_kept_character_style_leaves_what_it_doesnt_set_to_the_paragraph_style() {
|
||||
fn kept_styles_renamed_on_adoption_are_renamed_in_list_levels_too() {
|
||||
use crate::styles::Style;
|
||||
let mut ours = doc(&["ours"]);
|
||||
ours.styles.upsert(Style { id: "Heading1".into(), name: "heading 1".into(), ..Default::default() });
|
||||
Arc::make_mut(&mut ours.styles).upsert(Style { id: "Heading1".into(), name: "heading 1".into(), ..Default::default() });
|
||||
let mut theirs = doc(&["theirs"]);
|
||||
theirs.styles.upsert(Style { id: "Heading1".into(), name: "heading 1".into(), ..Default::default() });
|
||||
let list = theirs.numbering.add_list(crate::numbering::ListKind::Numbered);
|
||||
Arc::make_mut(&mut theirs.styles).upsert(Style { id: "Heading1".into(), name: "heading 1".into(), ..Default::default() });
|
||||
let list = Arc::make_mut(&mut theirs.numbering).add_list(crate::numbering::ListKind::Numbered);
|
||||
let abs = theirs.numbering.nums.iter().find(|n| n.id == list).map(|n| n.abstract_id).unwrap();
|
||||
let a = theirs.numbering.abstracts.iter_mut().find(|a| a.id == abs).unwrap();
|
||||
let a = Arc::make_mut(&mut theirs.numbering).abstracts.iter_mut().find(|a| a.id == abs).unwrap();
|
||||
a.levels[0].style = Some("Heading1".into());
|
||||
let level = a.levels[0].clone();
|
||||
theirs.numbering.nums.iter_mut().find(|n| n.id == list).unwrap().level_overrides.push((1, level));
|
||||
Arc::make_mut(&mut theirs.numbering).nums.iter_mut().find(|n| n.id == list).unwrap().level_overrides.push((1, level));
|
||||
|
||||
let before = ours.numbering.abstracts.len();
|
||||
ours.adopt_with(theirs, true);
|
||||
@@ -794,10 +814,10 @@ fn an_adopted_tracked_format_change_keeps_its_old_styles_and_list() {
|
||||
use crate::{Revision, RevisionKind};
|
||||
for keep in [false, true] {
|
||||
let mut ours = doc(&["ours"]);
|
||||
let our_list = ours.numbering.add_list(crate::numbering::ListKind::Bullet);
|
||||
let our_list = Arc::make_mut(&mut ours.numbering).add_list(crate::numbering::ListKind::Bullet);
|
||||
let mut theirs = doc(&["theirs"]);
|
||||
theirs.revisions.push(Revision { kind: RevisionKind::Format, ..Revision::default() });
|
||||
let list = theirs.numbering.add_list(crate::numbering::ListKind::Bullet);
|
||||
let list = Arc::make_mut(&mut theirs.numbering).add_list(crate::numbering::ListKind::Bullet);
|
||||
assert_eq!(list, our_list);
|
||||
let p = theirs.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
// Live and old props name the same styles (ours has them too) and their list.
|
||||
@@ -825,8 +845,8 @@ fn adopted_styles_never_become_our_defaults() {
|
||||
let mut ours = doc(&["ours"]);
|
||||
let mut theirs = doc(&["theirs"]);
|
||||
// A localized default character style, and one whose id we have too.
|
||||
theirs.styles.upsert(Style { id: "Absatz".into(), kind: StyleKind::Character, default: true, ..Default::default() });
|
||||
theirs.styles.upsert(Style { id: "Strong".into(), kind: StyleKind::Character, default: true, ..Default::default() });
|
||||
Arc::make_mut(&mut theirs.styles).upsert(Style { id: "Absatz".into(), kind: StyleKind::Character, default: true, ..Default::default() });
|
||||
Arc::make_mut(&mut theirs.styles).upsert(Style { id: "Strong".into(), kind: StyleKind::Character, default: true, ..Default::default() });
|
||||
ours.adopt_with(theirs, keep);
|
||||
assert!(ours.styles.styles.iter().all(|s| !s.default), "keep_styles {keep}");
|
||||
}
|
||||
@@ -891,3 +911,80 @@ fn an_adopted_notes_text_box_gets_a_story_of_its_own() {
|
||||
assert_eq!(part.kind, PartKind::TextBox);
|
||||
assert!(matches!(part.blocks.first().map(|b| &**b), Some(Block::Para(p)) if p.text == "their box"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_of_blocks_collects_graphic_and_group_images() {
|
||||
let mut d = doc(&["test"]);
|
||||
d.media.insert("graphic_img.png".into(), Arc::new(vec![1, 2, 3]));
|
||||
d.media.insert("group_img.png".into(), Arc::new(vec![4, 5, 6]));
|
||||
|
||||
let graphic = crate::graphic::Graphic {
|
||||
kind: crate::graphic::GraphicKind::Diagram,
|
||||
items: vec![crate::graphic::GraphicItem::Image { rect: [0.0, 0.0, 10.0, 10.0], media: "graphic_img.png".into() }],
|
||||
..Default::default()
|
||||
};
|
||||
let group = InlineObject::Group {
|
||||
w: 50.0,
|
||||
h: 50.0,
|
||||
float: Default::default(),
|
||||
ch_w: 50.0,
|
||||
ch_h: 50.0,
|
||||
canvas: None,
|
||||
children: vec![crate::para::GroupChild {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
obj: InlineObject::Image {
|
||||
media: "group_img.png".into(),
|
||||
w: 10.0,
|
||||
h: 10.0,
|
||||
alt: String::new(),
|
||||
float: Default::default(),
|
||||
crop: [0.0; 4],
|
||||
fx: Default::default(),
|
||||
ole: None,
|
||||
},
|
||||
}],
|
||||
};
|
||||
let mut p = Paragraph::with_text(&crate::para::OBJ.to_string(), CharProps::default());
|
||||
p.objects.push(InlineObject::Graphic { w: 100.0, h: 100.0, float: Default::default(), alt: String::new(), graphic: Arc::new(graphic) });
|
||||
p.objects.push(group);
|
||||
let blocks = vec![Block::Para(p)];
|
||||
let collected = d.media_of_blocks(&blocks);
|
||||
assert!(collected.contains_key("graphic_img.png"));
|
||||
assert!(collected.contains_key("group_img.png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_fragment_remaps_conflicting_media_keys() {
|
||||
let mut d = doc(&["destination"]);
|
||||
d.media.insert("image1.png".into(), Arc::new(vec![1, 1, 1]));
|
||||
|
||||
let mut frag_media = std::collections::BTreeMap::new();
|
||||
frag_media.insert("image1.png".into(), Arc::new(vec![2, 2, 2]));
|
||||
|
||||
let mut p = Paragraph::with_text(&crate::para::OBJ.to_string(), CharProps::default());
|
||||
p.objects.push(InlineObject::Image {
|
||||
media: "image1.png".into(),
|
||||
w: 10.0,
|
||||
h: 10.0,
|
||||
alt: String::new(),
|
||||
float: Default::default(),
|
||||
crop: [0.0; 4],
|
||||
fx: Default::default(),
|
||||
ole: None,
|
||||
});
|
||||
let frag = Fragment { blocks: vec![Block::Para(p)], parts: Default::default(), media: frag_media };
|
||||
|
||||
let pos = Pos::body(0, 0);
|
||||
d.insert_fragment(&pos, &frag).unwrap();
|
||||
// The original destination media is preserved.
|
||||
assert_eq!(d.media["image1.png"].as_slice(), &[1, 1, 1]);
|
||||
// A second media key was created for the conflicting fragment media.
|
||||
assert_eq!(d.media.len(), 2);
|
||||
let other_key = d.media.keys().find(|k| k.as_str() != "image1.png").unwrap();
|
||||
assert_eq!(d.media[other_key].as_slice(), &[2, 2, 2]);
|
||||
// The inserted paragraph references the remapped key.
|
||||
let para = d.para(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
let remapped = para.objects.iter().find_map(|o| if let InlineObject::Image { media, .. } = o { Some(media.as_str()) } else { None }).unwrap();
|
||||
assert_eq!(remapped, other_key);
|
||||
}
|
||||
|
||||
+44
-337
@@ -1,345 +1,52 @@
|
||||
//! 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), Mac OS Roman
|
||||
//! (10000, RTF `\mac`), and the East Asian multibyte code pages (932 Shift-JIS, 936 GBK, 949 UHC,
|
||||
//! 950 Big5), whose bytes decode as runs rather than one at a time.
|
||||
//! Legacy character decoding delegates to craft-codepage; document fallback policy stays here.
|
||||
pub use craft_codepage::{
|
||||
MAC_ROMAN_CODE_PAGE, MULTIBYTE_CODE_PAGES, OEM_CODE_PAGES, SINGLE_BYTE_CODE_PAGES, cp1252, is_multibyte_code_page, multibyte_encoding_name,
|
||||
oem_byte, windows_byte,
|
||||
};
|
||||
|
||||
/// Windows-1252 code points for 0x80..=0x9F.
|
||||
const CP1252: [u16; 32] = [
|
||||
0x20AC, 0x0081, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0x008D, 0x017D, 0x008F, 0x0090, 0x2018,
|
||||
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0x009D, 0x017E, 0x0178,
|
||||
];
|
||||
|
||||
/// One Windows-1252 byte as a char.
|
||||
pub fn cp1252(b: u8) -> char {
|
||||
if (0x80..0xA0).contains(&b) {
|
||||
CP1252.get((b - 0x80) as usize).and_then(|u| char::from_u32(u32::from(*u))).unwrap_or('\u{FFFD}')
|
||||
} else {
|
||||
b as char
|
||||
}
|
||||
/// Word's supported single-byte document code pages (symbol-font policy is separate).
|
||||
pub fn is_byte_code_page(cp: u16) -> bool {
|
||||
SINGLE_BYTE_CODE_PAGES.contains(&cp) || OEM_CODE_PAGES.contains(&cp) || cp == MAC_ROMAN_CODE_PAGE
|
||||
}
|
||||
|
||||
/// Windows-874 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
|
||||
const CP874: [u16; 128] = [
|
||||
0x20AC, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2026, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2018,
|
||||
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x00A0, 0x0E01, 0x0E02, 0x0E03,
|
||||
0x0E04, 0x0E05, 0x0E06, 0x0E07, 0x0E08, 0x0E09, 0x0E0A, 0x0E0B, 0x0E0C, 0x0E0D, 0x0E0E, 0x0E0F, 0x0E10, 0x0E11, 0x0E12, 0x0E13, 0x0E14, 0x0E15,
|
||||
0x0E16, 0x0E17, 0x0E18, 0x0E19, 0x0E1A, 0x0E1B, 0x0E1C, 0x0E1D, 0x0E1E, 0x0E1F, 0x0E20, 0x0E21, 0x0E22, 0x0E23, 0x0E24, 0x0E25, 0x0E26, 0x0E27,
|
||||
0x0E28, 0x0E29, 0x0E2A, 0x0E2B, 0x0E2C, 0x0E2D, 0x0E2E, 0x0E2F, 0x0E30, 0x0E31, 0x0E32, 0x0E33, 0x0E34, 0x0E35, 0x0E36, 0x0E37, 0x0E38, 0x0E39,
|
||||
0x0E3A, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x0E3F, 0x0E40, 0x0E41, 0x0E42, 0x0E43, 0x0E44, 0x0E45, 0x0E46, 0x0E47, 0x0E48, 0x0E49, 0x0E4A, 0x0E4B,
|
||||
0x0E4C, 0x0E4D, 0x0E4E, 0x0E4F, 0x0E50, 0x0E51, 0x0E52, 0x0E53, 0x0E54, 0x0E55, 0x0E56, 0x0E57, 0x0E58, 0x0E59, 0x0E5A, 0x0E5B, 0xFFFD, 0xFFFD,
|
||||
0xFFFD, 0xFFFD,
|
||||
];
|
||||
|
||||
/// Windows-1250 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
|
||||
const CP1250: [u16; 128] = [
|
||||
0x20AC, 0xFFFD, 0x201A, 0xFFFD, 0x201E, 0x2026, 0x2020, 0x2021, 0xFFFD, 0x2030, 0x0160, 0x2039, 0x015A, 0x0164, 0x017D, 0x0179, 0xFFFD, 0x2018,
|
||||
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0x2122, 0x0161, 0x203A, 0x015B, 0x0165, 0x017E, 0x017A, 0x00A0, 0x02C7, 0x02D8, 0x0141,
|
||||
0x00A4, 0x0104, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x015E, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x017B, 0x00B0, 0x00B1, 0x02DB, 0x0142, 0x00B4, 0x00B5,
|
||||
0x00B6, 0x00B7, 0x00B8, 0x0105, 0x015F, 0x00BB, 0x013D, 0x02DD, 0x013E, 0x017C, 0x0154, 0x00C1, 0x00C2, 0x0102, 0x00C4, 0x0139, 0x0106, 0x00C7,
|
||||
0x010C, 0x00C9, 0x0118, 0x00CB, 0x011A, 0x00CD, 0x00CE, 0x010E, 0x0110, 0x0143, 0x0147, 0x00D3, 0x00D4, 0x0150, 0x00D6, 0x00D7, 0x0158, 0x016E,
|
||||
0x00DA, 0x0170, 0x00DC, 0x00DD, 0x0162, 0x00DF, 0x0155, 0x00E1, 0x00E2, 0x0103, 0x00E4, 0x013A, 0x0107, 0x00E7, 0x010D, 0x00E9, 0x0119, 0x00EB,
|
||||
0x011B, 0x00ED, 0x00EE, 0x010F, 0x0111, 0x0144, 0x0148, 0x00F3, 0x00F4, 0x0151, 0x00F6, 0x00F7, 0x0159, 0x016F, 0x00FA, 0x0171, 0x00FC, 0x00FD,
|
||||
0x0163, 0x02D9,
|
||||
];
|
||||
|
||||
/// Windows-1251 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
|
||||
const CP1251: [u16; 128] = [
|
||||
0x0402, 0x0403, 0x201A, 0x0453, 0x201E, 0x2026, 0x2020, 0x2021, 0x20AC, 0x2030, 0x0409, 0x2039, 0x040A, 0x040C, 0x040B, 0x040F, 0x0452, 0x2018,
|
||||
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0x2122, 0x0459, 0x203A, 0x045A, 0x045C, 0x045B, 0x045F, 0x00A0, 0x040E, 0x045E, 0x0408,
|
||||
0x00A4, 0x0490, 0x00A6, 0x00A7, 0x0401, 0x00A9, 0x0404, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x0407, 0x00B0, 0x00B1, 0x0406, 0x0456, 0x0491, 0x00B5,
|
||||
0x00B6, 0x00B7, 0x0451, 0x2116, 0x0454, 0x00BB, 0x0458, 0x0405, 0x0455, 0x0457, 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, 0x0440, 0x0441, 0x0442, 0x0443, 0x0444, 0x0445, 0x0446, 0x0447, 0x0448, 0x0449, 0x044A, 0x044B, 0x044C, 0x044D,
|
||||
0x044E, 0x044F,
|
||||
];
|
||||
|
||||
/// Windows-1253 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
|
||||
const CP1253: [u16; 128] = [
|
||||
0x20AC, 0xFFFD, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0xFFFD, 0x2030, 0xFFFD, 0x2039, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2018,
|
||||
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0x2122, 0xFFFD, 0x203A, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x00A0, 0x0385, 0x0386, 0x00A3,
|
||||
0x00A4, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0xFFFD, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x2015, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x0384, 0x00B5,
|
||||
0x00B6, 0x00B7, 0x0388, 0x0389, 0x038A, 0x00BB, 0x038C, 0x00BD, 0x038E, 0x038F, 0x0390, 0x0391, 0x0392, 0x0393, 0x0394, 0x0395, 0x0396, 0x0397,
|
||||
0x0398, 0x0399, 0x039A, 0x039B, 0x039C, 0x039D, 0x039E, 0x039F, 0x03A0, 0x03A1, 0xFFFD, 0x03A3, 0x03A4, 0x03A5, 0x03A6, 0x03A7, 0x03A8, 0x03A9,
|
||||
0x03AA, 0x03AB, 0x03AC, 0x03AD, 0x03AE, 0x03AF, 0x03B0, 0x03B1, 0x03B2, 0x03B3, 0x03B4, 0x03B5, 0x03B6, 0x03B7, 0x03B8, 0x03B9, 0x03BA, 0x03BB,
|
||||
0x03BC, 0x03BD, 0x03BE, 0x03BF, 0x03C0, 0x03C1, 0x03C2, 0x03C3, 0x03C4, 0x03C5, 0x03C6, 0x03C7, 0x03C8, 0x03C9, 0x03CA, 0x03CB, 0x03CC, 0x03CD,
|
||||
0x03CE, 0xFFFD,
|
||||
];
|
||||
|
||||
/// Windows-1254 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
|
||||
const CP1254: [u16; 128] = [
|
||||
0x20AC, 0xFFFD, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2018,
|
||||
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0xFFFD, 0xFFFD, 0x0178, 0x00A0, 0x00A1, 0x00A2, 0x00A3,
|
||||
0x00A4, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x00AA, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00AF, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
|
||||
0x00B6, 0x00B7, 0x00B8, 0x00B9, 0x00BA, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x00BF, 0x00C0, 0x00C1, 0x00C2, 0x00C3, 0x00C4, 0x00C5, 0x00C6, 0x00C7,
|
||||
0x00C8, 0x00C9, 0x00CA, 0x00CB, 0x00CC, 0x00CD, 0x00CE, 0x00CF, 0x011E, 0x00D1, 0x00D2, 0x00D3, 0x00D4, 0x00D5, 0x00D6, 0x00D7, 0x00D8, 0x00D9,
|
||||
0x00DA, 0x00DB, 0x00DC, 0x0130, 0x015E, 0x00DF, 0x00E0, 0x00E1, 0x00E2, 0x00E3, 0x00E4, 0x00E5, 0x00E6, 0x00E7, 0x00E8, 0x00E9, 0x00EA, 0x00EB,
|
||||
0x00EC, 0x00ED, 0x00EE, 0x00EF, 0x011F, 0x00F1, 0x00F2, 0x00F3, 0x00F4, 0x00F5, 0x00F6, 0x00F7, 0x00F8, 0x00F9, 0x00FA, 0x00FB, 0x00FC, 0x0131,
|
||||
0x015F, 0x00FF,
|
||||
];
|
||||
|
||||
/// Windows-1255 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
|
||||
const CP1255: [u16; 128] = [
|
||||
0x20AC, 0xFFFD, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0xFFFD, 0x2039, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2018,
|
||||
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0xFFFD, 0x203A, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x00A0, 0x00A1, 0x00A2, 0x00A3,
|
||||
0x20AA, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x00D7, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00AF, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
|
||||
0x00B6, 0x00B7, 0x00B8, 0x00B9, 0x00F7, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x00BF, 0x05B0, 0x05B1, 0x05B2, 0x05B3, 0x05B4, 0x05B5, 0x05B6, 0x05B7,
|
||||
0x05B8, 0x05B9, 0x05BA, 0x05BB, 0x05BC, 0x05BD, 0x05BE, 0x05BF, 0x05C0, 0x05C1, 0x05C2, 0x05C3, 0x05F0, 0x05F1, 0x05F2, 0x05F3, 0x05F4, 0xFFFD,
|
||||
0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x05D0, 0x05D1, 0x05D2, 0x05D3, 0x05D4, 0x05D5, 0x05D6, 0x05D7, 0x05D8, 0x05D9, 0x05DA, 0x05DB,
|
||||
0x05DC, 0x05DD, 0x05DE, 0x05DF, 0x05E0, 0x05E1, 0x05E2, 0x05E3, 0x05E4, 0x05E5, 0x05E6, 0x05E7, 0x05E8, 0x05E9, 0x05EA, 0xFFFD, 0xFFFD, 0x200E,
|
||||
0x200F, 0xFFFD,
|
||||
];
|
||||
|
||||
/// Windows-1256 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
|
||||
const CP1256: [u16; 128] = [
|
||||
0x20AC, 0x067E, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0679, 0x2039, 0x0152, 0x0686, 0x0698, 0x0688, 0x06AF, 0x2018,
|
||||
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x06A9, 0x2122, 0x0691, 0x203A, 0x0153, 0x200C, 0x200D, 0x06BA, 0x00A0, 0x060C, 0x00A2, 0x00A3,
|
||||
0x00A4, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x06BE, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00AF, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
|
||||
0x00B6, 0x00B7, 0x00B8, 0x00B9, 0x061B, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x061F, 0x06C1, 0x0621, 0x0622, 0x0623, 0x0624, 0x0625, 0x0626, 0x0627,
|
||||
0x0628, 0x0629, 0x062A, 0x062B, 0x062C, 0x062D, 0x062E, 0x062F, 0x0630, 0x0631, 0x0632, 0x0633, 0x0634, 0x0635, 0x0636, 0x00D7, 0x0637, 0x0638,
|
||||
0x0639, 0x063A, 0x0640, 0x0641, 0x0642, 0x0643, 0x00E0, 0x0644, 0x00E2, 0x0645, 0x0646, 0x0647, 0x0648, 0x00E7, 0x00E8, 0x00E9, 0x00EA, 0x00EB,
|
||||
0x0649, 0x064A, 0x00EE, 0x00EF, 0x064B, 0x064C, 0x064D, 0x064E, 0x00F4, 0x064F, 0x0650, 0x00F7, 0x0651, 0x00F9, 0x0652, 0x00FB, 0x00FC, 0x200E,
|
||||
0x200F, 0x06D2,
|
||||
];
|
||||
|
||||
/// Windows-1257 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
|
||||
const CP1257: [u16; 128] = [
|
||||
0x20AC, 0xFFFD, 0x201A, 0xFFFD, 0x201E, 0x2026, 0x2020, 0x2021, 0xFFFD, 0x2030, 0xFFFD, 0x2039, 0xFFFD, 0x00A8, 0x02C7, 0x00B8, 0xFFFD, 0x2018,
|
||||
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0x2122, 0xFFFD, 0x203A, 0xFFFD, 0x00AF, 0x02DB, 0xFFFD, 0x00A0, 0xFFFD, 0x00A2, 0x00A3,
|
||||
0x00A4, 0xFFFD, 0x00A6, 0x00A7, 0x00D8, 0x00A9, 0x0156, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00C6, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
|
||||
0x00B6, 0x00B7, 0x00F8, 0x00B9, 0x0157, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x00E6, 0x0104, 0x012E, 0x0100, 0x0106, 0x00C4, 0x00C5, 0x0118, 0x0112,
|
||||
0x010C, 0x00C9, 0x0179, 0x0116, 0x0122, 0x0136, 0x012A, 0x013B, 0x0160, 0x0143, 0x0145, 0x00D3, 0x014C, 0x00D5, 0x00D6, 0x00D7, 0x0172, 0x0141,
|
||||
0x015A, 0x016A, 0x00DC, 0x017B, 0x017D, 0x00DF, 0x0105, 0x012F, 0x0101, 0x0107, 0x00E4, 0x00E5, 0x0119, 0x0113, 0x010D, 0x00E9, 0x017A, 0x0117,
|
||||
0x0123, 0x0137, 0x012B, 0x013C, 0x0161, 0x0144, 0x0146, 0x00F3, 0x014D, 0x00F5, 0x00F6, 0x00F7, 0x0173, 0x0142, 0x015B, 0x016B, 0x00FC, 0x017C,
|
||||
0x017E, 0x02D9,
|
||||
];
|
||||
|
||||
/// Windows-1258 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
|
||||
const CP1258: [u16; 128] = [
|
||||
0x20AC, 0xFFFD, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0xFFFD, 0x2039, 0x0152, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2018,
|
||||
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0xFFFD, 0x203A, 0x0153, 0xFFFD, 0xFFFD, 0x0178, 0x00A0, 0x00A1, 0x00A2, 0x00A3,
|
||||
0x00A4, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x00AA, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00AF, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
|
||||
0x00B6, 0x00B7, 0x00B8, 0x00B9, 0x00BA, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x00BF, 0x00C0, 0x00C1, 0x00C2, 0x0102, 0x00C4, 0x00C5, 0x00C6, 0x00C7,
|
||||
0x00C8, 0x00C9, 0x00CA, 0x00CB, 0x0300, 0x00CD, 0x00CE, 0x00CF, 0x0110, 0x00D1, 0x0309, 0x00D3, 0x00D4, 0x01A0, 0x00D6, 0x00D7, 0x00D8, 0x00D9,
|
||||
0x00DA, 0x00DB, 0x00DC, 0x01AF, 0x0303, 0x00DF, 0x00E0, 0x00E1, 0x00E2, 0x0103, 0x00E4, 0x00E5, 0x00E6, 0x00E7, 0x00E8, 0x00E9, 0x00EA, 0x00EB,
|
||||
0x0301, 0x00ED, 0x00EE, 0x00EF, 0x0111, 0x00F1, 0x0323, 0x00F3, 0x00F4, 0x01A1, 0x00F6, 0x00F7, 0x00F8, 0x00F9, 0x00FA, 0x00FB, 0x00FC, 0x01B0,
|
||||
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}')
|
||||
/// Decode document text without applying OEM control glyphs or symbol-font remapping.
|
||||
pub fn code_page_byte(cp: u16, b: u8) -> char {
|
||||
if b >= 0x80 && OEM_CODE_PAGES.contains(&cp) { oem_byte(cp, b) } else { windows_byte(cp, b) }
|
||||
}
|
||||
|
||||
/// 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];
|
||||
|
||||
/// 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,
|
||||
1250 => &CP1250,
|
||||
1251 => &CP1251,
|
||||
1253 => &CP1253,
|
||||
1254 => &CP1254,
|
||||
1255 => &CP1255,
|
||||
1256 => &CP1256,
|
||||
1257 => &CP1257,
|
||||
1258 => &CP1258,
|
||||
MAC_ROMAN_CODE_PAGE => &MAC_ROMAN,
|
||||
_ => return cp1252(b),
|
||||
};
|
||||
if b < 0x80 {
|
||||
return b as char;
|
||||
}
|
||||
table.get(usize::from(b - 0x80)).and_then(|u| char::from_u32(u32::from(*u))).unwrap_or('\u{FFFD}')
|
||||
/// Whether Word's document import accepts this encoding.
|
||||
pub fn is_text_code_page(cp: u16) -> bool {
|
||||
is_byte_code_page(cp) || is_multibyte_code_page(cp)
|
||||
}
|
||||
|
||||
/// 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 East Asian multibyte code pages a document format names with `\ansicpg` or a font's `\cpg`:
|
||||
/// 932 Shift-JIS (Japanese), 936 GBK (Simplified Chinese), 949 UHC (Korean) and 950 Big5
|
||||
/// (Traditional Chinese).
|
||||
pub const MULTIBYTE_CODE_PAGES: [u16; 4] = [932, 936, 949, 950];
|
||||
|
||||
/// Whether `code_page` is one of the East Asian multibyte code pages ([`MULTIBYTE_CODE_PAGES`]):
|
||||
/// its bytes decode as a run, a lead byte together with its trail byte.
|
||||
pub fn is_multibyte_code_page(code_page: u16) -> bool {
|
||||
multibyte_encoding_name(code_page).is_some()
|
||||
}
|
||||
|
||||
/// The WHATWG encoding name of an East Asian multibyte code page (see [`MULTIBYTE_CODE_PAGES`]),
|
||||
/// for `encoding_rs::Encoding::for_label`; `None` for the single-byte code pages, whose bytes
|
||||
/// decode one at a time, and anything else.
|
||||
pub fn multibyte_encoding_name(code_page: u16) -> Option<&'static str> {
|
||||
Some(match code_page {
|
||||
932 => "shift_jis",
|
||||
936 => "gbk",
|
||||
949 => "euc-kr",
|
||||
950 => "big5",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a document format can name `code_page` for its text: a single-byte Windows, DOS or Mac
|
||||
/// one ([`is_byte_code_page`]) or an East Asian multibyte one ([`is_multibyte_code_page`]).
|
||||
pub fn is_text_code_page(code_page: u16) -> bool {
|
||||
is_byte_code_page(code_page) || is_multibyte_code_page(code_page)
|
||||
}
|
||||
|
||||
/// The code page of an RTF or Word font charset (`\fcharset`): 204 Russian → 1251, 238 Eastern
|
||||
/// European → 1250, 77 Mac → 10000, 254 PC / 255 OEM → 437, 128 Japanese → 932, 129 Korean → 949,
|
||||
/// 134 Simplified Chinese → 936, 136 Traditional Chinese → 950… `None` for the ANSI and default
|
||||
/// charsets (the document's code page applies) and symbol fonts.
|
||||
/// Preserve Word's existing symbol-font selection policy, not the shared symbol extension.
|
||||
pub fn charset_code_page(charset: i32) -> Option<u16> {
|
||||
Some(match charset {
|
||||
77 => MAC_ROMAN_CODE_PAGE,
|
||||
128 => 932,
|
||||
129 => 949,
|
||||
134 => 936,
|
||||
136 => 950,
|
||||
161 => 1253,
|
||||
162 => 1254,
|
||||
163 => 1258,
|
||||
177 => 1255,
|
||||
178 => 1256,
|
||||
186 => 1257,
|
||||
204 => 1251,
|
||||
222 => 874,
|
||||
238 => 1250,
|
||||
254 | 255 => 437,
|
||||
_ => return None,
|
||||
})
|
||||
if charset == 2 { None } else { craft_codepage::charset_code_page(charset) }
|
||||
}
|
||||
#[cfg(test)]
|
||||
#[allow(dead_code)]
|
||||
#[path = "encoding_legacy_tests.rs"]
|
||||
mod legacy;
|
||||
#[cfg(test)]
|
||||
mod parity {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn every_supported_byte_and_fallback_matches_original() {
|
||||
let pages = [0, 42, 437, 737, 850, 852, 857, 866, 874, 1250, 1251, 1252, 1253, 1254, 1255, 1256, 1257, 1258, 10000, 9999, u16::MAX];
|
||||
for cp in pages {
|
||||
for b in 0..=u8::MAX {
|
||||
assert_eq!(windows_byte(cp, b), legacy::windows_byte(cp, b), "windows {cp}/{b}");
|
||||
assert_eq!(oem_byte(cp, b), legacy::oem_byte(cp, b), "oem {cp}/{b}");
|
||||
assert_eq!(code_page_byte(cp, b), legacy::code_page_byte(cp, b), "text {cp}/{b}");
|
||||
assert_eq!(cp1252(b), legacy::cp1252(b));
|
||||
}
|
||||
}
|
||||
for cp in 0..=u16::MAX {
|
||||
assert_eq!(is_byte_code_page(cp), legacy::is_byte_code_page(cp));
|
||||
assert_eq!(is_text_code_page(cp), legacy::is_text_code_page(cp));
|
||||
assert_eq!(multibyte_encoding_name(cp), legacy::multibyte_encoding_name(cp));
|
||||
}
|
||||
for charset in -1..=256 {
|
||||
assert_eq!(charset_code_page(charset), legacy::charset_code_page(charset));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
// Frozen pre-cutover implementation, test-only parity oracle. Original authors retained in file history.
|
||||
//! 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), Mac OS Roman
|
||||
//! (10000, RTF `\mac`), and the East Asian multibyte code pages (932 Shift-JIS, 936 GBK, 949 UHC,
|
||||
//! 950 Big5), whose bytes decode as runs rather than one at a time.
|
||||
|
||||
/// Windows-1252 code points for 0x80..=0x9F.
|
||||
const CP1252: [u16; 32] = [
|
||||
0x20AC, 0x0081, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0x008D, 0x017D, 0x008F, 0x0090, 0x2018,
|
||||
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0x009D, 0x017E, 0x0178,
|
||||
];
|
||||
|
||||
/// One Windows-1252 byte as a char.
|
||||
pub fn cp1252(b: u8) -> char {
|
||||
if (0x80..0xA0).contains(&b) {
|
||||
CP1252.get((b - 0x80) as usize).and_then(|u| char::from_u32(u32::from(*u))).unwrap_or('\u{FFFD}')
|
||||
} else {
|
||||
b as char
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows-874 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
|
||||
const CP874: [u16; 128] = [
|
||||
0x20AC, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2026, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2018,
|
||||
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x00A0, 0x0E01, 0x0E02, 0x0E03,
|
||||
0x0E04, 0x0E05, 0x0E06, 0x0E07, 0x0E08, 0x0E09, 0x0E0A, 0x0E0B, 0x0E0C, 0x0E0D, 0x0E0E, 0x0E0F, 0x0E10, 0x0E11, 0x0E12, 0x0E13, 0x0E14, 0x0E15,
|
||||
0x0E16, 0x0E17, 0x0E18, 0x0E19, 0x0E1A, 0x0E1B, 0x0E1C, 0x0E1D, 0x0E1E, 0x0E1F, 0x0E20, 0x0E21, 0x0E22, 0x0E23, 0x0E24, 0x0E25, 0x0E26, 0x0E27,
|
||||
0x0E28, 0x0E29, 0x0E2A, 0x0E2B, 0x0E2C, 0x0E2D, 0x0E2E, 0x0E2F, 0x0E30, 0x0E31, 0x0E32, 0x0E33, 0x0E34, 0x0E35, 0x0E36, 0x0E37, 0x0E38, 0x0E39,
|
||||
0x0E3A, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x0E3F, 0x0E40, 0x0E41, 0x0E42, 0x0E43, 0x0E44, 0x0E45, 0x0E46, 0x0E47, 0x0E48, 0x0E49, 0x0E4A, 0x0E4B,
|
||||
0x0E4C, 0x0E4D, 0x0E4E, 0x0E4F, 0x0E50, 0x0E51, 0x0E52, 0x0E53, 0x0E54, 0x0E55, 0x0E56, 0x0E57, 0x0E58, 0x0E59, 0x0E5A, 0x0E5B, 0xFFFD, 0xFFFD,
|
||||
0xFFFD, 0xFFFD,
|
||||
];
|
||||
|
||||
/// Windows-1250 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
|
||||
const CP1250: [u16; 128] = [
|
||||
0x20AC, 0xFFFD, 0x201A, 0xFFFD, 0x201E, 0x2026, 0x2020, 0x2021, 0xFFFD, 0x2030, 0x0160, 0x2039, 0x015A, 0x0164, 0x017D, 0x0179, 0xFFFD, 0x2018,
|
||||
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0x2122, 0x0161, 0x203A, 0x015B, 0x0165, 0x017E, 0x017A, 0x00A0, 0x02C7, 0x02D8, 0x0141,
|
||||
0x00A4, 0x0104, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x015E, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x017B, 0x00B0, 0x00B1, 0x02DB, 0x0142, 0x00B4, 0x00B5,
|
||||
0x00B6, 0x00B7, 0x00B8, 0x0105, 0x015F, 0x00BB, 0x013D, 0x02DD, 0x013E, 0x017C, 0x0154, 0x00C1, 0x00C2, 0x0102, 0x00C4, 0x0139, 0x0106, 0x00C7,
|
||||
0x010C, 0x00C9, 0x0118, 0x00CB, 0x011A, 0x00CD, 0x00CE, 0x010E, 0x0110, 0x0143, 0x0147, 0x00D3, 0x00D4, 0x0150, 0x00D6, 0x00D7, 0x0158, 0x016E,
|
||||
0x00DA, 0x0170, 0x00DC, 0x00DD, 0x0162, 0x00DF, 0x0155, 0x00E1, 0x00E2, 0x0103, 0x00E4, 0x013A, 0x0107, 0x00E7, 0x010D, 0x00E9, 0x0119, 0x00EB,
|
||||
0x011B, 0x00ED, 0x00EE, 0x010F, 0x0111, 0x0144, 0x0148, 0x00F3, 0x00F4, 0x0151, 0x00F6, 0x00F7, 0x0159, 0x016F, 0x00FA, 0x0171, 0x00FC, 0x00FD,
|
||||
0x0163, 0x02D9,
|
||||
];
|
||||
|
||||
/// Windows-1251 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
|
||||
const CP1251: [u16; 128] = [
|
||||
0x0402, 0x0403, 0x201A, 0x0453, 0x201E, 0x2026, 0x2020, 0x2021, 0x20AC, 0x2030, 0x0409, 0x2039, 0x040A, 0x040C, 0x040B, 0x040F, 0x0452, 0x2018,
|
||||
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0x2122, 0x0459, 0x203A, 0x045A, 0x045C, 0x045B, 0x045F, 0x00A0, 0x040E, 0x045E, 0x0408,
|
||||
0x00A4, 0x0490, 0x00A6, 0x00A7, 0x0401, 0x00A9, 0x0404, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x0407, 0x00B0, 0x00B1, 0x0406, 0x0456, 0x0491, 0x00B5,
|
||||
0x00B6, 0x00B7, 0x0451, 0x2116, 0x0454, 0x00BB, 0x0458, 0x0405, 0x0455, 0x0457, 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, 0x0440, 0x0441, 0x0442, 0x0443, 0x0444, 0x0445, 0x0446, 0x0447, 0x0448, 0x0449, 0x044A, 0x044B, 0x044C, 0x044D,
|
||||
0x044E, 0x044F,
|
||||
];
|
||||
|
||||
/// Windows-1253 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
|
||||
const CP1253: [u16; 128] = [
|
||||
0x20AC, 0xFFFD, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0xFFFD, 0x2030, 0xFFFD, 0x2039, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2018,
|
||||
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0x2122, 0xFFFD, 0x203A, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x00A0, 0x0385, 0x0386, 0x00A3,
|
||||
0x00A4, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0xFFFD, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x2015, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x0384, 0x00B5,
|
||||
0x00B6, 0x00B7, 0x0388, 0x0389, 0x038A, 0x00BB, 0x038C, 0x00BD, 0x038E, 0x038F, 0x0390, 0x0391, 0x0392, 0x0393, 0x0394, 0x0395, 0x0396, 0x0397,
|
||||
0x0398, 0x0399, 0x039A, 0x039B, 0x039C, 0x039D, 0x039E, 0x039F, 0x03A0, 0x03A1, 0xFFFD, 0x03A3, 0x03A4, 0x03A5, 0x03A6, 0x03A7, 0x03A8, 0x03A9,
|
||||
0x03AA, 0x03AB, 0x03AC, 0x03AD, 0x03AE, 0x03AF, 0x03B0, 0x03B1, 0x03B2, 0x03B3, 0x03B4, 0x03B5, 0x03B6, 0x03B7, 0x03B8, 0x03B9, 0x03BA, 0x03BB,
|
||||
0x03BC, 0x03BD, 0x03BE, 0x03BF, 0x03C0, 0x03C1, 0x03C2, 0x03C3, 0x03C4, 0x03C5, 0x03C6, 0x03C7, 0x03C8, 0x03C9, 0x03CA, 0x03CB, 0x03CC, 0x03CD,
|
||||
0x03CE, 0xFFFD,
|
||||
];
|
||||
|
||||
/// Windows-1254 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
|
||||
const CP1254: [u16; 128] = [
|
||||
0x20AC, 0xFFFD, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2018,
|
||||
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0xFFFD, 0xFFFD, 0x0178, 0x00A0, 0x00A1, 0x00A2, 0x00A3,
|
||||
0x00A4, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x00AA, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00AF, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
|
||||
0x00B6, 0x00B7, 0x00B8, 0x00B9, 0x00BA, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x00BF, 0x00C0, 0x00C1, 0x00C2, 0x00C3, 0x00C4, 0x00C5, 0x00C6, 0x00C7,
|
||||
0x00C8, 0x00C9, 0x00CA, 0x00CB, 0x00CC, 0x00CD, 0x00CE, 0x00CF, 0x011E, 0x00D1, 0x00D2, 0x00D3, 0x00D4, 0x00D5, 0x00D6, 0x00D7, 0x00D8, 0x00D9,
|
||||
0x00DA, 0x00DB, 0x00DC, 0x0130, 0x015E, 0x00DF, 0x00E0, 0x00E1, 0x00E2, 0x00E3, 0x00E4, 0x00E5, 0x00E6, 0x00E7, 0x00E8, 0x00E9, 0x00EA, 0x00EB,
|
||||
0x00EC, 0x00ED, 0x00EE, 0x00EF, 0x011F, 0x00F1, 0x00F2, 0x00F3, 0x00F4, 0x00F5, 0x00F6, 0x00F7, 0x00F8, 0x00F9, 0x00FA, 0x00FB, 0x00FC, 0x0131,
|
||||
0x015F, 0x00FF,
|
||||
];
|
||||
|
||||
/// Windows-1255 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
|
||||
const CP1255: [u16; 128] = [
|
||||
0x20AC, 0xFFFD, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0xFFFD, 0x2039, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2018,
|
||||
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0xFFFD, 0x203A, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x00A0, 0x00A1, 0x00A2, 0x00A3,
|
||||
0x20AA, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x00D7, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00AF, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
|
||||
0x00B6, 0x00B7, 0x00B8, 0x00B9, 0x00F7, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x00BF, 0x05B0, 0x05B1, 0x05B2, 0x05B3, 0x05B4, 0x05B5, 0x05B6, 0x05B7,
|
||||
0x05B8, 0x05B9, 0x05BA, 0x05BB, 0x05BC, 0x05BD, 0x05BE, 0x05BF, 0x05C0, 0x05C1, 0x05C2, 0x05C3, 0x05F0, 0x05F1, 0x05F2, 0x05F3, 0x05F4, 0xFFFD,
|
||||
0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x05D0, 0x05D1, 0x05D2, 0x05D3, 0x05D4, 0x05D5, 0x05D6, 0x05D7, 0x05D8, 0x05D9, 0x05DA, 0x05DB,
|
||||
0x05DC, 0x05DD, 0x05DE, 0x05DF, 0x05E0, 0x05E1, 0x05E2, 0x05E3, 0x05E4, 0x05E5, 0x05E6, 0x05E7, 0x05E8, 0x05E9, 0x05EA, 0xFFFD, 0xFFFD, 0x200E,
|
||||
0x200F, 0xFFFD,
|
||||
];
|
||||
|
||||
/// Windows-1256 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
|
||||
const CP1256: [u16; 128] = [
|
||||
0x20AC, 0x067E, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0679, 0x2039, 0x0152, 0x0686, 0x0698, 0x0688, 0x06AF, 0x2018,
|
||||
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x06A9, 0x2122, 0x0691, 0x203A, 0x0153, 0x200C, 0x200D, 0x06BA, 0x00A0, 0x060C, 0x00A2, 0x00A3,
|
||||
0x00A4, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x06BE, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00AF, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
|
||||
0x00B6, 0x00B7, 0x00B8, 0x00B9, 0x061B, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x061F, 0x06C1, 0x0621, 0x0622, 0x0623, 0x0624, 0x0625, 0x0626, 0x0627,
|
||||
0x0628, 0x0629, 0x062A, 0x062B, 0x062C, 0x062D, 0x062E, 0x062F, 0x0630, 0x0631, 0x0632, 0x0633, 0x0634, 0x0635, 0x0636, 0x00D7, 0x0637, 0x0638,
|
||||
0x0639, 0x063A, 0x0640, 0x0641, 0x0642, 0x0643, 0x00E0, 0x0644, 0x00E2, 0x0645, 0x0646, 0x0647, 0x0648, 0x00E7, 0x00E8, 0x00E9, 0x00EA, 0x00EB,
|
||||
0x0649, 0x064A, 0x00EE, 0x00EF, 0x064B, 0x064C, 0x064D, 0x064E, 0x00F4, 0x064F, 0x0650, 0x00F7, 0x0651, 0x00F9, 0x0652, 0x00FB, 0x00FC, 0x200E,
|
||||
0x200F, 0x06D2,
|
||||
];
|
||||
|
||||
/// Windows-1257 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
|
||||
const CP1257: [u16; 128] = [
|
||||
0x20AC, 0xFFFD, 0x201A, 0xFFFD, 0x201E, 0x2026, 0x2020, 0x2021, 0xFFFD, 0x2030, 0xFFFD, 0x2039, 0xFFFD, 0x00A8, 0x02C7, 0x00B8, 0xFFFD, 0x2018,
|
||||
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0x2122, 0xFFFD, 0x203A, 0xFFFD, 0x00AF, 0x02DB, 0xFFFD, 0x00A0, 0xFFFD, 0x00A2, 0x00A3,
|
||||
0x00A4, 0xFFFD, 0x00A6, 0x00A7, 0x00D8, 0x00A9, 0x0156, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00C6, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
|
||||
0x00B6, 0x00B7, 0x00F8, 0x00B9, 0x0157, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x00E6, 0x0104, 0x012E, 0x0100, 0x0106, 0x00C4, 0x00C5, 0x0118, 0x0112,
|
||||
0x010C, 0x00C9, 0x0179, 0x0116, 0x0122, 0x0136, 0x012A, 0x013B, 0x0160, 0x0143, 0x0145, 0x00D3, 0x014C, 0x00D5, 0x00D6, 0x00D7, 0x0172, 0x0141,
|
||||
0x015A, 0x016A, 0x00DC, 0x017B, 0x017D, 0x00DF, 0x0105, 0x012F, 0x0101, 0x0107, 0x00E4, 0x00E5, 0x0119, 0x0113, 0x010D, 0x00E9, 0x017A, 0x0117,
|
||||
0x0123, 0x0137, 0x012B, 0x013C, 0x0161, 0x0144, 0x0146, 0x00F3, 0x014D, 0x00F5, 0x00F6, 0x00F7, 0x0173, 0x0142, 0x015B, 0x016B, 0x00FC, 0x017C,
|
||||
0x017E, 0x02D9,
|
||||
];
|
||||
|
||||
/// Windows-1258 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
|
||||
const CP1258: [u16; 128] = [
|
||||
0x20AC, 0xFFFD, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0xFFFD, 0x2039, 0x0152, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2018,
|
||||
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0xFFFD, 0x203A, 0x0153, 0xFFFD, 0xFFFD, 0x0178, 0x00A0, 0x00A1, 0x00A2, 0x00A3,
|
||||
0x00A4, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x00AA, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00AF, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
|
||||
0x00B6, 0x00B7, 0x00B8, 0x00B9, 0x00BA, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x00BF, 0x00C0, 0x00C1, 0x00C2, 0x0102, 0x00C4, 0x00C5, 0x00C6, 0x00C7,
|
||||
0x00C8, 0x00C9, 0x00CA, 0x00CB, 0x0300, 0x00CD, 0x00CE, 0x00CF, 0x0110, 0x00D1, 0x0309, 0x00D3, 0x00D4, 0x01A0, 0x00D6, 0x00D7, 0x00D8, 0x00D9,
|
||||
0x00DA, 0x00DB, 0x00DC, 0x01AF, 0x0303, 0x00DF, 0x00E0, 0x00E1, 0x00E2, 0x0103, 0x00E4, 0x00E5, 0x00E6, 0x00E7, 0x00E8, 0x00E9, 0x00EA, 0x00EB,
|
||||
0x0301, 0x00ED, 0x00EE, 0x00EF, 0x0111, 0x00F1, 0x0323, 0x00F3, 0x00F4, 0x01A1, 0x00F6, 0x00F7, 0x00F8, 0x00F9, 0x00FA, 0x00FB, 0x00FC, 0x01B0,
|
||||
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];
|
||||
|
||||
/// 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,
|
||||
1250 => &CP1250,
|
||||
1251 => &CP1251,
|
||||
1253 => &CP1253,
|
||||
1254 => &CP1254,
|
||||
1255 => &CP1255,
|
||||
1256 => &CP1256,
|
||||
1257 => &CP1257,
|
||||
1258 => &CP1258,
|
||||
MAC_ROMAN_CODE_PAGE => &MAC_ROMAN,
|
||||
_ => return cp1252(b),
|
||||
};
|
||||
if b < 0x80 {
|
||||
return b as 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 East Asian multibyte code pages a document format names with `\ansicpg` or a font's `\cpg`:
|
||||
/// 932 Shift-JIS (Japanese), 936 GBK (Simplified Chinese), 949 UHC (Korean) and 950 Big5
|
||||
/// (Traditional Chinese).
|
||||
pub const MULTIBYTE_CODE_PAGES: [u16; 4] = [932, 936, 949, 950];
|
||||
|
||||
/// Whether `code_page` is one of the East Asian multibyte code pages ([`MULTIBYTE_CODE_PAGES`]):
|
||||
/// its bytes decode as a run, a lead byte together with its trail byte.
|
||||
pub fn is_multibyte_code_page(code_page: u16) -> bool {
|
||||
multibyte_encoding_name(code_page).is_some()
|
||||
}
|
||||
|
||||
/// The WHATWG encoding name of an East Asian multibyte code page (see [`MULTIBYTE_CODE_PAGES`]),
|
||||
/// for `encoding_rs::Encoding::for_label`; `None` for the single-byte code pages, whose bytes
|
||||
/// decode one at a time, and anything else.
|
||||
pub fn multibyte_encoding_name(code_page: u16) -> Option<&'static str> {
|
||||
Some(match code_page {
|
||||
932 => "shift_jis",
|
||||
936 => "gbk",
|
||||
949 => "euc-kr",
|
||||
950 => "big5",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a document format can name `code_page` for its text: a single-byte Windows, DOS or Mac
|
||||
/// one ([`is_byte_code_page`]) or an East Asian multibyte one ([`is_multibyte_code_page`]).
|
||||
pub fn is_text_code_page(code_page: u16) -> bool {
|
||||
is_byte_code_page(code_page) || is_multibyte_code_page(code_page)
|
||||
}
|
||||
|
||||
/// The code page of an RTF or Word font charset (`\fcharset`): 204 Russian → 1251, 238 Eastern
|
||||
/// European → 1250, 77 Mac → 10000, 254 PC / 255 OEM → 437, 128 Japanese → 932, 129 Korean → 949,
|
||||
/// 134 Simplified Chinese → 936, 136 Traditional Chinese → 950… `None` for the ANSI and default
|
||||
/// charsets (the document's code page applies) and symbol fonts.
|
||||
pub fn charset_code_page(charset: i32) -> Option<u16> {
|
||||
Some(match charset {
|
||||
77 => MAC_ROMAN_CODE_PAGE,
|
||||
128 => 932,
|
||||
129 => 949,
|
||||
134 => 936,
|
||||
136 => 950,
|
||||
161 => 1253,
|
||||
162 => 1254,
|
||||
163 => 1258,
|
||||
177 => 1255,
|
||||
178 => 1256,
|
||||
186 => 1257,
|
||||
204 => 1251,
|
||||
222 => 874,
|
||||
238 => 1250,
|
||||
254 | 255 => 437,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn single_byte_code_pages() {
|
||||
let decode = |cp: u16, bytes: &[u8]| bytes.iter().map(|&b| windows_byte(cp, b)).collect::<String>();
|
||||
assert_eq!(decode(1251, &[0xD1, 0xF7, 0xE5, 0xF2, 0x20, 0xB9]), "Счет №");
|
||||
assert_eq!(decode(1251, &[0xA8, 0xB8]), "Ёё");
|
||||
assert_eq!(decode(1250, &[0xA3, 0xF3, 0x9F]), "Łóź");
|
||||
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 {
|
||||
for b in 0..=255u8 {
|
||||
let _ = windows_byte(cp, b);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_1255_holam_haser_for_vav() {
|
||||
assert_eq!(windows_byte(1255, 0xCA), '\u{05BA}');
|
||||
}
|
||||
|
||||
/// #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));
|
||||
}
|
||||
|
||||
/// #1653: the East Asian charsets and code pages RTF names are multibyte: their bytes decode
|
||||
/// as runs (a lead byte and its trail byte together), not one at a time.
|
||||
#[test]
|
||||
fn east_asian_code_pages_are_multibyte() {
|
||||
assert_eq!(charset_code_page(128), Some(932), "Japanese");
|
||||
assert_eq!(charset_code_page(129), Some(949), "Korean");
|
||||
assert_eq!(charset_code_page(134), Some(936), "Simplified Chinese");
|
||||
assert_eq!(charset_code_page(136), Some(950), "Traditional Chinese");
|
||||
assert_eq!(charset_code_page(255), Some(437), "OEM is single-byte");
|
||||
assert_eq!(MULTIBYTE_CODE_PAGES, [932, 936, 949, 950]);
|
||||
for cp in MULTIBYTE_CODE_PAGES {
|
||||
assert!(is_multibyte_code_page(cp) && is_text_code_page(cp), "{cp}");
|
||||
assert!(!is_byte_code_page(cp) && !SINGLE_BYTE_CODE_PAGES.contains(&cp), "{cp}");
|
||||
assert!(multibyte_encoding_name(cp).is_some(), "{cp}");
|
||||
}
|
||||
assert!(is_text_code_page(1251) && is_text_code_page(437) && is_text_code_page(10000));
|
||||
assert!(!is_text_code_page(0) && !is_text_code_page(9999));
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,17 @@ pub struct Embedded {
|
||||
pub refs: Vec<EmbeddedRef>,
|
||||
}
|
||||
|
||||
impl Embedded {
|
||||
/// The root element of an ODF embedded object's markup: its files are kept in
|
||||
/// [`crate::Document::passthrough`] for the ODT writer; DOCX shows it as its picture.
|
||||
pub const ODF_OBJECT: &'static str = "<wordcraft:odf-object";
|
||||
|
||||
/// Is it an ODF embedded object (a spreadsheet in an ODT), not Word markup?
|
||||
pub fn is_odf_object(&self) -> bool {
|
||||
self.xml.trim_start().starts_with(Self::ODF_OBJECT)
|
||||
}
|
||||
}
|
||||
|
||||
/// One relationship an [`Embedded`] object uses.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct EmbeddedRef {
|
||||
|
||||
@@ -0,0 +1,783 @@
|
||||
//! Markup kept from the file a document was opened from, so that what isn't edited is written
|
||||
//! back as it was: a paragraph keeps its source XML, which a writer uses as long as the
|
||||
//! paragraph still is what the reader made of it (its [`fingerprint`] unchanged). Anything the
|
||||
//! model has no place for (an unknown element, an attribute nobody reads) survives a save that
|
||||
//! way. Never saved with the document (WordCraft's own format), and not part of its content
|
||||
//! (`==` ignores it).
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::Paragraph;
|
||||
|
||||
/// Most kept markup per document, bytes (a hostile file can't make the kept copy grow without
|
||||
/// bound: past it, paragraphs are written from the model as before).
|
||||
pub const MAX_KEPT_BYTES: usize = 64 * 1024 * 1024;
|
||||
|
||||
/// The kept markup of a document (see the module docs).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Kept(Option<Arc<KeptXml>>);
|
||||
|
||||
/// What a reader kept.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct KeptXml {
|
||||
/// The format it came from (`"odt"`): only that format's writer uses it.
|
||||
pub format: String,
|
||||
/// Paragraph [`fingerprint`] → the paragraph's markup, in the order read (identical
|
||||
/// paragraphs keep the first's).
|
||||
pub paras: HashMap<u64, Arc<str>>,
|
||||
/// The same for the body's tables ([`table_fingerprint`]).
|
||||
pub tables: HashMap<u64, Arc<str>>,
|
||||
/// While reading: the markup of the body's `n`th table, until [`Kept::seal`].
|
||||
pub pending_tables: Vec<(usize, Arc<str>)>,
|
||||
/// Wrappers kept as read around paragraphs (sections): for a paragraph's fingerprint, the
|
||||
/// start tags opening before it and how many close after it. Only paragraphs that were the
|
||||
/// only one of their kind have them (a copy can't open a section twice).
|
||||
pub wraps: HashMap<u64, (Vec<Arc<str>>, u32)>,
|
||||
/// While reading: the same by paragraph `rev`, until [`Kept::seal`].
|
||||
pub pending_wraps: HashMap<u64, (Vec<Arc<str>>, u32)>,
|
||||
/// The same for paragraphs with twins (an empty paragraph has many): by fingerprint and
|
||||
/// [`Paragraph::rev`], which stays while the paragraph does (a writer opens each once).
|
||||
pub twin_wraps: HashMap<(u64, u64), (Vec<Arc<str>>, u32)>,
|
||||
/// The same wrappers around the body's tables, by [`table_fingerprint`] (tables that are
|
||||
/// the only one of their kind).
|
||||
pub table_wraps: HashMap<u64, (Vec<Arc<str>>, u32)>,
|
||||
/// While reading: those of the body's `n`th table, until `seal_kept`.
|
||||
pub pending_table_wraps: Vec<(usize, (Vec<Arc<str>>, u32))>,
|
||||
/// Indexes kept as read: by their first paragraph's fingerprint, how many top-level
|
||||
/// paragraphs they take, those paragraphs' fingerprint together ([`run_fingerprint`]),
|
||||
/// and the index's markup. Written as read while those paragraphs are all as read.
|
||||
pub indexes: HashMap<u64, (usize, u64, Arc<str>)>,
|
||||
/// While reading: by the first paragraph's `rev`, how many paragraphs, and the markup.
|
||||
pub pending_indexes: HashMap<u64, (usize, Arc<str>)>,
|
||||
/// The group of paragraphs written as read together (see `wordcraft_formats::model::Group`)
|
||||
/// each paragraph kept belongs to, by its fingerprint: the group's id and size.
|
||||
pub groups: HashMap<u64, (u32, u32)>,
|
||||
/// While reading: the same by paragraph `rev`, until `seal_kept`.
|
||||
pub pending_groups: HashMap<u64, (u32, u32)>,
|
||||
/// Each text box's, shape's or note's story's [`story_fingerprint`] when read: a paragraph
|
||||
/// showing one is written as read only while it is as read.
|
||||
pub story_fps: HashMap<u32, u64>,
|
||||
/// Each top-level comment's [`comments_fingerprint`] when read (it and its replies): a
|
||||
/// paragraph holding comments is written as read only while they are as read.
|
||||
pub comment_fps: HashMap<u32, u64>,
|
||||
/// Named definitions the kept markup refers to (styles, font faces…), by a key the format
|
||||
/// chooses (`"auto:P12"`, `"style:Heading_20_1"`), as read.
|
||||
pub defs: BTreeMap<String, Arc<str>>,
|
||||
/// While reading: paragraph [`Paragraph::rev`] → its markup, until [`Kept::seal`] turns
|
||||
/// them into fingerprints.
|
||||
pub pending: HashMap<u64, Arc<str>>,
|
||||
/// Bytes kept so far.
|
||||
pub bytes: usize,
|
||||
/// The keys of [`KeptXml::defs`] in the order the file had them.
|
||||
pub order: Vec<String>,
|
||||
/// Files of the package kept definitions refer to (a style's fill picture), by path.
|
||||
pub files: BTreeMap<String, Arc<Vec<u8>>>,
|
||||
/// The document's style definitions' [`styles_fingerprint`] when read: while it is the
|
||||
/// same, the file's own style definitions can be written as they were.
|
||||
pub styles_fp: Option<u64>,
|
||||
/// The document's page setup's [`pages_fingerprint`] when read: while it is the same, the
|
||||
/// file's own page layouts and master pages can be written as they were.
|
||||
pub pages_fp: Option<u64>,
|
||||
/// Each section's master page, as the file named it where its section starts (`None`: the
|
||||
/// file's default), for a writer naming the file's own master pages.
|
||||
pub section_masters: Vec<Option<String>>,
|
||||
}
|
||||
|
||||
impl PartialEq for Kept {
|
||||
/// Not part of the document's content.
|
||||
fn eq(&self, _: &Self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl Kept {
|
||||
pub fn new(x: KeptXml) -> Kept {
|
||||
Kept(Some(Arc::new(x)))
|
||||
}
|
||||
pub fn get(&self) -> Option<&KeptXml> {
|
||||
self.0.as_deref()
|
||||
}
|
||||
/// What `format`'s writer may use, if anything was kept from such a file.
|
||||
pub fn of(&self, format: &str) -> Option<&KeptXml> {
|
||||
self.get().filter(|k| k.format == format)
|
||||
}
|
||||
pub fn get_mut(&mut self) -> Option<&mut KeptXml> {
|
||||
self.0.as_mut().map(Arc::make_mut)
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
self.0 = None;
|
||||
}
|
||||
/// After reading: each paragraph still marked pending (by its `rev`, which any change after
|
||||
/// the reader made it would have moved on) gets its markup under its fingerprint.
|
||||
pub fn seal(&mut self, paras: &mut dyn Iterator<Item = &Paragraph>) {
|
||||
let Some(k) = self.get_mut() else { return };
|
||||
if k.pending.is_empty() {
|
||||
return;
|
||||
}
|
||||
for p in paras {
|
||||
if let Some(xml) = k.pending.remove(&p.rev) {
|
||||
k.paras.entry(fingerprint(p)).or_insert(xml);
|
||||
}
|
||||
}
|
||||
k.pending.clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl KeptXml {
|
||||
/// The markup kept for paragraph `p`, while it still is what was read.
|
||||
pub fn para(&self, p: &Paragraph) -> Option<&Arc<str>> {
|
||||
if self.paras.is_empty() {
|
||||
return None;
|
||||
}
|
||||
self.paras.get(&fingerprint(p))
|
||||
}
|
||||
/// The markup kept for table `t`, while it still is what was read.
|
||||
pub fn table(&self, t: &crate::Table) -> Option<&Arc<str>> {
|
||||
if self.tables.is_empty() {
|
||||
return None;
|
||||
}
|
||||
self.tables.get(&table_fingerprint(t))
|
||||
}
|
||||
/// Whether the comments paragraph `p` holds (those it begins, with their replies) are as
|
||||
/// read and are all the comments its kept markup `xml` holds: a reply written elsewhere
|
||||
/// in the file, or one added since, isn't in it.
|
||||
pub fn comments_as_read(&self, doc: &crate::Document, p: &Paragraph, xml: &str) -> bool {
|
||||
let in_markup = xml.matches("<office:annotation ").count() + xml.matches("<office:annotation>").count();
|
||||
let mut count = 0usize;
|
||||
for o in &p.objects {
|
||||
if let crate::para::InlineObject::CommentStart { id } = o {
|
||||
let (fp, n) = comments_fingerprint(doc, *id);
|
||||
if self.comment_fps.get(id) != Some(&fp) {
|
||||
return false;
|
||||
}
|
||||
count = count.saturating_add(n);
|
||||
}
|
||||
}
|
||||
count == in_markup
|
||||
}
|
||||
/// Whether the stories paragraph `p` shows (its text boxes' and shapes' text, its notes') are
|
||||
/// as read.
|
||||
pub fn stories_as_read(&self, doc: &crate::Document, p: &Paragraph) -> bool {
|
||||
// A note's citation is its number in the document's numbering, which is the page setup's.
|
||||
let notes = p.objects.iter().any(|o| matches!(o, crate::para::InlineObject::NoteRef { .. }));
|
||||
if notes && !self.pages_as_read(doc) {
|
||||
return false;
|
||||
}
|
||||
p.objects
|
||||
.iter()
|
||||
.flat_map(|o| match o {
|
||||
crate::para::InlineObject::NoteRef { id, .. } => vec![*id],
|
||||
o => o.text_boxes(),
|
||||
})
|
||||
.all(|id| self.story_fps.get(&id).is_some_and(|fp| *fp == story_fingerprint(doc, id)))
|
||||
}
|
||||
/// The wrappers kept around table `t`, while it still is what was read.
|
||||
pub fn table_wrap(&self, t: &crate::Table) -> Option<&(Vec<Arc<str>>, u32)> {
|
||||
if self.table_wraps.is_empty() {
|
||||
return None;
|
||||
}
|
||||
self.table_wraps.get(&table_fingerprint(t))
|
||||
}
|
||||
/// The wrappers kept around paragraph `p`, while it still is what was read.
|
||||
pub fn wrap(&self, p: &Paragraph) -> Option<&(Vec<Arc<str>>, u32)> {
|
||||
if self.wraps.is_empty() && self.twin_wraps.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let fp = fingerprint(p);
|
||||
self.wraps.get(&fp).or_else(|| self.twin_wraps.get(&(fp, p.rev)))
|
||||
}
|
||||
/// The index kept as read starting at paragraph `p`: how many paragraphs it takes, their
|
||||
/// [`run_fingerprint`] when read, its markup.
|
||||
pub fn index(&self, p: &Paragraph) -> Option<&(usize, u64, Arc<str>)> {
|
||||
if self.indexes.is_empty() {
|
||||
return None;
|
||||
}
|
||||
self.indexes.get(&fingerprint(p))
|
||||
}
|
||||
/// Keep an index's markup, starting at the paragraph whose `rev` is `rev` and taking `n`.
|
||||
pub fn keep_pending_index(&mut self, rev: u64, n: usize, xml: Arc<str>) {
|
||||
if self.bytes.saturating_add(xml.len()) <= MAX_KEPT_BYTES {
|
||||
self.bytes += xml.len();
|
||||
self.pending_indexes.insert(rev, (n, xml));
|
||||
}
|
||||
}
|
||||
/// The paragraph whose `rev` is `rev` belongs to group `group` (id, size).
|
||||
pub fn keep_pending_group(&mut self, rev: u64, group: (u32, u32)) {
|
||||
if self.pending_groups.len() < 100_000 {
|
||||
self.pending_groups.insert(rev, group);
|
||||
}
|
||||
}
|
||||
/// The group paragraph `p`'s markup kept as read belongs to (id, size).
|
||||
pub fn group(&self, p: &Paragraph) -> Option<(u32, u32)> {
|
||||
if self.groups.is_empty() {
|
||||
return None;
|
||||
}
|
||||
self.groups.get(&fingerprint(p)).copied()
|
||||
}
|
||||
/// Keep wrapper markup for the paragraph whose `rev` is `rev`, within the budget.
|
||||
pub fn keep_pending_wrap(&mut self, rev: u64, wrap: (Vec<Arc<str>>, u32)) {
|
||||
let n: usize = wrap.0.iter().map(|t| t.len()).sum();
|
||||
if self.bytes.saturating_add(n) <= MAX_KEPT_BYTES {
|
||||
self.bytes += n;
|
||||
self.pending_wraps.insert(rev, wrap);
|
||||
}
|
||||
}
|
||||
/// Keep wrapper markup for the body's `n`th table, within the budget.
|
||||
pub fn keep_pending_table_wrap(&mut self, n: usize, wrap: (Vec<Arc<str>>, u32)) {
|
||||
let size: usize = wrap.0.iter().map(|t| t.len()).sum();
|
||||
if self.bytes.saturating_add(size) <= MAX_KEPT_BYTES {
|
||||
self.bytes += size;
|
||||
self.pending_table_wraps.push((n, wrap));
|
||||
}
|
||||
}
|
||||
/// Keep `xml` for the body's `n`th table, within the budget.
|
||||
pub fn keep_pending_table(&mut self, n: usize, xml: Arc<str>) {
|
||||
if self.bytes.saturating_add(xml.len()) <= MAX_KEPT_BYTES {
|
||||
self.bytes += xml.len();
|
||||
self.pending_tables.push((n, xml));
|
||||
}
|
||||
}
|
||||
/// Keep `xml` for the paragraph whose `rev` is `rev`, within the budget.
|
||||
pub fn keep_pending(&mut self, rev: u64, xml: Arc<str>) {
|
||||
if self.bytes.saturating_add(xml.len()) <= MAX_KEPT_BYTES {
|
||||
self.bytes += xml.len();
|
||||
self.pending.insert(rev, xml);
|
||||
}
|
||||
}
|
||||
/// Keep a named definition, within the budget.
|
||||
pub fn keep_def(&mut self, key: String, xml: Arc<str>) {
|
||||
if self.bytes.saturating_add(xml.len()) <= MAX_KEPT_BYTES && !self.defs.contains_key(&key) {
|
||||
self.bytes += xml.len();
|
||||
self.order.push(key.clone());
|
||||
self.defs.insert(key, xml);
|
||||
}
|
||||
}
|
||||
/// Keep a file kept definitions refer to, within the budget.
|
||||
pub fn keep_file(&mut self, path: String, bytes: Vec<u8>) {
|
||||
if self.bytes.saturating_add(bytes.len()) <= MAX_KEPT_BYTES && !self.files.contains_key(&path) {
|
||||
self.bytes += bytes.len();
|
||||
self.files.insert(path, Arc::new(bytes));
|
||||
}
|
||||
}
|
||||
/// Whether `doc`'s page setup is still as it was read.
|
||||
pub fn pages_as_read(&self, doc: &crate::Document) -> bool {
|
||||
let Some(fp) = self.pages_fp else { return false };
|
||||
let now = in_scope(doc, |sc| *sc.pages.get_or_insert_with(|| pages_fingerprint(doc))).unwrap_or_else(|| pages_fingerprint(doc));
|
||||
fp == now
|
||||
}
|
||||
/// Whether `doc`'s style definitions are still as they were read.
|
||||
pub fn styles_as_read(&self, doc: &crate::Document) -> bool {
|
||||
self.styles_fp.is_some_and(|fp| fp == styles_fingerprint(doc))
|
||||
}
|
||||
}
|
||||
|
||||
/// What `doc`'s style definitions are: its styles, lists and default tab interval.
|
||||
pub fn styles_fingerprint(doc: &crate::Document) -> u64 {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
match serde_json::to_vec(&(&doc.styles, &doc.numbering, doc.settings.default_tab)) {
|
||||
Ok(v) => v.hash(&mut h),
|
||||
Err(_) => u64::MAX.hash(&mut h),
|
||||
}
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// What a block is, as the kept markup knows it: its paragraphs' fingerprints and
|
||||
/// [`Paragraph::rev`]s in order, and a table's [`table_fingerprint`].
|
||||
pub struct Identities {
|
||||
paras: Vec<(u64, u64)>,
|
||||
table: Option<u64>,
|
||||
}
|
||||
|
||||
/// A block's [`Identities`] before and after a change that keeps what it is.
|
||||
pub type Moved = (Identities, Identities);
|
||||
|
||||
/// The [`Identities`] of block `b`.
|
||||
pub fn identities(b: &crate::Block) -> Identities {
|
||||
let mut paras_of = Vec::new();
|
||||
match b {
|
||||
crate::Block::Para(p) => paras_of.push(p),
|
||||
crate::Block::Table(t) => {
|
||||
for r in &t.rows {
|
||||
for c in &r.cells {
|
||||
paras(&c.blocks, 1, &mut paras_of);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Identities { paras: paras_of.iter().map(|p| (fingerprint(p), p.rev)).collect(), table: b.as_table().map(table_fingerprint) }
|
||||
}
|
||||
|
||||
impl KeptXml {
|
||||
/// The markup kept for a block's paragraphs and table follows them from `before` to `after`
|
||||
/// (what they were and are: see [`crate::Document::fill_objects`]).
|
||||
pub fn follow(&mut self, before: &Identities, after: &Identities) {
|
||||
for ((fp, rev), (nfp, nrev)) in before.paras.iter().zip(&after.paras) {
|
||||
if fp == nfp && rev == nrev {
|
||||
continue;
|
||||
}
|
||||
if let Some(x) = self.paras.remove(fp) {
|
||||
self.paras.entry(*nfp).or_insert(x);
|
||||
}
|
||||
if let Some(w) = self.wraps.remove(fp) {
|
||||
self.wraps.entry(*nfp).or_insert(w);
|
||||
}
|
||||
if let Some(w) = self.twin_wraps.remove(&(*fp, *rev)) {
|
||||
self.twin_wraps.entry((*nfp, *nrev)).or_insert(w);
|
||||
}
|
||||
// Its group too: all a group's paragraphs are written as read, or none.
|
||||
if let Some(g) = self.groups.remove(fp) {
|
||||
self.groups.entry(*nfp).or_insert(g);
|
||||
}
|
||||
}
|
||||
if let (Some(t), Some(nt)) = (before.table, after.table)
|
||||
&& t != nt
|
||||
{
|
||||
if let Some(x) = self.tables.remove(&t) {
|
||||
self.tables.entry(nt).or_insert(x);
|
||||
}
|
||||
if let Some(w) = self.table_wraps.remove(&t) {
|
||||
self.table_wraps.entry(nt).or_insert(w);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How deep [`story_fingerprint`] follows stories in stories (text boxes in text boxes, notes).
|
||||
const MAX_STORY_DEPTH: usize = 8;
|
||||
|
||||
/// What story `id` (a text box's or a shape's text) is: its blocks, and the stories and notes
|
||||
/// they hold in turn.
|
||||
pub fn story_fingerprint(doc: &crate::Document, id: u32) -> u64 {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
let mut todo = vec![(id, 0usize)];
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
while let Some((id, depth)) = todo.pop() {
|
||||
if !seen.insert(id) {
|
||||
continue;
|
||||
}
|
||||
let blocks = doc.parts.get(&id).map(|p| &p.blocks);
|
||||
match serde_json::to_vec(&(id, blocks)) {
|
||||
Ok(v) => v.hash(&mut h),
|
||||
Err(_) => u64::MAX.hash(&mut h),
|
||||
}
|
||||
if depth >= MAX_STORY_DEPTH {
|
||||
continue;
|
||||
}
|
||||
let Some(blocks) = blocks else { continue };
|
||||
let mut held = Vec::new();
|
||||
paras(blocks, 0, &mut held);
|
||||
for p in held {
|
||||
for o in &p.objects {
|
||||
todo.extend(o.text_boxes().into_iter().map(|s| (s, depth + 1)));
|
||||
if let crate::para::InlineObject::NoteRef { id, .. } = o {
|
||||
todo.push((*id, depth + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// Most replies a comment's fingerprint follows (hostile input).
|
||||
const MAX_REPLIES: usize = 10_000;
|
||||
|
||||
/// What comment `root` is with its replies (and theirs): each one's author, date, state and
|
||||
/// text, and how many they are.
|
||||
pub fn comments_fingerprint(doc: &crate::Document, root: u32) -> (u64, usize) {
|
||||
family_fingerprint(doc, root, &scoped_replies(doc))
|
||||
}
|
||||
|
||||
/// What a writer asks of a document's kept markup for many paragraphs, worked out once while
|
||||
/// [`while_writing`] it: each comment's replies, and its page setup's fingerprint. Without it a
|
||||
/// document of C comments took C² steps (and its page setup serialized once per note) to save.
|
||||
struct Scope {
|
||||
/// The document's address: it can't move or change while borrowed for the writing.
|
||||
doc: usize,
|
||||
replies: Option<std::rc::Rc<HashMap<u32, Vec<u32>>>>,
|
||||
pages: Option<u64>,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static SCOPE: std::cell::RefCell<Option<Scope>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// How many times [`replies`] was built (tests).
|
||||
static REPLY_BUILDS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Puts the scope before it back when dropped (when `f` returns or unwinds).
|
||||
struct Restore(Option<Scope>);
|
||||
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
let prev = self.0.take();
|
||||
SCOPE.with(|s| *s.borrow_mut() = prev);
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f`, which writes `doc`, with what the kept markup asks of `doc` worked out at most once.
|
||||
pub fn while_writing<R>(doc: &crate::Document, f: impl FnOnce() -> R) -> R {
|
||||
let key = std::ptr::from_ref(doc) as usize;
|
||||
let prev = SCOPE.with(|s| s.replace(Some(Scope { doc: key, replies: None, pages: None })));
|
||||
let _restore = Restore(prev);
|
||||
f()
|
||||
}
|
||||
|
||||
/// The scope for `doc`, if it is being written: `f` of it.
|
||||
fn in_scope<R>(doc: &crate::Document, f: impl FnOnce(&mut Scope) -> R) -> Option<R> {
|
||||
let key = std::ptr::from_ref(doc) as usize;
|
||||
SCOPE.with(|s| s.borrow_mut().as_mut().filter(|sc| sc.doc == key).map(f))
|
||||
}
|
||||
|
||||
/// [`replies`], built once while `doc` is being written.
|
||||
fn scoped_replies(doc: &crate::Document) -> std::rc::Rc<HashMap<u32, Vec<u32>>> {
|
||||
in_scope(doc, |sc| sc.replies.get_or_insert_with(|| std::rc::Rc::new(replies(doc))).clone()).unwrap_or_else(|| std::rc::Rc::new(replies(doc)))
|
||||
}
|
||||
|
||||
/// Each comment's replies, by the comment's id.
|
||||
fn replies(doc: &crate::Document) -> HashMap<u32, Vec<u32>> {
|
||||
#[cfg(test)]
|
||||
REPLY_BUILDS.with(|n| n.set(n.get() + 1));
|
||||
let mut out: HashMap<u32, Vec<u32>> = HashMap::new();
|
||||
for (id, c) in &doc.comments {
|
||||
if let Some(p) = c.parent {
|
||||
out.entry(p).or_default().push(*id);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// [`comments_fingerprint`], with each comment's `replies`.
|
||||
fn family_fingerprint(doc: &crate::Document, root: u32, replies: &HashMap<u32, Vec<u32>>) -> (u64, usize) {
|
||||
let mut ids = vec![root];
|
||||
let mut seen = std::collections::HashSet::from([root]);
|
||||
let mut i = 0;
|
||||
while let Some(&id) = ids.get(i) {
|
||||
for r in replies.get(&id).map(Vec::as_slice).unwrap_or(&[]) {
|
||||
// (A reply naming itself, or a circle of them, is followed once.)
|
||||
if ids.len() < MAX_REPLIES && seen.insert(*r) {
|
||||
ids.push(*r);
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
for id in &ids {
|
||||
let c = doc.comments.get(id);
|
||||
let text = c.and_then(|c| doc.parts.get(&c.part)).map(|p| &p.blocks);
|
||||
match serde_json::to_vec(&(c, text)) {
|
||||
Ok(v) => v.hash(&mut h),
|
||||
Err(_) => u64::MAX.hash(&mut h),
|
||||
}
|
||||
}
|
||||
(h.finish(), ids.len())
|
||||
}
|
||||
|
||||
/// What `doc`'s page setup is: each section's setup (where it starts, its size, margins,
|
||||
/// columns, headers and footers), the headers' and footers' content, whether odd and even pages
|
||||
/// differ, the page colour and picture, the watermark (one with no text isn't written), and the
|
||||
/// notes' numbering.
|
||||
pub fn pages_fingerprint(doc: &crate::Document) -> u64 {
|
||||
let mut sections: Vec<&crate::SectionProps> = doc.body.iter().filter_map(|b| b.as_para()).filter_map(|p| p.section.as_deref()).collect();
|
||||
sections.push(&doc.last_section);
|
||||
let parts: Vec<(&u32, &crate::Part)> =
|
||||
doc.parts.iter().filter(|(_, p)| matches!(p.kind, crate::PartKind::Header | crate::PartKind::Footer)).collect();
|
||||
let s = &doc.settings;
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
match serde_json::to_vec(&(
|
||||
sections,
|
||||
parts,
|
||||
s.even_odd_headers,
|
||||
&s.page_color,
|
||||
&s.page_picture,
|
||||
s.watermark.as_ref().filter(|w| !w.text.trim().is_empty()),
|
||||
(&s.footnote_format, &s.endnote_format, &s.footnote_pr, &s.endnote_pr),
|
||||
(&doc.footnote_separators, &doc.endnote_separators),
|
||||
)) {
|
||||
Ok(v) => v.hash(&mut h),
|
||||
Err(_) => u64::MAX.hash(&mut h),
|
||||
}
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// What a run of paragraphs is in the model, together.
|
||||
pub fn run_fingerprint<'a>(paras: impl Iterator<Item = &'a Paragraph>) -> u64 {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
for p in paras {
|
||||
fingerprint(p).hash(&mut h);
|
||||
}
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// What table `t` is in the model.
|
||||
pub fn table_fingerprint(t: &crate::Table) -> u64 {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
match serde_json::to_vec(t) {
|
||||
Ok(v) => v.hash(&mut h),
|
||||
Err(_) => u64::MAX.hash(&mut h),
|
||||
}
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// What paragraph `p` is in the model (its text, runs, objects and properties, not its `rev`):
|
||||
/// equal for a paragraph nobody changed.
|
||||
pub fn fingerprint(p: &Paragraph) -> u64 {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
match serde_json::to_vec(p) {
|
||||
Ok(v) => v.hash(&mut h),
|
||||
// Unserializable (no paragraph is): a value no paragraph's markup is kept under.
|
||||
Err(_) => u64::MAX.hash(&mut h),
|
||||
}
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// Deepest table nesting [`crate::Document::seal_kept`] looks into.
|
||||
const MAX_SEAL_DEPTH: usize = 16;
|
||||
|
||||
fn paras<'a>(bl: &'a crate::Blocks, depth: usize, out: &mut Vec<&'a Paragraph>) {
|
||||
for b in bl {
|
||||
match &**b {
|
||||
crate::Block::Para(p) => out.push(p),
|
||||
crate::Block::Table(t) if depth < MAX_SEAL_DEPTH => {
|
||||
for r in &t.rows {
|
||||
for c in &r.cells {
|
||||
paras(&c.blocks, depth + 1, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::Block::Table(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::Document {
|
||||
/// After reading: the markup kept for paragraphs still as the reader made them is filed
|
||||
/// under their fingerprints (see [`Kept::seal`]); the rest is dropped.
|
||||
pub fn seal_kept(&mut self) {
|
||||
// The body's tables kept, by their place among its tables; its indexes, by their run of
|
||||
// top-level paragraphs.
|
||||
if let Some(k) = self.kept.get_mut() {
|
||||
let pending = std::mem::take(&mut k.pending_indexes);
|
||||
for (i, b) in self.body.iter().enumerate() {
|
||||
let Some(p) = b.as_para() else { continue };
|
||||
let Some((n, xml)) = pending.get(&p.rev) else { continue };
|
||||
let run: Option<Vec<&Paragraph>> = self.body.get(i..i + n).map(|r| r.iter().filter_map(|b| b.as_para()).collect());
|
||||
if let Some(run) = run.filter(|r| r.len() == *n) {
|
||||
k.indexes.entry(fingerprint(p)).or_insert((*n, run_fingerprint(run.into_iter()), xml.clone()));
|
||||
}
|
||||
}
|
||||
let pending = std::mem::take(&mut k.pending_tables);
|
||||
let tables: Vec<&crate::Table> = self.body.iter().filter_map(|b| b.as_table()).collect();
|
||||
for (n, xml) in pending {
|
||||
if let Some(t) = tables.get(n) {
|
||||
k.tables.entry(table_fingerprint(t)).or_insert(xml);
|
||||
}
|
||||
}
|
||||
// Wrappers, for tables that are the only one of their kind (a copy can't open a
|
||||
// section twice).
|
||||
let pending = std::mem::take(&mut k.pending_table_wraps);
|
||||
if !pending.is_empty() {
|
||||
let fps: Vec<u64> = tables.iter().map(|t| table_fingerprint(t)).collect();
|
||||
for (n, w) in pending {
|
||||
if let Some(fp) = fps.get(n)
|
||||
&& fps.iter().filter(|f| *f == fp).count() == 1
|
||||
{
|
||||
k.table_wraps.insert(*fp, w);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// The text boxes' and shapes' stories as read, for paragraphs showing them.
|
||||
if self.kept.get().is_some_and(|k| !k.pending.is_empty()) {
|
||||
let fps: HashMap<u32, u64> = self
|
||||
.parts
|
||||
.iter()
|
||||
.filter(|(_, p)| matches!(p.kind, crate::PartKind::TextBox | crate::PartKind::Footnote | crate::PartKind::Endnote))
|
||||
.map(|(id, _)| (*id, story_fingerprint(self, *id)))
|
||||
.collect();
|
||||
if let Some(k) = self.kept.get_mut() {
|
||||
k.story_fps = fps;
|
||||
}
|
||||
}
|
||||
// The comments as read, for paragraphs holding them.
|
||||
if self.kept.get().is_some_and(|k| !k.pending.is_empty()) && !self.comments.is_empty() {
|
||||
let replies = replies(self);
|
||||
let fps: HashMap<u32, u64> =
|
||||
self.comments.iter().filter(|(_, c)| c.parent.is_none()).map(|(id, _)| (*id, family_fingerprint(self, *id, &replies).0)).collect();
|
||||
if let Some(k) = self.kept.get_mut() {
|
||||
k.comment_fps = fps;
|
||||
}
|
||||
}
|
||||
if self.kept.get().is_none_or(|k| k.pending.is_empty() && k.pending_wraps.is_empty()) {
|
||||
return;
|
||||
}
|
||||
let mut all = Vec::new();
|
||||
paras(&self.body, 0, &mut all);
|
||||
for p in self.parts.values() {
|
||||
paras(&p.blocks, 0, &mut all);
|
||||
}
|
||||
let mut kept = std::mem::take(&mut self.kept);
|
||||
// Groups, by fingerprint; a group with a paragraph that has twins (which a writer can't
|
||||
// tell apart) isn't kept.
|
||||
if let Some(k) = kept.get_mut()
|
||||
&& !k.pending_groups.is_empty()
|
||||
{
|
||||
let mut count: HashMap<u64, usize> = HashMap::new();
|
||||
let fps: Vec<(u64, u64)> = all.iter().map(|p| (p.rev, fingerprint(p))).collect();
|
||||
for (_, fp) in &fps {
|
||||
*count.entry(*fp).or_default() += 1;
|
||||
}
|
||||
let pending = std::mem::take(&mut k.pending_groups);
|
||||
let mut broken = std::collections::HashSet::new();
|
||||
let mut members: Vec<(u64, u64, (u32, u32))> = Vec::new();
|
||||
for (rev, fp) in &fps {
|
||||
if let Some(g) = pending.get(rev) {
|
||||
if count.get(fp) != Some(&1) {
|
||||
broken.insert(g.0);
|
||||
}
|
||||
members.push((*rev, *fp, *g));
|
||||
}
|
||||
}
|
||||
for (rev, fp, g) in members {
|
||||
if broken.contains(&g.0) {
|
||||
k.pending.remove(&rev);
|
||||
} else {
|
||||
k.groups.insert(fp, g);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Wrappers, for paragraphs that are the only one of their kind.
|
||||
if let Some(k) = kept.get_mut()
|
||||
&& !k.pending_wraps.is_empty()
|
||||
{
|
||||
let mut count: HashMap<u64, usize> = HashMap::new();
|
||||
let fps: Vec<(u64, u64)> = all.iter().map(|p| (p.rev, fingerprint(p))).collect();
|
||||
for (_, fp) in &fps {
|
||||
*count.entry(*fp).or_default() += 1;
|
||||
}
|
||||
for (rev, fp) in fps {
|
||||
if let Some(w) = k.pending_wraps.remove(&rev) {
|
||||
if count.get(&fp) == Some(&1) {
|
||||
k.wraps.insert(fp, w);
|
||||
} else {
|
||||
k.twin_wraps.insert((fp, rev), w);
|
||||
}
|
||||
}
|
||||
}
|
||||
k.pending_wraps.clear();
|
||||
}
|
||||
kept.seal(&mut all.into_iter());
|
||||
self.kept = kept;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_paragraph_keeps_its_markup_until_it_changes() {
|
||||
let mut p = Paragraph::with_text("Hello", Default::default());
|
||||
let mut kept = Kept::new(KeptXml { format: "odt".into(), ..Default::default() });
|
||||
if let Some(k) = kept.get_mut() {
|
||||
k.keep_pending(p.rev, Arc::from("<text:p>Hello</text:p>"));
|
||||
}
|
||||
kept.seal(&mut std::iter::once(&p));
|
||||
let k = kept.of("odt").unwrap();
|
||||
assert_eq!(k.para(&p).map(|x| &**x), Some("<text:p>Hello</text:p>"));
|
||||
assert!(k.pending.is_empty());
|
||||
// The same paragraph again (a copy, its rev moved on): still what was read.
|
||||
p.touch();
|
||||
assert!(k.para(&p).is_some());
|
||||
// Changed: written from the model.
|
||||
p.text.push('!');
|
||||
assert!(k.para(&p).is_none());
|
||||
assert!(kept.of("docx").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_paragraph_changed_while_reading_keeps_nothing() {
|
||||
let mut p = Paragraph::with_text("Hi", Default::default());
|
||||
let mut kept = Kept::new(KeptXml { format: "odt".into(), ..Default::default() });
|
||||
if let Some(k) = kept.get_mut() {
|
||||
k.keep_pending(p.rev, Arc::from("<text:p>Hi</text:p>"));
|
||||
}
|
||||
p.touch();
|
||||
kept.seal(&mut std::iter::once(&p));
|
||||
assert!(kept.of("odt").unwrap().paras.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kept_markup_is_bounded() {
|
||||
let mut k = KeptXml::default();
|
||||
let big: Arc<str> = Arc::from("x".repeat(MAX_KEPT_BYTES / 2 + 1));
|
||||
k.keep_pending(1, big.clone());
|
||||
k.keep_pending(2, big);
|
||||
assert_eq!(k.pending.len(), 1);
|
||||
}
|
||||
|
||||
/// Filling in what objects show (`Document::fill_objects`, a chart drawn from its data)
|
||||
/// doesn't change the document: a paragraph's group and a text box's story stay as read.
|
||||
#[test]
|
||||
fn filling_objects_keeps_groups_and_stories_as_read() {
|
||||
use crate::para::InlineObject;
|
||||
let field =
|
||||
|| InlineObject::Field { instr: "X".into(), result: String::new(), locked: false, dirty: false, code: false, form: String::new() };
|
||||
let mut doc = crate::Document::from_text("Body");
|
||||
let mut boxed = Paragraph::with_text("In the box", Default::default());
|
||||
boxed.insert_object(0, field(), &Default::default()).unwrap();
|
||||
let id = doc.add_part(crate::PartKind::TextBox, vec![crate::para_block(boxed)]);
|
||||
doc.para_mut(crate::StoryRef::Body, &crate::Path::top(0)).unwrap().insert_object(0, field(), &Default::default()).unwrap();
|
||||
let fp = fingerprint(doc.body[0].as_para().unwrap());
|
||||
let mut k = KeptXml { format: "odt".into(), ..Default::default() };
|
||||
k.paras.insert(fp, Arc::from("<text:p>Body</text:p>"));
|
||||
k.groups.insert(fp, (1, 1));
|
||||
k.story_fps.insert(id, story_fingerprint(&doc, id));
|
||||
doc.kept = Kept::new(k);
|
||||
doc.fill_objects(&|o| matches!(o, InlineObject::Field { result, .. } if result.is_empty()), &mut |o| {
|
||||
if let InlineObject::Field { result, .. } = o {
|
||||
*result = "filled".into();
|
||||
}
|
||||
});
|
||||
let p = doc.body[0].as_para().unwrap();
|
||||
assert_ne!(fingerprint(p), fp, "the paragraph's fingerprint moved");
|
||||
let k = doc.kept.get().unwrap();
|
||||
assert!(k.para(p).is_some());
|
||||
assert_eq!(k.group(p), Some((1, 1)));
|
||||
assert_eq!(k.story_fps.get(&id), Some(&story_fingerprint(&doc, id)));
|
||||
}
|
||||
|
||||
/// Writing a document checks each kept paragraph's comments against what was read: the
|
||||
/// comments' replies are looked up once for the whole document, not once per comment.
|
||||
#[test]
|
||||
fn writing_many_comments_looks_their_replies_up_once() {
|
||||
use crate::para::InlineObject;
|
||||
let n = 50u32;
|
||||
let text = (0..n).map(|i| format!("Para {i}")).collect::<Vec<_>>().join("\n");
|
||||
let mut doc = crate::Document::from_text(&text);
|
||||
for i in 0..n {
|
||||
let part = doc.add_part(crate::PartKind::Comment, vec![crate::para_block(Paragraph::with_text("Note", Default::default()))]);
|
||||
doc.comments.insert(i, crate::Comment { parent: None, part, ..Default::default() });
|
||||
doc.para_mut(crate::StoryRef::Body, &crate::Path::top(i as usize))
|
||||
.unwrap()
|
||||
.insert_object(0, InlineObject::CommentStart { id: i }, &Default::default())
|
||||
.unwrap();
|
||||
}
|
||||
let mut k = KeptXml { format: "odt".into(), ..Default::default() };
|
||||
for i in 0..n {
|
||||
k.comment_fps.insert(i, comments_fingerprint(&doc, i).0);
|
||||
}
|
||||
let xml = "<text:p><office:annotation>x</office:annotation></text:p>";
|
||||
let before = REPLY_BUILDS.with(std::cell::Cell::get);
|
||||
let all = while_writing(&doc, || doc.body.iter().filter_map(|b| b.as_para()).all(|p| k.comments_as_read(&doc, p, xml)));
|
||||
assert!(all, "every paragraph's comment is as read");
|
||||
assert_eq!(REPLY_BUILDS.with(std::cell::Cell::get) - before, 1);
|
||||
// Outside a writing, nothing is kept between calls.
|
||||
assert!(doc.body.first().and_then(|b| b.as_para()).is_some_and(|p| k.comments_as_read(&doc, p, xml)));
|
||||
assert_eq!(REPLY_BUILDS.with(std::cell::Cell::get) - before, 2);
|
||||
}
|
||||
}
|
||||
@@ -120,14 +120,57 @@ fn attr_in(tag: &str) -> Option<String> {
|
||||
Some(unescape(rest.get(..end)?))
|
||||
}
|
||||
|
||||
fn unescape(s: &str) -> String {
|
||||
s.replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'").replace("&", "&")
|
||||
fn unescape(mut s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
while let Some((text, rest)) = s.split_once('&') {
|
||||
out.push_str(text);
|
||||
if let Some(end) = rest.find(['&', ';'])
|
||||
&& rest.as_bytes()[end] == b';'
|
||||
{
|
||||
let entity = &rest[..end];
|
||||
let decoded = match entity {
|
||||
"lt" => Some('<'),
|
||||
"gt" => Some('>'),
|
||||
"quot" => Some('"'),
|
||||
"apos" => Some('\''),
|
||||
"amp" => Some('&'),
|
||||
_ => entity.strip_prefix('#').and_then(|n| {
|
||||
let value = if let Some(hex) = n.strip_prefix('x') {
|
||||
(!hex.is_empty() && hex.bytes().all(|b| b.is_ascii_hexdigit())).then(|| u32::from_str_radix(hex, 16).ok()).flatten()
|
||||
} else {
|
||||
(!n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())).then(|| n.parse::<u32>().ok()).flatten()
|
||||
}?;
|
||||
char::from_u32(value)
|
||||
.filter(|c| matches!(*c, '\t' | '\n' | '\r' | '\u{20}'..='\u{d7ff}' | '\u{e000}'..='\u{fffd}' | '\u{10000}'..='\u{10ffff}'))
|
||||
}),
|
||||
};
|
||||
if let Some(c) = decoded {
|
||||
out.push(c);
|
||||
s = &rest[end + 1..];
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push('&');
|
||||
s = rest;
|
||||
}
|
||||
out.push_str(s);
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn drop_down_entities_decode_once_including_xml_whitespace() {
|
||||
let xml = r#"<w:ffData><w:name w:val="Name"/><w:ddList><w:listEntry w:val="A B	C D😀&#10;<>"'"/></w:ddList></w:ffData>"#;
|
||||
let f = LegacyForm::parse(xml).unwrap();
|
||||
assert_eq!(f.name(), "Name");
|
||||
assert_eq!(f.shown_text(), Some("A\nB\tC\rD😀 <>\"'"));
|
||||
let malformed = r#"<w:ffData><w:ddList><w:listEntry w:val="���&#-1;&#xZZ;&unknown;&"/></w:ddList></w:ffData>"#;
|
||||
assert_eq!(LegacyForm::parse(malformed).unwrap().shown_text(), Some("���&#-1;&#xZZ;&unknown;&"));
|
||||
}
|
||||
|
||||
#[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>"#);
|
||||
|
||||
+29
-7
@@ -2,9 +2,9 @@
|
||||
//!
|
||||
//! A [`Document`] holds the main story (`body`) and secondary stories (`parts`: headers,
|
||||
//! footers, notes, comments, text boxes). A story is a list of [`Block`]s: paragraphs and tables;
|
||||
//! table cells hold block lists of their own. Blocks are `Arc`-shared, so cloning a document is
|
||||
//! cheap (undo snapshots, handing a frozen copy to the renderer) and edits copy only what they
|
||||
//! touch (`Arc::make_mut`).
|
||||
//! table cells hold block lists of their own. Blocks, the style sheet and the list definitions
|
||||
//! are `Arc`-shared, so cloning a document is cheap (undo snapshots, handing a frozen copy to the
|
||||
//! renderer) and edits copy only what they touch (`Arc::make_mut`).
|
||||
//!
|
||||
//! Positions ([`Pos`]) name a story, a [`Path`] to a paragraph and a byte offset in it. Paths
|
||||
//! compare in document order.
|
||||
@@ -28,6 +28,7 @@ pub mod fill;
|
||||
pub mod freeform;
|
||||
pub mod graphic;
|
||||
pub mod ime_text;
|
||||
pub mod kept;
|
||||
pub mod legacy_form;
|
||||
pub mod line_dash;
|
||||
pub mod link;
|
||||
@@ -39,14 +40,17 @@ pub mod math_linear;
|
||||
pub mod math_symbols;
|
||||
pub mod numbering;
|
||||
pub mod para;
|
||||
pub mod picfx;
|
||||
pub mod props;
|
||||
pub mod resolve;
|
||||
pub mod section;
|
||||
pub mod shrink;
|
||||
pub mod smart_art;
|
||||
pub mod styles;
|
||||
pub mod symbol_fonts;
|
||||
pub mod table;
|
||||
pub mod wordart;
|
||||
pub mod work;
|
||||
pub mod wrap;
|
||||
pub mod zip_guard;
|
||||
|
||||
@@ -61,6 +65,7 @@ pub use numbering::{ListKind, Numbering};
|
||||
pub use para::{InlineObject, Paragraph, Run};
|
||||
pub use props::{Align, CharProps, ParaProps, Rgb, TextColor};
|
||||
pub use section::SectionProps;
|
||||
pub use shrink::FitState;
|
||||
pub use styles::{Style, StyleKind, StyleSheet};
|
||||
pub use table::{Cell, Row, Table};
|
||||
|
||||
@@ -72,6 +77,7 @@ mod block;
|
||||
mod compare;
|
||||
mod document;
|
||||
mod error;
|
||||
mod missing;
|
||||
mod parts;
|
||||
mod position;
|
||||
mod settings;
|
||||
@@ -81,14 +87,15 @@ pub use block::{Block, Blocks, BoxBudget, MAX_TEXT_BOX_DEPTH};
|
||||
pub use compare::para_block;
|
||||
use document::MediaIndex;
|
||||
pub use error::{DocError, Result};
|
||||
pub use missing::{MAX_MISSING_PICTURES, MissingPicture, MissingPictureReason, MissingPictures, picture_hash};
|
||||
pub use parts::{MAX_LINKED_BOXES, MAX_TEXT_BOX_INSET, NoteSeparators, Part, PartKind, PermRange, TEXT_BOX_INSET, TextBody, free_id};
|
||||
pub use position::{Path, Pos, StoryRef};
|
||||
pub use settings::{
|
||||
COMPAT_MODE_CURRENT, CoreProps, CustomProp, DEFAULT_GRID, DEFAULT_HYPHENATION_ZONE, LEGACY_COMPAT_MODE, PagePicture, Settings, Source,
|
||||
COMPAT_MODE_CURRENT, CoreProps, CustomProp, DEFAULT_GRID, DEFAULT_HYPHENATION_ZONE, LEGACY_COMPAT_MODE, OdfLayout, PagePicture, Settings, Source,
|
||||
THEME_COLORS, Watermark, Zoom, ZoomFit,
|
||||
};
|
||||
pub use text_walk::count_words;
|
||||
pub(crate) use text_walk::{PARA_WALK_DEPTH, walk_between};
|
||||
pub use text_walk::{count_chars, count_chars_no_spaces, count_words};
|
||||
|
||||
/// A document.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -98,8 +105,12 @@ pub struct Document {
|
||||
/// Properties of the last section (OOXML's body-level `w:sectPr`).
|
||||
pub last_section: SectionProps,
|
||||
pub parts: BTreeMap<u32, Part>,
|
||||
pub styles: StyleSheet,
|
||||
pub numbering: Numbering,
|
||||
/// Shared, like the blocks: an undo step copies it only when a command changes it (a full
|
||||
/// copy is kilobytes a style, so typing into a document with hundreds of styles cost megabytes
|
||||
/// a step).
|
||||
pub styles: Arc<StyleSheet>,
|
||||
/// Shared like [`Document::styles`].
|
||||
pub numbering: Arc<Numbering>,
|
||||
pub comments: BTreeMap<u32, Comment>,
|
||||
pub revisions: Vec<Revision>,
|
||||
pub settings: Settings,
|
||||
@@ -127,6 +138,17 @@ pub struct Document {
|
||||
/// by `==`).
|
||||
#[serde(skip)]
|
||||
media_index: MediaIndex,
|
||||
/// Markup kept from the file it was opened from, for writing back what isn't edited (see
|
||||
/// [`kept`]); never saved, ignored by `==`.
|
||||
#[serde(skip)]
|
||||
pub kept: kept::Kept,
|
||||
/// Pictures a reader dropped because their target is missing, external or link-only (strict
|
||||
/// export reports them); never saved, never drawn, ignored by `==`.
|
||||
#[serde(skip)]
|
||||
pub missing_pictures: MissingPictures,
|
||||
/// Cumulative fit-to-page typography reduction state; internal tracking outside user-visible custom properties.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub fit_state: Option<shrink::FitState>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+125
-2
@@ -1,5 +1,6 @@
|
||||
//! Which hyperlink targets may leave the document: followed by the OS, or written into an
|
||||
//! exported HTML/Markdown file where a browser would run them.
|
||||
//! Hyperlink targets: where a `HYPERLINK` field code points, and which targets may leave the
|
||||
//! document: followed by the OS, or written into an exported HTML/Markdown file where a browser
|
||||
//! would run them.
|
||||
|
||||
/// Whether Ctrl/⌘+click may hand a document's hyperlink to the system: web and email links only,
|
||||
/// so a document can't launch `file:` paths, programs or custom-scheme handlers. Exact: a leading
|
||||
@@ -18,6 +19,12 @@ pub fn is_web_or_mail_link(url: &str) -> bool {
|
||||
/// 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 {
|
||||
// Exporters drop other control characters and noncharacters when they write the link
|
||||
// (`file\u{85}:` comes out `file:`), so a link holding one is refused, not judged as it
|
||||
// stands and written as something else.
|
||||
if url.chars().any(|c| (c.is_control() && !c.is_ascii_control()) || matches!(c, '\u{FFFE}' | '\u{FFFF}')) {
|
||||
return false;
|
||||
}
|
||||
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("//") {
|
||||
@@ -39,10 +46,122 @@ fn scheme(url: &str) -> Option<String> {
|
||||
(first.is_ascii_alphabetic() && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))).then(|| s.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
/// Where a `HYPERLINK` field code points, as a `w:hyperlink` reads: `url`, `url#bookmark` (`\l`)
|
||||
/// or `#bookmark`. None for any other field, or one with no target.
|
||||
pub fn hyperlink_target(instr: &str) -> Option<String> {
|
||||
// Only a `HYPERLINK` code is split up (a field's code may run to megabytes).
|
||||
if !instr.trim_start().get(..9).is_some_and(|k| k.eq_ignore_ascii_case("HYPERLINK")) {
|
||||
return None;
|
||||
}
|
||||
let args = field_args(instr);
|
||||
let mut args = args.iter().peekable();
|
||||
if !args.next().is_some_and(|(k, quoted)| !quoted && k.eq_ignore_ascii_case("HYPERLINK")) {
|
||||
return None;
|
||||
}
|
||||
let is_switch = |(a, quoted): &&(String, bool)| !quoted && a.starts_with('\\');
|
||||
let (mut url, mut anchor) = (None, None);
|
||||
while let Some(arg @ (a, _)) = args.next() {
|
||||
if !is_switch(&arg) {
|
||||
url = url.or(Some(a.as_str()));
|
||||
continue;
|
||||
}
|
||||
// `\l` names a bookmark, `\o` a tooltip, `\t` a frame, `\*` `\#` `\@` a format; `\m` and `\n` take none.
|
||||
let takes = matches!(a.to_ascii_lowercase().as_str(), "\\l" | "\\o" | "\\t" | "\\*" | "\\#" | "\\@");
|
||||
let value = takes.then(|| args.next_if(|v| !is_switch(v))).flatten();
|
||||
if a.eq_ignore_ascii_case("\\l")
|
||||
&& let Some((v, _)) = value
|
||||
{
|
||||
anchor = Some(v.as_str());
|
||||
}
|
||||
}
|
||||
let url = url.map(str::trim).filter(|u| !u.is_empty());
|
||||
let anchor = anchor.map(|a| a.trim().trim_start_matches('#')).filter(|a| !a.is_empty());
|
||||
match (url, anchor) {
|
||||
// `\l` replaces a fragment the address already has, as in Word.
|
||||
(Some(u), Some(a)) => Some(format!("{}#{a}", u.split_once('#').map_or(u, |(base, _)| base))),
|
||||
(Some(u), None) => Some(u.to_string()),
|
||||
(None, Some(a)) => Some(format!("#{a}")),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A field code's words: the field's name, its switches and its arguments, each with whether
|
||||
/// it was quoted. In quotes a backslash escapes the next character, as in Word.
|
||||
fn field_args(instr: &str) -> Vec<(String, bool)> {
|
||||
let mut out = Vec::new();
|
||||
let mut chars = instr.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c.is_whitespace() {
|
||||
continue;
|
||||
}
|
||||
let mut word = String::new();
|
||||
if c == '"' {
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'"' => break,
|
||||
'\\' => word.extend(chars.next()),
|
||||
_ => word.push(c),
|
||||
}
|
||||
}
|
||||
out.push((word, true));
|
||||
} else {
|
||||
word.push(c);
|
||||
while let Some(c) = chars.next_if(|c| !c.is_whitespace() && *c != '"') {
|
||||
word.push(c);
|
||||
}
|
||||
out.push((word, false));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Every reader's `HYPERLINK` cases: switches with and without values, quotes, escapes, spelling.
|
||||
#[test]
|
||||
fn hyperlink_field_codes_name_their_targets() {
|
||||
for (code, want) in [
|
||||
(r#"HYPERLINK "http://example.com" \l "heading""#, Some("http://example.com#heading")),
|
||||
(r#"HYPERLINK \l "heading""#, Some("#heading")),
|
||||
(r#"HYPERLINK \l "_Toc1" "#, Some("#_Toc1")),
|
||||
(r#"Hyperlink "http://example.com/a path""#, Some("http://example.com/a path")),
|
||||
("HYPERLINK https://example.org/b", Some("https://example.org/b")),
|
||||
("HYPERLINK\t\"http://example.com\"", Some("http://example.com")),
|
||||
(r#"HYPERLINK"http://example.com""#, Some("http://example.com")),
|
||||
(r#"HYPERLINK "http://example.com/path\latest""#, Some("http://example.com/pathlatest")),
|
||||
(r#"HYPERLINK "x \* Upper""#, Some("x * Upper")),
|
||||
(r#"HYPERLINK "C:\\Docs\\My file.docx""#, Some(r"C:\Docs\My file.docx")),
|
||||
(r#"HYPERLINK "say \"hi\"""#, Some(r#"say "hi""#)),
|
||||
(r#"HYPERLINK " http://example.com ""#, Some("http://example.com")),
|
||||
// Switches that take a value, before or after the address, and those that take none.
|
||||
(r#"HYPERLINK "http://example.com" \o "Help text""#, Some("http://example.com")),
|
||||
(r#"hyperlink \o "A tip" \t "_blank" "https://example.org/""#, Some("https://example.org/")),
|
||||
(r#"HYPERLINK \l "bm" \o "tip""#, Some("#bm")),
|
||||
(r#"HYPERLINK \l "Sec" \m"#, Some("#Sec")),
|
||||
(r#"HYPERLINK \n "http://example.com""#, Some("http://example.com")),
|
||||
(r#"HYPERLINK \l "x" \* MERGEFORMAT"#, Some("#x")),
|
||||
(r#"HYPERLINK "http://example.com" \l \o "tip""#, Some("http://example.com")),
|
||||
(r#"HYPERLINK \l "bm" \l"#, Some("#bm")),
|
||||
// A bookmark's own `#`, an address that has a fragment, and an empty address.
|
||||
(r##"HYPERLINK "http://example.com" \l "#top""##, Some("http://example.com#top")),
|
||||
(r#"HYPERLINK "http://example.com/#a" \l "b""#, Some("http://example.com/#b")),
|
||||
(r#"HYPERLINK "" \l "x""#, Some("#x")),
|
||||
// No target, or not a `HYPERLINK` field.
|
||||
("HYPERLINK", None),
|
||||
(r#"HYPERLINK "" \o "tip""#, None),
|
||||
(r#"HYPERLINKS "https://example.org/""#, None),
|
||||
(r#"HYPERLINKX "http://example.com""#, None),
|
||||
(r#"HYPERLINK\l "x""#, None),
|
||||
("PAGEREF _Toc1 \\h", None),
|
||||
("ab日本語", None),
|
||||
("", None),
|
||||
] {
|
||||
assert_eq!(hyperlink_target(code).as_deref(), want, "{code:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_web_and_mail_links_are_followable() {
|
||||
for ok in ["https://example.com/a?b#c", "http://example.com", "HTTPS://EXAMPLE.COM", "mailto:someone@example.com"] {
|
||||
@@ -87,6 +206,10 @@ mod tests {
|
||||
"\\\\attacker.example\\share\\x",
|
||||
"/\\attacker.example\\x",
|
||||
" //attacker.example/x",
|
||||
// Characters the exporters drop on writing don't hide a scheme.
|
||||
"file\u{85}:///etc/passwd",
|
||||
"java\u{9F}script:alert(1)",
|
||||
"/\u{FFFE}/attacker.example/x",
|
||||
"",
|
||||
] {
|
||||
assert!(!is_safe_href(bad), "{bad:?}");
|
||||
|
||||
@@ -22,10 +22,29 @@ pub fn decimal_separator(lang: Option<&str>) -> char {
|
||||
|
||||
const SPACES: &[char] = &[' ', '\u{a0}', '\u{202f}', '\u{2009}'];
|
||||
const CURRENCY: &[char] = &['$', '€', '£', '¥', '¢', '₽', '₴', '₺', '₹', '₩', '₪', '₫', '฿', '₱', '₸', '₼', '₾'];
|
||||
/// Currency signs spelled with more than one character: the Panamanian balboa (`B/.`).
|
||||
const CURRENCY_SYMBOLS: &[&str] = &["B/."];
|
||||
|
||||
/// Strip a currency symbol or a short currency word (`zł`, `kr`, `PLN`) from either end.
|
||||
/// Trim spaces and single-character currency signs from either end.
|
||||
fn trim_currency(s: &str) -> &str {
|
||||
s.trim_matches(|c: char| c.is_whitespace() || CURRENCY.contains(&c))
|
||||
}
|
||||
|
||||
/// Strip a currency symbol or a short currency word (`zł`, `kr`, `PLN`, `B/.`) from either end.
|
||||
/// As Word does, a multi-character sign is a word of its own (`B/. 1,250.50`, `1,250.50 B/.`);
|
||||
/// `B/.1,250.50` and `B/ 1,250.50` are not numbers.
|
||||
fn strip_currency(s: &str) -> &str {
|
||||
let mut s = s.trim_matches(|c: char| c.is_whitespace() || CURRENCY.contains(&c));
|
||||
let mut s = trim_currency(s);
|
||||
if let Some(rest) =
|
||||
CURRENCY_SYMBOLS.iter().find_map(|sym| s.strip_prefix(sym).filter(|rest| rest.chars().next().is_some_and(char::is_whitespace)))
|
||||
{
|
||||
s = trim_currency(rest);
|
||||
}
|
||||
if let Some(rest) =
|
||||
CURRENCY_SYMBOLS.iter().find_map(|sym| s.strip_suffix(sym).filter(|rest| rest.chars().next_back().is_some_and(char::is_whitespace)))
|
||||
{
|
||||
s = trim_currency(rest);
|
||||
}
|
||||
if let Some((head, word)) = s.rsplit_once(SPACES)
|
||||
&& (1..=3).contains(&word.chars().count())
|
||||
&& word.chars().all(|c| c.is_alphabetic() || c == '.')
|
||||
@@ -137,6 +156,7 @@ mod tests {
|
||||
assert_eq!(parse_number("1.5", pl), Some(1.5));
|
||||
assert_eq!(parse_number("-2,25", pl), Some(-2.25));
|
||||
assert_eq!(parse_number("PLN 12", pl), Some(12.0));
|
||||
assert_eq!(parse_number("-B/. 1 250,50", pl), Some(-1250.5));
|
||||
assert_eq!(parse_number("abc", pl), None);
|
||||
assert_eq!(parse_number("1,2,3", pl), None);
|
||||
// From review: ordinals, a leading zero, percentages and an en dash minus.
|
||||
@@ -174,11 +194,40 @@ mod tests {
|
||||
assert_eq!(format_number(5.75, Some("es-ES")), "5,75");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn balboa_numbers() {
|
||||
let en = Some("en-US");
|
||||
// Word 16.0 reads `B/.` as a word of its own, before or after the number.
|
||||
assert_eq!(parse_number("B/. 1,234.50", en), Some(1234.5));
|
||||
assert_eq!(parse_number("1,234.50 B/.", en), Some(1234.5));
|
||||
assert_eq!(parse_number("B/.\u{a0}1,234.50", en), Some(1234.5), "a no-break space after the sign");
|
||||
assert_eq!(parse_number("B/. 1,234.50 B/.", en), Some(1234.5), "the sign at both ends");
|
||||
assert_eq!(parse_number("-B/. 1,234.50", en), Some(-1234.5), "a minus before the sign");
|
||||
assert_eq!(parse_number("B/. 1,234.50", Some("es-PA")), Some(1234.5), "the balboa's own region");
|
||||
// The sign has no space of its own in Word: touching the number is text.
|
||||
assert_eq!(parse_number("B/.1,234.50", en), None);
|
||||
assert_eq!(parse_number("1,234.50B/.", en), None);
|
||||
assert_eq!(parse_number("B/ 1,234.50", en), None, "the balboa is B/. in Word");
|
||||
assert_eq!(parse_number("B/.", en), None);
|
||||
// The slash and the full stop make `B/.` ambiguous with a fraction: the sign is matched at
|
||||
// the ends of the text only, so a fraction is never read as a balboa amount and a sign in
|
||||
// the middle is not a sign.
|
||||
assert_eq!(parse_number("1/.5", en), None, "a fraction is not a price");
|
||||
assert_eq!(parse_number("1/.5", Some("es-PA")), None);
|
||||
assert_eq!(parse_number("1/.5 B/.", en), None, "the sign at the end does not rescue a fraction");
|
||||
assert_eq!(parse_number("B/. 1/.5", en), None);
|
||||
assert_eq!(parse_number("2 B/. 1,234.50", en), None, "the sign sits before the number");
|
||||
assert_eq!(parse_number("1,234.50 B/. 1,234.50", en), None, "the sign sits between two numbers");
|
||||
assert_eq!(parse_number("1,234.50/2", en), None, "a division is not a balboa amount");
|
||||
assert_eq!(parse_number("B/. 1,234.50,50", en), None, "a second decimal separator");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn english_numbers() {
|
||||
let en = Some("en-US");
|
||||
assert_eq!(parse_number("1,250.50", en), Some(1250.5));
|
||||
assert_eq!(parse_number("$1,250", en), Some(1250.0));
|
||||
assert_eq!(parse_number("PAB 1,250.50", en), Some(1250.5), "the ISO code");
|
||||
assert_eq!(parse_number("1.5", en), Some(1.5));
|
||||
assert_eq!(parse_number("12", None), Some(12.0));
|
||||
assert_eq!(parse_number(".5", None), Some(0.5));
|
||||
|
||||
@@ -16,6 +16,12 @@ pub type Arg = Vec<MNode>;
|
||||
/// Deepest structure nesting kept (parsers stop here; layout draws nothing deeper).
|
||||
pub const MAX_DEPTH: usize = 64;
|
||||
|
||||
/// Most matrix columns kept (writers pad every row to the widest, so this bounds a row's cost).
|
||||
pub const MAX_MATRIX_COLS: usize = 64;
|
||||
|
||||
/// Most matrix rows kept.
|
||||
pub const MAX_MATRIX_ROWS: usize = 1000;
|
||||
|
||||
/// An equation's structure, plus what's needed to write it back.
|
||||
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
|
||||
pub struct Math {
|
||||
|
||||
@@ -769,6 +769,30 @@ pub fn autocorrect(root: &mut Arg, pos: &MathPos) -> Option<MathPos> {
|
||||
Some(MathPos { path: pos.path, off: pos.off - name.chars().count() })
|
||||
}
|
||||
|
||||
/// The character just before the caret is an n-ary operator (∫, ∑…) in plain math text, as
|
||||
/// AutoCorrect leaves `\int` or `\sum`.
|
||||
pub fn nary_before(root: &Arg, pos: &MathPos) -> bool {
|
||||
let pos = clamp(root, pos);
|
||||
let Some(a) = arg_at(root, &pos.path) else { return false };
|
||||
let (i, k) = match locate(a, pos.off) {
|
||||
Loc::Inside(i, k) => (i, k),
|
||||
Loc::Before(i) => match i.checked_sub(1).and_then(|i| a.get(i).map(|n| (i, node_units(n)))) {
|
||||
Some(at) => at,
|
||||
None => return false,
|
||||
},
|
||||
};
|
||||
let Some(MNode::Run(r)) = a.get(i) else { return false };
|
||||
!r.nor && !r.lit && k.checked_sub(1).and_then(|k| r.text.chars().nth(k)).is_some_and(crate::math::is_nary_char)
|
||||
}
|
||||
|
||||
/// The node just before the caret is a built n-ary operator.
|
||||
pub fn nary_node_before(root: &Arg, pos: &MathPos) -> bool {
|
||||
let pos = clamp(root, pos);
|
||||
let Some(a) = arg_at(root, &pos.path) else { return false };
|
||||
let Loc::Before(i) = locate(a, pos.off) else { return false };
|
||||
i.checked_sub(1).and_then(|i| a.get(i)).is_some_and(|n| matches!(n, MNode::Nary { .. }))
|
||||
}
|
||||
|
||||
/// Characters that make typed linear text build up into structures.
|
||||
fn builds(s: &str) -> bool {
|
||||
s.chars().any(|c| {
|
||||
@@ -872,6 +896,28 @@ pub fn build_up(root: &mut Arg, pos: &MathPos, latex: bool) -> Option<MathPos> {
|
||||
Some(clamp(root, &out))
|
||||
}
|
||||
|
||||
/// Typing that just built an n-ary operator without its operand (`∫_0^1` then Space) goes on in
|
||||
/// the empty operand, as after inserting one from the gallery, so what is typed next is
|
||||
/// integrated or summed (#4917). An operator whose operand ends with another (`∫∫`) leads to the
|
||||
/// innermost one. Other carets, and moves or Enter that build up, stay put.
|
||||
pub fn into_empty_operand(root: &Arg, pos: &MathPos) -> MathPos {
|
||||
let mut path = pos.path.clone();
|
||||
let mut off = pos.off;
|
||||
while path.len() < MAX_DEPTH
|
||||
&& let Some(a) = arg_at(root, &path)
|
||||
&& let Loc::Before(i) = locate(a, off)
|
||||
&& let Some(k) = i.checked_sub(1)
|
||||
&& let Some(MNode::Nary { e, .. }) = a.get(k)
|
||||
{
|
||||
path.push((k, 2));
|
||||
if e.is_empty() {
|
||||
return MathPos { path, off: 0 };
|
||||
}
|
||||
off = units(e);
|
||||
}
|
||||
pos.clone()
|
||||
}
|
||||
|
||||
/// A top-level `#` numbers the equation: wrap it as a one-row equation array.
|
||||
pub fn number_equation(root: &mut Arg) -> bool {
|
||||
if has_number_mark(root) && !matches!(root.as_slice(), [MNode::EqArr { .. }]) {
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
//! Pictures a reader could not bring in or an exporter could not draw, for strict export (#6005).
|
||||
//! Kept beside the document while it is open; never saved, never drawn, and not part of the
|
||||
//! document's content (`==` ignores it).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Most pictures remembered per document; past it [`MissingPictures::truncated`] is set.
|
||||
pub const MAX_MISSING_PICTURES: usize = 256;
|
||||
|
||||
/// Most characters of a picture's part or story name a record keeps (names come from the file).
|
||||
pub const MAX_PICTURE_NAME_CHARS: usize = 1024;
|
||||
|
||||
/// Why a picture is not in the document or not drawn.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum MissingPictureReason {
|
||||
/// The relationship names a part the file does not contain, or no relationship at all.
|
||||
MissingPart,
|
||||
/// The target is outside the file (`TargetMode="External"`); it is never fetched.
|
||||
ExternalTarget,
|
||||
/// The picture only links to a file (`r:link`) and holds no embedded copy.
|
||||
LinkOnly,
|
||||
/// The bytes are a picture format we know but they do not decode.
|
||||
Undecodable,
|
||||
/// The bytes are not a picture format we can draw.
|
||||
UnsupportedFormat,
|
||||
}
|
||||
|
||||
impl MissingPictureReason {
|
||||
/// The words used in reports (`"missing part"`, `"external target"`, `"link only"`,
|
||||
/// `"undecodable"`, `"unsupported format"`).
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::MissingPart => "missing part",
|
||||
Self::ExternalTarget => "external target",
|
||||
Self::LinkOnly => "link only",
|
||||
Self::Undecodable => "undecodable",
|
||||
Self::UnsupportedFormat => "unsupported format",
|
||||
}
|
||||
}
|
||||
|
||||
/// The reason a [`code`](Self::code) names.
|
||||
pub fn from_code(code: &str) -> Option<Self> {
|
||||
[Self::MissingPart, Self::ExternalTarget, Self::LinkOnly, Self::Undecodable, Self::UnsupportedFormat].into_iter().find(|r| r.code() == code)
|
||||
}
|
||||
|
||||
/// The stable snake_case code the JSON report carries (`"missing_part"`, `"external_target"`,
|
||||
/// `"link_only"`, `"undecodable"`, `"unsupported_format"`); never reworded.
|
||||
pub fn code(self) -> &'static str {
|
||||
match self {
|
||||
Self::MissingPart => "missing_part",
|
||||
Self::ExternalTarget => "external_target",
|
||||
Self::LinkOnly => "link_only",
|
||||
Self::Undecodable => "undecodable",
|
||||
Self::UnsupportedFormat => "unsupported_format",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One picture that is missing.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct MissingPicture {
|
||||
/// The target as written, or the part name it resolved to (a media key for a decoded one).
|
||||
pub part: String,
|
||||
/// The story part it is in (`word/document.xml`, `word/header2.xml`), as the source file
|
||||
/// names it; empty when not known.
|
||||
pub story: String,
|
||||
pub reason: MissingPictureReason,
|
||||
}
|
||||
|
||||
/// A capped list of [`MissingPicture`]s, with the source names a report needs to say where a
|
||||
/// picture is (a reader fills them; a document made or opened another way leaves them empty).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct MissingPictures {
|
||||
pub list: Vec<MissingPicture>,
|
||||
/// More than [`MAX_MISSING_PICTURES`] were found; the rest are not listed.
|
||||
pub truncated: bool,
|
||||
/// Pictures found, those past the cap included (saturating). Each distinct one counts once.
|
||||
pub total: u64,
|
||||
/// The main story's part name in the source file (`word/document.xml`).
|
||||
pub main: String,
|
||||
/// A header, footer, note or comment story's part id ([`crate::Document::parts`]) to the
|
||||
/// part it was read from (`word/header2.xml`). Ids freed and reused after opening may name
|
||||
/// the old part.
|
||||
pub part_names: std::collections::BTreeMap<u32, std::sync::Arc<str>>,
|
||||
/// A [`crate::Document::media`] key to the part its bytes were read from
|
||||
/// (`word/media/image2.bin`).
|
||||
pub media_parts: std::collections::BTreeMap<String, String>,
|
||||
/// What `list` holds, for an O(1) test of "already there" (a hostile file has many).
|
||||
seen: std::collections::HashSet<MissingPicture>,
|
||||
/// Hashes of the distinct pictures past the cap (8 bytes each), so the total counts them once.
|
||||
unlisted: std::collections::HashSet<u64>,
|
||||
/// The chunk parts whose unlisted pictures [`Self::add_unlisted`] has counted, so a chunk
|
||||
/// imported twice counts them once.
|
||||
counted_chunks: std::collections::HashSet<String>,
|
||||
}
|
||||
|
||||
impl MissingPictures {
|
||||
/// Add `n` pictures of the chunk part `chunk` that its reader counted but did not list. The
|
||||
/// same chunk part counts once however often it is imported (its listed pictures dedupe
|
||||
/// through [`Self::push`]).
|
||||
pub fn add_unlisted(&mut self, chunk: &str, n: u64) {
|
||||
if n > 0 && self.counted_chunks.insert(chunk.to_string()) {
|
||||
self.total = self.total.saturating_add(n);
|
||||
}
|
||||
}
|
||||
|
||||
/// Add one (the same part, story and reason once).
|
||||
pub fn push(&mut self, mut item: MissingPicture) {
|
||||
for name in [&mut item.part, &mut item.story] {
|
||||
if name.len() > MAX_PICTURE_NAME_CHARS {
|
||||
*name = name.chars().take(MAX_PICTURE_NAME_CHARS).collect();
|
||||
}
|
||||
}
|
||||
if self.seen.contains(&item) {
|
||||
return;
|
||||
}
|
||||
if self.list.len() >= MAX_MISSING_PICTURES {
|
||||
self.truncated = true;
|
||||
if self.unlisted.insert(picture_hash(&item)) {
|
||||
self.total = self.total.saturating_add(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.total = self.total.saturating_add(1);
|
||||
self.seen.insert(item.clone());
|
||||
self.list.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
/// A 64-bit hash of a record, to tell the ones past a cap apart without keeping them.
|
||||
pub fn picture_hash(item: &MissingPicture) -> u64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
item.hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
impl PartialEq for MissingPictures {
|
||||
/// Not part of the document's content.
|
||||
fn eq(&self, _: &Self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
+167
-35
@@ -228,7 +228,7 @@ impl Numbering {
|
||||
/// Whether a paragraph in list `num` at `level` gets a label ([`Counters::next_label`] gives
|
||||
/// one), without counting it.
|
||||
pub fn shows_label(&self, num: u32, level: u8) -> bool {
|
||||
self.abstract_of(num).is_some() && self.level(num, level.min(8)).is_some_and(|d| d.format != NumFormat::None)
|
||||
self.abstract_of(num).is_some() && self.level(num, level.min(8)).is_some()
|
||||
}
|
||||
pub fn level(&self, num: u32, level: u8) -> Option<&Level> {
|
||||
if let Some((_, l)) = self.num(num)?.level_overrides.iter().find(|(i, _)| *i == level) {
|
||||
@@ -294,6 +294,14 @@ pub struct Counters {
|
||||
/// Per abstract list: whether each level has counted yet (a value of 0 can be a real count when a
|
||||
/// level starts at 0).
|
||||
flags: HashMap<u32, [LevelFlags; 9]>,
|
||||
/// The last explicitly rendered label and template at each abstract-list level.
|
||||
labels: HashMap<(u32, u8), CountedLabel>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct CountedLabel {
|
||||
label: Option<String>,
|
||||
levels: Vec<usize>,
|
||||
}
|
||||
|
||||
/// Whether a level has counted yet, and the value its next item takes if a start override says so.
|
||||
@@ -317,11 +325,9 @@ impl Counters {
|
||||
let lv = (level as usize).min(8);
|
||||
// This list's own definition of a level wins over its abstract list's.
|
||||
let level_def = |k: usize| numbering.level(num, k as u8);
|
||||
// A "none" level (Word's numFmt none) still counts and restarts the levels below it; only
|
||||
// its number reads as nothing, so its literal text ("RESPONSE:") shows (#4985).
|
||||
let def = level_def(lv)?.clone();
|
||||
// A "none" level shows no label at all (Word's numFmt none) — not even its text.
|
||||
if def.format == NumFormat::None {
|
||||
return None;
|
||||
}
|
||||
let st = self.state.entry(abs.id).or_insert([0; 9]);
|
||||
let fl = self.flags.entry(abs.id).or_default();
|
||||
// Only this level's override, not the num's others: the counters are shared by every
|
||||
@@ -366,42 +372,108 @@ impl Counters {
|
||||
if restarts {
|
||||
*deeper = 0;
|
||||
*f = LevelFlags::default();
|
||||
self.labels.remove(&(abs.id, d as u8));
|
||||
}
|
||||
}
|
||||
let vals = *st;
|
||||
let started = *fl;
|
||||
if def.format == NumFormat::Bullet {
|
||||
return Some((def.text.clone(), def));
|
||||
}
|
||||
let mut label = String::new();
|
||||
// Word's level text is short; .docx and the lists command already cap it at 64.
|
||||
let mut chars = def.text.chars().take(MAX_LEVEL_TEXT).peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '%'
|
||||
&& let Some(d) = chars.peek().and_then(|d| d.to_digit(10))
|
||||
{
|
||||
chars.next();
|
||||
let k = (d as usize).saturating_sub(1).min(8);
|
||||
let val = vals.get(k).copied().unwrap_or(0);
|
||||
let counted = started.get(k).is_some_and(|f| f.started);
|
||||
let val = if counted { val } else { level_def(k).map(|l| l.start).unwrap_or(1) };
|
||||
// Legal numbering (w:isLgl) shows every level in Arabic digits, whatever its own
|
||||
// format (二 of a Chinese outline becomes 2 in "2.1").
|
||||
let fmt = match level_def(k).map(|l| l.format).unwrap_or(NumFormat::Decimal) {
|
||||
NumFormat::DecimalZero => NumFormat::DecimalZero,
|
||||
_ if def.legal => NumFormat::Decimal,
|
||||
f => f,
|
||||
};
|
||||
label.push_str(&fmt.format(val));
|
||||
if label.len() > MAX_LABEL_BYTES {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
label.push(c);
|
||||
}
|
||||
}
|
||||
let numeric = def.format != NumFormat::Bullet;
|
||||
let label = if numeric { render_label(numbering, num, &def, &vals, &started) } else { def.text.clone() };
|
||||
// A "none" level shows its literal text, but has no number of its own for a reference to
|
||||
// show (#4985): it is kept as one that shows none.
|
||||
let counted = if def.format == NumFormat::None {
|
||||
CountedLabel { label: None, levels: Vec::new() }
|
||||
} else {
|
||||
CountedLabel { label: numeric.then(|| label.clone()), levels: template_levels(&def.text) }
|
||||
};
|
||||
self.labels.insert((abs.id, lv as u8), counted);
|
||||
Some((label, def))
|
||||
}
|
||||
/// Which levels of `num`'s list have counted a paragraph yet (a level never used has no
|
||||
/// number of its own to show in a reference).
|
||||
pub fn started(&self, numbering: &Numbering, num: u32) -> Option<[bool; 9]> {
|
||||
let abs = numbering.abstract_of(num)?;
|
||||
Some(std::array::from_fn(|k| self.labels.get(&(abs.id, k as u8)).is_some_and(|last| last.label.is_some())))
|
||||
}
|
||||
|
||||
/// The label `level` of `num`'s list shows at this point of the walk, without counting: what
|
||||
/// the last paragraph at that level showed (`"14."` while walking clause 14's sub-clauses).
|
||||
/// `None` for a bullet or "none" level, or a level the list doesn't define.
|
||||
pub fn current_label(&self, numbering: &Numbering, num: u32, level: u8) -> Option<String> {
|
||||
let abs = numbering.abstract_of(num)?;
|
||||
if let Some(last) = self.labels.get(&(abs.id, level.min(8))) {
|
||||
return last.label.clone();
|
||||
}
|
||||
// A deeper item can count a level implicitly without rendering a paragraph there.
|
||||
let def = numbering.level(num, level.min(8))?;
|
||||
if matches!(def.format, NumFormat::None | NumFormat::Bullet) {
|
||||
return None;
|
||||
}
|
||||
let vals = self.state.get(&abs.id).copied().unwrap_or([0; 9]);
|
||||
let started = self.flags.get(&abs.id).copied().unwrap_or_default();
|
||||
Some(render_label(numbering, num, def, &vals, &started))
|
||||
}
|
||||
|
||||
/// Levels shown by the last rendered label, or the current definition for an implicit level.
|
||||
pub fn current_template_levels(&self, numbering: &Numbering, num: u32, level: u8) -> Vec<usize> {
|
||||
if let Some(abs) = numbering.abstract_of(num)
|
||||
&& let Some(last) = self.labels.get(&(abs.id, level.min(8)))
|
||||
{
|
||||
return last.levels.clone();
|
||||
}
|
||||
numbering.level(num, level.min(8)).map(|def| template_levels(&def.text)).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The zero-based levels a label template shows (`"%1.%2"` → `[0, 1]`, `"(%3)"` → `[2]`).
|
||||
pub fn template_levels(text: &str) -> Vec<usize> {
|
||||
let mut out = Vec::new();
|
||||
let mut chars = text.chars().take(MAX_LEVEL_TEXT).peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '%'
|
||||
&& let Some(d) = chars.peek().and_then(|d| d.to_digit(10))
|
||||
{
|
||||
chars.next();
|
||||
let k = (d as usize).saturating_sub(1).min(8);
|
||||
if !out.contains(&k) {
|
||||
out.push(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Render a list label without advancing counters, retaining the existing hostile-input caps.
|
||||
fn render_label(numbering: &Numbering, num: u32, def: &Level, vals: &[u32; 9], started: &[LevelFlags; 9]) -> String {
|
||||
let level_def = |k: usize| numbering.level(num, k as u8);
|
||||
let mut label = String::new();
|
||||
// Word's level text is short; .docx and the lists command already cap it at 64.
|
||||
let mut chars = def.text.chars().take(MAX_LEVEL_TEXT).peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '%'
|
||||
&& let Some(d) = chars.peek().and_then(|d| d.to_digit(10))
|
||||
{
|
||||
chars.next();
|
||||
let k = (d as usize).saturating_sub(1).min(8);
|
||||
let val = vals.get(k).copied().unwrap_or(0);
|
||||
let counted = started.get(k).is_some_and(|f| f.started);
|
||||
let val = if counted { val } else { level_def(k).map(|l| l.start).unwrap_or(1) };
|
||||
// Legal numbering (w:isLgl) shows every level in Arabic digits, whatever its own
|
||||
// format (二 of a Chinese outline becomes 2 in "2.1"); a "none" level stays empty.
|
||||
let fmt = match level_def(k).map(|l| l.format).unwrap_or(NumFormat::Decimal) {
|
||||
f @ (NumFormat::DecimalZero | NumFormat::None) => f,
|
||||
_ if def.legal => NumFormat::Decimal,
|
||||
f => f,
|
||||
};
|
||||
label.push_str(&fmt.format(val));
|
||||
if label.len() > MAX_LABEL_BYTES {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
label.push(c);
|
||||
}
|
||||
}
|
||||
label
|
||||
}
|
||||
|
||||
/// Longest level text (in characters) expanded into a list label.
|
||||
@@ -413,6 +485,45 @@ const MAX_LABEL_BYTES: usize = 256;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Read-only counter APIs retain the renderer's bounds and never advance a list.
|
||||
#[test]
|
||||
fn reference_labels_are_bounded_and_do_not_advance_counters() {
|
||||
let mut n = Numbering::default();
|
||||
let id = n.add_list(ListKind::Numbered);
|
||||
if let Some(num) = n.nums.iter_mut().find(|x| x.id == id) {
|
||||
num.level_overrides = vec![(0, Level { text: "%1".repeat(30_000), ..Level::default() })];
|
||||
}
|
||||
let mut c = Counters::default();
|
||||
let label = c.next_label(&n, id, 0).unwrap().0;
|
||||
let before = c.values(&n, id);
|
||||
assert_eq!(c.current_label(&n, id, 0).as_deref(), Some(label.as_str()));
|
||||
assert_eq!(c.values(&n, id), before);
|
||||
assert!(label.len() <= 512);
|
||||
assert_eq!(template_levels(&"%1%9".repeat(30_000)), [0, 8]);
|
||||
assert!(c.started(&n, id).unwrap()[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_ancestor_label_keeps_the_instance_that_rendered_it() {
|
||||
let mut numbering = Numbering::default();
|
||||
let first = numbering.add_list(ListKind::Numbered);
|
||||
numbering.abstracts[0].levels[0].text = "Clause %1.".into();
|
||||
let mut second = numbering.num(first).unwrap().clone();
|
||||
second.id = first + 1;
|
||||
second.level_overrides = vec![
|
||||
(0, Level { text: "Part %1.%2.".into(), ..Level::default() }),
|
||||
(1, Level { format: NumFormat::LowerLetter, text: "(%2)".into(), ..Level::default() }),
|
||||
];
|
||||
let second_id = second.id;
|
||||
numbering.nums.push(second);
|
||||
let mut counters = Counters::default();
|
||||
assert_eq!(counters.next_label(&numbering, first, 0).unwrap().0, "Clause 1.");
|
||||
assert_eq!(counters.next_label(&numbering, second_id, 1).unwrap().0, "(a)");
|
||||
assert_eq!(counters.current_label(&numbering, second_id, 0).as_deref(), Some("Clause 1."));
|
||||
assert_eq!(counters.current_template_levels(&numbering, second_id, 0), vec![0]);
|
||||
assert_eq!(counters.started(&numbering, second_id).unwrap()[..2], [true, true]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_huge_level_text_gives_a_bounded_label() {
|
||||
let mut n = Numbering::default();
|
||||
@@ -449,6 +560,27 @@ mod tests {
|
||||
assert_eq!(n.level(id, 2).map(|l| l.format), Some(NumFormat::DecimalZero));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_none_level_shows_its_text_and_still_counts() {
|
||||
// As Word shows them (#4985): a "none" level's literal text is its label, its own number
|
||||
// reads as nothing (also in legal numbering), and it restarts the levels below it.
|
||||
let mut n = Numbering::default();
|
||||
let id = n.add_list(ListKind::Numbered);
|
||||
let lvl = |format, text: &str| Level { format, text: text.into(), ..Level::default() };
|
||||
let legal = Level { legal: true, ..lvl(NumFormat::Decimal, "%2.%3") };
|
||||
if let Some(num) = n.nums.iter_mut().find(|x| x.id == id) {
|
||||
num.level_overrides = vec![(0, lvl(NumFormat::None, "RESPONSE:")), (1, lvl(NumFormat::None, "[%2]")), (2, legal)];
|
||||
}
|
||||
assert!(n.shows_label(id, 0));
|
||||
let mut c = Counters::default();
|
||||
assert_eq!(c.next_label(&n, id, 0).unwrap().0, "RESPONSE:");
|
||||
assert_eq!(c.next_label(&n, id, 1).unwrap().0, "[]");
|
||||
assert_eq!(c.next_label(&n, id, 2).unwrap().0, ".1");
|
||||
assert_eq!(c.next_label(&n, id, 2).unwrap().0, ".2");
|
||||
assert_eq!(c.next_label(&n, id, 1).unwrap().0, "[]");
|
||||
assert_eq!(c.next_label(&n, id, 2).unwrap().0, ".1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_start_override_restarts_only_its_own_level_of_a_shared_list() {
|
||||
// One abstract list numbers the headings through two nums: one for levels 1-4, and one
|
||||
|
||||
@@ -231,7 +231,7 @@ impl Paragraph {
|
||||
|
||||
/// Number of objects in `text[..off]`.
|
||||
fn objects_before(&self, off: usize) -> usize {
|
||||
self.text.get(..off).map(|t| t.chars().filter(|c| *c == OBJ).count()).unwrap_or(0)
|
||||
self.text.get(..off).map(|t| t.chars().inspect(|_| crate::work::steps(1)).filter(|c| *c == OBJ).count()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The object at byte `off` (which must hold U+FFFC).
|
||||
@@ -250,13 +250,13 @@ impl Paragraph {
|
||||
}
|
||||
/// Byte offsets of all objects, in order.
|
||||
pub fn object_offsets(&self) -> Vec<usize> {
|
||||
self.text.char_indices().filter(|(_, c)| *c == OBJ).map(|(i, _)| i).collect()
|
||||
self.text.char_indices().inspect(|_| crate::work::steps(1)).filter(|(_, c)| *c == OBJ).map(|(i, _)| i).collect()
|
||||
}
|
||||
/// (byte offset, object) for every object, in text order, in one pass. Prefer it to
|
||||
/// [`Paragraph::object_offsets`] + [`Paragraph::object_at`], which counts the objects before
|
||||
/// each offset again, so a loop over a paragraph's objects is quadratic (#3614).
|
||||
pub fn objects_with_offsets(&self) -> impl Iterator<Item = (usize, &InlineObject)> + '_ {
|
||||
self.text.char_indices().filter(|(_, c)| *c == OBJ).map(|(i, _)| i).zip(self.objects.iter())
|
||||
self.text.char_indices().inspect(|_| crate::work::steps(1)).filter(|(_, c)| *c == OBJ).map(|(i, _)| i).zip(self.objects.iter())
|
||||
}
|
||||
|
||||
/// Insert plain text (U+FFFC is dropped) at `off` with `props`. Returns the inserted length.
|
||||
|
||||
@@ -186,6 +186,16 @@ pub struct Float {
|
||||
/// top. 0 = unknown, drawn in document order (objects inserted here, or from other formats).
|
||||
#[serde(skip_serializing_if = "is_zero_u32")]
|
||||
pub z: u32,
|
||||
/// The page a drawing anchored to a page is on, from 1 (ODF's `text:anchor-page-number`,
|
||||
/// §19.872); 0 = anchored to its paragraph or not said. Kept for saving: the layout places a
|
||||
/// drawing on its anchor paragraph's page.
|
||||
#[serde(skip_serializing_if = "is_zero_u32")]
|
||||
pub page: u32,
|
||||
/// Turned, but the file keeps no room for the turn (`wp:effectExtent` missing or zero, as
|
||||
/// files from other tools have): Word then keeps an inline one's line to its frame and lets
|
||||
/// the turned object overhang it (see [`Float::line_pad`]). Cleared by [`Float::set_spin`].
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
pub bare_turn: bool,
|
||||
}
|
||||
|
||||
/// Longest alternative text kept (characters): hostile files and commands are capped.
|
||||
@@ -241,6 +251,7 @@ impl Float {
|
||||
self.rot = wordcraft_geom::normalize_degrees(s.deg);
|
||||
self.flip_h = s.flip_h;
|
||||
self.flip_v = s.flip_v;
|
||||
self.bare_turn = false;
|
||||
}
|
||||
/// How far a `w` × `h` frame's rotated bounds reach past it on each side (x, y): the room a
|
||||
/// rotated object takes beyond its frame (zero unrotated; negative where the turned object
|
||||
@@ -250,6 +261,23 @@ impl Float {
|
||||
let (bw, bh) = self.spin().extent(w, h);
|
||||
((bw - w) / 2.0, (bh - h) / 2.0)
|
||||
}
|
||||
/// A [`Float::bare_turn`] still inline and without effects room: made floating, or given room
|
||||
/// for a shadow or glow since, it saves, and so lays out, with the turn's room again.
|
||||
pub fn is_bare_turn(&self) -> bool {
|
||||
self.bare_turn && self.wrap == Wrap::Inline && self.effect_extent() == [0.0; 4]
|
||||
}
|
||||
/// The room an inline `w` × `h` object's line keeps past its frame on each side (x, y) for its
|
||||
/// turn: its rotated bounds ([`Float::spin_pad`]) — or, for a [`Float::is_bare_turn`], none, except
|
||||
/// that Word 2013+ layout (`modern`) exchanges the frame's width and height from 45° up to 135°
|
||||
/// and from 225° up to 315°.
|
||||
pub fn line_pad(&self, w: f32, h: f32, modern: bool) -> (f32, f32) {
|
||||
if !self.is_bare_turn() {
|
||||
return self.spin_pad(w, h);
|
||||
}
|
||||
let (w, h) = (wordcraft_geom::finite(w).clamp(0.0, 4000.0), wordcraft_geom::finite(h).clamp(0.0, 4000.0));
|
||||
let deg = wordcraft_geom::normalize_degrees(self.rot);
|
||||
if modern && ((45.0..135.0).contains(°) || (225.0..315.0).contains(°)) { ((h - w) / 2.0, (w - h) / 2.0) } else { (0.0, 0.0) }
|
||||
}
|
||||
/// The effect extents, finite and clamped (left, top, right, bottom).
|
||||
pub fn effect_extent(&self) -> [f32; 4] {
|
||||
self.effect.map(|v| if v.is_finite() { v.clamp(0.0, 1584.0) } else { 0.0 })
|
||||
|
||||
@@ -19,6 +19,9 @@ pub enum InlineObject {
|
||||
/// Crop (left, top, right, bottom) fractions 0..1.
|
||||
#[serde(default)]
|
||||
crop: [f32; 4],
|
||||
/// Brightness, contrast and transparency read from the file (see [`crate::picfx`]).
|
||||
#[serde(default, skip_serializing_if = "crate::picfx::PicFx::is_identity")]
|
||||
fx: crate::picfx::PicFx,
|
||||
/// The OLE object (embedded or linked file) or Word signature line this picture shows, as
|
||||
/// read from a file: saving writes the object back, not only its picture. See
|
||||
/// [`crate::graphic::Embedded`].
|
||||
@@ -313,20 +316,34 @@ impl InlineObject {
|
||||
/// A group's members placed in a `w` × `h` box at (`x`, `y`): each member's rectangle
|
||||
/// (x, y, w, h) and the member. Empty for anything else.
|
||||
pub fn group_rects(&self, x: f32, y: f32, w: f32, h: f32) -> Vec<([f32; 4], &InlineObject)> {
|
||||
let InlineObject::Group { ch_w, ch_h, children, .. } = self else { return Vec::new() };
|
||||
let InlineObject::Group { children, .. } = self else { return Vec::new() };
|
||||
self.member_rects(x, y, w, h).into_iter().filter_map(|(i, r)| Some((r, &children.get(i)?.obj))).collect()
|
||||
}
|
||||
/// [`InlineObject::group_rects`] with each member's index in `children` instead of the member.
|
||||
pub fn member_rects(&self, x: f32, y: f32, w: f32, h: f32) -> Vec<(usize, [f32; 4])> {
|
||||
let InlineObject::Group { children, .. } = self else { return Vec::new() };
|
||||
let (sx, sy) = self.group_scale(w, h);
|
||||
let fin = |v: f32| if v.is_finite() { v } else { 0.0 };
|
||||
let sx = if fin(*ch_w) > 0.0 { fin(w) / ch_w } else { 1.0 };
|
||||
let sy = if fin(*ch_h) > 0.0 { fin(h) / ch_h } else { 1.0 };
|
||||
children
|
||||
.iter()
|
||||
.take(MAX_GROUP_CHILDREN)
|
||||
.filter_map(|c| {
|
||||
.enumerate()
|
||||
.filter_map(|(i, c)| {
|
||||
let (cw, chh, _) = c.obj.frame()?;
|
||||
let r = [x + fin(c.x) * sx, y + fin(c.y) * sy, (fin(cw) * sx).clamp(0.0, 4000.0), (fin(chh) * sy).clamp(0.0, 4000.0)];
|
||||
(!matches!(c.obj, InlineObject::Group { .. })).then_some((r, &c.obj))
|
||||
(!matches!(c.obj, InlineObject::Group { .. })).then_some((i, r))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// How much a group drawn `w` × `h` scales its members' space (x, y): 1 for a canvas, whose
|
||||
/// members keep their size.
|
||||
pub fn group_scale(&self, w: f32, h: f32) -> (f32, f32) {
|
||||
let InlineObject::Group { ch_w, ch_h, .. } = self else { return (1.0, 1.0) };
|
||||
let fin = |v: f32| if v.is_finite() { v } else { 0.0 };
|
||||
let sx = if fin(*ch_w) > 0.0 { fin(w) / ch_w } else { 1.0 };
|
||||
let sy = if fin(*ch_h) > 0.0 { fin(h) / ch_h } else { 1.0 };
|
||||
(sx, sy)
|
||||
}
|
||||
/// 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> {
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
//! Picture colour corrections read from a file: brightness, contrast and transparency
|
||||
//! (DrawingML `a:lum` and `a:alphaModFix` inside `a:blip`, ECMA-376 §20.1.8.42 and §20.1.8.4;
|
||||
//! VML `v:imagedata` `gain` and `blacklevel`, which Word's picture watermark "Washout" sets).
|
||||
//!
|
||||
//! Values are kept in OOXML units: thousandths of a percent, so 100 000 is 100 %. All zero is
|
||||
//! the picture as it is.
|
||||
//!
|
||||
//! The pixel formula was measured on Word (#1711, #1766): Word bakes the corrected pixels into
|
||||
//! the picture it embeds in a PDF export, so a synthetic grey and colour ramp exported with
|
||||
//! 20 `a:lum` combinations and 6 VML `gain`/`blacklevel` pairs gives the curve. Every channel is
|
||||
//! mapped on its own (no hue or luminance coupling) by one straight line, clamped to 0..=255
|
||||
//! only at the end:
|
||||
//!
|
||||
//! `y = s·(x − 127.5) + 127.5 + 127.5·b·(1 + s)`, with `b = bright / 100 000`, `c = contrast /
|
||||
//! 100 000`, `s = 1 + c` when `c ≤ 0` and `s = 1 / (1 − c)` when `c > 0`
|
||||
//!
|
||||
//! (as if half the brightness shift were applied before the contrast and half after). Measured
|
||||
//! slope and intercept of Word's output for grey input `x` (PNG pictures, exact to ±1 level):
|
||||
//! bright 20 000 → 1.00, +51; bright −40 000 → 1.00, −103; contrast 80 000 → 5.00, −510;
|
||||
//! contrast −80 000 → 0.20, +101.4; (40 000, −40 000) → 0.60, +132.2; (80 000, −80 000) →
|
||||
//! 0.20, +223.8 (also the #1766 header picture: fitted 0.197..0.201, +223.6..224.0);
|
||||
//! (−40 000, 40 000) → 1.667, −221.7; (30 000, 60 000) → 2.50, −57.8.
|
||||
//!
|
||||
//! VML: `gain` is the slope itself and `blacklevel` half the brightness — Word draws `gain` 0.3,
|
||||
//! `blacklevel` 0.35 (Washout) as 0.30, +205.3, the same as `a:lum` (70 000, −70 000), and
|
||||
//! `gain` 1, `blacklevel` 0.2 as the very same picture as `bright="40000"`.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The largest magnitude of a correction (100 %).
|
||||
pub const FX_MAX: i32 = 100_000;
|
||||
|
||||
/// Brightness, contrast and transparency of a picture (see the module docs).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct PicFx {
|
||||
/// −100 000 (black) ..= 100 000 (white).
|
||||
pub bright: i32,
|
||||
/// −100 000 (flat grey) ..= 100 000 (hard threshold).
|
||||
pub contrast: i32,
|
||||
/// 0 (opaque) ..= 100 000 (invisible).
|
||||
pub transparency: i32,
|
||||
}
|
||||
|
||||
impl PicFx {
|
||||
/// Corrections clamped to their ranges.
|
||||
pub fn new(bright: i32, contrast: i32, transparency: i32) -> PicFx {
|
||||
PicFx { bright: bright.clamp(-FX_MAX, FX_MAX), contrast: contrast.clamp(-FX_MAX, FX_MAX), transparency: transparency.clamp(0, FX_MAX) }
|
||||
}
|
||||
|
||||
/// The same corrections, each clamped to its range (for values that came from anywhere).
|
||||
pub fn clamped(self) -> PicFx {
|
||||
PicFx::new(self.bright, self.contrast, self.transparency)
|
||||
}
|
||||
|
||||
/// No correction at all.
|
||||
pub fn is_identity(&self) -> bool {
|
||||
*self == PicFx::default()
|
||||
}
|
||||
|
||||
/// Brightness or contrast changes the pixels (transparency alone doesn't).
|
||||
pub fn changes_pixels(&self) -> bool {
|
||||
let f = self.clamped();
|
||||
f.bright != 0 || f.contrast != 0
|
||||
}
|
||||
|
||||
/// The picture's opacity, 0..=1 (1 − transparency).
|
||||
pub fn opacity(&self) -> f32 {
|
||||
1.0 - self.transparency.clamp(0, FX_MAX) as f32 / FX_MAX as f32
|
||||
}
|
||||
|
||||
/// The corrections a VML `v:imagedata` gives with `gain` and `blacklevel` (already parsed to
|
||||
/// plain numbers, 1 and 0 when missing): contrast from the slope `gain`, brightness twice the
|
||||
/// black level (see the module docs). Non-finite values count as missing.
|
||||
pub fn from_vml(gain: f64, blacklevel: f64) -> PicFx {
|
||||
let gain = if gain.is_finite() { gain.clamp(0.0, 1.0e6) } else { 1.0 };
|
||||
let black = if blacklevel.is_finite() { blacklevel.clamp(-0.5, 0.5) } else { 0.0 };
|
||||
// s = 1 + c (c ≤ 0) or 1 / (1 − c) (c > 0).
|
||||
let c = if gain <= 1.0 { gain - 1.0 } else { 1.0 - 1.0 / gain };
|
||||
let fx = |v: f64| (v * FX_MAX as f64).round().clamp(-(FX_MAX as f64), FX_MAX as f64) as i32;
|
||||
PicFx::new(fx(2.0 * black), fx(c), 0)
|
||||
}
|
||||
|
||||
/// `gain` and `blacklevel` for VML (the inverse of [`PicFx::from_vml`]).
|
||||
pub fn to_vml(&self) -> (f64, f64) {
|
||||
(self.slope(), self.clamped().bright as f64 / FX_MAX as f64 / 2.0)
|
||||
}
|
||||
|
||||
/// The contrast slope `s` (capped at 255: contrast 100 % is a threshold).
|
||||
fn slope(&self) -> f64 {
|
||||
let c = self.contrast.clamp(-FX_MAX, FX_MAX) as f64 / FX_MAX as f64;
|
||||
if c <= 0.0 { 1.0 + c } else { (1.0 / (1.0 - c).max(1.0 / 255.0)).min(255.0) }
|
||||
}
|
||||
|
||||
/// The lookup table every colour channel goes through.
|
||||
pub fn table(&self) -> [u8; 256] {
|
||||
let s = self.slope();
|
||||
let b = self.bright.clamp(-FX_MAX, FX_MAX) as f64 / FX_MAX as f64;
|
||||
let mut t = [0u8; 256];
|
||||
for (x, slot) in t.iter_mut().enumerate() {
|
||||
let y = s * (x as f64 - 127.5) + 127.5 + 127.5 * b * (1.0 + s);
|
||||
*slot = if y.is_finite() { y.round().clamp(0.0, 255.0) as u8 } else { 0 };
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
/// Apply brightness and contrast to straight (not premultiplied) RGBA8 pixels in place. The
|
||||
/// alpha byte is left alone (transparency is drawn as an opacity, see [`PicFx::opacity`]); a
|
||||
/// trailing partial pixel is ignored.
|
||||
pub fn apply_rgba(&self, rgba: &mut [u8]) {
|
||||
if !self.changes_pixels() {
|
||||
return;
|
||||
}
|
||||
let t = self.table();
|
||||
for px in rgba.as_chunks_mut::<4>().0 {
|
||||
for c in px.iter_mut().take(3) {
|
||||
*c = t.get(usize::from(*c)).copied().unwrap_or(*c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Word's own output for grey input 0, 128 and 255 (PNG pictures in a PDF export).
|
||||
#[test]
|
||||
fn matches_word() {
|
||||
let cases: [(PicFx, [u8; 3]); 6] = [
|
||||
(PicFx::new(40_000, -40_000, 0), [132, 209, 255]),
|
||||
(PicFx::new(80_000, -80_000, 0), [224, 249, 255]),
|
||||
(PicFx::new(-40_000, 40_000, 0), [0, 0, 203]),
|
||||
(PicFx::new(0, 80_000, 0), [0, 130, 255]),
|
||||
(PicFx::new(20_000, 0, 0), [51, 179, 255]),
|
||||
(PicFx::from_vml(19661.0 / 65536.0, 22938.0 / 65536.0), [205, 244, 255]),
|
||||
];
|
||||
for (fx, want) in cases {
|
||||
let t = fx.table();
|
||||
for (x, w) in [0usize, 128, 255].into_iter().zip(want) {
|
||||
let got = t[x];
|
||||
assert!(got.abs_diff(w) <= 2, "{fx:?}: {x} → {got}, Word {w}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channels_apart_alpha_kept() {
|
||||
let fx = PicFx::new(80_000, -80_000, 0);
|
||||
let mut px = [255, 0, 0, 77, 0, 128, 255, 255];
|
||||
fx.apply_rgba(&mut px);
|
||||
// Word: red (255, 0, 0) → (255, 224, 224); 128 → 249 (the formula gives 250.0).
|
||||
assert_eq!(px, [255, 224, 224, 77, 224, 250, 255, 255]);
|
||||
let mut odd = [0u8; 6];
|
||||
fx.apply_rgba(&mut odd);
|
||||
assert_eq!(odd[4..], [0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vml_mapping() {
|
||||
// 16.16 fixed point: 19661f and 22938f are 0.3 and 0.35 to within 1e-5.
|
||||
let washout = PicFx::from_vml(19661.0 / 65536.0, 22938.0 / 65536.0);
|
||||
assert!((washout.bright - 70_000).abs() <= 2 && (washout.contrast + 70_000).abs() <= 2, "{washout:?}");
|
||||
assert_eq!(PicFx::from_vml(1.0, 0.2), PicFx::new(40_000, 0, 0));
|
||||
assert_eq!(PicFx::from_vml(2.0, 0.0), PicFx::new(0, 50_000, 0));
|
||||
assert!(PicFx::from_vml(1.0, 0.0).is_identity());
|
||||
for (g, b) in [(f64::NAN, f64::INFINITY), (-1.0e30, 1.0e30), (1.0e300, -7.0), (0.0, 0.0)] {
|
||||
let fx = PicFx::from_vml(g, b);
|
||||
assert_eq!(fx, fx.clamped(), "{g} {b}");
|
||||
let _ = fx.table();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_values_clamp() {
|
||||
for v in [i32::MIN, -100_001, 0, 100_001, i32::MAX] {
|
||||
let fx = PicFx { bright: v, contrast: v, transparency: v };
|
||||
let _ = fx.table();
|
||||
assert!((0.0..=1.0).contains(&fx.opacity()));
|
||||
let mut px = [1u8, 2, 3, 4];
|
||||
fx.apply_rgba(&mut px);
|
||||
assert_eq!(px[3], 4);
|
||||
}
|
||||
assert_eq!(PicFx::new(0, 0, 25_000).opacity(), 0.75);
|
||||
assert!(!PicFx::new(0, 0, 25_000).changes_pixels());
|
||||
// Contrast 100 %: a threshold at mid-grey, no NaN.
|
||||
let t = PicFx::new(0, FX_MAX, 0).table();
|
||||
assert_eq!((t[0], t[127], t[128], t[255]), (0, 0, 255, 255));
|
||||
}
|
||||
}
|
||||
@@ -23,12 +23,16 @@ mod para_props;
|
||||
mod table_props;
|
||||
|
||||
pub use char_props::{
|
||||
CharProps, CombineBrackets, DEFAULT_FONT_SIZE, EastAsianLayout, Highlight, Ligatures, NumChange, PropChange, Underline, VertAlign,
|
||||
CharProps, CombineBrackets, DEFAULT_FONT_SIZE, EastAsianLayout, EmphasisMark, Highlight, Ligatures, NumChange, PropChange, Underline, VertAlign,
|
||||
};
|
||||
pub use color::{Rgb, TextColor};
|
||||
pub use para_props::{Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, TAB_POS_EPS, TabAlign, TabLeader, TabStop, TextFrame};
|
||||
pub use para_props::{
|
||||
Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, ParagraphTextDirection, TAB_POS_EPS, TabAlign, TabLeader, TabStop,
|
||||
TextAlignment, TextFrame,
|
||||
};
|
||||
pub use table_props::{
|
||||
CellProps, HeightRule, RowProps, TableFloat, TableLook, TableProps, TextDirection, TextVert, VAlign, VMerge, anchor_ooxml, valign_from_anchor,
|
||||
CellPicture, CellProps, HeightRule, PictureRepeat, RowProps, TableFloat, TableLook, TableProps, TextDirection, TextVert, VAlign, VMerge,
|
||||
anchor_ooxml, valign_from_anchor,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -291,6 +291,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>,
|
||||
/// A mark over (or under) each character (`w:em`, Font › Emphasis mark), as East Asian text
|
||||
/// stresses words; `Some(EmphasisMark::None)` turns off one a style sets.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub emphasis: Option<EmphasisMark>,
|
||||
/// OpenType ligatures (`w14:ligatures`). `None` leaves WordCraft's default: standard and
|
||||
/// contextual ligatures.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -381,6 +385,41 @@ pub struct EastAsianLayout {
|
||||
pub vert_compress: Option<bool>,
|
||||
}
|
||||
|
||||
/// A mark over or under each character (`ST_Em`, ECMA-376 §17.18.24): Word's Font › Emphasis
|
||||
/// mark.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum EmphasisMark {
|
||||
#[default]
|
||||
None,
|
||||
/// A dot above.
|
||||
Dot,
|
||||
/// A comma (sesame) above.
|
||||
Comma,
|
||||
/// A hollow circle above.
|
||||
Circle,
|
||||
/// A dot below.
|
||||
UnderDot,
|
||||
}
|
||||
|
||||
impl EmphasisMark {
|
||||
pub const ALL: [EmphasisMark; 5] = [EmphasisMark::None, EmphasisMark::Dot, EmphasisMark::Comma, EmphasisMark::Circle, EmphasisMark::UnderDot];
|
||||
/// OOXML `w:em` value.
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
EmphasisMark::None => "none",
|
||||
EmphasisMark::Dot => "dot",
|
||||
EmphasisMark::Comma => "comma",
|
||||
EmphasisMark::Circle => "circle",
|
||||
EmphasisMark::UnderDot => "underDot",
|
||||
}
|
||||
}
|
||||
/// The mark an OOXML `w:em` value names; unknown values are none.
|
||||
pub fn from_ooxml(v: &str) -> EmphasisMark {
|
||||
EmphasisMark::ALL.into_iter().find(|m| m.ooxml() == v.trim()).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Brackets around two lines in one (`ST_CombineBrackets`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -466,7 +505,7 @@ impl CharProps {
|
||||
}
|
||||
|
||||
fn minus_run_visibility(&mut self, base: &CharProps) {
|
||||
drop_same_fields!(self, base; web_hidden, snap_to_grid);
|
||||
drop_same_fields!(self, base; web_hidden, snap_to_grid, emphasis);
|
||||
}
|
||||
|
||||
fn minus_script_style(&mut self, base: &CharProps) {
|
||||
@@ -497,7 +536,7 @@ impl CharProps {
|
||||
}
|
||||
|
||||
fn overlay_run_visibility(&mut self, patch: &CharProps) {
|
||||
overlay_fields!(self, patch; web_hidden, snap_to_grid);
|
||||
overlay_fields!(self, patch; web_hidden, snap_to_grid, emphasis);
|
||||
}
|
||||
|
||||
fn overlay_script_style(&mut self, patch: &CharProps) {
|
||||
|
||||
@@ -26,6 +26,101 @@ impl Align {
|
||||
}
|
||||
}
|
||||
|
||||
/// Paragraph-level text flow (`w:pPr/w:textDirection`, ECMA-376 §17.3.1.41).
|
||||
///
|
||||
/// Kept separate from [`super::TextDirection`], whose three values model the table-cell direction
|
||||
/// control. These values preserve the exact OOXML direction through JSON and DOCX saves.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ParagraphTextDirection {
|
||||
LrTb,
|
||||
LrTbV,
|
||||
Tb,
|
||||
TbRl,
|
||||
TbRlV,
|
||||
TbLr,
|
||||
TbLrV,
|
||||
BtLr,
|
||||
TbV,
|
||||
Rl,
|
||||
RlV,
|
||||
Lr,
|
||||
LrV,
|
||||
}
|
||||
|
||||
impl ParagraphTextDirection {
|
||||
pub fn from_ooxml(value: &str) -> Option<Self> {
|
||||
match value {
|
||||
"lrTb" => Some(Self::LrTb),
|
||||
"lrTbV" => Some(Self::LrTbV),
|
||||
"tb" => Some(Self::Tb),
|
||||
"tbRl" => Some(Self::TbRl),
|
||||
"tbRlV" => Some(Self::TbRlV),
|
||||
"tbLr" => Some(Self::TbLr),
|
||||
"tbLrV" => Some(Self::TbLrV),
|
||||
"btLr" => Some(Self::BtLr),
|
||||
"tbV" => Some(Self::TbV),
|
||||
"rl" => Some(Self::Rl),
|
||||
"rlV" => Some(Self::RlV),
|
||||
"lr" => Some(Self::Lr),
|
||||
"lrV" => Some(Self::LrV),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
Self::LrTb => "lrTb",
|
||||
Self::LrTbV => "lrTbV",
|
||||
Self::Tb => "tb",
|
||||
Self::TbRl => "tbRl",
|
||||
Self::TbRlV => "tbRlV",
|
||||
Self::TbLr => "tbLr",
|
||||
Self::TbLrV => "tbLrV",
|
||||
Self::BtLr => "btLr",
|
||||
Self::TbV => "tbV",
|
||||
Self::Rl => "rl",
|
||||
Self::RlV => "rlV",
|
||||
Self::Lr => "lr",
|
||||
Self::LrV => "lrV",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Vertical alignment of characters of different sizes on a line (`w:textAlignment`,
|
||||
/// ECMA-376 §17.3.1.39).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum TextAlignment {
|
||||
Auto,
|
||||
Top,
|
||||
Center,
|
||||
Baseline,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
impl TextAlignment {
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
TextAlignment::Auto => "auto",
|
||||
TextAlignment::Top => "top",
|
||||
TextAlignment::Center => "center",
|
||||
TextAlignment::Baseline => "baseline",
|
||||
TextAlignment::Bottom => "bottom",
|
||||
}
|
||||
}
|
||||
pub fn from_ooxml(s: &str) -> Option<TextAlignment> {
|
||||
match s {
|
||||
"auto" => Some(TextAlignment::Auto),
|
||||
"top" => Some(TextAlignment::Top),
|
||||
"center" => Some(TextAlignment::Center),
|
||||
"baseline" => Some(TextAlignment::Baseline),
|
||||
"bottom" => Some(TextAlignment::Bottom),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Line spacing rule.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "rule", content = "value")]
|
||||
@@ -107,6 +202,18 @@ pub enum BorderStyle {
|
||||
Triple,
|
||||
DotDash,
|
||||
Wave,
|
||||
/// The compound lines of thin and thick strokes (`thinThickSmallGap` to
|
||||
/// `thinThickThinLargeGap`), kept so a file writes back the line it read; drawn as a single
|
||||
/// line of the border's width.
|
||||
ThinThickSmallGap,
|
||||
ThickThinSmallGap,
|
||||
ThinThickThinSmallGap,
|
||||
ThinThickMediumGap,
|
||||
ThickThinMediumGap,
|
||||
ThinThickThinMediumGap,
|
||||
ThinThickLargeGap,
|
||||
ThickThinLargeGap,
|
||||
ThinThickThinLargeGap,
|
||||
}
|
||||
|
||||
impl BorderStyle {
|
||||
@@ -121,6 +228,15 @@ impl BorderStyle {
|
||||
BorderStyle::Triple => "triple",
|
||||
BorderStyle::DotDash => "dotDash",
|
||||
BorderStyle::Wave => "wave",
|
||||
BorderStyle::ThinThickSmallGap => "thinThickSmallGap",
|
||||
BorderStyle::ThickThinSmallGap => "thickThinSmallGap",
|
||||
BorderStyle::ThinThickThinSmallGap => "thinThickThinSmallGap",
|
||||
BorderStyle::ThinThickMediumGap => "thinThickMediumGap",
|
||||
BorderStyle::ThickThinMediumGap => "thickThinMediumGap",
|
||||
BorderStyle::ThinThickThinMediumGap => "thinThickThinMediumGap",
|
||||
BorderStyle::ThinThickLargeGap => "thinThickLargeGap",
|
||||
BorderStyle::ThickThinLargeGap => "thickThinLargeGap",
|
||||
BorderStyle::ThinThickThinLargeGap => "thinThickThinLargeGap",
|
||||
}
|
||||
}
|
||||
pub fn from_ooxml(s: &str) -> BorderStyle {
|
||||
@@ -133,6 +249,15 @@ impl BorderStyle {
|
||||
"triple" => BorderStyle::Triple,
|
||||
"dotDash" | "dotDotDash" => BorderStyle::DotDash,
|
||||
"wave" | "doubleWave" => BorderStyle::Wave,
|
||||
"thinThickSmallGap" => BorderStyle::ThinThickSmallGap,
|
||||
"thickThinSmallGap" => BorderStyle::ThickThinSmallGap,
|
||||
"thinThickThinSmallGap" => BorderStyle::ThinThickThinSmallGap,
|
||||
"thinThickMediumGap" => BorderStyle::ThinThickMediumGap,
|
||||
"thickThinMediumGap" => BorderStyle::ThickThinMediumGap,
|
||||
"thinThickThinMediumGap" => BorderStyle::ThinThickThinMediumGap,
|
||||
"thinThickLargeGap" => BorderStyle::ThinThickLargeGap,
|
||||
"thickThinLargeGap" => BorderStyle::ThickThinLargeGap,
|
||||
"thinThickThinLargeGap" => BorderStyle::ThinThickThinLargeGap,
|
||||
_ => BorderStyle::Single,
|
||||
}
|
||||
}
|
||||
@@ -183,6 +308,11 @@ impl Borders {
|
||||
pub fn any_visible(&self) -> bool {
|
||||
[self.top, self.left, self.bottom, self.right, self.between, self.inside_v].iter().flatten().any(Border::is_visible)
|
||||
}
|
||||
/// A side is drawn, or a paragraph side without a line holds padding (ODF's, which keeps
|
||||
/// its room in LibreOffice's layout).
|
||||
pub fn takes_room(&self) -> bool {
|
||||
self.any_visible() || [self.top, self.left, self.bottom, self.right].iter().flatten().any(|b| b.space.is_finite() && b.space > 0.0)
|
||||
}
|
||||
/// Apply every `Some` side of `patch` on top of `self`.
|
||||
pub fn overlay(&mut self, patch: &Borders) {
|
||||
if patch.top.is_some() {
|
||||
@@ -236,6 +366,9 @@ pub struct TextFrame {
|
||||
pub v_anchor: String,
|
||||
/// `around`, `auto`, `notBeside`, `none`, `tight` or `through`.
|
||||
pub wrap: String,
|
||||
/// Whether the frame stays fixed to its page anchor when its paragraph moves.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub anchor_lock: Option<bool>,
|
||||
/// Distance from the text left and right, and above and below.
|
||||
pub h_space: f32,
|
||||
pub v_space: f32,
|
||||
@@ -290,6 +423,9 @@ pub struct ParaProps {
|
||||
/// outside ones, so they swap sides on even (left-hand) pages.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mirror_indents: Option<bool>,
|
||||
/// `w:adjustRightInd`: automatically adjust the right indent for the document grid when enabled; keep the specified right indent when disabled.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub adjust_right_indent: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub keep_next: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -316,6 +452,9 @@ pub struct ParaProps {
|
||||
pub suppress_line_numbers: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bidi: Option<bool>,
|
||||
/// Paragraph text direction (`w:textDirection`); `None` inherits from the paragraph style.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub text_direction: Option<ParagraphTextDirection>,
|
||||
/// 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>,
|
||||
@@ -363,6 +502,10 @@ pub struct ParaProps {
|
||||
/// `w:snapToGrid`: lines snap to the section's document grid. Unset = on.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub snap_to_grid: Option<bool>,
|
||||
/// `w:textAlignment`: how characters of different sizes line up on a line. Kept for the
|
||||
/// file (not laid out yet); Word sizes a line by it, so dropping it changes Word's pages.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub text_alignment: Option<TextAlignment>,
|
||||
}
|
||||
|
||||
impl ParaProps {
|
||||
@@ -374,7 +517,7 @@ impl ParaProps {
|
||||
|
||||
fn overlay_layout(&mut self, patch: &ParaProps) {
|
||||
overlay_fields!(self, patch; style, align, indent_left, indent_right, indent_first, indent_left_chars, indent_right_chars, indent_first_chars,
|
||||
space_before, space_after, space_before_auto, space_after_auto, line_spacing, contextual_spacing, mirror_indents, tabs, shading, borders);
|
||||
space_before, space_after, space_before_auto, space_after_auto, line_spacing, contextual_spacing, mirror_indents, adjust_right_indent, tabs, shading, borders);
|
||||
}
|
||||
|
||||
fn overlay_pagination(&mut self, patch: &ParaProps) {
|
||||
@@ -383,7 +526,8 @@ impl ParaProps {
|
||||
}
|
||||
|
||||
fn overlay_script_layout(&mut self, patch: &ParaProps) {
|
||||
overlay_fields!(self, patch; bidi, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn);
|
||||
overlay_fields!(self, patch; bidi, text_direction, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn);
|
||||
overlay_fields!(self, patch; bidi, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn, text_alignment);
|
||||
}
|
||||
pub fn overlaid(mut self, patch: &ParaProps) -> ParaProps {
|
||||
self.overlay(patch);
|
||||
|
||||
@@ -158,6 +158,12 @@ pub struct TableProps {
|
||||
/// the right margin.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub bidi_visual: bool,
|
||||
/// Space above and below a table in the text flow, points (ODF's table `fo:margin-top` and
|
||||
/// `fo:margin-bottom`; Word has none). Added to the spacing of the paragraphs around it.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub space_before: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub space_after: Option<f32>,
|
||||
}
|
||||
|
||||
/// Where a floating table sits; the text after it wraps around it.
|
||||
@@ -294,6 +300,38 @@ pub struct CellProps {
|
||||
/// Tracked formatting change (`w:tcPrChange`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fmt_change: Option<Box<PropChange<CellProps>>>,
|
||||
/// A picture behind the cell's content (an ODF cell's `style:background-image`; Word has
|
||||
/// none).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub picture: Option<Box<CellPicture>>,
|
||||
}
|
||||
|
||||
/// A cell's background picture: drawn at its own size where `align` puts it, tiled, or stretched
|
||||
/// over the cell, and cut off at the cell's edges.
|
||||
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct CellPicture {
|
||||
/// Key into [`crate::Document::media`].
|
||||
pub media: String,
|
||||
/// Its own size, points.
|
||||
pub size: [f32; 2],
|
||||
pub repeat: PictureRepeat,
|
||||
/// Where an unrepeated picture sits: 0 the left (top) edge, 0.5 the middle, 1 the right
|
||||
/// (bottom) edge.
|
||||
pub align: [f32; 2],
|
||||
}
|
||||
|
||||
/// How a background picture fills its area (ODF §20.348 `style:repeat`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PictureRepeat {
|
||||
/// Once, at its own size.
|
||||
#[default]
|
||||
None,
|
||||
/// Repeated across the area.
|
||||
Tile,
|
||||
/// Stretched over the area.
|
||||
Stretch,
|
||||
}
|
||||
|
||||
impl CellProps {
|
||||
|
||||
+104
-2
@@ -41,6 +41,9 @@ pub struct ResolvedChar {
|
||||
/// Kerning: the smallest font size, in points, that is kerned (`w:kern`); 0 = never kerned.
|
||||
#[serde(default)]
|
||||
pub kern: f32,
|
||||
/// A mark over or under each character (Font › Emphasis mark).
|
||||
#[serde(default)]
|
||||
pub emphasis: crate::props::EmphasisMark,
|
||||
/// OpenType ligatures (`w14:ligatures`); `None` = the shaper's default (standard and
|
||||
/// contextual).
|
||||
#[serde(default)]
|
||||
@@ -97,6 +100,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),
|
||||
emphasis: c.emphasis.unwrap_or_default(),
|
||||
ligatures: c.ligatures,
|
||||
outline: c.outline.unwrap_or(false),
|
||||
shadow: c.shadow.unwrap_or(false),
|
||||
@@ -133,9 +137,12 @@ impl ResolvedChar {
|
||||
pub fn uses_complex(&self, c: char) -> bool {
|
||||
self.rtl || self.cs || crate::bidi::is_complex_script(c)
|
||||
}
|
||||
/// The size glyphs are drawn at (super/subscript shrink to ~2/3).
|
||||
/// The size glyphs are drawn and measured at. Word sets superscript and subscript text at
|
||||
/// 0.65 of the run's size rounded to the half point, a font size the format can store
|
||||
/// (measured in Word for Mac from digit advances: 8 pt → 5, 9 → 6, 11 → 7, 12 → 8,
|
||||
/// 16 → 10.5, 24 → 15.5, 36 → 23.5).
|
||||
pub fn draw_size(&self) -> f32 {
|
||||
if self.vert_align == VertAlign::Baseline { self.size } else { self.size * 0.65 }
|
||||
if self.vert_align == VertAlign::Baseline { self.size } else { (self.size * 0.65 * 2.0).round() / 2.0 }
|
||||
}
|
||||
/// Baseline shift, points (positive = up).
|
||||
pub fn baseline_shift(&self) -> f32 {
|
||||
@@ -149,6 +156,10 @@ impl ResolvedChar {
|
||||
}
|
||||
|
||||
/// Concrete paragraph formatting.
|
||||
fn default_adjust_right_indent() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ResolvedPara {
|
||||
@@ -169,6 +180,9 @@ pub struct ResolvedPara {
|
||||
pub contextual_spacing: bool,
|
||||
/// Left/right indents are inside/outside: they swap on even pages.
|
||||
pub mirror_indents: bool,
|
||||
/// Automatically adjust the right indent for the document grid when enabled; keep the specified right indent when disabled.
|
||||
#[serde(default = "default_adjust_right_indent")]
|
||||
pub adjust_right_indent: bool,
|
||||
pub keep_next: bool,
|
||||
pub keep_lines: bool,
|
||||
pub page_break_before: bool,
|
||||
@@ -178,6 +192,10 @@ pub struct ResolvedPara {
|
||||
pub tabs: Vec<TabStop>,
|
||||
pub shading: Option<Rgb>,
|
||||
pub borders: Option<Borders>,
|
||||
/// Padding on the sides without a line (top, left, bottom, right), points. ODF's box model
|
||||
/// (XSL's) keeps it for documents opened from ODF (`Settings::odf`); Word drops it.
|
||||
#[serde(default)]
|
||||
pub padding: [f32; 4],
|
||||
pub bidi: bool,
|
||||
pub drop_cap: u8,
|
||||
/// The drop cap hangs in the margin (see `ParaProps::drop_cap_margin`).
|
||||
@@ -256,6 +274,7 @@ impl ResolvedPara {
|
||||
},
|
||||
contextual_spacing: p.contextual_spacing.unwrap_or(false),
|
||||
mirror_indents: p.mirror_indents.unwrap_or(false),
|
||||
adjust_right_indent: p.adjust_right_indent.unwrap_or(true),
|
||||
keep_next: p.keep_next.unwrap_or(false),
|
||||
keep_lines: p.keep_lines.unwrap_or(false),
|
||||
page_break_before: p.page_break_before.unwrap_or(false),
|
||||
@@ -265,6 +284,10 @@ impl ResolvedPara {
|
||||
tabs,
|
||||
shading: p.shading,
|
||||
borders: p.borders.filter(Borders::any_visible),
|
||||
padding: p.borders.map_or([0.0; 4], |b| {
|
||||
[b.top, b.left, b.bottom, b.right]
|
||||
.map(|s| s.filter(|l| !l.is_visible() && l.space.is_finite() && l.space > 0.0).map_or(0.0, |l| l.space.min(1584.0)))
|
||||
}),
|
||||
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),
|
||||
@@ -323,6 +346,38 @@ impl StyleSheet {
|
||||
(pp, cp)
|
||||
}
|
||||
|
||||
/// What a table style's paragraph formatting `table` changes for a paragraph with `p`, once
|
||||
/// the paragraph style chain is applied over it (ECMA-376 §17.7.2): each property it gives
|
||||
/// that the chain doesn't set. Formats without table styles state these directly.
|
||||
pub fn table_para_patch(&self, p: &ParaProps, table: &ParaProps) -> ParaProps {
|
||||
let style = p.style.clone().unwrap_or_else(|| "Normal".into());
|
||||
let mut with = self.default_para.clone();
|
||||
with.overlay(&ParaProps { style: None, ..table.clone() });
|
||||
let (with, _) = self.para_style_props_over(&style, with, CharProps::default());
|
||||
let (without, _) = self.para_style_props_over(&style, self.default_para.clone(), CharProps::default());
|
||||
diff_patch(&with, &without)
|
||||
}
|
||||
|
||||
/// What a table style's run formatting `table` changes for a run of character style
|
||||
/// `run_style` in a paragraph of style `para_style`, once the paragraph and character style
|
||||
/// chains are applied over it (ECMA-376 §17.7.2).
|
||||
pub fn table_char_patch(&self, para_style: Option<&str>, run_style: Option<&str>, table: &CharProps) -> CharProps {
|
||||
let chain = |defaults: CharProps| {
|
||||
let (_, mut cp) = self.para_style_props_over(para_style.unwrap_or("Normal"), ParaProps::default(), defaults);
|
||||
if let Some(cs) = run_style {
|
||||
for s in self.chain(cs) {
|
||||
if s.kind == StyleKind::Character || s.kind == StyleKind::Paragraph {
|
||||
cp.overlay(&s.chr);
|
||||
}
|
||||
}
|
||||
}
|
||||
cp
|
||||
};
|
||||
let mut with = self.default_chr.clone();
|
||||
with.overlay(&CharProps { style: None, ..table.clone() });
|
||||
diff_patch(&chain(with), &chain(self.default_chr.clone()))
|
||||
}
|
||||
|
||||
/// Resolve a paragraph's properties.
|
||||
pub fn resolve_para(&self, p: &ParaProps) -> ResolvedPara {
|
||||
self.resolve_para_in(p, None)
|
||||
@@ -387,10 +442,46 @@ impl StyleSheet {
|
||||
}
|
||||
}
|
||||
|
||||
/// The properties of `with` that differ from `without` (both fully resolved property sets of one
|
||||
/// kind), as a set of only those; empty when serialisation fails.
|
||||
fn diff_patch<T: Serialize + serde::de::DeserializeOwned + Default>(with: &T, without: &T) -> T {
|
||||
let (Ok(serde_json::Value::Object(w)), Ok(serde_json::Value::Object(o))) = (serde_json::to_value(with), serde_json::to_value(without)) else {
|
||||
return T::default();
|
||||
};
|
||||
let patch: serde_json::Map<String, serde_json::Value> = w.into_iter().filter(|(k, v)| o.get(k) != Some(v)).collect();
|
||||
serde_json::from_value(serde_json::Value::Object(patch)).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Superscript and subscript sizes as Word for Mac sets them (measured from digit advances in
|
||||
/// its PDF export): 0.65 of the size, rounded to the half point.
|
||||
#[test]
|
||||
fn raised_and_lowered_text_is_sized_to_the_half_point() {
|
||||
for (size, want) in [
|
||||
(8.0, 5.0),
|
||||
(9.0, 6.0),
|
||||
(10.0, 6.5),
|
||||
(11.0, 7.0),
|
||||
(12.0, 8.0),
|
||||
(13.0, 8.5),
|
||||
(14.0, 9.0),
|
||||
(16.0, 10.5),
|
||||
(20.0, 13.0),
|
||||
(24.0, 15.5),
|
||||
(36.0, 23.5),
|
||||
] {
|
||||
for vert_align in [VertAlign::Superscript, VertAlign::Subscript] {
|
||||
let rc = ResolvedChar { size, vert_align, ..StyleSheet::builtin().resolve_char(None, &CharProps::default()) };
|
||||
assert_eq!(rc.draw_size(), want, "{size} pt {vert_align:?}");
|
||||
}
|
||||
}
|
||||
let rc = StyleSheet::builtin().resolve_char(None, &CharProps { size: Some(11.0), ..Default::default() });
|
||||
assert_eq!(rc.draw_size(), 11.0, "baseline text keeps its size");
|
||||
}
|
||||
|
||||
/// #2036: only a character itself (direct formatting) has a symbol font of its own; a
|
||||
/// style never gives one to its text.
|
||||
#[test]
|
||||
@@ -524,4 +615,15 @@ mod tests {
|
||||
assert_eq!(p.tabs.len(), 3);
|
||||
assert_eq!(p.tabs[0].pos, 100.0);
|
||||
}
|
||||
|
||||
/// Resolved formatting saved before paragraph padding existed still reads: no padding.
|
||||
#[test]
|
||||
fn resolved_para_without_padding_reads() {
|
||||
let p = StyleSheet::builtin().resolve_para(&ParaProps::default());
|
||||
let mut v = serde_json::to_value(&p).unwrap();
|
||||
v.as_object_mut().unwrap().remove("padding");
|
||||
let back: ResolvedPara = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(back.padding, [0.0; 4]);
|
||||
assert_eq!(back, ResolvedPara { padding: [0.0; 4], ..p });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,40 @@ pub enum NumFormat {
|
||||
DollarText,
|
||||
}
|
||||
|
||||
/// Chapter separator for page numbers (`w:chapSep`, ECMA-376 §17.18.12).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ChapterSep {
|
||||
Hyphen,
|
||||
Period,
|
||||
Colon,
|
||||
EmDash,
|
||||
EnDash,
|
||||
}
|
||||
|
||||
impl ChapterSep {
|
||||
pub fn from_ooxml(value: &str) -> Option<Self> {
|
||||
match value {
|
||||
"hyphen" => Some(Self::Hyphen),
|
||||
"period" => Some(Self::Period),
|
||||
"colon" => Some(Self::Colon),
|
||||
"emDash" => Some(Self::EmDash),
|
||||
"enDash" => Some(Self::EnDash),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
Self::Hyphen => "hyphen",
|
||||
Self::Period => "period",
|
||||
Self::Colon => "colon",
|
||||
Self::EmDash => "emDash",
|
||||
Self::EnDash => "enDash",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NumFormat {
|
||||
/// Every format, in the order ST_NumberFormat lists them (which is also the order of the
|
||||
/// binary .doc number format codes up to 0x27).
|
||||
@@ -255,13 +289,14 @@ impl NumFormat {
|
||||
pub fn from_ooxml(s: &str) -> NumFormat {
|
||||
NumFormat::ALL.into_iter().find(|f| f.ooxml() == s).unwrap_or(NumFormat::Decimal)
|
||||
}
|
||||
/// Format `n` (1-based). Formats WordCraft doesn't draw yet (Hebrew, Hindi, Thai,
|
||||
/// Vietnamese, text amounts) show decimal, as do values past a sequence's end
|
||||
/// (the 21st item of ①…⑳).
|
||||
/// Format `n` (1-based). Formats WordCraft doesn't draw yet (Hebrew, Hindi, Thai letters
|
||||
/// and counting, Vietnamese, text amounts) show decimal, as do values past a sequence's end
|
||||
/// (the 21st item of ①…⑳). Thai numbers are the decimal number in Thai digits (๑, ๒ … ๑๐).
|
||||
pub fn format(self, n: u32) -> String {
|
||||
use crate::cjk_numbers as cjk;
|
||||
match self {
|
||||
NumFormat::DecimalZero => format!("{n:02}"),
|
||||
NumFormat::ThaiNumbers => thai_digits(n),
|
||||
NumFormat::UpperRoman => roman(n).to_uppercase(),
|
||||
NumFormat::LowerRoman => roman(n),
|
||||
NumFormat::UpperLetter => letters(n).to_uppercase(),
|
||||
@@ -315,7 +350,6 @@ impl NumFormat {
|
||||
| NumFormat::HindiNumbers
|
||||
| NumFormat::HindiCounting
|
||||
| NumFormat::ThaiLetters
|
||||
| NumFormat::ThaiNumbers
|
||||
| NumFormat::ThaiCounting
|
||||
| NumFormat::BahtText
|
||||
| NumFormat::DollarText => n.to_string(),
|
||||
@@ -323,6 +357,11 @@ impl NumFormat {
|
||||
}
|
||||
}
|
||||
|
||||
/// `n` in Thai digits, ๐ (U+0E50) to ๙ (#5757).
|
||||
fn thai_digits(n: u32) -> String {
|
||||
n.to_string().chars().filter_map(|c| c.to_digit(10).and_then(|d| char::from_u32(0x0E50 + d))).collect()
|
||||
}
|
||||
|
||||
/// Chicago Manual of Style note marks: *, †, ‡, §, then doubled (**, ††, …) and so on.
|
||||
fn chicago(n: u32) -> String {
|
||||
const MARKS: [char; 4] = ['*', '\u{2020}', '\u{2021}', '\u{a7}'];
|
||||
@@ -733,6 +772,13 @@ pub struct SectionProps {
|
||||
/// Distance from the page edge to the header / footer.
|
||||
pub header: f32,
|
||||
pub footer: f32,
|
||||
/// The space kept between a header and the body, and between the body and a footer, points
|
||||
/// (ODF's header and footer spacing; Word keeps none). The margin already holds the band and
|
||||
/// its spacing, so it only moves the body once content and spacing reach past the margin.
|
||||
#[serde(skip_serializing_if = "is_zero")]
|
||||
pub header_gap: f32,
|
||||
#[serde(skip_serializing_if = "is_zero")]
|
||||
pub footer_gap: f32,
|
||||
pub gutter: f32,
|
||||
/// Mirror margins (inside/outside) — set from the document settings.
|
||||
pub columns: Columns,
|
||||
@@ -742,6 +788,9 @@ pub struct SectionProps {
|
||||
pub title_page: bool,
|
||||
pub page_num_start: Option<u32>,
|
||||
pub page_num_format: NumFormat,
|
||||
/// Chapter heading level and separator in `w:pgNumType`.
|
||||
pub chapter_style: Option<u8>,
|
||||
pub chapter_sep: Option<ChapterSep>,
|
||||
/// 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")]
|
||||
@@ -772,6 +821,18 @@ pub struct SectionProps {
|
||||
pub footnote_pr: NoteProps,
|
||||
#[serde(skip_serializing_if = "NoteProps::is_empty")]
|
||||
pub endnote_pr: NoteProps,
|
||||
/// First-page and other-page printer trays (`w:paperSrc`).
|
||||
pub paper_src_first: Option<u16>,
|
||||
pub paper_src_other: Option<u16>,
|
||||
/// Section-specific suppression of endnotes (`w:noEndnote`).
|
||||
pub no_endnote: Option<bool>,
|
||||
/// Independent footnote column count (`w15:footnoteColumns`).
|
||||
pub footnote_columns: Option<u32>,
|
||||
/// Whether its columns end balanced (an ODF section's `text:dont-balance-text-columns`):
|
||||
/// `None` is Word's rule, balanced when a continuous section follows; `Some(true)` also at
|
||||
/// the end of the document, `Some(false)` never.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub balance_columns: Option<bool>,
|
||||
}
|
||||
|
||||
impl Default for SectionProps {
|
||||
@@ -788,6 +849,8 @@ impl Default for SectionProps {
|
||||
margin_right: 72.0,
|
||||
header: 36.0,
|
||||
footer: 36.0,
|
||||
header_gap: 0.0,
|
||||
footer_gap: 0.0,
|
||||
gutter: 0.0,
|
||||
columns: Columns::default(),
|
||||
headers: HeaderSet::default(),
|
||||
@@ -795,6 +858,8 @@ impl Default for SectionProps {
|
||||
title_page: false,
|
||||
page_num_start: None,
|
||||
page_num_format: NumFormat::Decimal,
|
||||
chapter_style: None,
|
||||
chapter_sep: None,
|
||||
page_num_set: false,
|
||||
line_numbers: None,
|
||||
valign: PageVAlign::Top,
|
||||
@@ -807,10 +872,19 @@ impl Default for SectionProps {
|
||||
doc_grid: None,
|
||||
footnote_pr: NoteProps::default(),
|
||||
endnote_pr: NoteProps::default(),
|
||||
paper_src_first: None,
|
||||
paper_src_other: None,
|
||||
no_endnote: None,
|
||||
footnote_columns: None,
|
||||
balance_columns: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_zero(v: &f32) -> bool {
|
||||
*v == 0.0
|
||||
}
|
||||
|
||||
fn is_horizontal(d: &crate::props::TextDirection) -> bool {
|
||||
!d.is_turned()
|
||||
}
|
||||
@@ -930,6 +1004,18 @@ mod tests {
|
||||
assert_eq!(NumFormat::from_ooxml("klingon"), NumFormat::Decimal, "unknown formats are decimal");
|
||||
}
|
||||
|
||||
/// #5757: Thai numbers are the decimal number in Thai digits, every digit, at any length.
|
||||
#[test]
|
||||
fn thai_numbers_use_thai_digits() {
|
||||
let labels: Vec<String> = [1, 2, 3, 9, 10, 11, 20, 105, 2569].iter().map(|n| NumFormat::ThaiNumbers.format(*n)).collect();
|
||||
assert_eq!(labels.join(" "), "๑ ๒ ๓ ๙ ๑๐ ๑๑ ๒๐ ๑๐๕ ๒๕๖๙");
|
||||
assert_eq!(NumFormat::ThaiNumbers.format(0), "๐");
|
||||
assert_eq!(NumFormat::ThaiNumbers.format(u32::MAX), "๔๒๙๔๙๖๗๒๙๕");
|
||||
assert_eq!(NumFormat::from_ooxml("thaiNumbers"), NumFormat::ThaiNumbers);
|
||||
// Formats not drawn yet still show decimal.
|
||||
assert_eq!(NumFormat::ThaiLetters.format(3), "3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn russian_letters_follow_word() {
|
||||
// Word's own labels for a russianLower list: no ё, й, ъ or ь, then doubled letters.
|
||||
|
||||
@@ -46,6 +46,10 @@ pub struct CustomProp {
|
||||
/// Bookmark associated with this property (`linkTarget`), retained without evaluating it.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub link_target: Option<String>,
|
||||
/// Declared in an ODF document as a user field (§7.4.7 `text:user-field-decl`) rather than
|
||||
/// a document property: written back as one.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub odf_user_field: bool,
|
||||
}
|
||||
|
||||
/// Document properties (File › Info).
|
||||
@@ -162,6 +166,23 @@ pub struct Settings {
|
||||
/// comments and tracked changes are saved without `w:date`.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub remove_date_time: bool,
|
||||
/// The document was opened from ODF: the standard's rules that differ from Word's apply as
|
||||
/// it is laid out.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub odf: Option<OdfLayout>,
|
||||
}
|
||||
|
||||
/// A document opened from ODF, for the layout: rules of the standard that differ from Word's (a
|
||||
/// paragraph's padding takes room on a side without a border, XSL's box model) apply, and what
|
||||
/// the file says about laying it out.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct OdfLayout {
|
||||
/// Tab stops are measured from the paragraph's left indent, default ones too (LibreOffice's
|
||||
/// `TabsRelativeToIndent` in the file; from the margin when it doesn't say). The document's
|
||||
/// explicit stops are held from the margin either way; the default ones are counted from the
|
||||
/// indent.
|
||||
pub tabs_from_indent: bool,
|
||||
}
|
||||
|
||||
/// A saved zoom: a percentage, or a fit mode (the percentage it last came to).
|
||||
@@ -307,6 +328,7 @@ impl Default for Settings {
|
||||
zoom: None,
|
||||
remove_personal_info: false,
|
||||
remove_date_time: false,
|
||||
odf: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,755 @@
|
||||
//! Fit One Page typography helper.
|
||||
//!
|
||||
//! Adjusts font sizes and paragraph spacing across styles and editable stories
|
||||
//! while preserving visual hierarchy, clamping to safe bounds, and excluding
|
||||
//! fixed framing stories like headers and footers.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::edit::{each_para, each_para_mut};
|
||||
use crate::para::Paragraph;
|
||||
use crate::parts::PartKind;
|
||||
use crate::props::{CharProps, LineSpacing, ParaProps};
|
||||
use crate::styles::StyleSheet;
|
||||
use crate::{Block, Document};
|
||||
|
||||
/// Minimum font size allowed in points (WordCraft floor).
|
||||
const MIN_SHRINK_FONT_SIZE: f32 = 4.0;
|
||||
|
||||
/// Maximum font size allowed in points.
|
||||
const MAX_SHRINK_FONT_SIZE: f32 = 1638.0;
|
||||
|
||||
/// Safe line spacing floor factor (preventing line collision / crowding).
|
||||
const MIN_LINE_SPACING_FACTOR: f32 = 0.94;
|
||||
|
||||
/// Cumulative fit-to-page typography reduction state.
|
||||
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct FitState {
|
||||
/// Cumulative font reduction delta in points.
|
||||
pub cum_delta: f32,
|
||||
/// Cumulative line spacing scale factor.
|
||||
pub cum_space: f32,
|
||||
/// Unique effective body font sizes present immediately after the reduction was applied.
|
||||
pub reduced_font_sizes: Vec<f32>,
|
||||
/// Unique effective line spacing values present immediately after the reduction was applied.
|
||||
pub reduced_line_spacings: Vec<LineSpacing>,
|
||||
/// Unique effective paragraph spacing values present immediately after the reduction was applied.
|
||||
#[serde(default)]
|
||||
pub reduced_paragraph_spacings: Vec<f32>,
|
||||
/// Unique effective body font sizes before the first Fit One Page reduction was applied.
|
||||
#[serde(default)]
|
||||
pub original_font_sizes: Vec<f32>,
|
||||
/// Unique effective line spacing values before the first Fit One Page reduction was applied.
|
||||
#[serde(default)]
|
||||
pub original_line_spacings: Vec<LineSpacing>,
|
||||
/// Unique effective paragraph spacing values before the first Fit One Page reduction was applied.
|
||||
#[serde(default)]
|
||||
pub original_paragraph_spacings: Vec<f32>,
|
||||
/// Original effective body font sizes by formatting location key before the first fit.
|
||||
#[serde(default)]
|
||||
pub original_font_locations: std::collections::BTreeMap<String, f32>,
|
||||
/// Original effective line spacing values by formatting location key before the first fit.
|
||||
#[serde(default)]
|
||||
pub original_line_spacing_locations: std::collections::BTreeMap<String, LineSpacing>,
|
||||
/// Original effective paragraph spacing values by formatting location key before the first fit.
|
||||
#[serde(default)]
|
||||
pub original_paragraph_spacing_locations: std::collections::BTreeMap<String, f32>,
|
||||
}
|
||||
|
||||
/// Scale a font size down by `delta_pt`.
|
||||
#[inline]
|
||||
fn scale_size(size: f32, delta_pt: f32) -> f32 {
|
||||
if delta_pt == 0.0 {
|
||||
return size;
|
||||
}
|
||||
// Round up so the reduction never exceeds `delta_pt`, and never enlarge.
|
||||
let target = (size - delta_pt).clamp(MIN_SHRINK_FONT_SIZE, MAX_SHRINK_FONT_SIZE);
|
||||
((target * 2.0).ceil() / 2.0).min(size)
|
||||
}
|
||||
|
||||
/// Scale a spacing value (e.g. `space_before`, `space_after`) by `space_factor`.
|
||||
///
|
||||
/// Caps the result at the original nonnegative spacing value and rounds up to hundredths (with a
|
||||
/// tiny epsilon for floating-point noise), so a trial never removes more spacing than its factor
|
||||
/// permits and never increases spacing. Rounding down would over-compress very small values.
|
||||
#[inline]
|
||||
fn scale_spacing(val: f32, space_factor: f32) -> f32 {
|
||||
let non_neg = val.max(0.0);
|
||||
let scaled = (non_neg * space_factor).clamp(0.0, 1584.0);
|
||||
((scaled * 100.0 - 1e-4).ceil() / 100.0).min(non_neg)
|
||||
}
|
||||
|
||||
/// Scale fixed line spacing in points respecting the safe readability floor and rounding upward to half-points.
|
||||
///
|
||||
/// Rounds upward to half-points so the result never falls below the 0.94 floor, and caps at `pts` so
|
||||
/// reduction never increases fixed line spacing.
|
||||
#[inline]
|
||||
fn scale_fixed_line_spacing(pts: f32, space_factor: f32) -> f32 {
|
||||
let floor = pts * MIN_LINE_SPACING_FACTOR;
|
||||
let scaled = (pts * space_factor).max(floor).clamp(0.0, 1584.0);
|
||||
// Round upward to half-points (0.5 pt) so the resulting spacing never falls below the 0.94 floor.
|
||||
((scaled * 2.0).ceil() / 2.0).min(pts)
|
||||
}
|
||||
|
||||
/// Scale line spacing by `space_factor` respecting the safe readability floor.
|
||||
#[inline]
|
||||
fn scale_line_spacing(ls: LineSpacing, space_factor: f32) -> LineSpacing {
|
||||
match ls {
|
||||
LineSpacing::Multiple(m) => {
|
||||
// Guard against ascender/descender collisions: keep line spacing at or above
|
||||
// safe readability floor of 0.94 factor (~6% max compression).
|
||||
let floor = m * MIN_LINE_SPACING_FACTOR;
|
||||
let new_m = (m * space_factor).max(floor).clamp(0.5, 132.0);
|
||||
// Round upward to a hundredth so the resulting multiple never falls below the 0.94 floor.
|
||||
LineSpacing::Multiple(((new_m * 100.0).ceil() / 100.0).min(m))
|
||||
}
|
||||
LineSpacing::AtLeast(pts) => LineSpacing::AtLeast(scale_fixed_line_spacing(pts, space_factor)),
|
||||
LineSpacing::Exactly(pts) => LineSpacing::Exactly(scale_fixed_line_spacing(pts, space_factor)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Scale explicit font sizes on a `CharProps`.
|
||||
#[inline]
|
||||
fn scale_char_props(c: &mut CharProps, delta_pt: f32) {
|
||||
if delta_pt > 0.0 {
|
||||
if let Some(s) = c.size.as_mut() {
|
||||
*s = scale_size(*s, delta_pt);
|
||||
}
|
||||
if let Some(s) = c.size_cs.as_mut() {
|
||||
*s = scale_size(*s, delta_pt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Scale explicit paragraph spacing on a `ParaProps`.
|
||||
#[inline]
|
||||
fn scale_para_props(p: &mut ParaProps, space_factor: f32) {
|
||||
if space_factor < 1.0 - f32::EPSILON {
|
||||
if let Some(sb) = p.space_before.as_mut() {
|
||||
*sb = scale_spacing(*sb, space_factor);
|
||||
}
|
||||
if let Some(sa) = p.space_after.as_mut() {
|
||||
*sa = scale_spacing(*sa, space_factor);
|
||||
}
|
||||
if let Some(ls) = p.line_spacing.as_mut() {
|
||||
*ls = scale_line_spacing(*ls, space_factor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds all text box IDs transitively reachable from Header and Footer parts
|
||||
/// (including linked text box chains and nested text boxes).
|
||||
pub fn header_footer_text_boxes(doc: &Document) -> BTreeSet<u32> {
|
||||
let mut hf_boxes: BTreeSet<u32> = BTreeSet::new();
|
||||
let mut queue: Vec<u32> = Vec::new();
|
||||
|
||||
for part in doc.parts.values() {
|
||||
if matches!(part.kind, PartKind::Header | PartKind::Footer) {
|
||||
for b in &part.blocks {
|
||||
each_para(b, 0, &mut |p| {
|
||||
for obj in &p.objects {
|
||||
for box_id in obj.text_boxes() {
|
||||
if hf_boxes.insert(box_id) {
|
||||
queue.push(box_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while let Some(box_id) = queue.pop() {
|
||||
if let Some(part) = doc.parts.get(&box_id) {
|
||||
if let Some(next_id) = part.body.next
|
||||
&& hf_boxes.insert(next_id)
|
||||
{
|
||||
queue.push(next_id);
|
||||
}
|
||||
for b in &part.blocks {
|
||||
each_para(b, 0, &mut |p| {
|
||||
for obj in &p.objects {
|
||||
for nested_id in obj.text_boxes() {
|
||||
if hf_boxes.insert(nested_id) {
|
||||
queue.push(nested_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
hf_boxes
|
||||
}
|
||||
|
||||
/// Freezes resolved character and paragraph formatting on a single paragraph using the provided stylesheet and optional table-style context.
|
||||
fn freeze_para_formatting(
|
||||
p: &mut Paragraph,
|
||||
styles: &StyleSheet,
|
||||
table_chr: Option<&CharProps>,
|
||||
table_para: Option<&ParaProps>,
|
||||
delta_pt: f32,
|
||||
space_factor: f32,
|
||||
) {
|
||||
let style = p.props.style.clone();
|
||||
if delta_pt > 0.0 {
|
||||
for r in &mut p.runs {
|
||||
if r.props.size.is_none() {
|
||||
let resolved = styles.resolve_char_in(style.as_deref(), table_chr, &r.props).size;
|
||||
r.props.size = Some(resolved);
|
||||
}
|
||||
if r.props.size_cs.is_none() {
|
||||
let resolved_cs = styles.resolve_char_in(style.as_deref(), table_chr, &r.props).size_cs;
|
||||
r.props.size_cs = Some(resolved_cs);
|
||||
}
|
||||
}
|
||||
if p.mark.size.is_none() {
|
||||
let resolved = styles.resolve_char_in(style.as_deref(), table_chr, &p.mark).size;
|
||||
p.mark.size = Some(resolved);
|
||||
}
|
||||
if p.mark.size_cs.is_none() {
|
||||
let resolved_cs = styles.resolve_char_in(style.as_deref(), table_chr, &p.mark).size_cs;
|
||||
p.mark.size_cs = Some(resolved_cs);
|
||||
}
|
||||
}
|
||||
if space_factor < 1.0 - f32::EPSILON {
|
||||
let resolved_p = styles.resolve_para_in(&p.props, table_para);
|
||||
if p.props.space_before.is_none() {
|
||||
p.props.space_before = Some(resolved_p.space_before);
|
||||
}
|
||||
if p.props.space_after.is_none() {
|
||||
p.props.space_after = Some(resolved_p.space_after);
|
||||
}
|
||||
if p.props.line_spacing.is_none() {
|
||||
p.props.line_spacing = Some(resolved_p.line_spacing);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursively freezes resolved formatting on blocks while preserving table-style context.
|
||||
fn freeze_blocks_formatting(
|
||||
blocks: &mut [Arc<Block>],
|
||||
styles: &StyleSheet,
|
||||
table_chr: Option<&CharProps>,
|
||||
table_para: Option<&ParaProps>,
|
||||
delta_pt: f32,
|
||||
space_factor: f32,
|
||||
) {
|
||||
use crate::styles::PartSet;
|
||||
|
||||
for b in blocks {
|
||||
let b = Arc::make_mut(b);
|
||||
match b {
|
||||
Block::Para(p) => {
|
||||
freeze_para_formatting(p, styles, table_chr, table_para, delta_pt, space_factor);
|
||||
}
|
||||
Block::Table(tbl) => {
|
||||
let style = tbl.props.style.as_deref().and_then(|id| styles.table_style(id));
|
||||
let cell_para = style.as_ref().map(|st| &st.para);
|
||||
let nrows = tbl.rows.len();
|
||||
let ncols = tbl.cols();
|
||||
let cell_grids: Vec<Vec<usize>> =
|
||||
(0..nrows).map(|ri| (0..tbl.rows[ri].cells.len()).map(|ci| tbl.grid_col(ri, ci)).collect()).collect();
|
||||
for (ri, row) in tbl.rows.iter_mut().enumerate() {
|
||||
for (ci, cell) in row.cells.iter_mut().enumerate() {
|
||||
let mut cell_chr = CharProps::default();
|
||||
if let Some(st) = &style {
|
||||
let sizes = (st.parts.band_size.unwrap_or(0), st.parts.col_band_size.unwrap_or(0));
|
||||
let g = cell_grids.get(ri).and_then(|r| r.get(ci)).copied().unwrap_or(0);
|
||||
let span = cell.span();
|
||||
let region = PartSet::of_cell(&tbl.props.look, sizes, (ri, nrows), (g, span, ncols));
|
||||
cell_chr.overlay(&st.chr);
|
||||
for part in region.iter() {
|
||||
let f = st.part(part);
|
||||
cell_chr.overlay(&f.chr);
|
||||
}
|
||||
}
|
||||
freeze_blocks_formatting(&mut cell.blocks, styles, Some(&cell_chr), cell_para, delta_pt, space_factor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fits font sizes across the document by `delta_pt` points and scales paragraph
|
||||
/// spacing by `space_factor`.
|
||||
///
|
||||
/// Clean-room typographical helper for Fit One Page. Preserves relative typography
|
||||
/// hierarchies between headings, body text, and direct run formatting.
|
||||
/// Clamps font sizes to `[4.0, 1638.0]` pt and rounds to 0.5 pt (half-points).
|
||||
/// Headers, footers, and comments are explicitly excluded and retain their exact original sizes.
|
||||
/// Footnotes are included and shrink alongside body text.
|
||||
pub fn fit_document_typography(doc: &mut Document, delta_pt: f32, space_factor: f32) {
|
||||
if !delta_pt.is_finite() || !space_factor.is_finite() {
|
||||
return;
|
||||
}
|
||||
let delta_pt = delta_pt.max(0.0);
|
||||
let space_factor = space_factor.clamp(0.1, 1.0);
|
||||
|
||||
if delta_pt == 0.0 && (space_factor - 1.0).abs() < f32::EPSILON {
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Find all text boxes reachable from Header and Footer parts (including nested text boxes and chains).
|
||||
let hf_boxes = header_footer_text_boxes(doc);
|
||||
|
||||
// Freeze header, footer, and comment font sizes and spacing (and reachable text boxes) before modifying styles,
|
||||
// preserving table-style context so header/footer table cells retain their conditional table formatting,
|
||||
// and comment stories retain their inherited formatting from shared styles.
|
||||
let old_styles = doc.styles.clone();
|
||||
for (id, part) in doc.parts.iter_mut() {
|
||||
if matches!(part.kind, PartKind::Header | PartKind::Footer | PartKind::Comment) || hf_boxes.contains(id) {
|
||||
freeze_blocks_formatting(&mut part.blocks, &old_styles, None, None, delta_pt, space_factor);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Adjust styles: document defaults, character styles, paragraph styles, table style regions.
|
||||
let st = Arc::make_mut(&mut doc.styles);
|
||||
|
||||
if delta_pt > 0.0 {
|
||||
// Default character formatting
|
||||
let default_size = st.default_chr.size.unwrap_or(crate::props::DEFAULT_FONT_SIZE);
|
||||
st.default_chr.size = Some(scale_size(default_size, delta_pt));
|
||||
if let Some(cs) = st.default_chr.size_cs.as_mut() {
|
||||
*cs = scale_size(*cs, delta_pt);
|
||||
}
|
||||
}
|
||||
|
||||
// Default paragraph formatting
|
||||
scale_para_props(&mut st.default_para, space_factor);
|
||||
|
||||
// All named styles (excluding header/footer styles if any)
|
||||
for style in &mut st.styles {
|
||||
let is_hf = style.id.eq_ignore_ascii_case("header")
|
||||
|| style.id.eq_ignore_ascii_case("footer")
|
||||
|| style.name.eq_ignore_ascii_case("header")
|
||||
|| style.name.eq_ignore_ascii_case("footer");
|
||||
if is_hf {
|
||||
continue;
|
||||
}
|
||||
|
||||
if delta_pt > 0.0 {
|
||||
scale_char_props(&mut style.chr, delta_pt);
|
||||
}
|
||||
scale_para_props(&mut style.para, space_factor);
|
||||
|
||||
if let Some(t) = style.table.as_mut()
|
||||
&& delta_pt > 0.0
|
||||
{
|
||||
for c in [&mut t.header_chr, &mut t.band_chr, &mut t.first_col_chr, &mut t.last_col_chr, &mut t.total_chr, &mut t.col_band_chr] {
|
||||
scale_char_props(c, delta_pt);
|
||||
}
|
||||
for part in t.regions.values_mut() {
|
||||
scale_char_props(&mut part.chr, delta_pt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Helper to shrink editable paragraph content (runs, mark, spacing).
|
||||
let mut shrink_paragraph = |p: &mut Paragraph| {
|
||||
scale_para_props(&mut p.props, space_factor);
|
||||
|
||||
if delta_pt > 0.0 {
|
||||
// Scale explicit paragraph mark size
|
||||
scale_char_props(&mut p.mark, delta_pt);
|
||||
|
||||
// Scale explicit run sizes
|
||||
for r in &mut p.runs {
|
||||
scale_char_props(&mut r.props, delta_pt);
|
||||
}
|
||||
}
|
||||
|
||||
p.touch();
|
||||
};
|
||||
|
||||
// 4. Traverse main body story.
|
||||
for b in &mut doc.body {
|
||||
each_para_mut(Arc::make_mut(b), 0, &mut shrink_paragraph);
|
||||
}
|
||||
|
||||
// 5. Traverse secondary editable stories (footnotes, body text boxes), excluding headers, footers,
|
||||
// comments, and text boxes reachable from headers or footers.
|
||||
for (id, part) in doc.parts.iter_mut() {
|
||||
if matches!(part.kind, PartKind::Header | PartKind::Footer | PartKind::Comment) || hf_boxes.contains(id) {
|
||||
continue;
|
||||
}
|
||||
for b in &mut part.blocks {
|
||||
each_para_mut(Arc::make_mut(b), 0, &mut shrink_paragraph);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::block::Block;
|
||||
use crate::para::Paragraph;
|
||||
use crate::props::{CharProps, LineSpacing};
|
||||
use crate::table::Table;
|
||||
use crate::{Document, para_block};
|
||||
|
||||
#[test]
|
||||
fn test_shrink_preserves_hierarchy() {
|
||||
let mut doc = Document::new();
|
||||
// Setup Heading 1 paragraph and Normal paragraph
|
||||
let h1_para = Paragraph::with_text("Heading 1 Title", CharProps::default()).styled("Heading1");
|
||||
let normal_para = Paragraph::with_text("Body text in Normal style", CharProps::default()).styled("Normal");
|
||||
let mut custom_run_para = Paragraph::with_text("Normal prefix ", CharProps::default()).styled("Normal");
|
||||
custom_run_para.insert_text(custom_run_para.len(), "Special large text", &CharProps { size: Some(24.0), ..Default::default() }).unwrap();
|
||||
|
||||
doc.body = vec![para_block(h1_para), para_block(normal_para), para_block(custom_run_para)];
|
||||
|
||||
let h1_before = doc.styles.resolve_char(Some("Heading1"), &CharProps::default()).size;
|
||||
let normal_before = doc.styles.resolve_char(Some("Normal"), &CharProps::default()).size;
|
||||
|
||||
fit_document_typography(&mut doc, 0.5, 1.0);
|
||||
|
||||
let h1_after = doc.styles.resolve_char(Some("Heading1"), &CharProps::default()).size;
|
||||
let normal_after = doc.styles.resolve_char(Some("Normal"), &CharProps::default()).size;
|
||||
|
||||
// Both styles scaled down by exactly 0.5 pt
|
||||
assert_eq!(h1_after, h1_before - 0.5);
|
||||
assert_eq!(normal_after, normal_before - 0.5);
|
||||
// Relative hierarchy preserved
|
||||
assert_eq!(h1_after - normal_after, h1_before - normal_before);
|
||||
|
||||
// Explicit run in paragraph 3 was 24.0 pt, now 23.5 pt
|
||||
let p3 = doc.body[2].as_para().unwrap();
|
||||
assert_eq!(p3.runs[1].props.size, Some(23.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shrink_excludes_headers_and_footers() {
|
||||
let mut doc = Document::new();
|
||||
let body_para = Paragraph::with_text("Main story body text", CharProps::default());
|
||||
doc.body = vec![para_block(body_para)];
|
||||
|
||||
let header_para = Paragraph::with_text("Header Text", CharProps { size: Some(10.0), ..Default::default() });
|
||||
let footer_para = Paragraph::with_text("Footer Text Page 1", CharProps::default()); // inherits
|
||||
|
||||
let h_id = doc.add_part(PartKind::Header, vec![para_block(header_para)]);
|
||||
let f_id = doc.add_part(PartKind::Footer, vec![para_block(footer_para)]);
|
||||
|
||||
let footer_before_size = doc.styles.resolve_char(None, &CharProps::default()).size;
|
||||
|
||||
fit_document_typography(&mut doc, 0.5, 1.0);
|
||||
|
||||
// Body default style shrank
|
||||
let body_after_size = doc.styles.resolve_char(None, &CharProps::default()).size;
|
||||
assert_eq!(body_after_size, footer_before_size - 0.5);
|
||||
|
||||
// Header retained its explicit 10.0 pt size
|
||||
let h_part = doc.parts.get(&h_id).unwrap();
|
||||
let h_p = h_part.blocks[0].as_para().unwrap();
|
||||
assert_eq!(h_p.runs[0].props.size, Some(10.0));
|
||||
|
||||
// Footer retained its original resolved size
|
||||
let f_part = doc.parts.get(&f_id).unwrap();
|
||||
let f_p = f_part.blocks[0].as_para().unwrap();
|
||||
assert_eq!(f_p.runs[0].props.size, Some(footer_before_size));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shrink_scales_footnotes() {
|
||||
let mut doc = Document::new();
|
||||
let body_para = Paragraph::with_text("Main body with note", CharProps::default());
|
||||
doc.body = vec![para_block(body_para)];
|
||||
|
||||
let fn_para = Paragraph::with_text("Footnote content", CharProps { size: Some(10.0), ..Default::default() });
|
||||
let fn_id = doc.add_part(PartKind::Footnote, vec![para_block(fn_para)]);
|
||||
|
||||
fit_document_typography(&mut doc, 0.5, 1.0);
|
||||
|
||||
let fn_part = doc.parts.get(&fn_id).unwrap();
|
||||
let fn_p = fn_part.blocks[0].as_para().unwrap();
|
||||
assert_eq!(fn_p.runs[0].props.size, Some(9.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shrink_clamps_to_min_floor() {
|
||||
let mut doc = Document::new();
|
||||
let mut p = Paragraph::with_text("Very tiny text", CharProps { size: Some(4.2), ..Default::default() });
|
||||
p.insert_text(p.len(), "Floor text", &CharProps { size: Some(4.0), ..Default::default() }).unwrap();
|
||||
doc.body = vec![para_block(p)];
|
||||
|
||||
fit_document_typography(&mut doc, 1.0, 1.0);
|
||||
|
||||
let p_after = doc.body[0].as_para().unwrap();
|
||||
assert_eq!(p_after.runs[0].props.size, Some(4.0));
|
||||
assert_eq!(p_after.runs[1].props.size, Some(4.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shrink_zero_delta_identity() {
|
||||
let mut doc = Document::new();
|
||||
let h1_para = Paragraph::with_text("Heading 1 Title", CharProps::default()).styled("Heading1");
|
||||
let normal_para = Paragraph::with_text("Body text", CharProps::default());
|
||||
doc.body = vec![para_block(h1_para), para_block(normal_para)];
|
||||
doc.add_part(PartKind::Header, vec![para_block(Paragraph::with_text("Header", CharProps::default()))]);
|
||||
doc.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("Note", CharProps::default()))]);
|
||||
|
||||
let before = doc.clone();
|
||||
fit_document_typography(&mut doc, 0.0, 1.0);
|
||||
assert_eq!(doc, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shrink_paragraph_spacing() {
|
||||
let mut doc = Document::new();
|
||||
let mut p = Paragraph::with_text("Spaced paragraph", CharProps::default());
|
||||
p.props.space_before = Some(18.0);
|
||||
p.props.space_after = Some(12.0);
|
||||
p.props.line_spacing = Some(LineSpacing::Multiple(1.15));
|
||||
doc.body = vec![para_block(p)];
|
||||
|
||||
fit_document_typography(&mut doc, 0.5, 0.9);
|
||||
|
||||
let p_after = doc.body[0].as_para().unwrap();
|
||||
// 18.0 * 0.9 = 16.2, capped at the requested reduction
|
||||
assert_eq!(p_after.props.space_before, Some(16.2));
|
||||
// 12.0 * 0.9 = 10.8, capped at the requested reduction
|
||||
assert_eq!(p_after.props.space_after, Some(10.8));
|
||||
// 1.15 * 0.9 = 1.035 -> clamped to safe line spacing floor (1.15 * 0.94 = 1.081) -> ceil hundredth 1.09
|
||||
assert_eq!(p_after.props.line_spacing, Some(LineSpacing::Multiple(1.09)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spacing_scaling_never_exceeds_requested_compression() {
|
||||
// 0.26 * 0.94 = 0.2444 pt: it must not round down to zero and remove all spacing.
|
||||
let tiny = scale_spacing(0.26, 0.94);
|
||||
assert!(tiny > 0.0 && tiny <= 0.26, "tiny spacing must shrink without vanishing: {tiny}");
|
||||
// 0.019 * 0.94 = 0.01786 pt: rounding down to hundredths would remove 47%, so round up.
|
||||
assert_eq!(scale_spacing(0.019, 0.94), 0.019);
|
||||
// 18.0 * 0.9 = 16.2 pt: the reduction stays at the requested 1.8 pt.
|
||||
assert_eq!(scale_spacing(18.0, 0.9), 16.2);
|
||||
assert_eq!(scale_spacing(0.0, 0.9), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_line_spacing_floor_prevents_overlap() {
|
||||
let mut doc = Document::new();
|
||||
let mut p = Paragraph::with_text("Single spaced text", CharProps::default());
|
||||
p.props.line_spacing = Some(LineSpacing::Multiple(1.0));
|
||||
doc.body = vec![para_block(p)];
|
||||
|
||||
// Even with aggressive space_factor = 0.5, line spacing multiple must not fall below safe floor (0.94)
|
||||
fit_document_typography(&mut doc, 0.5, 0.5);
|
||||
|
||||
let p_after = doc.body[0].as_para().unwrap();
|
||||
assert_eq!(p_after.props.line_spacing, Some(LineSpacing::Multiple(0.94)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_header_text_boxes_are_frozen_and_do_not_shrink() {
|
||||
let mut doc = Document::new();
|
||||
let body_p = Paragraph::with_text("Body text", CharProps { size: Some(12.0), ..Default::default() });
|
||||
doc.body = vec![para_block(body_p)];
|
||||
|
||||
let header_box_id = doc
|
||||
.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text("Header box", CharProps { size: Some(14.0), ..Default::default() }))]);
|
||||
let mut header_p = Paragraph::with_text("Header prefix ", CharProps { size: Some(10.0), ..Default::default() });
|
||||
let shape = crate::para::InlineObject::Shape {
|
||||
kind: crate::para::ShapeKind::TextBox,
|
||||
w: 144.0,
|
||||
h: 72.0,
|
||||
fill: None,
|
||||
stroke: None,
|
||||
stroke_width: 0.75,
|
||||
float: Default::default(),
|
||||
story: Some(header_box_id),
|
||||
freeform: None,
|
||||
effects: Default::default(),
|
||||
extra: Default::default(),
|
||||
};
|
||||
header_p.objects.push(shape);
|
||||
doc.add_part(PartKind::Header, vec![para_block(header_p)]);
|
||||
|
||||
let body_box_id =
|
||||
doc.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text("Body box", CharProps { size: Some(16.0), ..Default::default() }))]);
|
||||
|
||||
fit_document_typography(&mut doc, 2.0, 1.0);
|
||||
|
||||
let b_p = doc.body[0].as_para().unwrap();
|
||||
assert_eq!(b_p.runs[0].props.size, Some(10.0));
|
||||
|
||||
let bb_part = doc.parts.get(&body_box_id).unwrap();
|
||||
let bb_p = bb_part.blocks[0].as_para().unwrap();
|
||||
assert_eq!(bb_p.runs[0].props.size, Some(14.0));
|
||||
|
||||
let hb_part = doc.parts.get(&header_box_id).unwrap();
|
||||
let hb_p = hb_part.blocks[0].as_para().unwrap();
|
||||
assert_eq!(hb_p.runs[0].props.size, Some(14.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shrink_table_cells() {
|
||||
let mut doc = Document::new();
|
||||
let mut table = Table::new(1, 1, 100.0);
|
||||
let cell_p = Paragraph::with_text("Cell text", CharProps { size: Some(12.0), ..Default::default() });
|
||||
table.rows[0].cells[0].blocks = vec![para_block(cell_p)];
|
||||
doc.body = vec![Arc::new(Block::Table(table))];
|
||||
|
||||
fit_document_typography(&mut doc, 0.5, 1.0);
|
||||
|
||||
let tbl = doc.body[0].as_table().unwrap();
|
||||
let cell_p_after = tbl.rows[0].cells[0].blocks[0].as_para().unwrap();
|
||||
assert_eq!(cell_p_after.runs[0].props.size, Some(11.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exact_line_spacing_preserves_floor_upward() {
|
||||
// LineSpacing::Exactly(7.5) with space_factor = 0.90 (below MIN_LINE_SPACING_FACTOR 0.94)
|
||||
// Floor = 7.5 * 0.94 = 7.05 pt. Rounding upward to half-points yields 7.5 pt (>= 7.05 pt).
|
||||
let ls = scale_line_spacing(LineSpacing::Exactly(7.5), 0.90);
|
||||
assert_eq!(ls, LineSpacing::Exactly(7.5));
|
||||
|
||||
// LineSpacing::Exactly(10.0) with space_factor = 0.90:
|
||||
// Floor = 10.0 * 0.94 = 9.4 pt. Rounding upward to half-points yields 9.5 pt (>= 9.4 pt).
|
||||
let ls2 = scale_line_spacing(LineSpacing::Exactly(10.0), 0.90);
|
||||
assert_eq!(ls2, LineSpacing::Exactly(9.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_spacing_only_leaves_unrounded_font_sizes_unchanged() {
|
||||
let mut doc = Document::new();
|
||||
// A run with fractional 9.3 pt font
|
||||
let p = Paragraph::with_text("Unrounded size text", CharProps { size: Some(9.3), ..Default::default() });
|
||||
doc.body = vec![para_block(p)];
|
||||
|
||||
// Spacing-only trial (delta_pt = 0.0, space_factor = 0.94)
|
||||
fit_document_typography(&mut doc, 0.0, 0.94);
|
||||
|
||||
let p_after = doc.body[0].as_para().unwrap();
|
||||
assert_eq!(p_after.runs[0].props.size, Some(9.3), "spacing-only trial must not round or scale font size");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scale_size_never_enlarges_sub_floor_font() {
|
||||
// 2.0 pt run (below 4.0 pt floor) with delta 0.5 pt must not be enlarged to 4.0 pt
|
||||
assert_eq!(scale_size(2.0, 0.5), 2.0);
|
||||
// 3.0 pt run with delta 0.5 pt must not be enlarged to 4.0 pt
|
||||
assert_eq!(scale_size(3.0, 0.5), 3.0);
|
||||
// 5.0 pt run with delta 0.5 pt shrinks to 4.5 pt
|
||||
assert_eq!(scale_size(5.0, 0.5), 4.5);
|
||||
// 12.0 pt run with delta 0.3 pt rounds up to 12.0 pt so reduction (0.0 pt) never exceeds delta (0.3 pt)
|
||||
assert_eq!(scale_size(12.0, 0.3), 12.0);
|
||||
// Zero delta leaves size unchanged
|
||||
assert_eq!(scale_size(2.0, 0.0), 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shrink_excludes_comment_stories() {
|
||||
let mut doc = Document::new();
|
||||
let comment_p = Paragraph::with_text("Comment text", CharProps { size: Some(8.0), ..Default::default() });
|
||||
let comment_id = doc.add_part(PartKind::Comment, vec![para_block(comment_p)]);
|
||||
|
||||
// Shrink with 1.0 pt delta
|
||||
fit_document_typography(&mut doc, 1.0, 0.94);
|
||||
|
||||
let comment_part = doc.parts.get(&comment_id).unwrap();
|
||||
let p_after = comment_part.blocks[0].as_para().unwrap();
|
||||
assert_eq!(p_after.runs[0].props.size, Some(8.0), "comments must be excluded from typography shrink pass");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shrink_preserves_comment_inherited_formatting() {
|
||||
let mut doc = Document::new();
|
||||
// Setup a named style "CommentText" with 12.0 pt font and 6.0 pt spacing after.
|
||||
let comment_style = crate::styles::Style {
|
||||
id: "CommentText".into(),
|
||||
name: "Comment Text".into(),
|
||||
kind: crate::styles::StyleKind::Paragraph,
|
||||
chr: CharProps { size: Some(12.0), ..Default::default() },
|
||||
para: ParaProps { space_after: Some(6.0), line_spacing: Some(LineSpacing::Multiple(1.2)), ..Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
Arc::make_mut(&mut doc.styles).upsert(comment_style);
|
||||
|
||||
// Comment paragraph inheriting from "CommentText" without explicit run size or paragraph spacing
|
||||
let mut comment_p = Paragraph::with_text("Inherited comment text", CharProps::default());
|
||||
comment_p.props.style = Some("CommentText".to_string());
|
||||
let comment_id = doc.add_part(PartKind::Comment, vec![para_block(comment_p)]);
|
||||
|
||||
// Shrink with 1.0 pt delta and 0.94 space factor
|
||||
fit_document_typography(&mut doc, 1.0, 0.94);
|
||||
|
||||
// Shared style "CommentText" is scaled (12.0 -> 11.0 pt)
|
||||
let scaled_style = doc.styles.get("CommentText").unwrap();
|
||||
assert_eq!(scaled_style.chr.size, Some(11.0));
|
||||
|
||||
// The comment story must have frozen its original inherited formatting before styles were modified
|
||||
let comment_part = doc.parts.get(&comment_id).unwrap();
|
||||
let p_after = comment_part.blocks[0].as_para().unwrap();
|
||||
assert_eq!(p_after.runs[0].props.size, Some(12.0), "comment run size must remain 12.0 pt from old styles");
|
||||
assert_eq!(p_after.props.space_after, Some(6.0), "comment paragraph space_after must remain 6.0 pt from old styles");
|
||||
assert_eq!(p_after.props.line_spacing, Some(LineSpacing::Multiple(1.2)), "comment paragraph line_spacing must remain 1.2 from old styles");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_header_table_text_inherits_size_from_table_style() {
|
||||
let mut doc = Document::new();
|
||||
// Setup a custom table style with 9.0 pt font
|
||||
let st = crate::styles::Style {
|
||||
id: "HeaderTableStyle".into(),
|
||||
name: "HeaderTableStyle".into(),
|
||||
kind: crate::styles::StyleKind::Table,
|
||||
chr: CharProps { size: Some(9.0), ..Default::default() },
|
||||
table: Some(crate::styles::TableStyleParts::default()),
|
||||
..Default::default()
|
||||
};
|
||||
Arc::make_mut(&mut doc.styles).upsert(st);
|
||||
|
||||
// Create a table in a Header part that uses HeaderTableStyle
|
||||
let mut table = crate::Table::new(1, 1, 100.0);
|
||||
table.props.style = Some("HeaderTableStyle".to_string());
|
||||
// Cell paragraph with no explicit font size on run (inherits from table style)
|
||||
let cell_p = Paragraph::with_text("Header cell text", CharProps::default());
|
||||
table.rows[0].cells[0].blocks = vec![crate::para_block(cell_p)];
|
||||
|
||||
let header_id = doc.add_part(PartKind::Header, vec![Arc::new(Block::Table(table))]);
|
||||
|
||||
// Run shrink with delta_pt = 1.0 (body font would shrink)
|
||||
fit_document_typography(&mut doc, 1.0, 0.95);
|
||||
|
||||
// Verify that the header table cell's run preserved the 9.0 pt size from the table style,
|
||||
// and was not frozen to the document default (11.0 pt)
|
||||
let header_part = doc.parts.get(&header_id).unwrap();
|
||||
if let Block::Table(tbl) = header_part.blocks[0].as_ref() {
|
||||
let p = tbl.rows[0].cells[0].blocks[0].as_para().unwrap();
|
||||
assert_eq!(p.runs[0].props.size, Some(9.0), "header table text must inherit and freeze table style font size");
|
||||
} else {
|
||||
panic!("expected table in header part");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scale_line_spacing_never_increases_fixed_or_multiple() {
|
||||
// LineSpacing::Exactly(7.45) with space_factor = 0.94:
|
||||
// 7.45 * 0.94 = 7.003 -> ceil to half-point would yield 7.5 pt, but capping at 7.45 ensures no enlargement
|
||||
assert_eq!(scale_fixed_line_spacing(7.45, 0.94), 7.45);
|
||||
// Regular scaling: 10.0 pt with 0.94 -> 9.4 -> ceil to half-point = 9.5 pt <= 10.0 pt
|
||||
assert_eq!(scale_fixed_line_spacing(10.0, 0.94), 9.5);
|
||||
|
||||
// LineSpacing::Multiple with small factor never enlarges
|
||||
let ls = LineSpacing::Multiple(0.942);
|
||||
let scaled = scale_line_spacing(ls, 0.94);
|
||||
if let LineSpacing::Multiple(m) = scaled {
|
||||
assert!(m <= 0.942);
|
||||
} else {
|
||||
panic!("expected Multiple");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scale_spacing_never_increases_value() {
|
||||
// Spacing 0.3 pt scaled by 0.94 yields 0.282 pt, rounded up to 0.29 and never above 0.3 pt.
|
||||
assert_eq!(scale_spacing(0.3, 0.94), 0.29);
|
||||
// 12.0 pt * 0.94 = 11.28 pt, capped at the requested reduction.
|
||||
assert_eq!(scale_spacing(12.0, 0.94), 11.28);
|
||||
// Zero spacing remains zero.
|
||||
assert_eq!(scale_spacing(0.0, 0.94), 0.0);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::props::Rgb;
|
||||
|
||||
/// Most items in a SmartArt graphic.
|
||||
pub const MAX_ITEMS: usize = 100;
|
||||
/// Longest item text, characters.
|
||||
@@ -198,11 +200,14 @@ pub struct SmartArtItem {
|
||||
pub text: String,
|
||||
#[serde(default)]
|
||||
pub level: u8,
|
||||
/// Fill of the item's shape in place of the style's colour.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub color: Option<Rgb>,
|
||||
}
|
||||
|
||||
impl SmartArtItem {
|
||||
pub fn new(text: impl Into<String>, level: u8) -> SmartArtItem {
|
||||
SmartArtItem { text: text.into(), level }
|
||||
SmartArtItem { text: text.into(), level, color: None }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,6 +262,35 @@ impl SmartArtSpec {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any item has its own colour.
|
||||
pub fn has_custom_colors(&self) -> bool {
|
||||
self.items.iter().any(|it| it.color.is_some())
|
||||
}
|
||||
|
||||
/// Give every item the style's colour again.
|
||||
pub fn clear_custom_colors(&mut self) {
|
||||
for it in &mut self.items {
|
||||
it.color = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// The theme accent (1–6) the style gives item `i`; `None` when the item has no shape of
|
||||
/// its own (it is text inside another item's shape).
|
||||
pub fn style_accent(&self, i: usize) -> Option<usize> {
|
||||
let it = self.items.get(i)?;
|
||||
let tops = || self.items.iter().take(i).filter(|o| o.level == 0).count();
|
||||
match self.layout {
|
||||
SmartArtLayout::Hierarchy => Some(self.colors.accent(i)),
|
||||
SmartArtLayout::Radial if i == 0 => Some(self.colors.accent(0)),
|
||||
SmartArtLayout::Radial => {
|
||||
let parents = self.parents();
|
||||
let spoke = |j: usize| j > 0 && matches!(parents.get(j), Some(Some(0)) | Some(None));
|
||||
spoke(i).then(|| self.colors.accent((1..i).filter(|j| spoke(*j)).count() + 1))
|
||||
}
|
||||
_ => (it.level == 0).then(|| self.colors.accent(tops())),
|
||||
}
|
||||
}
|
||||
|
||||
/// The graphic after `sanitize`.
|
||||
pub fn sanitized(mut self) -> SmartArtSpec {
|
||||
self.sanitize();
|
||||
|
||||
@@ -6,8 +6,8 @@ use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::props::{
|
||||
Align, Border, BorderStyle, Borders, CharProps, LineSpacing, ParaProps, Rgb, TabAlign, TabLeader, TabStop, TableLook, TextColor, Underline,
|
||||
VAlign,
|
||||
Align, Border, BorderStyle, Borders, CharProps, LineSpacing, ParaProps, PropChange, Rgb, TabAlign, TabLeader, TabStop, TableLook, TextColor,
|
||||
Underline, VAlign,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
@@ -46,6 +46,13 @@ pub struct Style {
|
||||
/// (`w:locked`).
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub locked: bool,
|
||||
/// A tracked change of the style's paragraph properties (`w:pPrChange`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub para_change: Option<Box<PropChange<ParaProps>>>,
|
||||
/// A tracked change of the style's run properties (`w:rPrChange`). Kept separate because a
|
||||
/// foreign DOCX can attribute the two property changes to different revisions.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub chr_change: Option<Box<PropChange<CharProps>>>,
|
||||
/// 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")]
|
||||
@@ -232,6 +239,40 @@ impl PartSet {
|
||||
}
|
||||
}
|
||||
|
||||
/// A cell's borders from the table style's parts it is in, later parts winning. Row parts give
|
||||
/// their left/right edges at the table's sides and `inside_v` between cells; column parts their
|
||||
/// top/bottom edges at the table's top and bottom and `between` between rows; corner cells their
|
||||
/// four edges.
|
||||
pub fn part_borders(parts: &[(TablePart, Borders)], (ri, nrows): (usize, usize), (first_g, last_g): (bool, bool)) -> Borders {
|
||||
let mut out = Borders::default();
|
||||
for (part, b) in parts {
|
||||
let b = *b;
|
||||
let mapped = if part.is_row() {
|
||||
Borders {
|
||||
top: b.top,
|
||||
bottom: b.bottom,
|
||||
left: if first_g { b.left } else { b.inside_v },
|
||||
right: if last_g { b.right } else { b.inside_v },
|
||||
between: None,
|
||||
inside_v: None,
|
||||
}
|
||||
} else if part.is_column() {
|
||||
Borders {
|
||||
top: if ri == 0 { b.top } else { b.between },
|
||||
bottom: if ri + 1 >= nrows { b.bottom } else { b.between },
|
||||
left: b.left,
|
||||
right: b.right,
|
||||
between: None,
|
||||
inside_v: None,
|
||||
}
|
||||
} else {
|
||||
Borders { between: None, inside_v: None, ..b }
|
||||
};
|
||||
out.overlay(&mapped);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// What one table style part gives its cells.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
@@ -932,6 +973,31 @@ fn table_styles() -> Vec<Style> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::resolve::ResolvedPara;
|
||||
|
||||
#[test]
|
||||
fn adjust_right_indent_inherits_and_can_be_overridden() {
|
||||
let resolved = StyleSheet::empty().resolve_para(&ParaProps::default());
|
||||
assert!(resolved.adjust_right_indent);
|
||||
|
||||
let mut older_json = serde_json::to_value(&resolved).unwrap();
|
||||
older_json.as_object_mut().unwrap().remove("adjustRightIndent");
|
||||
assert!(serde_json::from_value::<ResolvedPara>(older_json).unwrap().adjust_right_indent);
|
||||
|
||||
let mut s = StyleSheet::empty();
|
||||
s.styles.push(Style {
|
||||
id: "Normal".into(),
|
||||
name: "Normal".into(),
|
||||
para: ParaProps { adjust_right_indent: Some(true), ..Default::default() },
|
||||
..Default::default()
|
||||
});
|
||||
assert!(s.resolve_para(&ParaProps::default()).adjust_right_indent);
|
||||
assert!(!s.resolve_para(&ParaProps { adjust_right_indent: Some(false), ..Default::default() }).adjust_right_indent);
|
||||
|
||||
let json = serde_json::to_value(ParaProps { adjust_right_indent: Some(false), ..Default::default() }).unwrap();
|
||||
assert_eq!(json["adjustRightIndent"], false);
|
||||
assert_eq!(serde_json::from_value::<ParaProps>(json).unwrap().adjust_right_indent, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtins_present() {
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
//!
|
||||
//! Tables are indexed by code − 0x20; 0 = no Unicode equivalent.
|
||||
|
||||
/// The symbol fonts this module knows, by lowercase family name.
|
||||
fn table(family: &str) -> Option<&'static [u32; 224]> {
|
||||
/// The symbol table for a single family name, by lowercase family name.
|
||||
fn single_table(family: &str) -> Option<&'static [u32; 224]> {
|
||||
let f = family.trim();
|
||||
if f.eq_ignore_ascii_case("symbol") || f.eq_ignore_ascii_case("symbol (pcl6)") {
|
||||
Some(&SYMBOL)
|
||||
@@ -37,6 +37,11 @@ fn table(family: &str) -> Option<&'static [u32; 224]> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The symbol fonts this module knows, by lowercase family name.
|
||||
fn table(family: &str) -> Option<&'static [u32; 224]> {
|
||||
if family.contains(';') { family.split(';').map(str::trim).filter(|s| !s.is_empty()).find_map(single_table) } else { single_table(family) }
|
||||
}
|
||||
|
||||
/// Whether `family` is one of Word's symbol fonts (Symbol, Wingdings, Wingdings 2, Wingdings 3,
|
||||
/// Webdings), whose characters [`symbol_char`] maps.
|
||||
pub fn is_symbol_font(family: &str) -> bool {
|
||||
|
||||
@@ -71,10 +71,12 @@ pub struct Table {
|
||||
/// Hard limits that keep hostile input from exhausting memory.
|
||||
pub const MAX_ROWS: usize = 32_767;
|
||||
pub const MAX_COLS: usize = 63;
|
||||
/// Most tables nested one inside another: the depth the DOCX reader and writer keep. Pasting
|
||||
/// and inserting stop there too, so no edit builds a document deep enough to overflow the stack
|
||||
/// when it is saved, copied or dropped (#1596).
|
||||
pub const MAX_TABLE_DEPTH: usize = 24;
|
||||
/// Most tables nested one inside another. Pasting and inserting stop there, so no edit builds a
|
||||
/// document deep enough to overflow the stack when it is saved, copied or dropped (#1596), or
|
||||
/// deeper than a `.json` save reads back: serde_json reads 127 nested containers, a table level
|
||||
/// takes six, and the deepest cell of a header, note or text box needs up to 15 more for its
|
||||
/// paragraph and the richest content measured (#3618). The DOCX reader and writer keep 24.
|
||||
pub const MAX_TABLE_DEPTH: usize = 18;
|
||||
|
||||
impl Table {
|
||||
/// Grid columns `lo..hi` that [`Table::slice`] copies for `rows`/`cols` (widened over merged cells),
|
||||
|
||||
+59
-11
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
mod note_conversion;
|
||||
|
||||
@@ -18,6 +19,25 @@ fn word_count() {
|
||||
assert_eq!(count_words(" -- "), 0);
|
||||
}
|
||||
|
||||
/// #2797: the status bar's character counts, worded like the Word Count dialog's: characters
|
||||
/// with spaces leave the paragraph breaks out, characters without spaces leave every
|
||||
/// whitespace character out.
|
||||
#[test]
|
||||
fn character_counts() {
|
||||
assert_eq!(count_chars(""), 0);
|
||||
assert_eq!(count_chars("Hello"), 5);
|
||||
// Paragraph breaks join the paragraphs but aren't characters.
|
||||
assert_eq!(count_chars("Hello\nworld\r\n"), 10);
|
||||
assert_eq!(count_chars("a\tb"), 3, "a tab is a character");
|
||||
// Every kind of whitespace goes, spaces, tabs and (non-ASCII) spaces between words.
|
||||
assert_eq!(count_chars_no_spaces("Hello world"), 10);
|
||||
assert_eq!(count_chars_no_spaces("a\tb c\u{a0}d"), 4, "including a non-breaking space");
|
||||
assert_eq!(count_chars_no_spaces(" \n\r\t "), 0);
|
||||
// Accented letters and emoji count as one character each, as they are.
|
||||
assert_eq!(count_chars("café 🚀"), 6);
|
||||
assert_eq!(count_chars_no_spaces("café 🚀"), 5);
|
||||
}
|
||||
|
||||
/// #4547: each Han ideograph and kana counts as a word; punctuation and Hangul spacing don't change.
|
||||
#[test]
|
||||
fn word_count_counts_each_chinese_and_japanese_character() {
|
||||
@@ -35,8 +55,8 @@ fn word_count_counts_each_chinese_and_japanese_character() {
|
||||
#[test]
|
||||
fn word_count_includes_numbered_list_labels_but_not_bullets() {
|
||||
let mut d = Document::new();
|
||||
let num_id = d.numbering.add_list(ListKind::Numbered);
|
||||
let bul_id = d.numbering.add_list(ListKind::Bullet);
|
||||
let num_id = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered);
|
||||
let bul_id = Arc::make_mut(&mut d.numbering).add_list(ListKind::Bullet);
|
||||
|
||||
let mut p1 = Paragraph::with_text("Apple", CharProps::default());
|
||||
p1.props.numbering = Some(props::NumRef { num: num_id, level: 0 });
|
||||
@@ -60,9 +80,9 @@ fn word_count_includes_numbered_list_labels_but_not_bullets() {
|
||||
#[test]
|
||||
fn word_count_includes_numbered_labels_with_nothing_suffix() {
|
||||
let mut d = Document::new();
|
||||
let num_id = d.numbering.add_list(ListKind::Numbered);
|
||||
let num_id = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered);
|
||||
let aid = d.numbering.num(num_id).expect("list num exists").abstract_id;
|
||||
let a = d.numbering.abstracts.iter_mut().find(|a| a.id == aid).expect("abstract num exists");
|
||||
let a = Arc::make_mut(&mut d.numbering).abstracts.iter_mut().find(|a| a.id == aid).expect("abstract num exists");
|
||||
a.levels[0].suffix = numbering::LevelSuffix::Nothing;
|
||||
|
||||
let mut p1 = Paragraph::with_text("Apple", CharProps::default());
|
||||
@@ -81,9 +101,9 @@ fn word_count_includes_numbered_labels_with_nothing_suffix() {
|
||||
#[test]
|
||||
fn word_count_keeps_a_cjk_list_label_one_word() {
|
||||
let mut d = Document::new();
|
||||
let num_id = d.numbering.add_list(ListKind::Numbered);
|
||||
let num_id = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered);
|
||||
let aid = d.numbering.num(num_id).expect("list num exists").abstract_id;
|
||||
let a = d.numbering.abstracts.iter_mut().find(|a| a.id == aid).expect("abstract num exists");
|
||||
let a = Arc::make_mut(&mut d.numbering).abstracts.iter_mut().find(|a| a.id == aid).expect("abstract num exists");
|
||||
a.levels[0].format = section::NumFormat::ChineseCounting;
|
||||
a.levels[0].text = "第%1章".into();
|
||||
a.levels[0].start = 12;
|
||||
@@ -187,7 +207,7 @@ fn parts_and_lists_after_the_largest_ids_take_free_ones() {
|
||||
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;
|
||||
let n = Arc::make_mut(&mut d.numbering);
|
||||
n.add_list(ListKind::Bullet);
|
||||
n.add_list(ListKind::Bullet);
|
||||
n.abstracts[1].id = u32::MAX;
|
||||
@@ -229,11 +249,11 @@ fn same_content_sees_every_change() {
|
||||
assert!(!d.same_content(&e), "the formatting changed");
|
||||
// The styles changed.
|
||||
let mut e = d.clone();
|
||||
e.styles.get_mut("Normal").unwrap().chr.bold = Some(true);
|
||||
Arc::make_mut(&mut e.styles).get_mut("Normal").unwrap().chr.bold = Some(true);
|
||||
assert!(!d.same_content(&e), "a style changed");
|
||||
// The lists changed.
|
||||
let mut e = d.clone();
|
||||
e.numbering.add_list(ListKind::Numbered);
|
||||
Arc::make_mut(&mut e.numbering).add_list(ListKind::Numbered);
|
||||
assert!(!d.same_content(&e), "a list was added");
|
||||
// The settings changed.
|
||||
let mut e = d.clone();
|
||||
@@ -452,9 +472,9 @@ fn box_para(story: u32, id: u32) -> Blocks {
|
||||
fn adopting_relinks_lists_in_deeply_nested_tables() {
|
||||
// As #1339: a list paragraph 20 tables down takes the list it is brought in with.
|
||||
let mut ours = Document::from_text("ours");
|
||||
let our_list = ours.numbering.add_list(numbering::ListKind::Bullet);
|
||||
let our_list = Arc::make_mut(&mut ours.numbering).add_list(numbering::ListKind::Bullet);
|
||||
let mut theirs = Document::from_text("theirs");
|
||||
let list = theirs.numbering.add_list(numbering::ListKind::Bullet);
|
||||
let list = Arc::make_mut(&mut theirs.numbering).add_list(numbering::ListKind::Bullet);
|
||||
assert_eq!(list, our_list);
|
||||
let mut p = Paragraph::with_text("item", CharProps::default());
|
||||
p.props.numbering = Some(props::NumRef { num: list, level: 0 });
|
||||
@@ -766,3 +786,31 @@ fn lone_table_open_markers_mixed_tables_and_paired() {
|
||||
assert!(res1.controls.open.is_empty());
|
||||
assert_eq!(res1.controls.close, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_picture_names_are_capped_at_a_char_boundary() {
|
||||
let mut d = Document::new();
|
||||
let long: String = "é".repeat(5000);
|
||||
d.missing_pictures.push(MissingPicture { part: long.clone(), story: long, reason: MissingPictureReason::MissingPart });
|
||||
let item = &d.missing_pictures.list[0];
|
||||
assert_eq!((item.part.chars().count(), item.story.chars().count()), (1024, 1024));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_pictures_are_capped_deduplicated_and_not_content() {
|
||||
let mut d = Document::new();
|
||||
let item = |n: usize| MissingPicture { part: format!("p{n}"), story: "word/document.xml".into(), reason: MissingPictureReason::MissingPart };
|
||||
d.missing_pictures.push(item(0));
|
||||
d.missing_pictures.push(item(0));
|
||||
assert_eq!(d.missing_pictures.list.len(), 1);
|
||||
for n in 1..MAX_MISSING_PICTURES + 3 {
|
||||
d.missing_pictures.push(item(n));
|
||||
}
|
||||
assert_eq!(d.missing_pictures.list.len(), MAX_MISSING_PICTURES);
|
||||
assert!(d.missing_pictures.truncated);
|
||||
let e = Document::new();
|
||||
assert_eq!(d, e);
|
||||
assert!(d.same_content(&e));
|
||||
assert!(!serde_json::to_string(&d).unwrap().contains("p0"));
|
||||
assert_eq!(MissingPictureReason::ExternalTarget.label(), "external target");
|
||||
}
|
||||
|
||||
@@ -102,18 +102,6 @@ fn dense(n: usize) -> Document {
|
||||
d
|
||||
}
|
||||
|
||||
// Thread CPU excludes scheduler delays. Missing or nonadvancing counters provide no timing
|
||||
// evidence; the semantic assertions still run on those platforms.
|
||||
fn cpu_cost(f: impl FnOnce()) -> Option<f64> {
|
||||
let ran = || std::fs::read_to_string("/proc/thread-self/schedstat").ok()?.split_whitespace().next()?.parse::<u64>().ok();
|
||||
let before = ran();
|
||||
f();
|
||||
match (before, ran()) {
|
||||
(Some(a), Some(b)) if b > a => Some((b - a) as f64 * 1e-9),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversion_work_scales_with_reference_and_formatting_runs() {
|
||||
let (small, big) = (dense(2_000), dense(16_000));
|
||||
@@ -128,18 +116,13 @@ fn conversion_work_scales_with_reference_and_formatting_runs() {
|
||||
};
|
||||
convert(&small);
|
||||
convert(&big);
|
||||
let (mut t1, mut t8) = (f64::INFINITY, f64::INFINITY);
|
||||
for _ in 0..3 {
|
||||
let a = cpu_cost(|| (0..8).for_each(|_| convert(&small)));
|
||||
let b = cpu_cost(|| convert(&big));
|
||||
if let (Some(a), Some(b)) = (a, b) {
|
||||
t1 = t1.min(a / 8.0);
|
||||
t8 = t8.min(b);
|
||||
}
|
||||
}
|
||||
if t1.is_finite() && t8.is_finite() {
|
||||
let ratio = t8 / t1;
|
||||
eprintln!("note conversion CPU: small={t1:.6}s big={t8:.6}s ratio={ratio:.2}");
|
||||
assert!(ratio < 24.0, "8x the references and runs cost {ratio:.2}x CPU");
|
||||
}
|
||||
// The characters and runs the conversion walks (`crate::work`), not a timing: about 8x for 8x
|
||||
// the references and runs when they are walked together, about 64x when each run rescans the
|
||||
// references.
|
||||
let s1 = crate::work::count(|| convert(&small));
|
||||
let s8 = crate::work::count(|| convert(&big));
|
||||
let ratio = s8 as f64 / s1.max(1) as f64;
|
||||
eprintln!("note conversion steps: {s1} -> {s8}, {ratio:.1}x for 8x references and runs");
|
||||
assert!(s1 > 0, "no steps counted, so the walk this test watches no longer counts them (crate::work)");
|
||||
assert!(ratio <= 12.0, "8x the references and runs cost {ratio:.1}x the steps ({s1} -> {s8})");
|
||||
}
|
||||
|
||||
@@ -35,6 +35,18 @@ fn is_cjk_word(c: char) -> bool {
|
||||
) && c.is_alphanumeric()
|
||||
}
|
||||
|
||||
/// Characters in `s`, as the Word Count dialog counts them ("Characters (with spaces)"):
|
||||
/// paragraph breaks aren't characters (#2797).
|
||||
pub fn count_chars(s: &str) -> usize {
|
||||
s.chars().filter(|c| *c != '\n' && *c != '\r').count()
|
||||
}
|
||||
|
||||
/// Characters in `s` ignoring every whitespace character, as the Word Count dialog counts them
|
||||
/// ("Characters (no spaces)"; #2797).
|
||||
pub fn count_chars_no_spaces(s: &str) -> usize {
|
||||
s.chars().filter(|c| !c.is_whitespace()).count()
|
||||
}
|
||||
|
||||
/// How deep in nested tables [`Document::para_paths`] goes: cells below this are skipped.
|
||||
pub(crate) const PARA_WALK_DEPTH: usize = 16;
|
||||
|
||||
@@ -51,6 +63,7 @@ pub(crate) fn walk(bl: &Blocks, prefix: &mut Vec<u32>, out: &mut Vec<Path>, dept
|
||||
return;
|
||||
}
|
||||
for (i, b) in bl.iter().enumerate() {
|
||||
crate::work::steps(1);
|
||||
prefix.push(i as u32);
|
||||
match &**b {
|
||||
Block::Para(_) => out.push(Path(prefix.clone())),
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
//! A count of the characters a paragraph's object lookups walk, the blocks
|
||||
//! [`crate::Document::para_paths`] visits, and each block lookup by path with the table levels it
|
||||
//! descends, for the linear-time tests of the crates that use them: an input 8x the size must cost
|
||||
//! about 8x the steps, which a busy machine can't skew the way it skews a timing. Counted only in
|
||||
//! tests (this crate's, and other crates' through the `test-support` feature, which they turn on
|
||||
//! as a dev-dependency); in the app the count is nothing.
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
thread_local! {
|
||||
/// The steps counted on this thread.
|
||||
static STEPS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Count `n` steps of a walk (nothing outside tests). Public for the walks other crates do over a
|
||||
/// paragraph's text themselves (Markdown and HTML comment export, for one).
|
||||
#[inline(always)]
|
||||
pub fn steps(n: usize) {
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
STEPS.with(|s| s.set(s.get().saturating_add(n as u64)));
|
||||
#[cfg(not(any(test, feature = "test-support")))]
|
||||
let _ = n;
|
||||
}
|
||||
|
||||
/// The steps `f` takes.
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn count(f: impl FnOnce()) -> u64 {
|
||||
let before = STEPS.with(std::cell::Cell::get);
|
||||
f();
|
||||
STEPS.with(std::cell::Cell::get).saturating_sub(before)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::props::{Align, CharProps, ParaProps};
|
||||
use wordcraft_doc::{Block, Document, Paragraph, Path, Pos, StoryRef, Table, para_block};
|
||||
|
||||
/// Build an empty heading followed by explicitly formatted, mixed-run body text.
|
||||
fn fixture(first: &str, style: Option<&str>) -> Document {
|
||||
let mut d = Document::from_text(&format!("{first}\nBody paragraph."));
|
||||
let p = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
p.props.style = Some("Heading1".into());
|
||||
p.props.outline_level = Some(0);
|
||||
p.mark = CharProps { size: Some(40.0), bold: Some(true), ..Default::default() };
|
||||
let p = d.para_mut(StoryRef::Body, &Path::top(1)).unwrap();
|
||||
p.props = ParaProps {
|
||||
style: style.map(str::to_string),
|
||||
align: Some(Align::Right),
|
||||
indent_left: Some(18.0),
|
||||
space_after: Some(7.0),
|
||||
keep_next: Some(false),
|
||||
..Default::default()
|
||||
};
|
||||
p.mark = CharProps { size: Some(11.0), bold: Some(false), lang: Some("en-GB".into()), ..Default::default() };
|
||||
p.format(0, 4, &|c| c.italic = Some(true)).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// Compare paragraph, mark and inline-run formatting independently of text.
|
||||
fn assert_format(p: &Paragraph, expected: &Paragraph) {
|
||||
assert_eq!(p.props, expected.props, "the following paragraph keeps all its properties");
|
||||
assert_eq!(p.mark, expected.mark, "the empty heading's mark must not replace the following mark");
|
||||
assert_eq!(serde_json::to_value(&p.runs).unwrap(), serde_json::to_value(&expected.runs).unwrap());
|
||||
}
|
||||
|
||||
/// Deleting an empty paragraph preserves the following style, including implicit Normal.
|
||||
#[test]
|
||||
fn deleting_an_empty_paragraph_keeps_the_following_format() {
|
||||
for first_style in ["Heading1", "Heading3", "Normal"] {
|
||||
for style in [None, Some("Normal"), Some("Heading2")] {
|
||||
let mut d = fixture("", style);
|
||||
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().props.style = Some(first_style.into());
|
||||
let expected = d.para_at(&Pos::body(1, 0)).unwrap().clone();
|
||||
assert_eq!(d.delete_range(&Pos::body(0, 0), &Pos::body(1, 0)).unwrap(), Pos::body(0, 0));
|
||||
let p = d.para_at(&Pos::body(0, 0)).unwrap();
|
||||
assert_eq!(p.text, expected.text);
|
||||
assert_format(p, &expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A selection spanning several paragraphs keeps the surviving tail format in both directions.
|
||||
#[test]
|
||||
fn an_empty_start_preserves_the_last_paragraph_over_a_multi_paragraph_selection() {
|
||||
for backwards in [false, true] {
|
||||
let mut d = fixture("", None);
|
||||
d.body.insert(1, para_block(Paragraph::with_text("Removed middle paragraph", Default::default()).styled("Heading2")));
|
||||
let mut expected = d.para_at(&Pos::body(2, 0)).unwrap().clone();
|
||||
expected.delete(0, 5).unwrap();
|
||||
let (a, b) = (Pos::body(0, 0), Pos::body(2, 5));
|
||||
d.delete_range(if backwards { &b } else { &a }, if backwards { &a } else { &b }).unwrap();
|
||||
assert_eq!(d.body.len(), 1);
|
||||
let p = d.para_at(&Pos::body(0, 0)).unwrap();
|
||||
assert_eq!(p.text, "paragraph.");
|
||||
assert_format(p, &expected);
|
||||
}
|
||||
}
|
||||
|
||||
/// A control whose closing marker survives must move across the join without replacing tail formatting.
|
||||
#[test]
|
||||
fn an_empty_start_keeps_a_control_from_a_deleted_middle_paragraph() {
|
||||
use wordcraft_doc::control::ContentControl;
|
||||
use wordcraft_doc::{InlineObject, Revision, RevisionKind};
|
||||
|
||||
let mut d = fixture("", Some("Normal"));
|
||||
d.body.insert(1, para_block(Paragraph::with_text("Removed", Default::default())));
|
||||
d.body.push(para_block(Paragraph::with_text("After", Default::default())));
|
||||
d.revisions.push(Revision { kind: RevisionKind::Insert, ..Default::default() });
|
||||
d.revisions.push(Revision { kind: RevisionKind::Delete, ..Default::default() });
|
||||
let p = d.para_mut(StoryRef::Body, &Path::top(2)).unwrap();
|
||||
p.mark.ins = Some(0);
|
||||
p.mark.del = Some(1);
|
||||
let expected = p.clone();
|
||||
let marker = CharProps { bold: Some(true), ins: Some(0), ..Default::default() };
|
||||
d.insert_object(&Pos::body(1, 0), InlineObject::ControlStart { control: Box::new(ContentControl::default()) }, &marker).unwrap();
|
||||
d.insert_object(&Pos::body(3, 5), InlineObject::ControlEnd, &Default::default()).unwrap();
|
||||
assert_eq!(d.control_ranges(StoryRef::Body).len(), 1);
|
||||
|
||||
d.delete_range(&Pos::body(0, 0), &Pos::body(2, 0)).unwrap();
|
||||
let p = d.para_at(&Pos::body(0, 0)).unwrap();
|
||||
assert_eq!(p.props, expected.props);
|
||||
assert_eq!(p.mark, expected.mark, "tail revision IDs and formatting survive the moved marker");
|
||||
assert_eq!(p.props_of_char(0), &marker);
|
||||
assert!(matches!(p.objects.as_slice(), [InlineObject::ControlStart { .. }]));
|
||||
assert_eq!(p.text, format!("\u{fffc}{}", expected.text));
|
||||
let ranges = d.control_ranges(StoryRef::Body);
|
||||
assert_eq!(ranges.len(), 1);
|
||||
assert_eq!(ranges[0].start, Pos::body(0, 0));
|
||||
assert_eq!(ranges[0].end, Pos::body(1, 5));
|
||||
assert!(!d.has_unbalanced_controls());
|
||||
}
|
||||
|
||||
/// The empty-paragraph rule applies in header stories and table-cell containers.
|
||||
#[test]
|
||||
fn empty_paragraph_merges_work_in_a_header_and_a_table_cell() {
|
||||
for cell in [false, true] {
|
||||
let mut d = fixture("", Some("Normal"));
|
||||
let expected = d.para_at(&Pos::body(1, 0)).unwrap().clone();
|
||||
let (story, a, b) = if cell {
|
||||
let mut table = Table::new(1, 1, 300.0);
|
||||
table.rows[0].cells[0].blocks = d.body.clone();
|
||||
d.body = vec![Arc::new(Block::Table(table)), para_block(Paragraph::new())];
|
||||
(StoryRef::Body, Path(vec![0, 0, 0, 0]), Path(vec![0, 0, 0, 1]))
|
||||
} else {
|
||||
let id = d.add_part(wordcraft_doc::PartKind::Header, d.body.clone());
|
||||
(StoryRef::Part(id), Path::top(0), Path::top(1))
|
||||
};
|
||||
d.delete_range(&Pos { story, path: a.clone(), off: 0 }, &Pos { story, path: b, off: 0 }).unwrap();
|
||||
assert_format(d.para(story, &a).unwrap(), &expected);
|
||||
}
|
||||
}
|
||||
|
||||
/// Absorbing an empty section keeps the following format and inherited header.
|
||||
#[test]
|
||||
fn deleting_an_empty_section_paragraph_keeps_format_and_linked_headers() {
|
||||
let mut d = fixture("", None);
|
||||
let expected = d.para_at(&Pos::body(1, 0)).unwrap().clone();
|
||||
let header = d.add_part(wordcraft_doc::PartKind::Header, vec![para_block(Paragraph::with_text("Header", Default::default()))]);
|
||||
let mut section = d.last_section.clone();
|
||||
section.headers.default = Some(header);
|
||||
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().section = Some(Box::new(section));
|
||||
d.last_section.margin_left = 96.0;
|
||||
d.delete_range(&Pos::body(0, 0), &Pos::body(1, 0)).unwrap();
|
||||
assert_format(d.para_at(&Pos::body(0, 0)).unwrap(), &expected);
|
||||
assert!(d.para_at(&Pos::body(0, 0)).unwrap().section.is_none());
|
||||
assert_eq!(d.last_section.headers.default, Some(header));
|
||||
assert_eq!(d.last_section.margin_left, 96.0);
|
||||
}
|
||||
|
||||
/// Deleting actual text continues to use the first paragraph merge rule.
|
||||
#[test]
|
||||
fn joining_nonempty_paragraphs_keeps_the_existing_first_paragraph_rule() {
|
||||
for off in [0, 7] {
|
||||
let mut d = fixture("Heading", None);
|
||||
let expected = d.para_at(&Pos::body(0, 0)).unwrap().props.clone();
|
||||
d.delete_range(&Pos::body(0, off), &Pos::body(1, 0)).unwrap();
|
||||
assert_eq!(d.para_at(&Pos::body(0, 0)).unwrap().props, expected);
|
||||
}
|
||||
}
|
||||
|
||||
/// Removing a soft line break must not change paragraph formatting.
|
||||
#[test]
|
||||
fn a_soft_break_is_not_an_empty_paragraph_merge() {
|
||||
let mut d = fixture("", None);
|
||||
let p = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
p.insert_text(0, "\nHeading", &Default::default()).unwrap();
|
||||
let expected = p.props.clone();
|
||||
d.delete_range(&Pos::body(0, 0), &Pos::body(0, 1)).unwrap();
|
||||
assert_eq!(d.body.len(), 2);
|
||||
assert_eq!(d.para_at(&Pos::body(0, 0)).unwrap().props, expected);
|
||||
}
|
||||
@@ -9,17 +9,12 @@ description = "WordCraft reader for the Word 97-2003 binary file format (.doc, [
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
craft-doc-binary = { workspace = true }
|
||||
craft-wordmodel = { workspace = true }
|
||||
wordcraft-doc = { workspace = true }
|
||||
wordcraft-geom = { workspace = true }
|
||||
cfb = { workspace = true }
|
||||
image = { workspace = true }
|
||||
miniz_oxide = { workspace = true }
|
||||
sha1 = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
wordcraft-doc = { workspace = true, features = ["test-support"] }
|
||||
proptest = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
cfb = { workspace = true }
|
||||
|
||||
@@ -1,455 +0,0 @@
|
||||
//! 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
|
||||
}
|
||||
|
||||
/// SHA-1, as [MS-OFFCRYPTO] §2.3.5 specifies for the RC4 CryptoAPI key derivation.
|
||||
/// The digest is part of the file format, not a choice made here: a stronger hash would
|
||||
/// make password-protected `.doc` files written by Word unreadable.
|
||||
fn sha1(parts: &[&[u8]]) -> Vec<u8> {
|
||||
let mut h = Sha1::new(); // NOSONAR: required by the MS-OFFCRYPTO RC4 CryptoAPI format
|
||||
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"));
|
||||
}
|
||||
// [MS-OFFCRYPTO] defines AES only for the encrypted packages of Word 2007 and later
|
||||
// (which open through the .docx reader whatever their extension); a Word 97-2003 file
|
||||
// may only use XOR, RC4 or RC4 CryptoAPI, so there is no public scheme to follow here.
|
||||
if hflags & H_AES != 0 {
|
||||
return Err(unsupported(
|
||||
"this Word 97-2003 file claims AES encryption, which is only defined for the newer encrypted-package format; it can't be opened",
|
||||
));
|
||||
}
|
||||
if hflags & H_CRYPTO_API == 0 {
|
||||
return Err(unsupported("unknown encryption header"));
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
//! Floating shapes and pictures: the `PlcfSpa` anchors of the main document and the headers
|
||||
//! ([MS-DOC] §2.8.27 PlcfSpa, §2.9.253 Spa) joined with the OfficeArt shapes they name, and the
|
||||
//! text box stories (`PlcftxbxTxt`, §2.8.30) that fill text boxes.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use wordcraft_doc::fill::Fill;
|
||||
use wordcraft_doc::para::{Anchor, Float, FloatAlign, GroupChild, InlineObject, ShapeKind, Wrap};
|
||||
use wordcraft_doc::props::Rgb;
|
||||
|
||||
use crate::media::Pictures;
|
||||
use crate::officeart::{Node, Sp};
|
||||
|
||||
/// Most anchors (and text box stories) read per PLC.
|
||||
const MAX_ANCHORS: usize = 10_000;
|
||||
/// `Spa` and `FTXBXS` record sizes.
|
||||
const CB_SPA: usize = 26;
|
||||
const CB_FTXBXS: usize = 22;
|
||||
/// Largest frame or offset kept (points; 22 inches, Word's own limit for sizes).
|
||||
const MAX_PT: f32 = 1584.0;
|
||||
/// Deepest group nesting flattened.
|
||||
const MAX_GROUP_DEPTH: usize = 8;
|
||||
|
||||
/// The (CP, data) entries of a PLC with `cb`-byte data elements, the trailing CP dropped.
|
||||
fn plc(table: &[u8], (fc, lcb): (u32, u32), cb: usize) -> Vec<(u32, &[u8])> {
|
||||
let Some(blob) = table.get(fc as usize..(fc as usize).saturating_add(lcb as usize)) else { return Vec::new() };
|
||||
let n = blob.len().saturating_sub(4) / (4 + cb);
|
||||
let n = n.min(MAX_ANCHORS);
|
||||
let data_at = (n + 1) * 4;
|
||||
(0..n)
|
||||
.filter_map(|i| {
|
||||
let cp = u32::from_le_bytes(blob.get(i * 4..i * 4 + 4)?.try_into().ok()?);
|
||||
let at = data_at + i * cb;
|
||||
Some((cp, blob.get(at..at + cb)?))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 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 && *cp >= kept_to && lid != 0 {
|
||||
out.push((lid, (*cp, end)));
|
||||
kept_to = end;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// What building the floating objects needs.
|
||||
pub(crate) struct Build<'a> {
|
||||
/// The BLIP store and the stream holding BLIPs not embedded in it (WordDocument).
|
||||
pub(crate) bstore: &'a [&'a [u8]],
|
||||
pub(crate) delay: &'a [u8],
|
||||
/// Text box part ids by shape spid.
|
||||
pub(crate) stories: &'a HashMap<u32, u32>,
|
||||
}
|
||||
|
||||
/// The floating objects anchored by `PlcfSpa` (at `pair`), as (absolute anchor CP, object),
|
||||
/// sorted by CP. `cp_base` turns the PLC's CPs into absolute ones (the header subdocument's
|
||||
/// start for `PlcSpaHdr`).
|
||||
pub(crate) fn anchors(
|
||||
table: &[u8],
|
||||
pair: Option<(u32, u32)>,
|
||||
cp_base: u32,
|
||||
shapes: &HashMap<u32, Node>,
|
||||
b: &Build,
|
||||
pics: &mut Pictures,
|
||||
) -> Vec<(u32, InlineObject)> {
|
||||
let Some(pair) = pair else { return Vec::new() };
|
||||
let mut out = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for (cp, spa) in plc(table, pair, CB_SPA) {
|
||||
let i32_at = |o: usize| spa.get(o..o + 4).and_then(|b| b.try_into().ok()).map(i32::from_le_bytes).unwrap_or(0);
|
||||
let spid = i32_at(0) as u32;
|
||||
let [xl, yt, xr, yb] = [4, 8, 12, 16].map(i32_at);
|
||||
let flags = spa.get(20..22).map(|b| u16::from_le_bytes([b[0], b[1]])).unwrap_or(0);
|
||||
let Some(node) = shapes.get(&spid) else { continue };
|
||||
// Word anchors each shape once. A repeated spid would build another copy of the shape,
|
||||
// and of every member of a group, per anchor (#3710).
|
||||
if !seen.insert(spid) {
|
||||
continue;
|
||||
}
|
||||
let tw = |v: i64| (v as f32 / 20.0).clamp(-2.0 * MAX_PT, 2.0 * MAX_PT);
|
||||
let (mut x, mut y) = (tw(xl as i64), tw(yt as i64));
|
||||
let (mut w, mut h) = (tw(xr as i64 - xl as i64).clamp(0.0, MAX_PT), tw(yb as i64 - yt as i64).clamp(0.0, MAX_PT));
|
||||
let sp = node.sp();
|
||||
let rot = wordcraft_geom::normalize_degrees(if sp.props.rot.is_finite() { sp.props.rot } else { 0.0 });
|
||||
// A shape turned by 45°–135° (or 225°–315°) is anchored by its turned bounds: the
|
||||
// unrotated frame has the other aspect, about the same centre.
|
||||
if (45.0..135.0).contains(&rot) || (225.0..315.0).contains(&rot) {
|
||||
x += (w - h) / 2.0;
|
||||
y += (h - w) / 2.0;
|
||||
std::mem::swap(&mut w, &mut h);
|
||||
}
|
||||
let mut float = spa_float(flags, &sp.props);
|
||||
float.x = x;
|
||||
float.y = y;
|
||||
float.rot = rot;
|
||||
float.flip_h = sp.flip_h();
|
||||
float.flip_v = sp.flip_v();
|
||||
let obj = match node {
|
||||
Node::Shape(sp) => leaf(sp, w, h, float, b, pics),
|
||||
Node::Group(sp, members) => group(sp, members, w, h, float, b, pics),
|
||||
};
|
||||
match obj {
|
||||
Some(obj) => out.push((cp_base.saturating_add(cp), obj)),
|
||||
None => pics.dropped(cp_base.saturating_add(cp)),
|
||||
}
|
||||
}
|
||||
out.sort_by_key(|(cp, _)| *cp);
|
||||
out
|
||||
}
|
||||
|
||||
/// Placement from a `Spa`'s flags (fHdr, bx, by, wr, wrk, fRcaSimple, fBelowText, fAnchorLock)
|
||||
/// and the shape's position properties, which take precedence when present.
|
||||
fn spa_float(flags: u16, p: &crate::officeart::Props) -> Float {
|
||||
let bx = (flags >> 1) & 3;
|
||||
let by = (flags >> 3) & 3;
|
||||
let wr = (flags >> 5) & 0xF;
|
||||
let below = flags & (1 << 14) != 0;
|
||||
let wrap = match wr {
|
||||
1 => Wrap::TopAndBottom,
|
||||
3 => {
|
||||
if below {
|
||||
Wrap::BehindText
|
||||
} else {
|
||||
Wrap::InFrontOfText
|
||||
}
|
||||
}
|
||||
4 => Wrap::Tight,
|
||||
5 => Wrap::Through,
|
||||
_ => Wrap::Square,
|
||||
};
|
||||
let h_rel = match p.posrelh.unwrap_or(bx as u32) {
|
||||
0 => Anchor::Margin,
|
||||
1 => Anchor::Page,
|
||||
3 => Anchor::Character,
|
||||
_ => Anchor::Column,
|
||||
};
|
||||
let v_rel = match p.posrelv.unwrap_or(by as u32) {
|
||||
0 => Anchor::Margin,
|
||||
1 => Anchor::Page,
|
||||
3 => Anchor::Line,
|
||||
_ => Anchor::Paragraph,
|
||||
};
|
||||
let align = |v: Option<u32>| match v {
|
||||
Some(1) => Some(FloatAlign::Start),
|
||||
Some(2) => Some(FloatAlign::Center),
|
||||
Some(3) => Some(FloatAlign::End),
|
||||
Some(4) => Some(FloatAlign::Inside),
|
||||
Some(5) => Some(FloatAlign::Outside),
|
||||
_ => None,
|
||||
};
|
||||
// Distances from text, EMUs; Word's defaults are 0.125" at the sides, none above and below.
|
||||
let emu = |v: Option<u32>, def: f32| v.map_or(def, |e| (e as f32 / 12_700.0).clamp(0.0, MAX_PT));
|
||||
let [dl, dt, dr, db] = p.wrap_dist;
|
||||
Float {
|
||||
wrap,
|
||||
h_rel,
|
||||
v_rel,
|
||||
h_align: align(p.posh),
|
||||
v_align: align(p.posv),
|
||||
dist: emu(dl, 9.0).max(emu(dr, 9.0)),
|
||||
dist_top: emu(dt, 0.0),
|
||||
dist_bottom: emu(db, 0.0),
|
||||
..Float::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// One shape as a picture or a drawn shape, `w` × `h` points.
|
||||
fn leaf(sp: &Sp, w: f32, h: f32, float: Float, b: &Build, pics: &mut Pictures) -> Option<InlineObject> {
|
||||
let p = &sp.props;
|
||||
if let Some(pib) = p.pib {
|
||||
let media = pics.bse(pib, b.bstore, b.delay)?;
|
||||
return Some(InlineObject::Image { media, w, h, alt: p.alt.clone(), float, crop: p.crop, ole: None });
|
||||
}
|
||||
let story = b.stories.get(&sp.spid).copied().filter(|_| sp.textbox || sp.spt == 202);
|
||||
let mut kind = match sp.spt {
|
||||
1 => ShapeKind::Rectangle,
|
||||
2 => ShapeKind::RoundedRectangle,
|
||||
3 => ShapeKind::Ellipse,
|
||||
4 => ShapeKind::Diamond,
|
||||
5 => ShapeKind::Triangle,
|
||||
6 => ShapeKind::RightTriangle,
|
||||
12 | 187 => ShapeKind::Star,
|
||||
13 | 69 => ShapeKind::Arrow,
|
||||
66 => ShapeKind::ArrowLeft,
|
||||
67 => ShapeKind::ArrowDown,
|
||||
68 => ShapeKind::ArrowUp,
|
||||
20 | 32 => ShapeKind::Line,
|
||||
74 => ShapeKind::Heart,
|
||||
202 => ShapeKind::TextBox,
|
||||
// A freeform's path is not read: better left out than drawn as a box.
|
||||
0 => return None,
|
||||
_ => ShapeKind::Rectangle,
|
||||
};
|
||||
if story.is_some() && kind == ShapeKind::Rectangle {
|
||||
kind = ShapeKind::TextBox;
|
||||
}
|
||||
let line = kind == ShapeKind::Line;
|
||||
let fill = if line || p.filled == Some(false) { None } else { Some(Fill::solid(p.fill_color.unwrap_or(Rgb::WHITE))) };
|
||||
let stroke = if p.line == Some(false) { None } else { Some(p.line_color.unwrap_or(Rgb::BLACK)) };
|
||||
// Line widths are EMUs; Word's default is 0.75 pt.
|
||||
let stroke_width = p.line_width.map_or(0.75, |e| (e as f32 / 12_700.0).clamp(0.0, 100.0));
|
||||
Some(InlineObject::Shape {
|
||||
kind,
|
||||
w,
|
||||
h,
|
||||
fill,
|
||||
stroke,
|
||||
stroke_width,
|
||||
float,
|
||||
story,
|
||||
freeform: None,
|
||||
effects: Default::default(),
|
||||
extra: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// A group: its members flattened into the group's own coordinate space (nested groups mapped
|
||||
/// through their child anchors).
|
||||
fn group(sp: &Sp, members: &[Node], w: f32, h: f32, float: Float, b: &Build, pics: &mut Pictures) -> Option<InlineObject> {
|
||||
let [l, t, r, btm] = sp.group_space.unwrap_or([0, 0, 0, 0]);
|
||||
let (cw, ch) = ((r as f64 - l as f64) as f32, (btm as f64 - t as f64) as f32);
|
||||
let (ch_w, ch_h) = if cw > 0.0 && ch > 0.0 { (cw, ch) } else { (w.max(1.0), h.max(1.0)) };
|
||||
let mut children = Vec::new();
|
||||
// Map: outer = x0 + inner × s; the top group's space starts at its FSPGR origin.
|
||||
members_into(members, (-(l as f32), -(t as f32), 1.0, 1.0), 0, b, pics, &mut children);
|
||||
if children.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(InlineObject::Group { w, h, float, ch_w, ch_h, children, canvas: None })
|
||||
}
|
||||
|
||||
fn members_into(nodes: &[Node], t: (f32, f32, f32, f32), depth: usize, b: &Build, pics: &mut Pictures, out: &mut Vec<GroupChild>) {
|
||||
let (x0, y0, sx, sy) = t;
|
||||
let fit = |v: f32| if v.is_finite() { v.clamp(-1e7, 1e7) } else { 0.0 };
|
||||
for n in nodes {
|
||||
if out.len() >= wordcraft_doc::para::MAX_GROUP_CHILDREN {
|
||||
return;
|
||||
}
|
||||
let sp = n.sp();
|
||||
let Some([l, t, r, btm]) = sp.child else { continue };
|
||||
let (ox, oy) = (l as f32, t as f32);
|
||||
let (ew, eh) = ((r as f64 - l as f64) as f32, (btm as f64 - t as f64) as f32);
|
||||
match n {
|
||||
Node::Group(_, members) => {
|
||||
if depth >= MAX_GROUP_DEPTH {
|
||||
continue;
|
||||
}
|
||||
let [gl, gt, gr, gb] = sp.group_space.unwrap_or([l, t, r, btm]);
|
||||
let (gw, gh) = ((gr as f64 - gl as f64) as f32, (gb as f64 - gt as f64) as f32);
|
||||
let (kx, ky) = if gw > 0.0 && gh > 0.0 { (ew / gw, eh / gh) } else { (1.0, 1.0) };
|
||||
let nt = (fit(x0 + (ox - gl as f32 * kx) * sx), fit(y0 + (oy - gt as f32 * ky) * sy), fit(sx * kx), fit(sy * ky));
|
||||
members_into(members, nt, depth + 1, b, pics, out);
|
||||
}
|
||||
Node::Shape(sp) => {
|
||||
let float = Float { rot: sp.props.rot, flip_h: sp.flip_h(), flip_v: sp.flip_v(), ..Float::default() };
|
||||
let (w, h) = (fit(ew * sx).max(0.0), fit(eh * sy).max(0.0));
|
||||
if let Some(obj) = leaf(sp, w, h, float, b, pics) {
|
||||
out.push(GroupChild { x: fit(x0 + ox * sx), y: fit(y0 + oy * sy), obj });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
//! The File Information Block ([MS-DOC] §2.5): `FibBase` plus the variable sections
|
||||
//! (`FibRgW97`, `FibRgLw97`, the `RgFcLcb` blob, `FibRgCswNew`) at the start of the
|
||||
//! WordDocument stream.
|
||||
|
||||
use crate::DocbinError;
|
||||
|
||||
/// `wIdent` of every Word 97-2003 file (Word 6/95 wrote 0xA5DC).
|
||||
const W_IDENT: u16 = 0xA5EC;
|
||||
/// Offsets within the FIB, in bytes (see the spec's Fib layout).
|
||||
const OFF_FLAGS: usize = 0x0A;
|
||||
const OFF_CSW: usize = 0x20;
|
||||
const OFF_CSLW: usize = 0x3E;
|
||||
const OFF_RGLW: usize = 0x40;
|
||||
const OFF_CB_RG_FCLCB: usize = 0x98;
|
||||
/// `csw` (count of 16-bit values in `FibRgW97`) — fixed by the spec.
|
||||
const CSW: u16 = 0x000E;
|
||||
/// `cslw` (count of 32-bit values in `FibRgLw97`) — fixed by the spec.
|
||||
const CSLW: u16 = 0x0016;
|
||||
/// `cbRgFcLcb` for each known `nFib` ([MS-DOC] Fib.cbRgFcLcb table).
|
||||
const CB_RG_FCLCB: &[(u16, u16)] = &[(0x00C1, 0x005D), (0x00D9, 0x006C), (0x0101, 0x0088), (0x010C, 0x00A4), (0x0112, 0x00B7)];
|
||||
|
||||
/// Indices of `fc`/`lcb` pairs inside `FibRgFcLcb97` (each pair is 8 bytes: fc then lcb).
|
||||
pub(crate) mod pair {
|
||||
pub(crate) const STSHF: usize = 1;
|
||||
pub(crate) const PLCF_FND_REF: usize = 2;
|
||||
pub(crate) const PLCF_FND_TXT: usize = 3;
|
||||
pub(crate) const PLCF_SED: usize = 6;
|
||||
pub(crate) const PLCF_HDD: usize = 11;
|
||||
pub(crate) const PLCF_BTE_CHPX: usize = 12;
|
||||
pub(crate) const PLCF_BTE_PAPX: usize = 13;
|
||||
pub(crate) const STTBF_FFN: usize = 15;
|
||||
pub(crate) const STTBF_BKMK: usize = 21;
|
||||
pub(crate) const PLCF_BKF: usize = 22;
|
||||
pub(crate) const PLCF_BKL: usize = 23;
|
||||
pub(crate) const CLX: usize = 33;
|
||||
/// `fcPlcSpaMom` / `fcPlcSpaHdr`: floating shape anchors of the main document / headers.
|
||||
pub(crate) const PLC_SPA_MOM: usize = 40;
|
||||
pub(crate) const PLC_SPA_HDR: usize = 41;
|
||||
pub(crate) const PLCF_END_REF: usize = 46;
|
||||
pub(crate) const PLCF_END_TXT: usize = 47;
|
||||
/// `fcDggInfo`: the OfficeArt drawing content (BLIP store, shapes).
|
||||
pub(crate) const DGG_INFO: usize = 50;
|
||||
/// `fcPlcftxbxTxt` / `fcPlcfHdrtxbxTxt`: text box stories of the main document / headers.
|
||||
pub(crate) const PLCF_TXBX_TXT: usize = 56;
|
||||
pub(crate) const PLCF_HDR_TXBX_TXT: usize = 58;
|
||||
/// `fcPlfLst`: the list definitions (LSTF array, with the LVLs appended after the blob).
|
||||
pub(crate) const PLF_LST: usize = 73;
|
||||
/// `fcPlfLfo`: the list format overrides (LFO array with LFOData appended).
|
||||
pub(crate) const PLF_LFO: usize = 74;
|
||||
}
|
||||
|
||||
/// Character-position ranges of the subdocuments, from `FibRgLw97`.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub(crate) struct SubDocs {
|
||||
pub(crate) text: u32,
|
||||
pub(crate) ftn: u32,
|
||||
pub(crate) hdd: u32,
|
||||
pub(crate) atn: u32,
|
||||
pub(crate) edn: u32,
|
||||
pub(crate) txbx: u32,
|
||||
pub(crate) hdr_txbx: u32,
|
||||
}
|
||||
|
||||
impl SubDocs {
|
||||
/// Total CPs across all subdocuments, checked against a cap.
|
||||
pub(crate) fn total(&self) -> Result<u64, DocbinError> {
|
||||
let t = u64::from(self.text)
|
||||
+ u64::from(self.ftn)
|
||||
+ u64::from(self.hdd)
|
||||
+ u64::from(self.atn)
|
||||
+ u64::from(self.edn)
|
||||
+ u64::from(self.txbx)
|
||||
+ u64::from(self.hdr_txbx);
|
||||
// Word's own documents stay far below this; a larger total means a corrupt FIB
|
||||
// (the ccp fields are signed and "MUST be 0, 1, or greater").
|
||||
if t > 100_000_000 {
|
||||
return Err(DocbinError::Limit(format!("document claims {t} characters")));
|
||||
}
|
||||
Ok(t)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
/// Selects 1Table (true) or 0Table (false).
|
||||
pub(crate) which_tbl_stm: bool,
|
||||
pub(crate) ccp: SubDocs,
|
||||
/// The `RgFcLcb` pairs flattened as fc₀, lcb₀, fc₁, lcb₁, …
|
||||
pub(crate) pairs: Vec<u32>,
|
||||
}
|
||||
|
||||
impl Fib {
|
||||
/// The (fc, lcb) pair at `FibRgFcLcb97` index `i` (later blobs follow contiguously).
|
||||
pub(crate) fn pair(&self, i: usize) -> Option<(u32, u32)> {
|
||||
let fc = *self.pairs.get(i * 2)?;
|
||||
let lcb = *self.pairs.get(i * 2 + 1)?;
|
||||
Some((fc, lcb))
|
||||
}
|
||||
|
||||
/// Parse the FIB from the head of the WordDocument stream.
|
||||
pub(crate) fn parse(word: &[u8]) -> Result<Fib, DocbinError> {
|
||||
let u16le = |at: usize| -> u16 { u16::from_le_bytes([word.get(at).copied().unwrap_or(0), word.get(at + 1).copied().unwrap_or(0)]) };
|
||||
let u32le = |at: usize| -> u32 {
|
||||
u32::from_le_bytes([
|
||||
word.get(at).copied().unwrap_or(0),
|
||||
word.get(at + 1).copied().unwrap_or(0),
|
||||
word.get(at + 2).copied().unwrap_or(0),
|
||||
word.get(at + 3).copied().unwrap_or(0),
|
||||
])
|
||||
};
|
||||
if word.len() < OFF_RGLW + 88 {
|
||||
return Err(DocbinError::NotWord(format!("WordDocument stream is {} bytes", word.len())));
|
||||
}
|
||||
let w_ident = u16le(0);
|
||||
if w_ident == 0xA5DC {
|
||||
return Err(DocbinError::NotWord("Word 6.0/95 format, only Word 97-2003 is supported".into()));
|
||||
}
|
||||
if w_ident != W_IDENT {
|
||||
return Err(DocbinError::NotWord(format!("magic 0x{w_ident:04X}, expected 0x{W_IDENT:04X}")));
|
||||
}
|
||||
let flags = u16le(OFF_FLAGS);
|
||||
let nfib = u16le(2);
|
||||
if u16le(OFF_CSW) != CSW {
|
||||
return Err(DocbinError::NotWord(format!("csw is 0x{:04X}", u16le(OFF_CSW))));
|
||||
}
|
||||
if u16le(OFF_CSLW) != CSLW {
|
||||
return Err(DocbinError::NotWord(format!("cslw is 0x{:04X}", u16le(OFF_CSLW))));
|
||||
}
|
||||
// rglw u32 indices per FibRgLw97: cbMac, r1, r2, ccpText, ccpFtn, ccpHdd, r3, ccpAtn,
|
||||
// ccpEdn, ccpTxbx, ccpHdrTxbx, …
|
||||
let lw = |i: usize| -> u32 { u32le(OFF_RGLW + i * 4) };
|
||||
// The ccp fields are signed in the spec; negative values are corrupt input, clamped.
|
||||
let ccp = |i: usize| -> u32 { (lw(i) as i32).max(0) as u32 };
|
||||
let sub = SubDocs { text: ccp(3), ftn: ccp(4), hdd: ccp(5), atn: ccp(7), edn: ccp(8), txbx: ccp(9), hdr_txbx: ccp(10) };
|
||||
sub.total()?;
|
||||
|
||||
let cb_rg_fclcb = u16le(OFF_CB_RG_FCLCB) as usize;
|
||||
if cb_rg_fclcb == 0 {
|
||||
return Err(DocbinError::NotWord("empty RgFcLcb".into()));
|
||||
}
|
||||
if let Some(&(_, expected)) = CB_RG_FCLCB.iter().find(|(n, _)| *n == nfib)
|
||||
&& cb_rg_fclcb < expected as usize
|
||||
{
|
||||
// Word itself always writes the full blob for its version; anything smaller is
|
||||
// corrupt, but a *larger* blob (newer writer) is fine — we just read what's there.
|
||||
return Err(DocbinError::NotWord(format!("cbRgFcLcb is 0x{cb_rg_fclcb:04X} for nFib 0x{nfib:04X}")));
|
||||
}
|
||||
let blob_off = OFF_CB_RG_FCLCB + 2;
|
||||
let blob_len = cb_rg_fclcb.saturating_mul(8);
|
||||
if word.len() < blob_off + blob_len {
|
||||
return Err(DocbinError::NotWord("truncated RgFcLcb".into()));
|
||||
}
|
||||
let mut pairs = Vec::with_capacity(cb_rg_fclcb * 2);
|
||||
for i in 0..cb_rg_fclcb * 2 {
|
||||
pairs.push(u32le(blob_off + i * 4));
|
||||
}
|
||||
|
||||
Ok(Fib { which_tbl_stm: flags & 0x0200 != 0, ccp: sub, pairs })
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
//! Character and paragraph formatting lookup: the bin tables (`PlcBteChpx`/`PlcBtePapx`) in
|
||||
//! the Table stream point at 512-byte FKP pages in the WordDocument stream, which map FC
|
||||
//! ranges to CHPX (grpprls) and PAPX (istd + grpprl) ([MS-DOC] §2.4.6.1/2.4.6.2).
|
||||
|
||||
use crate::DocbinError;
|
||||
|
||||
/// Largest bin table (entries) we accept.
|
||||
const MAX_BINS: usize = 100_000;
|
||||
|
||||
/// A parsed `PlcBte*`: FC breakpoints and the FKP page each range lives in.
|
||||
pub(crate) struct Bins {
|
||||
fcs: Vec<u32>,
|
||||
/// Page number (× 512 = byte offset in the WordDocument stream).
|
||||
pns: Vec<u32>,
|
||||
}
|
||||
|
||||
impl Bins {
|
||||
/// Parse the Plc at `table[fc..fc+lcb]` (aFc[n+1] u32s + aPn[n] u32s).
|
||||
pub(crate) fn parse(table: &[u8], fc: u32, lcb: u32) -> Result<Bins, DocbinError> {
|
||||
let fc = fc as usize;
|
||||
let lcb = lcb as usize;
|
||||
if lcb < 4 || !(lcb - 4).is_multiple_of(8) {
|
||||
return Ok(Bins { fcs: Vec::new(), pns: Vec::new() });
|
||||
}
|
||||
let end = fc.checked_add(lcb).ok_or_else(|| DocbinError::Malformed("bin table overflow".into()))?;
|
||||
let plc = table.get(fc..end).ok_or_else(|| DocbinError::Malformed("bin table outside the Table stream".into()))?;
|
||||
let n = (lcb - 4) / 8;
|
||||
if n > MAX_BINS {
|
||||
return Err(DocbinError::Limit(format!("bin table has {n} entries")));
|
||||
}
|
||||
let 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 fcs = Vec::with_capacity(n + 1);
|
||||
for i in 0..=n {
|
||||
fcs.push(at(i).ok_or_else(|| DocbinError::Malformed("truncated bin aFc".into()))?);
|
||||
}
|
||||
let mut pns = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
// PnBte* keeps the page number in its low bits; the rest must be zero.
|
||||
pns.push(at(n + 1 + i).ok_or_else(|| DocbinError::Malformed("truncated bin aPn".into()))? & 0x003F_FFFF);
|
||||
}
|
||||
Ok(Bins { fcs, pns })
|
||||
}
|
||||
|
||||
/// The page containing `fc`, as a slice of the WordDocument stream.
|
||||
fn page<'w>(&self, word: &'w [u8], fc: u32) -> Option<&'w [u8]> {
|
||||
let i = self.fcs.partition_point(|&f| f <= fc).checked_sub(1)?;
|
||||
let pn = *self.pns.get(i)?;
|
||||
let at = pn.checked_mul(512)? as usize;
|
||||
word.get(at..at.checked_add(512)?)
|
||||
}
|
||||
|
||||
/// The grpprl of the Chpx covering `fc` ([MS-DOC] ChpxFkp: rgfc u32s, rgb offsets ×2,
|
||||
/// crun in the last byte).
|
||||
pub(crate) fn chpx<'w>(&self, word: &'w [u8], fc: u32) -> &'w [u8] {
|
||||
let Some(page) = self.page(word, fc) else { return &[] };
|
||||
let Some(&crun) = page.get(511) else { return &[] };
|
||||
let crun = crun.min(0x65) as usize;
|
||||
let rgfc = |i: usize| -> Option<u32> { page.get(i * 4..i * 4 + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])) };
|
||||
// rgb array sits right after rgfc (crun+1 u32s); each byte is a half-offset into the
|
||||
// region between rgb's end and crun.
|
||||
let rgb_at = (crun + 1) * 4;
|
||||
let j = (0..crun).rev().find(|&j| rgfc(j).is_some_and(|f| f <= fc));
|
||||
let Some(j) = j else { return &[] };
|
||||
let Some(&off) = page.get(rgb_at + j) else { return &[] };
|
||||
if off == 0 {
|
||||
return &[];
|
||||
}
|
||||
let at = off as usize * 2;
|
||||
let Some(&cb) = page.get(at) else { return &[] };
|
||||
page.get(at + 1..at + 1 + cb as usize).unwrap_or(&[])
|
||||
}
|
||||
|
||||
/// The (istd, grpprl) of the Papx of the paragraph whose mark is just before `fc`
|
||||
/// ([MS-DOC] PapxFkp: rgfc u32s — each the FC *following* a paragraph mark —, rgbx BX
|
||||
/// entries (1 offset byte + 12 PHE bytes), crun in the last byte; the PAPX itself is a
|
||||
/// PapxInFkp with the cb/cb' size quirk). Query with the FC of the character after the mark.
|
||||
pub(crate) fn papx<'w>(&self, word: &'w [u8], fc: u32) -> (u16, &'w [u8]) {
|
||||
let Some(page) = self.page(word, fc) else { return (0, &[]) };
|
||||
let Some(&crun) = page.get(511) else { return (0, &[]) };
|
||||
// BX is 13 bytes; cap crun at what fits before byte 511.
|
||||
let max_run = (511usize.saturating_sub((crun as usize + 1) * 4)) / 13;
|
||||
let crun = (crun as usize).min(max_run);
|
||||
let rgfc = |i: usize| -> Option<u32> { page.get(i * 4..i * 4 + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])) };
|
||||
let rgbx_at = (crun + 1) * 4;
|
||||
let j = (0..crun).rev().find(|&j| rgfc(j).is_some_and(|f| f <= fc));
|
||||
let Some(j) = j else { return (0, &[]) };
|
||||
let Some(&off) = page.get(rgbx_at + j * 13) else { return (0, &[]) };
|
||||
if off == 0 {
|
||||
return (0, &[]);
|
||||
}
|
||||
let at = off as usize * 2;
|
||||
let u16at = |o: usize| -> Option<u16> { page.get(o..o + 2).map(|b| u16::from_le_bytes([b[0], b[1]])) };
|
||||
let Some(cb) = page.get(at).copied() else { return (0, &[]) };
|
||||
// PapxInFkp: if cb is nonzero the GrpPrlAndIstd (istd u16 + grpprl) is 2×cb-1 bytes;
|
||||
// if cb is zero, the next byte cb' (1 byte) gives 2×cb' bytes of it.
|
||||
let (data_at, data_len) = if cb != 0 {
|
||||
(at + 1, 2 * cb as usize - 1)
|
||||
} else {
|
||||
let cb2 = page.get(at + 1).copied().unwrap_or(0) as usize;
|
||||
(at + 2, 2 * cb2)
|
||||
};
|
||||
if data_len < 2 {
|
||||
return (0, &[]);
|
||||
}
|
||||
let istd = u16at(data_at).unwrap_or(0);
|
||||
(istd, page.get(data_at + 2..data_at + data_len).unwrap_or(&[]))
|
||||
}
|
||||
}
|
||||
@@ -1,571 +0,0 @@
|
||||
//! Sprms → model properties, and style-chain resolution.
|
||||
|
||||
use wordcraft_doc::Rgb;
|
||||
use wordcraft_doc::props::{
|
||||
Align, Border, BorderStyle, Borders, CharProps, Highlight, LineSpacing, ParaProps, TabAlign, TabLeader, TabStop, TextColor, Underline, VertAlign,
|
||||
};
|
||||
use wordcraft_doc::styles::{Style, StyleKind, StyleSheet};
|
||||
|
||||
use crate::sprm::{self, Prl, Toggle};
|
||||
|
||||
// Character sprms ([MS-DOC] §2.6.1).
|
||||
const C_BOLD: u16 = 0x0835;
|
||||
const C_ITALIC: u16 = 0x0836;
|
||||
const C_STRIKE: u16 = 0x0837;
|
||||
const C_SMALL_CAPS: u16 = 0x083A;
|
||||
const C_CAPS: u16 = 0x083B;
|
||||
const C_HIDDEN: u16 = 0x083C; // sprmCFVanish
|
||||
const C_KUL: u16 = 0x2A3E; // underline style (Kul)
|
||||
const C_ICO: u16 = 0x2A42; // palette colour
|
||||
const C_CR: u16 = 0x6870; // explicit COLORREF
|
||||
const C_HPS: u16 = 0x4A43; // half-points
|
||||
const C_HPS_POS: u16 = 0x4845; // super/subscript by half-points
|
||||
const C_FTC0: u16 = 0x4A4F; // font index (ASCII)
|
||||
const C_FTC1: u16 = 0x4A50; // East Asian font index (sprmCRgFtc1)
|
||||
const C_FTC_BI: u16 = 0x4A5E; // complex-script font index (sprmCFtcBi)
|
||||
const C_HIGHLIGHT: u16 = 0x2A0C;
|
||||
const C_ISTD: u16 = 0x4A30; // character style
|
||||
|
||||
// Paragraph sprms ([MS-DOC] §2.6.2).
|
||||
const P_JC: u16 = 0x2461;
|
||||
const P_JC80: u16 = 0x2403;
|
||||
const P_DXA_LEFT: u16 = 0x845E;
|
||||
const P_DXA_LEFT80: u16 = 0x840F;
|
||||
const P_DXA_RIGHT: u16 = 0x845D;
|
||||
const P_DXA_RIGHT80: u16 = 0x840E;
|
||||
const P_DXA_FIRST: u16 = 0x8460; // sprmPDxaLeft1
|
||||
const P_DXA_FIRST80: u16 = 0x8411; // sprmPDxaLeft1_80
|
||||
// Indents in hundredths of a character (sprmPDxcRight, sprmPDxcLeft, sprmPDxcLeft1).
|
||||
const P_DXC_RIGHT: u16 = 0x4455;
|
||||
const P_DXC_LEFT: u16 = 0x4456;
|
||||
const P_DXC_FIRST: u16 = 0x4457;
|
||||
const P_DYA_BEFORE: u16 = 0xA413;
|
||||
const P_DYA_AFTER: u16 = 0xA414;
|
||||
const P_DYA_LINE: u16 = 0x6412;
|
||||
const P_KEEP: u16 = 0x2405; // keep lines together
|
||||
const P_KEEP_FOLLOW: u16 = 0x2406; // keep with next
|
||||
const P_PAGE_BREAK_BEFORE: u16 = 0x2407;
|
||||
const P_OUTLVL: u16 = 0x2640;
|
||||
// Paragraph borders ([MS-DOC] §2.6.2). The 80 forms carry a Brc80 (palette colour), the
|
||||
// newer ones a BrcOperand: cb(1) + Brc(8, with a full COLORREF).
|
||||
const P_BRC_TOP80: u16 = 0x6424;
|
||||
const P_BRC_LEFT80: u16 = 0x6425;
|
||||
const P_BRC_BOTTOM80: u16 = 0x6426;
|
||||
const P_BRC_RIGHT80: u16 = 0x6427;
|
||||
const P_BRC_BETWEEN80: u16 = 0x6428;
|
||||
const P_BRC_TOP: u16 = 0xC64E;
|
||||
const P_BRC_LEFT: u16 = 0xC64F;
|
||||
const P_BRC_BOTTOM: u16 = 0xC650;
|
||||
const P_BRC_RIGHT: u16 = 0xC651;
|
||||
const P_BRC_BETWEEN: u16 = 0xC652;
|
||||
|
||||
/// Word's 16-colour `Ico` palette ([MS-DOC] §2.9.135), 0 = automatic.
|
||||
const ICO: [Rgb; 17] = [
|
||||
Rgb(0, 0, 0),
|
||||
Rgb(0, 0, 0),
|
||||
Rgb(0, 0, 255),
|
||||
Rgb(0, 255, 255),
|
||||
Rgb(0, 255, 0),
|
||||
Rgb(255, 0, 255),
|
||||
Rgb(255, 0, 0),
|
||||
Rgb(255, 255, 0),
|
||||
Rgb(255, 255, 255),
|
||||
Rgb(0, 0, 128),
|
||||
Rgb(0, 128, 128),
|
||||
Rgb(0, 128, 0),
|
||||
Rgb(128, 0, 128),
|
||||
Rgb(128, 0, 0),
|
||||
Rgb(128, 128, 0),
|
||||
Rgb(128, 128, 128),
|
||||
Rgb(192, 192, 192),
|
||||
];
|
||||
|
||||
/// Highlight palette ([MS-DOC] §2.9.135 Highlight, order of Word's highlighter row).
|
||||
const HL: [Highlight; 17] = [
|
||||
Highlight::None,
|
||||
Highlight::Yellow,
|
||||
Highlight::BrightGreen,
|
||||
Highlight::Turquoise,
|
||||
Highlight::Pink,
|
||||
Highlight::Blue,
|
||||
Highlight::Red,
|
||||
Highlight::DarkBlue,
|
||||
Highlight::Teal,
|
||||
Highlight::Green,
|
||||
Highlight::Violet,
|
||||
Highlight::DarkRed,
|
||||
Highlight::DarkYellow,
|
||||
Highlight::Gray50,
|
||||
Highlight::Gray25,
|
||||
Highlight::None,
|
||||
Highlight::None,
|
||||
];
|
||||
|
||||
/// The `Ico` palette colour (0 = automatic → `None`).
|
||||
pub(crate) fn ico(i: u8) -> Option<Rgb> {
|
||||
if i == 0 {
|
||||
return None;
|
||||
}
|
||||
ICO.get(i as usize).copied()
|
||||
}
|
||||
|
||||
/// The model border style a `brcType` stands for ([MS-DOC] §2.9.22 BrcType). Types the model
|
||||
/// doesn't carry (the gap variants, 3-D, inset/outset) draw as single lines, as the DOCX
|
||||
/// reader maps them.
|
||||
fn brc_style(t: u8) -> BorderStyle {
|
||||
match t {
|
||||
0x00 => BorderStyle::None,
|
||||
0x03 => BorderStyle::Double,
|
||||
0x06 => BorderStyle::Dotted,
|
||||
0x07 | 0x16 => BorderStyle::Dashed,
|
||||
0x08 | 0x09 => BorderStyle::DotDash,
|
||||
0x0A => BorderStyle::Triple,
|
||||
0x14 | 0x15 => BorderStyle::Wave,
|
||||
_ => BorderStyle::Single,
|
||||
}
|
||||
}
|
||||
|
||||
/// The border width in points: `dptLineWidth` in 1/8-point steps (values below 2 count as 2;
|
||||
/// 1-point steps for page borders, which never reach a paragraph property).
|
||||
fn brc_width(t: u8, dpt: u8) -> f32 {
|
||||
if t >= 0x40 { f32::from(dpt) } else { f32::from(dpt.max(2)) / 8.0 }
|
||||
}
|
||||
|
||||
/// One border from a Brc80 operand ([MS-DOC] §2.9.18). All bits set means "no border"; a
|
||||
/// truncated operand is ignored.
|
||||
fn brc80(op: &[u8]) -> Option<Border> {
|
||||
let [dpt, ty, ico_index, tail, ..] = op else { return None };
|
||||
if [*dpt, *ty, *ico_index, *tail] == [0xFF; 4] {
|
||||
return Some(Border::default());
|
||||
}
|
||||
Some(Border { style: brc_style(*ty), width: brc_width(*ty, *dpt), color: ico(*ico_index), space: f32::from(tail & 0x1F) })
|
||||
}
|
||||
|
||||
/// One border from a `BrcOperand` ([MS-DOC] §2.9.21): cb(1, MUST be 8) + Brc(8).
|
||||
fn brc(op: &[u8]) -> Option<Border> {
|
||||
if op.first() != Some(&8) {
|
||||
return None;
|
||||
}
|
||||
let b = op.get(1..9)?;
|
||||
if b.iter().all(|x| *x == 0xFF) {
|
||||
return Some(Border::default());
|
||||
}
|
||||
let color = match b.get(..4) {
|
||||
Some([0, 0, 0, 0]) => None, // automatic
|
||||
Some(px) => Some(Rgb(px[0], px[1], px[2])),
|
||||
None => None,
|
||||
};
|
||||
Some(Border { style: brc_style(b[5]), width: brc_width(b[5], b[4]), color, space: f32::from(b[6] & 0x1F) })
|
||||
}
|
||||
|
||||
/// Put a decoded border on the side its sprm names (`between` = the border between this
|
||||
/// paragraph and the next).
|
||||
fn set_border(p: &mut ParaProps, op: u16, b: Border) {
|
||||
let borders = p.borders.get_or_insert_with(Borders::default);
|
||||
let slot = match op {
|
||||
P_BRC_TOP80 | P_BRC_TOP => &mut borders.top,
|
||||
P_BRC_LEFT80 | P_BRC_LEFT => &mut borders.left,
|
||||
P_BRC_BOTTOM80 | P_BRC_BOTTOM => &mut borders.bottom,
|
||||
P_BRC_RIGHT80 | P_BRC_RIGHT => &mut borders.right,
|
||||
_ => &mut borders.between,
|
||||
};
|
||||
*slot = Some(b);
|
||||
}
|
||||
|
||||
fn u16_of(p: &Prl) -> Option<u16> {
|
||||
match p.operand {
|
||||
[b0, b1, ..] => Some(u16::from_le_bytes([*b0, *b1])),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn s16_of(p: &Prl) -> Option<i16> {
|
||||
u16_of(p).map(|v| v as i16)
|
||||
}
|
||||
|
||||
fn set(props: &mut Option<bool>, t: Toggle, base: bool) {
|
||||
*props = Some(match t {
|
||||
Toggle::On => true,
|
||||
Toggle::Off => false,
|
||||
Toggle::Invert => !base,
|
||||
Toggle::Keep => return,
|
||||
});
|
||||
}
|
||||
|
||||
/// Apply a character-property sprm. `base` carries the style-inherited values so toggle
|
||||
/// operands can invert them; `fonts` maps sprmCRgFtc0 indices to names.
|
||||
pub(crate) fn apply_char(p: &mut CharProps, prl: &Prl, base: &CharProps, fonts: &[String]) {
|
||||
let op = prl.op;
|
||||
match op {
|
||||
C_BOLD => set(&mut p.bold, sprm::toggle(prl), base.bold.unwrap_or(false)),
|
||||
C_ITALIC => set(&mut p.italic, sprm::toggle(prl), base.italic.unwrap_or(false)),
|
||||
C_STRIKE => set(&mut p.strike, sprm::toggle(prl), base.strike.unwrap_or(false)),
|
||||
C_SMALL_CAPS => set(&mut p.small_caps, sprm::toggle(prl), base.small_caps.unwrap_or(false)),
|
||||
C_CAPS => set(&mut p.caps, sprm::toggle(prl), base.caps.unwrap_or(false)),
|
||||
C_HIDDEN => set(&mut p.hidden, sprm::toggle(prl), base.hidden.unwrap_or(false)),
|
||||
C_KUL => {
|
||||
p.underline = match prl.operand.first().copied().unwrap_or(0) {
|
||||
0x00 => Some(Underline::None),
|
||||
0x01 => Some(Underline::Single),
|
||||
0x02 => Some(Underline::Words),
|
||||
0x03 => Some(Underline::Double),
|
||||
0x04 => Some(Underline::Dotted),
|
||||
0x06 => Some(Underline::Thick),
|
||||
0x07 => Some(Underline::Dash),
|
||||
0x09 => Some(Underline::DotDash),
|
||||
0x0A => Some(Underline::DotDotDash),
|
||||
0x0B => Some(Underline::Wave),
|
||||
0x14 => Some(Underline::Dotted),
|
||||
0x17 => Some(Underline::Thick),
|
||||
0x18 => Some(Underline::Dash),
|
||||
0x19 => Some(Underline::DotDash),
|
||||
0x1A => Some(Underline::DotDotDash),
|
||||
0x1B => Some(Underline::DoubleWave),
|
||||
_ => Some(Underline::Single),
|
||||
};
|
||||
}
|
||||
C_ICO => {
|
||||
if let Some(rgb) = ICO.get(prl.operand.first().copied().unwrap_or(0) as usize) {
|
||||
p.color = Some(if rgb == &Rgb::BLACK && prl.operand.first() == Some(&0) { TextColor::Auto } else { TextColor::Rgb(*rgb) });
|
||||
}
|
||||
}
|
||||
C_CR => {
|
||||
// COLORREF 0x00BBGGRR, stored little-endian: operand bytes are R, G, B.
|
||||
if let [r, g, b, ..] = prl.operand
|
||||
&& let (r, g, b) = (*r, *g, *b)
|
||||
{
|
||||
p.color = Some(TextColor::Rgb(Rgb(r, g, b)));
|
||||
}
|
||||
}
|
||||
C_HPS => {
|
||||
if let Some(hps) = u16_of(prl) {
|
||||
p.size = Some((hps as f32 / 2.0).clamp(1.0, 1638.0));
|
||||
}
|
||||
}
|
||||
C_HPS_POS => {
|
||||
if let Some(pos) = s16_of(prl) {
|
||||
p.vert_align = Some(match pos {
|
||||
pos if pos > 0 => VertAlign::Superscript,
|
||||
pos if pos < 0 => VertAlign::Subscript,
|
||||
_ => VertAlign::Baseline,
|
||||
});
|
||||
}
|
||||
}
|
||||
C_FTC0 => {
|
||||
if let Some(name) = u16_of(prl).and_then(|i| fonts.get(i as usize)) {
|
||||
p.font = Some(name.clone());
|
||||
}
|
||||
}
|
||||
C_FTC1 => {
|
||||
if let Some(name) = u16_of(prl).and_then(|i| fonts.get(i as usize)) {
|
||||
p.font_ea = Some(name.clone());
|
||||
}
|
||||
}
|
||||
C_FTC_BI => {
|
||||
if let Some(name) = u16_of(prl).and_then(|i| fonts.get(i as usize)) {
|
||||
p.font_cs = Some(name.clone());
|
||||
}
|
||||
}
|
||||
C_HIGHLIGHT => {
|
||||
if let Some(h) = HL.get(prl.operand.first().copied().unwrap_or(0) as usize) {
|
||||
p.highlight = Some(*h);
|
||||
}
|
||||
}
|
||||
C_ISTD => {} // resolved separately through the style table
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom tab stops from `sprmPChgTabsPapx` / `sprmPChgTabs` ([MS-DOC] PChgTabsPapxOperand,
|
||||
/// PChgTabsOperand): the stops removed become `Clear` stops (they hide inherited ones) and the
|
||||
/// stops added carry their alignment and leader (TBD: jc in bits 0–2, tlc in bits 3–5).
|
||||
fn change_tabs(p: &mut ParaProps, prl: &Prl) {
|
||||
let op = prl.operand;
|
||||
let i16_at = |i: usize| op.get(i..i + 2).map(|b| f32::from(i16::from_le_bytes([b[0], b[1]])) / 20.0);
|
||||
// Both operands start with cb; sprmPChgTabs's removals also carry a "close" distance each.
|
||||
let close = prl.op == sprm::P_CHG_TABS;
|
||||
let mut at = 1;
|
||||
let Some(&del) = op.get(at) else { return };
|
||||
let del = usize::from(del.min(64));
|
||||
at += 1;
|
||||
let mut removed = Vec::with_capacity(del);
|
||||
for k in 0..del {
|
||||
if let Some(x) = i16_at(at + 2 * k) {
|
||||
removed.push(x);
|
||||
}
|
||||
}
|
||||
at += 2 * del * if close { 2 } else { 1 };
|
||||
let Some(&add) = op.get(at) else { return };
|
||||
let add = usize::from(add.min(64));
|
||||
at += 1;
|
||||
let mut tabs = p.tabs.take().unwrap_or_default();
|
||||
// Removed within 25 twips (1.25 pt), as the format says.
|
||||
tabs.retain(|t| !removed.iter().any(|x| (t.pos - x).abs() <= 1.25));
|
||||
for x in removed {
|
||||
tabs.push(TabStop { pos: x, align: TabAlign::Clear, leader: TabLeader::None });
|
||||
}
|
||||
for k in 0..add {
|
||||
let (Some(pos), Some(&tbd)) = (i16_at(at + 2 * k), op.get(at + 2 * add + k)) else { break };
|
||||
let align = match tbd & 7 {
|
||||
1 => TabAlign::Center,
|
||||
2 => TabAlign::Right,
|
||||
3 => TabAlign::Decimal,
|
||||
4 => TabAlign::Bar,
|
||||
_ => TabAlign::Left,
|
||||
};
|
||||
let leader = match (tbd >> 3) & 7 {
|
||||
1 => TabLeader::Dot,
|
||||
2 => TabLeader::Hyphen,
|
||||
3 | 4 => TabLeader::Underscore,
|
||||
5 => TabLeader::MiddleDot,
|
||||
_ => TabLeader::None,
|
||||
};
|
||||
tabs.retain(|t| (t.pos - pos).abs() > 0.05);
|
||||
tabs.push(TabStop { pos, align, leader });
|
||||
}
|
||||
tabs.sort_by(|a, b| a.pos.total_cmp(&b.pos));
|
||||
p.tabs = Some(tabs);
|
||||
}
|
||||
|
||||
/// Apply a paragraph-property sprm.
|
||||
pub(crate) fn apply_para(p: &mut ParaProps, prl: &Prl) {
|
||||
let tw = |v: i32| (v as f32 / 20.0).clamp(-1000.0, 1000.0);
|
||||
let s16 = |prl: &Prl| -> Option<i16> { u16_of(prl).map(|v| v as i16) };
|
||||
match prl.op {
|
||||
sprm::P_CHG_TABS | sprm::P_CHG_TABS_PAPX => change_tabs(p, prl),
|
||||
P_JC | P_JC80 => {
|
||||
p.align = match prl.operand.first().copied().unwrap_or(0) {
|
||||
1 => Some(Align::Center),
|
||||
2 => Some(Align::Right),
|
||||
3 => Some(Align::Justify),
|
||||
4 => Some(Align::Distribute),
|
||||
_ => Some(Align::Left),
|
||||
};
|
||||
}
|
||||
P_DXA_LEFT | P_DXA_LEFT80 => {
|
||||
if let Some(v) = s16(prl) {
|
||||
p.indent_left = Some(tw(v as i32));
|
||||
}
|
||||
}
|
||||
P_DXA_RIGHT | P_DXA_RIGHT80 => {
|
||||
if let Some(v) = s16(prl) {
|
||||
p.indent_right = Some(tw(v as i32));
|
||||
}
|
||||
}
|
||||
// A two-byte operand, like the other indents (spra 4).
|
||||
P_DXA_FIRST | P_DXA_FIRST80 => {
|
||||
if let Some(v) = s16(prl) {
|
||||
p.indent_first = Some(tw(v as i32));
|
||||
}
|
||||
}
|
||||
P_DXC_RIGHT | P_DXC_LEFT | P_DXC_FIRST => {
|
||||
if let Some(v) = s16(prl) {
|
||||
let chars = Some(f32::from(v) / 100.0);
|
||||
match prl.op {
|
||||
P_DXC_RIGHT => p.indent_right_chars = chars,
|
||||
P_DXC_LEFT => p.indent_left_chars = chars,
|
||||
_ => p.indent_first_chars = chars,
|
||||
}
|
||||
}
|
||||
}
|
||||
P_DYA_BEFORE => {
|
||||
if let Some(v) = s16(prl) {
|
||||
p.space_before = Some((v as f32 / 20.0).clamp(0.0, 3168.0));
|
||||
}
|
||||
}
|
||||
P_DYA_AFTER => {
|
||||
if let Some(v) = s16(prl) {
|
||||
p.space_after = Some((v as f32 / 20.0).clamp(0.0, 3168.0));
|
||||
}
|
||||
}
|
||||
// LSPD ([MS-DOC] §2.9.150): a signed dyaLine, then fMultLinespace. In lines (240ths) when
|
||||
// fMultLinespace is 1; else twips, at least when positive and exactly when negative.
|
||||
P_DYA_LINE => {
|
||||
if let Some(v) = s16(prl) {
|
||||
let mult = prl.operand.get(2..4).is_some_and(|b| b == [1, 0]);
|
||||
let v = f32::from(v);
|
||||
p.line_spacing = Some(if mult {
|
||||
LineSpacing::Multiple((v.abs() / 240.0).clamp(0.06, 132.0))
|
||||
} else if v < 0.0 {
|
||||
LineSpacing::Exactly((-v / 20.0).clamp(0.7, 1584.0))
|
||||
} else {
|
||||
LineSpacing::AtLeast((v / 20.0).clamp(0.0, 1584.0))
|
||||
});
|
||||
}
|
||||
}
|
||||
P_KEEP => {
|
||||
if matches!(sprm::toggle(prl), Toggle::On) {
|
||||
p.keep_lines = Some(true);
|
||||
}
|
||||
}
|
||||
P_KEEP_FOLLOW => {
|
||||
if matches!(sprm::toggle(prl), Toggle::On) {
|
||||
p.keep_next = Some(true);
|
||||
}
|
||||
}
|
||||
P_PAGE_BREAK_BEFORE => {
|
||||
if matches!(sprm::toggle(prl), Toggle::On) {
|
||||
p.page_break_before = Some(true);
|
||||
}
|
||||
}
|
||||
P_OUTLVL => {
|
||||
if let Some(&l) = prl.operand.first() {
|
||||
p.outline_level = Some(l.min(8));
|
||||
}
|
||||
}
|
||||
P_BRC_TOP80 | P_BRC_LEFT80 | P_BRC_BOTTOM80 | P_BRC_RIGHT80 | P_BRC_BETWEEN80 => {
|
||||
if let Some(b) = brc80(prl.operand) {
|
||||
set_border(p, prl.op, b);
|
||||
}
|
||||
}
|
||||
P_BRC_TOP | P_BRC_LEFT | P_BRC_BOTTOM | P_BRC_RIGHT | P_BRC_BETWEEN => {
|
||||
if let Some(b) = brc(prl.operand) {
|
||||
set_border(p, prl.op, b);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the model stylesheet: resolve each raw style's chain (base → this) and the ids we
|
||||
/// reference from paragraphs.
|
||||
pub(crate) fn stylesheet(raw: &[crate::stsh::RawStyle], fonts: &[String], standard: crate::stsh::StandardFonts) -> StyleSheet {
|
||||
let mut sheet = StyleSheet::empty();
|
||||
// Word's base character properties: the stylesheet's standard fonts (Aptos in a Word 365
|
||||
// file, whose font table still starts with Times New Roman, #5066), else font index 0 of the
|
||||
// table; 10 pt ([MS-DOC] sprmCHps).
|
||||
let font = |i: Option<u16>| i.and_then(|i| fonts.get(usize::from(i))).cloned();
|
||||
sheet.default_chr.font = font(standard.ascii).or_else(|| fonts.first().cloned());
|
||||
sheet.default_chr.font_ea = font(standard.east_asian);
|
||||
sheet.default_chr.font_cs = font(standard.bidi);
|
||||
if sheet.default_chr.size.is_none() {
|
||||
sheet.default_chr.size = Some(10.0);
|
||||
}
|
||||
for (istd, st) in raw.iter().enumerate() {
|
||||
if st.name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let id = style_id(&st.name);
|
||||
let kind = match st.stk {
|
||||
2 => StyleKind::Character,
|
||||
3 => StyleKind::Table,
|
||||
4 => StyleKind::Numbering,
|
||||
_ => StyleKind::Paragraph,
|
||||
};
|
||||
let base = base_of(raw, istd).and_then(|i| raw.get(i)).map(|b| style_id(&b.name));
|
||||
// Inherit: properties come from the base chain, root first, then our own grpprls.
|
||||
let mut chr = CharProps::default();
|
||||
let mut para = ParaProps::default();
|
||||
for s in style_chain(raw, istd).into_iter().filter_map(|i| raw.get(i)) {
|
||||
apply_chain(&mut chr, &s.chpx, fonts);
|
||||
apply_chain_para(&mut para, &s.papx);
|
||||
}
|
||||
if para.outline_level.is_none() && kind == StyleKind::Paragraph {
|
||||
let h = heading_level(&st.name);
|
||||
if h > 0 {
|
||||
para.outline_level = Some(h - 1);
|
||||
}
|
||||
}
|
||||
sheet.styles.push(Style { id, name: display_name(&st.name), kind, based_on: base, chr, para, ..Default::default() });
|
||||
}
|
||||
sheet
|
||||
}
|
||||
|
||||
/// Most `istdBase` links followed; a longer chain (or a cycle) in a corrupt file stops there.
|
||||
const MAX_STYLE_DEPTH: usize = 16;
|
||||
|
||||
/// The parent of style `istd`: its zero-based `istdBase` ([MS-DOC] §2.9.260), when that names
|
||||
/// another existing, named style (0x0FFF means none).
|
||||
fn base_of(raw: &[crate::stsh::RawStyle], istd: usize) -> Option<usize> {
|
||||
let b = raw.get(istd)?.istd_base;
|
||||
let b = usize::from(b);
|
||||
(b != 0x0FFF && b != istd && raw.get(b).is_some_and(|s| !s.name.is_empty())).then_some(b)
|
||||
}
|
||||
|
||||
/// Style `istd` and its ancestors, root first and `istd` last. Cycles and chains deeper than
|
||||
/// [`MAX_STYLE_DEPTH`] are cut.
|
||||
fn style_chain(raw: &[crate::stsh::RawStyle], istd: usize) -> Vec<usize> {
|
||||
let mut chain = vec![istd];
|
||||
let mut cur = istd;
|
||||
while chain.len() < MAX_STYLE_DEPTH {
|
||||
let Some(b) = base_of(raw, cur) else { break };
|
||||
if chain.contains(&b) {
|
||||
break;
|
||||
}
|
||||
chain.push(b);
|
||||
cur = b;
|
||||
}
|
||||
chain.reverse();
|
||||
chain
|
||||
}
|
||||
|
||||
/// The style-id form of a stored (lower-case) built-in name, matching the docx reader's ids.
|
||||
pub(crate) fn style_id(name: &str) -> String {
|
||||
let lower = name.trim().to_ascii_lowercase();
|
||||
match lower.as_str() {
|
||||
"normal" => "Normal".into(),
|
||||
"title" => "Title".into(),
|
||||
"subtitle" => "Subtitle".into(),
|
||||
"quote" | "body text" | "body text 2" | "body text 3" | "body text indent" | "body text indent 2" | "body text indent 3" => {
|
||||
let mut c = lower.chars();
|
||||
match c.next() {
|
||||
Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
"default paragraph font" => "Default Paragraph Font".into(),
|
||||
_ => {
|
||||
let mut out = String::with_capacity(name.len());
|
||||
let mut up = true;
|
||||
for ch in name.trim().chars() {
|
||||
if ch == ' ' {
|
||||
up = true;
|
||||
out.push(ch);
|
||||
} else if up {
|
||||
out.extend(ch.to_uppercase());
|
||||
up = false;
|
||||
} else {
|
||||
out.push(ch);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// "heading 3" → 3; 0 when not a heading.
|
||||
fn heading_level(name: &str) -> u8 {
|
||||
let lower = name.trim().to_ascii_lowercase();
|
||||
lower.strip_prefix("heading ").and_then(|d| d.parse().ok()).filter(|&l: &u8| (1..=9).contains(&l)).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn display_name(name: &str) -> String {
|
||||
let lower = name.trim().to_ascii_lowercase();
|
||||
let h = heading_level(&lower);
|
||||
if h > 0 {
|
||||
return format!("Heading {h}");
|
||||
}
|
||||
match lower.as_str() {
|
||||
"body text" => "Body Text".into(),
|
||||
_ => style_id(name),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a grpprl of character sprms with a toggle base.
|
||||
fn apply_chain(chr: &mut CharProps, grpprl: &[u8], fonts: &[String]) {
|
||||
let base = chr.clone();
|
||||
for prl in sprm::iter(grpprl) {
|
||||
if prl.sgc() == 2 {
|
||||
apply_char(chr, &prl, &base, fonts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a grpprl of paragraph sprms.
|
||||
fn apply_chain_para(para: &mut ParaProps, grpprl: &[u8]) {
|
||||
for prl in sprm::iter(grpprl) {
|
||||
if prl.sgc() == 1 {
|
||||
apply_para(para, &prl);
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
-937
@@ -1,950 +1,31 @@
|
||||
//! WordCraft reader for the Word 97-2003 binary file format (`.doc`, [MS-DOC]).
|
||||
//! WordCraft adapter for reading Word 97-2003 binary documents (`.doc`, [MS-DOC]).
|
||||
//!
|
||||
//! [`read`] turns the bytes of a `.doc` file into a [`wordcraft_doc::Document`]. There is no
|
||||
//! writer: saving always goes through the other formats. The reader is written from the public
|
||||
//! specification ([MS-DOC], Word (.doc) Binary File Format, Microsoft Open Specifications) and,
|
||||
//! like every WordCraft parser, is lenient by design (unknown structures are skipped, bad numbers
|
||||
//! fall back to defaults) and bounded (stream sizes, piece counts and property counts are
|
||||
//! 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)]
|
||||
//! The file-format implementation lives in `craft-doc-binary`. This crate adapts the shared
|
||||
//! model to WordCraft's application model while keeping the public read and password APIs stable.
|
||||
//! Stories are converted paragraph by paragraph, and embedded media buffers are moved without a
|
||||
//! full-document serialization copy.
|
||||
//!
|
||||
//! [MS-DOC]: https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/
|
||||
|
||||
mod crypt;
|
||||
mod drawing;
|
||||
mod fib;
|
||||
mod fkp;
|
||||
mod fmt;
|
||||
mod list;
|
||||
mod media;
|
||||
mod metadata;
|
||||
mod notes;
|
||||
mod officeart;
|
||||
mod piece;
|
||||
mod sections;
|
||||
mod sprm;
|
||||
mod stsh;
|
||||
mod table;
|
||||
mod model_bridge;
|
||||
|
||||
use std::io::{Read, Seek};
|
||||
pub use craft_doc_binary::DocbinError;
|
||||
pub use model_bridge::from_shared_model;
|
||||
|
||||
use wordcraft_doc::para::InlineObject;
|
||||
use wordcraft_doc::para::NoteKind;
|
||||
use wordcraft_doc::para::Paragraph;
|
||||
use wordcraft_doc::para::Run;
|
||||
use wordcraft_doc::props::CharProps;
|
||||
use wordcraft_doc::props::NumRef;
|
||||
use wordcraft_doc::section::HeaderSet;
|
||||
use wordcraft_doc::styles::StyleSheet;
|
||||
use wordcraft_doc::{Document, PartKind};
|
||||
|
||||
use fkp::Bins;
|
||||
use piece::{PieceTable, PrmRef};
|
||||
use sprm::Prl;
|
||||
use table::ParaOut;
|
||||
|
||||
/// Largest stream we materialise (bytes); the engine already caps whole files at 2 GB.
|
||||
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;
|
||||
/// Bytes of repeated work per document beyond the text itself: piece Prm grpprls (Prcs, up
|
||||
/// to 64 KB each) interpreted (#3692), hyperlink targets (#3708), picture alt text (#3709)
|
||||
/// and floating objects (#3710) copied into every run or anchor. Past it Prms are skipped,
|
||||
/// copied strings left empty and floating objects dropped, each kind logged once a document
|
||||
/// ([`Work::log`]).
|
||||
const MAX_WORK: u64 = 64 << 20;
|
||||
|
||||
/// What the [`MAX_WORK`] budget pays for, and leaves out once it is spent.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Cost {
|
||||
/// A piece's Prm grpprl: past the budget the piece keeps its CHPX formatting only.
|
||||
Formatting,
|
||||
/// A hyperlink target copied into a run of its result: past it the text is kept, unlinked.
|
||||
LinkTarget,
|
||||
/// A picture's alt text copied into an anchor: past it the picture has none.
|
||||
AltText,
|
||||
/// A floating object copied into its anchor: past it the object is left out.
|
||||
FloatingObject,
|
||||
}
|
||||
|
||||
impl Cost {
|
||||
const ALL: [Cost; 4] = [Cost::Formatting, Cost::LinkTarget, Cost::AltText, Cost::FloatingObject];
|
||||
|
||||
/// What is left out, for the log.
|
||||
fn left_out(self) -> &'static str {
|
||||
match self {
|
||||
Cost::Formatting => "some text keeps only part of its formatting",
|
||||
Cost::LinkTarget => "some hyperlinks' text is kept without its link",
|
||||
Cost::AltText => "some pictures are left without their alt text",
|
||||
Cost::FloatingObject => "some floating shapes and pictures are left out",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One document's [`MAX_WORK`] budget, and what it has left out (each kind logged once when
|
||||
/// the document has been read, [`Work::log`]).
|
||||
struct Work {
|
||||
left: std::cell::Cell<u64>,
|
||||
dropped: std::cell::Cell<[bool; 4]>,
|
||||
}
|
||||
|
||||
impl Work {
|
||||
fn new(budget: u64) -> Self {
|
||||
Work { left: std::cell::Cell::new(budget), dropped: std::cell::Cell::new([false; 4]) }
|
||||
}
|
||||
|
||||
/// Spends `cost` bytes on `what`: false (and `what` noted as left out) when they aren't left.
|
||||
fn charge(&self, cost: u64, what: Cost) -> bool {
|
||||
match self.left.get().checked_sub(cost) {
|
||||
Some(left) => {
|
||||
self.left.set(left);
|
||||
true
|
||||
}
|
||||
None => {
|
||||
let mut dropped = self.dropped.get();
|
||||
if let Some(d) = Cost::ALL.iter().position(|c| *c == what).and_then(|i| dropped.get_mut(i)) {
|
||||
*d = true;
|
||||
}
|
||||
self.dropped.set(dropped);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The kinds of work left out so far, in [`Cost::ALL`] order.
|
||||
fn dropped(&self) -> Vec<Cost> {
|
||||
Cost::ALL.iter().zip(self.dropped.get()).filter(|(_, d)| *d).map(|(c, _)| *c).collect()
|
||||
}
|
||||
|
||||
/// Logs one warning for each kind left out.
|
||||
fn log(&self) {
|
||||
let dropped = self.dropped();
|
||||
for c in &dropped {
|
||||
log::warn!(".doc: the document repeats more than WordCraft copies ({MAX_WORK} bytes): {}", c.left_out());
|
||||
}
|
||||
#[cfg(test)]
|
||||
LAST_DROPPED.with(|d| *d.borrow_mut() = dropped);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// What the last document read on this thread left out for the work budget ([`Work::log`]).
|
||||
static LAST_DROPPED: std::cell::RefCell<Vec<Cost>> = const { std::cell::RefCell::new(Vec::new()) };
|
||||
}
|
||||
/// `sprmCPicLocation`: the fc of a picture in the Data stream.
|
||||
const C_PIC_LOCATION: u16 = 0x6A03;
|
||||
/// `sprmCSymbol`: the record font and character of a symbol character ([MS-DOC] §2.6.1); the
|
||||
/// text stream holds the placeholder U+0028 at its position.
|
||||
const C_SYMBOL: u16 = 0x6A09;
|
||||
/// `sprmPIlvl` / `sprmPIlfo`: the paragraph's list level and list.
|
||||
const P_ILVL: u16 = 0x260A;
|
||||
const P_ILFO: u16 = 0x460B;
|
||||
|
||||
/// Errors from reading a Word 97-2003 binary file.
|
||||
#[derive(Debug, thiserror::Error, Clone, PartialEq)]
|
||||
pub enum DocbinError {
|
||||
/// The bytes are not a readable compound file (OLE2/CFB container).
|
||||
#[error("not a Word document (compound file): {0}")]
|
||||
Container(String),
|
||||
/// 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 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),
|
||||
/// A structure is malformed beyond recovery.
|
||||
#[error("malformed document: {0}")]
|
||||
Malformed(String),
|
||||
}
|
||||
|
||||
/// Is this `.doc` password-protected (XOR obfuscation, RC4 or RC4 CryptoAPI)? False for
|
||||
/// anything that is not a readable Word 97-2003 file.
|
||||
/// Whether the input is a readable Word 97-2003 document protected by a password.
|
||||
pub fn is_encrypted(bytes: &[u8]) -> bool {
|
||||
let Ok(mut comp) = open_container(bytes) else { return false };
|
||||
stream(&mut comp, "WordDocument").is_some_and(|w| fib::peek_encryption(&w).is_some())
|
||||
craft_doc_binary::is_encrypted(bytes)
|
||||
}
|
||||
|
||||
/// Open the compound file around a `.doc`, after [`wordcraft_doc::cfb_guard::check`].
|
||||
fn open_container(bytes: &[u8]) -> Result<cfb::CompoundFile<std::io::Cursor<&[u8]>>, DocbinError> {
|
||||
wordcraft_doc::cfb_guard::check(bytes).map_err(DocbinError::Container)?;
|
||||
cfb::CompoundFile::open(std::io::Cursor::new(bytes)).map_err(|e| DocbinError::Container(e.to_string()))
|
||||
}
|
||||
|
||||
/// Read a `.doc` (Word 97-2003 binary) file into a [`Document`]. A password-protected file
|
||||
/// gives [`DocbinError::PasswordRequired`]; see [`read_with_password`].
|
||||
pub fn read(bytes: &[u8]) -> Result<Document, DocbinError> {
|
||||
/// Read a Word 97-2003 binary document into WordCraft's document model.
|
||||
pub fn read(bytes: &[u8]) -> Result<wordcraft_doc::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 = open_container(bytes)?;
|
||||
let mut word = stream(&mut comp, "WordDocument").ok_or_else(|| DocbinError::NotWord("no WordDocument stream".into()))?;
|
||||
if word.len() as u64 > MAX_STREAM {
|
||||
return Err(DocbinError::Limit(format!("WordDocument stream is {} bytes", word.len())));
|
||||
}
|
||||
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 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) =
|
||||
fib.pair(fib::pair::CLX).filter(|(_, lcb)| *lcb > 0).ok_or_else(|| DocbinError::Malformed("the FIB has no piece table (Clx)".into()))?;
|
||||
let pieces = piece::PieceTable::parse(&table, fc_clx, lcb_clx)?;
|
||||
|
||||
// Styles, fonts and the formatting bin tables; each is optional in broken files.
|
||||
let fonts = fib.pair(fib::pair::STTBF_FFN).map(|(f, l)| stsh::fonts(&table, f, l)).unwrap_or_default();
|
||||
let raw_styles = match fib.pair(fib::pair::STSHF) {
|
||||
Some((f, l)) => stsh::parse(&table, f, l)?,
|
||||
None => Vec::new(),
|
||||
};
|
||||
let standard_fonts = fib.pair(fib::pair::STSHF).map(|(f, l)| stsh::standard_fonts(&table, f, l)).unwrap_or_default();
|
||||
let chpx_bins = match fib.pair(fib::pair::PLCF_BTE_CHPX) {
|
||||
Some((f, l)) => Bins::parse(&table, f, l)?,
|
||||
None => Bins::parse(&[], 0, 0)?,
|
||||
};
|
||||
let papx_bins = match fib.pair(fib::pair::PLCF_BTE_PAPX) {
|
||||
Some((f, l)) => Bins::parse(&table, f, l)?,
|
||||
None => Bins::parse(&[], 0, 0)?,
|
||||
};
|
||||
|
||||
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, standard_fonts);
|
||||
doc.numbering = list::parse(&table, &fib, &fonts);
|
||||
let sheet: StyleSheet = doc.styles.clone();
|
||||
let sects = sections::parse(&word, &table, &fib);
|
||||
let stories = sections::header_stories(&table, &fib);
|
||||
// Subdocument layout in the CP space: main document, footnotes, headers, comments,
|
||||
// endnotes, text boxes, header text boxes.
|
||||
// Story ranges from the Table stream are hostile: each is clamped to its subdocument's
|
||||
// length here and to the last piece's CP inside `walk`.
|
||||
let ftn_base = fib.ccp.text;
|
||||
let hdd_base = fib.ccp.text.saturating_add(fib.ccp.ftn);
|
||||
let edn_base = hdd_base.saturating_add(fib.ccp.hdd).saturating_add(fib.ccp.atn);
|
||||
let mut pics = media::Pictures::for_input(word.len().saturating_add(data.len()));
|
||||
// Every materialised character comes from at least one byte of the WordDocument stream, so a
|
||||
// legitimate document's total text (body plus every header, footnote and text box, each read
|
||||
// once) cannot exceed its stream. A hostile piece or story table that aliases the same bytes
|
||||
// many times can, so the walk is held to twice the stream's size — generous for real files,
|
||||
// tight against aliasing — and never past the absolute ceiling.
|
||||
let budget = std::cell::Cell::new((word.len() as u64).saturating_mul(2).min(MAX_TOTAL_CHARS));
|
||||
let work = Work::new(MAX_WORK);
|
||||
|
||||
let ctx = WalkCtx {
|
||||
word: &word,
|
||||
data: &data,
|
||||
pieces: &pieces,
|
||||
chpx_bins: &chpx_bins,
|
||||
papx_bins: &papx_bins,
|
||||
fonts: &fonts,
|
||||
raw_styles: &raw_styles,
|
||||
sheet: &sheet,
|
||||
notes: &[],
|
||||
marks: &[],
|
||||
drawn: &[],
|
||||
sect_ends: &[],
|
||||
budget: &budget,
|
||||
work: &work,
|
||||
};
|
||||
|
||||
// Drawings: text box stories become parts first, then the floating shapes and pictures
|
||||
// anchored in the main document and the headers, found by their anchor CP while walking.
|
||||
let txbx_base = edn_base.saturating_add(fib.ccp.edn);
|
||||
let hdr_txbx_base = txbx_base.saturating_add(fib.ccp.txbx);
|
||||
let art = fib
|
||||
.pair(fib::pair::DGG_INFO)
|
||||
.and_then(|(f, l)| table.get(f as usize..(f as usize).saturating_add(l as usize)))
|
||||
.map(officeart::content)
|
||||
.unwrap_or_default();
|
||||
let mut tb_stories = std::collections::HashMap::new();
|
||||
for (pair, base, len) in [(fib::pair::PLCF_TXBX_TXT, txbx_base, fib.ccp.txbx), (fib::pair::PLCF_HDR_TXBX_TXT, hdr_txbx_base, fib.ccp.hdr_txbx)] {
|
||||
for (spid, ab) in drawing::textbox_stories(&table, fib.pair(pair)) {
|
||||
let Some((a, b)) = story(base, len, ab) else { continue };
|
||||
if doc.parts.len() >= MAX_PARTS || tb_stories.contains_key(&spid) {
|
||||
continue;
|
||||
}
|
||||
let blocks = table::assemble(walk(&ctx, a, b, &mut pics));
|
||||
tb_stories.insert(spid, doc.add_part(PartKind::TextBox, blocks));
|
||||
}
|
||||
}
|
||||
let build = drawing::Build { bstore: &art.bstore, delay: &word, stories: &tb_stories };
|
||||
let mut drawn = drawing::anchors(&table, fib.pair(fib::pair::PLC_SPA_MOM), 0, &art.main, &build, &mut pics);
|
||||
drawn.extend(drawing::anchors(&table, fib.pair(fib::pair::PLC_SPA_HDR), hdd_base, &art.header, &build, &mut pics));
|
||||
drawn.sort_by_key(|(cp, _)| *cp);
|
||||
let ctx = WalkCtx { drawn: &drawn, ..ctx };
|
||||
|
||||
// Header and footer parts: skip the six separator stories, then six stories per section
|
||||
// in the order even header, odd (default) header, even footer, odd (default) footer,
|
||||
// first header, first footer. Empty stories inherit from the previous section.
|
||||
let mut hf_ids: Vec<(usize, usize, u32)> = Vec::new();
|
||||
for si in 0..sects.len() {
|
||||
for k in 0..6usize {
|
||||
let Some(&ab) = stories.get(6 + si * 6 + k) else { break };
|
||||
let Some((a, b)) = story(hdd_base, fib.ccp.hdd, ab) else { continue };
|
||||
if doc.parts.len() >= MAX_PARTS {
|
||||
continue;
|
||||
}
|
||||
let kind = match k {
|
||||
0 | 1 | 4 => PartKind::Header,
|
||||
_ => PartKind::Footer,
|
||||
};
|
||||
let blocks = table::assemble(walk(&ctx, a, b, &mut pics));
|
||||
if !blocks.is_empty() {
|
||||
let id = doc.add_part(kind, blocks);
|
||||
hf_ids.push((si, k, id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Footnote and endnote stories: each text range becomes a part; the reference CPs then
|
||||
// link to the part ids during the main walk.
|
||||
let mut note_links: Vec<(u32, NoteKind, u32)> = Vec::new();
|
||||
for (cp, ab) in notes::parse_notes(&table, &fib, fib::pair::PLCF_FND_REF, fib::pair::PLCF_FND_TXT) {
|
||||
let Some((a, b)) = story(ftn_base, fib.ccp.ftn, ab) else { continue };
|
||||
let blocks = table::assemble(walk(&ctx, a, b, &mut pics));
|
||||
if !blocks.is_empty() && doc.parts.len() < MAX_PARTS {
|
||||
let id = doc.add_part(PartKind::Footnote, blocks);
|
||||
note_links.push((cp, NoteKind::Footnote, id));
|
||||
}
|
||||
}
|
||||
for (cp, ab) in notes::parse_notes(&table, &fib, fib::pair::PLCF_END_REF, fib::pair::PLCF_END_TXT) {
|
||||
let Some((a, b)) = story(edn_base, fib.ccp.edn, ab) else { continue };
|
||||
let blocks = table::assemble(walk(&ctx, a, b, &mut pics));
|
||||
if !blocks.is_empty() && doc.parts.len() < MAX_PARTS {
|
||||
let id = doc.add_part(PartKind::Endnote, blocks);
|
||||
note_links.push((cp, NoteKind::Endnote, id));
|
||||
}
|
||||
}
|
||||
// Sorted so the main walk finds each reference by binary search.
|
||||
note_links.sort_by_key(|(cp, _, _)| *cp);
|
||||
let marks = notes::parse_bookmarks(&table, &fib);
|
||||
let mut sect_ends: Vec<u32> = sects.iter().map(|s| s.end_cp).collect();
|
||||
sect_ends.sort_unstable();
|
||||
let ctx = WalkCtx { notes: ¬e_links, marks: &marks, sect_ends: §_ends, ..ctx };
|
||||
|
||||
// Sections: each section's properties attach to the paragraph that ends it (its story of
|
||||
// header/footer parts included); the section reaching the end of the text is the final one.
|
||||
let mut paras = walk(&ctx, 0, fib.ccp.text, &mut pics);
|
||||
let last = sects.last().map(|s| s.props.clone());
|
||||
for (si, sec) in sects.iter().enumerate() {
|
||||
let mut props = sec.props.clone();
|
||||
let id_of = |k: usize| hf_ids.iter().find(|(s, kk, _)| *s == si && *kk == k).map(|(_, _, id)| *id);
|
||||
props.headers = HeaderSet { even: id_of(0), default: id_of(1), first: id_of(4) };
|
||||
props.footers = HeaderSet { even: id_of(2), default: id_of(3), first: id_of(5) };
|
||||
let is_final = si + 1 == sects.len() || sec.end_cp >= fib.ccp.text;
|
||||
if is_final {
|
||||
continue; // handled below through `last`
|
||||
}
|
||||
for p in paras.iter_mut() {
|
||||
if p.end_cp == sec.end_cp {
|
||||
p.para.section = Some(Box::new(props));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(mut l) = last {
|
||||
let si = sects.len().saturating_sub(1);
|
||||
let id_of = |k: usize| hf_ids.iter().find(|(s, kk, _)| *s == si && *kk == k).map(|(_, _, id)| *id);
|
||||
l.headers = HeaderSet { even: id_of(0), default: id_of(1), first: id_of(4) };
|
||||
l.footers = HeaderSet { even: id_of(2), default: id_of(3), first: id_of(5) };
|
||||
doc.last_section = l;
|
||||
}
|
||||
|
||||
doc.media.extend(pics.media);
|
||||
doc.body = table::assemble(paras);
|
||||
doc.ensure_nonempty();
|
||||
work.log();
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
/// A story's CP range `[a, b)`, relative to a subdocument starting at `base` with `len`
|
||||
/// CPs, as absolute CPs clamped to that subdocument; `None` when empty.
|
||||
fn story(base: u32, len: u32, (a, b): (u32, u32)) -> Option<(u32, u32)> {
|
||||
let (a, b) = (a.min(len), b.min(len));
|
||||
if b <= a {
|
||||
return None;
|
||||
}
|
||||
Some((base.checked_add(a)?, base.checked_add(b)?))
|
||||
}
|
||||
|
||||
/// Everything the walker needs besides its CP range.
|
||||
struct WalkCtx<'a> {
|
||||
word: &'a [u8],
|
||||
data: &'a [u8],
|
||||
pieces: &'a PieceTable,
|
||||
chpx_bins: &'a Bins,
|
||||
papx_bins: &'a Bins,
|
||||
fonts: &'a [String],
|
||||
raw_styles: &'a [stsh::RawStyle],
|
||||
sheet: &'a StyleSheet,
|
||||
/// (reference CP in the main document, kind, part id) for notes.
|
||||
notes: &'a [(u32, NoteKind, u32)],
|
||||
/// Bookmark boundaries (main-document CPs), sorted for a sequential walk.
|
||||
marks: &'a [(u32, notes::Mark)],
|
||||
/// Floating shapes and pictures by anchor CP (absolute), sorted.
|
||||
drawn: &'a [(u32, InlineObject)],
|
||||
/// 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>,
|
||||
/// Repeated-work bytes left ([`MAX_WORK`]), and what they left out.
|
||||
work: &'a Work,
|
||||
}
|
||||
|
||||
/// State while assembling one paragraph from per-CP formatting.
|
||||
#[derive(Default)]
|
||||
struct ParaBuild {
|
||||
text: String,
|
||||
runs: Vec<Run>,
|
||||
props: wordcraft_doc::props::ParaProps,
|
||||
mark: CharProps,
|
||||
/// The current run's properties, so runs only break when formatting changes.
|
||||
cur: Option<CharProps>,
|
||||
/// Inline objects, each anchored at a U+FFFC pushed into `text`.
|
||||
objects: Vec<InlineObject>,
|
||||
}
|
||||
|
||||
impl ParaBuild {
|
||||
fn push(&mut self, c: char, props: CharProps) {
|
||||
let len = c.len_utf8();
|
||||
match &self.cur {
|
||||
Some(p) if *p == props => {
|
||||
if let Some(r) = self.runs.last_mut() {
|
||||
r.len += len;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
self.runs.push(Run { len, props: props.clone() });
|
||||
self.cur = Some(props);
|
||||
}
|
||||
}
|
||||
self.text.push(c);
|
||||
}
|
||||
|
||||
/// Append `c` to the current run, whose properties the caller knows it matches.
|
||||
fn extend(&mut self, c: char) {
|
||||
if let Some(r) = self.runs.last_mut() {
|
||||
r.len += c.len_utf8();
|
||||
}
|
||||
self.text.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk a CP range, splitting paragraphs at 0x0D/0x07 marks, applying the direct character
|
||||
/// formatting of each CP and the paragraph formatting of each mark. Works for the main
|
||||
/// document and for any subdocument range (headers, footers, notes…). Field characters,
|
||||
/// note references, bookmarks and inline pictures become inline objects; pictures go into
|
||||
/// `pics`, which shares one media entry per Data-stream location.
|
||||
///
|
||||
/// The range is clamped to the last piece's CP, and unreadable stretches (gaps, truncated
|
||||
/// pieces) are skipped a whole piece at a time, so the work is bounded by the text that
|
||||
/// really exists, not by the CP numbers a file claims.
|
||||
fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pictures) -> Vec<ParaOut> {
|
||||
let cp_end = cp_end.min(ctx.pieces.cp_end());
|
||||
let mut out = Vec::new();
|
||||
let mut pb = ParaBuild::default();
|
||||
let mut cp = cp_start;
|
||||
let mut marks = ctx.marks.iter().peekable();
|
||||
// Field assembly state: instruction characters are swallowed, the result is kept until
|
||||
// the end character, then emitted as one object (hyperlinks keep their result text).
|
||||
let mut field: Option<FieldBuild> = None;
|
||||
let mut memo: Option<FmtMemo<'a>> = None;
|
||||
while cp < cp_end {
|
||||
// Bookmark boundaries at this CP (main-document walks only; subdocument walks get
|
||||
// an empty table and `cp` never matches the main-document CPs).
|
||||
// `<=` so a boundary inside a skipped stretch is still emitted (late) rather than
|
||||
// blocking every later one.
|
||||
while marks.peek().is_some_and(|&(mcp, _)| *mcp <= cp) {
|
||||
if let Some((_, m)) = marks.next() {
|
||||
let obj = match m {
|
||||
notes::Mark::Start(name) => InlineObject::BookmarkStart { name: name.clone() },
|
||||
notes::Mark::End(name) => InlineObject::BookmarkEnd { name: name.clone() },
|
||||
};
|
||||
pb.push('\u{FFFC}', CharProps::default());
|
||||
pb.objects.push(obj);
|
||||
}
|
||||
}
|
||||
let (c, units) = match ctx.pieces.char_at(ctx.word, cp) {
|
||||
Ok(found) => found,
|
||||
Err(next) => {
|
||||
cp = next;
|
||||
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 {
|
||||
'\r' | '\u{7}' | '\u{C}' if c != '\u{C}' || section_mark => {
|
||||
// Paragraph mark: its PAPX covers the FC range of the paragraph it ends, so
|
||||
// querying at the mark's own FC finds that paragraph's properties.
|
||||
let fc_mark = ctx.pieces.fc_of_cp(cp).unwrap_or(0);
|
||||
let (istd, papx) = ctx.papx_bins.papx(ctx.word, fc_mark);
|
||||
if let Some(st) = ctx.raw_styles.get(istd as usize).filter(|s| !s.name.is_empty()) {
|
||||
pb.props.style = Some(fmt::style_id(&st.name));
|
||||
}
|
||||
let mut row = table::decode(papx);
|
||||
row.cell_mark = c == '\u{7}';
|
||||
let mut ilvl = 0u8;
|
||||
let mut ilfo = 0i32;
|
||||
for prl in sprm::iter(papx) {
|
||||
if prl.op == P_ILVL {
|
||||
ilvl = prl.operand.first().copied().unwrap_or(0).min(8);
|
||||
continue;
|
||||
}
|
||||
if prl.op == P_ILFO {
|
||||
ilfo = match prl.operand {
|
||||
[b0, b1, ..] => i16::from_le_bytes([*b0, *b1]) as i32,
|
||||
_ => 0,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if prl.sgc() == 1 {
|
||||
fmt::apply_para(&mut pb.props, &prl);
|
||||
}
|
||||
}
|
||||
if let Some(num) = num_of(ilfo) {
|
||||
pb.props.numbering = Some(NumRef { num, level: ilvl });
|
||||
}
|
||||
let fc = ctx.pieces.fc_of_cp(cp).unwrap_or(0);
|
||||
pb.mark = char_props(ctx.chpx_bins.chpx(ctx.word, fc), ctx.fonts, &CharProps::default());
|
||||
let done = std::mem::take(&mut pb);
|
||||
out.push(ParaOut {
|
||||
para: Paragraph {
|
||||
text: done.text,
|
||||
runs: done.runs,
|
||||
props: done.props,
|
||||
mark: done.mark,
|
||||
objects: done.objects,
|
||||
..Default::default()
|
||||
},
|
||||
end_cp: cp.saturating_add(1),
|
||||
row,
|
||||
});
|
||||
}
|
||||
// Field begin/separator/end ([MS-DOC] §2.9.84 Plcfld).
|
||||
'\u{13}' => {
|
||||
match field.as_mut() {
|
||||
Some(f) => {
|
||||
f.depth = f.depth.saturating_add(1);
|
||||
}
|
||||
None => field = Some(FieldBuild { depth: 1, instr: String::new(), sep: None }),
|
||||
}
|
||||
cp = cp.saturating_add(1);
|
||||
continue;
|
||||
}
|
||||
'\u{14}' => {
|
||||
if let Some(f) = field.as_mut()
|
||||
&& f.depth == 1
|
||||
&& f.sep.is_none()
|
||||
{
|
||||
f.sep = Some(Vec::new());
|
||||
}
|
||||
cp = cp.saturating_add(1);
|
||||
continue;
|
||||
}
|
||||
'\u{15}' => {
|
||||
if let Some(mut f) = field.take() {
|
||||
if f.depth > 1 {
|
||||
f.depth -= 1;
|
||||
field = Some(f);
|
||||
} else {
|
||||
finish_field(&mut pb, f, ctx.work);
|
||||
}
|
||||
}
|
||||
cp = cp.saturating_add(1);
|
||||
continue;
|
||||
}
|
||||
'\u{1}' => {
|
||||
// Inline picture: the CHPX of the anchor carries sprmCPicLocation.
|
||||
let fc = ctx.pieces.fc_of_cp(cp).unwrap_or(0);
|
||||
let fc_pic = pic_location(ctx.chpx_bins.chpx(ctx.word, fc));
|
||||
match fc_pic.and_then(|at| pics.get(ctx.data, at)) {
|
||||
Some(p) => {
|
||||
// Every anchor of a picture gets its own copy of the alt text; the
|
||||
// copies are charged to the work budget (#3709).
|
||||
let alt = if ctx.work.charge(p.alt.len() as u64, Cost::AltText) { p.alt.clone() } else { String::new() };
|
||||
pb.push('\u{FFFC}', CharProps::default());
|
||||
pb.objects.push(InlineObject::Image {
|
||||
media: p.key.clone(),
|
||||
w: p.w,
|
||||
h: p.h,
|
||||
alt,
|
||||
float: Default::default(),
|
||||
crop: p.crop,
|
||||
ole: None,
|
||||
});
|
||||
}
|
||||
None => pics.dropped(cp),
|
||||
}
|
||||
}
|
||||
'\u{8}' => {
|
||||
// Drawn object anchor: the floating shape or picture anchored at this CP.
|
||||
let at = ctx.drawn.partition_point(|(c, _)| *c < cp);
|
||||
// Each copy (a story can be walked more than once) is charged to the work budget
|
||||
// by what a clone copies: a picture's alt text, a group with all its members (#3710).
|
||||
for (_, obj) in ctx.drawn.get(at..).unwrap_or_default().iter().take_while(|(c, _)| *c == cp) {
|
||||
if !ctx.work.charge(object_cost(obj), Cost::FloatingObject) {
|
||||
continue;
|
||||
}
|
||||
pb.push('\u{FFFC}', CharProps::default());
|
||||
pb.objects.push(obj.clone());
|
||||
}
|
||||
}
|
||||
'\u{2}' => {
|
||||
// Footnote or endnote reference; custom symbols share the same character.
|
||||
let at = ctx.notes.partition_point(|(c, _, _)| *c < cp);
|
||||
if let Some((_, kind, id)) = ctx.notes.get(at).filter(|(c, _, _)| *c == cp) {
|
||||
pb.push('\u{FFFC}', CharProps::default());
|
||||
pb.objects.push(InlineObject::NoteRef { kind: *kind, id: *id, custom: String::new() });
|
||||
}
|
||||
}
|
||||
'\t' => push_formatted(&mut pb, cp, '\t', ctx, field.as_mut(), &mut memo),
|
||||
'\u{B}' => push_formatted(&mut pb, cp, '\n', ctx, field.as_mut(), &mut memo),
|
||||
'\u{C}' => push_formatted(&mut pb, cp, '\u{C}', ctx, field.as_mut(), &mut memo),
|
||||
'\u{E}' => push_formatted(&mut pb, cp, '\u{E}', ctx, field.as_mut(), &mut memo),
|
||||
'\u{1E}' => push_formatted(&mut pb, cp, '\u{2011}', ctx, field.as_mut(), &mut memo),
|
||||
'\u{1F}' => push_formatted(&mut pb, cp, '\u{AD}', ctx, field.as_mut(), &mut memo),
|
||||
// Object anchors, comment references and other special characters are dropped
|
||||
// (or handled by later passes).
|
||||
c if (c as u32) < 0x20 => {}
|
||||
c => push_formatted(&mut pb, cp, c, ctx, field.as_mut(), &mut memo),
|
||||
}
|
||||
cp = cp.saturating_add(units);
|
||||
}
|
||||
if !pb.text.is_empty() {
|
||||
let done = pb;
|
||||
out.push(ParaOut {
|
||||
para: Paragraph { text: done.text, runs: done.runs, props: done.props, mark: done.mark, objects: done.objects, ..Default::default() },
|
||||
end_cp: cp_end,
|
||||
row: table::RowInfo::default(),
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Field assembly: `instr` collects everything before the separator (nested fields
|
||||
/// flattened), `sep` holds the cached result once the separator was seen.
|
||||
struct FieldBuild {
|
||||
depth: u32,
|
||||
instr: String,
|
||||
sep: Option<Vec<(char, CharProps)>>,
|
||||
}
|
||||
|
||||
/// Bytes a clone of `obj` copies: the object itself plus a picture's alt text, a group with all
|
||||
/// its members (#3710).
|
||||
fn object_cost(obj: &InlineObject) -> u64 {
|
||||
let shallow = std::mem::size_of::<InlineObject>() as u64;
|
||||
match obj {
|
||||
InlineObject::Image { alt, .. } => shallow.saturating_add(alt.len() as u64),
|
||||
InlineObject::Group { children, .. } => children.iter().fold(shallow, |n, c| n.saturating_add(object_cost(&c.obj))),
|
||||
_ => shallow,
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit a finished field: HYPERLINK keeps its result text as a linked run; every other
|
||||
/// field becomes one inline object with its cached result.
|
||||
///
|
||||
/// The target is copied once per run of equally formatted result text, not per character,
|
||||
/// and each copy is charged to `work` ([`MAX_WORK`]); once that is spent the rest of the
|
||||
/// result is kept as plain text (#3708).
|
||||
fn finish_field(pb: &mut ParaBuild, f: FieldBuild, work: &Work) {
|
||||
let instr = f.instr.trim().to_string();
|
||||
let result = f.sep.unwrap_or_default();
|
||||
if let Some(target) = hyperlink_target(&instr) {
|
||||
let cost = target.len() as u64;
|
||||
let mut run: Option<CharProps> = None;
|
||||
for (c, props) in result {
|
||||
if run.as_ref() == Some(&props) {
|
||||
pb.extend(c);
|
||||
continue;
|
||||
}
|
||||
let mut linked = props.clone();
|
||||
if work.charge(cost, Cost::LinkTarget) {
|
||||
linked.link = Some(target.clone());
|
||||
}
|
||||
pb.push(c, linked);
|
||||
run = Some(props);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let result_text: String = result.iter().map(|(c, _)| *c).collect();
|
||||
pb.push('\u{FFFC}', CharProps::default());
|
||||
pb.objects.push(InlineObject::Field { instr, result: result_text, locked: false, dirty: false, code: false, form: String::new() });
|
||||
}
|
||||
|
||||
/// `HYPERLINK "url"` / `HYPERLINK url \l "bookmark"` → the model link target
|
||||
/// (`#bookmark` for internal links); `None` when not a hyperlink.
|
||||
fn hyperlink_target(instr: &str) -> Option<String> {
|
||||
let t = instr.trim();
|
||||
let rest = if t.get(..9).is_some_and(|h| h.eq_ignore_ascii_case("hyperlink")) {
|
||||
t.get(9..).filter(|r| r.is_empty() || r.starts_with(char::is_whitespace))?.trim()
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
if rest.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut url: Option<String> = None;
|
||||
let mut local: Option<String> = None;
|
||||
let mut switch: Option<&str> = None;
|
||||
|
||||
let mut i = 0;
|
||||
let bytes = rest.as_bytes();
|
||||
while i < bytes.len() {
|
||||
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
|
||||
i += 1;
|
||||
}
|
||||
if i >= bytes.len() {
|
||||
break;
|
||||
}
|
||||
if bytes[i] == b'\\' {
|
||||
let start = i;
|
||||
i += 1;
|
||||
while i < bytes.len() && (bytes[i].is_ascii_alphabetic() || bytes[i] == b'*') {
|
||||
i += 1;
|
||||
}
|
||||
switch = Some(rest.get(start..i).unwrap_or(""));
|
||||
continue;
|
||||
}
|
||||
let arg = if bytes[i] == b'"' {
|
||||
i += 1;
|
||||
let start = i;
|
||||
while i < bytes.len() && bytes[i] != b'"' {
|
||||
i += 1;
|
||||
}
|
||||
let val = rest.get(start..i).unwrap_or("");
|
||||
if i < bytes.len() && bytes[i] == b'"' {
|
||||
i += 1;
|
||||
}
|
||||
val.to_string()
|
||||
} else {
|
||||
let start = i;
|
||||
while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
|
||||
i += 1;
|
||||
}
|
||||
rest.get(start..i).unwrap_or("").to_string()
|
||||
};
|
||||
let clean_arg = arg.replace("%22", "\"");
|
||||
match switch.take() {
|
||||
Some("\\l") => local = Some(clean_arg),
|
||||
Some(_) => {}
|
||||
None => {
|
||||
if url.is_none() {
|
||||
url = Some(clean_arg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match (url, local) {
|
||||
(Some(u), Some(l)) if !u.is_empty() && !l.is_empty() => {
|
||||
let frag = l.trim_start_matches('#');
|
||||
if u.contains('#') { Some(u) } else { Some(format!("{u}#{frag}")) }
|
||||
}
|
||||
(Some(u), _) if !u.is_empty() => Some(u),
|
||||
(None, Some(l)) if !l.is_empty() => {
|
||||
let frag = l.trim_start_matches('#');
|
||||
Some(format!("#{frag}"))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `sprmCPicLocation` from a CHPX grpprl: the fc of the picture in the Data stream.
|
||||
fn pic_location(chpx: &[u8]) -> Option<u32> {
|
||||
for prl in sprm::iter(chpx) {
|
||||
if prl.op == C_PIC_LOCATION {
|
||||
return match prl.operand {
|
||||
[b0, b1, b2, b3, ..] => Some(u32::from_le_bytes([*b0, *b1, *b2, *b3])),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `sprmCSymbol` from a CHPX grpprl: the font table index and the character code of a symbol
|
||||
/// character ([MS-DOC] §2.6.1, CSymbolOperand).
|
||||
fn symbol_operand(chpx: &[u8]) -> Option<(u16, u16)> {
|
||||
for prl in sprm::iter(chpx) {
|
||||
if prl.op == C_SYMBOL
|
||||
&& let [f0, f1, c0, c1, ..] = prl.operand
|
||||
{
|
||||
return Some((u16::from_le_bytes([*f0, *f1]), u16::from_le_bytes([*c0, *c1])));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `sprmPIlfo` operand (a signed 16-bit value, [MS-DOC] §2.6.2) → the num id, if the
|
||||
/// paragraph is in a list. 0x0001-0x07FE is a 1-based LFO index; 0xF802-0xFFFF (-2046..=-1)
|
||||
/// is the negation of one and keeps the paragraph's own indents, which we do by leaving the
|
||||
/// level's indents out of the paragraph (they never enter `ParaProps` anyway). 0 and 0xF801
|
||||
/// mean "not in a list".
|
||||
fn num_of(ilfo: i32) -> Option<u32> {
|
||||
match ilfo {
|
||||
0x0001..=0x07FE | -0x07FE..=-1 => Some(ilfo.unsigned_abs()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the character properties for the character at `cp`: the CHPX grpprl of its FC,
|
||||
/// plus the piece's Prm, over the paragraph style's base character props. Inside a field,
|
||||
/// the character is recorded into the instruction or the cached result instead of (or as
|
||||
/// well as) the visible text.
|
||||
fn push_formatted<'a>(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx<'a>, field: Option<&mut FieldBuild>, memo: &mut Option<FmtMemo<'a>>) {
|
||||
let fc = ctx.pieces.fc_of_cp(cp).unwrap_or(0);
|
||||
let grpprl = ctx.chpx_bins.chpx(ctx.word, fc);
|
||||
let prm = ctx.pieces.prm_of(cp);
|
||||
let style = pb.props.style.as_deref();
|
||||
let mut props = match memo.as_ref().filter(|m| std::ptr::eq(m.chpx, grpprl) && m.prm == prm && m.style.as_deref() == style) {
|
||||
Some(m) => m.props.clone(),
|
||||
None => {
|
||||
let default;
|
||||
let base = match style.and_then(|id| ctx.sheet.get(id)) {
|
||||
Some(st) => &st.chr,
|
||||
None => {
|
||||
default = CharProps::default();
|
||||
&default
|
||||
}
|
||||
};
|
||||
let mut props = char_props(grpprl, ctx.fonts, base);
|
||||
apply_prm(&mut props, ctx.pieces.piece_prm(cp), ctx.fonts, base, ctx.work);
|
||||
*memo = Some(FmtMemo { chpx: grpprl, prm, style: style.map(str::to_owned), props: props.clone() });
|
||||
props
|
||||
}
|
||||
};
|
||||
// A symbol character (Insert › Symbol) is stored as the placeholder U+0028 with
|
||||
// sprmCSymbol carrying the font and the character code ([MS-DOC] §2.6.1).
|
||||
let mut c = c;
|
||||
if c == '('
|
||||
&& let Some((ftc, xchar)) = symbol_operand(grpprl)
|
||||
{
|
||||
if let Some(font) = ctx.fonts.get(usize::from(ftc)) {
|
||||
props.font = Some(font.clone());
|
||||
}
|
||||
if let Some(sym) = char::from_u32(u32::from(xchar)) {
|
||||
c = sym;
|
||||
}
|
||||
}
|
||||
match field {
|
||||
Some(f) => match f.sep.as_mut() {
|
||||
// Inside a field, characters are recorded for the field object instead of shown;
|
||||
// `finish_field` emits the visible text (hyperlink runs or one object anchor).
|
||||
Some(result) => result.push((c, props)),
|
||||
None => f.instr.push(c),
|
||||
},
|
||||
None => pb.push(c, props),
|
||||
}
|
||||
}
|
||||
|
||||
/// The last character formatting resolved, reused while the CHPX, the piece's Prm and the
|
||||
/// paragraph style stay the same, so a Prm grpprl (up to 64 KB) is interpreted once per
|
||||
/// formatting change, not once per character (#3692).
|
||||
struct FmtMemo<'a> {
|
||||
/// The CHPX grpprl, compared by address: it is a slice of the WordDocument stream.
|
||||
chpx: &'a [u8],
|
||||
prm: u16,
|
||||
style: Option<String>,
|
||||
props: CharProps,
|
||||
}
|
||||
|
||||
/// Apply a piece's Prm (Prm0 short sprm or Prm1 grpprl) to character properties. A Prm1
|
||||
/// grpprl is charged to `work`; once that is spent the grpprl is skipped.
|
||||
fn apply_prm(props: &mut CharProps, prm: PrmRef<'_>, fonts: &[String], base: &CharProps, work: &Work) {
|
||||
match prm {
|
||||
PrmRef::None => {}
|
||||
PrmRef::Sprm0 { isprm, val } => {
|
||||
if let Some(op) = sprm::from_prm0(isprm) {
|
||||
let operand = [val];
|
||||
let prl = Prl { op, operand: &operand };
|
||||
if prl.sgc() == 2 {
|
||||
fmt::apply_char(props, &prl, base, fonts);
|
||||
}
|
||||
}
|
||||
}
|
||||
PrmRef::Grpprl(Some(g)) => {
|
||||
if !work.charge(g.len() as u64, Cost::Formatting) {
|
||||
return;
|
||||
}
|
||||
for prl in sprm::iter(g) {
|
||||
if prl.sgc() == 2 {
|
||||
fmt::apply_char(props, &prl, base, fonts);
|
||||
}
|
||||
}
|
||||
}
|
||||
PrmRef::Grpprl(None) => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Character props from a CHPX grpprl applied over `base`.
|
||||
fn char_props(grpprl: &[u8], fonts: &[String], base: &CharProps) -> CharProps {
|
||||
let mut p = base.clone();
|
||||
for prl in sprm::iter(grpprl) {
|
||||
if prl.sgc() == 2 {
|
||||
fmt::apply_char(&mut p, &prl, base, fonts);
|
||||
}
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// Read a root-level stream fully, or `None` if it is absent.
|
||||
fn stream<F: Read + Seek>(comp: &mut cfb::CompoundFile<F>, name: &str) -> Option<Vec<u8>> {
|
||||
let mut s = comp.open_stream(format!("/{name}")).ok()?;
|
||||
let mut v = Vec::new();
|
||||
// Read with a cap one byte above the limit so oversized streams are still caught by the
|
||||
// caller instead of exhausting memory here.
|
||||
s.by_ref().take(MAX_STREAM + 1).read_to_end(&mut v).ok()?;
|
||||
Some(v)
|
||||
/// Read a document that may be password-protected, preserving the shared reader's diagnostics.
|
||||
pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<wordcraft_doc::Document, DocbinError> {
|
||||
let shared = craft_doc_binary::read_with_password(bytes, password)?;
|
||||
from_shared_model(shared)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,302 +0,0 @@
|
||||
//! Lists: `PlfLst` (the LSTF definitions with their LVLs appended) and `PlfLfo` (the LFO
|
||||
//! instances with LFOLVL overrides) resolved into the numbering model
|
||||
//! ([MS-DOC] §2.8.1 "Determining List Formatting of a Paragraph", §2.9).
|
||||
|
||||
use wordcraft_doc::numbering::{AbstractNum, Level, LevelSuffix, Num, Numbering};
|
||||
use wordcraft_doc::props::{Align, CharProps, ParaProps};
|
||||
use wordcraft_doc::section::NumFormat;
|
||||
|
||||
use crate::fib::{Fib, pair};
|
||||
use crate::fmt;
|
||||
use crate::sprm;
|
||||
|
||||
/// Lists (and therefore LFOs) we are willing to read.
|
||||
const MAX_LISTS: usize = 2_000;
|
||||
/// Override levels per LFO ([MS-DOC] LFOLVL count is at most 9 by construction).
|
||||
const MAX_LFO_LVL: usize = 9;
|
||||
|
||||
/// One list definition: an LSTF plus its levels (one if `fSimpleList`, else nine).
|
||||
struct ListDef {
|
||||
lsid: i32,
|
||||
levels: Vec<RawLvl>,
|
||||
}
|
||||
|
||||
/// One LVL, as stored ([MS-DOC] §2.9.182): LVLF fields, the two grpprls and the number text.
|
||||
struct RawLvl {
|
||||
start: u32,
|
||||
nfc: u8,
|
||||
jc: u8,
|
||||
flags: u8,
|
||||
ixch_follow: u8,
|
||||
rgbxch_nums: [u8; 9],
|
||||
papx: Vec<u8>,
|
||||
chpx: Vec<u8>,
|
||||
/// The number text with each placeholder left as its raw char.
|
||||
xst: Vec<u16>,
|
||||
}
|
||||
|
||||
/// A bullet level's character, with the 0xF000 flag bits stripped and the common
|
||||
/// Symbol/Wingdings glyphs mapped to their Unicode equivalents so every font renders them.
|
||||
fn bullet_char(c: u16) -> char {
|
||||
match c & 0x0FFF {
|
||||
0xB7 => '\u{2022}', // Symbol bullet
|
||||
0xA7 => '\u{25AA}', // Wingdings small square
|
||||
0x6F => '\u{25CB}', // Symbol open circle
|
||||
0x77 | 0x78 => '\u{25CF}', // Wingdings filled circles
|
||||
other => char::from_u32(other as u32).unwrap_or('\u{2022}'),
|
||||
}
|
||||
}
|
||||
|
||||
/// MSONFC → model number format ([MS-OSHARED] §2.2.1.3; 0x17 = bullets, 0xFF = none). Codes
|
||||
/// 0x00 to 0x27 run in the order of ST_NumberFormat (decimal, upperRoman … 0x16 decimalZero, 0x17
|
||||
/// bullet … 0x25 chineseCounting, 0x26 chineseLegalSimplified, 0x27 chineseCountingThousand);
|
||||
/// later codes are drawn as decimal.
|
||||
fn num_format(nfc: u8) -> NumFormat {
|
||||
match nfc {
|
||||
0xFF => NumFormat::None,
|
||||
0x00..=0x27 => NumFormat::ALL.get(nfc as usize).copied().unwrap_or(NumFormat::Decimal),
|
||||
_ => NumFormat::Decimal,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn number_format_codes() {
|
||||
assert_eq!(num_format(0x04), NumFormat::LowerLetter);
|
||||
assert_eq!(num_format(0x16), NumFormat::DecimalZero);
|
||||
assert_eq!(num_format(0x17), NumFormat::Bullet);
|
||||
assert_eq!(num_format(0x25), NumFormat::ChineseCounting);
|
||||
assert_eq!(num_format(0x27), NumFormat::ChineseCountingThousand);
|
||||
assert_eq!(num_format(0x60), NumFormat::Decimal);
|
||||
}
|
||||
|
||||
/// Read the lists of a document; empty when the FIB carries none (or they are unreadable).
|
||||
pub(crate) fn parse(table: &[u8], fib: &Fib, fonts: &[String]) -> Numbering {
|
||||
let defs = match fib.pair(pair::PLF_LST) {
|
||||
Some((fc, _)) => parse_plf_lst(table, fc),
|
||||
None => Vec::new(),
|
||||
};
|
||||
if defs.is_empty() {
|
||||
return Numbering::default();
|
||||
}
|
||||
let mut numbering = Numbering::default();
|
||||
for (i, def) in defs.iter().enumerate() {
|
||||
numbering.abstracts.push(AbstractNum {
|
||||
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)
|
||||
&& lcb > 0
|
||||
{
|
||||
parse_plf_lfo(table, fc, &defs, &mut numbering, fonts);
|
||||
}
|
||||
numbering
|
||||
}
|
||||
|
||||
/// `PlfLst`: cLst u16, then cLst × LSTF (28 bytes each); the LVLs follow the whole array,
|
||||
/// one per simple list and nine per multi-level list, in LSTF order. `lcbPlfLst` covers only
|
||||
/// the LSTFs, so the LVLs are read past it, bounded by the stream.
|
||||
fn parse_plf_lst(table: &[u8], fc: u32) -> Vec<ListDef> {
|
||||
let at = fc as usize;
|
||||
let Some(c_lst) = table.get(at..at.saturating_add(2)).map(|b| u16::from_le_bytes([b[0], b[1]])) else { return Vec::new() };
|
||||
let c_lst = c_lst as usize;
|
||||
if c_lst == 0 || c_lst > MAX_LISTS {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut defs = Vec::with_capacity(c_lst);
|
||||
// LSTF: lsid i32, tplc, rgistdPara[9], flags byte (bit 0 = fSimpleList), grfhic.
|
||||
let mut counts = Vec::with_capacity(c_lst);
|
||||
for i in 0..c_lst {
|
||||
let f = at.saturating_add(2).saturating_add(i.saturating_mul(28));
|
||||
let lsid = table.get(f..f.saturating_add(4)).map(|b| i32::from_le_bytes([b[0], b[1], b[2], b[3]])).unwrap_or(-1);
|
||||
let simple = table.get(f.saturating_add(26)).copied().unwrap_or(0) & 1 != 0;
|
||||
defs.push(ListDef { lsid, levels: Vec::new() });
|
||||
counts.push(if simple { 1 } else { 9 });
|
||||
}
|
||||
let mut off = at.saturating_add(2).saturating_add(c_lst.saturating_mul(28));
|
||||
for (def, n) in defs.iter_mut().zip(counts) {
|
||||
for _ in 0..n {
|
||||
match parse_lvl(table, off) {
|
||||
Some((lvl, len)) => {
|
||||
def.levels.push(lvl);
|
||||
off = off.saturating_add(len);
|
||||
}
|
||||
None => return defs,
|
||||
}
|
||||
}
|
||||
}
|
||||
defs
|
||||
}
|
||||
|
||||
/// One LVL ([MS-DOC] §2.9.182): LVLF (28 bytes) + grpprlPapx + grpprlChpx + Xst.
|
||||
/// Returns the level and its total length in bytes.
|
||||
fn parse_lvl(table: &[u8], at: usize) -> Option<(RawLvl, usize)> {
|
||||
let lvlf = table.get(at..at.saturating_add(28))?;
|
||||
let start = u32::from_le_bytes([lvlf[0], lvlf[1], lvlf[2], lvlf[3]]);
|
||||
let nfc = lvlf[4];
|
||||
// Byte 5 packs jc (bits 0-1), fLegal (bit 2), fNoRestart (bit 3), …
|
||||
let jc_flags = lvlf[5];
|
||||
let mut rgbxch_nums = [0u8; 9];
|
||||
rgbxch_nums.copy_from_slice(&lvlf[6..15]);
|
||||
let ixch_follow = lvlf[15];
|
||||
let cb_chpx = lvlf[24] as usize;
|
||||
let cb_papx = lvlf[25] as usize;
|
||||
let mut at = at.saturating_add(28);
|
||||
// Field order in an LVL: lvlf, grpprlPapx, grpprlChpx, then the Xst.
|
||||
let papx = table.get(at..at.saturating_add(cb_papx)).unwrap_or(&[]).to_vec();
|
||||
at = at.saturating_add(cb_papx);
|
||||
let chpx = table.get(at..at.saturating_add(cb_chpx)).unwrap_or(&[]).to_vec();
|
||||
at = at.saturating_add(cb_chpx);
|
||||
let cch = table.get(at..at.saturating_add(2)).map(|b| u16::from_le_bytes([b[0], b[1]])).unwrap_or(0);
|
||||
let cch = cch.min(64) as usize;
|
||||
let chars = table.get(at.saturating_add(2)..at.saturating_add(2).saturating_add(cch * 2))?;
|
||||
let xst: Vec<u16> = chars.as_chunks::<2>().0.iter().map(|b| u16::from_le_bytes([b[0], b[1]])).collect();
|
||||
let len = 28 + cb_chpx + cb_papx + 2 + cch * 2;
|
||||
Some((RawLvl { start: start.min(0x7FFF), nfc, jc: jc_flags & 3, flags: jc_flags, ixch_follow, rgbxch_nums, papx, chpx, xst }, len))
|
||||
}
|
||||
|
||||
/// `PlfLfo`: lfoMac u32, then lfoMac × LFO (16 bytes), then lfoMac × LFOData in parallel.
|
||||
/// Each LFOData is a CP (skipped) followed by clfolvl × LFOLVL (8 bytes + optional LVL).
|
||||
fn parse_plf_lfo(table: &[u8], fc: u32, defs: &[ListDef], numbering: &mut Numbering, fonts: &[String]) {
|
||||
let at = fc as usize;
|
||||
let Some(lfo_mac) = table.get(at..at.saturating_add(4)).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])) else { return };
|
||||
let lfo_mac = lfo_mac.min(MAX_LISTS as u32) as usize;
|
||||
let mut lsids = Vec::with_capacity(lfo_mac);
|
||||
let mut clfolvls = Vec::with_capacity(lfo_mac);
|
||||
for i in 0..lfo_mac {
|
||||
let f = at.saturating_add(4).saturating_add(i.saturating_mul(16));
|
||||
let lsid = table.get(f..f.saturating_add(4)).map(|b| i32::from_le_bytes([b[0], b[1], b[2], b[3]])).unwrap_or(-1);
|
||||
let clfolvl = table.get(f.saturating_add(12)).copied().unwrap_or(0);
|
||||
lsids.push(lsid);
|
||||
clfolvls.push(clfolvl);
|
||||
}
|
||||
let mut off = at.saturating_add(4).saturating_add(lfo_mac.saturating_mul(16));
|
||||
for (i, (&lsid, &clfolvl)) in lsids.iter().zip(&clfolvls).enumerate() {
|
||||
let Some(def) = defs.iter().position(|d| d.lsid == lsid) else {
|
||||
// Keep the LFOData walk in step even for unknown lists.
|
||||
off = skip_lfodatas(table, off, clfolvl);
|
||||
continue;
|
||||
};
|
||||
// LFOData: cp u32, then clfolvl × LFOLVL.
|
||||
let mut start_overrides = Vec::new();
|
||||
let mut overrides = Vec::new();
|
||||
off = off.saturating_add(4);
|
||||
for _ in 0..(clfolvl as usize).min(MAX_LFO_LVL) {
|
||||
let Some(hdr) = table.get(off..off.saturating_add(8)) else { break };
|
||||
let i_start = u32::from_le_bytes([hdr[0], hdr[1], hdr[2], hdr[3]]);
|
||||
let flags = hdr[4];
|
||||
let lvl = (flags & 0x0F) as usize;
|
||||
let f_start = flags & 0x10 != 0;
|
||||
let f_fmt = flags & 0x20 != 0;
|
||||
off = off.saturating_add(8);
|
||||
let mut fmt_lvl = None;
|
||||
if f_fmt {
|
||||
match parse_lvl(table, off) {
|
||||
Some((l, len)) => {
|
||||
fmt_lvl = Some(l);
|
||||
off = off.saturating_add(len);
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
if f_start {
|
||||
start_overrides.push((lvl as u8, i_start.min(0x7FFF)));
|
||||
}
|
||||
if let Some(l) = fmt_lvl {
|
||||
overrides.push((lvl, l));
|
||||
}
|
||||
}
|
||||
// Skip any LFOLVLs we did not consume (clfolvl above the cap).
|
||||
if clfolvl as usize > MAX_LFO_LVL {
|
||||
off = skip_lfodatas(table, off, clfolvl - MAX_LFO_LVL as u8);
|
||||
}
|
||||
let abstract_id = if overrides.is_empty() {
|
||||
def as u32
|
||||
} else {
|
||||
// A formatting override replaces whole levels: give the LFO its own abstract
|
||||
// built from the base levels with the overrides applied.
|
||||
let mut levels = numbering.abstracts.get(def).map(|a| a.levels.clone()).unwrap_or_default();
|
||||
for (lvl, raw) in overrides {
|
||||
if let Some(slot) = levels.get_mut(lvl) {
|
||||
*slot = level_of(&raw, lvl, fonts);
|
||||
}
|
||||
}
|
||||
let id = numbering.abstracts.len() as u32;
|
||||
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() });
|
||||
}
|
||||
}
|
||||
|
||||
/// Advance past `n` LFOLVLs whose bodies we do not need; bounded and best-effort.
|
||||
fn skip_lfodatas(table: &[u8], mut off: usize, n: u8) -> usize {
|
||||
for _ in 0..n {
|
||||
let Some(hdr) = table.get(off..off.saturating_add(8)) else { return off };
|
||||
let f_fmt = hdr[4] & 0x20 != 0;
|
||||
off = off.saturating_add(8);
|
||||
if f_fmt && let Some((_, len)) = parse_lvl(table, off) {
|
||||
off = off.saturating_add(len);
|
||||
}
|
||||
}
|
||||
off
|
||||
}
|
||||
|
||||
/// Turn a stored LVL into a model level.
|
||||
fn level_of(l: &RawLvl, lvl_index: usize, fonts: &[String]) -> Level {
|
||||
// Number text: characters listed in rgbxchNums are level placeholders (their value is
|
||||
// the zero-based level); bullet chars carry 0xF000 flag bits to strip.
|
||||
let mut text = String::new();
|
||||
for (i, &c) in l.xst.iter().enumerate() {
|
||||
if l.rgbxch_nums.contains(&(i as u8 + 1)) && c <= 8 {
|
||||
text.push('%');
|
||||
text.push((b'1' + c as u8) as char);
|
||||
} else {
|
||||
text.push(bullet_char(c));
|
||||
}
|
||||
}
|
||||
if text.is_empty() {
|
||||
text.push('\u{2022}');
|
||||
}
|
||||
// Indents from the level's grpprlPapx.
|
||||
let mut props = ParaProps::default();
|
||||
for prl in sprm::iter(&l.papx) {
|
||||
if prl.sgc() == 1 {
|
||||
fmt::apply_para(&mut props, &prl);
|
||||
}
|
||||
}
|
||||
let indent = props.indent_left.unwrap_or(18.0 * (lvl_index as f32 + 1.0)).clamp(0.0, 1000.0);
|
||||
let hanging = props.indent_first.map(|f| (-f).clamp(0.0, 1000.0)).unwrap_or(18.0);
|
||||
let mut chr = CharProps::default();
|
||||
let base = chr.clone();
|
||||
for prl in sprm::iter(&l.chpx) {
|
||||
if prl.sgc() == 2 {
|
||||
fmt::apply_char(&mut chr, &prl, &base, fonts);
|
||||
}
|
||||
}
|
||||
Level {
|
||||
start: l.start.max(1),
|
||||
format: num_format(l.nfc),
|
||||
text,
|
||||
align: match l.jc {
|
||||
1 => Align::Center,
|
||||
2 => Align::Right,
|
||||
_ => Align::Left,
|
||||
},
|
||||
indent,
|
||||
hanging,
|
||||
suffix: match l.ixch_follow {
|
||||
1 => LevelSuffix::Space,
|
||||
2 => LevelSuffix::Nothing,
|
||||
_ => LevelSuffix::Tab,
|
||||
},
|
||||
chr,
|
||||
restart: l.flags & 0x08 == 0,
|
||||
legal: l.flags & 0x04 != 0,
|
||||
style: None,
|
||||
..Level::default()
|
||||
}
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
//! Inline pictures: `sprmCPicLocation` → a `PICF` in the Data stream → embedded image bytes
|
||||
//! ([MS-DOC] §2.9.190 PICF, §1.3.5 Pictures), and the shared store of every picture the
|
||||
//! document reads (inline and floating).
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::ops::Range;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::officeart;
|
||||
|
||||
/// `PICF.cbHeader` — the fixed size of the PICF structure.
|
||||
const CB_HEADER: usize = 0x44;
|
||||
/// Offsets inside PICF: `PICMID.dxaGoal/dyaGoal/mx/my` start at 28.
|
||||
const OFF_PICMID: usize = 28;
|
||||
|
||||
/// Most distinct pictures one document materialises from the Data stream.
|
||||
const MAX_PICTURES: usize = 5_000;
|
||||
/// Most picture bytes one document copies out of the Data stream, all pictures together.
|
||||
pub(crate) const MAX_MEDIA_BYTES: usize = 256 << 20;
|
||||
/// Most bytes one document decodes pictures into (metafiles inflated, DIB pixels), charged
|
||||
/// per attempt whether it succeeds or not: retained media is capped separately, by
|
||||
/// `MAX_MEDIA_BYTES` (#3711, #3712).
|
||||
const MAX_DECODE_WORK: usize = 512 << 20;
|
||||
/// The decode budget grows with the input: up to this many bytes per byte of the WordDocument
|
||||
/// and Data streams. An uncompressed DIB declares at most 32 bytes of pixels per file byte (1
|
||||
/// bit per pixel, 4 bytes charged each), so 64 passes any such picture. RLE8/RLE4 bitmaps
|
||||
/// reach about 510 times that and rely on the `MAX_DECODE_WORK` floor instead; a decompression
|
||||
/// bomb (ratios around 1e6) still hits the cap.
|
||||
const DECODE_WORK_PER_INPUT_BYTE: usize = 64;
|
||||
/// The most the decode budget grows to, whatever the input.
|
||||
const MAX_DECODE_WORK_CEILING: usize = 2 << 30;
|
||||
|
||||
/// The decode budget for a document whose pictures live in `input` stream bytes, never less
|
||||
/// than `floor`.
|
||||
pub(crate) fn decode_work(input: usize, floor: usize) -> usize {
|
||||
input.saturating_mul(DECODE_WORK_PER_INPUT_BYTE).clamp(floor, floor.max(MAX_DECODE_WORK_CEILING))
|
||||
}
|
||||
|
||||
/// The document's pictures, keyed by their location in the Data stream: anchors that share a
|
||||
/// `fc_pic` share one media entry (or one failure), and the total bytes copied are capped, so a
|
||||
/// 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 (`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>>,
|
||||
/// (stream, foDelay, record length) of a BLIP record outside the store → media key: store
|
||||
/// entries and inline pictures that alias one record share one decode (`None`: failed, not
|
||||
/// retried). The stream is told apart by its address.
|
||||
by_delay: HashMap<(usize, usize, usize), Option<String>>,
|
||||
/// The media entries, merged into the document at the end.
|
||||
pub(crate) media: BTreeMap<String, Arc<Vec<u8>>>,
|
||||
total: usize,
|
||||
/// The byte cap (`MAX_MEDIA_BYTES`; smaller in tests).
|
||||
pub(crate) limit: usize,
|
||||
/// Decode work left (`MAX_DECODE_WORK`; smaller in tests).
|
||||
pub(crate) work: officeart::Decode,
|
||||
/// Anchors whose picture was dropped (only the first few are logged).
|
||||
dropped: usize,
|
||||
/// Pictures kept as placeholders (formats we can't draw).
|
||||
placeholders: usize,
|
||||
}
|
||||
|
||||
impl Default for Pictures {
|
||||
fn default() -> Self {
|
||||
Pictures {
|
||||
by_fc: HashMap::new(),
|
||||
by_bse: HashMap::new(),
|
||||
by_delay: HashMap::new(),
|
||||
media: BTreeMap::new(),
|
||||
total: 0,
|
||||
limit: MAX_MEDIA_BYTES,
|
||||
work: officeart::Decode::new(MAX_DECODE_WORK),
|
||||
dropped: 0,
|
||||
placeholders: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An inline picture: its media key, display size (points), crop fractions and alt text.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Inline {
|
||||
pub(crate) key: String,
|
||||
pub(crate) w: f32,
|
||||
pub(crate) h: f32,
|
||||
pub(crate) crop: [f32; 4],
|
||||
pub(crate) alt: String,
|
||||
}
|
||||
|
||||
impl Pictures {
|
||||
/// An empty store whose decode budget scales with the `input` stream bytes (WordDocument
|
||||
/// plus Data) the pictures can come from.
|
||||
pub(crate) fn for_input(input: usize) -> Self {
|
||||
Pictures { work: officeart::Decode::new(decode_work(input, MAX_DECODE_WORK)), ..Pictures::default() }
|
||||
}
|
||||
|
||||
/// The inline picture at `fc_pic` in the Data stream, copying its bytes the first time.
|
||||
/// `None` when the header is malformed, the picture can't be read, or a picture
|
||||
/// count/byte cap is reached.
|
||||
pub(crate) fn get(&mut self, data: &[u8], fc_pic: u32) -> Option<&Inline> {
|
||||
if !self.by_fc.contains_key(&fc_pic) {
|
||||
if self.by_fc.len().saturating_add(self.by_bse.len()) >= MAX_PICTURES {
|
||||
return None;
|
||||
}
|
||||
let pic = self.read(data, fc_pic);
|
||||
self.by_fc.insert(fc_pic, pic);
|
||||
}
|
||||
self.by_fc.get(&fc_pic)?.as_ref()
|
||||
}
|
||||
|
||||
/// Read the inline picture at `fc_pic`: nothing is decoded or copied beyond what is left of
|
||||
/// 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, self)? {
|
||||
Found::Range(pic) => {
|
||||
let bytes = data.get(pic.range.clone()).filter(|b| b.len() <= room)?.to_vec();
|
||||
let key = self.insert(bytes, pic.ext, false)?;
|
||||
Some(Inline { key, w: pic.w, h: pic.h, crop: [0.0; 4], alt: String::new() })
|
||||
}
|
||||
Found::Shape { w, h, key, props } => Some(Inline { key: key?, w, h, crop: props.crop, alt: props.alt }),
|
||||
}
|
||||
}
|
||||
|
||||
/// The media key of BLIP store entry `pib` (1-based), decoding it the first time; `delay`
|
||||
/// is the stream that holds BLIPs not embedded in the store (the WordDocument stream).
|
||||
pub(crate) fn bse(&mut self, pib: u32, bstore: &[&[u8]], delay: &[u8]) -> Option<String> {
|
||||
if let Some(hit) = self.by_bse.get(&pib) {
|
||||
return hit.clone();
|
||||
}
|
||||
if self.by_fc.len().saturating_add(self.by_bse.len()) >= MAX_PICTURES {
|
||||
return None;
|
||||
}
|
||||
let fbse = bstore.get((pib as usize).checked_sub(1)?)?;
|
||||
let key = self.decode_bse(fbse, delay);
|
||||
self.by_bse.insert(pib, key.clone());
|
||||
key
|
||||
}
|
||||
|
||||
/// Decode a store entry's BLIP, once per delay-stream record however many entries name it.
|
||||
fn decode_bse(&mut self, fbse: &[u8], delay: &[u8]) -> Option<String> {
|
||||
let (rec, at) = officeart::fbse_record(fbse, delay)?;
|
||||
let at = at.map(|(fo, used)| (delay.as_ptr() as usize, fo, used));
|
||||
if let Some(hit) = at.and_then(|at| self.by_delay.get(&at)) {
|
||||
return hit.clone();
|
||||
}
|
||||
let room = self.limit.saturating_sub(self.total);
|
||||
let key = officeart::blip(&rec, room, &mut self.work).and_then(|b| self.insert(b.bytes, b.ext, b.placeholder));
|
||||
if let Some(at) = at {
|
||||
self.by_delay.insert(at, key.clone());
|
||||
}
|
||||
key
|
||||
}
|
||||
|
||||
/// Add picture bytes as a new media entry, within the byte cap.
|
||||
fn insert(&mut self, bytes: Vec<u8>, ext: &str, placeholder: bool) -> Option<String> {
|
||||
let total = self.total.checked_add(bytes.len()).filter(|&t| t <= self.limit)?;
|
||||
let key = format!("image{}.{ext}", self.media.len() + 1);
|
||||
if placeholder {
|
||||
if self.placeholders < 10 {
|
||||
log::warn!("docbin: picture `{key}` is in a format WordCraft can't draw ({ext}); it shows as a placeholder");
|
||||
}
|
||||
self.placeholders = self.placeholders.saturating_add(1);
|
||||
}
|
||||
self.media.insert(key.clone(), Arc::new(bytes));
|
||||
self.total = total;
|
||||
Some(key)
|
||||
}
|
||||
|
||||
/// Note a picture anchor that could not be materialised.
|
||||
pub(crate) fn dropped(&mut self, cp: u32) {
|
||||
if self.dropped < 10 {
|
||||
log::warn!("docbin: picture at CP {cp} could not be read (or a picture cap was reached) and was dropped");
|
||||
}
|
||||
self.dropped = self.dropped.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a picture's image bytes sit in the Data stream, and its display size in points.
|
||||
struct Located {
|
||||
w: f32,
|
||||
h: f32,
|
||||
range: Range<usize>,
|
||||
ext: &'static str,
|
||||
}
|
||||
|
||||
/// What a `PICFAndOfficeArtData` holds: raw image bytes (older writers), or an
|
||||
/// `OfficeArtInlineSpContainer` (Word 97 and later) whose BLIP is decoded.
|
||||
enum Found {
|
||||
Range(Located),
|
||||
Shape { w: f32, h: f32, key: Option<String>, props: officeart::Props },
|
||||
}
|
||||
|
||||
/// `PICF.mfpf.mm` values of pictures stored as a shape (`MM_SHAPE`) or a shape with a file
|
||||
/// name (`MM_SHAPEFILE`).
|
||||
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);
|
||||
/// the BLIP is decoded once per record and charged to the decode budget.
|
||||
fn locate(data: &[u8], fc_pic: u32, pics: &mut Pictures) -> Option<Found> {
|
||||
let base = fc_pic as usize;
|
||||
let pic = data.get(base..)?;
|
||||
let lcb = u32::from_le_bytes(pic.get(0..4)?.try_into().ok()?) as usize;
|
||||
let cb_header = u16::from_le_bytes(pic.get(4..6)?.try_into().ok()?) as usize;
|
||||
let cb_header = if (0x20..=0x100).contains(&cb_header) { cb_header } else { CB_HEADER };
|
||||
let mm = u16::from_le_bytes(pic.get(6..8)?.try_into().ok()?);
|
||||
let m = pic.get(OFF_PICMID..OFF_PICMID + 8)?;
|
||||
let dxa_goal = i16::from_le_bytes([m[0], m[1]]) as f32;
|
||||
let dya_goal = i16::from_le_bytes([m[2], m[3]]) as f32;
|
||||
let mx = u16::from_le_bytes([m[4], m[5]]) as f32;
|
||||
let my = u16::from_le_bytes([m[6], m[7]]) as f32;
|
||||
let scale = |r: f32| if r > 0.0 { (r / 1000.0).clamp(0.01, 100.0) } else { 1.0 };
|
||||
let end = lcb.min(pic.len());
|
||||
if mm == MM_SHAPE || mm == MM_SHAPEFILE {
|
||||
let mut at = cb_header;
|
||||
if mm == MM_SHAPEFILE {
|
||||
at = at.checked_add(1 + *pic.get(at)? as usize)?;
|
||||
}
|
||||
let (key, props) = inline_shape(pic.get(at..end)?, data, pics)?;
|
||||
// The goal size is before cropping; the crop fractions take their share off it.
|
||||
let [cl, ct, cr, cb] = props.crop;
|
||||
let w = (dxa_goal / 20.0 * (1.0 - cl - cr).max(0.01) * scale(mx)).clamp(1.0, 1584.0);
|
||||
let h = (dya_goal / 20.0 * (1.0 - ct - cb).max(0.01) * scale(my)).clamp(1.0, 1584.0);
|
||||
return Some(Found::Shape { w, h, key, props });
|
||||
}
|
||||
let w = (dxa_goal / 20.0 * scale(mx)).clamp(1.0, 1584.0);
|
||||
let h = (dya_goal / 20.0 * scale(my)).clamp(1.0, 1584.0);
|
||||
let body = pic.get(cb_header..end)?;
|
||||
let at = sniff(body)?;
|
||||
let image = body.get(at..)?;
|
||||
let start = base.checked_add(cb_header)?.checked_add(at)?;
|
||||
Some(Found::Range(Located { w, h, range: start..start.checked_add(image.len())?, ext: ext_of(image) }))
|
||||
}
|
||||
|
||||
/// An `OfficeArtInlineSpContainer`: the picture shape, then the `OfficeArtFBSE` records its
|
||||
/// `pib` indexes (BLIPs embedded, or at `foDelay` in the Data stream). The BLIP is decoded
|
||||
/// once per record, however many pictures (and FBSE sizes) name it.
|
||||
fn inline_shape(buf: &[u8], data: &[u8], pics: &mut Pictures) -> Option<(Option<String>, officeart::Props)> {
|
||||
let mut budget = 10_000usize;
|
||||
let recs = officeart::children(buf, &mut budget);
|
||||
let sp = recs.iter().find(|r| r.typ == officeart::rt::SP_CONTAINER).map(|r| officeart::shape(r.body, &mut budget))?;
|
||||
let fbses: Vec<&[u8]> = recs.iter().filter(|r| r.typ == officeart::rt::FBSE).map(|r| r.body).collect();
|
||||
let fbse = sp.props.pib.and_then(|p| fbses.get((p as usize).checked_sub(1)?)).or(fbses.first())?;
|
||||
let key = pics.decode_bse(fbse, data);
|
||||
Some((key, sp.props))
|
||||
}
|
||||
|
||||
/// Offset of the first embedded raster image inside the payload, skipping the small
|
||||
/// metafile header some writers put in front of PNG/JPEG data. `None` for metafiles.
|
||||
fn sniff(body: &[u8]) -> Option<usize> {
|
||||
for at in [0usize, 8, 12, 16, 20, 22, 24, 32] {
|
||||
if let Some(b) = body.get(at..)
|
||||
&& (b.starts_with(b"\x89PNG") || b.starts_with(b"\xFF\xD8\xFF") || b.starts_with(b"GIF8") || b.starts_with(b"BM"))
|
||||
{
|
||||
return Some(at);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn ext_of(b: &[u8]) -> &'static str {
|
||||
if b.starts_with(b"\x89PNG") {
|
||||
"png"
|
||||
} else if b.starts_with(b"\xFF\xD8\xFF") {
|
||||
"jpeg"
|
||||
} else if b.starts_with(b"GIF8") {
|
||||
"gif"
|
||||
} else {
|
||||
"bmp"
|
||||
}
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
//! 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()
|
||||
})
|
||||
}
|
||||
|
||||
/// A `VT_FILETIME` property value as an ISO-8601 UTC string (`YYYY-MM-DDTHH:MM:SSZ`), or `None`
|
||||
/// if it is not a FILETIME, is zero, or predates the 1601 FILETIME epoch.
|
||||
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 = i64::try_from(unix_100ns / 10_000_000).ok()?;
|
||||
let (days, sod) = (unix_secs.div_euclid(86_400), unix_secs.rem_euclid(86_400));
|
||||
let (h, mi, s) = (sod / 3600, sod / 60 % 60, sod % 60);
|
||||
let (y, m, d) = civil(days);
|
||||
// `{y:04}` rather than `%Y`, which writes a `+` before years past 9999.
|
||||
Some(format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z"))
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 → proleptic Gregorian (year, month, day).
|
||||
///
|
||||
/// A FILETIME can land past `NaiveDate`'s ±262143-year range, where the old hand-rolled converter
|
||||
/// still produced a date; dropping to `None` there would change the output. The Gregorian calendar
|
||||
/// repeats every 400 years (146_097 days), so chrono converts the day within one cycle and whole
|
||||
/// cycles go back into the year (an `i128` so the extremes can't overflow), keeping chrono as the
|
||||
/// only calendar logic and reproducing the old result exactly. 1970-01-01 is `num_days_from_ce` 719_163.
|
||||
fn civil(days: i64) -> (i64, u32, u32) {
|
||||
use chrono::Datelike;
|
||||
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
|
||||
// `rest + 719_163` is 719_163..865_259, always a valid CE day.
|
||||
match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
|
||||
Some(d) => {
|
||||
let y = i128::from(d.year()) + i128::from(cycles) * 400;
|
||||
(i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.month(), d.day())
|
||||
}
|
||||
None => (1970, 1, 1), // unreachable
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::filetime_iso;
|
||||
|
||||
/// A `FILETIME` property (VT_FILETIME = 64) as `YYYY-MM-DDTHH:MM:SSZ`. The values are pinned
|
||||
/// to what the old hand-rolled `civil` produced.
|
||||
fn value(ft: u64) -> Vec<u8> {
|
||||
let mut v = 64u32.to_le_bytes().to_vec();
|
||||
v.extend_from_slice(&((ft & 0xffff_ffff) as u32).to_le_bytes());
|
||||
v.extend_from_slice(&((ft >> 32) as u32).to_le_bytes());
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filetime_to_iso() {
|
||||
// 2020-01-01T00:00:00Z and the same day at 13:45:09.
|
||||
assert_eq!(filetime_iso(&value(132_223_104_000_000_000)).as_deref(), Some("2020-01-01T00:00:00Z"));
|
||||
assert_eq!(filetime_iso(&value(132_223_599_090_000_000)).as_deref(), Some("2020-01-01T13:45:09Z"));
|
||||
// Year 10000 keeps the plain zero-padded year (no leading `+` that chrono's `%Y` would add).
|
||||
assert_eq!(filetime_iso(&value(2_650_467_744_000_000_000)).as_deref(), Some("10000-01-01T00:00:00Z"));
|
||||
// The largest FILETIME (u64::MAX) lands in year 60056: past year 9999 (so the plain year
|
||||
// matters) and the old `civil` converted it without ever dropping to the epoch. chrono's
|
||||
// own range (year 262143) isn't reachable here — a u64 FILETIME caps at unix seconds
|
||||
// ~1.83e12 — but the 400-year cycle keeps the result exact regardless.
|
||||
assert_eq!(filetime_iso(&value(u64::MAX)).as_deref(), Some("60056-05-28T05:36:10Z"));
|
||||
// Zero, a non-FILETIME kind and times before the 1601 epoch are rejected, not a panic.
|
||||
assert_eq!(filetime_iso(&value(0)), None);
|
||||
assert_eq!(filetime_iso(&[2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), None);
|
||||
assert_eq!(filetime_iso(&value(1)), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
//! Bounded conversion from the shared Word model to WordCraft's app model.
|
||||
//!
|
||||
//! The model crates intentionally have independent Rust types. Convert document stories a
|
||||
//! paragraph at a time, recursively through tables, so a large document never needs a second
|
||||
//! serialized copy of its complete body in memory. Embedded media and opaque streams are moved
|
||||
//! directly because both models use the same `Arc<Vec<u8>>` representation.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
use crate::DocbinError;
|
||||
|
||||
fn convert<T, U>(value: T) -> Result<U, DocbinError>
|
||||
where
|
||||
T: Serialize,
|
||||
U: DeserializeOwned,
|
||||
{
|
||||
let bytes = serde_json::to_vec(&value).map_err(|error| DocbinError::Malformed(format!("shared model conversion: {error}")))?;
|
||||
drop(value);
|
||||
serde_json::from_slice(&bytes).map_err(|error| DocbinError::Malformed(format!("shared model conversion: {error}")))
|
||||
}
|
||||
|
||||
fn blocks(source: craft_wordmodel::Blocks) -> Result<wordcraft_doc::Blocks, DocbinError> {
|
||||
source
|
||||
.into_iter()
|
||||
.map(|block| {
|
||||
let block = Arc::try_unwrap(block).unwrap_or_else(|shared| (*shared).clone());
|
||||
block_value(block).map(Arc::new)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn block_value(source: craft_wordmodel::Block) -> Result<wordcraft_doc::Block, DocbinError> {
|
||||
match source {
|
||||
craft_wordmodel::Block::Para(paragraph) => paragraph_value(paragraph).map(wordcraft_doc::Block::Para),
|
||||
craft_wordmodel::Block::Table(table) => table_value(table).map(wordcraft_doc::Block::Table),
|
||||
}
|
||||
}
|
||||
|
||||
fn paragraph_value(source: craft_wordmodel::para::Paragraph) -> Result<wordcraft_doc::para::Paragraph, DocbinError> {
|
||||
let craft_wordmodel::para::Paragraph { text, runs, props, mark, objects, section, .. } = source;
|
||||
let mut paragraph = wordcraft_doc::para::Paragraph::new();
|
||||
paragraph.text = text;
|
||||
paragraph.runs =
|
||||
runs.into_iter().map(|run| Ok(wordcraft_doc::para::Run { len: run.len, props: convert(run.props)? })).collect::<Result<_, DocbinError>>()?;
|
||||
paragraph.props = convert(props)?;
|
||||
paragraph.mark = convert(mark)?;
|
||||
paragraph.objects = objects.into_iter().map(object_value).collect::<Result<_, _>>()?;
|
||||
paragraph.section = section.map(|section| convert(*section).map(Box::new)).transpose()?;
|
||||
Ok(paragraph)
|
||||
}
|
||||
|
||||
fn object_value(source: craft_wordmodel::para::InlineObject) -> Result<wordcraft_doc::para::InlineObject, DocbinError> {
|
||||
use craft_wordmodel::para::InlineObject as Source;
|
||||
use wordcraft_doc::para::InlineObject as Target;
|
||||
|
||||
match source {
|
||||
Source::Group { w, h, float, ch_w, ch_h, children, canvas } => Ok(Target::Group {
|
||||
w,
|
||||
h,
|
||||
float: convert(float)?,
|
||||
ch_w,
|
||||
ch_h,
|
||||
children: children.into_iter().map(group_child_value).collect::<Result<_, _>>()?,
|
||||
canvas: canvas.map(convert).transpose()?,
|
||||
}),
|
||||
other => convert(other),
|
||||
}
|
||||
}
|
||||
|
||||
fn group_child_value(source: craft_wordmodel::para::GroupChild) -> Result<wordcraft_doc::para::GroupChild, DocbinError> {
|
||||
Ok(wordcraft_doc::para::GroupChild { x: source.x, y: source.y, obj: object_value(source.obj)? })
|
||||
}
|
||||
|
||||
fn table_value(source: craft_wordmodel::table::Table) -> Result<wordcraft_doc::table::Table, DocbinError> {
|
||||
Ok(wordcraft_doc::table::Table {
|
||||
props: convert(source.props)?,
|
||||
grid: source.grid,
|
||||
rows: source.rows.into_iter().map(row_value).collect::<Result<_, _>>()?,
|
||||
controls: convert(source.controls)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn row_value(source: craft_wordmodel::table::Row) -> Result<wordcraft_doc::table::Row, DocbinError> {
|
||||
Ok(wordcraft_doc::table::Row {
|
||||
props: convert(source.props)?,
|
||||
cells: source.cells.into_iter().map(cell_value).collect::<Result<_, _>>()?,
|
||||
controls: convert(source.controls)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn cell_value(source: craft_wordmodel::table::Cell) -> Result<wordcraft_doc::table::Cell, DocbinError> {
|
||||
Ok(wordcraft_doc::table::Cell { props: convert(source.props)?, blocks: blocks(source.blocks)?, controls: convert(source.controls)? })
|
||||
}
|
||||
|
||||
fn part_value(source: craft_wordmodel::Part) -> Result<wordcraft_doc::Part, DocbinError> {
|
||||
Ok(wordcraft_doc::Part { kind: convert(source.kind)?, blocks: blocks(source.blocks)?, body: convert(source.body)? })
|
||||
}
|
||||
|
||||
fn glossary_value(source: craft_wordmodel::BuildingBlock) -> Result<wordcraft_doc::BuildingBlock, DocbinError> {
|
||||
Ok(wordcraft_doc::BuildingBlock {
|
||||
name: source.name,
|
||||
gallery: source.gallery,
|
||||
category: source.category,
|
||||
description: source.description,
|
||||
guid: source.guid,
|
||||
style: source.style,
|
||||
types: source.types,
|
||||
behaviors: source.behaviors,
|
||||
blocks: blocks(source.blocks)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Converts the shared reader's model without copying the full document or its embedded media.
|
||||
///
|
||||
/// The DOC reader leaves WordCraft-only transient fit state, editor fragments, and style change
|
||||
/// annotations at their defaults; the persistent document fields are transferred here. Body and
|
||||
/// table-cell stories are mapped one paragraph at a time, while shared media buffers are moved.
|
||||
pub fn from_shared_model(source: craft_wordmodel::Document) -> Result<wordcraft_doc::Document, DocbinError> {
|
||||
let craft_wordmodel::Document {
|
||||
body,
|
||||
last_section,
|
||||
parts,
|
||||
styles,
|
||||
numbering,
|
||||
comments,
|
||||
revisions,
|
||||
settings,
|
||||
core,
|
||||
sources,
|
||||
custom_props,
|
||||
footnote_separators,
|
||||
endnote_separators,
|
||||
media,
|
||||
passthrough,
|
||||
glossary,
|
||||
..
|
||||
} = source;
|
||||
|
||||
let mut document = wordcraft_doc::Document::new();
|
||||
document.body = blocks(body)?;
|
||||
document.last_section = convert(last_section)?;
|
||||
document.parts = parts.into_iter().map(|(id, part)| Ok((id, part_value(part)?))).collect::<Result<_, DocbinError>>()?;
|
||||
document.styles = Arc::new(convert((*styles).clone())?);
|
||||
document.numbering = Arc::new(convert((*numbering).clone())?);
|
||||
document.comments = convert(comments)?;
|
||||
document.revisions = convert(revisions)?;
|
||||
document.settings = convert(settings)?;
|
||||
document.core = convert(core)?;
|
||||
document.sources = convert(sources)?;
|
||||
document.custom_props = convert(custom_props)?;
|
||||
document.footnote_separators = convert(footnote_separators)?;
|
||||
document.endnote_separators = convert(endnote_separators)?;
|
||||
document.media = media;
|
||||
document.passthrough = passthrough;
|
||||
document.glossary = glossary.into_iter().map(glossary_value).collect::<Result<_, _>>()?;
|
||||
Ok(document)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bridges_nested_stories_and_moves_media_without_copying() {
|
||||
let mut source = craft_wordmodel::Document::new();
|
||||
let long_text = "x".repeat(4 << 20);
|
||||
let mut body_para = craft_wordmodel::para::Paragraph::with_text(&long_text, Default::default());
|
||||
body_para.objects.push(craft_wordmodel::para::InlineObject::Group {
|
||||
w: 32.0,
|
||||
h: 24.0,
|
||||
float: Default::default(),
|
||||
ch_w: 64.0,
|
||||
ch_h: 48.0,
|
||||
children: vec![craft_wordmodel::para::GroupChild {
|
||||
x: 4.0,
|
||||
y: 6.0,
|
||||
obj: craft_wordmodel::para::InlineObject::Field {
|
||||
instr: "PAGE".into(),
|
||||
result: "7".into(),
|
||||
locked: true,
|
||||
dirty: false,
|
||||
code: false,
|
||||
form: String::new(),
|
||||
},
|
||||
}],
|
||||
canvas: None,
|
||||
});
|
||||
let cell = craft_wordmodel::table::Cell::with_text("nested cell");
|
||||
let table = craft_wordmodel::table::Table {
|
||||
rows: vec![craft_wordmodel::table::Row { cells: vec![cell], ..Default::default() }],
|
||||
..Default::default()
|
||||
};
|
||||
source.body = vec![Arc::new(craft_wordmodel::Block::Para(body_para)), Arc::new(craft_wordmodel::Block::Table(table))];
|
||||
source.parts.insert(
|
||||
12,
|
||||
craft_wordmodel::Part {
|
||||
kind: craft_wordmodel::PartKind::TextBox,
|
||||
blocks: vec![craft_wordmodel::para_block(craft_wordmodel::para::Paragraph::with_text("part story", Default::default()))],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
source.settings.compat_mode = 11;
|
||||
let image = Arc::new(vec![0x5a; 1 << 20]);
|
||||
source.media.insert("image1.png".into(), image.clone());
|
||||
|
||||
let document = from_shared_model(source).expect("model conversion");
|
||||
assert_eq!(document.body.len(), 2);
|
||||
assert_eq!(document.body[0].as_para().map(|p| p.text.as_str()), Some(long_text.as_str()));
|
||||
let group = match &document.body[0].as_para().expect("paragraph").objects[0] {
|
||||
wordcraft_doc::para::InlineObject::Group { children, ch_w, .. } => {
|
||||
assert_eq!(*ch_w, 64.0);
|
||||
children
|
||||
}
|
||||
other => panic!("expected grouped object, got {other:?}"),
|
||||
};
|
||||
assert_eq!((group[0].x, group[0].y), (4.0, 6.0));
|
||||
assert!(
|
||||
matches!(&group[0].obj, wordcraft_doc::para::InlineObject::Field { instr, result, locked: true, .. } if instr == "PAGE" && result == "7")
|
||||
);
|
||||
let table = document.body[1].as_table().expect("table block");
|
||||
assert_eq!(table.rows[0].cells[0].blocks[0].as_para().map(|p| p.text.as_str()), Some("nested cell"));
|
||||
assert_eq!(document.parts[&12].blocks[0].as_para().map(|p| p.text.as_str()), Some("part story"));
|
||||
assert_eq!(document.settings.compat_mode, 11);
|
||||
assert!(Arc::ptr_eq(document.media.get("image1.png").expect("moved media"), &image));
|
||||
assert!(document.fit_state.is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
//! Footnotes, endnotes and bookmarks: `PlcffndRef`/`PlcffndTxt`, `PlcfendRef`/`PlcfendTxt`,
|
||||
//! and `SttbfBkmk` + `Plcfbkf`/`Plcfbkl` ([MS-DOC] §2.9).
|
||||
|
||||
/// Cap on notes and bookmarks per document.
|
||||
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();
|
||||
if txts.len() < 2 {
|
||||
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))
|
||||
&& b > a
|
||||
&& a >= kept_to
|
||||
{
|
||||
out.push((cp, (a, b)));
|
||||
kept_to = b;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The CPs of a PLC (the trailing element is a limit and is kept so callers can pair
|
||||
/// range ends) plus the data-element size check. Returns the CPs only.
|
||||
fn plc_of(table: &[u8], fc: u32, lcb: u32, data_size: usize) -> Option<Vec<u32>> {
|
||||
if lcb == 0 {
|
||||
return None;
|
||||
}
|
||||
let plc = table.get(fc as usize..(fc as usize).saturating_add(lcb as usize))?;
|
||||
if plc.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
// aCP has one more element than aData: n = (len - data_size) / (4 + data_size).
|
||||
let n = (plc.len() - data_size) / (4 + data_size);
|
||||
if n == 0 || n > MAX {
|
||||
return None;
|
||||
}
|
||||
let mut cps = Vec::with_capacity(n.min(MAX));
|
||||
for i in 0..=n.min(MAX) {
|
||||
match plc.get(i * 4..i * 4 + 4) {
|
||||
Some(b) => cps.push(u32::from_le_bytes([b[0], b[1], b[2], b[3]])),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
Some(cps)
|
||||
}
|
||||
|
||||
/// A bookmark boundary to insert while walking.
|
||||
pub(crate) enum Mark {
|
||||
Start(String),
|
||||
End(String),
|
||||
}
|
||||
|
||||
/// Parse `SttbfBkmk` + `Plcfbkf` + `Plcfbkl` into (cp, event) pairs sorted for a sequential
|
||||
/// walk. Hidden bookmarks (leading `_`) are kept: the engine can decide.
|
||||
pub(crate) fn parse_bookmarks(table: &[u8], fib: &crate::fib::Fib) -> Vec<(u32, Mark)> {
|
||||
let names = fib.pair(crate::fib::pair::STTBF_BKMK).and_then(|(fc, lcb)| sttb_strings(table, fc, lcb)).unwrap_or_default();
|
||||
let starts = fib.pair(crate::fib::pair::PLCF_BKF).and_then(|(fc, lcb)| plc_of(table, fc, lcb, 4)).unwrap_or_default();
|
||||
let ends = fib.pair(crate::fib::pair::PLCF_BKL).and_then(|(fc, lcb)| plc_of(table, fc, lcb, 2)).unwrap_or_default();
|
||||
let n = starts.len().saturating_sub(1).min(ends.len().saturating_sub(1)).min(names.len()).min(MAX);
|
||||
let mut out = Vec::with_capacity(n * 2);
|
||||
for i in 0..n {
|
||||
let (Some(&a), Some(&b)) = (starts.get(i), ends.get(i)) else { break };
|
||||
if b < a {
|
||||
continue;
|
||||
}
|
||||
if let Some(name) = names.get(i) {
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
out.push((a, Mark::Start(name.clone())));
|
||||
out.push((b, Mark::End(name.clone())));
|
||||
}
|
||||
}
|
||||
out.sort_by_key(|(cp, m)| (*cp, matches!(m, Mark::End(_))));
|
||||
out
|
||||
}
|
||||
|
||||
/// Strings of an STTB: extended (fExtend = 0xFFFF, 2-byte length and UTF-16 chars) or not
|
||||
/// (1-byte length and Windows-1252 chars). `cbExtra` bytes after each string are skipped.
|
||||
/// Names are kept to 40 characters, but the reader always advances by the stored length.
|
||||
fn sttb_strings(table: &[u8], fc: u32, lcb: u32) -> Option<Vec<String>> {
|
||||
let b = table.get(fc as usize..(fc as usize).saturating_add(lcb as usize))?;
|
||||
let extended = b.get(0..2)? == [0xFF, 0xFF];
|
||||
let head = if extended { 2 } else { 0 };
|
||||
let h = b.get(head..head + 4)?;
|
||||
let c_data = u16::from_le_bytes([h[0], h[1]]) as usize;
|
||||
let cb_extra = u16::from_le_bytes([h[2], h[3]]) as usize;
|
||||
if c_data > MAX {
|
||||
return None;
|
||||
}
|
||||
let mut at = head + 4;
|
||||
let mut out = Vec::with_capacity(c_data);
|
||||
for _ in 0..c_data {
|
||||
let (cch, unit) = if extended {
|
||||
match b.get(at..at + 2) {
|
||||
Some(x) => (u16::from_le_bytes([x[0], x[1]]) as usize, 2),
|
||||
None => break,
|
||||
}
|
||||
} else {
|
||||
match b.get(at) {
|
||||
Some(&x) => (x as usize, 1),
|
||||
None => break,
|
||||
}
|
||||
};
|
||||
at += unit;
|
||||
let Some(chars) = b.get(at..at + cch * unit) else { break };
|
||||
at += cch * unit;
|
||||
// The string's extra data must be there too, or the table is cut short.
|
||||
if b.get(at..at + cb_extra).is_none() {
|
||||
break;
|
||||
}
|
||||
at += cb_extra;
|
||||
let s: String = if extended {
|
||||
chars.as_chunks::<2>().0.iter().take(40).map(|c| char::from_u32(u16::from_le_bytes(*c) as u32).unwrap_or('\u{FFFD}')).collect()
|
||||
} else {
|
||||
chars.iter().take(40).map(|&c| wordcraft_doc::encoding::cp1252(c)).collect()
|
||||
};
|
||||
out.push(s);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::sttb_strings;
|
||||
|
||||
fn ext_sttb(names: &[&str], extra: &[u8]) -> Vec<u8> {
|
||||
let mut b = vec![0xFF, 0xFF];
|
||||
b.extend_from_slice(&(names.len() as u16).to_le_bytes());
|
||||
b.extend_from_slice(&(extra.len() as u16).to_le_bytes());
|
||||
for n in names {
|
||||
let units: Vec<u16> = n.encode_utf16().collect();
|
||||
b.extend_from_slice(&(units.len() as u16).to_le_bytes());
|
||||
for u in units {
|
||||
b.extend_from_slice(&u.to_le_bytes());
|
||||
}
|
||||
b.extend_from_slice(extra);
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_bookmark_name_does_not_desync_table() {
|
||||
let long = "A".repeat(50);
|
||||
let b = ext_sttb(&[&long, "bm2"], &[]);
|
||||
let got = sttb_strings(&b, 0, b.len() as u32).unwrap();
|
||||
assert_eq!(got, vec!["A".repeat(40), "bm2".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sttb_extra_data_is_skipped() {
|
||||
let b = ext_sttb(&["first", "bm2"], &[1, 2, 3, 4]);
|
||||
let got = sttb_strings(&b, 0, b.len() as u32).unwrap();
|
||||
assert_eq!(got, vec!["first".to_string(), "bm2".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_without_its_extra_data_is_dropped() {
|
||||
let mut b = ext_sttb(&["first", "bm2"], &[1, 2, 3, 4]);
|
||||
b.truncate(b.len() - 2);
|
||||
let got = sttb_strings(&b, 0, b.len() as u32).unwrap();
|
||||
assert_eq!(got, vec!["first".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_extended_sttb_reads_byte_strings() {
|
||||
let mut b = Vec::new();
|
||||
b.extend_from_slice(&2u16.to_le_bytes());
|
||||
b.extend_from_slice(&2u16.to_le_bytes());
|
||||
for n in ["one", "caf\u{e9}"] {
|
||||
let bytes: Vec<u8> = n.chars().map(|c| c as u8).collect();
|
||||
b.push(bytes.len() as u8);
|
||||
b.extend_from_slice(&bytes);
|
||||
b.extend_from_slice(&[9, 9]);
|
||||
}
|
||||
let got = sttb_strings(&b, 0, b.len() as u32).unwrap();
|
||||
assert_eq!(got, vec!["one".to_string(), "caf\u{e9}".to_string()]);
|
||||
}
|
||||
}
|
||||
@@ -1,552 +0,0 @@
|
||||
//! OfficeArt (Escher) drawing records, as Word 97-2003 stores them ([MS-ODRAW], public Microsoft
|
||||
//! Open Specifications): the record header, the BLIP store and its pictures, shape containers with
|
||||
//! their property tables, and the `OfficeArtContent` of the Table stream ([MS-DOC] §2.9.172).
|
||||
//!
|
||||
//! Everything here walks hostile bytes: record lengths are clamped to their parent, nesting depth
|
||||
//! 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;
|
||||
|
||||
/// Deepest container nesting walked (groups inside groups…).
|
||||
const MAX_DEPTH: usize = 12;
|
||||
/// Most records walked in one drawing tree or BLIP store.
|
||||
const MAX_RECORDS: usize = 200_000;
|
||||
/// Most BLIP store entries kept.
|
||||
const MAX_BSE: usize = 10_000;
|
||||
/// Most drawings (main document, headers) read from `OfficeArtContent`.
|
||||
const MAX_DRAWINGS: usize = 8;
|
||||
/// Largest decompressed metafile (bytes).
|
||||
const MAX_METAFILE: usize = 64 << 20;
|
||||
/// Largest DIB converted to PNG, per side (pixels).
|
||||
const MAX_DIB_SIDE: u32 = 16_384;
|
||||
|
||||
/// Record types ([MS-ODRAW] §2.2, §2.3).
|
||||
pub(crate) mod rt {
|
||||
pub(crate) const DGG_CONTAINER: u16 = 0xF000;
|
||||
pub(crate) const BSTORE_CONTAINER: u16 = 0xF001;
|
||||
pub(crate) const DG_CONTAINER: u16 = 0xF002;
|
||||
pub(crate) const SPGR_CONTAINER: u16 = 0xF003;
|
||||
pub(crate) const SP_CONTAINER: u16 = 0xF004;
|
||||
pub(crate) const FBSE: u16 = 0xF007;
|
||||
pub(crate) const FSPGR: u16 = 0xF009;
|
||||
pub(crate) const FSP: u16 = 0xF00A;
|
||||
pub(crate) const FOPT: u16 = 0xF00B;
|
||||
pub(crate) const CLIENT_TEXTBOX: u16 = 0xF00D;
|
||||
pub(crate) const CHILD_ANCHOR: u16 = 0xF00F;
|
||||
pub(crate) const SECONDARY_FOPT: u16 = 0xF121;
|
||||
pub(crate) const TERTIARY_FOPT: u16 = 0xF122;
|
||||
pub(crate) const BLIP_EMF: u16 = 0xF01A;
|
||||
pub(crate) const BLIP_WMF: u16 = 0xF01B;
|
||||
pub(crate) const BLIP_PICT: u16 = 0xF01C;
|
||||
pub(crate) const BLIP_JPEG: u16 = 0xF01D;
|
||||
pub(crate) const BLIP_PNG: u16 = 0xF01E;
|
||||
pub(crate) const BLIP_DIB: u16 = 0xF01F;
|
||||
pub(crate) const BLIP_TIFF: u16 = 0xF029;
|
||||
pub(crate) const BLIP_JPEG_CMYK: u16 = 0xF02A;
|
||||
}
|
||||
|
||||
/// One record: header fields and its body (clamped to what the parent holds).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct Rec<'a> {
|
||||
pub(crate) inst: u16,
|
||||
pub(crate) typ: u16,
|
||||
pub(crate) body: &'a [u8],
|
||||
}
|
||||
|
||||
/// The record at the start of `buf` and the bytes it spans (header included). A body longer
|
||||
/// than `buf` is cut to what is there.
|
||||
pub(crate) fn record(buf: &[u8]) -> Option<(Rec<'_>, usize)> {
|
||||
let h = buf.get(0..8)?;
|
||||
let ver_inst = u16::from_le_bytes([h[0], h[1]]);
|
||||
let typ = u16::from_le_bytes([h[2], h[3]]);
|
||||
let len = u32::from_le_bytes([h[4], h[5], h[6], h[7]]) as usize;
|
||||
let rest = buf.get(8..)?;
|
||||
let len = len.min(rest.len());
|
||||
let body = rest.get(..len)?;
|
||||
Some((Rec { inst: ver_inst >> 4, typ, body }, 8 + len))
|
||||
}
|
||||
|
||||
/// The sibling records of a container body, at most `budget` of them (the budget is shared
|
||||
/// across a whole walk, so a deep or wide tree cannot multiply the work).
|
||||
pub(crate) fn children<'a>(body: &'a [u8], budget: &mut usize) -> Vec<Rec<'a>> {
|
||||
let mut out = Vec::new();
|
||||
let mut at = 0usize;
|
||||
while *budget > 0 {
|
||||
let Some((rec, used)) = body.get(at..).and_then(record) else { break };
|
||||
*budget -= 1;
|
||||
out.push(rec);
|
||||
at = at.saturating_add(used);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn u16_at(b: &[u8], at: usize) -> Option<u16> {
|
||||
Some(u16::from_le_bytes(b.get(at..at.checked_add(2)?)?.try_into().ok()?))
|
||||
}
|
||||
|
||||
fn u32_at(b: &[u8], at: usize) -> Option<u32> {
|
||||
Some(u32::from_le_bytes(b.get(at..at.checked_add(4)?)?.try_into().ok()?))
|
||||
}
|
||||
|
||||
fn i32_at(b: &[u8], at: usize) -> Option<i32> {
|
||||
u32_at(b, at).map(|v| v as i32)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Pictures (BLIPs).
|
||||
|
||||
/// A decoded picture: bytes ready for `Document::media` and their file extension.
|
||||
pub(crate) struct Blip {
|
||||
pub(crate) bytes: Vec<u8>,
|
||||
pub(crate) ext: &'static str,
|
||||
/// The format cannot be drawn (Macintosh PICT): the picture is kept as a placeholder of
|
||||
/// the right size.
|
||||
pub(crate) placeholder: bool,
|
||||
}
|
||||
|
||||
/// The picture of an `OfficeArtFBSE` body: its embedded BLIP, or the BLIP stored at `foDelay`
|
||||
/// in `delay` (the WordDocument stream for the document's BLIP store, the Data stream for
|
||||
/// inline pictures). `limit` caps the bytes produced.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn fbse_blip(fbse: &[u8], delay: &[u8], limit: usize, work: &mut Decode) -> Option<Blip> {
|
||||
let (rec, _) = fbse_record(fbse, delay)?;
|
||||
blip(&rec, limit, work)
|
||||
}
|
||||
|
||||
/// The document's decode budget: bytes of inflating and pixel decoding left, and how many
|
||||
/// attempts it has refused (only the first few are logged).
|
||||
pub(crate) struct Decode {
|
||||
pub(crate) left: usize,
|
||||
refused: usize,
|
||||
}
|
||||
|
||||
impl Decode {
|
||||
pub(crate) fn new(left: usize) -> Self {
|
||||
Decode { left, refused: 0 }
|
||||
}
|
||||
|
||||
/// Take `cost` off the budget; `false` (and a log line) when it doesn't fit.
|
||||
fn charge(&mut self, cost: usize, what: &str) -> bool {
|
||||
match self.left.checked_sub(cost) {
|
||||
Some(left) if cost > 0 => {
|
||||
self.left = left;
|
||||
true
|
||||
}
|
||||
_ => {
|
||||
if self.refused < 10 {
|
||||
log::warn!(
|
||||
"docbin: a {what} needing {cost} bytes of decode work is over the decode budget ({} left) and was not decoded",
|
||||
self.left
|
||||
);
|
||||
}
|
||||
self.refused = self.refused.saturating_add(1);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The BLIP record of an `OfficeArtFBSE`: embedded after it, or in the delay stream, where
|
||||
/// its (`foDelay`, record length) location is returned too (entries can share one record,
|
||||
/// whatever sizes their FBSEs claim: the record's own header is what counts).
|
||||
pub(crate) fn fbse_record<'a>(fbse: &'a [u8], delay: &'a [u8]) -> Option<(Rec<'a>, Option<(usize, usize)>)> {
|
||||
// btWin32, btMacOS, rgbUid[16], tag, size, cRef, foDelay, unused, cbName, unused×2.
|
||||
let fo_delay = u32_at(fbse, 28)? as usize;
|
||||
let cb_name = *fbse.get(33)? as usize;
|
||||
let at = 36usize.checked_add(cb_name)?;
|
||||
if let Some((rec, _)) = fbse.get(at..).and_then(record) {
|
||||
return Some((rec, None));
|
||||
}
|
||||
// Not embedded: the record sits in the delay stream.
|
||||
let (rec, used) = record(delay.get(fo_delay..)?)?;
|
||||
Some((rec, Some((fo_delay, used))))
|
||||
}
|
||||
|
||||
/// Decode one `OfficeArtBlip*` record ([MS-ODRAW] §2.2.23-§2.2.31). `work` is the
|
||||
/// document's decode budget: inflating a metafile or decoding a DIB is charged to it before
|
||||
/// it runs, whether it succeeds or not, and nothing is decoded once it is spent.
|
||||
pub(crate) fn blip(rec: &Rec, limit: usize, work: &mut Decode) -> Option<Blip> {
|
||||
// Each type has two instance values; the odd one carries a second 16-byte UID.
|
||||
let uids = if rec.inst & 1 == 1 { 32 } else { 16 };
|
||||
match rec.typ {
|
||||
rt::BLIP_PNG | rt::BLIP_JPEG | rt::BLIP_JPEG_CMYK | rt::BLIP_DIB | rt::BLIP_TIFF => {
|
||||
let data = rec.body.get(uids + 1..)?; // + tag byte
|
||||
if data.is_empty() || data.len() > limit {
|
||||
return None;
|
||||
}
|
||||
match rec.typ {
|
||||
rt::BLIP_PNG => Some(Blip { bytes: data.to_vec(), ext: "png", placeholder: false }),
|
||||
rt::BLIP_JPEG | rt::BLIP_JPEG_CMYK => Some(Blip { bytes: data.to_vec(), ext: "jpeg", placeholder: false }),
|
||||
rt::BLIP_TIFF => Some(Blip { bytes: data.to_vec(), ext: "tiff", placeholder: false }),
|
||||
_ => dib_to_png(data, limit, work),
|
||||
}
|
||||
}
|
||||
rt::BLIP_EMF | rt::BLIP_WMF | rt::BLIP_PICT => {
|
||||
// OfficeArtMetafileHeader: cbSize, rcBounds, ptSize, cbSave, compression, filter.
|
||||
let hdr = rec.body.get(uids..uids + 34)?;
|
||||
let cb_size = u32_at(hdr, 0)? as usize;
|
||||
let cb_save = u32_at(hdr, 28)? as usize;
|
||||
let compression = *hdr.get(32)?;
|
||||
let raw = rec.body.get(uids + 34..)?;
|
||||
let raw = raw.get(..cb_save.min(raw.len()))?;
|
||||
let cap = limit.min(MAX_METAFILE);
|
||||
let bytes = if compression == 0 {
|
||||
let want = if cb_size > 0 { cb_size.min(cap) } else { cap };
|
||||
// The compressed stream is read whole whatever it inflates to: a record that
|
||||
// claims to inflate to nothing still costs its input (#3871).
|
||||
if !work.charge(want.max(raw.len()), "metafile") {
|
||||
return None;
|
||||
}
|
||||
miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(raw, want).ok()?
|
||||
} else {
|
||||
if raw.len() > cap {
|
||||
return None;
|
||||
}
|
||||
raw.to_vec()
|
||||
};
|
||||
if bytes.is_empty() {
|
||||
return None;
|
||||
}
|
||||
match rec.typ {
|
||||
rt::BLIP_EMF => Some(Blip { bytes, ext: "emf", placeholder: false }),
|
||||
rt::BLIP_WMF => Some(Blip { bytes, ext: "wmf", placeholder: false }),
|
||||
_ => Some(Blip { bytes, ext: "pict", placeholder: true }),
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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, or its declared pixels (4 bytes each) don't fit in `work`, the
|
||||
/// BMP bytes are kept (and render as a grey placeholder).
|
||||
fn dib_to_png(dib: &[u8], limit: usize, work: &mut Decode) -> Option<Blip> {
|
||||
let header = u32_at(dib, 0)? as usize;
|
||||
if !(12..=124).contains(&header) {
|
||||
return None;
|
||||
}
|
||||
let (bits, used, compression) =
|
||||
if header == 12 { (u16_at(dib, 10)? as usize, 0, 0) } else { (u16_at(dib, 14)? as usize, u32_at(dib, 32)? as usize, u32_at(dib, 16)?) };
|
||||
let entry = if header == 12 { 3 } else { 4 };
|
||||
let colors = if used > 0 {
|
||||
used.min(256)
|
||||
} else if bits <= 8 {
|
||||
1usize << bits
|
||||
} else {
|
||||
0
|
||||
};
|
||||
// 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()).filter(|&size| size <= limit)?;
|
||||
// The declared size is what decoding allocates, however small the (RLE) data is.
|
||||
let (w, h) = if header == 12 {
|
||||
(u64::from(u16_at(dib, 4)?), u64::from(u16_at(dib, 6)?))
|
||||
} else {
|
||||
(u64::from(u32_at(dib, 4)?), u64::from((u32_at(dib, 8)? as i32).unsigned_abs()))
|
||||
};
|
||||
let cost = w.saturating_mul(h).saturating_mul(4).try_into().unwrap_or(usize::MAX);
|
||||
// Copying the DIB into a BMP is work too, however few pixels it declares: it is charged
|
||||
// before the copy is made (#3871). A bitmap whose pixels don't fit still keeps its BMP
|
||||
// bytes as a placeholder, if the copy fits.
|
||||
let decode = work.charge(cost.max(dib.len()), "bitmap");
|
||||
if !decode && !work.charge(dib.len(), "bitmap") {
|
||||
return None;
|
||||
}
|
||||
let mut bmp = Vec::with_capacity(file_size);
|
||||
bmp.extend_from_slice(b"BM");
|
||||
bmp.extend_from_slice(&u32::try_from(file_size).ok()?.to_le_bytes());
|
||||
bmp.extend_from_slice(&[0, 0, 0, 0]);
|
||||
bmp.extend_from_slice(&u32::try_from(off_bits).ok()?.to_le_bytes());
|
||||
bmp.extend_from_slice(dib);
|
||||
let placeholder = |bmp| Some(Blip { bytes: bmp, ext: "bmp", placeholder: true });
|
||||
if !decode {
|
||||
return placeholder(bmp);
|
||||
}
|
||||
let mut reader = image::ImageReader::new(std::io::Cursor::new(&bmp));
|
||||
reader.set_format(image::ImageFormat::Bmp);
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_image_width = Some(MAX_DIB_SIDE);
|
||||
limits.max_image_height = Some(MAX_DIB_SIDE);
|
||||
limits.max_alloc = Some((256 << 20).min(limit as u64));
|
||||
reader.limits(limits);
|
||||
let mut png = PictureWriter { bytes: Vec::new(), limit };
|
||||
match reader.decode() {
|
||||
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 })
|
||||
}
|
||||
_ => placeholder(bmp),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
||||
/// The shape properties we use, from `OfficeArtFOPT` and its secondary/tertiary tables
|
||||
/// ([MS-ODRAW] §2.3).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub(crate) struct Props {
|
||||
/// `pib`: 1-based index of the picture in the BLIP store.
|
||||
pub(crate) pib: Option<u32>,
|
||||
/// Rotation, degrees clockwise.
|
||||
pub(crate) rot: f32,
|
||||
/// Crop fractions (left, top, right, bottom).
|
||||
pub(crate) crop: [f32; 4],
|
||||
pub(crate) fill_color: Option<Rgb>,
|
||||
pub(crate) filled: Option<bool>,
|
||||
pub(crate) line_color: Option<Rgb>,
|
||||
/// Line width in EMUs.
|
||||
pub(crate) line_width: Option<u32>,
|
||||
pub(crate) line: Option<bool>,
|
||||
/// An arrowhead at either end of a line.
|
||||
pub(crate) arrowhead: bool,
|
||||
/// `posh`/`posrelh`/`posv`/`posrelv`: alignment and reference frame (Word 2000 and later).
|
||||
pub(crate) posh: Option<u32>,
|
||||
pub(crate) posrelh: Option<u32>,
|
||||
pub(crate) posv: Option<u32>,
|
||||
pub(crate) posrelv: Option<u32>,
|
||||
/// Distance from wrapped text (left, top, right, bottom), EMUs.
|
||||
pub(crate) wrap_dist: [Option<u32>; 4],
|
||||
/// `wzDescription`: the alternative text.
|
||||
pub(crate) alt: String,
|
||||
}
|
||||
|
||||
impl Props {
|
||||
/// Apply one property table (`OfficeArtFOPT` or a secondary/tertiary one): `count` 6-byte
|
||||
/// entries, then the complex data of the entries flagged complex, in order.
|
||||
pub(crate) fn apply(&mut self, body: &[u8], count: u16) {
|
||||
let n = (count as usize).min(body.len() / 6);
|
||||
let mut complex_at = n * 6;
|
||||
for i in 0..n {
|
||||
let Some(opid) = u16_at(body, i * 6) else { break };
|
||||
let Some(op) = u32_at(body, i * 6 + 2) else { break };
|
||||
let pid = opid & 0x3FFF;
|
||||
if opid & 0x8000 != 0 {
|
||||
let start = complex_at;
|
||||
complex_at = complex_at.saturating_add(op as usize);
|
||||
if pid == 0x0381 {
|
||||
let end = complex_at.min(body.len());
|
||||
if let Some(data) = body.get(start..end) {
|
||||
let units: Vec<u16> =
|
||||
data.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).take_while(|u| *u != 0).take(4096).collect();
|
||||
self.alt = String::from_utf16_lossy(&units);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let fixed = |v: u32| (v as i32) as f32 / 65_536.0;
|
||||
match pid {
|
||||
0x0004 => self.rot = fixed(op),
|
||||
0x0100..=0x0103 => {
|
||||
// cropFromTop, cropFromBottom, cropFromLeft, cropFromRight.
|
||||
let slot = [1, 3, 0, 2][(pid - 0x0100) as usize];
|
||||
let v = fixed(op);
|
||||
self.crop[slot] = if v.is_finite() { v.clamp(0.0, 0.99) } else { 0.0 };
|
||||
}
|
||||
0x0104 => self.pib = Some(op),
|
||||
0x0181 => self.fill_color = color(op),
|
||||
// fFilled (bit 4) is valid when fUsefFilled (bit 20) is set.
|
||||
0x01BF if op & (1 << 20) != 0 => self.filled = Some(op & (1 << 4) != 0),
|
||||
0x01BF => {}
|
||||
0x01C0 => self.line_color = color(op),
|
||||
0x01CB => self.line_width = Some(op),
|
||||
0x01D0 | 0x01D1 => self.arrowhead |= op != 0,
|
||||
// fLine (bit 3) is valid when fUsefLine (bit 19) is set.
|
||||
0x01FF if op & (1 << 19) != 0 => self.line = Some(op & (1 << 3) != 0),
|
||||
0x01FF => {}
|
||||
0x0384..=0x0387 => self.wrap_dist[(pid - 0x0384) as usize] = Some(op),
|
||||
0x038F => self.posh = Some(op),
|
||||
0x0390 => self.posrelh = Some(op),
|
||||
0x0391 => self.posv = Some(op),
|
||||
0x0392 => self.posrelv = Some(op),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An `OfficeArtCOLORREF` holding a literal RGB value (palette, scheme and system colour
|
||||
/// references are not resolved).
|
||||
fn color(v: u32) -> Option<Rgb> {
|
||||
let [r, g, b, flags] = v.to_le_bytes();
|
||||
// fPaletteIndex, fSchemeIndex, fSysIndex.
|
||||
if flags & 0x19 != 0 {
|
||||
return None;
|
||||
}
|
||||
Some(Rgb(r, g, b))
|
||||
}
|
||||
|
||||
/// One shape (`OfficeArtSpContainer`).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct Sp {
|
||||
pub(crate) spid: u32,
|
||||
/// Shape type (`MSOSPT`), from the FSP's instance.
|
||||
pub(crate) spt: u16,
|
||||
/// FSP flags: fGroup, fChild, fPatriarch, fDeleted, fOleShape, fHaveMaster, fFlipH, fFlipV…
|
||||
pub(crate) flags: u32,
|
||||
pub(crate) props: Props,
|
||||
/// `OfficeArtChildAnchor` (left, top, right, bottom) in the parent group's space.
|
||||
pub(crate) child: Option<[i32; 4]>,
|
||||
/// `OfficeArtFSPGR`: a group's own coordinate space.
|
||||
pub(crate) group_space: Option<[i32; 4]>,
|
||||
/// Has an `OfficeArtClientTextbox` (its text is in the text box story).
|
||||
pub(crate) textbox: bool,
|
||||
}
|
||||
|
||||
impl Sp {
|
||||
pub(crate) fn flip_h(&self) -> bool {
|
||||
self.flags & (1 << 6) != 0
|
||||
}
|
||||
pub(crate) fn flip_v(&self) -> bool {
|
||||
self.flags & (1 << 7) != 0
|
||||
}
|
||||
fn deleted(&self) -> bool {
|
||||
self.flags & (1 << 3) != 0
|
||||
}
|
||||
}
|
||||
|
||||
/// A shape or a group of shapes.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum Node {
|
||||
Shape(Sp),
|
||||
/// A group: its own shape record (FSPGR, anchor) and members.
|
||||
Group(Sp, Vec<Node>),
|
||||
}
|
||||
|
||||
impl Node {
|
||||
pub(crate) fn sp(&self) -> &Sp {
|
||||
match self {
|
||||
Node::Shape(sp) | Node::Group(sp, _) => sp,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse one `OfficeArtSpContainer`.
|
||||
pub(crate) fn shape(body: &[u8], budget: &mut usize) -> Sp {
|
||||
let mut sp = Sp::default();
|
||||
for rec in children(body, budget) {
|
||||
match rec.typ {
|
||||
rt::FSP => {
|
||||
sp.spt = rec.inst;
|
||||
sp.spid = u32_at(rec.body, 0).unwrap_or(0);
|
||||
sp.flags = u32_at(rec.body, 4).unwrap_or(0);
|
||||
}
|
||||
rt::FOPT | rt::SECONDARY_FOPT | rt::TERTIARY_FOPT => sp.props.apply(rec.body, rec.inst),
|
||||
rt::FSPGR | rt::CHILD_ANCHOR => {
|
||||
let r = [0, 4, 8, 12].map(|o| i32_at(rec.body, o));
|
||||
if let [Some(l), Some(t), Some(r), Some(b)] = r {
|
||||
if rec.typ == rt::FSPGR {
|
||||
sp.group_space = Some([l, t, r, b]);
|
||||
} else {
|
||||
sp.child = Some([l, t, r, b]);
|
||||
}
|
||||
}
|
||||
}
|
||||
rt::CLIENT_TEXTBOX => sp.textbox = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
sp
|
||||
}
|
||||
|
||||
/// Parse an `OfficeArtSpgrContainer`: the first shape is the group itself, the rest its members.
|
||||
fn group(body: &[u8], depth: usize, budget: &mut usize) -> Option<Node> {
|
||||
let recs = children(body, budget);
|
||||
let mut it = recs.iter();
|
||||
let head = it.find(|r| r.typ == rt::SP_CONTAINER).map(|r| shape(r.body, budget))?;
|
||||
let mut members = Vec::new();
|
||||
for rec in it {
|
||||
if let Some(n) = node(rec, depth + 1, budget) {
|
||||
members.push(n);
|
||||
}
|
||||
}
|
||||
Some(Node::Group(head, members))
|
||||
}
|
||||
|
||||
/// A shape or group record as a node (`None` for other records, deleted shapes or past the
|
||||
/// depth cap).
|
||||
fn node(rec: &Rec, depth: usize, budget: &mut usize) -> Option<Node> {
|
||||
match rec.typ {
|
||||
rt::SP_CONTAINER => Some(shape(rec.body, budget)).filter(|s| !s.deleted()).map(Node::Shape),
|
||||
rt::SPGR_CONTAINER if depth < MAX_DEPTH => group(rec.body, depth, budget),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The document's drawings: the BLIP store and the top-level shapes of each drawing by spid.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Content<'a> {
|
||||
/// `OfficeArtFBSE` bodies, in BLIP store order (`pib` 1 = the first).
|
||||
pub(crate) bstore: Vec<&'a [u8]>,
|
||||
/// The main document's shapes (`dgglbl` 0) and the headers' (`dgglbl` 1), by spid.
|
||||
pub(crate) main: HashMap<u32, Node>,
|
||||
pub(crate) header: HashMap<u32, Node>,
|
||||
}
|
||||
|
||||
/// Parse `OfficeArtContent` (the `fcDggInfo` blob of the Table stream): an
|
||||
/// `OfficeArtDggContainer` followed by (`dgglbl`, `OfficeArtDgContainer`) drawings.
|
||||
pub(crate) fn content(blob: &[u8]) -> Content<'_> {
|
||||
let mut out = Content::default();
|
||||
let mut budget = MAX_RECORDS;
|
||||
let Some((dgg, mut at)) = record(blob) else { return out };
|
||||
if dgg.typ == rt::DGG_CONTAINER {
|
||||
for rec in children(dgg.body, &mut budget) {
|
||||
if rec.typ == rt::BSTORE_CONTAINER {
|
||||
out.bstore = children(rec.body, &mut budget).into_iter().filter(|r| r.typ == rt::FBSE).map(|r| r.body).take(MAX_BSE).collect();
|
||||
}
|
||||
}
|
||||
}
|
||||
for _ in 0..MAX_DRAWINGS {
|
||||
let Some(&label) = blob.get(at) else { break };
|
||||
let Some((dg, used)) = blob.get(at + 1..).and_then(record) else { break };
|
||||
at = at.saturating_add(1 + used);
|
||||
if dg.typ != rt::DG_CONTAINER {
|
||||
continue;
|
||||
}
|
||||
let into = if label == 0 { &mut out.main } else { &mut out.header };
|
||||
for rec in children(dg.body, &mut budget) {
|
||||
if rec.typ != rt::SPGR_CONTAINER {
|
||||
continue;
|
||||
}
|
||||
// The top group: its first shape is the patriarch; the members are the drawing's shapes.
|
||||
if let Some(Node::Group(_, members)) = group(rec.body, 0, &mut budget) {
|
||||
for n in members {
|
||||
into.entry(n.sp().spid).or_insert(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
//! The piece table ([MS-DOC] `Clx`/`Pcdt`/`PlcPcd`): where every character position lives in
|
||||
//! the WordDocument stream, and how it is encoded.
|
||||
|
||||
use wordcraft_doc::encoding::cp1252;
|
||||
|
||||
use crate::DocbinError;
|
||||
|
||||
/// Most pieces any real file has; more means a corrupt `PlcPcd`.
|
||||
const MAX_PIECES: usize = 1_000_000;
|
||||
|
||||
/// One piece: text at FcCompressed `fc` (30-bit offset + compression bit).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Pcd {
|
||||
fc: u32,
|
||||
compressed: bool,
|
||||
/// `Prm`: either a single sprm (Prm0) or an index into the Clx's Prc grpprls (Prm1).
|
||||
prm: u16,
|
||||
}
|
||||
|
||||
/// The parsed piece table plus the grpprls of the Prcs that Prm1 references.
|
||||
pub(crate) struct PieceTable {
|
||||
/// n+1 character-position starts.
|
||||
cps: Vec<u32>,
|
||||
pieces: Vec<Pcd>,
|
||||
prc_grpprls: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl PieceTable {
|
||||
/// Parse the `Clx` at `table[fc .. fc+lcb]`.
|
||||
pub(crate) fn parse(table: &[u8], fc: u32, lcb: u32) -> Result<PieceTable, DocbinError> {
|
||||
let fc = fc as usize;
|
||||
let lcb = lcb as usize;
|
||||
if lcb == 0 {
|
||||
return Err(DocbinError::Malformed("empty Clx".into()));
|
||||
}
|
||||
let end = fc.checked_add(lcb).ok_or_else(|| DocbinError::Malformed("Clx offset overflow".into()))?;
|
||||
let clx = table.get(fc..end).ok_or_else(|| DocbinError::Malformed(format!("Clx at {fc}+{lcb} is outside the Table stream")))?;
|
||||
// A Clx is an array of Prc (clxt 0x01, each holding a grpprl that Prm1 values can
|
||||
// reference) followed by the Pcdt (clxt 0x02) that holds the piece table.
|
||||
let mut prc_grpprls = Vec::new();
|
||||
let mut i = 0usize;
|
||||
let plc = loop {
|
||||
let Some(&clxt) = clx.get(i) else {
|
||||
return Err(DocbinError::Malformed("Clx ends before the Pcdt".into()));
|
||||
};
|
||||
match clxt {
|
||||
0x01 => {
|
||||
let cb = clx
|
||||
.get(i + 1..i + 3)
|
||||
.map(|b| u16::from_le_bytes([b[0], b[1]]) as usize)
|
||||
.ok_or_else(|| DocbinError::Malformed("truncated Prc".into()))?;
|
||||
let stop = i.checked_add(3).and_then(|v| v.checked_add(cb)).ok_or_else(|| DocbinError::Malformed("Prc size overflow".into()))?;
|
||||
if let Some(g) = clx.get(i + 3..stop)
|
||||
&& prc_grpprls.len() < MAX_PIECES
|
||||
{
|
||||
prc_grpprls.push(g.to_vec());
|
||||
}
|
||||
i = stop;
|
||||
}
|
||||
0x02 => {
|
||||
let lcb_plc = clx
|
||||
.get(i + 1..i + 5)
|
||||
.map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as usize)
|
||||
.ok_or_else(|| DocbinError::Malformed("truncated Pcdt".into()))?;
|
||||
let start = i.checked_add(5).ok_or_else(|| DocbinError::Malformed("Pcdt offset overflow".into()))?;
|
||||
let stop = start.checked_add(lcb_plc).ok_or_else(|| DocbinError::Malformed("Pcdt size overflow".into()))?;
|
||||
break clx.get(start..stop).ok_or_else(|| DocbinError::Malformed("PlcPcd is outside the Clx".into()))?;
|
||||
}
|
||||
other => return Err(DocbinError::Malformed(format!("unexpected Clx item 0x{other:02X}"))),
|
||||
}
|
||||
};
|
||||
// PlcPcd: aCp[n+1] u32s followed by aPcd[n] Pcds (8 bytes each).
|
||||
if plc.len() < 4 || (plc.len() - 4) % 12 != 0 {
|
||||
return Err(DocbinError::Malformed(format!("PlcPcd is {} bytes", plc.len())));
|
||||
}
|
||||
let n = (plc.len() - 4) / 12;
|
||||
if n > MAX_PIECES {
|
||||
return Err(DocbinError::Limit(format!("piece table has {n} pieces")));
|
||||
}
|
||||
let mut cps = Vec::with_capacity(n + 1);
|
||||
for j in 0..=n {
|
||||
let at = j * 4;
|
||||
let Some(b) = plc.get(at..at + 4) else {
|
||||
return Err(DocbinError::Malformed("truncated aCp".into()));
|
||||
};
|
||||
// Keep the CPs non-decreasing so binary search over them is sound: an inverted
|
||||
// piece in a corrupt file becomes an empty one.
|
||||
let c = u32::from_le_bytes([b[0], b[1], b[2], b[3]]);
|
||||
cps.push(c.max(cps.last().copied().unwrap_or(0)));
|
||||
}
|
||||
let mut pieces = Vec::with_capacity(n);
|
||||
for j in 0..n {
|
||||
let at = (n + 1) * 4 + j * 8 + 2;
|
||||
let Some(fc_raw) = plc.get(at..at + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])) else {
|
||||
return Err(DocbinError::Malformed("truncated aPcd".into()));
|
||||
};
|
||||
let prm = plc.get(at + 4..at + 6).map(|b| u16::from_le_bytes([b[0], b[1]])).unwrap_or(0);
|
||||
pieces.push(Pcd { fc: fc_raw & 0x3FFF_FFFF, compressed: fc_raw & 0x4000_0000 != 0, prm });
|
||||
}
|
||||
Ok(PieceTable { cps, pieces, prc_grpprls })
|
||||
}
|
||||
|
||||
/// The Prls this piece's `Prm` adds. Bit 0 (`fComplex`) discriminates: a Prm0
|
||||
/// ([MS-DOC] §2.9.215) holds a 7-bit `isprm` in bits 1-7 and its one-byte operand in
|
||||
/// bits 8-15; a Prm1 (§2.9.216) holds a zero-based index into the Clx's Prcs in bits
|
||||
/// 1-15.
|
||||
pub(crate) fn piece_prm(&self, cp: u32) -> PrmRef<'_> {
|
||||
let Some(p) = self.piece_of(cp) else { return PrmRef::None };
|
||||
if p.prm & 1 == 0 {
|
||||
let isprm = ((p.prm >> 1) & 0x7F) as u8;
|
||||
if isprm == 0 {
|
||||
return PrmRef::None;
|
||||
}
|
||||
PrmRef::Sprm0 { isprm, val: (p.prm >> 8) as u8 }
|
||||
} else {
|
||||
PrmRef::Grpprl(self.prc_grpprls.get((p.prm >> 1) as usize).map(Vec::as_slice))
|
||||
}
|
||||
}
|
||||
|
||||
/// The raw Prm of the piece holding `cp` (0, no properties, when there is none): two CPs
|
||||
/// with the same value get the same [`PrmRef`].
|
||||
pub(crate) fn prm_of(&self, cp: u32) -> u16 {
|
||||
self.piece_of(cp).map_or(0, |p| p.prm)
|
||||
}
|
||||
|
||||
fn piece_of(&self, cp: u32) -> Option<&Pcd> {
|
||||
let i = self.cps.partition_point(|&c| c <= cp).checked_sub(1)?;
|
||||
if i >= self.pieces.len() {
|
||||
return None;
|
||||
}
|
||||
self.pieces.get(i)
|
||||
}
|
||||
|
||||
/// The FC of a character position in the space the FKP bin tables use: true byte
|
||||
/// offsets into the WordDocument stream. A compressed piece stores its base `fc`
|
||||
/// doubled (`byte = fc/2`), so the doubling unwinds here; an uncompressed piece's `fc`
|
||||
/// already is its byte offset and each character covers two bytes.
|
||||
pub(crate) fn fc_of_cp(&self, cp: u32) -> Option<u32> {
|
||||
let p = self.piece_of(cp)?;
|
||||
let i = self.cps.partition_point(|&c| c <= cp).checked_sub(1)?;
|
||||
let cp0 = *self.cps.get(i)?;
|
||||
let step = cp.checked_sub(cp0)?.checked_mul(2)?;
|
||||
if p.compressed { p.fc.checked_add(step)?.checked_div(2) } else { p.fc.checked_add(step) }
|
||||
}
|
||||
|
||||
/// The CP just past the last piece: no text exists at or beyond it.
|
||||
pub(crate) fn cp_end(&self) -> u32 {
|
||||
self.cps.last().copied().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The character stored at `cp` (control characters included) and how many CPs it
|
||||
/// covers (2 for a surrogate pair), found by binary search over the pieces.
|
||||
/// `Err(next)` when there is no readable character at `cp` (a gap between pieces, an
|
||||
/// inverted piece, or piece bytes past the end of the stream): `next` is the CP to
|
||||
/// resume at, always greater than `cp` (`u32::MAX` past the last piece), so callers
|
||||
/// skip an unreadable range in one step instead of one CP at a time.
|
||||
pub(crate) fn char_at(&self, word: &[u8], cp: u32) -> Result<(char, u32), u32> {
|
||||
let i = self.cps.partition_point(|&c| c <= cp);
|
||||
// The next piece boundary above `cp`, or the end of the CP space.
|
||||
let next = self.cps.get(i).copied().filter(|&n| n > cp).unwrap_or(u32::MAX);
|
||||
let Some(i) = i.checked_sub(1) else { return Err(next) };
|
||||
let (Some(p), Some(&cp0)) = (self.pieces.get(i), self.cps.get(i)) else { return Err(u32::MAX) };
|
||||
let Some(off) = cp.checked_sub(cp0) else { return Err(next) };
|
||||
let off = off as usize;
|
||||
if p.compressed {
|
||||
// 8-bit, Windows-1252, one byte per character, stored at fc/2.
|
||||
let at = ((p.fc / 2) as usize).checked_add(off);
|
||||
return match at.and_then(|a| word.get(a)) {
|
||||
Some(&b) => Ok((cp1252(b), 1)),
|
||||
None => Err(next),
|
||||
};
|
||||
}
|
||||
// UTF-16LE, one 16-bit unit per character, stored at fc.
|
||||
let unit = |k: usize| -> Option<u16> {
|
||||
let at = (p.fc as usize).checked_add(k.checked_mul(2)?)?;
|
||||
word.get(at..at.checked_add(2)?).map(|b| u16::from_le_bytes([b[0], b[1]]))
|
||||
};
|
||||
let Some(u) = unit(off) else { return Err(next) };
|
||||
if (0xD800..0xDC00).contains(&u)
|
||||
&& cp.saturating_add(1) < next
|
||||
&& let Some(lo) = unit(off.saturating_add(1)).filter(|lo| (0xDC00..0xE000).contains(lo))
|
||||
&& let Some(Ok(c)) = char::decode_utf16([u, lo]).next()
|
||||
{
|
||||
return Ok((c, 2));
|
||||
}
|
||||
Ok((char::from_u32(u as u32).unwrap_or('\u{FFFD}'), 1))
|
||||
}
|
||||
}
|
||||
|
||||
/// What a piece's `Prm` refers to.
|
||||
pub(crate) enum PrmRef<'a> {
|
||||
None,
|
||||
/// Prm0: a short sprm index into the isprm table, with a 1-byte operand.
|
||||
Sprm0 {
|
||||
isprm: u8,
|
||||
val: u8,
|
||||
},
|
||||
/// Prm1: a grpprl from a Prc (absent if out of range).
|
||||
Grpprl(Option<&'a [u8]>),
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
//! Sections and headers/footers: `PlcfSed` → `Sed` → `Sepx` (grpprls of section sprms) and
|
||||
//! the `Plcfhdd` stories of the header subdocument ([MS-DOC] §2.8.25, §2.9.244).
|
||||
|
||||
use wordcraft_doc::section::NumFormat;
|
||||
use wordcraft_doc::section::{Columns, SectionProps, SectionStart};
|
||||
|
||||
use crate::fib::{Fib, pair};
|
||||
use crate::sprm::{self, Prl};
|
||||
|
||||
/// Most sections we read.
|
||||
const MAX_SECTIONS: usize = 1024;
|
||||
|
||||
// Section sprms ([MS-DOC] §2.6.4).
|
||||
const S_BKC: u16 = 0x3009;
|
||||
const S_TITLE_PAGE: u16 = 0x300A; // sprmSFTitlePage
|
||||
const S_CCOLUMNS: u16 = 0x500B;
|
||||
const S_PGN_START: u16 = 0x300C; // sprmSPgnStart
|
||||
const S_ORIENTATION: u16 = 0x301D; // sprmSBOrientation
|
||||
const S_HDR_TOP: u16 = 0xB017;
|
||||
const S_HDR_BOTTOM: u16 = 0xB018;
|
||||
const S_XA_PAGE: u16 = 0xB01F;
|
||||
const S_YA_PAGE: u16 = 0xB020;
|
||||
const S_DXA_LEFT: u16 = 0xB021;
|
||||
const S_DXA_RIGHT: u16 = 0xB022;
|
||||
const S_DYA_TOP: u16 = 0x9023;
|
||||
const S_DYA_BOTTOM: u16 = 0x9024;
|
||||
const S_DXA_GUTTER: u16 = 0xB025;
|
||||
|
||||
/// One section: covers paragraphs up to (excluding) `end_cp`, with its properties.
|
||||
pub(crate) struct Section {
|
||||
pub(crate) end_cp: u32,
|
||||
pub(crate) props: SectionProps,
|
||||
}
|
||||
|
||||
/// Parse `PlcfSed` and each section's `Sepx` grpprl.
|
||||
pub(crate) fn parse(word: &[u8], table: &[u8], fib: &Fib) -> Vec<Section> {
|
||||
let Some((fc, lcb)) = fib.pair(pair::PLCF_SED) else { return Vec::new() };
|
||||
let Some(plc) = table.get(fc as usize..(fc as usize).saturating_add(lcb as usize)) else { return Vec::new() };
|
||||
// PlcSed: aCp[n+1] u32s + aSed[n] (Sed = fn 2, fcSepx 4, fnMpr 2, fcMpr 4).
|
||||
if plc.len() < 4 || (plc.len() - 4) % 16 != 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let n = (plc.len() - 4) / 16;
|
||||
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 out = Vec::with_capacity(n.min(MAX_SECTIONS));
|
||||
for i in 0..n.min(MAX_SECTIONS) {
|
||||
let Some(end_cp) = cp_at(i + 1) else { break };
|
||||
let sed_at = (n + 1) * 4 + i * 12;
|
||||
let fc_sepx = plc.get(sed_at + 2..sed_at + 6).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])).unwrap_or(0xFFFF_FFFF);
|
||||
let mut props = SectionProps::default();
|
||||
// Sepx: cb u16, then a grpprl of section sprms.
|
||||
let mut explicit_orient = false;
|
||||
if let Some(grpprl) =
|
||||
word.get(fc_sepx as usize..).and_then(|sepx| sepx.get(..2).map(|b| u16::from_le_bytes([b[0], b[1]]) as usize)).and_then(|cb| {
|
||||
let at = (fc_sepx as usize).checked_add(2)?;
|
||||
word.get(at..at.checked_add(cb)?)
|
||||
})
|
||||
{
|
||||
for prl in sprm::iter(grpprl) {
|
||||
// Only a defined operand counts as explicit; anything else leaves the
|
||||
// orientation to the page shape.
|
||||
if prl.op == S_ORIENTATION && matches!(prl.operand.first(), Some(1 | 2)) {
|
||||
explicit_orient = true;
|
||||
}
|
||||
apply_section(&mut props, &prl);
|
||||
}
|
||||
}
|
||||
// Without an explicit orientation sprm, infer it from the page shape.
|
||||
if !explicit_orient {
|
||||
if props.page_w < props.page_h {
|
||||
props.landscape = false;
|
||||
} else if props.page_w > props.page_h {
|
||||
props.landscape = true;
|
||||
}
|
||||
}
|
||||
out.push(Section { end_cp, props });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Apply a section sprm to the model's section properties. All the measurement sprms here
|
||||
/// carry 16-bit twip operands ([MS-DOC] §2.6.4).
|
||||
fn apply_section(s: &mut SectionProps, prl: &Prl) {
|
||||
let tw = |v: i32| (v as f32 / 20.0).clamp(0.0, 1584.0);
|
||||
let s16 = |prl: &Prl| -> Option<i16> {
|
||||
match prl.operand {
|
||||
[b0, b1, ..] => Some(i16::from_le_bytes([*b0, *b1])),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
match prl.op {
|
||||
S_BKC => {
|
||||
s.start = match prl.operand.first().copied().unwrap_or(2) {
|
||||
// SBkcOperand ([MS-DOC] sprmSBkc): bkcContinuous, bkcNewColumn, bkcNewPage,
|
||||
// bkcEvenPage, bkcOddPage; anything else falls back to the default (new page).
|
||||
0 => SectionStart::Continuous,
|
||||
1 => SectionStart::NextColumn,
|
||||
3 => SectionStart::EvenPage,
|
||||
4 => SectionStart::OddPage,
|
||||
_ => SectionStart::NextPage,
|
||||
};
|
||||
}
|
||||
S_TITLE_PAGE => s.title_page = prl.operand.first().is_some_and(|&v| v != 0),
|
||||
S_CCOLUMNS => {
|
||||
if let Some(c) = s16(prl) {
|
||||
s.columns = Columns { count: (c as u32).saturating_add(1).min(44), ..Default::default() };
|
||||
}
|
||||
}
|
||||
S_PGN_START => {
|
||||
if let Some(v) = s16(prl) {
|
||||
s.page_num_start = Some(v.max(0) as u32);
|
||||
s.page_num_format = NumFormat::Decimal;
|
||||
}
|
||||
}
|
||||
// SBOrientationOperand ([MS-DOC] §2.9.236): 1 portrait, 2 landscape.
|
||||
S_ORIENTATION => match prl.operand.first() {
|
||||
Some(1) => s.landscape = false,
|
||||
Some(2) => s.landscape = true,
|
||||
_ => {}
|
||||
},
|
||||
S_HDR_TOP => {
|
||||
if let Some(v) = s16(prl) {
|
||||
s.header = tw(v as i32);
|
||||
}
|
||||
}
|
||||
S_HDR_BOTTOM => {
|
||||
if let Some(v) = s16(prl) {
|
||||
s.footer = tw(v as i32);
|
||||
}
|
||||
}
|
||||
S_XA_PAGE => {
|
||||
if let Some(v) = s16(prl) {
|
||||
s.page_w = tw(v as i32).max(7.2);
|
||||
}
|
||||
}
|
||||
S_YA_PAGE => {
|
||||
if let Some(v) = s16(prl) {
|
||||
s.page_h = tw(v as i32).max(7.2);
|
||||
}
|
||||
}
|
||||
S_DXA_LEFT => {
|
||||
if let Some(v) = s16(prl) {
|
||||
s.margin_left = tw(v as i32);
|
||||
}
|
||||
}
|
||||
S_DXA_RIGHT => {
|
||||
if let Some(v) = s16(prl) {
|
||||
s.margin_right = tw(v as i32);
|
||||
}
|
||||
}
|
||||
S_DYA_TOP => {
|
||||
if let Some(v) = s16(prl) {
|
||||
s.margin_top = tw(v as i32);
|
||||
}
|
||||
}
|
||||
S_DYA_BOTTOM => {
|
||||
if let Some(v) = s16(prl) {
|
||||
s.margin_bottom = tw(v as i32);
|
||||
}
|
||||
}
|
||||
S_DXA_GUTTER => {
|
||||
if let Some(v) = s16(prl) {
|
||||
s.gutter = tw(v as i32);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The header stories' CP ranges from `Plcfhdd` (a PLC of bare CPs): story i spans
|
||||
/// `[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() };
|
||||
// Only the six separator stories and six per section (at most 1024) are ever used.
|
||||
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)) {
|
||||
let b = if a < kept_to { a } else { b.max(a) };
|
||||
kept_to = kept_to.max(b);
|
||||
stories.push((a, b));
|
||||
}
|
||||
}
|
||||
stories
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
//! Sprm decoding ([MS-DOC] §2.6). A Prl is a 2-byte opcode followed by an operand whose
|
||||
//! size comes from the opcode's `spra` field (with two variable-length exceptions).
|
||||
|
||||
/// `sprmPChgTabs`: custom tab stops added and removed ([MS-DOC] §2.6.3, PChgTabsOperand).
|
||||
pub(crate) const P_CHG_TABS: u16 = 0xC615;
|
||||
/// `sprmPChgTabsPapx`: the same for paragraph and style formatting (PChgTabsPapxOperand).
|
||||
pub(crate) const P_CHG_TABS_PAPX: u16 = 0xC60D;
|
||||
/// `sprmTDefTable`: table cell borders and widths.
|
||||
const T_DEF_TABLE: u16 = 0xD608;
|
||||
|
||||
/// One decoded Prl: an opcode plus its operand bytes.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct Prl<'a> {
|
||||
pub(crate) op: u16,
|
||||
pub(crate) operand: &'a [u8],
|
||||
}
|
||||
|
||||
impl Prl<'_> {
|
||||
/// Which kind of property this sprm modifies: 1 paragraph, 2 character, 3 picture,
|
||||
/// 4 section, 5 table ([MS-DOC] Sprm.sgc).
|
||||
pub(crate) fn sgc(&self) -> u8 {
|
||||
((self.op >> 10) & 7) as u8
|
||||
}
|
||||
}
|
||||
|
||||
/// Iterate the Prls of a grpprl. Malformed tails are dropped.
|
||||
pub(crate) fn iter(grpprl: &[u8]) -> impl Iterator<Item = Prl<'_>> {
|
||||
let mut rest = grpprl;
|
||||
std::iter::from_fn(move || {
|
||||
if rest.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let op = u16::from_le_bytes([rest[0], rest[1]]);
|
||||
let after = &rest[2..];
|
||||
// Operand size per spra, with the exceptions whose size byte lies.
|
||||
let len = match (op >> 13, op) {
|
||||
// PChgTabsOperand.cb is one byte, the size after it; 255 means "work it out" from the
|
||||
// two counts: cb, cTabs, 4 bytes per removed stop, cTabs, 3 bytes per added stop.
|
||||
(_, P_CHG_TABS) => match after.first().copied() {
|
||||
Some(255) => after.get(1).and_then(|&del| {
|
||||
let add = after.get(2 + 4 * del as usize).copied()?;
|
||||
Some(1 + 1 + 4 * del as usize + 1 + 3 * add as usize)
|
||||
}),
|
||||
Some(cb) => Some(1 + cb as usize),
|
||||
None => None,
|
||||
},
|
||||
// TDefTableOperand.cb counts the bytes after cb, incremented by 1, so the
|
||||
// operand is one byte longer than cb.
|
||||
(_, T_DEF_TABLE) => after.get(..2).map(|b| 1 + u16::from_le_bytes([b[0], b[1]]) as usize),
|
||||
(0, _) | (1, _) => Some(1),
|
||||
(2, _) | (4, _) | (5, _) => Some(2),
|
||||
(3, _) => Some(4),
|
||||
(7, _) => Some(3),
|
||||
(6, _) => after.first().map(|&n| 1 + n as usize),
|
||||
_ => None,
|
||||
}?;
|
||||
let operand = after.get(..len)?;
|
||||
rest = &after[len.min(after.len())..];
|
||||
Some(Prl { op, operand })
|
||||
})
|
||||
}
|
||||
|
||||
/// A ToggleOperand value: 0 = off, 1 = on, 0x80 = leave unchanged, 0x81 = invert.
|
||||
pub(crate) enum Toggle {
|
||||
Off,
|
||||
On,
|
||||
Keep,
|
||||
Invert,
|
||||
}
|
||||
|
||||
pub(crate) fn toggle(p: &Prl) -> Toggle {
|
||||
match p.operand.first().copied().unwrap_or(0) {
|
||||
0x81 => Toggle::Invert,
|
||||
0x80 => Toggle::Keep,
|
||||
0x00 => Toggle::Off,
|
||||
_ => Toggle::On,
|
||||
}
|
||||
}
|
||||
|
||||
/// The isprm indices of Prm0 that map to sprms we understand ([MS-DOC] §2.9.215 table).
|
||||
pub(crate) fn from_prm0(isprm: u8) -> Option<u16> {
|
||||
Some(match isprm {
|
||||
0x05 => 0x2461, // sprmPJc
|
||||
0x07 => 0x2405, // sprmPFKeep
|
||||
0x08 => 0x2406, // sprmPFKeepFollow
|
||||
0x09 => 0x2407, // sprmPFPageBreakBefore
|
||||
0x33 => 0x2435, // sprmPWidowControl (low byte of the operand is the value)
|
||||
0x78 => 0x2640, // sprmPOutLvl
|
||||
0x4D => 0x2A0C, // sprmCHighlight
|
||||
0x55 => 0x0835, // sprmCFBold
|
||||
0x56 => 0x0836, // sprmCFItalic
|
||||
0x57 => 0x0837, // sprmCFStrike
|
||||
0x58 => 0x0838, // sprmCFOutline
|
||||
0x59 => 0x0839, // sprmCFShadow
|
||||
0x5A => 0x083A, // sprmCFSmallCaps
|
||||
0x5B => 0x083B, // sprmCFCaps
|
||||
0x5C => 0x083C, // sprmCFVanish
|
||||
0x5E => 0x2A3E, // sprmCKul
|
||||
0x62 => 0x2A42, // sprmCIco
|
||||
0x74 => 0x0854, // sprmCFImprint
|
||||
0x75 => 0x0855, // sprmCFSpec
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
//! The stylesheet (`STSH`) and the font table (`SttbfFfn`) from the Table stream
|
||||
//! ([MS-DOC] §2.9.271 and §2.9.247).
|
||||
|
||||
use crate::DocbinError;
|
||||
|
||||
/// Largest stylesheet we accept (styles are 0x0FFE max by the spec; stay stricter).
|
||||
const MAX_STYLES: usize = 4096;
|
||||
|
||||
/// One raw style record (`STD`).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct RawStyle {
|
||||
/// 1 paragraph, 2 character, 3 table, 4 numbering.
|
||||
pub(crate) stk: u8,
|
||||
/// Zero-based index of the parent style; 0x0FFF = none.
|
||||
pub(crate) istd_base: u16,
|
||||
pub(crate) name: String,
|
||||
/// Paragraph-formatting UPX grpprl (paragraph styles).
|
||||
pub(crate) papx: Vec<u8>,
|
||||
/// Character-formatting UPX grpprl (paragraph and character styles).
|
||||
pub(crate) chpx: Vec<u8>,
|
||||
}
|
||||
|
||||
/// The stylesheet's standard fonts, the ones text takes when neither its style nor its own
|
||||
/// formatting names a font: font-table indexes of the ASCII, East Asian and other
|
||||
/// (non-East-Asian, non-ASCII) fonts (`Stshif.rgftcStandardChpStsh`, [MS-DOC] §2.9.273) and the
|
||||
/// complex-script font (`Stshi.ftcBi`, §2.9.272). `None` where the header is too short for one.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) struct StandardFonts {
|
||||
pub(crate) ascii: Option<u16>,
|
||||
pub(crate) east_asian: Option<u16>,
|
||||
pub(crate) other: Option<u16>,
|
||||
pub(crate) bidi: Option<u16>,
|
||||
}
|
||||
|
||||
/// `StshiLsd.cbLSD`: the size of a latent style's `LSD` ([MS-DOC] `StshiLsd`).
|
||||
const LSD_SIZE: u16 = 4;
|
||||
|
||||
/// The [`StandardFonts`] in the header (`Stshi`) of the `STSH` at `table[fc..fc+lcb]`: Word 365
|
||||
/// writes Aptos there while the font table's first entry is still Times New Roman (#5066).
|
||||
pub(crate) fn standard_fonts(table: &[u8], fc: u32, lcb: u32) -> StandardFonts {
|
||||
let Some(stsh) = (fc as usize).checked_add(lcb as usize).and_then(|end| table.get(fc as usize..end)) else { return StandardFonts::default() };
|
||||
let cb_stshi = stsh.get(0..2).map_or(0, |b| u16::from_le_bytes([b[0], b[1]]) as usize);
|
||||
let stshi = stsh.get(2..).map_or(&[][..], |s| s.get(..cb_stshi.min(s.len())).unwrap_or(&[]));
|
||||
// Stshif: cstd, cbSTDBaseInFile, flags, stiMaxWhenSaved, istdMaxFixedWhenSaved,
|
||||
// nVerBuiltInNamesWhenSaved (2 bytes each), then rgftcStandardChpStsh (3 × 2); ftcBi follows.
|
||||
let at = |o: usize| stshi.get(o..o + 2).map(|b| u16::from_le_bytes([b[0], b[1]]));
|
||||
// ftcBi is optional, and the latent style data (`StshiLsd`, whose `cbLSD` MUST be 4) follows
|
||||
// it: a 4 right after it says it is there, a 4 in its place with more data after says it was
|
||||
// left out. A header that ends right after the field (as Word 97's) has it, a 4 included.
|
||||
let bidi = match (at(18), at(20)) {
|
||||
(b, Some(LSD_SIZE) | None) => b,
|
||||
(Some(LSD_SIZE), Some(_)) => None,
|
||||
(b, _) => b,
|
||||
};
|
||||
StandardFonts { ascii: at(12), east_asian: at(14), other: at(16), bidi }
|
||||
}
|
||||
|
||||
/// Parse the `STSH` at `table[fc..fc+lcb]`.
|
||||
pub(crate) fn parse(table: &[u8], fc: u32, lcb: u32) -> Result<Vec<RawStyle>, DocbinError> {
|
||||
let fc = fc as usize;
|
||||
let lcb = lcb as usize;
|
||||
let end = fc.checked_add(lcb).ok_or_else(|| DocbinError::Malformed("STSH overflow".into()))?;
|
||||
let stsh = table.get(fc..end).ok_or_else(|| DocbinError::Malformed("STSH outside the Table stream".into()))?;
|
||||
if stsh.len() < 4 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
// LPStshi: cbStshi u16 + Stshi; the style count and the size of each Stdf are what we need.
|
||||
let cb_stshi = u16::from_le_bytes([stsh[0], stsh[1]]) as usize;
|
||||
let stshi = stsh.get(2..2 + cb_stshi).unwrap_or(&[]);
|
||||
if stshi.len() < 4 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let cstd = u16::from_le_bytes([stshi[0], stshi[1]]) as usize;
|
||||
let cb_std_base = u16::from_le_bytes([stshi[2], stshi[3]]) as usize;
|
||||
// 10 (Word 97) or 18 (with StdfPost2000); anything else is corrupt but tolerated at 10.
|
||||
let cb_std_base = if cb_std_base == 0x12 { 18 } else { 10 };
|
||||
let cstd = cstd.min(MAX_STYLES);
|
||||
let mut rest = stsh.get(2 + cb_stshi..).unwrap_or(&[]);
|
||||
let mut out = Vec::with_capacity(cstd);
|
||||
for _ in 0..cstd {
|
||||
if rest.len() < 2 || out.len() >= MAX_STYLES {
|
||||
break;
|
||||
}
|
||||
let cb_std = u16::from_le_bytes([rest[0], rest[1]]) as usize;
|
||||
let (std, next) = rest.split_at(rest.len().min(2 + cb_std));
|
||||
rest = next;
|
||||
if cb_std == 0 {
|
||||
out.push(RawStyle::default()); // empty style: keeps istd numbering aligned
|
||||
continue;
|
||||
}
|
||||
let Some(stdf) = std.get(2..2 + cb_std_base.min(cb_std.saturating_sub(2))) else {
|
||||
out.push(RawStyle::default());
|
||||
continue;
|
||||
};
|
||||
// StdfBase ([MS-DOC] §2.9.260, 10 bytes): sti(12)+flags(4); stk(4)+istdBase(12);
|
||||
// cupx(4)+istdNext(12); bchUpe(16) (= cbStd); grfstd(16). istdBase is a zero-based
|
||||
// index into the stylesheet, 0x0FFF meaning "no parent".
|
||||
let stk = (stdf.get(2).copied().unwrap_or(1) & 0x0F).max(1);
|
||||
let w1 = stdf.get(2..4).map(|b| u16::from_le_bytes([b[0], b[1]])).unwrap_or(0xFFF1);
|
||||
let istd_base = w1 >> 4;
|
||||
let w2 = stdf.get(4..6).map(|b| u16::from_le_bytes([b[0], b[1]])).unwrap_or(0);
|
||||
let cupx = (w2 & 0x0F) as usize;
|
||||
let _istd_next = w2 >> 4; // next-paragraph style; unused for now
|
||||
let mut at = 2 + cb_std_base;
|
||||
// xstzName: Xst (cch u16 + UTF-16LE) + terminator u16.
|
||||
let mut name = String::new();
|
||||
if let Some(cch) = std.get(at..at + 2).map(|b| u16::from_le_bytes([b[0], b[1]])).map(|c| (c as usize).min(256)) {
|
||||
if let Some(units) = std.get(at + 2..at + 2 + cch * 2) {
|
||||
let chars: Vec<u16> = units.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).collect();
|
||||
name = String::from_utf16_lossy(&chars);
|
||||
}
|
||||
at += 2 + cch * 2 + 2;
|
||||
}
|
||||
// grLPUpxSw: cupx entries, each cbUpx u16 + data (+ pad to even).
|
||||
let mut upxs: Vec<Vec<u8>> = Vec::new();
|
||||
for _ in 0..cupx.min(3) {
|
||||
let Some(cb) = std.get(at..at + 2).map(|b| u16::from_le_bytes([b[0], b[1]])).map(|v| v as usize) else { break };
|
||||
let data = std.get(at + 2..at + 2 + cb).unwrap_or(&[]).to_vec();
|
||||
upxs.push(data);
|
||||
at += 2 + cb + (cb & 1); // padded to even; cb in the header excludes the pad
|
||||
}
|
||||
// Paragraph styles: UPX0 is a PAPX (istd u16 + grpprl), UPX1 a raw grpprl of
|
||||
// character sprms; character styles: UPX0 is the character grpprl.
|
||||
let mut papx = Vec::new();
|
||||
let mut chpx = Vec::new();
|
||||
if stk == 1 {
|
||||
if let Some(u) = upxs.first() {
|
||||
papx = u.get(2..).unwrap_or(&[]).to_vec(); // skip the istd of GrpPrlAndIstd
|
||||
}
|
||||
if let Some(u) = upxs.get(1) {
|
||||
chpx = u.clone();
|
||||
}
|
||||
} else if stk == 2
|
||||
&& let Some(u) = upxs.first()
|
||||
{
|
||||
chpx = u.clone();
|
||||
}
|
||||
out.push(RawStyle { stk, istd_base, name, papx, chpx });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Font names from the `SttbfFfn` at `table[fc..fc+lcb]` ([MS-DOC] §2.9.286): an STTB
|
||||
/// (§2.2.4) whose entries are FFN records (§2.9.82). Each entry is a length (`cchData`: a
|
||||
/// one-byte byte count in the non-extended table the spec requires, a two-byte count of
|
||||
/// 16-bit units in an extended one) followed by the FFN, and that length alone decides where
|
||||
/// the next entry starts: an FFN ends with its primary name and an optional alternate name
|
||||
/// (`xszAlt`), so scanning for the primary name's terminator would land inside the latter.
|
||||
pub(crate) fn fonts(table: &[u8], fc: u32, lcb: u32) -> Vec<String> {
|
||||
let end = match (fc as usize).checked_add(lcb as usize) {
|
||||
Some(e) if e <= table.len() => e,
|
||||
_ => return Vec::new(),
|
||||
};
|
||||
let Some(sttb) = table.get(fc as usize..end) else { return Vec::new() };
|
||||
let u16at = |o: usize| -> Option<u16> { sttb.get(o..o.checked_add(2)?).map(|b| u16::from_le_bytes([b[0], b[1]])) };
|
||||
let Some(first) = u16at(0) else { return Vec::new() };
|
||||
// FFN: ffid(1) wWeight(2) chs(1) ixchSzAlt(1) panose(10) fs(24) = 39 bytes, then the
|
||||
// primary name, NUL-terminated, as UTF-16LE in the Word 97 files we accept (8-bit names
|
||||
// from older writers fall back to Windows-1252).
|
||||
let name_of = |ffn: &[u8]| -> String {
|
||||
let tail = ffn.get(FFN_HEADER.min(ffn.len())..).unwrap_or(&[]);
|
||||
let units: Vec<u16> = tail.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).take_while(|&u| u != 0).take(64).collect();
|
||||
let s = String::from_utf16_lossy(&units);
|
||||
if s.chars().any(|c| c as u32 > 0x7F || c.is_ascii_graphic() || c == ' ') || s.is_empty() {
|
||||
s
|
||||
} else {
|
||||
// An 8-bit name reads as garbage through UTF-16; fall back to Windows-1252.
|
||||
tail.iter().take_while(|&&b| b != 0).take(64).map(|&b| wordcraft_doc::encoding::cp1252(b)).collect()
|
||||
}
|
||||
};
|
||||
// STTB header: [fExtend 0xFFFF] cData(2) cbExtra(2); each entry is followed by cbExtra
|
||||
// bytes (0 for font tables, but skip them if present).
|
||||
let (extended, cdata, cb_extra, mut at) =
|
||||
if first == 0xFFFF { (true, u16at(2).unwrap_or(0), u16at(4).unwrap_or(0), 6usize) } else { (false, first, u16at(2).unwrap_or(0), 4usize) };
|
||||
let mut out = Vec::new();
|
||||
for _ in 0..cdata.min(1024) {
|
||||
let (len_bytes, cb) = if extended {
|
||||
let Some(cb) = u16at(at) else { break };
|
||||
(2usize, usize::from(cb).saturating_mul(2))
|
||||
} else {
|
||||
let Some(&cb) = sttb.get(at) else { break };
|
||||
(1usize, usize::from(cb))
|
||||
};
|
||||
let start = at.saturating_add(len_bytes);
|
||||
let stop = start.saturating_add(cb);
|
||||
// A record cut short by the end of the table still yields what it holds.
|
||||
out.push(name_of(sttb.get(start..stop.min(sttb.len())).unwrap_or(&[])));
|
||||
at = stop.saturating_add(usize::from(cb_extra));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Bytes of an FFN before its primary name ([MS-DOC] §2.9.82).
|
||||
const FFN_HEADER: usize = 39;
|
||||
@@ -1,518 +0,0 @@
|
||||
//! Tables: the table sprms (sgc 5) carried by a row-end (TTP) mark's grpprl, and the
|
||||
//! assembly of cell-ended paragraphs into [`Table`] blocks ([MS-DOC] §2.6.3, §2.4.3).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::Rgb;
|
||||
use wordcraft_doc::para::Paragraph;
|
||||
use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, CellProps, HeightRule, RowProps, TableProps, VAlign, VMerge};
|
||||
use wordcraft_doc::table::{Cell, Row, Table};
|
||||
use wordcraft_doc::{Block, Blocks, para_block};
|
||||
|
||||
use crate::fmt;
|
||||
use crate::sprm::{self, Prl};
|
||||
|
||||
// Paragraph sprms for table membership.
|
||||
const P_IN_TABLE: u16 = 0x2416;
|
||||
const P_TTP: u16 = 0x2417;
|
||||
/// `sprmPItap`: table depth (we only model depth 1; deeper rows fall back to paragraphs).
|
||||
const P_ITAP: u16 = 0x6649;
|
||||
|
||||
// Table sprms ([MS-DOC] §2.6.3).
|
||||
const T_JC: u16 = 0x5400;
|
||||
const T_JC_90: u16 = 0x548A;
|
||||
const T_DXA_LEFT: u16 = 0x9601;
|
||||
const T_DXA_GAP_HALF: u16 = 0x9602;
|
||||
const T_TABLE_HEADER: u16 = 0x3404;
|
||||
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;
|
||||
const T_VERT_ALIGN: u16 = 0xD62C;
|
||||
const T_SET_SHD: u16 = 0xD62D;
|
||||
const T_CANT_SPLIT: u16 = 0x3466;
|
||||
const T_CELL_WIDTH: u16 = 0xD635;
|
||||
|
||||
/// Most grid columns we build from a `sprmTDefTable` (the model's own cap is 63; one more
|
||||
/// edge is fine but merged rows can sum spans slightly over, so clamp when assembling).
|
||||
const MAX_COLS: usize = 63;
|
||||
|
||||
/// What a paragraph mark says about its table context, from its PAPX.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct RowInfo {
|
||||
pub(crate) in_table: bool,
|
||||
pub(crate) ttp: bool,
|
||||
/// The mark itself was 0x07 (a cell end), set by the walker.
|
||||
pub(crate) cell_mark: bool,
|
||||
/// Table depth from `sprmPItap` (0 or 1 supported; deeper tables degrade to paragraphs).
|
||||
pub(crate) depth: u32,
|
||||
/// 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,
|
||||
cant_split: bool,
|
||||
align: Option<Align>,
|
||||
indent: Option<f32>,
|
||||
borders: Option<Borders>,
|
||||
/// Horizontal merges: (itcFirst, itcLim) cell-index ranges.
|
||||
merges: Vec<(u8, u8)>,
|
||||
/// Per-cell overrides keyed by (pre-merge) cell index.
|
||||
vmerge: Vec<(u8, VMerge)>,
|
||||
/// 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)>,
|
||||
}
|
||||
|
||||
/// Read the table context out of a paragraph mark's PAPX grpprl.
|
||||
impl RowInfo {
|
||||
/// The row height from `sprmTDyaRowHeight`, for tests.
|
||||
#[cfg(test)]
|
||||
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 {
|
||||
let mut r = RowInfo::default();
|
||||
let mut gap_half: Option<i16> = None;
|
||||
let mut dxa_left: Option<i16> = None;
|
||||
for prl in sprm::iter(papx) {
|
||||
// The membership sprms are paragraph sprms (sgc 1); the rest here are table sprms
|
||||
// (sgc 5), so match opcodes directly.
|
||||
match prl.op {
|
||||
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.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 => {
|
||||
if let Some(v) = s16(&prl) {
|
||||
if v < 0 {
|
||||
r.height_rule = HeightRule::Exact;
|
||||
r.height = Some((-(v as f32) / 20.0).clamp(0.0, 1584.0));
|
||||
} else {
|
||||
r.height_rule = HeightRule::AtLeast;
|
||||
r.height = Some((v as f32 / 20.0).clamp(0.0, 1584.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
T_JC | T_JC_90 => {
|
||||
r.align = match u16_of(&prl).unwrap_or(0) {
|
||||
1 => Some(Align::Center),
|
||||
2 => Some(Align::Right),
|
||||
_ => Some(Align::Left),
|
||||
};
|
||||
}
|
||||
T_DXA_LEFT => dxa_left = s16(&prl),
|
||||
T_DXA_GAP_HALF => gap_half = s16(&prl),
|
||||
T_TABLE_BORDERS_80 => r.borders = table_borders(prl.operand),
|
||||
T_MERGE => {
|
||||
if let [a, b, ..] = prl.operand
|
||||
&& b > a
|
||||
{
|
||||
r.merges.push((*a, *b));
|
||||
}
|
||||
}
|
||||
T_VERT_MERGE => {
|
||||
// cb, itc, flag: 1 = continues the merge from above, 3 = restarts it.
|
||||
if let [_, itc, flag, ..] = prl.operand {
|
||||
let m = match flag {
|
||||
3 => VMerge::Restart,
|
||||
1 => VMerge::Continue,
|
||||
_ => VMerge::None,
|
||||
};
|
||||
if m != VMerge::None {
|
||||
r.vmerge.push((*itc, m));
|
||||
}
|
||||
}
|
||||
}
|
||||
T_SET_SHD => {
|
||||
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).min(MAX_COLS as u8) {
|
||||
r.shading.push((c, Some(rgb)));
|
||||
}
|
||||
}
|
||||
}
|
||||
T_DEF_TABLE_SHD_80 => {
|
||||
let cells = prl.operand.get(1..).unwrap_or(&[]);
|
||||
for (i, b) in cells.as_chunks::<2>().0.iter().take(MAX_COLS).enumerate() {
|
||||
if let Some(rgb) = shd80_rgb(b) {
|
||||
r.shading.push((i as u8, Some(rgb)));
|
||||
}
|
||||
}
|
||||
}
|
||||
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 => {
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
T_VERT_ALIGN => {
|
||||
if let [_, a, b, va, ..] = prl.operand {
|
||||
let v = match va {
|
||||
1 => VAlign::Center,
|
||||
2 => VAlign::Bottom,
|
||||
_ => VAlign::Top,
|
||||
};
|
||||
for c in *a..*b {
|
||||
r.valign.push((c, v));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// The table indent is the row origin minus half the cell gap.
|
||||
if let Some(l) = dxa_left {
|
||||
let gap = gap_half.unwrap_or(0) as f32 / 20.0;
|
||||
r.indent = Some(((l as f32 / 20.0) - gap).clamp(-1584.0, 1584.0));
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// A finished paragraph plus the bookkeeping the block assembly needs.
|
||||
pub(crate) struct ParaOut {
|
||||
pub(crate) para: Paragraph,
|
||||
/// CP just past the terminating mark.
|
||||
pub(crate) end_cp: u32,
|
||||
/// Table context from the mark's PAPX (empty default = not in a table).
|
||||
pub(crate) row: RowInfo,
|
||||
}
|
||||
|
||||
/// Group consecutive in-table paragraphs into [`Table`] blocks; the rest pass through.
|
||||
/// Rows that never close (or tables nested deeper than we model) degrade to paragraphs so
|
||||
/// no text is lost.
|
||||
pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
|
||||
let mut out: Blocks = Vec::new();
|
||||
let mut rows: Vec<Row> = Vec::new();
|
||||
let mut cells: Vec<Cell> = Vec::new();
|
||||
let mut cell: Blocks = Vec::new();
|
||||
let mut grid: Vec<f32> = Vec::new();
|
||||
let mut props = TableProps::default();
|
||||
let mut open = false;
|
||||
let mut depth_seen = 0u32;
|
||||
|
||||
fn flush_cell(cells: &mut Vec<Cell>, cell: &mut Blocks) {
|
||||
let mut blocks = std::mem::take(cell);
|
||||
if blocks.is_empty() {
|
||||
blocks.push(para_block(Paragraph::new()));
|
||||
}
|
||||
cells.push(Cell { props: CellProps { span: 1, ..Default::default() }, blocks, controls: Default::default() });
|
||||
}
|
||||
fn build_row(mut cells: Vec<Cell>, info: &RowInfo) -> Row {
|
||||
// Per-cell overrides first (sprm cell indices count pre-merge cells), then merges.
|
||||
for (i, c) in cells.iter_mut().enumerate() {
|
||||
let i = i as u8;
|
||||
if let Some((_, m)) = info.vmerge.iter().find(|(k, _)| *k == i) {
|
||||
c.props.vmerge = *m;
|
||||
}
|
||||
if let Some((_, w)) = info.widths.iter().rfind(|(k, _)| *k == i) {
|
||||
c.props.width = Some(*w);
|
||||
}
|
||||
if let Some((_, pct)) = info.width_pcts.iter().rfind(|(k, _)| *k == i) {
|
||||
c.props.width_pct = Some(*pct);
|
||||
}
|
||||
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) {
|
||||
let a = a as usize;
|
||||
let b = (b as usize).min(cells.len());
|
||||
if a < b && b <= cells.len() && b - a > 1 {
|
||||
let removed: Vec<Cell> = cells.drain(a + 1..b).collect();
|
||||
if let Some(first) = cells.get_mut(a) {
|
||||
first.props.span = ((b - a) as u32).min(63);
|
||||
for c in removed {
|
||||
first.blocks.extend(c.blocks.into_iter().filter(|x| x.as_para().is_none_or(|p| !p.is_empty())));
|
||||
}
|
||||
if first.blocks.len() > 1 && first.blocks.first().and_then(|x| x.as_para()).is_some_and(Paragraph::is_empty) {
|
||||
first.blocks.remove(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Row {
|
||||
props: RowProps {
|
||||
height: info.height,
|
||||
height_rule: info.height_rule,
|
||||
header: info.header,
|
||||
cant_split: info.cant_split,
|
||||
..Default::default()
|
||||
},
|
||||
cells,
|
||||
controls: Default::default(),
|
||||
}
|
||||
}
|
||||
fn flush_table(out: &mut Blocks, rows: &mut Vec<Row>, cells: &mut Vec<Cell>, cell: &mut Blocks, grid: &[f32], props: &TableProps, nested: bool) {
|
||||
if !cells.is_empty() || !cell.is_empty() {
|
||||
flush_cell(cells, cell);
|
||||
// A stray trailing row (no TTP of its own): size its cells from the grid.
|
||||
let mut stray = build_row(std::mem::take(cells), &RowInfo::default());
|
||||
for (i, c) in stray.cells.iter_mut().enumerate() {
|
||||
if c.props.width.is_none() && c.props.span == 1 {
|
||||
c.props.width = grid.get(i).copied();
|
||||
}
|
||||
}
|
||||
rows.push(stray);
|
||||
}
|
||||
if rows.is_empty() {
|
||||
return;
|
||||
}
|
||||
if nested {
|
||||
// Unclosed or nested deeper than we model: keep the text, drop the table shape.
|
||||
for r in rows.iter() {
|
||||
for c in &r.cells {
|
||||
out.extend(c.blocks.iter().cloned());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
out.push(Arc::new(Block::Table(Table {
|
||||
props: props.clone(),
|
||||
grid: grid.to_vec(),
|
||||
rows: std::mem::take(rows),
|
||||
controls: Default::default(),
|
||||
})));
|
||||
}
|
||||
rows.clear();
|
||||
}
|
||||
|
||||
for p in paras {
|
||||
let info = p.row;
|
||||
let is_table = info.in_table || info.ttp || info.cell_mark;
|
||||
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;
|
||||
}
|
||||
out.push(para_block(p.para));
|
||||
continue;
|
||||
}
|
||||
if !open {
|
||||
open = true;
|
||||
}
|
||||
depth_seen = depth_seen.max(info.depth);
|
||||
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;
|
||||
}
|
||||
if props.align.is_none() {
|
||||
props.align = info.align;
|
||||
}
|
||||
if props.indent.is_none() {
|
||||
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));
|
||||
}
|
||||
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);
|
||||
}
|
||||
} else {
|
||||
cell.push(para_block(p.para));
|
||||
}
|
||||
}
|
||||
if open {
|
||||
flush_table(&mut out, &mut rows, &mut cells, &mut cell, &grid, &props, depth_seen > 1);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
return Vec::new();
|
||||
}
|
||||
// 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
|
||||
}
|
||||
|
||||
/// Table borders from `sprmTTableBorders80`: cb (0x18) then six Brc80s in the order top,
|
||||
/// left, bottom, right, inside-horizontal, inside-vertical.
|
||||
fn table_borders(operand: &[u8]) -> Option<Borders> {
|
||||
let b = |i: usize| operand.get(1 + i * 4..1 + i * 4 + 4).and_then(brc80);
|
||||
Some(Borders { top: b(0), left: b(1), bottom: b(2), right: b(3), between: b(4), inside_v: b(5) })
|
||||
}
|
||||
|
||||
/// One Brc80: width in 1/8 pt, type, palette colour, space/flags byte.
|
||||
fn brc80(b: &[u8]) -> Option<Border> {
|
||||
let [w, ty, ico, flags] = b else { return None };
|
||||
let style = match ty {
|
||||
0x00 => BorderStyle::None,
|
||||
0x02 | 0x15 => BorderStyle::Thick,
|
||||
0x03 => BorderStyle::Double,
|
||||
0x04 | 0x05 => BorderStyle::Dotted,
|
||||
0x06 | 0x07 => BorderStyle::Dashed,
|
||||
0x08 | 0x09 => BorderStyle::DotDash,
|
||||
0x0A | 0x18 => BorderStyle::Wave,
|
||||
0x11 => BorderStyle::Triple,
|
||||
_ => BorderStyle::Single,
|
||||
};
|
||||
if *ty == 0 && *w == 0 {
|
||||
return None;
|
||||
}
|
||||
Some(Border { style, width: (*w as f32 / 8.0).clamp(0.25, 12.0), color: fmt::ico(*ico), space: (flags & 0x1F) as f32 })
|
||||
}
|
||||
|
||||
/// Background colour from a 10-byte `Shd`: cvFore, cvBack (COLORREFs), ipat. Solid patterns
|
||||
/// 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 {
|
||||
[_, _, _, 0xFF] => None, // fAuto
|
||||
[r, g, bl, _] => Some(Rgb(*r, *g, *bl)),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
let ipat = b.get(8..10).map(|x| u16::from_le_bytes([x[0], x[1]]))?;
|
||||
match ipat {
|
||||
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). 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 fore == 0x1F && back == 0x1F {
|
||||
return None;
|
||||
}
|
||||
fmt::ico(if ipat == 1 { fore as u8 } else { back as u8 })
|
||||
}
|
||||
|
||||
fn u16_of(p: &Prl) -> Option<u16> {
|
||||
match p.operand {
|
||||
[b0, b1, ..] => Some(u16::from_le_bytes([*b0, *b1])),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn s16(p: &Prl) -> Option<i16> {
|
||||
u16_of(p).map(|v| v as i16)
|
||||
}
|
||||
|
||||
fn i32_of(p: &Prl) -> Option<i32> {
|
||||
match p.operand {
|
||||
[b0, b1, b2, b3, ..] => Some(i32::from_le_bytes([*b0, *b1, *b2, *b3])),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
+33
-2505
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user