mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-26 11:27:10 -06:00
Compare commits
555
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d0c8b7b0d | ||
|
|
7290677207 | ||
|
|
9130181a5f | ||
|
|
00bc6a4b02 | ||
|
|
1928957bef | ||
|
|
8e986366c7 | ||
|
|
653a2311b5 | ||
|
|
169581d786 | ||
|
|
b07e045d7e | ||
|
|
6e8113891e | ||
|
|
54003ebc69 | ||
|
|
f2b06167aa | ||
|
|
079a743e93 | ||
|
|
eb12fc6ee4 | ||
|
|
c04d670117 | ||
|
|
3f46d9c65f | ||
|
|
2060f86e8e | ||
|
|
64f98d824a | ||
|
|
ef39b78adf | ||
|
|
55c31bdbb5 | ||
|
|
946dea4327 | ||
|
|
8efd6667ec | ||
|
|
5d59afae74 | ||
|
|
5cfdd7c0dd | ||
|
|
92bdefc3e8 | ||
|
|
c354162712 | ||
|
|
d99ac8c5a7 | ||
|
|
acfdc0cb5f | ||
|
|
22124867d0 | ||
|
|
65421603e8 | ||
|
|
ec988829c9 | ||
|
|
5dc68481ed | ||
|
|
e1e258e4d9 | ||
|
|
3581f80251 | ||
|
|
04b3881e3e | ||
|
|
16d1c8a933 | ||
|
|
0d3b8eb0cb | ||
|
|
86df1e70ce | ||
|
|
245b5bd8d0 | ||
|
|
674e0cef5b | ||
|
|
3017804459 | ||
|
|
520f54cb81 | ||
|
|
d4debe0592 | ||
|
|
3bf53830c5 | ||
|
|
5c260a16fd | ||
|
|
ed2ecefac2 | ||
|
|
789256d2d1 | ||
|
|
406694ea58 | ||
|
|
ef00b55870 | ||
|
|
d015988e93 | ||
|
|
c91ac7f68f | ||
|
|
31ba42a013 | ||
|
|
c7d5a949a7 | ||
|
|
7b95c59980 | ||
|
|
0255a44d9a | ||
|
|
f62c6f99ed | ||
|
|
61570835bc | ||
|
|
412fc47300 | ||
|
|
eb5d0b74ed | ||
|
|
f90a9896ad | ||
|
|
e311bd410f | ||
|
|
d985d0ecce | ||
|
|
f9d0295d98 | ||
|
|
6336f7b521 | ||
|
|
92b36ceae0 | ||
|
|
9cd24539bd | ||
|
|
b7776ee6ac | ||
|
|
a05c650a16 | ||
|
|
2dc0e4adf7 | ||
|
|
5b19acb4ec | ||
|
|
423fc3f50d | ||
|
|
57205e310a | ||
|
|
264bfdaed3 | ||
|
|
d64ce28903 | ||
|
|
9c3df446ff | ||
|
|
c37c693b46 | ||
|
|
1c757ece6b | ||
|
|
b0fb2b9386 | ||
|
|
932b8028be | ||
|
|
2df0803aea | ||
|
|
b639f355f1 | ||
|
|
9e2b624792 | ||
|
|
bfa5925433 | ||
|
|
66c01b6663 | ||
|
|
833a75d59c | ||
|
|
3f52369269 | ||
|
|
a7b8e32fde | ||
|
|
3c97026297 | ||
|
|
f2c8042093 | ||
|
|
601af83f2c | ||
|
|
61addba013 | ||
|
|
544647318d | ||
|
|
f247a0f1c9 | ||
|
|
2928ef10c0 | ||
|
|
c8b657ff9b | ||
|
|
e362772dee | ||
|
|
0b81f22d5b | ||
|
|
19701dbd18 | ||
|
|
ca14f46b05 | ||
|
|
1d141de189 | ||
|
|
96dd01bb30 | ||
|
|
94b5f4a871 | ||
|
|
029945a313 | ||
|
|
13e33ad072 | ||
|
|
1d0916bfec | ||
|
|
f79372538c | ||
|
|
6bdd901cc9 | ||
|
|
1cb405ea48 | ||
|
|
5c63e25ddc | ||
|
|
3df5e8ec39 | ||
|
|
54d7597afc | ||
|
|
0ac7ba58ae | ||
|
|
93bfd4671c | ||
|
|
ecef6d4ab6 | ||
|
|
82b9795c62 | ||
|
|
cb8d365923 | ||
|
|
ec81170136 | ||
|
|
23f919338a | ||
|
|
503b52eb67 | ||
|
|
3bcb07633f | ||
|
|
7290d7df20 | ||
|
|
6e9bea465a | ||
|
|
7e159a35f2 | ||
|
|
558f6c85af | ||
|
|
cbaa8ab403 | ||
|
|
eedcd7e98c | ||
|
|
ec16888bab | ||
|
|
c9ab2db79f | ||
|
|
dcf2763480 | ||
|
|
415320056b | ||
|
|
bc3290f38d | ||
|
|
992c74e5c8 | ||
|
|
43cd083a9f | ||
|
|
d1182d5f8d | ||
|
|
eae3d8496e | ||
|
|
f14a0ce13f | ||
|
|
43b922e667 | ||
|
|
9eb0dba8d0 | ||
|
|
7d8f31547e | ||
|
|
d5547f80ec | ||
|
|
5a8cb21978 | ||
|
|
53418631eb | ||
|
|
f57d38b3e2 | ||
|
|
32fe53c0ac | ||
|
|
0004566068 | ||
|
|
9b1d6c8393 | ||
|
|
467ad8e279 | ||
|
|
56a392cd48 | ||
|
|
429163d857 | ||
|
|
6d90c345d4 | ||
|
|
e60f53f716 | ||
|
|
61fbb080a9 | ||
|
|
e4c1700ddc | ||
|
|
d15c57a713 | ||
|
|
8077310218 | ||
|
|
11f265b807 | ||
|
|
a69e40353e | ||
|
|
60b4d57a9b | ||
|
|
f29dc368e1 | ||
|
|
0c583fe58c | ||
|
|
570190aa31 | ||
|
|
44bf5bcfb0 | ||
|
|
ee7dc1ce00 | ||
|
|
0ee24065e6 | ||
|
|
1adecc63b1 | ||
|
|
41dc49f581 | ||
|
|
5b9b1f552b | ||
|
|
84371b5e45 | ||
|
|
a18cbda41b | ||
|
|
71c55337af | ||
|
|
cbc3246c2c | ||
|
|
861236167f | ||
|
|
2e67eba0e7 | ||
|
|
d8e5b1823d | ||
|
|
0c039708db | ||
|
|
2d4455ff34 | ||
|
|
60cd2547e2 | ||
|
|
c88b2740d8 | ||
|
|
320a62bce8 | ||
|
|
78f2ef78c6 | ||
|
|
463119f663 | ||
|
|
23e1985189 | ||
|
|
b9f294c769 | ||
|
|
bfbf732047 | ||
|
|
f2e441862d | ||
|
|
dc1039b475 | ||
|
|
05c34486f5 | ||
|
|
3784187781 | ||
|
|
db55704aef | ||
|
|
d21c8825c2 | ||
|
|
f6d7d98a9e | ||
|
|
c2255f1fe8 | ||
|
|
81fbf9fb77 | ||
|
|
abd1c7fb36 | ||
|
|
ade6481d98 | ||
|
|
c5a9c6c6d2 | ||
|
|
e10d20e1a1 | ||
|
|
bd3f8d45e7 | ||
|
|
57387979f1 | ||
|
|
934b3495ad | ||
|
|
39ba45ce30 | ||
|
|
885c9fad59 | ||
|
|
a20cb66f98 | ||
|
|
997164757e | ||
|
|
8b9c85ded8 | ||
|
|
50ef30939f | ||
|
|
d97e32fd06 | ||
|
|
2bb10e3244 | ||
|
|
f2b3fc2491 | ||
|
|
f43e7f49f8 | ||
|
|
a7568eb86c | ||
|
|
a6c31d8efd | ||
|
|
44d569cf74 | ||
|
|
fd7152826e | ||
|
|
5b0ad41cbd | ||
|
|
d3f953a36a | ||
|
|
d064baeaec | ||
|
|
ac334d65f3 | ||
|
|
3bc152f91c | ||
|
|
b9666f7ff8 | ||
|
|
80b19a90c0 | ||
|
|
8837570252 | ||
|
|
00b2bfc329 | ||
|
|
211daa5862 | ||
|
|
0af1a197ad | ||
|
|
0cf588cc48 | ||
|
|
1f37fb230a | ||
|
|
a803888b11 | ||
|
|
4da4ced7ce | ||
|
|
01e3603035 | ||
|
|
29540d03c3 | ||
|
|
5a6d2b6070 | ||
|
|
dbebb2eca7 | ||
|
|
4913b20259 | ||
|
|
7f38f41d8c | ||
|
|
67ec657af6 | ||
|
|
6b01aa967f | ||
|
|
db7807a5f6 | ||
|
|
62dbbe9062 | ||
|
|
726652f088 | ||
|
|
ff3c7d8d25 | ||
|
|
c02ceef673 | ||
|
|
2451b5a421 | ||
|
|
71aeebc2da | ||
|
|
b4a1d34996 | ||
|
|
72a08a16a9 | ||
|
|
7d453e7217 | ||
|
|
a4572e5376 | ||
|
|
40ae6ac08c | ||
|
|
955d268dc9 | ||
|
|
f996654264 | ||
|
|
4f924ca518 | ||
|
|
c5de19a287 | ||
|
|
05827d20fc | ||
|
|
d8f459e70c | ||
|
|
084b76d9de | ||
|
|
be26db3545 | ||
|
|
034e083f58 | ||
|
|
6c26714802 | ||
|
|
fb588fcd46 | ||
|
|
5886e12ac1 | ||
|
|
fd91a78e71 | ||
|
|
6328b25209 | ||
|
|
f5d3ea818c | ||
|
|
a3a573cdd6 | ||
|
|
93dc6000f9 | ||
|
|
e9c0def9bf | ||
|
|
63e794716b | ||
|
|
658e13e9ca | ||
|
|
a8ba49e68b | ||
|
|
bfb81b72eb | ||
|
|
f6c69172cc | ||
|
|
318e7cc315 | ||
|
|
1678d93f54 | ||
|
|
cc724045dd | ||
|
|
51c1847b61 | ||
|
|
22ae9e8961 | ||
|
|
b333885228 | ||
|
|
c60d9df0c3 | ||
|
|
56f56f4735 | ||
|
|
6b66f88f7c | ||
|
|
37809cf380 | ||
|
|
83771b147e | ||
|
|
3944664035 | ||
|
|
7f77660e03 | ||
|
|
c329a3be0e | ||
|
|
85c72c2862 | ||
|
|
67d21b0a68 | ||
|
|
bfb9ecf948 | ||
|
|
52fab678b6 | ||
|
|
aa8ca4bf1c | ||
|
|
ea61d1fcb8 | ||
|
|
cb2072bfe7 | ||
|
|
5124ee6d76 | ||
|
|
35fdb45156 | ||
|
|
07a9880c14 | ||
|
|
cd3944c36e | ||
|
|
a74b2cb8cf | ||
|
|
3b6040ee39 | ||
|
|
7b19cd02d2 | ||
|
|
58fda7ee21 | ||
|
|
05b1c5a2e3 | ||
|
|
5f5a12bf78 | ||
|
|
67eb71facb | ||
|
|
23c47b5c6f | ||
|
|
dff6221f4c | ||
|
|
9603c68905 | ||
|
|
77b6ca0e2a | ||
|
|
1c90d8bb4b | ||
|
|
e2c9c26dfd | ||
|
|
e8b41d157f | ||
|
|
21ceb636f4 | ||
|
|
afbfa43351 | ||
|
|
bf9571d7e1 | ||
|
|
15d5617e70 | ||
|
|
1b712863c1 | ||
|
|
642bf70594 | ||
|
|
d72b5eb2a2 | ||
|
|
e717ae4dc5 | ||
|
|
d51f011a55 | ||
|
|
c2fa8763e3 | ||
|
|
f4299ff644 | ||
|
|
31b36442d5 | ||
|
|
dfb9e1a851 | ||
|
|
253fab8a81 | ||
|
|
6479743c78 | ||
|
|
a31743eb6d | ||
|
|
172b34b62f | ||
|
|
a03deeeb26 | ||
|
|
ab68111717 | ||
|
|
5af42b2748 | ||
|
|
3d03c7cc7a | ||
|
|
1bd8d0b327 | ||
|
|
fdc325fa89 | ||
|
|
ee51f59602 | ||
|
|
950a7adaaa | ||
|
|
62533d21a6 | ||
|
|
37f28eedd0 | ||
|
|
dc5e8cec41 | ||
|
|
8e799be97e | ||
|
|
b57871b6ad | ||
|
|
30b30718e3 | ||
|
|
fffd48c885 | ||
|
|
51ef9b9ebc | ||
|
|
de6a52cc01 | ||
|
|
7f2f45743a | ||
|
|
c3ead8d2c2 | ||
|
|
fc5063c38d | ||
|
|
9ea75bd921 | ||
|
|
50a89f4fa5 | ||
|
|
16f36cdb5d | ||
|
|
c55402452b | ||
|
|
0cca2f7d21 | ||
|
|
ffeccc7148 | ||
|
|
0a93c0edd4 | ||
|
|
8f30acf112 | ||
|
|
ff77295db2 | ||
|
|
835f5b5f5f | ||
|
|
79270d8314 | ||
|
|
43335e7c0e | ||
|
|
8d16b26630 | ||
|
|
714306fd31 | ||
|
|
fc36739622 | ||
|
|
260014167d | ||
|
|
862e7b212f | ||
|
|
a7f888055a | ||
|
|
2d645dba03 | ||
|
|
2c06a6b065 | ||
|
|
66ca8745c0 | ||
|
|
91e4839060 | ||
|
|
d4192a0e98 | ||
|
|
60e3ff8881 | ||
|
|
08d077d97a | ||
|
|
a1d83853ec | ||
|
|
2ce9518be8 | ||
|
|
f550ab7bea | ||
|
|
ddfb47d861 | ||
|
|
912441262e | ||
|
|
49204c5c8b | ||
|
|
579b2dc6ef | ||
|
|
213045230d | ||
|
|
deea76838d | ||
|
|
9992f47270 | ||
|
|
34f638ade1 | ||
|
|
e6e007d018 | ||
|
|
25fb9b93f8 | ||
|
|
1dd86b0d0a | ||
|
|
7489ef9f8e | ||
|
|
9516e3977a | ||
|
|
12bb1039d1 | ||
|
|
9139162e0f | ||
|
|
9c3d39c597 | ||
|
|
1fa2533a52 | ||
|
|
e375004cd6 | ||
|
|
2d957c905b | ||
|
|
88d936c3c5 | ||
|
|
156817d6ba | ||
|
|
5174973770 | ||
|
|
05a3df36f2 | ||
|
|
59d9beaeec | ||
|
|
d1e7acfdf5 | ||
|
|
fb92192fd2 | ||
|
|
6f9b7a1803 | ||
|
|
06a120b19f | ||
|
|
fec27d66d1 | ||
|
|
912b98c833 | ||
|
|
ed7f458560 | ||
|
|
bf1135c0d7 | ||
|
|
a683260abf | ||
|
|
e537dce6af | ||
|
|
10dff145c5 | ||
|
|
076eb68cd0 | ||
|
|
a216519d44 | ||
|
|
c1d829d5c5 | ||
|
|
a22c2757b4 | ||
|
|
94d938fa63 | ||
|
|
858cc838bf | ||
|
|
b42f1b8061 | ||
|
|
af2eb4c6ad | ||
|
|
bb4c579a4d | ||
|
|
d78238345c | ||
|
|
2b43d06b90 | ||
|
|
943fe2c8ae | ||
|
|
7360d50e72 | ||
|
|
452244fd19 | ||
|
|
0a5d0af4f0 | ||
|
|
322b3d765c | ||
|
|
d16dd5cd72 | ||
|
|
7d80ba4caa | ||
|
|
73d159b6c9 | ||
|
|
8664afe58f | ||
|
|
bbdd11f8ff | ||
|
|
9540d5ee4d | ||
|
|
5a5305a7b4 | ||
|
|
4c0dbb429f | ||
|
|
47bcb64dbe | ||
|
|
1ed20b8685 | ||
|
|
53296879db | ||
|
|
a923cf6dc5 | ||
|
|
5813d01f73 | ||
|
|
115ccd0e89 | ||
|
|
a7095086c3 | ||
|
|
85bbc821f0 | ||
|
|
3e5eeccfa2 | ||
|
|
fba2331bbf | ||
|
|
955d166a69 | ||
|
|
328b36c9bd | ||
|
|
52e119da5f | ||
|
|
fec6872e68 | ||
|
|
21604d5fde | ||
|
|
ffec4db77b | ||
|
|
3de7f8e31e | ||
|
|
0d48f236e5 | ||
|
|
bedd8fe2b5 | ||
|
|
53e0dd3ce8 | ||
|
|
4c874d82a2 | ||
|
|
ec26a573be | ||
|
|
54d89d563b | ||
|
|
80f6ba6a25 | ||
|
|
487a7ccb86 | ||
|
|
635137b769 | ||
|
|
5aa87f27f3 | ||
|
|
d9467df1c0 | ||
|
|
da99deb3aa | ||
|
|
e0daa7bdea | ||
|
|
2263941a6f | ||
|
|
de86d323ee | ||
|
|
8d259df48c | ||
|
|
300164dde0 | ||
|
|
66b73105b2 | ||
|
|
70a4d7524c | ||
|
|
5450419a39 | ||
|
|
d342135bc7 | ||
|
|
763f9ca44e | ||
|
|
b22f18467f | ||
|
|
bbedcb73e5 | ||
|
|
98942eb97e | ||
|
|
aa84111205 | ||
|
|
ffb3a2189f | ||
|
|
de3db3f02c | ||
|
|
0fa438c7d1 | ||
|
|
657c37ddd3 | ||
|
|
8450147093 | ||
|
|
f695a53365 | ||
|
|
7817c137a9 | ||
|
|
80b4b82419 | ||
|
|
bea8ab8704 | ||
|
|
4a9f24f642 | ||
|
|
0c9575230c | ||
|
|
e60c699775 | ||
|
|
6a60779105 | ||
|
|
7c4f41270a | ||
|
|
3c968aa782 | ||
|
|
09f065c11f | ||
|
|
e30e7fd132 | ||
|
|
b407859f74 | ||
|
|
0d1ede1291 | ||
|
|
da47503f1d | ||
|
|
3e2cf5edb4 | ||
|
|
425e01dec9 | ||
|
|
02fc9e6f9a | ||
|
|
e5ffb15cb5 | ||
|
|
2ffdf353a1 | ||
|
|
193b82d570 | ||
|
|
0137a5157a | ||
|
|
c9bebab058 | ||
|
|
48ff1e52be | ||
|
|
772cccf038 | ||
|
|
51fc220092 | ||
|
|
5817874b6d | ||
|
|
4818b7b8f2 | ||
|
|
f9808ae495 | ||
|
|
07ecf526a6 | ||
|
|
8711f5cfd1 | ||
|
|
16e18d8492 | ||
|
|
efef885543 | ||
|
|
a4e2cebc07 | ||
|
|
62bb20187d | ||
|
|
9e477da0ee | ||
|
|
68479ee33f | ||
|
|
dfdce752dc | ||
|
|
262310756d | ||
|
|
147866bce5 | ||
|
|
19109657d0 | ||
|
|
bdbfa62056 | ||
|
|
57a000e978 | ||
|
|
9371cb0536 | ||
|
|
5795259c45 | ||
|
|
9d22d69ee5 | ||
|
|
e500df7fb6 | ||
|
|
624ce0bdc2 | ||
|
|
e4d9eb61b9 | ||
|
|
70cfe06846 | ||
|
|
bdc7226fc7 | ||
|
|
7e33e5ae31 | ||
|
|
9066db8334 | ||
|
|
e9f1724f8a | ||
|
|
8a0dcefb3f | ||
|
|
3ab6d287d1 | ||
|
|
8922ec6ff7 | ||
|
|
a4b3a74655 | ||
|
|
6931b8db2a | ||
|
|
9acb1241b5 | ||
|
|
622df29a1e | ||
|
|
07479516dd | ||
|
|
16adb80b66 | ||
|
|
a643d4bec5 | ||
|
|
3fb47b910c | ||
|
|
71d327163e | ||
|
|
322454a8cb | ||
|
|
5b84776c05 | ||
|
|
2d2e064ac6 | ||
|
|
9e608e85aa | ||
|
|
be93f3b82d | ||
|
|
ad485db499 |
@@ -50,7 +50,7 @@ jobs:
|
||||
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@stable
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
@@ -43,6 +43,7 @@ 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
|
||||
|
||||
@@ -11,10 +11,12 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
|
||||
| `assets/hyphenation/en-us.pat` | DesignCraft contributors (generated) | trained from the Moby list by DesignCraft's patgen | Public Domain (CC0-1.0) | Our own Liang hyphenation patterns (no TeX patterns) |
|
||||
| `assets/hyphenation/en-us.dic` | Grady Ward (Moby Hyphenator II word list); converted by DesignCraft contributors | https://www.gutenberg.org/ebooks/3204 (file `mhyph.txt`, SHA-256 `eeb30474c86b8af3469035ec1a0913e35905325ca885290db9dfed9881e230ac`), via DesignCraft | Public Domain | ~165k words with break points; drives spelling and hyphenation |
|
||||
| `assets/spelling/en-extra.txt` | WordCraft contributors | original word list written for this repository (common modern words the Moby list lacks); no other dictionary copied | MIT OR Apache-2.0 | ~240 lowercase English base forms supplementing spelling (#520), including British spellings (#40) |
|
||||
| `assets/spelling/en-variants.txt` | WordCraft contributors | original list written for this repository from general knowledge of English spelling; no other dictionary or variant list copied | MIT OR Apache-2.0 | ~280 American/British spelling pairs (-or/-our, -er/-re, -ize/-ise, -yze/-yse, -ense/-ence, doubled l, ae/oe…) with a policy per English: en-US, en-CA and the British-spelling Englishes (#1281, #1045) |
|
||||
| `assets/spelling/ru-extra.txt` | WordCraft contributors | original word list written for this repository (common modern Russian words older Hunspell dictionaries lack, with their declension classes); no other dictionary copied | MIT OR Apache-2.0 | ~200 Russian base forms supplementing spelling; their forms are generated by `crates/proof/src/ru/extra.rs` |
|
||||
| `assets/hyphenation/pl.pat` | Hanna Kołodziejska, Bogusław Jackowski, Marek Ryćko (Polish TeX hyphenation patterns, 1987–1995); hyph-utf8 package | https://github.com/hyphenation/tex-hyphen (`hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-pl.tex`, SHA-256 `83ddbbd21bc06a44bc4f4a4c49c38782e809cff5f026b764c044daa29e818843`), bundled unmodified | MIT (the file is offered under MIT or Knuth's public-domain terms; WordCraft uses MIT, whose notice is in the file's header) | 4,053 Liang patterns and 20 exception words; Polish hyphenation |
|
||||
| `assets/hyphenation/pt.pat` | Pedro J. de Rezende, J. Joao Dias Almeida, Leonardo Araujo, Aline Benevides (Portuguese TeX hyphenation patterns, 1987–2024); hyph-utf8 package | https://github.com/hyphenation/tex-hyphen (`hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-pt.tex`, SHA-256 `c110a39e501db372d1d755e1c64464321a2efa7d8e9617f66001000389b941f2`), bundled unmodified | BSD 3-clause (the file's licence header names it and carries the notice) | 426 Liang patterns and 2 exception words; Portuguese hyphenation |
|
||||
| `assets/hyphenation/it.pat` | Claudio Beccari (Italian TeX hyphenation patterns, 2008–2014); hyph-utf8 package | https://github.com/hyphenation/tex-hyphen (`hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-it.tex`, SHA-256 `6ce56ed6ed2ca7c0688838e366e6595e0429f984f4fc6e96ec045cebb1e76a47`), bundled unmodified | MIT (the file is offered under LPPL 1.3+ or MIT; WordCraft uses MIT, whose notice is in the file's header) | 355 Liang patterns, no exception words; Italian hyphenation |
|
||||
| `assets/hyphenation/de.pat` | Deutschsprachige Trennmustermannschaft: Stephan Hennig, Werner Lemberg, Günter Milde, Sander van Geloven, Georg Pfeiffer, Gisbert W. Selke, Tobias Wendorf, Keno Wehr (German TeX hyphenation patterns, reformed spelling, 2013–2024); hyph-utf8 package | https://github.com/hyphenation/tex-hyphen (`hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-de-1996.tex`, SHA-256 `374ad1ce3263f8a2791ec070ef9b516c532dae64456f3fb656e617e8ae53a9d3`), bundled unmodified | MIT (the notice is in the file's header) | 36,709 Liang patterns, no exception words; German hyphenation (de-DE, de-AT, de-CH) |
|
||||
| `assets/app-icon/wordcraft-1024.png` | WordCraft contributors | original artwork drawn in SVG (`assets/app-icon/wordcraft.svg`), rendered by `packaging/icons.sh` | MIT OR Apache-2.0 | Placeholder app icon until the WordCraft mascot drawing lands |
|
||||
| `packaging/macos/dmg/background.svg` | @XusBadia | original work for this repository, derived from the WordCraft app icon (`assets/app-icon/wordcraft.svg`, referenced via `<image>`, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0, like the code ([`LICENSE-MIT`](LICENSE-MIT), [`LICENSE-APACHE`](LICENSE-APACHE)) | WordCraft macOS DMG window background (2026-10-08); used for the Finder window of the macOS DMG, rendered to `background.tiff` by `packaging/macos/dmg/generate.py` |
|
||||
| `packaging/macos/dmg/background.tiff` | @XusBadia | Rendered from `packaging/macos/dmg/background.svg` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0, like the code ([`LICENSE-MIT`](LICENSE-MIT), [`LICENSE-APACHE`](LICENSE-APACHE)) | 1x + 2x HiDPI TIFF for the macOS DMG window; includes the app icon (see its row) and LittleCMS's built-in sRGB profile ("No copyright, use freely") |
|
||||
@@ -74,7 +76,9 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
|
||||
| `crates/ui-egui/src/i18n/ru.tsv` | Dmitrii Khlapov with Claude | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Russian interface catalog; reviewed by a native speaker |
|
||||
| `crates/ui-egui/src/i18n/tr.tsv` | @PbyLoyth with Codex and Claude Code | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Turkish interface catalog |
|
||||
| `crates/ui-egui/src/i18n/bg.tsv` | Dimitar Klaturov (@bulgariamitko) with Claude Code | Original translation from the English interface labels in this repository; no other product localisation or other catalog used | MIT OR Apache-2.0 | Bulgarian interface catalog |
|
||||
| `crates/ui-egui/src/i18n/sl.tsv` | Filip Strajnar with Grok Bot | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Slovenian interface catalog |
|
||||
| `crates/ui-egui/src/i18n/hu.tsv` | @Skaywalker | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Hungarian interface catalog |
|
||||
| `crates/ui-egui/src/i18n/fr.tsv` | @pechnikov2026-star | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | French interface catalog |
|
||||
| `crates/ui-egui/src/i18n/sv.tsv` | Erik Holmbom (@holmbom) with Claude Code | Original translation from the English interface labels in this repository; no other product localisation or other catalog used | MIT OR Apache-2.0 | Swedish interface catalog; AI-assisted, terminology and the running app reviewed by a native speaker |
|
||||
| `crates/ui-egui/src/i18n/hr.tsv` | Andrej Škvorc (@andrejskvorc) with Claude Code (Sonnet 5.5); proofread with Gemini | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Croatian interface catalog; reviewed and corrected in the running interface, menu by menu, by the native-speaker author, who also checked the Gemini suggestions |
|
||||
| `crates/ui-egui/src/i18n/ro.tsv` | Viorel Toader (@vioreltus) with Claude Code (Opus 5.5) | Original translation from the English interface labels in this repository; no other product localisation copied (a few dozen short, generic labels were reused from the PdfCraft Romanian catalog written the same way) | MIT OR Apache-2.0 | Romanian interface catalog; AI-assisted draft, not yet reviewed line by line by a native speaker |
|
||||
|
||||
Generated
+38
-21
@@ -704,6 +704,15 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.5.2"
|
||||
@@ -2883,7 +2892,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5131,9 +5140,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
"clipboard-win",
|
||||
"craft-print",
|
||||
"dispatch2",
|
||||
@@ -5164,7 +5174,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-cli"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"sys-locale",
|
||||
@@ -5177,12 +5187,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-control-key"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-doc"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"proptest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -5193,9 +5204,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-docbin"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chrono",
|
||||
"image",
|
||||
"log",
|
||||
"miniz_oxide 0.8.9",
|
||||
@@ -5209,11 +5221,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-docx"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64",
|
||||
"cfb",
|
||||
"chrono",
|
||||
"getrandom 0.3.4",
|
||||
"hmac",
|
||||
"image",
|
||||
@@ -5232,9 +5245,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-engine"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chrono",
|
||||
"encoding_rs",
|
||||
"getrandom 0.3.4",
|
||||
"image",
|
||||
@@ -5261,7 +5275,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-fonts"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"harfrust 0.13.3",
|
||||
"kurbo",
|
||||
@@ -5274,10 +5288,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-formats"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chardetng",
|
||||
"chrono",
|
||||
"encoding_rs",
|
||||
"image",
|
||||
"js-sys",
|
||||
@@ -5291,14 +5306,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-geom"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-layout"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"js-sys",
|
||||
@@ -5312,12 +5327,13 @@ dependencies = [
|
||||
"wordcraft-doc",
|
||||
"wordcraft-fonts",
|
||||
"wordcraft-geom",
|
||||
"wordcraft-metafile",
|
||||
"wordcraft-proof",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-mcp"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde_json",
|
||||
@@ -5329,7 +5345,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-metafile"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"thiserror 2.0.21",
|
||||
@@ -5337,8 +5353,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-pdf"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-pdf",
|
||||
"hayro-cmap",
|
||||
"hayro-interpret",
|
||||
@@ -5357,7 +5374,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-proof"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"miniz_oxide 0.8.9",
|
||||
@@ -5366,7 +5383,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-render"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -5380,7 +5397,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-ui-egui"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"craft-ui",
|
||||
@@ -5407,7 +5424,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-web"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5426,7 +5443,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-zotero"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -5539,7 +5556,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+3
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -35,6 +35,8 @@ thiserror = "2"
|
||||
serde = { version = "1", features = ["derive", "rc"] }
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
log = "0.4"
|
||||
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
|
||||
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
|
||||
proptest = "1"
|
||||
rayon = "1"
|
||||
unicode-bidi = "0.3"
|
||||
|
||||
@@ -52,6 +52,28 @@ list (author, source, license) is in ATTRIBUTION.md.
|
||||
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
||||
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
|
||||
OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
- German hyphenation patterns (assets/hyphenation/de.pat) — hyph-de-1996.tex (reformed spelling)
|
||||
from the hyph-utf8 package, bundled unmodified and used under the MIT License; the copyright
|
||||
and permission notice is in the file:
|
||||
|
||||
Copyright (c) 2013-2024 Stephan Hennig, Werner Lemberg, Günter Milde, Sander van Geloven,
|
||||
Georg Pfeiffer, Gisbert W. Selke, Tobias Wendorf, Keno Wehr
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this
|
||||
software and associated documentation files (the "Software"), to deal in the Software
|
||||
without restriction, including without limitation the rights to use, copy, modify, merge,
|
||||
publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
|
||||
to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or
|
||||
substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
||||
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
||||
PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
|
||||
FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
||||
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
- Italian hyphenation patterns (assets/hyphenation/it.pat) — hyph-it.tex from the hyph-utf8
|
||||
package, Copyright (C) 2008-2011 Claudio Beccari, bundled unmodified and used under the MIT
|
||||
License (offered under LPPL 1.3+ or MIT); the copyright and permission notice is in the file:
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.wordcraft.png" width="128" height="128" alt="WordCraft icon: a document page and pen on a blue tile">
|
||||
</p>
|
||||
|
||||
<h1 align="center">WordCraft</h1>
|
||||
|
||||
<p align="center">
|
||||
@@ -120,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, 한국어 or Svenska (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 or Slovenščina (follows the system language by default) |
|
||||
| **Files** | .docx read/write, legacy 97-2003 .doc import, PDF export, .odt, .rtf, .html, .md, .tex, .txt import/export, page images |
|
||||
|
||||
The honest picture, area by area, is in [ROADMAP.md](ROADMAP.md), the
|
||||
|
||||
+122
-7
File diff suppressed because one or more lines are too long
@@ -471,12 +471,13 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
||||
}
|
||||
let mut s = match (from_file, from_template) {
|
||||
(Some(f), _) => open(&f, password.as_deref())?,
|
||||
(None, Some(t)) => {
|
||||
// Both go through `file.new`, which puts a blank document on the region's paper (#3152).
|
||||
(None, t) => {
|
||||
let mut s = session(wordcraft_doc::Document::new());
|
||||
s.run("file.new", &json!({"template": t})).map_err(|e| e.to_string())?;
|
||||
let params = t.map_or_else(|| json!({}), |t| json!({"template": t}));
|
||||
s.run("file.new", ¶ms).map_err(|e| e.to_string())?;
|
||||
s
|
||||
}
|
||||
_ => session(wordcraft_doc::Document::new()),
|
||||
};
|
||||
if let Some(name) = arg_value(&rest, "--author") {
|
||||
set_author(&mut s, &name)?;
|
||||
|
||||
@@ -40,10 +40,11 @@ fn html_converts_onto_the_region_paper() {
|
||||
|
||||
#[test]
|
||||
fn every_run_session_follows_the_region() {
|
||||
// `run --file`, `run --template` and a bare `run` all start a session in the system region,
|
||||
// `run --file`, `run --template` and a bare `run` (with or without `file.new`) all start a session in the system region,
|
||||
// so `file.new` gives the same paper whichever way the session began.
|
||||
for args in [
|
||||
&["run", "--cmd", "file.new", "--save", "{out}"][..],
|
||||
&["run", "--cmd", "text.insert={\"text\":\"x\"}", "--save", "{out}"][..],
|
||||
&["run", "--template", "blank", "--save", "{out}"][..],
|
||||
&["run", "--file", "{src}", "--cmd", "file.new", "--save", "{out}"][..],
|
||||
] {
|
||||
|
||||
@@ -23,6 +23,7 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
# Icon + VERSIONINFO for wordcraft.exe; build.rs only uses it when the target is Windows.
|
||||
|
||||
@@ -93,8 +93,12 @@ fn picker(dialog: rfd::AsyncFileDialog, req: &FileDialogRequest) -> Picking {
|
||||
match req.clone() {
|
||||
FileDialogRequest::Open { purpose } => Box::pin(async move { open_filters(dialog, &purpose).pick_file().await.into_iter().collect() }),
|
||||
FileDialogRequest::OpenMany { purpose } => Box::pin(async move { open_filters(dialog, &purpose).pick_files().await.unwrap_or_default() }),
|
||||
FileDialogRequest::Save { name } => {
|
||||
FileDialogRequest::Save { name, dir } => {
|
||||
let mut dialog = dialog.set_file_name(name.clone());
|
||||
// Save as Word Template starts in the user templates folder (#2852).
|
||||
if let Some(dir) = dir.filter(|d| std::path::Path::new(d).is_dir()) {
|
||||
dialog = dialog.set_directory(dir);
|
||||
}
|
||||
for (label, exts) in save_filters(&name) {
|
||||
dialog = dialog.add_filter(label, &exts);
|
||||
}
|
||||
|
||||
@@ -94,7 +94,10 @@ fn matches(name: &str, target: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch (the
|
||||
/// `duration_since` clamp); past the last date chrono can represent (year 262143) the proleptic
|
||||
/// Gregorian date is kept via the 400-year cycle rather than falling back to the epoch, so the
|
||||
/// output never changes for a value the old formatter accepted and logging never panics.
|
||||
pub fn timestamp(t: SystemTime) -> String {
|
||||
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
|
||||
let secs = since.as_secs();
|
||||
@@ -103,20 +106,25 @@ pub fn timestamp(t: SystemTime) -> String {
|
||||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
|
||||
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch). Saturating, so no clock
|
||||
/// value can overflow it.
|
||||
fn civil_from_days(days: u64) -> (u64, u64, u64) {
|
||||
let z = days.saturating_add(719_468);
|
||||
let era = z / 146_097;
|
||||
let doe = z % 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
|
||||
(y, m, d)
|
||||
/// Days since 1970-01-01 → proleptic Gregorian (year, month, day).
|
||||
///
|
||||
/// `timestamp` clamps a pre-1970 clock to the epoch, so `days` is an unsigned count that can run
|
||||
/// past `NaiveDate`'s ±262143-year range. 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`, then saturated into `i64`), keeping chrono as the only calendar logic and
|
||||
/// reproducing the old `civil_from_days` result exactly without hand-rolled day math. 1970-01-01
|
||||
/// is `num_days_from_ce` 719_163.
|
||||
fn civil_from_days(days: u64) -> (i64, u32, u32) {
|
||||
use chrono::Datelike;
|
||||
let (cycles, rest) = (days / 146_097, days % 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(i64::MAX), d.month(), d.day())
|
||||
}
|
||||
None => (1970, 1, 1), // unreachable
|
||||
}
|
||||
}
|
||||
|
||||
fn numbered(dir: &Path, n: usize) -> PathBuf {
|
||||
@@ -435,8 +443,21 @@ mod tests {
|
||||
// 2000 is a leap year (divisible by 400), 2100 is not.
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
|
||||
// Before the epoch (a clock set wrong) clamps instead of panicking.
|
||||
// Before the epoch (a clock set wrong) clamps to the epoch instead of panicking.
|
||||
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
|
||||
// Past chrono's range (a clock set absurdly far ahead) keeps the proleptic Gregorian date
|
||||
// the old formatter gave, rather than falling back to the epoch. FredApps' counterexample:
|
||||
// UNIX_EPOCH + 10_000_000_000_000 s was 318857-05-20T17:46:40.000Z and must stay so.
|
||||
// Windows' SystemTime holds only up to about year 30828, so this is checked where it fits.
|
||||
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
|
||||
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
|
||||
}
|
||||
// Windows' SystemTime can't hold u64::MAX / 2 seconds, so it is only checked where it exists.
|
||||
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
|
||||
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
|
||||
}
|
||||
// Years past 9999 keep the plain zero-padded year (no `+`).
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -77,7 +77,7 @@ impl eframe::App for App {
|
||||
{
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||
self.0.quit_requested = false;
|
||||
schedule_macos_terminate(&self.0, self.2.as_ref());
|
||||
schedule_macos_terminate(&mut self.0, self.2.as_ref());
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
if self.0.quit_requested {
|
||||
@@ -94,6 +94,7 @@ impl eframe::App for App {
|
||||
}
|
||||
fn on_exit(&mut self) {
|
||||
print::finish_pending_jobs();
|
||||
self.0.session.finish_recovery();
|
||||
save_prefs(&self.0);
|
||||
if let Some(key_file) = &self.2 {
|
||||
key_file.remove();
|
||||
@@ -109,7 +110,7 @@ impl eframe::App for App {
|
||||
/// removed first, as `on_exit` does, because a fallback [`std::process::exit`]
|
||||
/// skips `on_exit`. A second close while a quit is already scheduled is ignored.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn schedule_macos_terminate(app: &WordApp, key_file: Option<&control_server::KeyFile>) {
|
||||
fn schedule_macos_terminate(app: &mut WordApp, key_file: Option<&control_server::KeyFile>) {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
if app.printing.busy() {
|
||||
return;
|
||||
@@ -121,6 +122,7 @@ fn schedule_macos_terminate(app: &WordApp, key_file: Option<&control_server::Key
|
||||
log::info!("window close: quitting");
|
||||
// This path can bypass on_exit. Join the owned workers before termination and cleanup.
|
||||
print::finish_pending_jobs();
|
||||
app.session.finish_recovery();
|
||||
save_prefs(app);
|
||||
if let Some(key_file) = key_file {
|
||||
key_file.remove();
|
||||
@@ -244,6 +246,8 @@ fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services {
|
||||
print_native: Some(print::submit()),
|
||||
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.
|
||||
templates_dir: wordcraft_control_key::settings_dir().map(|d| d.join("Templates")),
|
||||
animations_enabled: Some(animations::hook(ctx)),
|
||||
clipboard_text: Some(Box::new(clipboard::text)),
|
||||
clipboard_html: Some(Box::new(clipboard::html)),
|
||||
@@ -303,10 +307,10 @@ fn text_args() -> (Vec<String>, Vec<std::path::PathBuf>) {
|
||||
|
||||
/// 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.
|
||||
fn open_non_unicode(app: &mut WordApp, path: &std::path::Path) {
|
||||
let name = path.to_string_lossy();
|
||||
let opened = std::fs::read(path)
|
||||
.map_err(|e| e.to_string())
|
||||
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)})));
|
||||
match opened {
|
||||
// Not when it waits for a password: that open names the document itself.
|
||||
@@ -513,6 +517,40 @@ mod tests {
|
||||
assert_eq!(app.session.path.as_deref(), Some(path.as_path()));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// 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]
|
||||
fn a_pipe_or_device_whose_name_isnt_unicode_is_not_read() {
|
||||
let dir = std::env::temp_dir().join(format!("wc-app-non-utf8-dev-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let fifo = dir.join(std::ffi::OsString::from_vec(b"fif\xe9.txt".to_vec()));
|
||||
if !std::process::Command::new("mkfifo").arg(&fifo).status().is_ok_and(|s| s.success()) {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
eprintln!("mkfifo unavailable; skipping");
|
||||
return;
|
||||
}
|
||||
let zero = dir.join(std::ffi::OsString::from_vec(b"z\xe9ro.txt".to_vec()));
|
||||
std::os::unix::fs::symlink("/dev/zero", &zero).unwrap();
|
||||
// If the open blocks on the pipe, a writer comes after 10 s, so the test fails rather than hangs.
|
||||
let unblock = fifo.clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_secs(10));
|
||||
let _ = std::fs::OpenOptions::new().write(true).open(unblock);
|
||||
});
|
||||
|
||||
let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
|
||||
app.run("text.insert", serde_json::json!({"text": "kept"})).unwrap();
|
||||
for p in [&fifo, &zero] {
|
||||
let started = std::time::Instant::now();
|
||||
open_non_unicode(&mut app, p);
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(5), "{p:?} took {:?}", started.elapsed());
|
||||
assert_eq!(app.session.path, None, "{p:?}");
|
||||
assert!(app.session.doc.plain_text(wordcraft_doc::StoryRef::Body).contains("kept"), "{p:?}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,305 @@
|
||||
# English spelling variants: American and British forms of the same word, for the English
|
||||
# dialects (#1281, #1045). Written by WordCraft contributors from general knowledge of English
|
||||
# spelling (MIT OR Apache-2.0); no other word list copied. See ATTRIBUTION.md.
|
||||
#
|
||||
# Each line: POLICY AMERICAN BRITISH
|
||||
# POLICY is three letters, for American English (en-US), Canadian English (en-CA) and the
|
||||
# British-spelling Englishes (en-GB, en-AU, en-NZ, en-IE, en-ZA, en-IN…), in that order:
|
||||
# u only the American form is right there (the British one is flagged, American suggested)
|
||||
# b only the British form is right there
|
||||
# * both are right
|
||||
# Inflections (colours, centred, organising, organisation…) come from the rules in
|
||||
# crates/proof/src/en_variants.rs for -or/-our, -er/-re and -ize/-ise style pairs; other pairs
|
||||
# get only a plural, so list their other forms on lines of their own. When in doubt a form is
|
||||
# accepted: a false alarm is worse than a missed variant.
|
||||
|
||||
# -or / -our
|
||||
ubb arbor arbour
|
||||
ubb ardor ardour
|
||||
ubb armor armour
|
||||
ubb armory armoury
|
||||
ubb behavior behaviour
|
||||
ubb behavioral behavioural
|
||||
ubb candor candour
|
||||
ubb clamor clamour
|
||||
ubb color colour
|
||||
ubb demeanor demeanour
|
||||
ubb discolor discolour
|
||||
ubb dishonor dishonour
|
||||
ubb endeavor endeavour
|
||||
ubb favor favour
|
||||
ubb favorite favourite
|
||||
ubb favoritism favouritism
|
||||
ubb fervor fervour
|
||||
ubb flavor flavour
|
||||
ubb harbor harbour
|
||||
ubb honor honour
|
||||
ubb humor humour
|
||||
ubb labor labour
|
||||
ubb misbehavior misbehaviour
|
||||
ubb neighbor neighbour
|
||||
ubb neighborhood neighbourhood
|
||||
ubb neighborly neighbourly
|
||||
ubb odor odour
|
||||
ubb parlor parlour
|
||||
ubb rancor rancour
|
||||
ubb rigor rigour
|
||||
ubb rumor rumour
|
||||
ubb savior saviour
|
||||
ubb savor savour
|
||||
ubb savory savoury
|
||||
ubb splendor splendour
|
||||
ubb succor succour
|
||||
ubb tumor tumour
|
||||
ubb unfavorable unfavourable
|
||||
ubb valor valour
|
||||
ubb vapor vapour
|
||||
ubb vigor vigour
|
||||
|
||||
# -er / -re
|
||||
ubb caliber calibre
|
||||
ubb center centre
|
||||
ubb centimeter centimetre
|
||||
ubb epicenter epicentre
|
||||
ubb fiber fibre
|
||||
ubb kilometer kilometre
|
||||
ubb liter litre
|
||||
ubb luster lustre
|
||||
ubb maneuver manoeuvre
|
||||
ubb meager meagre
|
||||
ubb millimeter millimetre
|
||||
ubb miter mitre
|
||||
ubb ocher ochre
|
||||
ubb saber sabre
|
||||
ubb sepulcher sepulchre
|
||||
ubb somber sombre
|
||||
ubb specter spectre
|
||||
*bb theater theatre
|
||||
# A meter is also a measuring device in British English.
|
||||
u** meter metre
|
||||
|
||||
# -ize / -ise (British English accepts both: Oxford spelling uses -ize)
|
||||
uu* agonize agonise
|
||||
uu* apologize apologise
|
||||
uu* authorize authorise
|
||||
uu* capitalize capitalise
|
||||
uu* categorize categorise
|
||||
uu* centralize centralise
|
||||
uu* characterize characterise
|
||||
uu* civilize civilise
|
||||
uu* colonize colonise
|
||||
uu* commercialize commercialise
|
||||
uu* computerize computerise
|
||||
uu* criticize criticise
|
||||
uu* crystallize crystallise
|
||||
uu* customize customise
|
||||
uu* decentralize decentralise
|
||||
uu* demonize demonise
|
||||
uu* deodorize deodorise
|
||||
uu* destabilize destabilise
|
||||
uu* digitize digitise
|
||||
uu* dramatize dramatise
|
||||
uu* economize economise
|
||||
uu* emphasize emphasise
|
||||
uu* energize energise
|
||||
uu* equalize equalise
|
||||
uu* familiarize familiarise
|
||||
uu* fantasize fantasise
|
||||
uu* fertilize fertilise
|
||||
uu* finalize finalise
|
||||
uu* formalize formalise
|
||||
uu* fossilize fossilise
|
||||
uu* galvanize galvanise
|
||||
uu* generalize generalise
|
||||
uu* globalize globalise
|
||||
uu* harmonize harmonise
|
||||
uu* hospitalize hospitalise
|
||||
uu* humanize humanise
|
||||
uu* hypnotize hypnotise
|
||||
uu* hypothesize hypothesise
|
||||
uu* idealize idealise
|
||||
uu* idolize idolise
|
||||
uu* immobilize immobilise
|
||||
uu* immortalize immortalise
|
||||
uu* immunize immunise
|
||||
uu* industrialize industrialise
|
||||
uu* initialize initialise
|
||||
uu* italicize italicise
|
||||
uu* itemize itemise
|
||||
uu* jeopardize jeopardise
|
||||
uu* legalize legalise
|
||||
uu* legitimize legitimise
|
||||
uu* liberalize liberalise
|
||||
uu* localize localise
|
||||
uu* magnetize magnetise
|
||||
uu* marginalize marginalise
|
||||
uu* maximize maximise
|
||||
uu* mechanize mechanise
|
||||
uu* memorize memorise
|
||||
uu* mesmerize mesmerise
|
||||
uu* minimize minimise
|
||||
uu* mobilize mobilise
|
||||
uu* modernize modernise
|
||||
uu* monopolize monopolise
|
||||
uu* moralize moralise
|
||||
uu* nationalize nationalise
|
||||
uu* naturalize naturalise
|
||||
uu* neutralize neutralise
|
||||
uu* normalize normalise
|
||||
uu* optimize optimise
|
||||
uu* organize organise
|
||||
uu* ostracize ostracise
|
||||
uu* oxidize oxidise
|
||||
uu* pasteurize pasteurise
|
||||
uu* patronize patronise
|
||||
uu* penalize penalise
|
||||
uu* personalize personalise
|
||||
uu* plagiarize plagiarise
|
||||
uu* polarize polarise
|
||||
uu* popularize popularise
|
||||
uu* pressurize pressurise
|
||||
uu* prioritize prioritise
|
||||
uu* privatize privatise
|
||||
uu* publicize publicise
|
||||
uu* pulverize pulverise
|
||||
uu* randomize randomise
|
||||
uu* rationalize rationalise
|
||||
uu* realize realise
|
||||
uu* recognize recognise
|
||||
uu* reorganize reorganise
|
||||
uu* revolutionize revolutionise
|
||||
uu* satirize satirise
|
||||
uu* scrutinize scrutinise
|
||||
uu* sensitize sensitise
|
||||
uu* socialize socialise
|
||||
uu* specialize specialise
|
||||
uu* stabilize stabilise
|
||||
uu* standardize standardise
|
||||
uu* sterilize sterilise
|
||||
uu* stigmatize stigmatise
|
||||
uu* subsidize subsidise
|
||||
uu* summarize summarise
|
||||
uu* symbolize symbolise
|
||||
uu* sympathize sympathise
|
||||
uu* synchronize synchronise
|
||||
uu* synthesize synthesise
|
||||
uu* systematize systematise
|
||||
uu* tantalize tantalise
|
||||
uu* terrorize terrorise
|
||||
uu* theorize theorise
|
||||
uu* trivialize trivialise
|
||||
uu* urbanize urbanise
|
||||
uu* utilize utilise
|
||||
uu* vandalize vandalise
|
||||
uu* verbalize verbalise
|
||||
uu* victimize victimise
|
||||
uu* visualize visualise
|
||||
uu* vocalize vocalise
|
||||
uu* westernize westernise
|
||||
|
||||
# -yze / -yse
|
||||
u*b analyze analyse
|
||||
u*b catalyze catalyse
|
||||
u*b dialyze dialyse
|
||||
u*b electrolyze electrolyse
|
||||
u*b hydrolyze hydrolyse
|
||||
u*b paralyze paralyse
|
||||
u*b psychoanalyze psychoanalyse
|
||||
|
||||
# -ense / -ence
|
||||
ubb defense defence
|
||||
ubb offense offence
|
||||
ubb pretense pretence
|
||||
ubb defenseless defenceless
|
||||
# In British English "license" and "practise" are the verbs, so only American English flags.
|
||||
u** license licence
|
||||
u** practice practise
|
||||
|
||||
# Doubled l (American English accepts both)
|
||||
*bb canceled cancelled
|
||||
*bb canceling cancelling
|
||||
*bb channeled channelled
|
||||
*bb channeling channelling
|
||||
*bb counseled counselled
|
||||
*bb counseling counselling
|
||||
*bb counselor counsellor
|
||||
*bb counselors counsellors
|
||||
*bb dueling duelling
|
||||
*bb equaled equalled
|
||||
*bb equaling equalling
|
||||
*bb fueled fuelled
|
||||
*bb fueling fuelling
|
||||
*bb jeweler jeweller
|
||||
*bb jewelers jewellers
|
||||
*bb jewelry jewellery
|
||||
*bb labeled labelled
|
||||
*bb labeling labelling
|
||||
*bb leveled levelled
|
||||
*bb leveling levelling
|
||||
*bb marveled marvelled
|
||||
*bb marveling marvelling
|
||||
*bb marvelous marvellous
|
||||
*bb modeled modelled
|
||||
*bb modeling modelling
|
||||
*bb quarreled quarrelled
|
||||
*bb quarreling quarrelling
|
||||
*bb signaled signalled
|
||||
*bb signaling signalling
|
||||
*bb totaled totalled
|
||||
*bb totaling totalling
|
||||
*bb traveled travelled
|
||||
*bb traveler traveller
|
||||
*bb travelers travellers
|
||||
*bb traveling travelling
|
||||
*bb tunneled tunnelled
|
||||
*bb tunneling tunnelling
|
||||
*bb woolen woollen
|
||||
|
||||
# -og / -ogue
|
||||
*bb catalog catalogue
|
||||
*bb cataloged catalogued
|
||||
*bb cataloging cataloguing
|
||||
|
||||
# ae / oe
|
||||
u*b anemia anaemia
|
||||
u*b anemic anaemic
|
||||
u*b anesthesia anaesthesia
|
||||
u*b anesthetic anaesthetic
|
||||
**b archeology archaeology
|
||||
**b archeologist archaeologist
|
||||
u*b diarrhea diarrhoea
|
||||
u*b edema oedema
|
||||
u*b esophagus oesophagus
|
||||
u*b estrogen oestrogen
|
||||
u*b gynecologist gynaecologist
|
||||
u*b gynecology gynaecology
|
||||
u*b hematology haematology
|
||||
u*b hemoglobin haemoglobin
|
||||
u*b hemorrhage haemorrhage
|
||||
u*b leukemia leukaemia
|
||||
u*b orthopedic orthopaedic
|
||||
u*b pediatric paediatric
|
||||
u*b pediatrician paediatrician
|
||||
u*b pediatrics paediatrics
|
||||
|
||||
# Other words
|
||||
u*b aluminum aluminium
|
||||
u*b cozy cosy
|
||||
ubb mold mould
|
||||
ubb molded moulded
|
||||
ubb molding moulding
|
||||
ubb moldy mouldy
|
||||
ubb molt moult
|
||||
ubb pajamas pyjamas
|
||||
ubb plow plough
|
||||
ubb plowed ploughed
|
||||
ubb plowing ploughing
|
||||
u** program programme
|
||||
u*b skeptic sceptic
|
||||
u*b skeptical sceptical
|
||||
u*b skepticism scepticism
|
||||
ubb smolder smoulder
|
||||
ubb smoldering smouldering
|
||||
# "Tire" and "curb" are also verbs everywhere, so only American English flags.
|
||||
uu* tire tyre
|
||||
uu* curb kerb
|
||||
@@ -166,3 +166,12 @@ real_name = "Max Murphy"
|
||||
|
||||
[people.SlavenDj]
|
||||
sync_github_name = true
|
||||
|
||||
[people.asystemoffields]
|
||||
display_name = "asystemoffields"
|
||||
|
||||
[people.dmim2502-commits]
|
||||
display_name = "Mulai"
|
||||
|
||||
[people.Bortlesboat]
|
||||
real_name = "Andrew Barnes"
|
||||
|
||||
@@ -10,6 +10,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
wordcraft-geom = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
//! How [`crate::Document::media`] and [`crate::Document::passthrough`] are written in the native
|
||||
//! JSON form (`.json` files and crash-recovery copies): each part's bytes as one base64 string.
|
||||
//! serde's default for bytes, an array of numbers, took about 3.6 times their size compact and
|
||||
//! over six times pretty-printed (a 27 MB picture made a 99 MB recovery copy, #586).
|
||||
//!
|
||||
//! Reading takes either form, so `.json` files and recovery copies written before still open.
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
|
||||
use base64::Engine as _;
|
||||
use base64::display::Base64Display;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use serde::de::{self, Deserializer, MapAccess, SeqAccess, Visitor};
|
||||
use serde::ser::{Serialize, Serializer};
|
||||
|
||||
type Store = BTreeMap<String, Arc<Vec<u8>>>;
|
||||
|
||||
/// One part's bytes, encoded as it is written out: no second copy of a large picture is made.
|
||||
struct Encoded<'a>(&'a [u8]);
|
||||
|
||||
impl Serialize for Encoded<'_> {
|
||||
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||
s.collect_str(&Base64Display::new(self.0, &STANDARD))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn serialize<S: Serializer>(store: &Store, s: S) -> Result<S::Ok, S::Error> {
|
||||
s.collect_map(store.iter().map(|(name, bytes)| (name, Encoded(bytes))))
|
||||
}
|
||||
|
||||
pub(crate) fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Store, D::Error> {
|
||||
d.deserialize_map(StoreVisitor)
|
||||
}
|
||||
|
||||
struct StoreVisitor;
|
||||
|
||||
impl<'de> Visitor<'de> for StoreVisitor {
|
||||
type Value = Store;
|
||||
|
||||
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
f.write_str("a map of part names to base64 text or byte arrays")
|
||||
}
|
||||
|
||||
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Store, A::Error> {
|
||||
let mut store = Store::new();
|
||||
while let Some((name, Bytes(bytes))) = map.next_entry::<String, Bytes>()? {
|
||||
store.insert(name, Arc::new(bytes));
|
||||
}
|
||||
Ok(store)
|
||||
}
|
||||
}
|
||||
|
||||
/// A part's bytes as either form.
|
||||
struct Bytes(Vec<u8>);
|
||||
|
||||
impl<'de> de::Deserialize<'de> for Bytes {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
d.deserialize_any(BytesVisitor).map(Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
struct BytesVisitor;
|
||||
|
||||
impl<'de> Visitor<'de> for BytesVisitor {
|
||||
type Value = Vec<u8>;
|
||||
|
||||
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
f.write_str("base64 text or an array of bytes")
|
||||
}
|
||||
|
||||
fn visit_str<E: de::Error>(self, text: &str) -> Result<Vec<u8>, E> {
|
||||
STANDARD.decode(text).map_err(|e| E::custom(format!("bytes are not valid base64: {e}")))
|
||||
}
|
||||
|
||||
fn visit_bytes<E: de::Error>(self, bytes: &[u8]) -> Result<Vec<u8>, E> {
|
||||
Ok(bytes.to_vec())
|
||||
}
|
||||
|
||||
/// The form written before: one number per byte.
|
||||
fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<u8>, A::Error> {
|
||||
// The length hint comes from the file: trust it only so far.
|
||||
let mut out = Vec::with_capacity(seq.size_hint().unwrap_or(0).min(1 << 20));
|
||||
while let Some(b) = seq.next_element::<u8>()? {
|
||||
out.push(b);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug, Default)]
|
||||
struct Parts {
|
||||
#[serde(default, with = "super")]
|
||||
media: Store,
|
||||
}
|
||||
|
||||
fn parts(entries: &[(&str, &[u8])]) -> Parts {
|
||||
Parts { media: entries.iter().map(|(n, b)| ((*n).to_string(), Arc::new(b.to_vec()))).collect() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bytes_are_written_as_base64_and_read_back() {
|
||||
let all: Vec<u8> = (0..=255).collect();
|
||||
for n in [0, 1, 2, 3, 4, 255, 256] {
|
||||
let p = parts(&[("a.png", &all[..n.min(all.len())])]);
|
||||
let text = serde_json::to_string(&p).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Parts>(&text).unwrap(), p, "{n}");
|
||||
}
|
||||
assert_eq!(serde_json::to_string(&parts(&[("m", b"Man"), ("ma", b"Ma")])).unwrap(), r#"{"media":{"m":"TWFu","ma":"TWE="}}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_arrays_written_before_still_read() {
|
||||
let old = r#"{"media":{"a.png":[0,1,127,128,254,255],"empty":[]}}"#;
|
||||
assert_eq!(serde_json::from_str::<Parts>(old).unwrap(), parts(&[("a.png", &[0, 1, 127, 128, 254, 255]), ("empty", &[])]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_values_are_errors_not_panics() {
|
||||
for bad in [r#""T""#, r#""TWFu!""#, r#""-_""#, r#""TQ=x""#, "[256]", "[-1]", "[1.5]", "true", "{}", r#"["a"]"#] {
|
||||
let text = format!(r#"{{"media":{{"a.png":{bad}}}}}"#);
|
||||
assert!(serde_json::from_str::<Parts>(&text).is_err(), "{bad}");
|
||||
}
|
||||
assert!(serde_json::from_str::<Parts>(r#"{"media":"x"}"#).is_err());
|
||||
}
|
||||
}
|
||||
@@ -150,6 +150,8 @@ const HU: &[Tailor] = &[
|
||||
const SV: &[Tailor] = &[t("å", 'z', 1), t("ä", 'z', 2), ts("æ", 'z', 2, 16), t("ö", 'z', 3), ts("ø", 'z', 3, 11)];
|
||||
const DA: &[Tailor] = &[t("æ", 'z', 1), ts("ä", 'z', 1, 4), t("ø", 'z', 2), ts("ö", 'z', 2, 4), t("å", 'z', 3)];
|
||||
const ES: &[Tailor] = &[t("ñ", 'n', 1)];
|
||||
// Russian ё shares е's primary weight; the diaeresis breaks ties (CLDR Russian standard).
|
||||
const RU: &[Tailor] = &[ts("ё", 'е', 0, 4)];
|
||||
const TR: &[Tailor] = &[t("ç", 'c', 1), t("ğ", 'g', 1), t("ı", 'h', 1), t("ö", 'o', 1), t("ş", 's', 1), t("ü", 'u', 1)];
|
||||
const HR: &[Tailor] =
|
||||
&[t("č", 'c', 1), t("ć", 'c', 2), t("dž", 'd', 1), t("đ", 'd', 2), t("lj", 'l', 1), t("nj", 'n', 1), t("š", 's', 1), t("ž", 'z', 1)];
|
||||
@@ -184,6 +186,7 @@ fn tailoring(lang: Option<&str>) -> &'static [Tailor] {
|
||||
"sv" | "fi" => SV,
|
||||
"da" | "nb" | "nn" | "no" => DA,
|
||||
"es" | "gl" => ES,
|
||||
"ru" => RU,
|
||||
"tr" | "az" => TR,
|
||||
"hr" | "bs" | "sr" => HR,
|
||||
"ro" => RO,
|
||||
@@ -196,7 +199,7 @@ fn tailoring(lang: Option<&str>) -> &'static [Tailor] {
|
||||
}
|
||||
}
|
||||
|
||||
/// A lowercase Latin letter followed by a combining mark, and the single letter Unicode
|
||||
/// A lowercase Latin or Russian letter followed by a combining mark, and the single letter Unicode
|
||||
/// composes them to (NFC; generated from the Unicode Character Database). Text pasted from
|
||||
/// decomposed sources (macOS file names: `z` + U+0307) collates like the composed letter.
|
||||
const COMPOSE: &[(char, char, char)] = &[
|
||||
@@ -329,6 +332,8 @@ const COMPOSE: &[(char, char, char)] = &[
|
||||
('z', '\u{302}', 'ẑ'),
|
||||
('z', '\u{307}', 'ż'),
|
||||
('z', '\u{30c}', 'ž'),
|
||||
('е', '\u{308}', 'ё'),
|
||||
('и', '\u{306}', 'й'),
|
||||
];
|
||||
|
||||
/// `s` lowercased, with each letter + combining mark that has a single-letter form composed.
|
||||
@@ -476,6 +481,25 @@ mod tests {
|
||||
assert_eq!(sorted(&["Straße", "Strasse", "Strand"], "de"), ["Strand", "Strasse", "Straße"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn russian_yo_sorts_with_e_instead_of_after_ya() {
|
||||
for lang in ["ru", "ru-RU", "RU_ru"] {
|
||||
assert_eq!(sorted(&["яблоко", "ёлка", "жук", "ель", "ёж", "еж"], lang), ["еж", "ёж", "ёлка", "ель", "жук", "яблоко"]);
|
||||
assert_eq!(collate("ЁЛКА", "ёлка", Some(lang)), Ordering::Equal);
|
||||
assert_eq!(heading_letter("Ёлка", Some(lang)).as_deref(), Some("Ё"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn russian_decomposed_letters_match_their_composed_forms() {
|
||||
for (composed, decomposed) in [("ёлка", "е\u{308}лка"), ("Ёлка", "Е\u{308}лка"), ("йод", "и\u{306}од"), ("Йод", "И\u{306}од")]
|
||||
{
|
||||
assert_eq!(collate(composed, decomposed, Some("ru-RU")), Ordering::Equal);
|
||||
assert_eq!(heading_letter(composed, Some("ru")), heading_letter(decomposed, Some("ru")));
|
||||
}
|
||||
assert_eq!(sorted(&["к", "и\u{306}", "и"], "ru"), ["и", "и\u{306}", "к"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spanish_n_tilde_after_n() {
|
||||
assert_eq!(sorted(&["oso", "ñu", "nube"], "es-ES"), ["nube", "ñu", "oso"]);
|
||||
|
||||
@@ -41,6 +41,10 @@ pub struct ShapeExtra {
|
||||
/// The outline dash pattern; absent in older saved documents means solid.
|
||||
#[serde(skip_serializing_if = "crate::line_dash::LineDash::is_solid")]
|
||||
pub dash: crate::line_dash::LineDash,
|
||||
/// The outline's gradient (`a:ln/a:gradFill`), kept for saving; the outline is drawn in its
|
||||
/// main colour (the shape's `stroke`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub outline_fill: Option<crate::fill::Fill>,
|
||||
}
|
||||
|
||||
fn is_zero(v: &u32) -> bool {
|
||||
|
||||
+128
-2
@@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize};
|
||||
use crate::para::InlineObject;
|
||||
use crate::props::{CharProps, ParaProps};
|
||||
use crate::{Block, Blocks, DocError, Document, Paragraph, Part, PartKind, Path, Pos, Result, para_block};
|
||||
use crate::{Table, table::MAX_TABLE_DEPTH};
|
||||
|
||||
/// A copied piece of a document: blocks, where the first and last paragraphs may be partial.
|
||||
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
|
||||
@@ -136,6 +137,53 @@ fn order(a: &Pos, b: &Pos) -> (Pos, Pos) {
|
||||
if a <= b { (a.clone(), b.clone()) } else { (b.clone(), a.clone()) }
|
||||
}
|
||||
|
||||
/// The blocks in `t`'s cells, row by row.
|
||||
fn cell_blocks(t: &Table) -> impl DoubleEndedIterator<Item = &Block> {
|
||||
t.rows.iter().flat_map(|r| r.cells.iter()).flat_map(|c| c.blocks.iter()).map(|b| &**b)
|
||||
}
|
||||
|
||||
/// How many tables deep `t` goes, itself included: 1 when its cells hold no table (without
|
||||
/// recursion: a pasted fragment may be deep).
|
||||
fn table_height(t: &Table) -> usize {
|
||||
let mut most = 1;
|
||||
let mut stack: Vec<(&Block, usize)> = cell_blocks(t).map(|b| (b, 1)).collect();
|
||||
while let Some((b, d)) = stack.pop() {
|
||||
if let Block::Table(inner) = b {
|
||||
let d = d.saturating_add(1);
|
||||
most = most.max(d);
|
||||
stack.extend(cell_blocks(inner).map(|cb| (cb, d)));
|
||||
}
|
||||
}
|
||||
most
|
||||
}
|
||||
|
||||
/// `b` as blocks nesting at most `room` tables deep: a table that would go deeper is replaced by
|
||||
/// its paragraphs (tables in its cells too, document order), as the DOCX reader does.
|
||||
fn fit_tables(b: Block, room: usize, out: &mut Vec<Block>) {
|
||||
match b {
|
||||
Block::Table(t) if room == 0 => {
|
||||
let mut stack: Vec<&Block> = cell_blocks(&t).rev().collect();
|
||||
while let Some(cb) = stack.pop() {
|
||||
match cb {
|
||||
Block::Para(p) => out.push(Block::Para(p.clone())),
|
||||
Block::Table(inner) => stack.extend(cell_blocks(inner).rev()),
|
||||
}
|
||||
}
|
||||
}
|
||||
Block::Table(mut t) if table_height(&t) > room => {
|
||||
for cell in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
|
||||
let mut kept = Vec::new();
|
||||
for cb in std::mem::take(&mut cell.blocks) {
|
||||
fit_tables(Arc::unwrap_or_clone(cb), room.saturating_sub(1), &mut kept);
|
||||
}
|
||||
cell.blocks = kept.into_iter().map(Arc::new).collect();
|
||||
}
|
||||
out.push(Block::Table(t));
|
||||
}
|
||||
b => out.push(b),
|
||||
}
|
||||
}
|
||||
|
||||
/// Nesting limit when following text boxes inside text boxes.
|
||||
const MAX_BOX_DEPTH: usize = 8;
|
||||
/// Most text box stories one paste may create (hostile documents can fan out).
|
||||
@@ -361,8 +409,14 @@ impl Document {
|
||||
Ok(Pos { story: pos.story, path: pos.path.with_last(i + 1), off: 0 })
|
||||
}
|
||||
|
||||
/// Insert a block before the block at `path` (same container).
|
||||
/// Insert a block before the block at `path` (same container). A table that would nest
|
||||
/// deeper than [`MAX_TABLE_DEPTH`] is refused.
|
||||
pub fn insert_block(&mut self, story: crate::StoryRef, path: &Path, block: Block) -> Result<()> {
|
||||
if let Block::Table(t) = &block
|
||||
&& path.depth().saturating_add(table_height(t)) > MAX_TABLE_DEPTH
|
||||
{
|
||||
return Err(DocError::Invalid(format!("tables can't nest more than {MAX_TABLE_DEPTH} deep")));
|
||||
}
|
||||
let i = path.last();
|
||||
let bl = self.container_mut(story, path)?;
|
||||
if i > bl.len() {
|
||||
@@ -736,7 +790,12 @@ 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 blocks = frag.blocks.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());
|
||||
for b in frag.blocks.iter().cloned() {
|
||||
fit_tables(b, room, &mut blocks);
|
||||
}
|
||||
if blocks.is_empty() {
|
||||
return Ok(pos.clone());
|
||||
}
|
||||
@@ -941,6 +1000,73 @@ mod tests {
|
||||
assert!(d.plain_text(StoryRef::Body).starts_with("XYalpha"));
|
||||
}
|
||||
|
||||
/// The first paragraph of the deepest cell, following the first table at each level.
|
||||
fn deepest(d: &Document) -> Path {
|
||||
let (mut path, mut blocks) = (Vec::new(), &d.body);
|
||||
while let Some((i, t)) = blocks.iter().enumerate().find_map(|(i, b)| if let Block::Table(t) = &**b { Some((i, t)) } else { None }) {
|
||||
path.extend([i as u32, 0, 0]);
|
||||
blocks = &t.rows[0].cells[0].blocks;
|
||||
}
|
||||
path.push(0);
|
||||
Path(path)
|
||||
}
|
||||
|
||||
fn body_height(d: &Document) -> usize {
|
||||
d.body.iter().filter_map(|b| if let Block::Table(t) = &**b { Some(table_height(t)) } else { None }).max().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// How many times `c` is in `blocks`, the deepest cells too.
|
||||
fn count(blocks: &[Arc<Block>], c: char) -> usize {
|
||||
blocks
|
||||
.iter()
|
||||
.map(|b| match &**b {
|
||||
Block::Para(p) => p.text.matches(c).count(),
|
||||
Block::Table(t) => t.rows.iter().flat_map(|r| &r.cells).map(|cell| count(&cell.blocks, c)).sum(),
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tables_nest_at_most_max_table_depth() {
|
||||
let mut d = doc(&["x", ""]);
|
||||
for _ in 0..MAX_TABLE_DEPTH {
|
||||
let at = deepest(&d);
|
||||
d.insert_block(StoryRef::Body, &at, Block::Table(Table::new(1, 1, 100.0))).unwrap();
|
||||
}
|
||||
assert_eq!(body_height(&d), MAX_TABLE_DEPTH);
|
||||
// One more in the deepest cell is refused and changes nothing; a paragraph is fine.
|
||||
let (at, before) = (deepest(&d), d.clone());
|
||||
assert_eq!(at.depth(), MAX_TABLE_DEPTH);
|
||||
assert!(d.insert_block(StoryRef::Body, &at, Block::Table(Table::new(1, 1, 100.0))).is_err());
|
||||
assert_eq!(d, before);
|
||||
d.insert_block(StoryRef::Body, &at, Block::Para(Paragraph::new())).unwrap();
|
||||
// One level up, a table fits but a table holding a table doesn't.
|
||||
let up = Path(at.0[..at.0.len() - 3].to_vec());
|
||||
let mut two = Table::new(1, 1, 100.0);
|
||||
two.rows[0].cells[0].blocks.insert(0, Arc::new(Block::Table(Table::new(1, 1, 100.0))));
|
||||
assert!(d.insert_block(StoryRef::Body, &up, Block::Table(two)).is_err());
|
||||
d.insert_block(StoryRef::Body, &up, Block::Table(Table::new(1, 1, 100.0))).unwrap();
|
||||
assert_eq!(body_height(&d), MAX_TABLE_DEPTH);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasting_a_table_into_its_own_cell_stops_at_max_table_depth() {
|
||||
// #1596: copy everything and paste it into the deepest cell, again and again: each round
|
||||
// doubled the nesting, until saving, copying or closing the document overflowed the stack.
|
||||
let mut d = doc(&["x", ""]);
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(Table::new(1, 1, 100.0))).unwrap();
|
||||
d.insert_text(&Pos { story: StoryRef::Body, path: Path(vec![1, 0, 0, 0]), off: 0 }, "a", &CharProps::default()).unwrap();
|
||||
for _ in 0..8 {
|
||||
let all = d.copy_range(&Pos::body(0, 0), &Pos::body(d.body.len() - 1, 0));
|
||||
let at = deepest(&d);
|
||||
let off = d.para(StoryRef::Body, &at).map_or(0, Paragraph::len);
|
||||
d.insert_fragment(&Pos { story: StoryRef::Body, path: at, off }, &all).unwrap();
|
||||
}
|
||||
assert_eq!(body_height(&d), MAX_TABLE_DEPTH);
|
||||
// Nothing is lost: every copy's text is there, below the limit as paragraphs.
|
||||
assert_eq!((count(&d.body, 'x'), count(&d.body, 'a')), (256, 256));
|
||||
}
|
||||
|
||||
fn text_box(d: &mut Document, pos: &Pos, text: &str) -> u32 {
|
||||
let id = d.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text(text, CharProps::default()))]);
|
||||
d.insert_object(pos, box_shape(Some(id)), &CharProps::default()).unwrap();
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! 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), and Mac OS Roman
|
||||
//! (10000, RTF `\mac`).
|
||||
//! 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] = [
|
||||
@@ -84,7 +85,7 @@ const CP1255: [u16; 128] = [
|
||||
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, 0xFFFD, 0x05BB, 0x05BC, 0x05BD, 0x05BE, 0x05BF, 0x05C0, 0x05C1, 0x05C2, 0x05C3, 0x05F0, 0x05F1, 0x05F2, 0x05F3, 0x05F4, 0xFFFD,
|
||||
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,
|
||||
@@ -286,13 +287,47 @@ 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… `None` for the ANSI and default charsets (the
|
||||
/// document's code page applies), symbol fonts and the double-byte East Asian charsets.
|
||||
/// European → 1250, 77 Mac → 10000, 254 PC / 255 OEM → 437, 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,
|
||||
254 | 255 => 437,
|
||||
128 => 932,
|
||||
129 => 949,
|
||||
134 => 936,
|
||||
136 => 950,
|
||||
161 => 1253,
|
||||
162 => 1254,
|
||||
163 => 1258,
|
||||
@@ -302,6 +337,7 @@ pub fn charset_code_page(charset: i32) -> Option<u16> {
|
||||
204 => 1251,
|
||||
222 => 874,
|
||||
238 => 1250,
|
||||
254 | 255 => 437,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -333,6 +369,11 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[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() {
|
||||
@@ -347,6 +388,25 @@ mod tests {
|
||||
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>();
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
//! Shape fills: what paints a shape's, text box's or canvas's interior, and a shape outline's
|
||||
//! gradient (DrawingML `a:solidFill` / `a:gradFill`, ECMA-376 §20.1.8.54 and §20.1.8.33; VML
|
||||
//! `v:fill`).
|
||||
//!
|
||||
//! A gradient is kept as the file states it (stops, linear angle or path focus, tile rectangle,
|
||||
//! `rotWithShape`, flip) so saving writes it back; drawing approximates rectangular and
|
||||
//! shape-following paths as circular. Transparencies are percent (0 = opaque); rectangles are
|
||||
//! percent insets (left, top, right, bottom) from the shape's edges.
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::props::Rgb;
|
||||
#[cfg(test)]
|
||||
use crate::wordart::MAX_STOPS;
|
||||
use crate::wordart::{GradStop, clamp, sanitized_stops};
|
||||
|
||||
/// A shape's fill. In `.json` documents an opaque solid fill is written `[r, g, b]` as before
|
||||
/// fills had transparency or gradients; the rest are tagged (`{"type": "gradient", ...}`).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Fill {
|
||||
Solid { color: Rgb, transparency: f32 },
|
||||
Gradient(Arc<Gradient>),
|
||||
}
|
||||
|
||||
/// A gradient fill.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Gradient {
|
||||
pub stops: Vec<GradStop>,
|
||||
pub kind: GradientKind,
|
||||
/// `a:tileRect`: where the gradient's area sits relative to the shape (percent insets).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tile: Option<[f32; 4]>,
|
||||
/// Turns with the shape (`rotWithShape`).
|
||||
#[serde(default)]
|
||||
pub rot_with_shape: bool,
|
||||
#[serde(default, skip_serializing_if = "GradientFlip::is_none")]
|
||||
pub flip: GradientFlip,
|
||||
}
|
||||
|
||||
/// How a gradient runs.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum GradientKind {
|
||||
/// Along a line at `angle` degrees (0 = left to right, 90 = top to bottom). `scaled`: the
|
||||
/// angle is scaled with the shape's aspect ratio.
|
||||
Linear {
|
||||
angle: f32,
|
||||
#[serde(default)]
|
||||
scaled: bool,
|
||||
},
|
||||
/// Outwards from `focus` (percent insets from the shape's edges, `a:fillToRect`).
|
||||
Path { shape: PathShape, focus: [f32; 4] },
|
||||
}
|
||||
|
||||
/// A path gradient's outline (`a:path/@path`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PathShape {
|
||||
Circle,
|
||||
Rect,
|
||||
Shape,
|
||||
}
|
||||
|
||||
/// How the gradient's tile is mirrored when tiled (`a:gradFill/@flip`).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum GradientFlip {
|
||||
#[default]
|
||||
None,
|
||||
X,
|
||||
Y,
|
||||
Xy,
|
||||
}
|
||||
|
||||
impl GradientFlip {
|
||||
pub fn is_none(&self) -> bool {
|
||||
*self == GradientFlip::None
|
||||
}
|
||||
}
|
||||
|
||||
/// Largest rectangle inset kept, percent (hostile files).
|
||||
const MAX_INSET: f32 = 1000.0;
|
||||
|
||||
fn rect(r: [f32; 4]) -> [f32; 4] {
|
||||
r.map(|v| clamp(v, -MAX_INSET, MAX_INSET, 0.0))
|
||||
}
|
||||
|
||||
impl Fill {
|
||||
/// An opaque solid fill.
|
||||
pub fn solid(color: Rgb) -> Fill {
|
||||
Fill::Solid { color, transparency: 0.0 }
|
||||
}
|
||||
/// The one colour standing for the fill where only one fits: the solid colour, or a
|
||||
/// gradient's middle stop.
|
||||
pub fn main_color(&self) -> Rgb {
|
||||
match self {
|
||||
Fill::Solid { color, .. } => *color,
|
||||
Fill::Gradient(g) => g.stops.get(g.stops.len() / 2).map_or(Rgb::BLACK, |s| s.color),
|
||||
}
|
||||
}
|
||||
/// Finite, in range: at most [`MAX_STOPS`] stops, in order, positions and transparencies
|
||||
/// 0–100%, angles 0–360°. A gradient with one stop is that stop's solid colour; with none,
|
||||
/// no fill (`None`).
|
||||
pub fn sanitized(&self) -> Option<Fill> {
|
||||
Some(match self {
|
||||
Fill::Solid { color, transparency } => Fill::Solid { color: *color, transparency: clamp(*transparency, 0.0, 100.0, 0.0) },
|
||||
Fill::Gradient(g) => {
|
||||
let stops = sanitized_stops(&g.stops);
|
||||
match stops[..] {
|
||||
[] => return None,
|
||||
[s] => return Some(Fill::Solid { color: s.color, transparency: s.transparency }),
|
||||
_ => {}
|
||||
}
|
||||
let kind = match g.kind {
|
||||
GradientKind::Linear { angle, scaled } => {
|
||||
GradientKind::Linear { angle: clamp(angle, -1e6, 1e6, 0.0).rem_euclid(360.0) % 360.0, scaled }
|
||||
}
|
||||
GradientKind::Path { shape, focus } => GradientKind::Path { shape, focus: rect(focus) },
|
||||
};
|
||||
Fill::Gradient(Arc::new(Gradient { stops, kind, tile: g.tile.map(rect), rot_with_shape: g.rot_with_shape, flip: g.flip }))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The tagged `.json` form of fills other than opaque solid ones.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
enum Tagged {
|
||||
Solid {
|
||||
color: Rgb,
|
||||
#[serde(default)]
|
||||
transparency: f32,
|
||||
},
|
||||
Gradient(Gradient),
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum AnyForm {
|
||||
Plain(Rgb),
|
||||
Tagged(Tagged),
|
||||
}
|
||||
|
||||
impl Serialize for Fill {
|
||||
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||
match self {
|
||||
Fill::Solid { color, transparency: 0.0 } => color.serialize(s),
|
||||
Fill::Solid { color, transparency } => Tagged::Solid { color: *color, transparency: *transparency }.serialize(s),
|
||||
Fill::Gradient(g) => Tagged::Gradient(Gradient::clone(g)).serialize(s),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Fill {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Fill, D::Error> {
|
||||
Ok(match AnyForm::deserialize(d)? {
|
||||
AnyForm::Plain(color) => Fill::solid(color),
|
||||
AnyForm::Tagged(Tagged::Solid { color, transparency }) => Fill::Solid { color, transparency },
|
||||
AnyForm::Tagged(Tagged::Gradient(g)) => Fill::Gradient(Arc::new(g)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn stop(pos: f32, color: Rgb, transparency: f32) -> GradStop {
|
||||
GradStop { pos, color, transparency }
|
||||
}
|
||||
|
||||
fn grad(stops: Vec<GradStop>, kind: GradientKind) -> Fill {
|
||||
Fill::Gradient(Arc::new(Gradient { stops, kind, tile: None, rot_with_shape: true, flip: GradientFlip::None }))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opaque_solid_fills_are_written_as_before_and_old_documents_load() {
|
||||
let f = Fill::solid(Rgb(1, 2, 3));
|
||||
assert_eq!(serde_json::to_string(&f).ok().as_deref(), Some("[1,2,3]"));
|
||||
assert_eq!(serde_json::from_str::<Fill>("[1,2,3]").ok(), Some(f));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparent_and_gradient_fills_round_trip_tagged() {
|
||||
let fills = [
|
||||
Fill::Solid { color: Rgb::WHITE, transparency: 70.0 },
|
||||
grad(vec![stop(0.0, Rgb(9, 9, 9), 0.0), stop(100.0, Rgb::WHITE, 50.0)], GradientKind::Linear { angle: 90.0, scaled: true }),
|
||||
Fill::Gradient(Arc::new(Gradient {
|
||||
stops: vec![stop(0.0, Rgb::BLACK, 0.0), stop(100.0, Rgb::WHITE, 0.0)],
|
||||
kind: GradientKind::Path { shape: PathShape::Rect, focus: [50.0, 50.0, 50.0, 50.0] },
|
||||
tile: Some([-10.0, 0.0, 0.0, 0.0]),
|
||||
rot_with_shape: false,
|
||||
flip: GradientFlip::Xy,
|
||||
})),
|
||||
];
|
||||
for f in fills {
|
||||
let json = serde_json::to_string(&f).unwrap_or_default();
|
||||
assert!(json.contains("\"type\""), "{json}");
|
||||
assert_eq!(serde_json::from_str::<Fill>(&json).ok(), Some(f), "{json}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_fills_are_sanitized() {
|
||||
let many: Vec<GradStop> = (0..30).map(|i| stop(100.0 - i as f32, Rgb::BLACK, f32::NAN)).collect();
|
||||
let Some(Fill::Gradient(g)) = grad(many, GradientKind::Linear { angle: -450.0, scaled: false }).sanitized() else { panic!() };
|
||||
assert_eq!(g.stops.len(), MAX_STOPS);
|
||||
assert!(g.stops.windows(2).all(|w| w[0].pos <= w[1].pos) && g.stops.iter().all(|s| s.transparency == 0.0));
|
||||
assert_eq!(g.kind, GradientKind::Linear { angle: 270.0, scaled: false });
|
||||
let path = grad(
|
||||
vec![stop(150.0, Rgb::BLACK, 200.0), stop(-5.0, Rgb::WHITE, 0.0)],
|
||||
GradientKind::Path { shape: PathShape::Circle, focus: [f32::NAN, 1e9, -1e9, 5.0] },
|
||||
);
|
||||
let Some(Fill::Gradient(g)) = path.sanitized() else { panic!() };
|
||||
assert_eq!((g.stops[0].pos, g.stops[1].pos, g.stops[1].transparency), (0.0, 100.0, 100.0));
|
||||
assert_eq!(g.kind, GradientKind::Path { shape: PathShape::Circle, focus: [0.0, MAX_INSET, -MAX_INSET, 5.0] });
|
||||
assert_eq!(
|
||||
grad(vec![stop(0.0, Rgb::WHITE, 30.0)], GradientKind::Linear { angle: 0.0, scaled: false }).sanitized(),
|
||||
Some(Fill::Solid { color: Rgb::WHITE, transparency: 30.0 })
|
||||
);
|
||||
assert_eq!(grad(vec![], GradientKind::Linear { angle: 0.0, scaled: false }).sanitized(), None);
|
||||
assert_eq!(Fill::Solid { color: Rgb::BLACK, transparency: f32::NAN }.sanitized(), Some(Fill::solid(Rgb::BLACK)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn main_colour_is_the_middle_stop() {
|
||||
let g = grad(
|
||||
vec![stop(0.0, Rgb(1, 0, 0), 0.0), stop(50.0, Rgb(2, 0, 0), 0.0), stop(100.0, Rgb(3, 0, 0), 0.0)],
|
||||
GradientKind::Linear { angle: 0.0, scaled: false },
|
||||
);
|
||||
assert_eq!(g.main_color(), Rgb(2, 0, 0));
|
||||
assert_eq!(Fill::solid(Rgb::WHITE).main_color(), Rgb::WHITE);
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ pub struct Graphic {
|
||||
pub struct Embedded {
|
||||
/// The object's markup as a standalone XML element with its namespace declarations (DOCX:
|
||||
/// the `a:graphic` of a chart, SmartArt diagram or native ink, an ink `mc:AlternateContent`,
|
||||
/// or the `w:object` of an OLE object).
|
||||
/// the `w:object` of an OLE object, or the `w:pict` of a signature line).
|
||||
pub xml: String,
|
||||
/// The relationships it uses, by the ids in `xml` (or in its parts, for a SmartArt drawing).
|
||||
pub refs: Vec<EmbeddedRef>,
|
||||
|
||||
+85
-8
@@ -11,6 +11,7 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod bidi;
|
||||
mod byte_store;
|
||||
pub mod chart;
|
||||
mod cjk_numbers;
|
||||
pub mod cluster;
|
||||
@@ -22,6 +23,7 @@ pub mod effects;
|
||||
pub mod encoding;
|
||||
pub mod eq;
|
||||
pub mod fields;
|
||||
pub mod fill;
|
||||
pub mod freeform;
|
||||
pub mod graphic;
|
||||
pub mod legacy_form;
|
||||
@@ -631,11 +633,40 @@ pub struct Watermark {
|
||||
pub color: Rgb,
|
||||
pub diagonal: bool,
|
||||
pub semitransparent: bool,
|
||||
/// The shape's box (its `style` width and height, points), which the text is stretched to
|
||||
/// fill (`v:textpath fitshape`); `None`: the box Word's Design › Watermark gives
|
||||
/// ([`Watermark::frame`]).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub size: Option<[f32; 2]>,
|
||||
}
|
||||
|
||||
impl Default for Watermark {
|
||||
fn default() -> Self {
|
||||
Watermark { text: "DRAFT".into(), font: styles::BODY_FONT.into(), color: Rgb(0xC0, 0xC0, 0xC0), diagonal: true, semitransparent: true }
|
||||
Watermark {
|
||||
text: "DRAFT".into(),
|
||||
font: styles::BODY_FONT.into(),
|
||||
color: Rgb(0xC0, 0xC0, 0xC0),
|
||||
diagonal: true,
|
||||
semitransparent: true,
|
||||
size: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Watermark {
|
||||
/// The box the text fills on a page `page_w` × `page_h` (points): its own size, else half the
|
||||
/// page diagonal across (half the page width when not diagonal), as high as the text's length
|
||||
/// makes it. Centred on the margins and, when diagonal, turned 45° counterclockwise (Word's
|
||||
/// `rotation:315`).
|
||||
pub fn frame(&self, page_w: f32, page_h: f32) -> [f32; 2] {
|
||||
if let Some([w, h]) = self.size.filter(|[w, h]| w.is_finite() && h.is_finite() && *w > 0.0 && *h > 0.0) {
|
||||
return [w.min(4000.0), h.min(4000.0)];
|
||||
}
|
||||
let (pw, ph) = (wordcraft_geom::finite(page_w).clamp(72.0, 4000.0), wordcraft_geom::finite(page_h).clamp(72.0, 4000.0));
|
||||
let across = if self.diagonal { (pw * pw + ph * ph).sqrt() } else { pw };
|
||||
let width = (across * 0.5).clamp(36.0, 2000.0);
|
||||
let chars = self.text.chars().take(200).count().max(1) as f32;
|
||||
[width, (width / (0.55 * chars)).clamp(12.0, 400.0)]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -722,11 +753,11 @@ pub struct Document {
|
||||
/// The document's own endnote separators and continuation notice.
|
||||
#[serde(skip_serializing_if = "NoteSeparators::is_empty")]
|
||||
pub endnote_separators: NoteSeparators,
|
||||
/// Embedded media (images) by key.
|
||||
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
|
||||
/// Embedded media (images) by key. Native JSON writes each as base64 (`byte_store.rs`).
|
||||
#[serde(skip_serializing_if = "BTreeMap::is_empty", with = "byte_store")]
|
||||
pub media: BTreeMap<String, Arc<Vec<u8>>>,
|
||||
/// Source-format parts we keep verbatim for round-trip (e.g. custom XML), by part name.
|
||||
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
|
||||
#[serde(skip_serializing_if = "BTreeMap::is_empty", with = "byte_store")]
|
||||
pub passthrough: BTreeMap<String, Arc<Vec<u8>>>,
|
||||
/// The document's own building blocks (its glossary document), in file order.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
@@ -1079,10 +1110,48 @@ impl Document {
|
||||
/// The body, then the text boxes, footnotes and endnotes it shows (nested ones too), in
|
||||
/// document order. Headers, footers, comments and stories nothing points at aren't included.
|
||||
pub fn counted_stories(&self) -> Vec<StoryRef> {
|
||||
self.reachable(vec![StoryRef::Body], &|o| match o {
|
||||
InlineObject::NoteRef { id, .. } => vec![(*id, &[PartKind::Footnote, PartKind::Endnote][..])],
|
||||
o => o.text_boxes().into_iter().map(|id| (id, &[PartKind::TextBox][..])).collect(),
|
||||
})
|
||||
self.reachable(vec![StoryRef::Body], ¬es_and_boxes)
|
||||
}
|
||||
|
||||
/// The stories a reader sees, in reading order: the body, then the headers and footers in
|
||||
/// use, each followed by the notes and text boxes it shows. Hidden stories (a removed header,
|
||||
/// a first-page or even-page header whose option is off, a linked text box's own text) and
|
||||
/// comment text aren't included.
|
||||
pub fn shown_stories(&self) -> Vec<StoryRef> {
|
||||
let followers: std::collections::BTreeSet<u32> = self.text_box_chains().into_iter().flat_map(|c| c.into_iter().skip(1)).collect();
|
||||
let follow = |o: &InlineObject| {
|
||||
let mut v = notes_and_boxes(o);
|
||||
v.retain(|(id, _)| !followers.contains(id));
|
||||
v
|
||||
};
|
||||
let mut out = self.reachable(vec![StoryRef::Body], &follow);
|
||||
let mut seen: std::collections::BTreeSet<StoryRef> = out.iter().copied().collect();
|
||||
let mut roots = Vec::new();
|
||||
let mut root_seen = std::collections::BTreeSet::new();
|
||||
let (mut headers, mut footers) = (section::HeaderSet::default(), section::HeaderSet::default());
|
||||
for (_, sec) in self.sections() {
|
||||
// Unset versions are inherited from the previous section.
|
||||
for (set, own) in [(&mut headers, &sec.headers), (&mut footers, &sec.footers)] {
|
||||
set.default = own.default.or(set.default);
|
||||
set.first = own.first.or(set.first);
|
||||
set.even = own.even.or(set.even);
|
||||
let first = set.first.filter(|_| sec.title_page);
|
||||
let even = set.even.filter(|_| self.settings.even_odd_headers);
|
||||
// By the first page each one shows on: page 1, then page 2 (even), then page 3.
|
||||
let order = if sec.title_page { [first, even, set.default] } else { [set.default, even, None] };
|
||||
for id in order.into_iter().flatten() {
|
||||
if self.parts.get(&id).is_some_and(|p| matches!(p.kind, PartKind::Header | PartKind::Footer)) && root_seen.insert(id) {
|
||||
roots.push(StoryRef::Part(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for st in self.reachable(roots, &follow) {
|
||||
if seen.insert(st) {
|
||||
out.push(st);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Drop the text box stories no box shows any more (a deleted or cut box's text), nested
|
||||
@@ -1452,6 +1521,14 @@ pub fn count_words(s: &str) -> usize {
|
||||
/// How deep in nested tables [`Document::para_paths`] goes: cells below this are skipped.
|
||||
pub(crate) const PARA_WALK_DEPTH: usize = 16;
|
||||
|
||||
/// The note and text box stories an object shows, for `Document::reachable`.
|
||||
fn notes_and_boxes(o: &InlineObject) -> Vec<(u32, &'static [PartKind])> {
|
||||
match o {
|
||||
InlineObject::NoteRef { id, .. } => vec![(*id, &[PartKind::Footnote, PartKind::Endnote][..])],
|
||||
o => o.text_boxes().into_iter().map(|id| (id, &[PartKind::TextBox][..])).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn walk(bl: &Blocks, prefix: &mut Vec<u32>, out: &mut Vec<Path>, depth: usize) {
|
||||
if depth > PARA_WALK_DEPTH {
|
||||
return;
|
||||
|
||||
@@ -8,7 +8,7 @@ pub fn decimal_separator(lang: Option<&str>) -> char {
|
||||
let tag = lang.unwrap_or("").trim().to_ascii_lowercase().replace('_', "-");
|
||||
// Regions that write a decimal point where the language usually writes a comma.
|
||||
const POINT_REGIONS: &[&str] =
|
||||
&["de-ch", "de-li", "it-ch", "es-mx", "es-us", "es-419", "es-pr", "es-do", "es-gt", "es-hn", "es-ni", "es-pa", "es-sv"];
|
||||
&["de-ch", "de-li", "it-ch", "es-mx", "es-us", "es-419", "es-pr", "es-do", "es-gt", "es-hn", "es-ni", "es-pa", "es-sv", "es-cu", "es-pe"];
|
||||
if POINT_REGIONS.iter().any(|r| tag == *r || tag.starts_with(&format!("{r}-"))) {
|
||||
return '.';
|
||||
}
|
||||
@@ -151,6 +151,17 @@ mod tests {
|
||||
assert_eq!(format_number(-0.001, pl), "0,00", "a rounded zero has no sign");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cuban_and_peruvian_numbers_use_decimal_points() {
|
||||
for lang in [Some("es-CU"), Some("es-PE")] {
|
||||
assert_eq!(parse_number("1.234", lang), Some(1.234));
|
||||
assert_eq!(parse_number("1,234.50", lang), Some(1234.5));
|
||||
assert_eq!(format_number(5.75, lang), "5.75");
|
||||
}
|
||||
assert_eq!(parse_number("1.234,50", Some("es-ES")), Some(1234.5));
|
||||
assert_eq!(format_number(5.75, Some("es-ES")), "5,75");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn english_numbers() {
|
||||
let en = Some("en-US");
|
||||
|
||||
+90
-14
@@ -288,8 +288,19 @@ impl Numbering {
|
||||
pub struct Counters {
|
||||
/// Per abstract list: current value per level.
|
||||
state: HashMap<u32, [u32; 9]>,
|
||||
/// Which nums have started (to apply start overrides once).
|
||||
seen: HashMap<u32, bool>,
|
||||
/// Which levels of which nums have started: a num's start override for a level applies
|
||||
/// once, when that level of that num is first used (ECMA-376 Part 1, 17.9.27 startOverride).
|
||||
seen: HashMap<(u32, u8), bool>,
|
||||
/// 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]>,
|
||||
}
|
||||
|
||||
/// Whether a level has counted yet, and the value its next item takes if a start override says so.
|
||||
#[derive(Default, Debug, Clone, Copy)]
|
||||
struct LevelFlags {
|
||||
started: bool,
|
||||
first: Option<u32>,
|
||||
}
|
||||
|
||||
impl Counters {
|
||||
@@ -312,29 +323,36 @@ impl Counters {
|
||||
return None;
|
||||
}
|
||||
let st = self.state.entry(abs.id).or_insert([0; 9]);
|
||||
if let std::collections::hash_map::Entry::Vacant(e) = self.seen.entry(num) {
|
||||
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
|
||||
// num of the abstract list, so restarting its other levels here would renumber
|
||||
// paragraphs of the other nums. A heading style can use one num for levels 1-4 and
|
||||
// another, with start overrides on every level, for 5-9; its first level-5 heading
|
||||
// must not restart the chapter count.
|
||||
if let std::collections::hash_map::Entry::Vacant(e) = self.seen.entry((num, lv as u8)) {
|
||||
e.insert(true);
|
||||
for (l, start) in &n.start_overrides {
|
||||
if let Some(s) = st.get_mut(*l as usize) {
|
||||
*s = start.saturating_sub(1);
|
||||
}
|
||||
if let Some((_, start)) = n.start_overrides.iter().find(|(l, _)| *l as usize == lv)
|
||||
&& let (Some(s), Some(f)) = (st.get_mut(lv), fl.get_mut(lv))
|
||||
{
|
||||
*s = 0;
|
||||
*f = LevelFlags { started: false, first: Some(*start) };
|
||||
}
|
||||
}
|
||||
if let Some(v) = st.get_mut(lv) {
|
||||
*v = if *v == 0 { def.start.max(if def.start == 0 { 0 } else { 1 }) } else { v.saturating_add(1) };
|
||||
if *v == 0 {
|
||||
*v = def.start;
|
||||
}
|
||||
if let (Some(v), Some(f)) = (st.get_mut(lv), fl.get_mut(lv)) {
|
||||
*v = if f.started { v.saturating_add(1) } else { f.first.unwrap_or(def.start) };
|
||||
*f = LevelFlags { started: true, first: None };
|
||||
}
|
||||
// Deeper levels restart, unless they never restart or only restart after a level above
|
||||
// this one (Word's lvlRestart).
|
||||
for (d, deeper) in st.iter_mut().enumerate().skip(lv + 1) {
|
||||
for (d, (deeper, f)) in st.iter_mut().zip(fl.iter_mut()).enumerate().skip(lv + 1) {
|
||||
let restarts = level_def(d).is_none_or(|l| l.restart && l.restart_after.is_none_or(|k| lv < k as usize));
|
||||
if restarts {
|
||||
*deeper = 0;
|
||||
*f = LevelFlags::default();
|
||||
}
|
||||
}
|
||||
let vals = *st;
|
||||
let started = *fl;
|
||||
if def.format == NumFormat::Bullet {
|
||||
return Some((def.text.clone(), def));
|
||||
}
|
||||
@@ -347,7 +365,8 @@ impl Counters {
|
||||
chars.next();
|
||||
let k = (d as usize).saturating_sub(1).min(8);
|
||||
let val = vals.get(k).copied().unwrap_or(0);
|
||||
let val = if val == 0 { level_def(k).map(|l| l.start).unwrap_or(1) } else { val };
|
||||
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) {
|
||||
@@ -389,6 +408,29 @@ mod tests {
|
||||
assert_eq!(n.level(id, 2).map(|l| l.format), Some(NumFormat::DecimalZero));
|
||||
}
|
||||
|
||||
#[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
|
||||
// restarting every level (startOverride 1 on 0..8) for levels 5-9, first used deep in
|
||||
// chapter 2. Chapter 3 is still chapter 3, and the deep heading counts from 1.
|
||||
let mut n = Numbering::default();
|
||||
let chapters = n.add_list(ListKind::Legal);
|
||||
let abs = n.abstract_of(chapters).map(|a| a.id).unwrap();
|
||||
let deep = chapters + 1;
|
||||
n.nums.push(Num { id: deep, abstract_id: abs, start_overrides: (0..9).map(|l| (l, 1)).collect(), ..Num::default() });
|
||||
let mut c = Counters::default();
|
||||
let mut l = |num, lv| c.next_label(&n, num, lv).unwrap().0;
|
||||
assert_eq!(l(chapters, 0), "1.");
|
||||
assert_eq!(l(chapters, 0), "2.");
|
||||
assert_eq!(l(chapters, 1), "2.1.");
|
||||
assert_eq!(l(chapters, 2), "2.1.1.");
|
||||
assert_eq!(l(chapters, 3), "2.1.1.1.");
|
||||
assert_eq!(l(deep, 4), "2.1.1.1.1.", "the deep num restarts only level 5");
|
||||
assert_eq!(l(deep, 4), "2.1.1.1.2.");
|
||||
assert_eq!(l(chapters, 0), "3.", "and not the chapter count");
|
||||
assert_eq!(l(chapters, 1), "3.1.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn levels_restart_only_after_their_restart_level() {
|
||||
let mut n = Numbering::default();
|
||||
@@ -411,6 +453,40 @@ mod tests {
|
||||
assert_eq!(l(2), "(1)", "level 1 restarts level 3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_level_starting_at_zero_counts_up() {
|
||||
let mut n = Numbering::default();
|
||||
let id = n.add_list(ListKind::Numbered);
|
||||
if let Some(a) = n.abstracts.first_mut() {
|
||||
a.levels[0].start = 0;
|
||||
}
|
||||
let mut c = Counters::default();
|
||||
let labels: Vec<String> = (0..4).map(|_| c.next_label(&n, id, 0).unwrap().0).collect();
|
||||
assert_eq!(labels, ["0.", "1.", "2.", "3."]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_start_override_of_one_or_zero_is_honoured() {
|
||||
let mut n = Numbering::default();
|
||||
let id = n.add_list(ListKind::Numbered);
|
||||
if let Some(a) = n.abstracts.first_mut() {
|
||||
a.levels[0].start = 5;
|
||||
}
|
||||
if let Some(num) = n.nums.iter_mut().find(|x| x.id == id) {
|
||||
num.start_overrides = vec![(0, 1)];
|
||||
}
|
||||
let mut c = Counters::default();
|
||||
let labels: Vec<String> = (0..2).map(|_| c.next_label(&n, id, 0).unwrap().0).collect();
|
||||
assert_eq!(labels, ["1.", "2."]);
|
||||
|
||||
if let Some(num) = n.nums.iter_mut().find(|x| x.id == id) {
|
||||
num.start_overrides = vec![(0, 0)];
|
||||
}
|
||||
let mut c = Counters::default();
|
||||
let labels: Vec<String> = (0..3).map(|_| c.next_label(&n, id, 0).unwrap().0).collect();
|
||||
assert_eq!(labels, ["0.", "1.", "2."]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbered_labels() {
|
||||
let mut n = Numbering::default();
|
||||
|
||||
+24
-11
@@ -77,12 +77,17 @@ pub enum WrapText {
|
||||
impl WrapText {
|
||||
/// From the OOXML value (`bothSides`, `left`, `right`, `largest`).
|
||||
pub fn from_ooxml(v: &str) -> Option<WrapText> {
|
||||
match v {
|
||||
"bothSides" => Some(WrapText::BothSides),
|
||||
"left" => Some(WrapText::Left),
|
||||
"right" => Some(WrapText::Right),
|
||||
"largest" => Some(WrapText::Largest),
|
||||
_ => None,
|
||||
let v = v.trim();
|
||||
if v.eq_ignore_ascii_case("bothSides") || v.eq_ignore_ascii_case("both") {
|
||||
Some(WrapText::BothSides)
|
||||
} else if v.eq_ignore_ascii_case("left") {
|
||||
Some(WrapText::Left)
|
||||
} else if v.eq_ignore_ascii_case("right") {
|
||||
Some(WrapText::Right)
|
||||
} else if v.eq_ignore_ascii_case("largest") {
|
||||
Some(WrapText::Largest)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,6 +225,10 @@ pub struct Float {
|
||||
/// off, any VML shape in a header or footer): a picture watermark in a header that starts with a table.
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
pub outside_cell: bool,
|
||||
/// Stacking order among floating objects (`wp:anchor/@relativeHeight`): higher is drawn on
|
||||
/// top. 0 = unknown, drawn in document order (objects inserted here, or from other formats).
|
||||
#[serde(skip_serializing_if = "is_zero_u32")]
|
||||
pub z: u32,
|
||||
}
|
||||
|
||||
/// Longest alternative text kept (characters): hostile files and commands are capped.
|
||||
@@ -259,6 +268,9 @@ fn is_zero(v: &f32) -> bool {
|
||||
fn is_false(v: &bool) -> bool {
|
||||
!*v
|
||||
}
|
||||
fn is_zero_u32(v: &u32) -> bool {
|
||||
*v == 0
|
||||
}
|
||||
|
||||
impl Float {
|
||||
/// The rotation and flips, the angle normalised to `0..360` (a NaN angle is no rotation).
|
||||
@@ -440,8 +452,9 @@ pub enum InlineObject {
|
||||
/// Crop (left, top, right, bottom) fractions 0..1.
|
||||
#[serde(default)]
|
||||
crop: [f32; 4],
|
||||
/// The OLE object (embedded or linked file) this picture shows, as read from a file:
|
||||
/// saving writes the object back, not only its picture. See [`crate::graphic::Embedded`].
|
||||
/// The OLE object (embedded or linked file) or Word signature line this picture shows, as
|
||||
/// read from a file: saving writes the object back, not only its picture. See
|
||||
/// [`crate::graphic::Embedded`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
ole: Option<std::sync::Arc<crate::graphic::Embedded>>,
|
||||
},
|
||||
@@ -459,7 +472,7 @@ pub enum InlineObject {
|
||||
kind: ShapeKind,
|
||||
w: f32,
|
||||
h: f32,
|
||||
fill: Option<crate::props::Rgb>,
|
||||
fill: Option<crate::fill::Fill>,
|
||||
stroke: Option<crate::props::Rgb>,
|
||||
#[serde(default)]
|
||||
stroke_width: f32,
|
||||
@@ -641,10 +654,10 @@ pub struct GroupChild {
|
||||
pub const MAX_GROUP_CHILDREN: usize = 1000;
|
||||
|
||||
/// The background fill and outline of a Drawing Canvas (an [`InlineObject::Group`] with `canvas`).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct CanvasStyle {
|
||||
pub fill: Option<crate::props::Rgb>,
|
||||
pub fill: Option<crate::fill::Fill>,
|
||||
pub stroke: Option<crate::props::Rgb>,
|
||||
/// Outline width, points.
|
||||
pub stroke_width: f32,
|
||||
|
||||
+96
-4
@@ -47,13 +47,38 @@ impl Rgb {
|
||||
}
|
||||
/// Parse `RRGGBB` or `#RRGGBB`.
|
||||
pub fn parse(s: &str) -> Option<Rgb> {
|
||||
let s = s.trim().trim_start_matches('#');
|
||||
if s.len() != 6 || !s.is_ascii() {
|
||||
let s = s.trim();
|
||||
let s = s.strip_prefix('#').unwrap_or(s);
|
||||
if s.len() != 6 || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
let p = |i: usize| s.get(i..i + 2).and_then(|h| u8::from_str_radix(h, 16).ok());
|
||||
Some(Rgb(p(0)?, p(2)?, p(4)?))
|
||||
}
|
||||
/// This colour with the DrawingML colour modifier `name` (`lumMod`, `lumOff`, `tint`,
|
||||
/// `shade`, `satMod`, ECMA-376 §20.1.2.3) at `v` (1.0 = 100%); other names leave it
|
||||
/// unchanged.
|
||||
pub fn modified(self, name: &str, v: f64) -> Rgb {
|
||||
match name {
|
||||
"lumMod" => adjust_lum(self, |l| l * v),
|
||||
"lumOff" => adjust_lum(self, |l| l + v),
|
||||
"tint" => mix(self, 255.0, v),
|
||||
"shade" => mix(self, 0.0, v),
|
||||
"satMod" => {
|
||||
let (h, sat, l) = to_hsl(self);
|
||||
from_hsl(h, (sat * v).clamp(0.0, 1.0), l)
|
||||
}
|
||||
_ => self,
|
||||
}
|
||||
}
|
||||
/// The colour with hue `h` (0..1 of a turn), saturation `s` and lightness `l` (0..1).
|
||||
pub fn from_hsl(h: f64, s: f64, l: f64) -> Rgb {
|
||||
from_hsl(h, s, l)
|
||||
}
|
||||
/// This colour with its HSL luminance changed by `f`.
|
||||
pub fn with_lum(self, f: impl Fn(f64) -> f64) -> Rgb {
|
||||
adjust_lum(self, f)
|
||||
}
|
||||
pub fn luma(self) -> f32 {
|
||||
0.299 * self.0 as f32 + 0.587 * self.1 as f32 + 0.114 * self.2 as f32
|
||||
}
|
||||
@@ -421,6 +446,9 @@ pub struct CharProps {
|
||||
/// BCP 47 language of complex-script text (`w:lang/@w:bidi`, e.g. `fa-IR`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lang_bidi: Option<String>,
|
||||
/// BCP 47 language of East Asian text (`w:lang/@w:eastAsia`, e.g. `zh-CN`, `ja-JP`, `ko-KR`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lang_ea: Option<String>,
|
||||
/// East Asian typography: two lines in one and horizontal in vertical (`w:eastAsianLayout`).
|
||||
/// Kept for round trip; layout doesn't apply it yet.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -549,7 +577,7 @@ impl CharProps {
|
||||
pub fn minus(mut self, base: &CharProps) -> CharProps {
|
||||
drop_same_fields!(self, base; style, font, sym_font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight,
|
||||
shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl,
|
||||
cs, font_cs, size_cs, bold_cs, italic_cs, lang_bidi, east_asian_layout, link, ins, del, ligatures);
|
||||
cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, lang_ea, east_asian_layout, link, ins, del, ligatures);
|
||||
self
|
||||
}
|
||||
|
||||
@@ -560,7 +588,7 @@ impl CharProps {
|
||||
pub fn overlay(&mut self, patch: &CharProps) {
|
||||
overlay_fields!(self, patch; style, font, sym_font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight,
|
||||
shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl,
|
||||
cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, east_asian_layout, link, ins, del, fmt_change, ligatures);
|
||||
cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, lang_ea, east_asian_layout, link, ins, del, fmt_change, ligatures);
|
||||
if let Some(p) = &patch.text_effects {
|
||||
self.text_effects.get_or_insert_with(Default::default).overlay(p);
|
||||
}
|
||||
@@ -1298,6 +1326,60 @@ impl CellProps {
|
||||
}
|
||||
}
|
||||
|
||||
/// `tint` (towards `to` = 255) and `shade` (towards 0): `v` is the share of the original colour kept.
|
||||
fn mix(c: Rgb, to: f64, v: f64) -> Rgb {
|
||||
let f = |x: u8| (x as f64 * v + to * (1.0 - v)).round().clamp(0.0, 255.0) as u8;
|
||||
Rgb(f(c.0), f(c.1), f(c.2))
|
||||
}
|
||||
|
||||
/// `c` with its HSL luminance changed by `f`.
|
||||
fn adjust_lum(c: Rgb, f: impl Fn(f64) -> f64) -> Rgb {
|
||||
let (h, s, l) = to_hsl(c);
|
||||
from_hsl(h, s, f(l).clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
fn to_hsl(c: Rgb) -> (f64, f64, f64) {
|
||||
let (r, g, b) = (c.0 as f64 / 255.0, c.1 as f64 / 255.0, c.2 as f64 / 255.0);
|
||||
let (max, min) = (r.max(g).max(b), r.min(g).min(b));
|
||||
let l = (max + min) / 2.0;
|
||||
if max == min {
|
||||
return (0.0, 0.0, l);
|
||||
}
|
||||
let d = max - min;
|
||||
let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) };
|
||||
let h = if max == r {
|
||||
(g - b) / d + if g < b { 6.0 } else { 0.0 }
|
||||
} else if max == g {
|
||||
(b - r) / d + 2.0
|
||||
} else {
|
||||
(r - g) / d + 4.0
|
||||
};
|
||||
(h / 6.0, s, l)
|
||||
}
|
||||
|
||||
fn from_hsl(h: f64, s: f64, l: f64) -> Rgb {
|
||||
let byte = |v: f64| (v * 255.0).round().clamp(0.0, 255.0) as u8;
|
||||
if s == 0.0 {
|
||||
return Rgb(byte(l), byte(l), byte(l));
|
||||
}
|
||||
let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s };
|
||||
let p = 2.0 * l - q;
|
||||
let channel = |t: f64| {
|
||||
let t = t.rem_euclid(1.0);
|
||||
let v = if t < 1.0 / 6.0 {
|
||||
p + (q - p) * 6.0 * t
|
||||
} else if t < 0.5 {
|
||||
q
|
||||
} else if t < 2.0 / 3.0 {
|
||||
p + (q - p) * (2.0 / 3.0 - t) * 6.0
|
||||
} else {
|
||||
p
|
||||
};
|
||||
byte(v)
|
||||
};
|
||||
Rgb(channel(h + 1.0 / 3.0), channel(h), channel(h - 1.0 / 3.0))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1310,6 +1392,16 @@ mod tests {
|
||||
assert_eq!(Rgb::parse("é1234"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rgb_parse_rejects_non_hex() {
|
||||
assert_eq!(Rgb::parse("+1+2+3"), None);
|
||||
assert_eq!(Rgb::parse("-1-2-3"), None);
|
||||
assert_eq!(Rgb::parse("##ff0000"), None);
|
||||
assert_eq!(Rgb::parse("ff00 0"), None);
|
||||
assert_eq!(Rgb::parse("#ff0000"), Some(Rgb(255, 0, 0)));
|
||||
assert_eq!(Rgb::parse("ff0000"), Some(Rgb(255, 0, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_keeps_unset() {
|
||||
let base = CharProps { bold: Some(true), size: Some(12.0), ..Default::default() };
|
||||
|
||||
@@ -73,8 +73,13 @@ pub struct TableStyleParts {
|
||||
pub band_chr: CharProps,
|
||||
/// Cell borders of the odd row bands (`band1Horz` region's `w:tcBorders`).
|
||||
pub band_borders: Option<Borders>,
|
||||
/// Rows per band (`w:tblStyleRowBandSize`); one when unset.
|
||||
/// Rows per band (`w:tblStyleRowBandSize`). Unset (in the whole based-on chain) or zero, the
|
||||
/// row bands are off: Word reads a missing size as zero, where ECMA-376 says one (MS-OE376
|
||||
/// §2.7.5.7, #3415). Our own banded styles set one.
|
||||
pub band_size: Option<u32>,
|
||||
/// Columns per band (`w:tblStyleColBandSize`); unset or zero, the column bands are off, as
|
||||
/// in Word (MS-OI29500 §17.7.6.5).
|
||||
pub col_band_size: Option<u32>,
|
||||
pub first_col_chr: CharProps,
|
||||
pub total_chr: CharProps,
|
||||
pub total_border_top: Option<Border>,
|
||||
@@ -179,17 +184,24 @@ pub struct PartSet(u16);
|
||||
|
||||
impl PartSet {
|
||||
/// The parts of the cell at row `ri` of `nrows`, grid column `g` spanning `span` of `ncols`,
|
||||
/// in a table with options `look` and row bands of `band_size` rows.
|
||||
pub fn of_cell(look: &TableLook, band_size: u32, (ri, nrows): (usize, usize), (g, span, ncols): (usize, usize, usize)) -> PartSet {
|
||||
/// in a table with options `look`, row bands of `row_size` rows and column bands of
|
||||
/// `col_size` columns (a size of zero turns those bands off, as in Word).
|
||||
pub fn of_cell(
|
||||
look: &TableLook,
|
||||
(row_size, col_size): (u32, u32),
|
||||
(ri, nrows): (usize, usize),
|
||||
(g, span, ncols): (usize, usize, usize),
|
||||
) -> PartSet {
|
||||
let header = look.header_row && ri == 0;
|
||||
let total = look.total_row && ri + 1 == nrows && nrows > 1;
|
||||
let first_col = look.first_column && g == 0;
|
||||
let last_col = look.last_column && g.saturating_add(span) >= ncols && ncols > 1;
|
||||
let size = band_size.clamp(1, 1000) as usize;
|
||||
let row_band = look.banded_rows && !header;
|
||||
let odd_row = (ri.saturating_sub(usize::from(look.header_row)) / size).is_multiple_of(2);
|
||||
let col_band = look.banded_columns && !first_col;
|
||||
let odd_col = g.saturating_sub(usize::from(look.first_column)).is_multiple_of(2);
|
||||
let rows = row_size.min(1000) as usize;
|
||||
let cols = col_size.min(1000) as usize;
|
||||
let row_band = look.banded_rows && !header && rows > 0;
|
||||
let odd_row = (ri.saturating_sub(usize::from(look.header_row)) / rows.max(1)).is_multiple_of(2);
|
||||
let col_band = look.banded_columns && !first_col && cols > 0;
|
||||
let odd_col = (g.saturating_sub(usize::from(look.first_column)) / cols.max(1)).is_multiple_of(2);
|
||||
let mut bits = TablePart::WholeTable.bit();
|
||||
for (on, p) in [
|
||||
(col_band && odd_col, TablePart::Band1Vert),
|
||||
@@ -270,6 +282,7 @@ impl TableStyleParts {
|
||||
self.header_borders = merge(self.header_borders, patch.header_borders);
|
||||
self.band_borders = merge(self.band_borders, patch.band_borders);
|
||||
self.band_size = patch.band_size.or(self.band_size);
|
||||
self.col_band_size = patch.col_band_size.or(self.col_band_size);
|
||||
self.band_chr.overlay(&patch.band_chr);
|
||||
self.fill = patch.fill.or(self.fill);
|
||||
self.cell_margins = patch.cell_margins.or(self.cell_margins);
|
||||
@@ -846,6 +859,7 @@ fn p_style() -> Style {
|
||||
/// A few table styles of our own (grid, plain, light/medium accents).
|
||||
fn table_styles() -> Vec<Style> {
|
||||
let thin = |c: Rgb| Border { style: BorderStyle::Single, width: 0.5, color: Some(c), space: 0.0 };
|
||||
// Bands of one row and one column, written out so Word bands them too (#3415).
|
||||
let mk = |id: &str, name: &str, parts: TableStyleParts| Style {
|
||||
id: id.into(),
|
||||
name: name.into(),
|
||||
@@ -854,7 +868,7 @@ fn table_styles() -> Vec<Style> {
|
||||
para: ParaProps { space_after: Some(0.0), line_spacing: Some(LineSpacing::Multiple(1.0)), ..ParaProps::default() },
|
||||
priority: Some(60),
|
||||
builtin: true,
|
||||
table: Some(parts),
|
||||
table: Some(TableStyleParts { band_size: Some(1), col_band_size: Some(1), ..parts }),
|
||||
..Default::default()
|
||||
};
|
||||
let accents = [
|
||||
@@ -979,6 +993,25 @@ mod tests {
|
||||
assert!(c.len() <= 2);
|
||||
}
|
||||
|
||||
/// #3415: a band size of zero (Word's reading of a style without one) turns those bands off.
|
||||
#[test]
|
||||
fn band_size_zero_turns_bands_off() {
|
||||
let look = crate::props::TableLook { banded_columns: true, ..Default::default() };
|
||||
let parts = |sizes, ri, g| PartSet::of_cell(&look, sizes, (ri, 5), (g, 1, 4));
|
||||
assert!(parts((1, 1), 1, 1).contains(TablePart::Band1Horz) && parts((1, 1), 1, 1).contains(TablePart::Band1Vert));
|
||||
assert!(parts((1, 1), 2, 2).contains(TablePart::Band2Horz) && parts((1, 1), 2, 2).contains(TablePart::Band2Vert));
|
||||
for (ri, g) in [(1, 1), (2, 2)] {
|
||||
let p = parts((0, 0), ri, g);
|
||||
for band in [TablePart::Band1Horz, TablePart::Band2Horz, TablePart::Band1Vert, TablePart::Band2Vert] {
|
||||
assert!(!p.contains(band), "{band:?} at row {ri}, column {g}");
|
||||
}
|
||||
}
|
||||
// Our banded styles say so, so Word bands them too.
|
||||
let s = StyleSheet::builtin();
|
||||
let t = s.table_style("GridTable4AccentBlue").map(|t| t.parts);
|
||||
assert_eq!(t.map(|t| (t.band_size, t.col_band_size)), Some((Some(1), Some(1))));
|
||||
}
|
||||
|
||||
/// A table style based on another inherits what it doesn't set itself; borders merge edge by
|
||||
/// edge.
|
||||
#[test]
|
||||
|
||||
+30
-3
@@ -43,6 +43,13 @@ pub struct Row {
|
||||
pub controls: ControlWrap,
|
||||
}
|
||||
|
||||
impl Row {
|
||||
/// Grid column where the row's first cell starts: the columns `w:gridBefore` skips.
|
||||
pub fn first_grid(&self) -> usize {
|
||||
(self.props.grid_before as usize).min(MAX_COLS)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
pub struct Table {
|
||||
#[serde(default)]
|
||||
@@ -58,6 +65,10 @@ 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;
|
||||
|
||||
impl Table {
|
||||
/// A `rows × cols` table spanning `width` points, "Table Grid" style.
|
||||
@@ -86,18 +97,22 @@ impl Table {
|
||||
}
|
||||
|
||||
pub fn cols(&self) -> usize {
|
||||
self.grid.len().max(self.rows.iter().map(|r| r.cells.iter().map(Cell::span).sum::<usize>()).max().unwrap_or(0))
|
||||
let row_cols = |r: &Row| r.first_grid() + r.cells.iter().map(Cell::span).sum::<usize>() + r.props.grid_after as usize;
|
||||
self.grid.len().max(self.rows.iter().map(row_cols).max().unwrap_or(0))
|
||||
}
|
||||
|
||||
/// Grid column where cell `c` of row `r` starts.
|
||||
pub fn grid_col(&self, r: usize, c: usize) -> usize {
|
||||
self.rows.get(r).map(|row| row.cells.iter().take(c).map(Cell::span).sum()).unwrap_or(0)
|
||||
self.rows.get(r).map(|row| row.first_grid() + row.cells.iter().take(c).map(Cell::span).sum::<usize>()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Cell index in row `r` that covers grid column `g`.
|
||||
pub fn cell_at_grid(&self, r: usize, g: usize) -> Option<usize> {
|
||||
let row = self.rows.get(r)?;
|
||||
let mut x = 0;
|
||||
let mut x = row.first_grid();
|
||||
if g < x {
|
||||
return None;
|
||||
}
|
||||
for (i, c) in row.cells.iter().enumerate() {
|
||||
if g < x + c.span() {
|
||||
return Some(i);
|
||||
@@ -495,6 +510,18 @@ impl Table {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn grid_before_shifts_the_row() {
|
||||
let mut t = Table::new(2, 3, 300.0);
|
||||
t.rows[1].cells.remove(0);
|
||||
t.rows[1].props.grid_before = 1;
|
||||
assert_eq!(t.grid_col(1, 0), 1);
|
||||
assert_eq!(t.grid_col(1, 1), 2);
|
||||
assert_eq!(t.cell_at_grid(1, 0), None);
|
||||
assert_eq!(t.cell_at_grid(1, 2), Some(1));
|
||||
assert_eq!(t.cols(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_table_shape() {
|
||||
let t = Table::new(3, 4, 468.0);
|
||||
|
||||
@@ -100,10 +100,22 @@ impl Default for Reflection {
|
||||
}
|
||||
}
|
||||
|
||||
fn clamp(v: f32, lo: f32, hi: f32, dflt: f32) -> f32 {
|
||||
pub(crate) fn clamp(v: f32, lo: f32, hi: f32, dflt: f32) -> f32 {
|
||||
if v.is_finite() { v.clamp(lo, hi) } else { dflt }
|
||||
}
|
||||
|
||||
/// At most [`MAX_STOPS`] stops, positions and transparencies 0–100%, in order (text and shape
|
||||
/// gradients).
|
||||
pub(crate) fn sanitized_stops(stops: &[GradStop]) -> Vec<GradStop> {
|
||||
let mut s: Vec<GradStop> = stops
|
||||
.iter()
|
||||
.take(MAX_STOPS)
|
||||
.map(|g| GradStop { pos: clamp(g.pos, 0.0, 100.0, 0.0), color: g.color, transparency: clamp(g.transparency, 0.0, 100.0, 0.0) })
|
||||
.collect();
|
||||
s.sort_by(|a, b| a.pos.total_cmp(&b.pos));
|
||||
s
|
||||
}
|
||||
|
||||
impl TextFill {
|
||||
/// Finite, in range: at most [`MAX_STOPS`] stops, in order; a gradient without stops is no
|
||||
/// gradient (`None` back).
|
||||
@@ -112,15 +124,10 @@ impl TextFill {
|
||||
TextFill::None => TextFill::None,
|
||||
TextFill::Solid { color, transparency } => TextFill::Solid { color: *color, transparency: clamp(*transparency, 0.0, 100.0, 0.0) },
|
||||
TextFill::Gradient { stops, angle } => {
|
||||
let mut s: Vec<GradStop> = stops
|
||||
.iter()
|
||||
.take(MAX_STOPS)
|
||||
.map(|g| GradStop { pos: clamp(g.pos, 0.0, 100.0, 0.0), color: g.color, transparency: clamp(g.transparency, 0.0, 100.0, 0.0) })
|
||||
.collect();
|
||||
let s = sanitized_stops(stops);
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
s.sort_by(|a, b| a.pos.total_cmp(&b.pos));
|
||||
TextFill::Gradient { stops: s, angle: clamp(*angle, -1e6, 1e6, 0.0).rem_euclid(360.0) }
|
||||
}
|
||||
})
|
||||
|
||||
@@ -17,6 +17,7 @@ miniz_oxide = { workspace = true }
|
||||
sha1 = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
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;
|
||||
|
||||
@@ -201,7 +202,7 @@ fn leaf(sp: &Sp, w: f32, h: f32, float: Float, b: &Build, pics: &mut Pictures) -
|
||||
kind = ShapeKind::TextBox;
|
||||
}
|
||||
let line = kind == ShapeKind::Line;
|
||||
let fill = if line || p.filled == Some(false) { None } else { Some(p.fill_color.unwrap_or(Rgb::WHITE)) };
|
||||
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));
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
//! Sprms → model properties, and style-chain resolution.
|
||||
|
||||
use wordcraft_doc::Rgb;
|
||||
use wordcraft_doc::props::{Align, CharProps, Highlight, LineSpacing, ParaProps, TabAlign, TabLeader, TabStop, TextColor, Underline, VertAlign};
|
||||
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};
|
||||
@@ -42,6 +44,18 @@ 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] = [
|
||||
@@ -93,6 +107,69 @@ pub(crate) fn ico(i: u8) -> Option<Rgb> {
|
||||
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])),
|
||||
@@ -324,6 +401,16 @@ pub(crate) fn apply_para(p: &mut ParaProps, prl: &Prl) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
+96
-17
@@ -51,6 +51,9 @@ const MAX_PARTS: usize = 50_000;
|
||||
const MAX_TOTAL_CHARS: u64 = 64 << 20;
|
||||
/// `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;
|
||||
@@ -581,30 +584,80 @@ fn finish_field(pb: &mut ParaBuild, f: FieldBuild) {
|
||||
/// `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 rest = instr.strip_prefix("HYPERLINK").or_else(|| instr.strip_prefix("hyperlink"))?;
|
||||
let rest = rest.strip_prefix(' ').unwrap_or(rest);
|
||||
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 quoted = None;
|
||||
let mut local = None;
|
||||
let mut tokens = rest.split_whitespace().peekable();
|
||||
while let Some(tok) = tokens.next() {
|
||||
match tok {
|
||||
"\\l" => {
|
||||
local = Some(tokens.next().unwrap_or_default().trim_matches('"').to_string());
|
||||
|
||||
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;
|
||||
}
|
||||
"\\o" | "\\m" | "\\n" | "\\t" => {
|
||||
let _ = tokens.next();
|
||||
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);
|
||||
}
|
||||
}
|
||||
_ if quoted.is_none() => quoted = Some(tok.trim_matches('"').to_string()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some(match local {
|
||||
Some(name) if !name.is_empty() => format!("#{name}"),
|
||||
_ => quoted.unwrap_or_default(),
|
||||
})
|
||||
|
||||
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.
|
||||
@@ -620,6 +673,19 @@ fn pic_location(chpx: &[u8]) -> Option<u32> {
|
||||
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
|
||||
@@ -649,6 +715,19 @@ fn push_formatted(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx, field: Op
|
||||
};
|
||||
let mut props = char_props(grpprl, ctx.fonts, base);
|
||||
apply_prm(&mut props, ctx.pieces.piece_prm(cp), ctx.fonts, base);
|
||||
// A symbol character (Insert › Symbol) is stored as the placeholder U+0028 with
|
||||
// sprmCSymbol carrying the font and the character code ([MS-DOC] §2.6.1).
|
||||
let mut c = c;
|
||||
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;
|
||||
|
||||
@@ -87,20 +87,8 @@ fn parse(bytes: &[u8]) -> Option<CoreProps> {
|
||||
})
|
||||
}
|
||||
|
||||
fn civil(days: i64) -> (i64, u32, u32) {
|
||||
let z = days + 719_468;
|
||||
let era = (if z >= 0 { z } else { z - 146_096 }) / 146_097;
|
||||
let doe = (z - era * 146_097) as u32;
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
||||
let y = yoe as i64 + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = y + i64::from(m <= 2);
|
||||
(y, m, d)
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -114,14 +102,34 @@ fn filetime_iso(value: &[u8]) -> Option<String> {
|
||||
}
|
||||
const EPOCH_DIFFERENCE: u64 = 116_444_736_000_000_000;
|
||||
let unix_100ns = ft.checked_sub(EPOCH_DIFFERENCE)?;
|
||||
let unix_secs = unix_100ns / 10_000_000;
|
||||
let days = (unix_secs / 86_400) as i64;
|
||||
let secs_of_day = (unix_secs % 86_400) as u32;
|
||||
let (h, mi, s) = (secs_of_day / 3600, (secs_of_day / 60) % 60, secs_of_day % 60);
|
||||
let 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;
|
||||
@@ -151,3 +159,35 @@ fn text(value: &[u8], codepage: u16) -> Option<String> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -856,7 +856,7 @@ fn struct_doc(spec: &StructSpec) -> Vec<u8> {
|
||||
let stsh = stsh_bytes(&[std_bytes(1, 0x0FFF, "Normal", &Vec::new(), &Vec::new())]);
|
||||
let stsh_at = table.len();
|
||||
table.extend_from_slice(&stsh);
|
||||
let ffn = ffn_bytes(&["Times New Roman"]);
|
||||
let ffn = ffn_bytes(&["Times New Roman", "Wingdings"]);
|
||||
let ffn_at = table.len();
|
||||
table.extend_from_slice(&ffn);
|
||||
let lst_at = table.len();
|
||||
@@ -1354,6 +1354,22 @@ fn fields_and_hyperlinks() {
|
||||
assert_eq!(result, "3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hyperlink_target_sublocation_and_quotes() {
|
||||
assert_eq!(super::hyperlink_target(r#"HYPERLINK "http://example.com" \l "heading""#), Some("http://example.com#heading".into()));
|
||||
assert_eq!(super::hyperlink_target(r#"HYPERLINK \l "heading""#), Some("#heading".into()));
|
||||
assert_eq!(super::hyperlink_target(r#"Hyperlink "http://example.com/a path""#), Some("http://example.com/a path".into()));
|
||||
assert_eq!(super::hyperlink_target(r#"HYPERLINK "http://example.com" \o "Help text""#), Some("http://example.com".into()));
|
||||
assert_eq!(super::hyperlink_target(r#"HYPERLINK \l "bm" \o "tip""#), Some("#bm".into()));
|
||||
}
|
||||
|
||||
/// A field whose name only starts with HYPERLINK is not a hyperlink.
|
||||
#[test]
|
||||
fn hyperlink_target_needs_the_whole_field_name() {
|
||||
assert_eq!(super::hyperlink_target(r#"HYPERLINKS "x""#), None);
|
||||
assert_eq!(super::hyperlink_target(r#"HYPERLINK "http://example.com""#), Some("http://example.com".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bookmarks_become_objects() {
|
||||
let text = "Hello world\r";
|
||||
@@ -2330,3 +2346,49 @@ fn sprm_t_cell_width_twips_and_percentage() {
|
||||
assert_eq!(table.rows[0].cells[1].props.width, Some(72.0));
|
||||
assert_eq!(table.rows[0].cells[2].props.width_pct, Some(50.0));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// #1767: symbol runs and paragraph borders.
|
||||
|
||||
/// Word stores a symbol character (Insert › Symbol) as the placeholder U+0028 plus
|
||||
/// `sprmCSymbol` (0x6A09), which carries the font table index and the character code
|
||||
/// ([MS-DOC] §2.6.1 CSymbolOperand); the text stream alone would read "(".
|
||||
#[test]
|
||||
fn symbol_runs_read_the_character_and_font_from_sprm_c_symbol() {
|
||||
let text = "(\r";
|
||||
// ftc = 1 (Wingdings), xchar = F071: the box Word draws for Wingdings 0x71.
|
||||
let sym = [1u16.to_le_bytes(), 0xF071u16.to_le_bytes()].concat();
|
||||
let spec = StructSpec { text, chpx_runs: vec![(0, grpprl(&[(0x6A09, &sym)]))], ..structured_spec(text, text.chars().count() as u32) };
|
||||
let doc = read(&struct_doc(&spec)).expect("opens");
|
||||
let p = doc.body.first().and_then(|b| b.as_para()).expect("paragraph");
|
||||
assert_eq!(p.text, "\u{F071}", "the symbol, not the placeholder");
|
||||
assert_eq!(p.props_of_char(0).font.as_deref(), Some("Wingdings"), "its font from the font table");
|
||||
}
|
||||
|
||||
/// Paragraph borders around a block are read from both storage forms: the Word 97 Brc80
|
||||
/// (palette colour) and the Word 2000+ BrcOperand (cb + Brc with a full colour).
|
||||
#[test]
|
||||
fn paragraph_borders_read_from_both_forms() {
|
||||
let b80 = [8u8, 0x01, 0x06, 0x01]; // 1 pt single line, palette red (6), 1 pt from the text
|
||||
let brc = [8u8, 0xFF, 0x00, 0x00, 0x00, 8, 0x01, 0x02, 0x00]; // cb 8, red COLORREF, 1 pt single, space 2
|
||||
let text = "boxed\r\r";
|
||||
let spec = StructSpec {
|
||||
text,
|
||||
papx_entries: vec![
|
||||
(0, 0, grpprl(&[(0x6424, &b80), (0x6425, &b80), (0x6426, &b80), (0x6427, &b80), (0x6428, &b80)])),
|
||||
(6, 0, grpprl(&[(0xC64E, &brc)])),
|
||||
],
|
||||
..structured_spec(text, text.chars().count() as u32)
|
||||
};
|
||||
let doc = read(&struct_doc(&spec)).expect("opens");
|
||||
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("first paragraph");
|
||||
let b = p0.props.borders.expect("borders");
|
||||
let top = b.top.expect("top border");
|
||||
assert_eq!((top.style, top.width, top.space), (wordcraft_doc::props::BorderStyle::Single, 1.0, 1.0));
|
||||
assert_eq!(top.color, Some(wordcraft_doc::Rgb(0xFF, 0, 0)));
|
||||
assert!(b.left.is_some() && b.bottom.is_some() && b.right.is_some() && b.between.is_some());
|
||||
let p1 = doc.body.get(1).and_then(|b| b.as_para()).expect("second paragraph");
|
||||
let top = p1.props.borders.expect("borders").top.expect("top border");
|
||||
assert_eq!((top.style, top.width, top.space), (wordcraft_doc::props::BorderStyle::Single, 1.0, 2.0));
|
||||
assert_eq!(top.color, Some(wordcraft_doc::Rgb(0xFF, 0, 0)));
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! specification, with tiny pictures generated in code. No file produced by Word is used.
|
||||
|
||||
use proptest::prelude::*;
|
||||
use wordcraft_doc::fill::Fill;
|
||||
use wordcraft_doc::para::{Anchor, InlineObject, ShapeKind, Wrap};
|
||||
use wordcraft_doc::props::Rgb;
|
||||
|
||||
@@ -173,10 +174,10 @@ fn shapes_keep_kind_colours_wrap_and_text_box_text() {
|
||||
assert_eq!(p0.objects.len(), 2, "{:?}", p0.objects);
|
||||
let Some(InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, .. }) = p0.objects.first() else { panic!("{:?}", p0.objects) };
|
||||
assert_eq!((*kind, *w, *h), (ShapeKind::Ellipse, 72.0, 72.0));
|
||||
assert_eq!((*fill, *stroke, *stroke_width), (Some(Rgb(255, 0, 0)), Some(Rgb(0, 0, 255)), 2.0));
|
||||
assert_eq!((fill.clone(), *stroke, *stroke_width), (Some(Fill::solid(Rgb(255, 0, 0))), Some(Rgb(0, 0, 255)), 2.0));
|
||||
assert_eq!((float.wrap, float.h_rel, float.v_rel), (Wrap::BehindText, Anchor::Column, Anchor::Paragraph));
|
||||
let Some(InlineObject::Shape { kind, fill, float, story, .. }) = p0.objects.get(1) else { panic!("{:?}", p0.objects) };
|
||||
assert_eq!((*kind, *fill, float.wrap), (ShapeKind::TextBox, None, Wrap::TopAndBottom));
|
||||
assert_eq!((*kind, fill.clone(), float.wrap), (ShapeKind::TextBox, None, Wrap::TopAndBottom));
|
||||
let part = story.and_then(|id| doc.parts.get(&id)).expect("text box story part");
|
||||
assert_eq!(part.kind, wordcraft_doc::PartKind::TextBox);
|
||||
assert_eq!(part.blocks.first().and_then(|b| b.as_para()).map(|p| p.text.as_str()), Some("Inside"));
|
||||
|
||||
@@ -15,6 +15,7 @@ quick-xml = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
unicode-bidi = { workspace = true }
|
||||
cfb = { workspace = true }
|
||||
aes = { workspace = true }
|
||||
|
||||
@@ -52,6 +52,22 @@ pub fn read_xml(bytes: &[u8]) -> Result<wordcraft_doc::Document, DocxError> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The title (`dc:title`) in a package's core properties part (`docProps/core.xml`, ECMA-376
|
||||
/// Part 2 §11), trimmed; `None` when the part can't be read or has no title. Only the package's
|
||||
/// directory and at most `max_part` bytes of that part are read: for listing templates by name
|
||||
/// without reading the whole package.
|
||||
pub fn package_title<R: std::io::Read + std::io::Seek>(package: R, max_part: u64) -> Option<String> {
|
||||
use std::io::Read;
|
||||
let mut zip = zip::ZipArchive::new(package).ok()?;
|
||||
let part = zip.by_name("docProps/core.xml").ok()?;
|
||||
let mut xml = Vec::new();
|
||||
part.take(max_part).read_to_end(&mut xml).ok()?;
|
||||
let root = xml::parse(&xml).ok()?;
|
||||
let title = root.child("dc:title")?.text();
|
||||
let title = title.trim();
|
||||
(!title.is_empty()).then(|| title.to_string())
|
||||
}
|
||||
|
||||
/// What [`DocxError::PasswordRequired`] says.
|
||||
pub const PASSWORD_REQUIRED: &str = "this document is protected with a password";
|
||||
/// What [`DocxError::WrongPassword`] says.
|
||||
|
||||
+233
-22
@@ -2,7 +2,8 @@
|
||||
//!
|
||||
//! Bar and column (clustered, stacked or 100% stacked), line, area, pie, doughnut and scatter
|
||||
//! charts are drawn from the first supported chart type of the plot area, with their values as
|
||||
//! data labels where `c:dLbls` shows them (`c:showVal`). A chart of only other types (radar, stock…)
|
||||
//! data labels where `c:dLbls` shows them (`c:showVal`). Radar charts (standard, with markers or
|
||||
//! filled) draw as spokes, concentric grid polygons and one polygon per series. A chart of only other types (stock, bubble…)
|
||||
//! draws a placeholder: its frame, its title and the type it is. Text widths are estimated ([`CHAR_W`] em a character), so label places are approximate.
|
||||
|
||||
use std::f64::consts::{FRAC_PI_2, TAU};
|
||||
@@ -23,6 +24,10 @@ const MAX_POINTS: usize = 10_000;
|
||||
const MAX_CELLS: usize = 100_000;
|
||||
/// Longest series name or category label kept.
|
||||
const MAX_LABEL: usize = 200;
|
||||
/// Most categories (spokes) of a radar chart.
|
||||
const MAX_SPOKES: usize = 100;
|
||||
/// Most series of a radar chart.
|
||||
const MAX_RADAR_SERIES: usize = 50;
|
||||
/// Most axis ticks drawn.
|
||||
const MAX_TICKS: usize = 20;
|
||||
/// Default text size, points.
|
||||
@@ -75,6 +80,7 @@ pub(crate) fn chart_items(space: &El, theme: &[Rgb], w: f32, h: f32) -> Vec<Grap
|
||||
}
|
||||
match chart.kind {
|
||||
Kind::Pie | Kind::Doughnut => pie(&mut out, &chart, area),
|
||||
Kind::Radar => radar(&mut out, &chart, area),
|
||||
_ => cartesian(&mut out, &chart, area),
|
||||
}
|
||||
out
|
||||
@@ -87,7 +93,7 @@ fn title_block(out: &mut Vec<GraphicItem>, t: &str, area: Rect) -> Rect {
|
||||
Rect { y: area.y + lh + PAD, h: (area.h - lh - PAD).max(1.0), ..area }
|
||||
}
|
||||
|
||||
/// A chart whose plot area holds only chart types WordCraft doesn't draw (radar, stock…): its
|
||||
/// A chart whose plot area holds only chart types WordCraft doesn't draw (stock, bubble…): its
|
||||
/// frame with a light border, its title and a line saying which type it is, so it isn't blank
|
||||
/// space. Nothing when the plot area has no chart type at all, or a supported one with nothing
|
||||
/// to draw.
|
||||
@@ -114,7 +120,6 @@ fn placeholder(out: &mut Vec<GraphicItem>, space: &El, theme: &[Rgb], w: f32, h:
|
||||
/// What a placeholder calls a chart type element WordCraft doesn't draw.
|
||||
fn unsupported_name(name: &str) -> &'static str {
|
||||
match name {
|
||||
"c:radarChart" => "Radar chart",
|
||||
"c:stockChart" => "Stock chart",
|
||||
"c:bubbleChart" => "Bubble chart",
|
||||
"c:surfaceChart" | "c:surface3DChart" => "Surface chart",
|
||||
@@ -144,6 +149,7 @@ enum Kind {
|
||||
Pie,
|
||||
Doughnut,
|
||||
Scatter,
|
||||
Radar,
|
||||
}
|
||||
|
||||
impl Kind {
|
||||
@@ -155,6 +161,7 @@ impl Kind {
|
||||
"c:pieChart" => Kind::Pie,
|
||||
"c:doughnutChart" => Kind::Doughnut,
|
||||
"c:scatterChart" => Kind::Scatter,
|
||||
"c:radarChart" => Kind::Radar,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -221,6 +228,10 @@ struct Chart {
|
||||
/// Doughnut hole, fraction of the outer radius.
|
||||
hole: f32,
|
||||
first_angle: f64,
|
||||
/// Radar: series polygons are filled (`c:radarStyle` = `filled`).
|
||||
radar_fill: bool,
|
||||
/// Radar: points carry markers (`c:radarStyle` = `marker`).
|
||||
radar_marks: bool,
|
||||
vary: bool,
|
||||
/// Number of categories (scatter: points per series at most).
|
||||
n: usize,
|
||||
@@ -253,7 +264,9 @@ impl Chart {
|
||||
return None;
|
||||
}
|
||||
let cats = ty.children("c:ser").find_map(|s| s.child("c:cat")).map(|c| labels(Some(c), MAX_POINTS, date1904)).unwrap_or_default();
|
||||
let n = if matches!(kind, Kind::Pie | Kind::Doughnut) {
|
||||
let n = if kind == Kind::Radar {
|
||||
series.iter().map(|s| s.values.len()).max().unwrap_or(0).max(cats.len()).min(MAX_SPOKES)
|
||||
} else if matches!(kind, Kind::Pie | Kind::Doughnut) {
|
||||
series.first().map_or(0, |s| s.values.len()).max(cats.len())
|
||||
} else {
|
||||
series.iter().map(|s| s.values.len()).max().unwrap_or(0).max(cats.len())
|
||||
@@ -262,6 +275,9 @@ impl Chart {
|
||||
if let Some(keep) = MAX_CELLS.checked_div(n) {
|
||||
series.truncate(keep.max(1));
|
||||
}
|
||||
if kind == Kind::Radar {
|
||||
series.truncate(MAX_RADAR_SERIES);
|
||||
}
|
||||
let grouping = match ty.child("c:grouping").and_then(|g| g.attr("val")) {
|
||||
Some("stacked") if matches!(kind, Kind::Bar | Kind::Area | Kind::Line) => Grouping::Stacked,
|
||||
Some("percentStacked") if matches!(kind, Kind::Bar | Kind::Area | Kind::Line) => Grouping::Percent,
|
||||
@@ -269,6 +285,7 @@ impl Chart {
|
||||
};
|
||||
let mut cats = cats;
|
||||
cats.resize(n, String::new());
|
||||
let radar_style = ty.child("c:radarStyle").and_then(|r| r.attr("val"));
|
||||
let type_labels = show_values(ty.child("c:dLbls"));
|
||||
for s in &mut series {
|
||||
s.values.resize(n, None);
|
||||
@@ -296,6 +313,8 @@ impl Chart {
|
||||
overlap: num("c:overlap").unwrap_or(0).clamp(-100, 100) as f32 / 100.0,
|
||||
hole: num("c:holeSize").unwrap_or(50).clamp(10, 90) as f32 / 100.0,
|
||||
first_angle: num("c:firstSliceAng").unwrap_or(0).clamp(0, 360) as f64,
|
||||
radar_fill: radar_style == Some("filled"),
|
||||
radar_marks: radar_style == Some("marker"),
|
||||
vary: matches!(kind, Kind::Pie | Kind::Doughnut) && ty.child("c:varyColors").and_then(|v| v.attr("val")).is_none_or(truthy),
|
||||
n,
|
||||
cats,
|
||||
@@ -803,18 +822,27 @@ fn excel_stamp(serial: f64, date1904: bool) -> Option<Stamp> {
|
||||
Some(Stamp { y, m, d, weekday: (day - 1).rem_euclid(7) as u32, secs })
|
||||
}
|
||||
|
||||
/// Calendar date (year, month, day) of day `n` counted from 30 December 1899 (Howard Hinnant's
|
||||
/// civil-from-days algorithm).
|
||||
/// Calendar date (year, month, day) of day `n` counted from 30 December 1899 (an Excel serial
|
||||
/// day number). Serial `n` is `n - 25_569` days from 1970-01-01.
|
||||
///
|
||||
/// The caller's serial guard keeps `n` inside chrono's range, but the Gregorian calendar repeats
|
||||
/// every 400 years (146_097 days), so this stays exact for any `n` without hand-rolled calendar
|
||||
/// math: chrono converts the day within its cycle and whole cycles go back into the year (an
|
||||
/// i128 so the extremes can't overflow). 1970-01-01 is `num_days_from_ce` 719_163, so serial `n`
|
||||
/// is CE day `n - 25_569 + 719_163`.
|
||||
fn civil(n: i64) -> (i64, u32, u32) {
|
||||
let z = n - 25_569 + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097);
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
||||
let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
|
||||
(yoe + era * 400 + i64::from(m <= 2), m, d)
|
||||
use chrono::Datelike;
|
||||
// CE day of serial `n`, in i128 so no `n` overflows.
|
||||
let from_ce = i128::from(n) - 25_569 + 719_163;
|
||||
let (cycles, rest) = (from_ce.div_euclid(146_097), from_ce.rem_euclid(146_097));
|
||||
match i32::try_from(rest).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
|
||||
Some(date) => {
|
||||
let y = i128::from(date.year()) + cycles * 400;
|
||||
(i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), date.month(), date.day())
|
||||
}
|
||||
// Unreachable: every `rest` in 0..146_097 is a valid CE day.
|
||||
None => (1900, 1, 1),
|
||||
}
|
||||
}
|
||||
|
||||
const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
|
||||
@@ -1324,7 +1352,7 @@ fn cartesian(out: &mut Vec<GraphicItem>, c: &Chart, area: Rect) {
|
||||
Kind::Line => lines(out, c, &g, &mut labels),
|
||||
Kind::Area => areas(out, c, &g, &mut labels),
|
||||
Kind::Scatter => draw_scatter(out, c, &g, &mut labels),
|
||||
Kind::Pie | Kind::Doughnut => {}
|
||||
Kind::Pie | Kind::Doughnut | Kind::Radar => {}
|
||||
}
|
||||
draw_axes(out, c, &g);
|
||||
draw_data_labels(out, &labels, c.text_size, g.horiz && c.kind == Kind::Bar);
|
||||
@@ -1621,6 +1649,94 @@ fn pie(out: &mut Vec<GraphicItem>, c: &Chart, area: Rect) {
|
||||
draw_data_labels(out, &labels, c.text_size, false);
|
||||
}
|
||||
|
||||
/// A radar chart in `area`: category spokes from the centre, concentric grid polygons at the value
|
||||
/// ticks, one polygon per series (filled, or outlined with markers) and the category labels at the
|
||||
/// spoke ends. The lowest tick is the centre.
|
||||
fn radar(out: &mut Vec<GraphicItem>, c: &Chart, area: Rect) {
|
||||
const MARKER: f32 = 5.0;
|
||||
if c.n == 0 {
|
||||
return;
|
||||
}
|
||||
let fs = c.text_size;
|
||||
let lh = fs * LINE;
|
||||
let labelled = c.cats.iter().any(|s| !s.is_empty());
|
||||
let side = if labelled { widest(&c.cats, fs).min(area.w * 0.25) + GAP + 2.0 } else { 0.0 };
|
||||
let vert = if labelled { lh + GAP } else { 0.0 };
|
||||
let r = ((area.w / 2.0 - side).min(area.h / 2.0 - vert) - 2.0) as f64;
|
||||
if r.is_nan() || r < 8.0 {
|
||||
return;
|
||||
}
|
||||
let (cx, cy) = ((area.x + area.w / 2.0) as f64, (area.y + area.h / 2.0) as f64);
|
||||
let vs = Axes::of(c).vs;
|
||||
let span = vs.hi - vs.lo;
|
||||
let angle = |i: usize| i as f64 / c.n as f64 * TAU;
|
||||
let at = |v: f64, i: usize| {
|
||||
let t = if span > 0.0 { ((v - vs.lo) / span).clamp(0.0, 1.0) } else { 0.0 };
|
||||
polar(cx, cy, r * t, angle(i))
|
||||
};
|
||||
let ticks = vs.ticks();
|
||||
if c.val.grid {
|
||||
for t in ticks.iter().skip(1) {
|
||||
let mut segs: Vec<PathSeg> = (0..c.n).map(|i| if i == 0 { pt(at(*t, i)) } else { line_to(at(*t, i)) }).collect();
|
||||
segs.push(PathSeg::Close);
|
||||
stroke(out, segs, GRID, 0.75);
|
||||
}
|
||||
}
|
||||
for i in 0..c.n {
|
||||
let end = polar(cx, cy, r, angle(i));
|
||||
stroke(out, vec![pt((cx, cy)), line_to(end)], GRID, 0.75);
|
||||
}
|
||||
let mut labels = Vec::new();
|
||||
for (j, s) in c.series.iter().enumerate().filter(|(_, s)| has_values(s)) {
|
||||
let col = c.series_colour(j);
|
||||
// A missing value counts as the centre, as Word draws it.
|
||||
let ring: Vec<(f64, f64)> = (0..c.n).map(|i| at(s.values.get(i).copied().flatten().unwrap_or(vs.lo), i)).collect();
|
||||
let mut segs: Vec<PathSeg> = ring.iter().enumerate().map(|(i, p)| if i == 0 { pt(*p) } else { line_to(*p) }).collect();
|
||||
segs.push(PathSeg::Close);
|
||||
if c.radar_fill {
|
||||
fill(out, segs.clone(), col);
|
||||
}
|
||||
stroke(out, segs, col, s.line_w);
|
||||
for (i, p) in ring.iter().enumerate() {
|
||||
if c.radar_marks && s.values.get(i).copied().flatten().is_some() {
|
||||
let rect = [p.0 as f32 - MARKER / 2.0, p.1 as f32 - MARKER / 2.0, MARKER, MARKER];
|
||||
out.push(GraphicItem::Shape { rect, kind: ShapeKind::Ellipse, fill: Some(col), stroke: None, stroke_width: 0.0 });
|
||||
}
|
||||
if let Some(text) = label_of(s, i) {
|
||||
labels.push(DataLabel { x: p.0 as f32, y: p.1 as f32, text, inside: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
draw_data_labels(out, &labels, fs, false);
|
||||
if !c.val.deleted {
|
||||
for t in &ticks {
|
||||
let y = at(*t, 0).1 as f32;
|
||||
let s = format_value(*t, &c.val_fmt, vs.step);
|
||||
let w = text_w(&s, fs) + 4.0;
|
||||
text(out, Rect { x: cx as f32 - GAP / 2.0 - w, y: y - lh / 2.0, w, h: lh }, &s, fs, TextAlign::Right, false);
|
||||
}
|
||||
}
|
||||
if labelled {
|
||||
for (i, s) in c.cats.iter().enumerate().filter(|(_, s)| !s.is_empty()) {
|
||||
let a = angle(i);
|
||||
let (x, y) = polar(cx, cy, r + GAP as f64, a);
|
||||
let (x, y) = (x as f32, y as f32);
|
||||
let w = side - GAP;
|
||||
let txt = clip_text(s, w, fs);
|
||||
let (sx, sy) = (a.sin(), a.cos());
|
||||
let (rect, align) = if sx > 0.2 {
|
||||
(Rect { x, y: y - lh / 2.0, w, h: lh }, TextAlign::Left)
|
||||
} else if sx < -0.2 {
|
||||
(Rect { x: x - w, y: y - lh / 2.0, w, h: lh }, TextAlign::Right)
|
||||
} else {
|
||||
let cw = (text_w(&txt, fs) + 4.0).max(1.0);
|
||||
(Rect { x: x - cw / 2.0, y: if sy > 0.0 { y - lh } else { y }, w: cw, h: lh }, TextAlign::Center)
|
||||
};
|
||||
text(out, rect, &txt, fs, align, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The data label of point `i` of series `s`, when it shows one: the value as the file has it,
|
||||
/// in the series' number format.
|
||||
fn label_of(s: &Series, i: usize) -> Option<String> {
|
||||
@@ -1849,21 +1965,104 @@ mod tests {
|
||||
fn unsupported_chart_types_draw_a_placeholder() {
|
||||
let ser = r#"<c:ser><c:val><c:numLit><c:ptCount val="1"/><c:pt idx="0"><c:v>1</c:v></c:pt></c:numLit></c:val></c:ser>"#;
|
||||
let title = r#"<c:title><c:tx><c:rich><a:p><a:r><a:t>Skills</a:t></a:r></a:p></c:rich></c:tx></c:title>"#;
|
||||
let x = format!(r#"<c:chartSpace {NS}><c:chart>{title}<c:plotArea><c:radarChart>{ser}</c:radarChart></c:plotArea></c:chart></c:chartSpace>"#);
|
||||
let radar = chart_items(&crate::xml::parse(x.as_bytes()).unwrap(), &theme(), 300.0, 200.0);
|
||||
assert_eq!(texts(&radar), ["Skills", "Radar chart (not supported yet)"]);
|
||||
let framed = radar.iter().any(|it| matches!(it, GraphicItem::Shape { rect, stroke: Some(_), .. } if *rect == [0.0, 0.0, 300.0, 200.0]));
|
||||
assert!(framed, "a light border marks the chart's frame: {radar:?}");
|
||||
let x =
|
||||
format!(r#"<c:chartSpace {NS}><c:chart>{title}<c:plotArea><c:bubbleChart>{ser}</c:bubbleChart></c:plotArea></c:chart></c:chartSpace>"#);
|
||||
let bubble = chart_items(&crate::xml::parse(x.as_bytes()).unwrap(), &theme(), 300.0, 200.0);
|
||||
assert_eq!(texts(&bubble), ["Skills", "Bubble chart (not supported yet)"]);
|
||||
let framed = bubble.iter().any(|it| matches!(it, GraphicItem::Shape { rect, stroke: Some(_), .. } if *rect == [0.0, 0.0, 300.0, 200.0]));
|
||||
assert!(framed, "a light border marks the chart's frame: {bubble:?}");
|
||||
|
||||
let stock = chart_items(&space(&format!("<c:stockChart>{ser}{ser}{ser}</c:stockChart>")), &theme(), 300.0, 200.0);
|
||||
assert_eq!(texts(&stock), ["Stock chart (not supported yet)"]);
|
||||
let other = chart_items(&space(&format!("<c:fancyChart>{ser}</c:fancyChart>")), &theme(), 300.0, 200.0);
|
||||
assert_eq!(texts(&other), ["Chart (not supported yet)"]);
|
||||
// A supported type in the plot area is drawn, an unsupported one beside it is skipped.
|
||||
let combo = chart_items(&space(&format!("<c:radarChart>{ser}</c:radarChart><c:lineChart>{ser}</c:lineChart>")), &theme(), 300.0, 200.0);
|
||||
let combo = chart_items(&space(&format!("<c:stockChart>{ser}</c:stockChart><c:lineChart>{ser}</c:lineChart>")), &theme(), 300.0, 200.0);
|
||||
assert!(!texts(&combo).iter().any(|t| t.contains("not supported")), "{combo:?}");
|
||||
}
|
||||
|
||||
fn radar_chart(style: &str, sers: &[String]) -> Vec<GraphicItem> {
|
||||
let plot = format!(r#"<c:radarChart><c:radarStyle val="{style}"/>{}</c:radarChart><c:valAx><c:majorGridlines/></c:valAx>"#, sers.concat());
|
||||
chart_items(&space(&plot), &theme(), 300.0, 220.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radar_charts_draw_spokes_grid_and_series_polygons() {
|
||||
let cats = ["Speed", "Power", "Range", "Armor", "Luck"];
|
||||
let sers = [ser(0, "A", Some("FF0000"), &cats, &["3", "4", "5", "2", "1"]), ser(1, "B", Some("0000FF"), &cats, &["2", "2", "3", "5", "4"])];
|
||||
let std = radar_chart("standard", &sers);
|
||||
assert!(!texts(&std).iter().any(|t| t.contains("not supported")), "{std:?}");
|
||||
for c in cats {
|
||||
assert!(texts(&std).contains(&c), "category label {c}: {:?}", texts(&std));
|
||||
}
|
||||
let stroked = |items: &[GraphicItem], col: Rgb| {
|
||||
items.iter().filter(|it| matches!(it, GraphicItem::Path { stroke: Some(c), segs, .. } if *c == col && segs.len() == 6)).count()
|
||||
};
|
||||
assert_eq!(stroked(&std, Rgb(0xFF, 0, 0)), 1, "one closed pentagon per series: {std:?}");
|
||||
assert_eq!(stroked(&std, Rgb(0, 0, 0xFF)), 1);
|
||||
assert!(filled(&std).is_empty(), "standard radar is not filled");
|
||||
assert!(!std.iter().any(|it| matches!(it, GraphicItem::Shape { kind: ShapeKind::Ellipse, .. })));
|
||||
// 5 spokes (2 segs each) plus grid pentagons.
|
||||
let spokes = std.iter().filter(|it| matches!(it, GraphicItem::Path { stroke: Some(c), segs, .. } if *c == GRID && segs.len() == 2)).count();
|
||||
assert_eq!(spokes, 5);
|
||||
assert!(std.iter().any(|it| matches!(it, GraphicItem::Path { stroke: Some(c), segs, .. } if *c == GRID && segs.len() == 6)));
|
||||
|
||||
let marked = radar_chart("marker", &sers);
|
||||
let dots = marked.iter().filter(|it| matches!(it, GraphicItem::Shape { kind: ShapeKind::Ellipse, .. })).count();
|
||||
assert_eq!(dots, 10, "a marker per point");
|
||||
|
||||
let solid = radar_chart("filled", &sers);
|
||||
let f = filled(&solid);
|
||||
assert_eq!(f.len(), 2);
|
||||
assert!(f.iter().any(|(c, ..)| *c == Rgb(0xFF, 0, 0)) && f.iter().any(|(c, ..)| *c == Rgb(0, 0, 0xFF)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radar_charts_show_a_legend() {
|
||||
let cats = ["a", "b", "c"];
|
||||
let plot = format!("<c:radarChart>{}</c:radarChart>", ser(0, "Team", None, &cats, &["1", "2", "3"]));
|
||||
let items = with_legend(&plot, "r", 300.0, 200.0);
|
||||
assert!(texts(&items).contains(&"Team"), "{:?}", texts(&items));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_radar_charts_never_panic() {
|
||||
// No categories and no values.
|
||||
let empty =
|
||||
r#"<c:ser><c:cat><c:strLit><c:ptCount val="0"/></c:strLit></c:cat><c:val><c:numLit><c:ptCount val="0"/></c:numLit></c:val></c:ser>"#;
|
||||
let _ = chart_items(&space(&format!("<c:radarChart>{empty}</c:radarChart>")), &theme(), 300.0, 200.0);
|
||||
let none = chart_items(&space("<c:radarChart/>"), &theme(), 300.0, 200.0);
|
||||
assert!(none.is_empty(), "{none:?}");
|
||||
// One category: a degenerate polygon, nothing to crash on; two as well.
|
||||
for n in 1..=2 {
|
||||
let cats: Vec<String> = (0..n).map(|i| format!("c{i}")).collect();
|
||||
let refs: Vec<&str> = cats.iter().map(String::as_str).collect();
|
||||
let vals = vec!["5"; n];
|
||||
let items = radar_chart("filled", &[ser(0, "S", None, &refs, &vals)]);
|
||||
assert!(texts(&items).contains(&"c0"), "{items:?}");
|
||||
}
|
||||
// NaN, infinities, junk and a tiny frame.
|
||||
let junk = ser(0, "S", None, &["a", "b", "c"], &["NaN", "inf", "1e999"]);
|
||||
let _ = radar_chart("filled", &[junk]);
|
||||
let small = chart_items(
|
||||
&space(&format!("<c:radarChart>{}</c:radarChart>", ser(0, "S", None, &["a", "b", "c"], &["1", "2", "3"]))),
|
||||
&theme(),
|
||||
20.0,
|
||||
20.0,
|
||||
);
|
||||
let _ = small;
|
||||
// Caps: thousands of categories and series stay bounded.
|
||||
let cats: Vec<String> = (0..5000).map(|i| format!("c{i}")).collect();
|
||||
let refs: Vec<&str> = cats.iter().map(String::as_str).collect();
|
||||
let vals = vec!["1"; 5000];
|
||||
let many: Vec<String> = (0..300).map(|i| ser(i, "S", None, &refs, &vals)).collect();
|
||||
let items = radar_chart("marker", &many);
|
||||
let polygons = items.iter().filter(|it| matches!(it, GraphicItem::Path { stroke: Some(c), .. } if *c != GRID)).count();
|
||||
assert!(polygons <= MAX_RADAR_SERIES, "{polygons}");
|
||||
let spokes = items.iter().filter(|it| matches!(it, GraphicItem::Path { stroke: Some(c), segs, .. } if *c == GRID && segs.len() == 2)).count();
|
||||
assert_eq!(spokes, MAX_SPOKES);
|
||||
}
|
||||
|
||||
/// Chart items for a chart with a legend at `pos` (`r`, `b`…).
|
||||
fn with_legend(plot: &str, pos: &str, w: f32, h: f32) -> Vec<GraphicItem> {
|
||||
let x = format!(
|
||||
@@ -1975,6 +2174,18 @@ mod tests {
|
||||
assert_eq!(ymd(45292.0), Some((2024, 1, 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn civil_is_exact_beyond_chronos_range() {
|
||||
// Serial 1 is 1899-12-31, serial 60 Excel's phantom 1900-02-29 handled by `excel_stamp`.
|
||||
assert_eq!(civil(1), (1899, 12, 31));
|
||||
// Past `NaiveDate`'s ±262143 the 400-year cycles carry the year, exactly, both sides of
|
||||
// the epoch; values from the pre-chrono proleptic Gregorian formula.
|
||||
assert_eq!(civil(100_000_000), (275_690, 9, 11));
|
||||
assert_eq!(civil(-100_000_000), (-271_891, 4, 18));
|
||||
// The extremes must not overflow or panic (i128 intermediates).
|
||||
let _ = (civil(i64::MAX), civil(i64::MIN));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_formats_keep_literal_text_and_currency() {
|
||||
assert_eq!(format_value(1234.5, "#,##0.00 [$€-407]", 0.25), "1,234.50 €");
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
//! DrawingML colours shared by charts and SmartArt: a colour element with its modifiers, and the
|
||||
//! paint of a fill or line (solid, the first stop of a gradient, a pattern's foreground).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::THEME_COLORS;
|
||||
use wordcraft_doc::fill::{Fill, Gradient, GradientFlip, GradientKind, PathShape};
|
||||
use wordcraft_doc::props::Rgb;
|
||||
use wordcraft_doc::wordart::{GradStop, MAX_STOPS};
|
||||
|
||||
use crate::units::int;
|
||||
use crate::xml::El;
|
||||
@@ -35,11 +39,24 @@ fn is_color(e: &El) -> bool {
|
||||
}
|
||||
|
||||
/// A colour element (`a:srgbClr`, `a:schemeClr`, `a:sysClr`), or the one inside `e`, with its
|
||||
/// `lumMod` / `lumOff` / `tint` / `shade` applied in document order.
|
||||
/// `lumMod` / `lumOff` / `tint` / `shade` / `satMod` applied in document order.
|
||||
pub(crate) fn color(e: &El, theme: &[Rgb]) -> Option<Rgb> {
|
||||
color_alpha(e, theme, None).map(|(c, _)| c)
|
||||
}
|
||||
|
||||
/// [`color`] and its transparency, percent (`a:alpha` is opacity, so 30% opaque is 70%
|
||||
/// transparent). `ph` is the colour and transparency `phClr` stands for (in a theme's fill
|
||||
/// styles: the colour of the shape's style reference).
|
||||
pub(crate) fn color_alpha(e: &El, theme: &[Rgb], ph: Option<(Rgb, f32)>) -> Option<(Rgb, f32)> {
|
||||
let c = if is_color(e) { e } else { e.els().find(|c| is_color(c))? };
|
||||
let mut opacity = 100.0;
|
||||
let base = match c.name.as_str() {
|
||||
"a:srgbClr" => Rgb::parse(c.attr("val")?)?,
|
||||
"a:schemeClr" if c.attr("val") == Some("phClr") => {
|
||||
let (rgb, t) = ph?;
|
||||
opacity -= t;
|
||||
rgb
|
||||
}
|
||||
"a:schemeClr" => *theme.get(scheme_slot(c.attr("val")?)?)?,
|
||||
"a:sysClr" => match c.attr("lastClr").and_then(Rgb::parse) {
|
||||
Some(rgb) => rgb,
|
||||
@@ -58,7 +75,7 @@ pub(crate) fn color(e: &El, theme: &[Rgb]) -> Option<Rgb> {
|
||||
"a:hslClr" => {
|
||||
let v = |n: &str| c.attr(n).and_then(int).unwrap_or(0) as f64;
|
||||
let hue = (v("hue") / 60_000.0).rem_euclid(360.0) / 360.0;
|
||||
from_hsl(hue, (v("sat") / 100_000.0).clamp(0.0, 1.0), (v("lum") / 100_000.0).clamp(0.0, 1.0))
|
||||
Rgb::from_hsl(hue, (v("sat") / 100_000.0).clamp(0.0, 1.0), (v("lum") / 100_000.0).clamp(0.0, 1.0))
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
@@ -66,14 +83,68 @@ pub(crate) fn color(e: &El, theme: &[Rgb]) -> Option<Rgb> {
|
||||
let mut rgb = base;
|
||||
for m in c.els() {
|
||||
rgb = match m.name.as_str() {
|
||||
"a:lumMod" => adjust_lum(rgb, |l| l * val(m)),
|
||||
"a:lumOff" => adjust_lum(rgb, |l| l + val(m)),
|
||||
"a:tint" => mix(rgb, 255.0, val(m)),
|
||||
"a:shade" => mix(rgb, 0.0, val(m)),
|
||||
n @ ("a:lumMod" | "a:lumOff" | "a:tint" | "a:shade" | "a:satMod") => modified(rgb, n.trim_start_matches("a:"), val(m)),
|
||||
"a:alpha" => {
|
||||
opacity = val(m) as f32 * 100.0;
|
||||
rgb
|
||||
}
|
||||
"a:alphaMod" => {
|
||||
opacity *= val(m) as f32;
|
||||
rgb
|
||||
}
|
||||
"a:alphaOff" => {
|
||||
opacity += val(m) as f32 * 100.0;
|
||||
rgb
|
||||
}
|
||||
_ => rgb,
|
||||
};
|
||||
}
|
||||
Some(rgb)
|
||||
Some((rgb, (100.0 - opacity).clamp(0.0, 100.0)))
|
||||
}
|
||||
|
||||
/// Fill element `e` (`a:solidFill`, `a:gradFill`; a pattern as its foreground colour) as a
|
||||
/// shape's fill; `None` for no fill, picture and group fills. `ph` as in [`color_alpha`].
|
||||
pub(crate) fn fill(e: &El, theme: &[Rgb], ph: Option<(Rgb, f32)>) -> Option<Fill> {
|
||||
let solid = |c: Option<&El>| c.and_then(|c| color_alpha(c, theme, ph)).map(|(color, transparency)| Fill::Solid { color, transparency });
|
||||
match e.name.as_str() {
|
||||
"a:solidFill" => solid(Some(e)),
|
||||
"a:pattFill" => solid(e.child("a:fgClr")),
|
||||
"a:gradFill" => {
|
||||
let pct = |v: Option<&str>| v.and_then(int).map_or(0.0, |v| v.clamp(-100_000_000, 100_000_000) as f32 / 1000.0);
|
||||
let rect = |r: Option<&El>| r.map_or([0.0; 4], |r| ["l", "t", "r", "b"].map(|k| pct(r.attr(k))));
|
||||
let stops = e
|
||||
.child("a:gsLst")
|
||||
.into_iter()
|
||||
.flat_map(|l| l.children("a:gs"))
|
||||
.take(MAX_STOPS * 4)
|
||||
.filter_map(|g| color_alpha(g, theme, ph).map(|(color, transparency)| GradStop { pos: pct(g.attr("pos")), color, transparency }))
|
||||
.collect();
|
||||
let on = |v: Option<&str>| v.is_some_and(|v| v == "1" || v == "true");
|
||||
let kind = match (e.child("a:path"), e.child("a:lin")) {
|
||||
(Some(p), _) => {
|
||||
let shape = match p.attr("path") {
|
||||
Some("rect") => PathShape::Rect,
|
||||
Some("shape") => PathShape::Shape,
|
||||
_ => PathShape::Circle,
|
||||
};
|
||||
GradientKind::Path { shape, focus: rect(p.child("a:fillToRect")) }
|
||||
}
|
||||
(None, l) => GradientKind::Linear {
|
||||
angle: l.and_then(|l| l.attr("ang")).and_then(int).map_or(0.0, |a| (a % 21_600_000) as f32 / 60_000.0),
|
||||
scaled: on(l.and_then(|l| l.attr("scaled"))),
|
||||
},
|
||||
};
|
||||
let flip = match e.attr("flip") {
|
||||
Some("x") => GradientFlip::X,
|
||||
Some("y") => GradientFlip::Y,
|
||||
Some("xy") => GradientFlip::Xy,
|
||||
_ => GradientFlip::None,
|
||||
};
|
||||
let tile = e.child("a:tileRect").map(|r| rect(Some(r)));
|
||||
Fill::Gradient(Arc::new(Gradient { stops, kind, tile, rot_with_shape: on(e.attr("rotWithShape")), flip })).sanitized()
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A preset colour (`a:prstClr/@val`, ECMA-376 Part 1 §20.1.10.48): the common ones; others are
|
||||
@@ -159,58 +230,14 @@ pub(crate) fn lum(c: Rgb, f: impl Fn(f64) -> f64) -> Rgb {
|
||||
adjust_lum(c, f)
|
||||
}
|
||||
|
||||
/// `tint` (towards `to` = 255) and `shade` (towards 0): `v` is the share of the original colour kept.
|
||||
fn mix(c: Rgb, to: f64, v: f64) -> Rgb {
|
||||
let f = |x: u8| (x as f64 * v + to * (1.0 - v)).round().clamp(0.0, 255.0) as u8;
|
||||
Rgb(f(c.0), f(c.1), f(c.2))
|
||||
/// `c` with the DrawingML colour modifier `name` at `v` (see [`Rgb::modified`]).
|
||||
fn modified(c: Rgb, name: &str, v: f64) -> Rgb {
|
||||
c.modified(name, v)
|
||||
}
|
||||
|
||||
/// `c` with its HSL luminance changed by `f`.
|
||||
pub(crate) fn adjust_lum(c: Rgb, f: impl Fn(f64) -> f64) -> Rgb {
|
||||
let (h, s, l) = to_hsl(c);
|
||||
from_hsl(h, s, f(l).clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
fn to_hsl(c: Rgb) -> (f64, f64, f64) {
|
||||
let (r, g, b) = (c.0 as f64 / 255.0, c.1 as f64 / 255.0, c.2 as f64 / 255.0);
|
||||
let (max, min) = (r.max(g).max(b), r.min(g).min(b));
|
||||
let l = (max + min) / 2.0;
|
||||
if max == min {
|
||||
return (0.0, 0.0, l);
|
||||
}
|
||||
let d = max - min;
|
||||
let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) };
|
||||
let h = if max == r {
|
||||
(g - b) / d + if g < b { 6.0 } else { 0.0 }
|
||||
} else if max == g {
|
||||
(b - r) / d + 2.0
|
||||
} else {
|
||||
(r - g) / d + 4.0
|
||||
};
|
||||
(h / 6.0, s, l)
|
||||
}
|
||||
|
||||
fn from_hsl(h: f64, s: f64, l: f64) -> Rgb {
|
||||
let byte = |v: f64| (v * 255.0).round().clamp(0.0, 255.0) as u8;
|
||||
if s == 0.0 {
|
||||
return Rgb(byte(l), byte(l), byte(l));
|
||||
}
|
||||
let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s };
|
||||
let p = 2.0 * l - q;
|
||||
let channel = |t: f64| {
|
||||
let t = t.rem_euclid(1.0);
|
||||
let v = if t < 1.0 / 6.0 {
|
||||
p + (q - p) * 6.0 * t
|
||||
} else if t < 0.5 {
|
||||
q
|
||||
} else if t < 2.0 / 3.0 {
|
||||
p + (q - p) * (2.0 / 3.0 - t) * 6.0
|
||||
} else {
|
||||
p
|
||||
};
|
||||
byte(v)
|
||||
};
|
||||
Rgb(channel(h + 1.0 / 3.0), channel(h), channel(h - 1.0 / 3.0))
|
||||
c.with_lum(f)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+156
-40
@@ -16,6 +16,7 @@ mod wordart;
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::control::{ControlEvent, ControlKind, control_events, pair_events};
|
||||
use wordcraft_doc::graphic::{Graphic, GraphicKind};
|
||||
use wordcraft_doc::numbering::{AbstractNum, Level, LevelSuffix, Num};
|
||||
use wordcraft_doc::para::NoteKind;
|
||||
@@ -96,6 +97,10 @@ pub(crate) struct Reader<'p> {
|
||||
/// Linked text boxes as read: (`wps:txbx/@id` or `wps:linkedTxbx/@id`, `seq` (0 for the box
|
||||
/// holding the text), text box part). See [`link_text_boxes`].
|
||||
pub txbx_links: Vec<(u32, u32, u32)>,
|
||||
/// The theme's fill styles, which shapes' style references point to.
|
||||
pub theme_fills: shape_style::ThemeFills,
|
||||
/// The fill of the group being read (what its members' `a:grpFill` stands for).
|
||||
pub group_fill: Option<wordcraft_doc::fill::Fill>,
|
||||
}
|
||||
|
||||
/// The package path that internal relationship `id` points at, if it has type `kind` (an `rt`
|
||||
@@ -159,6 +164,8 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
|
||||
content_types: None,
|
||||
embedded: EmbeddedManifest::default(),
|
||||
txbx_links: Vec::new(),
|
||||
theme_fills: Default::default(),
|
||||
group_fill: None,
|
||||
};
|
||||
r.pc.major_font = r.doc.settings.major_font.clone();
|
||||
r.pc.minor_font = r.doc.settings.minor_font.clone();
|
||||
@@ -178,7 +185,8 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
|
||||
log::warn!("docx: ignoring unreadable {what}: {e}");
|
||||
}
|
||||
};
|
||||
// Styles come before settings, but their theme colours already follow its colour mapping.
|
||||
// Styles come before settings, but their theme colours already follow its colour mapping, and
|
||||
// their `…Bidi` theme fonts its theme font language.
|
||||
if let Some(p) = part(rt::SETTINGS) {
|
||||
r.read_color_mapping(&p);
|
||||
}
|
||||
@@ -252,11 +260,23 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
|
||||
r.flush_pending(&mut sc, &mut blocks);
|
||||
r.doc.body = blocks;
|
||||
unstyle_toc_links(&mut r.doc);
|
||||
toc_control_to_toc(&mut r.doc.body);
|
||||
toc_field_to_heading(&mut r.doc.body);
|
||||
if let Some(s) = body.child("w:sectPr") {
|
||||
r.doc.last_section = r.read_section(s, &rels);
|
||||
}
|
||||
}
|
||||
// A watermark in the box Word's Design › Watermark gives it for the page keeps no box of its
|
||||
// own: it follows the page, as when it was made.
|
||||
let (pw, ph) = (r.doc.last_section.page_w, r.doc.last_section.page_h);
|
||||
if let Some(wm) = r.doc.settings.watermark.as_mut()
|
||||
&& let Some([w, h]) = wm.size
|
||||
{
|
||||
let [dw, dh] = wordcraft_doc::Watermark { size: None, ..wm.clone() }.frame(pw, ph);
|
||||
if (w - dw).abs() < 0.06 && (h - dh).abs() < 0.06 {
|
||||
wm.size = None;
|
||||
}
|
||||
}
|
||||
|
||||
let core = root_rels.by_type(rt::CORE).map(|x| x.target.clone()).unwrap_or_else(|| "docProps/core.xml".into());
|
||||
r.read_core(&core);
|
||||
@@ -416,12 +436,21 @@ fn keep_glued_ids(doc: &mut Document, glued: &HashSet<u32>) {
|
||||
/// no underline) though the entries carry it (measured in Word for Windows: a Hyperlink-styled run
|
||||
/// in a hyperlink inside a TOC field's result is plain; one outside a hyperlink, another style,
|
||||
/// direct formatting, or a hyperlink in another field keep their look). The style is dropped from
|
||||
/// those runs so they look the same here; the links stay.
|
||||
/// those runs so they look the same here; the links stay. The style is found by its built-in
|
||||
/// name: localized Word and older files give it another id (`Hipervnculo`, `Hyperlnk`).
|
||||
fn unstyle_toc_links(doc: &mut Document) {
|
||||
let ids: HashSet<String> = doc
|
||||
.styles
|
||||
.styles
|
||||
.iter()
|
||||
.filter(|s| s.kind == StyleKind::Character && s.name.eq_ignore_ascii_case("Hyperlink"))
|
||||
.map(|s| s.id.clone())
|
||||
.chain(["Hyperlink".to_string()])
|
||||
.collect();
|
||||
for f in doc.field_ranges(wordcraft_doc::StoryRef::Body) {
|
||||
if is_toc(&f.instr) {
|
||||
let _ = doc.format_range(&f.start, &f.end, &|c| {
|
||||
if c.link.is_some() && c.style.as_deref() == Some("Hyperlink") {
|
||||
if c.link.is_some() && c.style.as_deref().is_some_and(|s| ids.contains(s)) {
|
||||
c.style = None;
|
||||
}
|
||||
});
|
||||
@@ -429,47 +458,113 @@ fn unstyle_toc_links(doc: &mut Document) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Word's Table of Contents content control belongs to the TOC: the writer puts it back around
|
||||
/// the TOC heading and its entries (`toc_span`), which References › Update Table rebuilds. It is
|
||||
/// handed to the TOC only when the writer would put it back around the same paragraphs; any other
|
||||
/// is kept as read, or saving would drop it (#2427).
|
||||
fn toc_control_to_toc(body: &mut Blocks) {
|
||||
let span = {
|
||||
let events = control_events(body);
|
||||
let (pairs, _) = pair_events(&events);
|
||||
pairs.iter().find_map(|&(o, c, depth)| {
|
||||
let (ControlEvent::Open { block: s, off: Some(0), control }, ControlEvent::Close { block: e, off: Some(off) }) =
|
||||
(events.get(o)?, events.get(c)?)
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
let toc = matches!(&control.kind, ControlKind::Gallery { list: false, gallery, .. } if gallery == "Table of Contents");
|
||||
let at_end = body.get(*e)?.as_para()?.len() == off.checked_add(OBJ.len_utf8())?;
|
||||
(depth == 0 && toc && at_end).then_some((*s, *e, *off))
|
||||
})
|
||||
};
|
||||
let Some((s, e, off)) = span else { return };
|
||||
let mut toc = body.clone();
|
||||
// The end first: both markers may be in the same paragraph.
|
||||
if let Some(Block::Para(p)) = toc.get_mut(e).map(Arc::make_mut) {
|
||||
let _ = p.delete(off, p.len());
|
||||
}
|
||||
if let Some(Block::Para(p)) = toc.get_mut(s).map(Arc::make_mut) {
|
||||
let _ = p.delete(0, OBJ.len_utf8());
|
||||
}
|
||||
toc_field_to_heading(&mut toc);
|
||||
if crate::write::written_toc_span(&toc) == Some((s, e)) {
|
||||
*body = toc;
|
||||
return;
|
||||
}
|
||||
// TOCs the writer rebuilds in the same paragraphs once Update Table has turned them into the
|
||||
// engine's TOC (`range_toc_to_field`) are handed over as the range read, as without the control:
|
||||
// - Word's layout, the field's end alone in a paragraph after the entries (it goes with them);
|
||||
// - ours with a heading that isn't a `TOCHeading`, written empty and the field opening in the
|
||||
// first entry. Kept in the control, that paragraph wouldn't be empty and each save and update
|
||||
// would add a blank line above the entries (#2182).
|
||||
let mut engine = toc.clone();
|
||||
let mut last = e;
|
||||
// The entry before Word's paragraph holding nothing but the field's end, and that end's format.
|
||||
let end_only = e.checked_sub(1).filter(|&l| l > s).and_then(|l| {
|
||||
let p = engine.get(e)?.as_para()?;
|
||||
let only = p.section.is_none() && p.len() == OBJ.len_utf8() && matches!(p.object_at(0), Some(InlineObject::FieldEnd));
|
||||
only.then(|| (l, p.props_of_char(0).clone()))
|
||||
});
|
||||
if let Some((l, props)) = end_only {
|
||||
engine.remove(e);
|
||||
last = l;
|
||||
if let Some(Block::Para(p)) = engine.get_mut(l).map(Arc::make_mut) {
|
||||
let len = p.len();
|
||||
let _ = p.insert_object(len, InlineObject::FieldEnd, &props);
|
||||
}
|
||||
}
|
||||
if engine.get(s).and_then(|b| b.as_para()).is_some_and(|p| p.is_empty() && p.section.is_none()) {
|
||||
field_to_heading(&mut engine, s + 1);
|
||||
} else {
|
||||
toc_field_to_heading(&mut engine);
|
||||
}
|
||||
if crate::write::written_toc_span(&engine) == Some((s, last)) {
|
||||
*body = toc;
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize only a complete TOC range that the engine's heading/entry representation can
|
||||
/// reproduce exactly. Other imported TOCs keep their explicit boundaries, independent of styles.
|
||||
fn toc_field_to_heading(body: &mut Blocks) {
|
||||
for i in 1..body.len() {
|
||||
let heading = body[i - 1].as_para().filter(|p| p.props.style.as_deref() == Some("TOCHeading"));
|
||||
let has_toc = |o: &InlineObject| matches!(o, InlineObject::Field { instr, .. } if is_toc(instr));
|
||||
if heading.is_none_or(|h| h.objects.iter().any(has_toc)) {
|
||||
continue;
|
||||
if body[i - 1].as_para().is_some_and(|p| p.props.style.as_deref() == Some("TOCHeading")) {
|
||||
field_to_heading(body, i);
|
||||
}
|
||||
let Some((field, props, end)) = body[i].as_para().and_then(|p| {
|
||||
let (field, end) = match p.object_at(0) {
|
||||
Some(InlineObject::FieldStart { instr, locked, dirty }) if is_toc(instr) => (
|
||||
InlineObject::Field {
|
||||
instr: instr.clone(),
|
||||
result: String::new(),
|
||||
locked: *locked,
|
||||
dirty: *dirty,
|
||||
code: false,
|
||||
form: String::new(),
|
||||
},
|
||||
Some(toc_range_end(body, i)?),
|
||||
),
|
||||
Some(f @ InlineObject::Field { result, .. }) if result.is_empty() && has_toc(f) => (f.clone(), None),
|
||||
_ => return None,
|
||||
};
|
||||
Some((field, p.props_of_char(0).clone(), end))
|
||||
}) else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves the TOC field that opens paragraph `i` (a complete range over the entries, or an empty
|
||||
/// field) to the end of paragraph `i - 1`, the engine's TOC heading, unless that one has a TOC
|
||||
/// field already.
|
||||
fn field_to_heading(body: &mut Blocks, i: usize) {
|
||||
let has_toc = |o: &InlineObject| matches!(o, InlineObject::Field { instr, .. } if is_toc(instr));
|
||||
if i == 0 || body.get(i - 1).and_then(|b| b.as_para()).is_none_or(|h| h.objects.iter().any(has_toc)) {
|
||||
return;
|
||||
}
|
||||
let Some((field, props, end)) = body.get(i).and_then(|b| b.as_para()).and_then(|p| {
|
||||
let (field, end) = match p.object_at(0) {
|
||||
Some(InlineObject::FieldStart { instr, locked, dirty }) if is_toc(instr) => (
|
||||
InlineObject::Field { instr: instr.clone(), result: String::new(), locked: *locked, dirty: *dirty, code: false, form: String::new() },
|
||||
Some(toc_range_end(body, i)?),
|
||||
),
|
||||
Some(f @ InlineObject::Field { result, .. }) if result.is_empty() && has_toc(f) => (f.clone(), None),
|
||||
_ => return None,
|
||||
};
|
||||
// Delete the end first: the two markers may occupy the same paragraph.
|
||||
if let Some(Block::Para(p)) = end.and_then(|end| body.get_mut(end)).map(Arc::make_mut) {
|
||||
let off = p.len().saturating_sub(OBJ.len_utf8());
|
||||
let _ = p.delete(off, p.len());
|
||||
}
|
||||
if let Some(Block::Para(p)) = body.get_mut(i).map(Arc::make_mut) {
|
||||
let _ = p.delete(0, OBJ.len_utf8());
|
||||
}
|
||||
if let Some(Block::Para(h)) = body.get_mut(i - 1).map(Arc::make_mut) {
|
||||
let end = h.len();
|
||||
let _ = h.insert_object(end, field, &props);
|
||||
}
|
||||
Some((field, p.props_of_char(0).clone(), end))
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
// Delete the end first: the two markers may occupy the same paragraph.
|
||||
if let Some(Block::Para(p)) = end.and_then(|end| body.get_mut(end)).map(Arc::make_mut) {
|
||||
let off = p.len().saturating_sub(OBJ.len_utf8());
|
||||
let _ = p.delete(off, p.len());
|
||||
}
|
||||
if let Some(Block::Para(p)) = body.get_mut(i).map(Arc::make_mut) {
|
||||
let _ = p.delete(0, OBJ.len_utf8());
|
||||
}
|
||||
if let Some(Block::Para(h)) = body.get_mut(i - 1).map(Arc::make_mut) {
|
||||
let end = h.len();
|
||||
let _ = h.insert_object(end, field, &props);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -717,6 +812,7 @@ impl Reader<'_> {
|
||||
self.pc.theme_colors = colors.clone();
|
||||
self.doc.settings.theme_colors = colors;
|
||||
}
|
||||
self.theme_fills = shape_style::ThemeFills::read(el.child("a:fmtScheme"));
|
||||
if let Some(fs) = el.child("a:fontScheme") {
|
||||
let face =
|
||||
|n: &str| fs.child(n).and_then(|f| f.child("a:latin")).and_then(|l| l.attr("typeface")).filter(|t| !t.is_empty()).map(str::to_string);
|
||||
@@ -729,15 +825,30 @@ impl Reader<'_> {
|
||||
let cs = |n: &str| fs.child(n).and_then(|f| f.child("a:cs")).and_then(|c| c.attr("typeface")).unwrap_or_default().to_string();
|
||||
self.pc.major_cs = cs("a:majorFont");
|
||||
self.pc.minor_cs = cs("a:minorFont");
|
||||
// Per-script fonts (`a:font script="Arab"`); Office's themes list about 30.
|
||||
let scripts = |n: &str| -> Vec<(String, String)> {
|
||||
let Some(f) = fs.child(n) else { return Vec::new() };
|
||||
f.els()
|
||||
.filter(|e| e.name == "a:font")
|
||||
.filter_map(|e| Some((e.attr("script")?, e.attr("typeface")?)))
|
||||
.filter(|(s, t)| s.len() <= 8 && !t.is_empty() && t.len() < 256)
|
||||
.take(256)
|
||||
.map(|(s, t)| (s.to_string(), t.to_string()))
|
||||
.collect()
|
||||
};
|
||||
self.pc.major_scripts = scripts("a:majorFont");
|
||||
self.pc.minor_scripts = scripts("a:minorFont");
|
||||
}
|
||||
self.pc.major_font = self.doc.settings.major_font.clone();
|
||||
self.pc.minor_font = self.doc.settings.minor_font.clone();
|
||||
self.pc.theme = self.doc.settings.theme_colors.clone();
|
||||
}
|
||||
|
||||
/// `w:settings/w:clrSchemeMapping`: which scheme colour text1, background1… stand for.
|
||||
/// `w:settings/w:clrSchemeMapping`: which scheme colour text1, background1… stand for. Also
|
||||
/// `w:themeFontLang/@w:bidi`, the language whose script picks the `…Bidi` theme fonts.
|
||||
fn read_color_mapping(&mut self, path: &str) {
|
||||
let Ok(Some(root)) = self.xml(path) else { return };
|
||||
self.pc.bidi_script = root.child("w:themeFontLang").and_then(|l| l.attr("w:bidi")).filter(|v| !v.trim().is_empty()).map(props::lang_script);
|
||||
let Some(m) = root.child("w:clrSchemeMapping") else { return };
|
||||
let names = [
|
||||
"dark1",
|
||||
@@ -798,6 +909,10 @@ impl Reader<'_> {
|
||||
}
|
||||
if let Some(p) = dd.child("w:pPrDefault").and_then(|d| d.child("w:pPr")) {
|
||||
sheet.default_para = self.pc.ppr(p).0;
|
||||
// Word makes no paragraph a text frame from the document defaults: the sizeless
|
||||
// top-left `w:framePr` Apple Pages writes there would put every paragraph, the
|
||||
// headers' and footers' included, at the page's corner (#2360).
|
||||
sheet.default_para.frame = None;
|
||||
}
|
||||
}
|
||||
if sheet.default_chr.size.is_none() {
|
||||
@@ -894,7 +1009,8 @@ impl Reader<'_> {
|
||||
}
|
||||
t.fill = tp.child("w:shd").and_then(|e| self.pc.shd_fill(e));
|
||||
t.cell_margins = tp.child("w:tblCellMar").map(props::margins);
|
||||
t.band_size = tp.child_val("w:tblStyleRowBandSize").and_then(u32_of).filter(|n| *n > 0).map(|n| n.min(1000));
|
||||
t.band_size = tp.child_val("w:tblStyleRowBandSize").and_then(u32_of).map(|n| n.min(1000));
|
||||
t.col_band_size = tp.child_val("w:tblStyleColBandSize").and_then(u32_of).map(|n| n.min(1000));
|
||||
}
|
||||
for c in s.children("w:tblStylePr").take(64) {
|
||||
let Some(part) = c.attr("w:type").and_then(TablePart::from_ooxml) else { continue };
|
||||
|
||||
@@ -44,9 +44,15 @@ use crate::xml::El;
|
||||
pub struct PropCtx {
|
||||
pub major_font: String,
|
||||
pub minor_font: String,
|
||||
/// The theme's complex-script fonts (`a:cs`), which the `…Bidi` theme slots name.
|
||||
/// The theme's complex-script fonts (`a:cs`), which the `…Bidi` theme slots fall back on.
|
||||
pub major_cs: String,
|
||||
pub minor_cs: String,
|
||||
/// The theme's per-script fonts (`a:font script="Arab" typeface="…"`).
|
||||
pub major_scripts: Vec<(String, String)>,
|
||||
pub minor_scripts: Vec<(String, String)>,
|
||||
/// The script (ISO 15924) of `w:themeFontLang/@w:bidi`, which picks the font a `…Bidi` theme
|
||||
/// slot names. `None` = the settings name no bidi language.
|
||||
pub bidi_script: Option<&'static str>,
|
||||
/// The theme's colour scheme (dk1, lt1, dk2, lt2, accent1..6, hlink, folHlink); empty when the
|
||||
/// package has none, and then `w:themeColor` falls back to the fixed colour.
|
||||
pub theme_colors: Vec<Rgb>,
|
||||
@@ -64,6 +70,64 @@ pub struct PropCtx {
|
||||
/// a document (`resolve_list_levels`).
|
||||
pub(crate) const LEVEL_ONLY: u32 = u32::MAX;
|
||||
|
||||
/// The font Word draws a `minorBidi`/`majorBidi` theme slot in when the settings name no bidi
|
||||
/// language, or the theme has no font for it.
|
||||
const BIDI_THEME_FALLBACK_FONT: &str = "Times New Roman";
|
||||
|
||||
/// The script a language tag is written in, as a theme's `a:font script` names it (ISO 15924, and
|
||||
/// Office's own `Uigh` for Uyghur and `Viet` for Vietnamese): an explicit script subtag
|
||||
/// (`ku-Arab`), else the language's usual script. Languages not listed are taken as Latin.
|
||||
pub(crate) fn lang_script(tag: &str) -> &'static str {
|
||||
const SCRIPTS: &[&str] = &[
|
||||
"Arab", "Armn", "Beng", "Cans", "Cher", "Cyrl", "Deva", "Ethi", "Geor", "Grek", "Gujr", "Guru", "Hang", "Hans", "Hant", "Hebr", "Jpan",
|
||||
"Khmr", "Knda", "Laoo", "Latn", "Mlym", "Mong", "Mymr", "Orya", "Sinh", "Syrc", "Taml", "Telu", "Tfng", "Thaa", "Thai", "Tibt", "Uigh",
|
||||
"Viet", "Yiii",
|
||||
];
|
||||
let mut parts = tag.split(['-', '_']).filter(|p| !p.is_empty());
|
||||
let lang = parts.next().unwrap_or_default().to_ascii_lowercase();
|
||||
let rest: Vec<&str> = parts.take(8).collect();
|
||||
if let Some(s) = rest.iter().find_map(|p| SCRIPTS.iter().find(|s| s.eq_ignore_ascii_case(p))) {
|
||||
return s;
|
||||
}
|
||||
match lang.as_str() {
|
||||
"ar" | "fa" | "ur" | "ps" | "sd" | "ckb" | "prs" => "Arab",
|
||||
"ug" => "Uigh",
|
||||
"vi" => "Viet",
|
||||
"he" | "yi" => "Hebr",
|
||||
"syr" => "Syrc",
|
||||
"dv" => "Thaa",
|
||||
"th" => "Thai",
|
||||
"hi" | "mr" | "ne" | "sa" | "kok" => "Deva",
|
||||
"bn" | "as" => "Beng",
|
||||
// Measured in Word: Punjabi takes the Arabic-script font in Pakistan, Gurmukhi elsewhere.
|
||||
"pa" if rest.iter().any(|p| p.eq_ignore_ascii_case("pk")) => "Arab",
|
||||
"pa" => "Guru",
|
||||
"gu" => "Gujr",
|
||||
"or" => "Orya",
|
||||
"ta" => "Taml",
|
||||
"te" => "Telu",
|
||||
"kn" => "Knda",
|
||||
"ml" => "Mlym",
|
||||
"si" => "Sinh",
|
||||
"lo" => "Laoo",
|
||||
"km" => "Khmr",
|
||||
"bo" => "Tibt",
|
||||
"my" => "Mymr",
|
||||
"am" | "ti" => "Ethi",
|
||||
"chr" => "Cher",
|
||||
"iu" => "Cans",
|
||||
"ii" => "Yiii",
|
||||
"ka" => "Geor",
|
||||
"hy" => "Armn",
|
||||
"el" => "Grek",
|
||||
"ja" => "Jpan",
|
||||
"ko" => "Hang",
|
||||
"zh" if rest.iter().any(|p| ["tw", "hk", "mo"].iter().any(|r| r.eq_ignore_ascii_case(p))) => "Hant",
|
||||
"zh" => "Hans",
|
||||
_ => "Latn",
|
||||
}
|
||||
}
|
||||
|
||||
/// The list a level-only `numPr` refers to: the nearest numbering in style `style`'s based-on chain
|
||||
/// (its own included) that names a list. `None` when no style in the chain has one.
|
||||
pub(crate) fn chain_list(sheet: &wordcraft_doc::styles::StyleSheet, style: &str) -> Option<u32> {
|
||||
@@ -86,11 +150,25 @@ impl PropCtx {
|
||||
}
|
||||
|
||||
fn theme_font(&self, t: &str) -> Option<String> {
|
||||
let (latin, cs) = if t.starts_with("major") { (&self.major_font, &self.major_cs) } else { (&self.minor_font, &self.minor_cs) };
|
||||
// Measured in Word: `minorBidi`/`majorBidi` name the complex-script font, and an empty one
|
||||
// names nothing, so the font beside it or the inherited one applies.
|
||||
let f = if t.ends_with("Bidi") { cs } else { latin };
|
||||
(!f.is_empty()).then(|| f.clone())
|
||||
let major = t.starts_with("major");
|
||||
let latin = if major { &self.major_font } else { &self.minor_font };
|
||||
if t.ends_with("Bidi") {
|
||||
return Some(self.bidi_theme_font(major));
|
||||
}
|
||||
(!latin.is_empty()).then(|| latin.clone())
|
||||
}
|
||||
|
||||
/// The font a `majorBidi`/`minorBidi` theme slot names. Measured in Word for Mac: the theme's
|
||||
/// font for the script of the settings' `w:themeFontLang/@w:bidi` (its `a:latin` for a Latin
|
||||
/// language), else the theme's `a:cs`, else Times New Roman; with no bidi language there,
|
||||
/// Times New Roman whatever the theme says. The run's own `w:lang` plays no part.
|
||||
fn bidi_theme_font(&self, major: bool) -> String {
|
||||
let (latin, cs, scripts) =
|
||||
if major { (&self.major_font, &self.major_cs, &self.major_scripts) } else { (&self.minor_font, &self.minor_cs, &self.minor_scripts) };
|
||||
let named = |f: &String| (!f.is_empty()).then(|| f.clone());
|
||||
let Some(script) = self.bidi_script else { return BIDI_THEME_FALLBACK_FONT.to_string() };
|
||||
let by_script = if script == "Latn" { named(latin) } else { scripts.iter().find(|(s, _)| s == script).and_then(|(_, f)| named(f)) };
|
||||
by_script.or_else(|| named(cs)).unwrap_or_else(|| BIDI_THEME_FALLBACK_FONT.to_string())
|
||||
}
|
||||
|
||||
/// The theme colour `e` names in `attr` (`w:themeColor`, `w:themeFill`), through the colour
|
||||
@@ -227,8 +305,9 @@ impl PropCtx {
|
||||
c.east_asian_layout = (l != EastAsianLayout::default()).then_some(l);
|
||||
}
|
||||
"w:lang" => {
|
||||
c.lang = k.attr("w:val").filter(|v| !v.is_empty() && v.len() < 64).map(str::to_string);
|
||||
c.lang_bidi = k.attr("w:bidi").filter(|v| !v.is_empty() && v.len() < 64).map(str::to_string);
|
||||
c.lang = k.attr("w:val").map(str::trim).filter(|v| !v.is_empty() && v.len() < 64).map(str::to_string);
|
||||
c.lang_bidi = k.attr("w:bidi").map(str::trim).filter(|v| !v.is_empty() && v.len() < 64).map(str::to_string);
|
||||
c.lang_ea = k.attr("w:eastAsia").map(str::trim).filter(|v| !v.is_empty() && v.len() < 64).map(str::to_string);
|
||||
}
|
||||
"w14:ligatures" => c.ligatures = k.attr("w14:val").and_then(wordcraft_doc::props::Ligatures::from_ooxml),
|
||||
n if n.starts_with("w14:") => {
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
//! How a Word shape (`wps:wsp`) looks: its preset geometry, and the fill and outline it gets from its own properties (`wps:spPr`) or, failing those, from the theme through its
|
||||
//! style references (`wps:style`: `a:fillRef`, `a:lnRef`, ECMA-376 Part 1 §20.1.4.2).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::fill::{Fill, Gradient, GradientFlip, GradientKind, PathShape};
|
||||
use wordcraft_doc::para::ShapeKind;
|
||||
use wordcraft_doc::props::Rgb;
|
||||
use wordcraft_doc::wordart::{GradStop, MAX_STOPS};
|
||||
|
||||
use super::drawing_color::{color, fill_colour};
|
||||
use super::drawing_color::{color, color_alpha, fill as read_fill, fill_colour};
|
||||
use crate::units::{int, measure};
|
||||
use crate::xml::El;
|
||||
|
||||
@@ -108,29 +112,76 @@ pub(crate) fn vml_preset_kind(spt: u32) -> Option<ShapeKind> {
|
||||
/// A shape's fill and outline.
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
pub(crate) struct ShapePaint {
|
||||
pub fill: Option<Rgb>,
|
||||
pub fill: Option<Fill>,
|
||||
pub stroke: Option<Rgb>,
|
||||
/// Points.
|
||||
pub stroke_width: f32,
|
||||
/// The outline's gradient (drawn in its main colour, `stroke`).
|
||||
pub outline_fill: Option<Fill>,
|
||||
}
|
||||
|
||||
/// The theme's fill styles (`a:fmtScheme`): `a:fillStyleLst` and `a:bgFillStyleLst`, which a
|
||||
/// shape's `a:fillRef` 1–999 and 1001+ point to.
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct ThemeFills {
|
||||
pub fill: Vec<El>,
|
||||
pub bg: Vec<El>,
|
||||
}
|
||||
|
||||
impl ThemeFills {
|
||||
pub fn read(fmt: Option<&El>) -> ThemeFills {
|
||||
let list = |n: &str| fmt.and_then(|f| f.child(n)).map(|l| l.els().take(MAX_THEME_FILLS).cloned().collect()).unwrap_or_default();
|
||||
ThemeFills { fill: list("a:fillStyleLst"), bg: list("a:bgFillStyleLst") }
|
||||
}
|
||||
/// Style `idx` (1-based; 1001+ the background styles; 1000 names none).
|
||||
fn get(&self, idx: i64) -> Option<&El> {
|
||||
let (list, i) = if idx > 1000 { (&self.bg, idx - 1001) } else { (&self.fill, idx - 1) };
|
||||
list.get(usize::try_from(i).ok()?)
|
||||
}
|
||||
}
|
||||
|
||||
/// Most fill styles kept per theme list: every one a `a:fillRef` can reach (1–999 into
|
||||
/// `a:fillStyleLst`, 1001–1999 into `a:bgFillStyleLst`, ECMA-376 Part 1 §20.1.4.2.10), and no
|
||||
/// more, so a hostile theme with millions of entries keeps a bounded list.
|
||||
const MAX_THEME_FILLS: usize = 999;
|
||||
|
||||
/// The fill a `…spPr` element states itself: `Some(None)` for none (`a:noFill`, picture fills),
|
||||
/// `group` for `a:grpFill`, `None` when it leaves the fill to the shape's style.
|
||||
pub(crate) fn own_fill(sppr: Option<&El>, theme: &[Rgb], group: Option<&Fill>) -> Option<Option<Fill>> {
|
||||
let f = sppr?.els().find(|c| matches!(c.name.as_str(), "a:noFill" | "a:blipFill" | "a:grpFill" | "a:solidFill" | "a:gradFill" | "a:pattFill"))?;
|
||||
Some(match f.name.as_str() {
|
||||
"a:grpFill" => group.cloned(),
|
||||
_ => read_fill(f, theme, None),
|
||||
})
|
||||
}
|
||||
|
||||
/// The paint of a shape from its properties `sppr`, falling back on its style `style` (theme
|
||||
/// colours `theme`) for what they leave out: an explicit `a:noFill` is no fill or outline, an
|
||||
/// explicit colour wins, and a style reference with index 0 is none.
|
||||
pub(crate) fn shape_paint(sppr: Option<&El>, style: Option<&El>, theme: &[Rgb]) -> ShapePaint {
|
||||
/// colours `theme`, fill styles `styles`) for what they leave out: an explicit `a:noFill` is no
|
||||
/// fill or outline, an explicit colour wins, `a:grpFill` is the enclosing group's fill `group`,
|
||||
/// and a style reference with index 0 is none.
|
||||
pub(crate) fn shape_paint(sppr: Option<&El>, style: Option<&El>, theme: &[Rgb], styles: &ThemeFills, group: Option<&Fill>) -> ShapePaint {
|
||||
let style_ref = |name: &str| -> Option<(i64, &El)> {
|
||||
let r = style?.child(name)?;
|
||||
Some((r.attr("idx").and_then(int).unwrap_or(0), r))
|
||||
};
|
||||
let fill = match sppr {
|
||||
Some(s) if s.child("a:noFill").is_some() || s.child("a:blipFill").is_some() => None,
|
||||
Some(s) if s.els().any(|c| matches!(c.name.as_str(), "a:solidFill" | "a:gradFill" | "a:pattFill")) => fill_colour(s, theme),
|
||||
_ => style_ref("a:fillRef").filter(|(idx, _)| *idx > 0).and_then(|(_, r)| color(r, theme)),
|
||||
};
|
||||
let fill = own_fill(sppr, theme, group).unwrap_or_else(|| {
|
||||
let (idx, r) = style_ref("a:fillRef").filter(|(idx, _)| *idx > 0)?;
|
||||
// The theme style in the reference's colour (`phClr`), else that colour: also for a style
|
||||
// the model can't hold (a picture fill), so the shape doesn't vanish.
|
||||
let ph = color_alpha(r, theme, None)?;
|
||||
let solid = Fill::Solid { color: ph.0, transparency: ph.1 };
|
||||
match styles.get(idx) {
|
||||
Some(st) if st.name == "a:noFill" => None,
|
||||
Some(st) => read_fill(st, theme, Some(ph)).or(Some(solid)),
|
||||
None => Some(solid),
|
||||
}
|
||||
});
|
||||
let ln = sppr.and_then(|s| s.child("a:ln"));
|
||||
let line_ref = style_ref("a:lnRef").filter(|(idx, _)| *idx > 0);
|
||||
let outline_fill = ln.and_then(|l| l.child("a:gradFill")).and_then(|g| read_fill(g, theme, None)).filter(|f| matches!(f, Fill::Gradient(_)));
|
||||
let stroke = match ln {
|
||||
Some(l) if l.child("a:noFill").is_some() => None,
|
||||
_ if outline_fill.is_some() => outline_fill.as_ref().map(Fill::main_color),
|
||||
Some(l) if l.els().any(|c| matches!(c.name.as_str(), "a:solidFill" | "a:gradFill" | "a:pattFill")) => fill_colour(l, theme),
|
||||
_ => line_ref.and_then(|(_, r)| color(r, theme)),
|
||||
};
|
||||
@@ -142,7 +193,86 @@ pub(crate) fn shape_paint(sppr: Option<&El>, style: Option<&El>, theme: &[Rgb])
|
||||
// Word's default outline when the file names none (no outline: no width).
|
||||
.unwrap_or(if stroke.is_some() { 0.75 } else { 0.0 })
|
||||
.clamp(0.0, 100.0);
|
||||
ShapePaint { fill, stroke, stroke_width }
|
||||
ShapePaint { fill, stroke, stroke_width, outline_fill }
|
||||
}
|
||||
|
||||
/// A VML shape's fill: `fillcolor` (white by default) and its `v:fill`: opacity, and a linear
|
||||
/// (`type="gradient"`) or circular (`gradientRadial`) gradient to `color2`, or through `colors`.
|
||||
/// A gradient's `focus` (−100%–100%) and `angle` are approximated: 0 and 100% run `color` to
|
||||
/// `color2`, −100% the other way, and a focus between mirrors the gradient there (`color2`
|
||||
/// there for a positive focus, `color` for a negative one). `None` when not filled.
|
||||
pub(crate) fn vml_fill(shape: &El, shapetype: Option<&El>) -> Option<Fill> {
|
||||
// What the shape leaves out comes from its shapetype (#1377).
|
||||
let own = |n: &str| shape.attr(n).or_else(|| shapetype.and_then(|t| t.attr(n)));
|
||||
let f = shape.child("v:fill").or_else(|| shapetype.and_then(|t| t.child("v:fill")));
|
||||
let attr = |n: &str| f.and_then(|f| f.attr(n));
|
||||
let off = |v: Option<&str>| {
|
||||
v.is_some_and(|v| {
|
||||
let t = v.trim();
|
||||
t.eq_ignore_ascii_case("f") || t.eq_ignore_ascii_case("false") || t == "0"
|
||||
})
|
||||
};
|
||||
if off(own("filled")) || off(attr("on")) {
|
||||
return None;
|
||||
}
|
||||
let c1 = attr("color").or(own("fillcolor")).map_or(Some(Rgb::WHITE), vml_color)?;
|
||||
let t1 = 100.0 - attr("opacity").and_then(vml_fraction).unwrap_or(1.0) * 100.0;
|
||||
let radial = match attr("type") {
|
||||
Some("gradient") => false,
|
||||
Some("gradientRadial") => true,
|
||||
_ => return Fill::Solid { color: c1, transparency: t1 }.sanitized(),
|
||||
};
|
||||
let c2 = attr("color2").and_then(vml_color).unwrap_or(Rgb::WHITE);
|
||||
let t2 = 100.0 - attr("o:opacity2").and_then(vml_fraction).unwrap_or(1.0) * 100.0;
|
||||
let stop = |pos: f32, (color, transparency): (Rgb, f32)| GradStop { pos, color, transparency };
|
||||
let (a, b) = ((c1, t1), (c2, t2));
|
||||
let focus = attr("focus").and_then(vml_fraction).unwrap_or(0.0).clamp(-1.0, 1.0) * 100.0;
|
||||
let colors: Vec<GradStop> = attr("colors")
|
||||
.into_iter()
|
||||
.flat_map(|v| v.split(';'))
|
||||
.take(MAX_STOPS)
|
||||
.filter_map(|s| {
|
||||
let (pos, c) = s.trim().split_once(' ')?;
|
||||
Some(stop(vml_fraction(pos)? * 100.0, (vml_color(c)?, t1)))
|
||||
})
|
||||
.collect();
|
||||
let stops = if colors.len() >= 2 {
|
||||
colors
|
||||
} else if focus <= -100.0 {
|
||||
vec![stop(0.0, b), stop(100.0, a)]
|
||||
} else if focus >= 100.0 || focus == 0.0 {
|
||||
vec![stop(0.0, a), stop(100.0, b)]
|
||||
} else if focus > 0.0 {
|
||||
vec![stop(0.0, a), stop(focus, b), stop(100.0, a)]
|
||||
} else {
|
||||
vec![stop(0.0, b), stop(-focus, a), stop(100.0, b)]
|
||||
};
|
||||
let kind = if radial {
|
||||
// `focusposition` "x,y" (fractions of the shape) is the centre.
|
||||
let fp = attr("focusposition").unwrap_or("0.5,0.5");
|
||||
let (x, y) = fp.split_once(',').unwrap_or((fp, fp));
|
||||
let (x, y) = (vml_fraction(x).unwrap_or(0.5) * 100.0, vml_fraction(y).unwrap_or(0.5) * 100.0);
|
||||
GradientKind::Path { shape: PathShape::Circle, focus: [x, y, 100.0 - x, 100.0 - y] }
|
||||
} else {
|
||||
// VML's angle is counter-clockwise from a top-to-bottom gradient.
|
||||
let ang = attr("angle").and_then(|v| v.trim().parse::<f32>().ok()).filter(|v| v.is_finite()).unwrap_or(0.0);
|
||||
GradientKind::Linear { angle: 90.0 - ang, scaled: false }
|
||||
};
|
||||
Fill::Gradient(Arc::new(Gradient { stops, kind, tile: None, rot_with_shape: true, flip: GradientFlip::None })).sanitized()
|
||||
}
|
||||
|
||||
/// A VML fraction (`opacity`, `focus`, a `colors` position): `0.5`, `50%`, or 65536ths
|
||||
/// (`32768f`). Not clamped: a focus runs from -1 to 1.
|
||||
pub(crate) fn vml_fraction(v: &str) -> Option<f32> {
|
||||
let v = v.trim();
|
||||
let n = if let Some(f) = v.strip_suffix('f') {
|
||||
f.trim().parse::<f32>().ok()? / 65_536.0
|
||||
} else if let Some(p) = v.strip_suffix('%') {
|
||||
p.trim().parse::<f32>().ok()? / 100.0
|
||||
} else {
|
||||
v.parse::<f32>().ok()?
|
||||
};
|
||||
n.is_finite().then_some(n)
|
||||
}
|
||||
|
||||
/// Explicit DrawingML dash stops override the solid default.
|
||||
@@ -222,7 +352,7 @@ mod tests {
|
||||
let st = el(
|
||||
r#"<wps:style xmlns:wps="w" xmlns:a="a"><a:lnRef idx="1"><a:schemeClr val="accent4"/></a:lnRef><a:fillRef idx="0"><a:schemeClr val="accent4"/></a:fillRef></wps:style>"#,
|
||||
);
|
||||
let p = shape_paint(Some(&sp), Some(&st), &THEME_COLORS);
|
||||
let p = shape_paint(Some(&sp), Some(&st), &THEME_COLORS, &ThemeFills::default(), None);
|
||||
assert_eq!(p.stroke, Some(THEME_COLORS[7]));
|
||||
assert_eq!(p.stroke_width, 0.5);
|
||||
assert_eq!(p.fill, None);
|
||||
@@ -239,32 +369,94 @@ mod tests {
|
||||
let st = el(
|
||||
r#"<wps:style xmlns:wps="w" xmlns:a="a"><a:lnRef idx="2"><a:schemeClr val="accent1"><a:shade val="15000"/></a:schemeClr></a:lnRef><a:fillRef idx="1"><a:schemeClr val="accent1"/></a:fillRef></wps:style>"#,
|
||||
);
|
||||
let p = shape_paint(Some(&sp), Some(&st), &THEME_COLORS);
|
||||
let p = shape_paint(Some(&sp), Some(&st), &THEME_COLORS, &ThemeFills::default(), None);
|
||||
assert_eq!((p.fill, p.stroke, p.stroke_width), (None, Some(Rgb(0xEE, 0, 0)), 1.0));
|
||||
// Preset and scheme colours as the box's own fill and outline.
|
||||
let sp = el(
|
||||
r#"<wps:spPr xmlns:wps="w" xmlns:a="a"><a:solidFill><a:schemeClr val="lt1"/></a:solidFill><a:ln w="6350"><a:solidFill><a:prstClr val="black"/></a:solidFill></a:ln></wps:spPr>"#,
|
||||
);
|
||||
let p = shape_paint(Some(&sp), None, &THEME_COLORS);
|
||||
assert_eq!((p.fill, p.stroke, p.stroke_width), (Some(THEME_COLORS[1]), Some(Rgb(0, 0, 0)), 0.5));
|
||||
let p = shape_paint(Some(&sp), None, &THEME_COLORS, &ThemeFills::default(), None);
|
||||
assert_eq!((p.fill, p.stroke, p.stroke_width), (Some(Fill::solid(THEME_COLORS[1])), Some(Rgb(0, 0, 0)), 0.5));
|
||||
// A style fill with index 0 is none; an outline set to no fill is none.
|
||||
let sp = el(r#"<wps:spPr xmlns:wps="w" xmlns:a="a"><a:ln><a:noFill/></a:ln></wps:spPr>"#);
|
||||
let st = el(
|
||||
r#"<wps:style xmlns:wps="w" xmlns:a="a"><a:lnRef idx="2"><a:schemeClr val="accent1"/></a:lnRef><a:fillRef idx="0"><a:schemeClr val="accent1"/></a:fillRef></wps:style>"#,
|
||||
);
|
||||
let p = shape_paint(Some(&sp), Some(&st), &THEME_COLORS);
|
||||
let p = shape_paint(Some(&sp), Some(&st), &THEME_COLORS, &ThemeFills::default(), None);
|
||||
assert_eq!((p.fill, p.stroke), (None, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_theme_picture_fill_falls_back_on_the_reference_colour() {
|
||||
let fmt = el(
|
||||
r#"<a:fmtScheme xmlns:wps="w" xmlns:a="a"><a:fillStyleLst><a:noFill/><a:blipFill><a:blip/></a:blipFill></a:fillStyleLst></a:fmtScheme>"#,
|
||||
);
|
||||
let styles = ThemeFills::read(Some(&fmt));
|
||||
let st = |idx: u8| {
|
||||
el(&format!(r#"<wps:style xmlns:wps="w" xmlns:a="a"><a:fillRef idx="{idx}"><a:srgbClr val="112233"/></a:fillRef></wps:style>"#))
|
||||
};
|
||||
assert_eq!(shape_paint(None, Some(&st(2)), &THEME_COLORS, &styles, None).fill, Some(Fill::solid(Rgb(0x11, 0x22, 0x33))));
|
||||
// An explicit theme `a:noFill` stays none.
|
||||
assert_eq!(shape_paint(None, Some(&st(1)), &THEME_COLORS, &styles, None).fill, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_reachable_theme_fill_style_is_kept() {
|
||||
// 20 fill styles and 20 background styles: the 17th of each a gradient / none, the rest
|
||||
// solid, so a reference past Word's usual three still finds its style.
|
||||
let entries = |odd: &str| {
|
||||
(1..=20)
|
||||
.map(|i| if i == 17 { odd.to_string() } else { r#"<a:solidFill><a:schemeClr val="phClr"/></a:solidFill>"#.to_string() })
|
||||
.collect::<String>()
|
||||
};
|
||||
let grad = r#"<a:gradFill><a:gsLst><a:gs pos="0"><a:schemeClr val="phClr"/></a:gs><a:gs pos="100000"><a:srgbClr val="FFFFFF"/></a:gs></a:gsLst><a:lin ang="0"/></a:gradFill>"#;
|
||||
let fmt = el(&format!(
|
||||
r#"<a:fmtScheme xmlns:wps="w" xmlns:a="a"><a:fillStyleLst>{}</a:fillStyleLst><a:bgFillStyleLst>{}</a:bgFillStyleLst></a:fmtScheme>"#,
|
||||
entries(grad),
|
||||
entries("<a:noFill/>")
|
||||
));
|
||||
let styles = ThemeFills::read(Some(&fmt));
|
||||
let st = |idx: u32| {
|
||||
el(&format!(r#"<wps:style xmlns:wps="w" xmlns:a="a"><a:fillRef idx="{idx}"><a:srgbClr val="112233"/></a:fillRef></wps:style>"#))
|
||||
};
|
||||
let fill = |idx| shape_paint(None, Some(&st(idx)), &THEME_COLORS, &styles, None).fill;
|
||||
assert!(matches!(fill(17), Some(Fill::Gradient(_))), "{:?}", fill(17));
|
||||
assert_eq!(fill(1017), None);
|
||||
assert_eq!(fill(16), Some(Fill::solid(Rgb(0x11, 0x22, 0x33))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_theme_with_absurdly_many_fill_styles_stays_bounded() {
|
||||
let many = "<a:noFill/>".repeat(100_000);
|
||||
let fmt = el(&format!(
|
||||
r#"<a:fmtScheme xmlns:wps="w" xmlns:a="a"><a:fillStyleLst>{many}</a:fillStyleLst><a:bgFillStyleLst>{many}</a:bgFillStyleLst></a:fmtScheme>"#
|
||||
));
|
||||
let styles = ThemeFills::read(Some(&fmt));
|
||||
assert_eq!((styles.fill.len(), styles.bg.len()), (MAX_THEME_FILLS, MAX_THEME_FILLS));
|
||||
let st = |idx: &str| {
|
||||
el(&format!(r#"<wps:style xmlns:wps="w" xmlns:a="a"><a:fillRef idx="{idx}"><a:srgbClr val="112233"/></a:fillRef></wps:style>"#))
|
||||
};
|
||||
// The last reachable style of each list is kept; past them (and 1000) the reference colour.
|
||||
for (idx, want) in [
|
||||
("999", None),
|
||||
("1999", None),
|
||||
("1000", Some(Fill::solid(Rgb(0x11, 0x22, 0x33)))),
|
||||
("2000", Some(Fill::solid(Rgb(0x11, 0x22, 0x33)))),
|
||||
("99999999999", Some(Fill::solid(Rgb(0x11, 0x22, 0x33)))),
|
||||
] {
|
||||
assert_eq!(shape_paint(None, Some(&st(idx)), &THEME_COLORS, &styles, None).fill, want, "idx {idx}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_style_references_do_not_panic() {
|
||||
let st = el(
|
||||
r#"<wps:style xmlns:wps="w" xmlns:a="a"><a:lnRef idx="-99999999999"><a:schemeClr val="nope"/></a:lnRef><a:fillRef idx="99999999999"/></wps:style>"#,
|
||||
);
|
||||
let p = shape_paint(None, Some(&st), &[]);
|
||||
let p = shape_paint(None, Some(&st), &[], &ThemeFills::default(), None);
|
||||
assert_eq!((p.fill, p.stroke), (None, None));
|
||||
let st = el(r#"<wps:style xmlns:wps="w" xmlns:a="a"><a:lnRef idx="77"><a:srgbClr val="010203"/></a:lnRef></wps:style>"#);
|
||||
assert_eq!(shape_paint(None, Some(&st), &[]).stroke_width, 1.5);
|
||||
assert_eq!(shape_paint(None, Some(&st), &[], &ThemeFills::default(), None).stroke_width, 1.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -297,6 +489,37 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vml_gradients_focus_and_colours() {
|
||||
let v = |attrs: &str, fill: &str| {
|
||||
let ns = r#"xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office""#;
|
||||
vml_fill(&crate::xml::parse(format!(r#"<v:rect {ns} {attrs}>{fill}</v:rect>"#).as_bytes()).unwrap(), None)
|
||||
};
|
||||
let stops = |f: Option<Fill>| match f {
|
||||
Some(Fill::Gradient(g)) => g.stops.iter().map(|s| (s.pos, s.color)).collect::<Vec<_>>(),
|
||||
o => panic!("{o:?}"),
|
||||
};
|
||||
let (red, blue) = (Rgb(255, 0, 0), Rgb(0, 0, 255));
|
||||
// A focus of 50% mirrors the gradient in the middle; 100% runs as 0%, −100% reverses it.
|
||||
assert_eq!(
|
||||
stops(v(r##"fillcolor="red""##, r##"<v:fill type="gradient" color2="blue" focus="50%"/>"##)),
|
||||
[(0.0, red), (50.0, blue), (100.0, red)]
|
||||
);
|
||||
assert_eq!(stops(v(r##"fillcolor="red""##, r##"<v:fill type="gradient" color2="blue" focus="100%"/>"##)), [(0.0, red), (100.0, blue)]);
|
||||
assert_eq!(stops(v(r##"fillcolor="red""##, r##"<v:fill type="gradient" color2="blue" focus="-100%"/>"##)), [(0.0, blue), (100.0, red)]);
|
||||
assert_eq!(v("", r##"<v:fill opacity="-2"/>"##), Some(Fill::Solid { color: Rgb::WHITE, transparency: 100.0 }));
|
||||
// `colors` lists the stops (fractions or 65536ths).
|
||||
assert_eq!(stops(v("", r##"<v:fill type="gradient" colors="0 red;32768f blue;1 red"/>"##)), [(0.0, red), (50.0, blue), (100.0, red)]);
|
||||
let Some(Fill::Gradient(g)) = v(r##"fillcolor="red""##, r##"<v:fill type="gradientRadial" color2="blue" focusposition=".25,.75"/>"##) else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(g.kind, GradientKind::Path { shape: PathShape::Circle, focus: [25.0, 75.0, 75.0, 25.0] });
|
||||
assert_eq!(v(r##"filled="f""##, ""), None);
|
||||
assert_eq!(v("", r##"<v:fill opacity="32768f"/>"##), Some(Fill::Solid { color: Rgb::WHITE, transparency: 50.0 }));
|
||||
// Hostile values don't panic.
|
||||
assert!(v("", r##"<v:fill type="gradient" colors="x;;1 #zz;NaN red" focus="NaN" angle="inf" opacity="1e99f"/>"##).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vml_colours() {
|
||||
assert_eq!(vml_color("#ffc000 [3207]"), Some(Rgb(0xFF, 0xC0, 0)));
|
||||
|
||||
+180
-56
@@ -3,7 +3,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::connector::{ConnEnd, ShapeExtra};
|
||||
use wordcraft_doc::control::{ContentControl, ControlKind, ControlWrap};
|
||||
use wordcraft_doc::control::{ContentControl, ControlWrap};
|
||||
use wordcraft_doc::effects::{Glow, Shadow, ShapeEffects};
|
||||
use wordcraft_doc::graphic::{Embedded, Graphic, GraphicItem, GraphicKind};
|
||||
use wordcraft_doc::para::{AltText, Anchor, Float, FloatAlign, NoteKind, PTabAlign, PTabBase, ShapeKind, Wrap, WrapText, cap_alt};
|
||||
@@ -13,7 +13,7 @@ use wordcraft_doc::{Block, Blocks, InlineObject, Paragraph, PartKind, RevisionKi
|
||||
|
||||
use super::Reader;
|
||||
use super::props::{sectpr, tcpr, trpr};
|
||||
use super::shape_style::{preset_kind, shape_paint, vml_color, vml_preset_kind};
|
||||
use super::shape_style::{own_fill, preset_kind, shape_paint, vml_color, vml_fill, vml_fraction, vml_preset_kind};
|
||||
use crate::package::{ContentTypes, Rels, rt};
|
||||
use crate::units::{int, measure, u32_of};
|
||||
use crate::xml::{El, Node};
|
||||
@@ -101,6 +101,69 @@ fn is_range_instr(instr: &str) -> bool {
|
||||
super::is_toc(instr) || instr.trim_start().get(..5).is_some_and(|k| k.eq_ignore_ascii_case("ADDIN"))
|
||||
}
|
||||
|
||||
/// Where a `HYPERLINK` field goes, as a `w:hyperlink` reads: `url`, `url#bookmark` (`\l`) or
|
||||
/// `#bookmark`. None for any other field, or one with no target.
|
||||
fn hyperlink_target(instr: &str) -> Option<String> {
|
||||
// Only a `HYPERLINK` code is split up (a field's code may run to megabytes).
|
||||
if !instr.split_whitespace().next().is_some_and(|k| k.eq_ignore_ascii_case("HYPERLINK")) {
|
||||
return None;
|
||||
}
|
||||
let args = field_args(instr);
|
||||
let mut args = args.iter();
|
||||
if !args.next().is_some_and(|(k, quoted)| !quoted && k.eq_ignore_ascii_case("HYPERLINK")) {
|
||||
return None;
|
||||
}
|
||||
let (mut url, mut anchor) = (None, None);
|
||||
while let Some((a, quoted)) = args.next() {
|
||||
if *quoted || !a.starts_with('\\') {
|
||||
url = url.or(Some(a.as_str()));
|
||||
continue;
|
||||
}
|
||||
// `\l` names a bookmark, `\o` a tooltip and `\t` a target frame; `\m` and `\n` take none.
|
||||
let value = matches!(a.to_ascii_lowercase().as_str(), "\\l" | "\\o" | "\\t").then(|| args.next()).flatten();
|
||||
if a.eq_ignore_ascii_case("\\l") {
|
||||
anchor = value.map(|(v, _)| v.as_str());
|
||||
}
|
||||
}
|
||||
let (url, anchor) = (url.map(str::trim).filter(|u| !u.is_empty()), anchor.map(str::trim).filter(|a| !a.is_empty()));
|
||||
match (url, anchor) {
|
||||
(Some(u), Some(a)) => Some(format!("{u}#{a}")),
|
||||
(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, `\"` is a quote and `\\` a backslash.
|
||||
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,
|
||||
'\\' if matches!(chars.peek(), Some('"' | '\\')) => 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
|
||||
}
|
||||
|
||||
/// The last paragraph of a block list: its last block, or inside a final table the last
|
||||
/// paragraph of its last cell (nested tables to [`MAX_TABLE_DEPTH`]).
|
||||
fn last_para_mut(out: &mut Blocks, depth: usize) -> Option<&mut Paragraph> {
|
||||
@@ -265,10 +328,9 @@ impl Reader<'_> {
|
||||
ctl.run_props = e.child("w:sdtPr").and_then(|pr| pr.child("w:rPr")).map(|r| self.pc.rpr(r));
|
||||
let mut inner = Blocks::new();
|
||||
self.read_blocks(sc, content, rels, &mut inner, depth + 1);
|
||||
// A table of contents' control belongs to the TOC: the writer puts it back around the
|
||||
// TOC field and its entries, which References › Update Table rebuilds.
|
||||
let toc = matches!(&ctl.kind, ControlKind::Gallery { list: false, gallery, .. } if gallery == "Table of Contents");
|
||||
if inner.is_empty() || toc {
|
||||
// A table of contents' control is handed to the TOC later, when the writer puts it back
|
||||
// (`toc_control_to_toc`).
|
||||
if inner.is_empty() {
|
||||
out.extend(inner);
|
||||
return;
|
||||
}
|
||||
@@ -702,8 +764,12 @@ impl Reader<'_> {
|
||||
}
|
||||
}
|
||||
"w:pict" => {
|
||||
for o in self.read_vml_all(sc, k, rels) {
|
||||
if let Some(o) = self.read_signature_line(sc, k, rels) {
|
||||
self.emit_obj(sc, pb, o, props);
|
||||
} else {
|
||||
for o in self.read_vml_all(sc, k, rels) {
|
||||
self.emit_obj(sc, pb, o, props);
|
||||
}
|
||||
}
|
||||
}
|
||||
"w:object" => {
|
||||
@@ -889,6 +955,19 @@ impl Reader<'_> {
|
||||
self.emit_obj(sc, pb, InlineObject::FieldEnd, &f.props);
|
||||
return;
|
||||
}
|
||||
// A `HYPERLINK` field is a link, as a `w:hyperlink` is: its result stays ordinary content
|
||||
// carrying the link, so a tab in it reaches its tab stop and the `PAGEREF` in a table of
|
||||
// contents entry (Word for the web writes them this way) stays a field of its own (#2174).
|
||||
if !nested_in_instr && let Some(target) = hyperlink_target(&f.instr) {
|
||||
for (it, mut p) in f.buf {
|
||||
p.link.get_or_insert_with(|| target.clone());
|
||||
match it {
|
||||
Item::Text(t) => self.emit_text(sc, pb, &t, &p),
|
||||
Item::Obj(o) => self.emit_obj(sc, pb, *o, &p),
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// A cached result holding a picture, chart or shape (`INCLUDEPICTURE`'s embedded
|
||||
// picture) can't be a text result: keep the field as a range around it so the picture
|
||||
// stays in the document and is saved again. The linked source is never read.
|
||||
@@ -1048,14 +1127,19 @@ impl Reader<'_> {
|
||||
float.effect = float.effect.map(|e| e.max(0.0));
|
||||
return Some(InlineObject::Graphic { w, h, alt, float, graphic });
|
||||
}
|
||||
// A textbox may contain a picture; its descendant blip is not the outer payload.
|
||||
let direct_shape = gd.child("wps:wsp").filter(|shape| {
|
||||
// Picture fills still use the image fallback; the shape model has no image fill.
|
||||
shape.child("wps:spPr").and_then(|props| props.child("a:blipFill")).is_none()
|
||||
});
|
||||
let mut obj = if let Some(c) = gd.child("wpc:wpc") {
|
||||
self.read_canvas(sc, c, rels, w, h, float)
|
||||
} else if let Some(g) = gd.child("wpg:wgp") {
|
||||
self.read_group(sc, g, rels, w, h, float)?
|
||||
} else if gd.find("a:blip").is_some() {
|
||||
} else if direct_shape.is_none() && gd.find("a:blip").is_some() {
|
||||
self.read_pic(gd, rels, w, h, alt.clone(), float)?
|
||||
} else {
|
||||
let wsp = gd.find("wps:wsp")?;
|
||||
let wsp = direct_shape.or_else(|| gd.find("wps:wsp"))?;
|
||||
let mut obj = self.read_wsp(sc, wsp, rels, w, h, float);
|
||||
// A freeform: whether it's ink WordCraft wrote.
|
||||
if let InlineObject::Shape { freeform: Some(f), .. } = &mut obj
|
||||
@@ -1246,7 +1330,10 @@ impl Reader<'_> {
|
||||
};
|
||||
let (cx, cy) = x.ch_off.unwrap_or((0.0, 0.0));
|
||||
let mut children = Vec::new();
|
||||
let outer = self.group_fill.take();
|
||||
self.group_fill = own_fill(g.child("wpg:grpSpPr"), &self.doc.settings.theme_colors, None).flatten();
|
||||
self.group_members(sc, g, rels, (-cx, -cy, 1.0, 1.0), 0, &mut children);
|
||||
self.group_fill = outer;
|
||||
if children.is_empty() {
|
||||
return None;
|
||||
}
|
||||
@@ -1266,7 +1353,7 @@ impl Reader<'_> {
|
||||
};
|
||||
let ln = c.child("wpc:whole").and_then(|e| e.child("a:ln"));
|
||||
let style = wordcraft_doc::para::CanvasStyle {
|
||||
fill: solid(c.child("wpc:bg")),
|
||||
fill: own_fill(c.child("wpc:bg"), &self.doc.settings.theme_colors, None).flatten(),
|
||||
stroke: solid(ln),
|
||||
stroke_width: ln.and_then(|l| l.attr("w")).and_then(|v| measure(v, 12_700.0)).unwrap_or(0.0).clamp(0.0, 100.0),
|
||||
};
|
||||
@@ -1332,7 +1419,12 @@ impl Reader<'_> {
|
||||
let (cw, ch) = x.ch_ext.filter(|(cw, ch)| *cw > 0.0 && *ch > 0.0).unwrap_or((ew.max(1.0), eh.max(1.0)));
|
||||
let (kx, ky) = (ew / cw, eh / ch);
|
||||
let nt = (x0 + (ox - cox * kx) * sx, y0 + (oy - coy * ky) * sy, sx * kx, sy * ky);
|
||||
// Its own fill, for its members' `a:grpFill` (#1500).
|
||||
// One without a fill of its own passes its group's on.
|
||||
let own = own_fill(sppr, &self.doc.settings.theme_colors, self.group_fill.as_ref()).unwrap_or_else(|| self.group_fill.clone());
|
||||
let outer = std::mem::replace(&mut self.group_fill, own);
|
||||
self.group_members(sc, e, rels, nt, depth + 1, out);
|
||||
self.group_fill = outer;
|
||||
continue;
|
||||
}
|
||||
let max = crate::units::MAX_LEN_PT;
|
||||
@@ -1380,7 +1472,7 @@ impl Reader<'_> {
|
||||
kind = ShapeKind::TextBox;
|
||||
}
|
||||
// Explicit paint, else the theme's through the style references (#611).
|
||||
let paint = shape_paint(sppr, wsp.child("wps:style"), &self.doc.settings.theme_colors);
|
||||
let paint = shape_paint(sppr, wsp.child("wps:style"), &self.doc.settings.theme_colors, &self.theme_fills, self.group_fill.as_ref());
|
||||
let (stroke, stroke_width) = (paint.stroke, paint.stroke_width);
|
||||
let fill = if kind.is_open() { None } else { paint.fill };
|
||||
let ln = sppr.and_then(|s| s.child("a:ln"));
|
||||
@@ -1441,7 +1533,7 @@ impl Reader<'_> {
|
||||
{
|
||||
part.body.auto_color = Some(rgb);
|
||||
}
|
||||
let mut extra = ShapeExtra { dash: super::shape_style::line_dash(ln), ..Default::default() };
|
||||
let mut extra = ShapeExtra { dash: super::shape_style::line_dash(ln), outline_fill: paint.outline_fill, ..ShapeExtra::default() };
|
||||
// A rounded rectangle's own corner (`adj`, ECMA-376 §20.1.10.56); other presets drawn as
|
||||
// rounded rectangles keep the default.
|
||||
if prst == "roundRect" {
|
||||
@@ -1489,7 +1581,10 @@ impl Reader<'_> {
|
||||
fn read_textbox(&mut self, sc: &StoryCtx, content: &El, rels: &Rels) -> u32 {
|
||||
let mut inner = StoryCtx { story_depth: sc.story_depth + 1, ..Default::default() };
|
||||
let mut blocks = Blocks::new();
|
||||
// Shapes in the text aren't members of a group the box may be in.
|
||||
let group = self.group_fill.take();
|
||||
self.read_blocks(&mut inner, content, rels, &mut blocks, 0);
|
||||
self.group_fill = group;
|
||||
self.flush_pending(&mut inner, &mut blocks);
|
||||
self.doc.add_part(PartKind::TextBox, blocks)
|
||||
}
|
||||
@@ -1624,10 +1719,15 @@ impl Reader<'_> {
|
||||
let type_id = shape.attr("type").map(|t| t.trim().trim_start_matches('#'));
|
||||
let shapetype = type_id.and_then(|id| pict.els().find(|e| e.name == "v:shapetype" && e.attr("id") == Some(id)));
|
||||
let attr = |name: &str| shape.attr(name).or_else(|| shapetype.and_then(|t| t.attr(name)));
|
||||
let on = |name: &str| attr(name).is_none_or(|v| !matches!(v, "f" | "false" | "0"));
|
||||
let on = |name: &str| {
|
||||
attr(name).is_none_or(|v| {
|
||||
let t = v.trim();
|
||||
!t.eq_ignore_ascii_case("f") && !t.eq_ignore_ascii_case("false") && t != "0"
|
||||
})
|
||||
};
|
||||
let stroke = on("stroked").then(|| attr("strokecolor").map_or(Some(Rgb::BLACK), vml_color)).flatten();
|
||||
let stroke_width = attr("strokeweight").and_then(|v| measure(v, 1.0)).unwrap_or(0.75).clamp(0.0, 100.0);
|
||||
let fill = on("filled").then(|| attr("fillcolor").map_or(Some(Rgb::WHITE), vml_color)).flatten();
|
||||
let fill = vml_fill(shape, shapetype);
|
||||
let kind = match shape.local() {
|
||||
"line" => Some(ShapeKind::Line),
|
||||
"rect" => Some(ShapeKind::Rectangle),
|
||||
@@ -1702,14 +1802,17 @@ impl Reader<'_> {
|
||||
}
|
||||
part.body.inset = (inset != wordcraft_doc::TEXT_BOX_INSET).then_some(inset);
|
||||
// Resize shape to fit text (DrawingML's `a:spAutoFit`).
|
||||
let fit = vml_style(tb.attr("style").unwrap_or(""), "mso-fit-shape-to-text");
|
||||
let fit = vml_style(tb.attr("style").unwrap_or(""), "mso-fit-shape-to-text").or_else(|| vml_style(style, "mso-fit-shape-to-text"));
|
||||
part.body.auto_fit = fit.is_some_and(|v| v.eq_ignore_ascii_case("t") || v.eq_ignore_ascii_case("true"));
|
||||
// Where the text sits between the box's top and bottom (DrawingML's `anchor`).
|
||||
if let Some(a) = vml_style(style, "v-text-anchor") {
|
||||
part.body.anchor = match a {
|
||||
a if a.starts_with("middle") => wordcraft_doc::props::VAlign::Center,
|
||||
a if a.starts_with("bottom") => wordcraft_doc::props::VAlign::Bottom,
|
||||
_ => wordcraft_doc::props::VAlign::Top,
|
||||
if let Some(a) = vml_style(tb.attr("style").unwrap_or(""), "v-text-anchor").or_else(|| vml_style(style, "v-text-anchor")) {
|
||||
let al = a.trim().to_ascii_lowercase();
|
||||
part.body.anchor = if al.starts_with("middle") || al == "center" {
|
||||
wordcraft_doc::props::VAlign::Center
|
||||
} else if al.starts_with("bottom") {
|
||||
wordcraft_doc::props::VAlign::Bottom
|
||||
} else {
|
||||
wordcraft_doc::props::VAlign::Top
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1737,7 +1840,7 @@ impl Reader<'_> {
|
||||
&& let Some(path) = shape.attr("path")
|
||||
&& let Some(vp) = super::freeform::vml_path(path, shape.attr("coordsize"), shape.attr("coordorigin"), w, h)
|
||||
{
|
||||
let opacity = |el: &str| shape.child(el).and_then(|e| e.attr("opacity")).and_then(vml_fraction).unwrap_or(1.0);
|
||||
let opacity = |el: &str| shape.child(el).and_then(|e| e.attr("opacity")).and_then(vml_opacity).unwrap_or(1.0);
|
||||
let fill = fill.filter(|_| !vp.no_fill && opacity("v:fill") > 0.0);
|
||||
let stroke = stroke.filter(|_| !vp.no_stroke);
|
||||
if stroke.is_none() && fill.is_none() {
|
||||
@@ -1819,6 +1922,23 @@ impl Reader<'_> {
|
||||
Some(o)
|
||||
}
|
||||
|
||||
/// A Word signature line (Insert › Signature Line): a `w:pict` whose one VML picture carries
|
||||
/// an `o:signatureline` (the suggested signer, title, e-mail and instructions). Shown as its
|
||||
/// picture, and kept whole like an OLE object so saving writes the signature line back,
|
||||
/// still signable in Word, not a plain picture. `None` for any other `w:pict`.
|
||||
fn read_signature_line(&mut self, sc: &mut StoryCtx, pict: &El, rels: &Rels) -> Option<InlineObject> {
|
||||
let mut drawings = pict.els().filter(|e| e.name.starts_with("v:") && e.local() != "shapetype");
|
||||
let shape = drawings.next().filter(|s| s.name == "v:shape")?;
|
||||
if drawings.next().is_some() || shape.child("o:signatureline").is_none() {
|
||||
return None;
|
||||
}
|
||||
let mut o = self.read_vml_shape(sc, pict, shape, rels, None)?;
|
||||
if let InlineObject::Image { ole, .. } = &mut o {
|
||||
*ole = self.keep_embedded(pict, rels, &[]);
|
||||
}
|
||||
Some(o)
|
||||
}
|
||||
|
||||
// ---- tables ----
|
||||
|
||||
fn read_table(&mut self, sc: &mut StoryCtx, t: &El, rels: &Rels, depth: usize) -> Option<Table> {
|
||||
@@ -1940,7 +2060,10 @@ fn nv_alt(p: Option<&El>) -> (String, AltText) {
|
||||
}
|
||||
|
||||
fn anchor_float(c: &El) -> Float {
|
||||
let mut f = Float { wrap: Wrap::Square, ..Float::default() };
|
||||
// `relativeHeight`: the stacking order Word draws floating objects in (higher on top), which
|
||||
// need not be their order in the file.
|
||||
let z = c.attr("relativeHeight").and_then(|v| v.trim().parse::<u32>().ok()).unwrap_or(0);
|
||||
let mut f = Float { wrap: Wrap::Square, z, ..Float::default() };
|
||||
let behind = on_off_attr(c, "behindDoc");
|
||||
// Word wraps a position relative to the page's size (`wp14:pctPosVOffset`) in
|
||||
// `mc:AlternateContent`; its Fallback gives the same position as an offset (#743).
|
||||
@@ -2126,37 +2249,43 @@ fn vml_float(shape: &El, style: &str, in_hf: bool) -> Float {
|
||||
let off = |k: &str| get(k).and_then(|v| measure(v, 1.0)).unwrap_or(0.0);
|
||||
let max = crate::units::MAX_LEN_PT;
|
||||
let behind = get("z-index").and_then(int).is_some_and(|z| z < 0);
|
||||
let wrap = match shape.child("w10:wrap").and_then(|w| w.attr("type")).unwrap_or("none") {
|
||||
"square" => Wrap::Square,
|
||||
"tight" => Wrap::Tight,
|
||||
"through" => Wrap::Through,
|
||||
"topAndBottom" => Wrap::TopAndBottom,
|
||||
let wrap_el = shape.child("w10:wrap");
|
||||
let wrap_type = wrap_el.and_then(|w| w.attr("type")).unwrap_or("none");
|
||||
let wrap = match wrap_type {
|
||||
t if t.eq_ignore_ascii_case("square") => Wrap::Square,
|
||||
t if t.eq_ignore_ascii_case("tight") => Wrap::Tight,
|
||||
t if t.eq_ignore_ascii_case("through") => Wrap::Through,
|
||||
t if t.eq_ignore_ascii_case("topAndBottom") || t.eq_ignore_ascii_case("top-and-bottom") => Wrap::TopAndBottom,
|
||||
_ if behind => Wrap::BehindText,
|
||||
_ => Wrap::InFrontOfText,
|
||||
};
|
||||
let h_rel = match get("mso-position-horizontal-relative").unwrap_or("text") {
|
||||
"page" => Anchor::Page,
|
||||
"margin" => Anchor::Margin,
|
||||
"char" => Anchor::Character,
|
||||
"left-margin-area" => Anchor::LeftMargin,
|
||||
"right-margin-area" => Anchor::RightMargin,
|
||||
"inner-margin-area" => Anchor::InsideMargin,
|
||||
"outer-margin-area" => Anchor::OutsideMargin,
|
||||
let wrap_text = wrap_el.and_then(|w| w.attr("side")).and_then(wordcraft_doc::para::WrapText::from_ooxml).unwrap_or_default();
|
||||
let h_rel_str = get("mso-position-horizontal-relative").unwrap_or("text");
|
||||
let h_rel = match h_rel_str {
|
||||
s if s.eq_ignore_ascii_case("page") => Anchor::Page,
|
||||
s if s.eq_ignore_ascii_case("margin") => Anchor::Margin,
|
||||
s if s.eq_ignore_ascii_case("char") => Anchor::Character,
|
||||
s if s.eq_ignore_ascii_case("left-margin-area") => Anchor::LeftMargin,
|
||||
s if s.eq_ignore_ascii_case("right-margin-area") => Anchor::RightMargin,
|
||||
s if s.eq_ignore_ascii_case("inner-margin-area") => Anchor::InsideMargin,
|
||||
s if s.eq_ignore_ascii_case("outer-margin-area") => Anchor::OutsideMargin,
|
||||
_ => Anchor::Column,
|
||||
};
|
||||
let v_rel = match get("mso-position-vertical-relative").unwrap_or("text") {
|
||||
"page" => Anchor::Page,
|
||||
"margin" => Anchor::Margin,
|
||||
"line" => Anchor::Line,
|
||||
"top-margin-area" => Anchor::TopMargin,
|
||||
"bottom-margin-area" => Anchor::BottomMargin,
|
||||
"inner-margin-area" => Anchor::InsideMargin,
|
||||
"outer-margin-area" => Anchor::OutsideMargin,
|
||||
let v_rel_str = get("mso-position-vertical-relative").unwrap_or("text");
|
||||
let v_rel = match v_rel_str {
|
||||
s if s.eq_ignore_ascii_case("page") => Anchor::Page,
|
||||
s if s.eq_ignore_ascii_case("margin") => Anchor::Margin,
|
||||
s if s.eq_ignore_ascii_case("line") => Anchor::Line,
|
||||
s if s.eq_ignore_ascii_case("top-margin-area") => Anchor::TopMargin,
|
||||
s if s.eq_ignore_ascii_case("bottom-margin-area") => Anchor::BottomMargin,
|
||||
s if s.eq_ignore_ascii_case("inner-margin-area") => Anchor::InsideMargin,
|
||||
s if s.eq_ignore_ascii_case("outer-margin-area") => Anchor::OutsideMargin,
|
||||
_ => Anchor::Paragraph,
|
||||
};
|
||||
let dist = |k: &str| get(k).and_then(|v| measure(v, 1.0)).unwrap_or(0.0).clamp(0.0, 1584.0);
|
||||
let mut f = Float {
|
||||
wrap,
|
||||
wrap_text,
|
||||
h_rel,
|
||||
v_rel,
|
||||
x: (off("left") + off("margin-left")).clamp(-max, max),
|
||||
@@ -2207,21 +2336,16 @@ fn vml_watermark(shape: &El) -> Option<wordcraft_doc::Watermark> {
|
||||
.filter(|f| !f.is_empty())
|
||||
.unwrap_or(default.font);
|
||||
let color = shape.attr("fillcolor").and_then(vml_color).unwrap_or(default.color);
|
||||
let opacity = shape.child("v:fill").and_then(|f| f.attr("opacity")).and_then(vml_fraction).unwrap_or(1.0);
|
||||
Some(wordcraft_doc::Watermark { text, font, color, diagonal: vml_spin(style).deg != 0.0, semitransparent: opacity < 1.0 })
|
||||
let opacity = shape.child("v:fill").and_then(|f| f.attr("opacity")).and_then(vml_opacity).unwrap_or(1.0);
|
||||
// The box the text is stretched to (`fitshape`).
|
||||
let len = |k: &str| vml_style(style, k).and_then(|v| measure(v, 1.0)).filter(|v| v.is_finite() && *v > 0.0).map(|v| v.min(4000.0));
|
||||
let size = len("width").zip(len("height")).map(|(w, h)| [w, h]);
|
||||
Some(wordcraft_doc::Watermark { text, font, color, diagonal: vml_spin(style).deg != 0.0, semitransparent: opacity < 1.0, size })
|
||||
}
|
||||
|
||||
/// A VML fraction (`opacity`): a number, a percentage, or 65536ths with an `f` suffix; 0..=1.
|
||||
fn vml_fraction(v: &str) -> Option<f32> {
|
||||
let v = v.trim();
|
||||
let f = if let Some(n) = v.strip_suffix('f') {
|
||||
n.trim().parse::<f32>().ok()? / 65_536.0
|
||||
} else if let Some(n) = v.strip_suffix('%') {
|
||||
n.trim().parse::<f32>().ok()? / 100.0
|
||||
} else {
|
||||
v.parse::<f32>().ok()?
|
||||
};
|
||||
f.is_finite().then(|| f.clamp(0.0, 1.0))
|
||||
/// A VML `opacity`, 0..=1.
|
||||
fn vml_opacity(v: &str) -> Option<f32> {
|
||||
vml_fraction(v).map(|f| f.clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
/// A VML shape's turn: `rotation` in degrees, or 65536ths of one with an `fd` suffix; `flip`: `x`
|
||||
|
||||
@@ -13,13 +13,39 @@ pub fn measure(s: &str, per_pt: f32) -> Option<f32> {
|
||||
if t.is_empty() || t.len() > 40 {
|
||||
return None;
|
||||
}
|
||||
let split = t.find(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-' || c == '+' || c == 'e' || c == 'E')).unwrap_or(t.len());
|
||||
let bytes = t.as_bytes();
|
||||
let mut split = t.len();
|
||||
let mut in_exp = false;
|
||||
for (i, &b) in bytes.iter().enumerate() {
|
||||
if b.is_ascii_digit() || b == b'.' {
|
||||
in_exp = false;
|
||||
continue;
|
||||
}
|
||||
if (b == b'-' || b == b'+') && (i == 0 || in_exp) {
|
||||
in_exp = false;
|
||||
continue;
|
||||
}
|
||||
if b == b'e' || b == b'E' {
|
||||
let rest = &bytes[i + 1..];
|
||||
let after_sign = match rest.first() {
|
||||
Some(&s) if s == b'+' || s == b'-' => &rest[1..],
|
||||
_ => rest,
|
||||
};
|
||||
if after_sign.first().is_some_and(|c| c.is_ascii_digit()) {
|
||||
in_exp = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
split = i;
|
||||
break;
|
||||
}
|
||||
let (num, unit) = t.split_at(split);
|
||||
let v: f64 = num.parse().ok()?;
|
||||
if !v.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let pt = match unit.trim() {
|
||||
let unit_lower = unit.trim().to_ascii_lowercase();
|
||||
let pt = match unit_lower.as_str() {
|
||||
"" => v / per_pt as f64,
|
||||
"pt" => v,
|
||||
"in" => v * 72.0,
|
||||
@@ -119,6 +145,15 @@ mod tests {
|
||||
assert!((inches - 7.2).abs() < 1e-5, "{inches}");
|
||||
assert_eq!(line_multiple("junk"), None);
|
||||
assert_eq!(line_multiple(""), None);
|
||||
assert_eq!(measure("12700emu", 20.0), Some(1.0));
|
||||
assert_eq!(measure("25400EMU", 20.0), Some(2.0));
|
||||
assert_eq!(measure("1IN", 20.0), Some(72.0));
|
||||
assert_eq!(measure("12PT", 2.0), Some(12.0));
|
||||
assert_eq!(measure("2.54CM", 20.0), Some(72.0));
|
||||
assert_eq!(measure("25.4MM", 20.0), Some(72.0));
|
||||
assert_eq!(measure("10PX", 20.0), Some(7.5));
|
||||
assert_eq!(measure("1e2pt", 20.0), Some(100.0));
|
||||
assert_eq!(measure("1e+2pt", 20.0), Some(100.0));
|
||||
assert_eq!(int("12.6"), Some(13));
|
||||
assert_eq!(u32_of("-5"), Some(0));
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ mod math;
|
||||
mod props;
|
||||
mod sdt;
|
||||
mod story;
|
||||
pub(crate) use story::written_toc_span;
|
||||
mod watermark;
|
||||
mod wordart;
|
||||
|
||||
@@ -795,10 +796,13 @@ fn style_xml(w: &mut W, st: &Style, flagged: &[StyleKind]) {
|
||||
w.close("w:rPr");
|
||||
}
|
||||
if let Some(t) = &st.table {
|
||||
if t.borders.is_some() || t.cell_margins.is_some() || t.band_size.is_some() {
|
||||
if t.borders.is_some() || t.cell_margins.is_some() || t.band_size.is_some() || t.col_band_size.is_some() {
|
||||
w.open("w:tblPr", &[]);
|
||||
if let Some(n) = t.band_size {
|
||||
w.val("w:tblStyleRowBandSize", &n.clamp(1, 1000).to_string());
|
||||
w.val("w:tblStyleRowBandSize", &n.min(1000).to_string());
|
||||
}
|
||||
if let Some(n) = t.col_band_size {
|
||||
w.val("w:tblStyleColBandSize", &n.min(1000).to_string());
|
||||
}
|
||||
if let Some(b) = &t.borders {
|
||||
props::borders(w, "w:tblBorders", b, Some("w:insideH"), &[]);
|
||||
|
||||
@@ -106,11 +106,18 @@ pub fn rpr_inner(w: &mut W, c: &CharProps) {
|
||||
}
|
||||
toggle(w, "w:rtl", c.rtl);
|
||||
toggle(w, "w:cs", c.cs);
|
||||
match (&c.lang, &c.lang_bidi) {
|
||||
(Some(l), Some(b)) => w.empty("w:lang", &[("w:val", l), ("w:bidi", b)]),
|
||||
(Some(l), None) => w.val("w:lang", l),
|
||||
(None, Some(b)) => w.empty("w:lang", &[("w:bidi", b)]),
|
||||
(None, None) => {}
|
||||
let mut lang_attrs: Vec<(&str, &str)> = Vec::with_capacity(3);
|
||||
if let Some(l) = &c.lang {
|
||||
lang_attrs.push(("w:val", l));
|
||||
}
|
||||
if let Some(ea) = &c.lang_ea {
|
||||
lang_attrs.push(("w:eastAsia", ea));
|
||||
}
|
||||
if let Some(b) = &c.lang_bidi {
|
||||
lang_attrs.push(("w:bidi", b));
|
||||
}
|
||||
if !lang_attrs.is_empty() {
|
||||
w.empty("w:lang", &lang_attrs);
|
||||
}
|
||||
// After w:lang (CT_RPr's sequence).
|
||||
if let Some(l) = &c.east_asian_layout {
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
//! elements around runs (inline) or around whole paragraphs and tables (block level). [`plan`]
|
||||
//! decides, per block list, which controls are written where so that the XML always nests.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use wordcraft_doc::Blocks;
|
||||
use wordcraft_doc::control::{ContentControl, ControlEvent, ControlKind, control_events, pair_events};
|
||||
use wordcraft_doc::para::OBJ;
|
||||
@@ -20,8 +18,11 @@ pub struct Plan<'a> {
|
||||
pub opens: Vec<Vec<&'a ContentControl>>,
|
||||
/// Controls closing after block `i`.
|
||||
pub closes: Vec<u32>,
|
||||
/// Paragraph markers written inline, as (block, byte offset).
|
||||
pub inline: HashSet<(usize, usize)>,
|
||||
/// The paragraph markers written inline, by block: the byte offsets in block `i`, in order.
|
||||
///
|
||||
/// Grouped by block rather than one set of (block, offset) pairs, so that asking for a
|
||||
/// paragraph's markers is a lookup and not a search of every marker in the document (#13).
|
||||
pub inline: Vec<Vec<usize>>,
|
||||
}
|
||||
|
||||
impl Plan<'_> {
|
||||
@@ -38,9 +39,7 @@ impl Plan<'_> {
|
||||
}
|
||||
/// The byte offsets of paragraph `i`'s markers that are written inline.
|
||||
pub fn inline_in(&self, i: usize) -> Vec<usize> {
|
||||
let mut v: Vec<usize> = self.inline.iter().filter(|(b, _)| *b == i).map(|(_, o)| *o).collect();
|
||||
v.sort_unstable();
|
||||
v
|
||||
self.inline.get(i).cloned().unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +53,7 @@ enum Mode {
|
||||
/// Markers without a partner are not written.
|
||||
pub fn plan(bl: &Blocks) -> Plan<'_> {
|
||||
let n = bl.len();
|
||||
let mut out = Plan { opens: vec![Vec::new(); n], closes: vec![0; n], inline: HashSet::new() };
|
||||
let mut out = Plan { opens: vec![Vec::new(); n], closes: vec![0; n], inline: vec![Vec::new(); n] };
|
||||
let events = control_events(bl);
|
||||
if events.is_empty() {
|
||||
return out;
|
||||
@@ -115,17 +114,37 @@ pub fn plan(bl: &Blocks) -> Plan<'_> {
|
||||
}
|
||||
// Block-level controls nest by block: one that opens in the block another (not its
|
||||
// ancestor) closes after starts with the next block instead.
|
||||
//
|
||||
// Such a clash is with a control that closes before this one opens and ends in the block
|
||||
// this one starts in. The block-level controls are grouped by the block they end in, each
|
||||
// group keeping the two earliest closes in it: the earliest close that is not this
|
||||
// control's own is the one the test is about (#13).
|
||||
let mut first_close: Vec<usize> = vec![usize::MAX; n + 1];
|
||||
let mut second_close: Vec<usize> = vec![usize::MAX; n + 1];
|
||||
let mut first_owner: Vec<usize> = vec![usize::MAX; n + 1];
|
||||
for (q, (_, qc)) in ranges.iter().enumerate() {
|
||||
if mode.get(q) != Some(&Mode::Block) {
|
||||
continue;
|
||||
}
|
||||
let Some(&(_, qe)) = span.get(q) else { continue };
|
||||
let e = qe.min(n);
|
||||
if *qc < first_close[e] {
|
||||
second_close[e] = first_close[e];
|
||||
first_close[e] = *qc;
|
||||
first_owner[e] = q;
|
||||
} else if *qc < second_close[e] {
|
||||
second_close[e] = *qc;
|
||||
}
|
||||
}
|
||||
for k in 0..ranges.len() {
|
||||
if mode.get(k) != Some(&Mode::Block) {
|
||||
continue;
|
||||
}
|
||||
let Some(&(o, _)) = ranges.get(k) else { continue };
|
||||
let Some(&(sb, eb)) = span.get(k) else { continue };
|
||||
let clash = ranges
|
||||
.iter()
|
||||
.enumerate()
|
||||
.any(|(q, (_, qc))| q != k && *qc < o && mode.get(q) == Some(&Mode::Block) && span.get(q).is_some_and(|s| s.1 == sb));
|
||||
if clash {
|
||||
let e = sb.min(n);
|
||||
let close = if first_owner[e] == k { second_close[e] } else { first_close[e] };
|
||||
if close < o {
|
||||
let to = (sb + 1).min(eb);
|
||||
if let Some(s) = span.get_mut(k) {
|
||||
s.0 = to;
|
||||
@@ -146,13 +165,19 @@ pub fn plan(bl: &Blocks) -> Plan<'_> {
|
||||
}
|
||||
_ => {
|
||||
for e in [events.get(*o), events.get(*c)].into_iter().flatten() {
|
||||
if let Some(off) = e.off() {
|
||||
out.inline.insert((e.block(), off));
|
||||
if let Some(off) = e.off()
|
||||
&& let Some(v) = out.inline.get_mut(e.block())
|
||||
{
|
||||
v.push(off);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for v in out.inline.iter_mut() {
|
||||
v.sort_unstable();
|
||||
v.dedup();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Blocks, paragraphs, runs, objects and tables → WordprocessingML.
|
||||
|
||||
use wordcraft_doc::effects::ShapeEffects;
|
||||
use wordcraft_doc::fill::{Fill, GradientFlip, GradientKind, PathShape};
|
||||
use wordcraft_doc::para::{AltText, Anchor, Float, NoteKind, ShapeKind, Wrap};
|
||||
use wordcraft_doc::props::{CharProps, PropChange, Rgb, TextColor};
|
||||
use wordcraft_doc::section::{LineNumberRestart, NoteProps, SectionProps, SectionStart};
|
||||
@@ -35,8 +36,7 @@ impl Writer<'_> {
|
||||
return;
|
||||
}
|
||||
let plan = super::sdt::plan(bl);
|
||||
// A TOC inside a content control already has one (read from a file).
|
||||
let toc = if top { toc_span(bl).filter(|(start, _)| !plan.covers(*start)) } else { None };
|
||||
let toc = if top { written_toc_span(bl) } else { None };
|
||||
// Whether the heading's TOC field was written and still needs its end.
|
||||
let mut toc_field_open = false;
|
||||
for (i, b) in bl.iter().enumerate() {
|
||||
@@ -720,14 +720,14 @@ impl Writer<'_> {
|
||||
w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas")]);
|
||||
w.open("wpc:wpc", &[]);
|
||||
w.open("wpc:bg", &[]);
|
||||
solid_fill(w, c.fill);
|
||||
write_fill(w, c.fill.as_ref());
|
||||
w.close("wpc:bg");
|
||||
w.open("wpc:whole", &[]);
|
||||
match c.stroke {
|
||||
Some(_) => w.open("a:ln", &[("w", &emu(c.stroke_width.clamp(0.0, 100.0)))]),
|
||||
None => w.open("a:ln", &[]),
|
||||
}
|
||||
solid_fill(w, c.stroke);
|
||||
write_fill(w, c.stroke.map(Fill::solid).as_ref());
|
||||
w.close("a:ln");
|
||||
w.close("wpc:whole");
|
||||
"wpc:wpc"
|
||||
@@ -1074,28 +1074,27 @@ impl Writer<'_> {
|
||||
}
|
||||
let alpha = geom.map_or(1.0, |f| f.alpha);
|
||||
let ink = geom.is_some_and(|f| f.is_ink());
|
||||
match fill.filter(|_| !ink && !kind.is_open()) {
|
||||
Some(c) => {
|
||||
w.open("a:solidFill", &[]);
|
||||
w.empty("a:srgbClr", &[("val", &c.hex())]);
|
||||
w.close("a:solidFill");
|
||||
}
|
||||
None => w.empty("a:noFill", &[]),
|
||||
}
|
||||
write_fill(w, fill.as_ref().filter(|_| !ink && !kind.is_open()));
|
||||
match stroke {
|
||||
Some(c) => {
|
||||
let width = emu(stroke_width.clamp(0.0, 100.0));
|
||||
let ln: &[(&str, &str)] = if ink { &[("w", width.as_str()), ("cap", "rnd")] } else { &[("w", width.as_str())] };
|
||||
w.open("a:ln", ln);
|
||||
w.open("a:solidFill", &[]);
|
||||
if alpha < 1.0 {
|
||||
w.open("a:srgbClr", &[("val", &c.hex())]);
|
||||
w.empty("a:alpha", &[("val", &n((alpha.clamp(0.0, 1.0) * 100_000.0).round() as i64))]);
|
||||
w.close("a:srgbClr");
|
||||
// An outline gradient read from a file, while the outline keeps its colour (#1500).
|
||||
let grad = extra.outline_fill.as_ref().filter(|f| !ink && f.main_color() == *c);
|
||||
if grad.is_some() {
|
||||
write_fill(w, grad);
|
||||
} else {
|
||||
w.empty("a:srgbClr", &[("val", &c.hex())]);
|
||||
w.open("a:solidFill", &[]);
|
||||
if alpha < 1.0 {
|
||||
w.open("a:srgbClr", &[("val", &c.hex())]);
|
||||
w.empty("a:alpha", &[("val", &n((alpha.clamp(0.0, 1.0) * 100_000.0).round() as i64))]);
|
||||
w.close("a:srgbClr");
|
||||
} else {
|
||||
w.empty("a:srgbClr", &[("val", &c.hex())]);
|
||||
}
|
||||
w.close("a:solidFill");
|
||||
}
|
||||
w.close("a:solidFill");
|
||||
line_dash(w, &extra.dash);
|
||||
if ink {
|
||||
w.empty("a:round", &[]);
|
||||
@@ -1184,7 +1183,9 @@ impl Writer<'_> {
|
||||
} else {
|
||||
let d = emu(float.dist.clamp(0.0, 1584.0));
|
||||
let (dt, db) = (emu(float.dist_top.clamp(0.0, 1584.0)), emu(float.dist_bottom.clamp(0.0, 1584.0)));
|
||||
self.z += 1;
|
||||
// The stacking order read from the file is kept; objects without one stack in
|
||||
// document order above it.
|
||||
self.z = if float.z > 0 { float.z } else { self.z.saturating_add(1) };
|
||||
let behind = if float.wrap == Wrap::BehindText { "1" } else { "0" };
|
||||
w.open(
|
||||
"wp:anchor",
|
||||
@@ -1688,6 +1689,12 @@ fn is_toc(instr: &str) -> bool {
|
||||
instr.split_whitespace().next().is_some_and(|k| k.eq_ignore_ascii_case("TOC"))
|
||||
}
|
||||
|
||||
/// The body TOC the writer wraps in Word's Table of Contents control (`toc_span`): not one whose
|
||||
/// heading is inside a content control already (read from a file).
|
||||
pub(crate) fn written_toc_span(bl: &Blocks) -> Option<(usize, usize)> {
|
||||
toc_span(bl).filter(|(start, _)| !super::sdt::plan(bl).covers(*start))
|
||||
}
|
||||
|
||||
/// The engine's TOC: a paragraph holding an empty `TOC` field, then its TOC-styled entries.
|
||||
/// Returns (heading, last entry) so the field can be written spanning the entries, as Word does.
|
||||
fn toc_span(bl: &Blocks) -> Option<(usize, usize)> {
|
||||
@@ -1734,15 +1741,68 @@ pub(super) fn nv_props(w: &mut W, tag: &str, id: &str, name: &str, descr: &str,
|
||||
w.close(tag);
|
||||
}
|
||||
|
||||
/// A DrawingML `a:solidFill` in `c`, or `a:noFill`.
|
||||
fn solid_fill(w: &mut W, c: Option<wordcraft_doc::props::Rgb>) {
|
||||
match c {
|
||||
Some(c) => {
|
||||
w.open("a:solidFill", &[]);
|
||||
/// A shape's fill: `a:solidFill` (with `a:alpha` when transparent), `a:gradFill` with everything
|
||||
/// read from the file, or `a:noFill`.
|
||||
fn write_fill(w: &mut W, f: Option<&Fill>) {
|
||||
let pct = |v: f32| n((v * 1000.0).round() as i64);
|
||||
let color = |w: &mut W, c: Rgb, transparency: f32| {
|
||||
if transparency > 0.0 {
|
||||
w.open("a:srgbClr", &[("val", &c.hex())]);
|
||||
w.empty("a:alpha", &[("val", &pct(100.0 - transparency))]);
|
||||
w.close("a:srgbClr");
|
||||
} else {
|
||||
w.empty("a:srgbClr", &[("val", &c.hex())]);
|
||||
}
|
||||
};
|
||||
let rect = |r: [f32; 4]| [("l", pct(r[0])), ("t", pct(r[1])), ("r", pct(r[2])), ("b", pct(r[3]))];
|
||||
match f.and_then(Fill::sanitized) {
|
||||
None => w.empty("a:noFill", &[]),
|
||||
Some(Fill::Solid { color: c, transparency }) => {
|
||||
w.open("a:solidFill", &[]);
|
||||
color(w, c, transparency);
|
||||
w.close("a:solidFill");
|
||||
}
|
||||
None => w.empty("a:noFill", &[]),
|
||||
Some(Fill::Gradient(g)) => {
|
||||
// `rotWithShape` and `scaled` are written only when set: an absent one reads back off.
|
||||
let mut attrs = if g.rot_with_shape { vec![("rotWithShape", "1")] } else { vec![] };
|
||||
match g.flip {
|
||||
GradientFlip::None => {}
|
||||
GradientFlip::X => attrs.insert(0, ("flip", "x")),
|
||||
GradientFlip::Y => attrs.insert(0, ("flip", "y")),
|
||||
GradientFlip::Xy => attrs.insert(0, ("flip", "xy")),
|
||||
}
|
||||
w.open("a:gradFill", &attrs);
|
||||
w.open("a:gsLst", &[]);
|
||||
for s in &g.stops {
|
||||
w.open("a:gs", &[("pos", &pct(s.pos))]);
|
||||
color(w, s.color, s.transparency);
|
||||
w.close("a:gs");
|
||||
}
|
||||
w.close("a:gsLst");
|
||||
match g.kind {
|
||||
GradientKind::Linear { angle, scaled } => {
|
||||
let ang = n((angle * 60_000.0).round() as i64);
|
||||
let attrs: &[(&str, &str)] = if scaled { &[("ang", &ang), ("scaled", "1")] } else { &[("ang", &ang)] };
|
||||
w.empty("a:lin", attrs)
|
||||
}
|
||||
GradientKind::Path { shape, focus } => {
|
||||
let path = match shape {
|
||||
PathShape::Circle => "circle",
|
||||
PathShape::Rect => "rect",
|
||||
PathShape::Shape => "shape",
|
||||
};
|
||||
w.open("a:path", &[("path", path)]);
|
||||
let r = rect(focus);
|
||||
w.empty("a:fillToRect", &r.iter().map(|(k, v)| (*k, v.as_str())).collect::<Vec<_>>());
|
||||
w.close("a:path");
|
||||
}
|
||||
}
|
||||
if let Some(t) = g.tile {
|
||||
let r = rect(t);
|
||||
w.empty("a:tileRect", &r.iter().map(|(k, v)| (*k, v.as_str())).collect::<Vec<_>>());
|
||||
}
|
||||
w.close("a:gradFill");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,15 +19,10 @@ pub(super) fn text(wm: &Watermark) -> Option<String> {
|
||||
}
|
||||
|
||||
/// A `w:r` holding the watermark shape (and its shape type), for the first paragraph of header
|
||||
/// part number `n`. Sized like the renderers draw it: half the page width across, or half the
|
||||
/// page diagonal when diagonal; the height follows the text's length.
|
||||
/// part number `n`, in its box ([`Watermark::frame`]: its own, or Word's default for the page).
|
||||
pub(super) fn run(wm: &Watermark, n: usize, page: &SectionProps) -> Option<String> {
|
||||
let text = text(wm)?;
|
||||
let (pw, ph) = (page.page_w.clamp(72.0, 4000.0), page.page_h.clamp(72.0, 4000.0));
|
||||
let across = if wm.diagonal { (pw * pw + ph * ph).sqrt() } else { pw };
|
||||
let width = (across * 0.5).clamp(36.0, 2000.0);
|
||||
let chars = text.chars().count().clamp(1, 200) as f32;
|
||||
let height = (width / (0.55 * chars)).clamp(12.0, 400.0);
|
||||
let [width, height] = wm.frame(page.page_w, page.page_h);
|
||||
// A font name can't break out of the textpath's CSS-like style.
|
||||
let font: String = wm.font.chars().filter(|c| !matches!(c, '"' | '\'' | ';' | ':')).take(100).collect();
|
||||
let font = if font.trim().is_empty() { Watermark::default().font } else { font };
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
//! What saving a document with many content controls costs (#13).
|
||||
//!
|
||||
//! A form-shaped document: a control per paragraph, inline, or a control around two paragraphs.
|
||||
//! The writer used to look for each paragraph's markers in the whole set of markers, once per
|
||||
//! paragraph, and to compare every control with every other one, so the save was quadratic in the
|
||||
//! number of controls.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use wordcraft_doc::control::{ContentControl, ControlKind};
|
||||
use wordcraft_doc::para::InlineObject;
|
||||
use wordcraft_doc::props::CharProps;
|
||||
use wordcraft_doc::{Document, Paragraph, para_block};
|
||||
use wordcraft_docx::write;
|
||||
|
||||
/// One paragraph per control, the control inline in it.
|
||||
fn inline_controls(n: usize) -> Document {
|
||||
let mut doc = Document::new();
|
||||
doc.body.clear();
|
||||
let props = CharProps::default();
|
||||
for _ in 0..n {
|
||||
let mut p = Paragraph::with_text("value: ", props.clone());
|
||||
let off = p.len();
|
||||
p.insert_object(off, InlineObject::ControlStart { control: Box::new(ContentControl::new(ControlKind::RichText)) }, &props)
|
||||
.expect("insert the start marker");
|
||||
let off = p.len();
|
||||
p.insert_object(off, InlineObject::ControlEnd, &props).expect("insert the end marker");
|
||||
doc.body.push(para_block(p));
|
||||
}
|
||||
doc
|
||||
}
|
||||
|
||||
/// Two paragraphs per control, the control written around both of them.
|
||||
fn block_controls(n: usize) -> Document {
|
||||
let mut doc = Document::new();
|
||||
doc.body.clear();
|
||||
let props = CharProps::default();
|
||||
for _ in 0..n {
|
||||
let mut open = Paragraph::new();
|
||||
let off = open.len();
|
||||
open.insert_object(off, InlineObject::ControlStart { control: Box::new(ContentControl::new(ControlKind::RichText)) }, &props)
|
||||
.expect("insert the start marker");
|
||||
doc.body.push(para_block(open));
|
||||
let mut close = Paragraph::with_text("value", props.clone());
|
||||
let off = close.len();
|
||||
close.insert_object(off, InlineObject::ControlEnd, &props).expect("insert the end marker");
|
||||
doc.body.push(para_block(close));
|
||||
}
|
||||
doc
|
||||
}
|
||||
|
||||
/// How much more saving the bigger document costs than the smaller one, for the bigger one
|
||||
/// `scale` times the size of the smaller one's: ~`scale` when linear, ~`scale`² when quadratic.
|
||||
/// Each is the cheapest of several runs after a warm-up, the runs alternating so a busy spell
|
||||
/// slows both sizes alike; the smaller document is saved `scale` times over per run, so both
|
||||
/// timings are long against the clock's resolution.
|
||||
fn cost_ratio(scale: usize, mut small: impl FnMut(), mut big: impl FnMut()) -> (f64, f64, f64) {
|
||||
small();
|
||||
big();
|
||||
let mut small_scaled = || (0..scale).for_each(|_| small());
|
||||
let (mut t1, mut tk) = (f64::INFINITY, f64::INFINITY);
|
||||
for _ in 0..5 {
|
||||
let a = Instant::now();
|
||||
small_scaled();
|
||||
t1 = t1.min(a.elapsed().as_secs_f64() / scale as f64);
|
||||
let b = Instant::now();
|
||||
big();
|
||||
tk = tk.min(b.elapsed().as_secs_f64());
|
||||
}
|
||||
(t1, tk, tk / t1.max(1e-9))
|
||||
}
|
||||
|
||||
/// A document with many inline content controls saves in the time the controls take: the writer
|
||||
/// looked for each paragraph's markers in the whole set of markers, once per paragraph, so a
|
||||
/// document with 32,000 controls took four seconds to save where it takes a tenth of one now.
|
||||
#[test]
|
||||
fn a_document_with_many_inline_content_controls_saves_in_linear_time() {
|
||||
let scale = 4;
|
||||
let small = inline_controls(8_000);
|
||||
let big = inline_controls(8_000 * scale);
|
||||
let (t1, tk, ratio) = cost_ratio(
|
||||
scale,
|
||||
|| {
|
||||
write(&small).expect("write");
|
||||
},
|
||||
|| {
|
||||
write(&big).expect("write");
|
||||
},
|
||||
);
|
||||
eprintln!("t(n) {t1:.4} s, t(4n) {tk:.4} s, ratio {ratio:.1}");
|
||||
assert!(ratio < 8.0, "four times the controls took {ratio:.1}x the time ({t1:.4} s → {tk:.4} s): not linear");
|
||||
}
|
||||
|
||||
/// The same for the controls written around whole paragraphs: the writer compared every control
|
||||
/// with every other one to find the ones that clash, so the save was quadratic in their number.
|
||||
#[test]
|
||||
fn a_document_with_many_block_content_controls_saves_in_linear_time() {
|
||||
let scale = 4;
|
||||
let small = block_controls(8_000);
|
||||
let big = block_controls(8_000 * scale);
|
||||
let (t1, tk, ratio) = cost_ratio(
|
||||
scale,
|
||||
|| {
|
||||
write(&small).expect("write");
|
||||
},
|
||||
|| {
|
||||
write(&big).expect("write");
|
||||
},
|
||||
);
|
||||
eprintln!("t(n) {t1:.4} s, t(4n) {tk:.4} s, ratio {ratio:.1}");
|
||||
assert!(ratio < 8.0, "four times the controls took {ratio:.1}x the time ({t1:.4} s → {tk:.4} s): not linear");
|
||||
}
|
||||
|
||||
/// The controls are written where they were: the plan that decides whether a control is written
|
||||
/// inline or around whole paragraphs gives the same answer as it did before it was made faster.
|
||||
#[test]
|
||||
fn the_controls_are_written_where_they_were() {
|
||||
// Inline controls: both markers in the same paragraph, so the control is written in it.
|
||||
let bytes = write(&inline_controls(50)).expect("write");
|
||||
let back = wordcraft_docx::read(&bytes).expect("read back");
|
||||
let ranges = back.control_ranges(wordcraft_doc::StoryRef::Body);
|
||||
assert_eq!(ranges.len(), 50, "every control survives the round trip");
|
||||
for r in &ranges {
|
||||
assert_eq!(r.start.path, r.end.path, "the control is written inline: {:?}", r.start.path);
|
||||
}
|
||||
|
||||
// Block controls: the markers in different paragraphs, so the control is written around them.
|
||||
let bytes = write(&block_controls(50)).expect("write");
|
||||
let back = wordcraft_docx::read(&bytes).expect("read back");
|
||||
let ranges = back.control_ranges(wordcraft_doc::StoryRef::Body);
|
||||
assert_eq!(ranges.len(), 50, "every control survives the round trip");
|
||||
for r in &ranges {
|
||||
assert_ne!(r.start.path, r.end.path, "the control is written around the paragraphs");
|
||||
}
|
||||
}
|
||||
|
||||
/// The names of the parts of a package, in the order they are in it.
|
||||
fn part_names(bytes: &[u8]) -> Vec<String> {
|
||||
let z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("zip");
|
||||
z.file_names().map(str::to_string).collect()
|
||||
}
|
||||
|
||||
/// The body of the document part of a package.
|
||||
fn document_body(bytes: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("zip");
|
||||
let mut xml = String::new();
|
||||
{
|
||||
use std::io::Read;
|
||||
z.by_name("word/document.xml").expect("the document part").read_to_string(&mut xml).expect("read");
|
||||
}
|
||||
let rest = xml.split_once("<w:body>").expect("the body").1;
|
||||
let body = rest.split_once("</w:body>").expect("the end of the body").0;
|
||||
format!("<w:body>{body}</w:body>")
|
||||
}
|
||||
|
||||
/// A document whose controls are written in an order and a place worth pinning: two inline
|
||||
/// controls in one paragraph, and a control around a whole paragraph.
|
||||
fn pinned_doc() -> Document {
|
||||
let props = CharProps::default();
|
||||
let mut doc = Document::new();
|
||||
doc.body.clear();
|
||||
// "before [a] between [b] after", the two controls inline in one paragraph.
|
||||
let mut p = Paragraph::with_text("before ", props.clone());
|
||||
let mut at = p.len();
|
||||
p.insert_object(at, InlineObject::ControlStart { control: Box::new(ContentControl::new(ControlKind::Text { multi_line: false })) }, &props)
|
||||
.expect("the first marker");
|
||||
at = p.len();
|
||||
p.insert_text(at, "inside a", &props).expect("text");
|
||||
at = p.len();
|
||||
p.insert_object(at, InlineObject::ControlEnd, &props).expect("the first end marker");
|
||||
at = p.len();
|
||||
p.insert_text(at, " between ", &props).expect("text");
|
||||
at = p.len();
|
||||
p.insert_object(at, InlineObject::ControlStart { control: Box::new(ContentControl::new(ControlKind::RichText)) }, &props)
|
||||
.expect("the second marker");
|
||||
at = p.len();
|
||||
p.insert_text(at, "inside b", &props).expect("text");
|
||||
at = p.len();
|
||||
p.insert_object(at, InlineObject::ControlEnd, &props).expect("the second end marker");
|
||||
at = p.len();
|
||||
p.insert_text(at, " after", &props).expect("text");
|
||||
doc.body.push(para_block(p));
|
||||
// A control around a whole paragraph.
|
||||
let mut open = Paragraph::new();
|
||||
let at = open.len();
|
||||
open.insert_object(at, InlineObject::ControlStart { control: Box::new(ContentControl::new(ControlKind::RichText)) }, &props)
|
||||
.expect("the block marker");
|
||||
doc.body.push(para_block(open));
|
||||
let mut inside = Paragraph::with_text("in the block", props.clone());
|
||||
let at = inside.len();
|
||||
inside.insert_object(at, InlineObject::ControlEnd, &props).expect("the block end marker");
|
||||
doc.body.push(para_block(inside));
|
||||
doc
|
||||
}
|
||||
|
||||
/// The body of the document part the writer writes for [`pinned_doc`], byte for byte: what the
|
||||
/// writer wrote before it was made faster. The counts and the paragraphs in the test above say
|
||||
/// that the controls are where they belong; this says that the rest is where it was too, down to
|
||||
/// the order and the place of the markers inside a paragraph.
|
||||
///
|
||||
/// The body is what the plan decides: which controls are written inline and which around whole
|
||||
/// paragraphs, and where. Nothing outside it can change with the plan, so it is what is pinned.
|
||||
///
|
||||
/// Regenerate it when the writer's output changes on purpose: write [`pinned_doc`] and take the
|
||||
/// body of the document part.
|
||||
#[test]
|
||||
fn the_document_body_is_written_as_it_was() {
|
||||
let bytes = write(&pinned_doc()).expect("write");
|
||||
assert_eq!(document_body(&bytes), PINNED_BODY);
|
||||
// The parts are the same ones, so nothing is added to the package and nothing is lost.
|
||||
assert_eq!(part_names(&bytes), PINNED_PARTS);
|
||||
}
|
||||
|
||||
/// The body of the document part of [`pinned_doc`], as the writer wrote it before it was made
|
||||
/// faster.
|
||||
const PINNED_PARTS: &[&str] = &[
|
||||
"[Content_Types].xml",
|
||||
"_rels/.rels",
|
||||
"word/document.xml",
|
||||
"word/_rels/document.xml.rels",
|
||||
"word/styles.xml",
|
||||
"word/settings.xml",
|
||||
"word/theme/theme1.xml",
|
||||
"docProps/core.xml",
|
||||
"docProps/app.xml",
|
||||
];
|
||||
|
||||
const PINNED_BODY: &str = r#"<w:body><w:p><w:r><w:t xml:space="preserve">before </w:t></w:r><w:sdt><w:sdtPr><w:text/></w:sdtPr><w:sdtContent><w:r><w:t xml:space="preserve">inside a</w:t></w:r></w:sdtContent></w:sdt><w:r><w:t xml:space="preserve"> between </w:t></w:r><w:sdt><w:sdtPr></w:sdtPr><w:sdtContent><w:r><w:t xml:space="preserve">inside b</w:t></w:r></w:sdtContent></w:sdt><w:r><w:t xml:space="preserve"> after</w:t></w:r></w:p><w:sdt><w:sdtPr></w:sdtPr><w:sdtContent><w:p></w:p><w:p><w:r><w:t xml:space="preserve">in the block</w:t></w:r></w:p></w:sdtContent></w:sdt><w:sectPr><w:type w:val="nextPage"/><w:pgSz w:w="12240" w:h="15840"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="720" w:footer="720" w:gutter="0"/><w:cols w:space="720" w:num="1"/></w:sectPr></w:body>"#;
|
||||
@@ -245,3 +245,89 @@ fn a_controls_run_properties_are_read_and_kept() {
|
||||
let out = document_xml(&write(&d).unwrap());
|
||||
assert!(out.contains(r#"<w:rPr><w:rStyle w:val="Eingabe"/><w:sz w:val="20"/></w:rPr>"#), "{out}");
|
||||
}
|
||||
|
||||
/// Word's Table of Contents control (`w:docPartObj`, #2427) around a TOC the engine's
|
||||
/// heading/entry form doesn't cover (no `TOCHeading`, unstyled entries, the field ending in a
|
||||
/// paragraph of its own) is kept as read: nothing rebuilds it on save.
|
||||
#[test]
|
||||
fn toc_gallery_around_a_free_form_toc_round_trips() {
|
||||
let pr = r#"<w:sdtPr><w:id w:val="-1234567"/><w:docPartObj><w:docPartGallery w:val="Table of Contents"/><w:docPartUnique/></w:docPartObj></w:sdtPr><w:sdtEndPr/><w:sdtContent>"#;
|
||||
let field = format!(
|
||||
r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> TOC \o "1-3" \h \z \u </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>{}<w:r><w:tab/></w:r>{}"#,
|
||||
t("Chapter One"),
|
||||
t("1")
|
||||
);
|
||||
let end = r#"<w:r><w:fldChar w:fldCharType="end"/></w:r>"#;
|
||||
let body = format!("<w:sdt>{pr}{}{}{}</w:sdtContent></w:sdt>{}", p(&t("Contents")), p(&field), p(end), p(&t("Chapter One")));
|
||||
let mut bytes = package(&body);
|
||||
for pass in 0..2 {
|
||||
let d = read(&bytes).unwrap();
|
||||
bytes = write(&d).unwrap();
|
||||
let xml = document_xml(&bytes);
|
||||
assert_eq!(xml.matches("<w:sdt>").count(), 1, "pass {pass}: {xml}");
|
||||
let open = xml.find(pr).unwrap_or_else(|| panic!("pass {pass}: {xml}"));
|
||||
let close = xml.find("</w:sdtContent></w:sdt>").unwrap();
|
||||
let (contents, fend) = (xml.find(">Contents<").unwrap(), xml.find(r#"w:fldCharType="end""#).unwrap());
|
||||
assert!(open < contents && fend < close, "pass {pass}: {xml}");
|
||||
assert!(close < xml.rfind(">Chapter One<").unwrap(), "the body paragraph stays outside: {xml}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The control around a TOC in the engine's form (a `TOCHeading`, then TOC-styled entries the
|
||||
/// field spans) still goes to the TOC: written once, around the heading and the entries, and the
|
||||
/// field opens in the heading as References › Update Table expects.
|
||||
#[test]
|
||||
fn toc_gallery_around_the_engine_form_goes_to_the_toc() {
|
||||
let style = |s: &str, inner: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{s}"/></w:pPr>{inner}</w:p>"#);
|
||||
let begin = r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> TOC \o "1-3" \h \z \u </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>"#;
|
||||
let end = r#"<w:r><w:fldChar w:fldCharType="end"/></w:r>"#;
|
||||
let content = format!(
|
||||
"{}{}{}",
|
||||
style("TOCHeading", &t("Contents")),
|
||||
style("TOC1", &format!("{begin}{}", t("Chapter One\t1"))),
|
||||
style("TOC2", &format!("{}{end}", t("Section\t2")))
|
||||
);
|
||||
let pr = r#"<w:id w:val="-1234567"/><w:docPartObj><w:docPartGallery w:val="Table of Contents"/><w:docPartUnique/></w:docPartObj>"#;
|
||||
let mut bytes = package(&format!("{}{}", sdt(pr, &content), p(&t("Chapter One"))));
|
||||
for pass in 0..2 {
|
||||
let d = read(&bytes).unwrap();
|
||||
assert!(controls(&d).is_empty(), "pass {pass}");
|
||||
let heading = d.body[0].as_para().unwrap();
|
||||
assert!(heading.objects.iter().any(|o| matches!(o, wordcraft_doc::InlineObject::Field { instr, .. } if instr.trim().starts_with("TOC"))));
|
||||
bytes = write(&d).unwrap();
|
||||
let xml = document_xml(&bytes);
|
||||
assert_eq!(xml.matches("<w:sdt>").count(), 1, "pass {pass}: {xml}");
|
||||
assert!(xml.contains(r#"<w:docPartGallery w:val="Table of Contents"/>"#), "pass {pass}: {xml}");
|
||||
assert!(xml.find("</w:sdt>").unwrap() < xml.rfind(">Chapter One<").unwrap(), "pass {pass}: {xml}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A rich text control around the heading and the first entry of a TOC in the engine's form,
|
||||
/// inside Word's Table of Contents control: the writer doesn't put a TOC control around a heading
|
||||
/// already in a block control, so the outer one is kept as read rather than lost on save.
|
||||
#[test]
|
||||
fn toc_gallery_around_a_toc_whose_heading_is_in_a_block_control_round_trips() {
|
||||
let style = |s: &str, inner: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{s}"/></w:pPr>{inner}</w:p>"#);
|
||||
let begin = r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> TOC \o "1-3" \h \z \u </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>"#;
|
||||
let end = r#"<w:r><w:fldChar w:fldCharType="end"/></w:r>"#;
|
||||
let content = format!(
|
||||
"{}{}",
|
||||
sdt(
|
||||
r#"<w:id w:val="31"/><w:richText/>"#,
|
||||
&format!("{}{}", style("TOCHeading", &t("Contents")), style("TOC1", &format!("{begin}{}", t("Chapter One\t1"))))
|
||||
),
|
||||
style("TOC2", &format!("{}{end}", t("Section\t2")))
|
||||
);
|
||||
let pr = r#"<w:id w:val="-1234567"/><w:docPartObj><w:docPartGallery w:val="Table of Contents"/><w:docPartUnique/></w:docPartObj>"#;
|
||||
let mut bytes = package(&format!("{}{}", sdt(pr, &content), p(&t("Chapter One"))));
|
||||
for pass in 0..2 {
|
||||
let d = read(&bytes).unwrap();
|
||||
bytes = write(&d).unwrap();
|
||||
let xml = document_xml(&bytes);
|
||||
assert_eq!(xml.matches("<w:sdt>").count(), 2, "pass {pass}: {xml}");
|
||||
let (gallery, rich) =
|
||||
(xml.find(&format!("<w:sdtPr>{pr}")).unwrap_or_else(|| panic!("pass {pass}: {xml}")), xml.find(r#"<w:id w:val="31"/>"#).unwrap());
|
||||
assert!(gallery < rich && rich < xml.find(">Contents<").unwrap(), "pass {pass}: {xml}");
|
||||
assert!(xml.rfind("</w:sdt>").unwrap() < xml.rfind(">Chapter One<").unwrap(), "pass {pass}: {xml}");
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,85 @@
|
||||
//! East Asian language attribute of a run (`w:lang/@w:eastAsia`, ISO/IEC 29500-1 §17.3.2.20).
|
||||
//! Verifies that saving DOCX documents preserves the East Asian language of `w:lang`.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
fn docx(body: &str, styles: Option<&str>) -> Vec<u8> {
|
||||
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let doc_rels = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>"#;
|
||||
let doc = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
|
||||
);
|
||||
let mut parts = vec![("_rels/.rels", root.to_string()), ("word/document.xml", doc)];
|
||||
if let Some(s) = styles {
|
||||
parts.push(("word/_rels/document.xml.rels", doc_rels.to_string()));
|
||||
parts.push((
|
||||
"word/styles.xml",
|
||||
format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">{s}</w:styles>"#),
|
||||
));
|
||||
}
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, xml) in &parts {
|
||||
zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(xml.as_bytes()).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn part(bytes: &[u8], name: &str) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut s = String::new();
|
||||
z.by_name(name).unwrap().read_to_string(&mut s).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn run(text: &str, rpr: &str) -> String {
|
||||
format!(r#"<w:r><w:rPr>{rpr}</w:rPr><w:t>{text}</w:t></w:r>"#)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn east_asian_lang_survives_a_save() {
|
||||
let body = format!(
|
||||
r#"<w:p>{}{}</w:p>"#,
|
||||
run("Hello", r#"<w:lang w:val="en-US" w:eastAsia="ja-JP" w:bidi="ar-SA"/>"#),
|
||||
run("中文", r#"<w:lang w:eastAsia="zh-CN"/>"#)
|
||||
);
|
||||
let d = wordcraft_docx::read(&docx(&body, None)).unwrap();
|
||||
let p = d.body[0].as_para().unwrap();
|
||||
|
||||
let r0 = &p.runs[0];
|
||||
assert_eq!(r0.props.lang.as_deref(), Some("en-US"));
|
||||
assert_eq!(r0.props.lang_ea.as_deref(), Some("ja-JP"));
|
||||
assert_eq!(r0.props.lang_bidi.as_deref(), Some("ar-SA"));
|
||||
|
||||
let r1 = &p.runs[1];
|
||||
assert_eq!(r1.props.lang, None);
|
||||
assert_eq!(r1.props.lang_ea.as_deref(), Some("zh-CN"));
|
||||
assert_eq!(r1.props.lang_bidi, None);
|
||||
|
||||
let out = wordcraft_docx::write(&d).unwrap();
|
||||
let xml = part(&out, "word/document.xml");
|
||||
assert!(xml.contains(r#"w:eastAsia="ja-JP""#), "{xml}");
|
||||
assert!(xml.contains(r#"w:eastAsia="zh-CN""#), "{xml}");
|
||||
|
||||
let back = wordcraft_docx::read(&out).unwrap();
|
||||
let back_p = back.body[0].as_para().unwrap();
|
||||
assert_eq!(back_p.runs[0].props.lang.as_deref(), Some("en-US"));
|
||||
assert_eq!(back_p.runs[0].props.lang_ea.as_deref(), Some("ja-JP"));
|
||||
assert_eq!(back_p.runs[0].props.lang_bidi.as_deref(), Some("ar-SA"));
|
||||
assert_eq!(back_p.runs[1].props.lang_ea.as_deref(), Some("zh-CN"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn east_asian_lang_style_and_whitespace_trim() {
|
||||
let styles =
|
||||
r#"<w:style w:type="character" w:styleId="CJKLang"><w:name w:val="CJK Language"/><w:rPr><w:lang w:eastAsia=" ko-KR "/></w:rPr></w:style>"#;
|
||||
let body = format!(r#"<w:p>{}</w:p>"#, run("한국어", r#"<w:rStyle w:val="CJKLang"/>"#));
|
||||
let d = wordcraft_docx::read(&docx(&body, Some(styles))).unwrap();
|
||||
|
||||
let style = d.styles.get("CJKLang").unwrap();
|
||||
assert_eq!(style.chr.lang_ea.as_deref(), Some("ko-KR"));
|
||||
|
||||
let out = wordcraft_docx::write(&d).unwrap();
|
||||
let styles_xml = part(&out, "word/styles.xml");
|
||||
assert!(styles_xml.contains(r#"w:eastAsia="ko-KR""#), "{styles_xml}");
|
||||
}
|
||||
+395
-21
@@ -2,6 +2,7 @@
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use wordcraft_doc::fill::Fill;
|
||||
use wordcraft_doc::para::{Anchor, FloatAlign, NoteKind, ShapeKind, Wrap};
|
||||
use wordcraft_doc::props::{Align, Border, BorderStyle, Rgb, TextColor, Underline, VMerge};
|
||||
use wordcraft_doc::{Block, Document, InlineObject, Paragraph, RevisionKind};
|
||||
@@ -1012,6 +1013,25 @@ fn anchor_alignment_reference_areas_and_distances() {
|
||||
assert_eq!((b.dist, b.dist_top, b.dist_bottom), (0.0, 0.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchor_stacking_order_is_read_and_kept() {
|
||||
let shape = |z: &str| {
|
||||
format!(
|
||||
r#"<w:r><w:drawing><wp:anchor behindDoc="0" {z}><wp:positionH relativeFrom="column"><wp:posOffset>0</wp:posOffset></wp:positionH><wp:positionV relativeFrom="paragraph"><wp:posOffset>0</wp:posOffset></wp:positionV><wp:extent cx="127000" cy="127000"/><wp:wrapNone/><wp:docPr id="1" name="S"/><a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingShape"><wps:wsp><wps:spPr><a:prstGeom prst="rect"/></wps:spPr></wps:wsp></a:graphicData></a:graphic></wp:anchor></w:drawing></w:r>"#
|
||||
)
|
||||
};
|
||||
let body = format!("<w:p>{}{}{}</w:p>", shape(r#"relativeHeight="251660288""#), shape(r#"relativeHeight="251657214""#), shape(""));
|
||||
let zs = |d: &Document| -> Vec<u32> {
|
||||
paras(d)[0].objects.iter().filter_map(|o| if let InlineObject::Shape { float, .. } = o { Some(float.z) } else { None }).collect()
|
||||
};
|
||||
let d = read_body(&body);
|
||||
assert_eq!(zs(&d), [251660288, 251657214, 0]);
|
||||
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
let z = zs(&again);
|
||||
assert_eq!(&z[..2], &[251660288, 251657214]);
|
||||
assert!(z[2] > 0, "an object without one gets an order on saving: {z:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn documents_without_a_compatibility_mode_are_laid_out_as_word_2007() {
|
||||
assert_eq!(read_body("<w:p/>").settings.compat_mode, wordcraft_doc::LEGACY_COMPAT_MODE);
|
||||
@@ -1623,6 +1643,46 @@ fn ole_object_round_trips() {
|
||||
assert!(matches!(paras(&back)[0].objects.first(), Some(InlineObject::Image { w: 144.0, ole: Some(_), .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_line_round_trips() {
|
||||
// Insert › Signature Line: a VML picture (the line, its X and the signer drawn in a metafile)
|
||||
// with an `o:signatureline` that makes it signable in Word. Saving keeps it a signature line,
|
||||
// not a plain picture; a VML picture without one still becomes a DrawingML picture.
|
||||
let body = r##"<w:p><w:r><w:pict><v:shapetype id="_x0000_t75" coordsize="21600,21600" o:spt="75" o:preferrelative="t" path="m@4@5l@4@11@9@11@9@5xe" filled="f" stroked="f"><v:path o:extrusionok="f" gradientshapeok="t" o:connecttype="rect"/><o:lock v:ext="edit" aspectratio="t"/></v:shapetype><v:shape id="_x0000_i1025" type="#_x0000_t75" alt="Signature line" style="width:192pt;height:96pt"><v:imagedata r:id="rId8" o:title=""/><o:lock v:ext="edit" ungrouping="t" rotation="t" cropping="t" verticies="t" text="t" grouping="t"/><o:signatureline v:ext="edit" id="{00000000-0000-0000-0000-000000000001}" provid="{00000000-0000-0000-0000-000000000000}" o:suggestedsigner="A. Example" o:suggestedsigner2="Director" showsigndate="f" issignatureline="t"/></v:shape></w:pict></w:r><w:r><w:pict><v:shape style="width:72pt;height:36pt" alt="plain"><v:imagedata r:id="rId8" o:title=""/></v:shape></w:pict></w:r></w:p>"##;
|
||||
let picture = "\u{89}PNG stand-in";
|
||||
let types =
|
||||
r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="png" ContentType="image/png"/></Types>"#;
|
||||
let bytes = docx(body, &[("rId8", "image", "media/image1.png")], &[("[Content_Types].xml", types), ("word/media/image1.png", picture)]);
|
||||
let d = wordcraft_docx::read(&bytes).unwrap();
|
||||
let objects = ¶s(&d)[0].objects;
|
||||
let [InlineObject::Image { w, alt, ole: Some(_), .. }, InlineObject::Image { ole: None, .. }] = objects.as_slice() else { panic!("{objects:?}") };
|
||||
assert_eq!((*w, alt.as_str()), (192.0, "Signature line"));
|
||||
|
||||
// Resized in WordCraft.
|
||||
let mut doc = d.clone();
|
||||
if let Some(wordcraft_doc::Block::Para(p)) = doc.body.first_mut().map(std::sync::Arc::make_mut)
|
||||
&& let Some(InlineObject::Image { w, h, .. }) = p.objects.first_mut()
|
||||
{
|
||||
(*w, *h) = (144.0, 72.0);
|
||||
}
|
||||
let out = wordcraft_docx::write(&doc).unwrap();
|
||||
let files = unzip(&out);
|
||||
let body = text(&files, "word/document.xml");
|
||||
let rels = text(&files, "word/_rels/document.xml.rels");
|
||||
assert_eq!(attr_values(&body, "o:signatureline", "o:suggestedsigner"), ["A. Example"]);
|
||||
assert_eq!(attr_values(&body, "o:signatureline", "o:suggestedsigner2"), ["Director"]);
|
||||
assert_eq!(attr_values(&body, "v:shape", "style"), ["width:144pt;height:72pt"]);
|
||||
assert_eq!(attr_values(&body, "v:shapetype", "id"), ["_x0000_t75"]);
|
||||
let [pic_id] = attr_values(&body, "v:imagedata", "r:id").try_into().unwrap();
|
||||
let (kind, target) = rel(&rels, &pic_id);
|
||||
assert!(kind.ends_with("/image"), "{kind}");
|
||||
assert_eq!(files.get(&part_at("word", &target)).map(Vec::as_slice), Some(picture.as_bytes()));
|
||||
assert_eq!(body.matches("<w:drawing>").count(), 1, "only the plain picture becomes a DrawingML picture");
|
||||
|
||||
let back = wordcraft_docx::read(&out).unwrap();
|
||||
assert!(matches!(paras(&back)[0].objects.first(), Some(InlineObject::Image { w: 144.0, ole: Some(_), .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_embedded_relationships_stay_bounded() {
|
||||
// The chart relates to itself, the main document, a relationships part and a missing part;
|
||||
@@ -2138,7 +2198,7 @@ fn theme_styled_connectors_and_callout_boxes_keep_their_paint() {
|
||||
assert_eq!(objs.len(), 5);
|
||||
let shape = |i: usize| match &objs[i] {
|
||||
InlineObject::Shape { kind, fill, stroke, stroke_width, extra, story, .. } => {
|
||||
(*kind, *fill, *stroke, *stroke_width, extra.arrow_end, story.is_some())
|
||||
(*kind, fill.as_ref().map(Fill::main_color), *stroke, *stroke_width, extra.arrow_end, story.is_some())
|
||||
}
|
||||
o => panic!("{o:?}"),
|
||||
};
|
||||
@@ -2211,7 +2271,10 @@ fn vml_lines_and_rectangles() {
|
||||
}
|
||||
o => panic!("{o:?}"),
|
||||
}
|
||||
assert!(matches!(&objs[1], InlineObject::Shape { kind: ShapeKind::Rectangle, fill: Some(Rgb(255, 0, 0)), stroke: None, w: 30.0, h: 20.0, .. }));
|
||||
assert!(matches!(
|
||||
&objs[1],
|
||||
InlineObject::Shape { kind: ShapeKind::Rectangle, fill: Some(Fill::Solid { color: Rgb(255, 0, 0), .. }), stroke: None, w: 30.0, h: 20.0, .. }
|
||||
));
|
||||
}
|
||||
|
||||
/// The right arrow's shapetype as Word writes it (ECMA-376 Part 4 `v:shapetype`, `o:spt` 13).
|
||||
@@ -2263,7 +2326,7 @@ fn vml_preset_shapes_are_drawn() {
|
||||
let ps = paras(&d);
|
||||
let shape = |i: usize| match ps[i].objects.first() {
|
||||
Some(InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, story, .. }) => {
|
||||
Some((*kind, *w, *h, *fill, *stroke, *stroke_width, float.wrap == Wrap::Inline, story.is_some()))
|
||||
Some((*kind, *w, *h, fill.as_ref().map(Fill::main_color), *stroke, *stroke_width, float.wrap == Wrap::Inline, story.is_some()))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
@@ -2339,7 +2402,14 @@ fn vml_preset_shape_survives_saving() {
|
||||
assert!(
|
||||
matches!(
|
||||
¶s(&again)[0].objects[..],
|
||||
[InlineObject::Shape { kind: ShapeKind::Arrow, w: 120.0, h: 40.0, fill: Some(Rgb(0xFF, 0xA5, 0x00)), stroke: Some(Rgb::BLACK), .. }]
|
||||
[InlineObject::Shape {
|
||||
kind: ShapeKind::Arrow,
|
||||
w: 120.0,
|
||||
h: 40.0,
|
||||
fill: Some(Fill::Solid { color: Rgb(0xFF, 0xA5, 0x00), .. }),
|
||||
stroke: Some(Rgb::BLACK),
|
||||
..
|
||||
}]
|
||||
),
|
||||
"{:?}",
|
||||
paras(&again)[0].objects
|
||||
@@ -2360,7 +2430,7 @@ fn vml_nearest_preset_shape_survives_saving() {
|
||||
assert!(
|
||||
matches!(
|
||||
¶s(&again)[0].objects[..],
|
||||
[InlineObject::Shape { kind: ShapeKind::Arrow, w: 60.0, h: 20.0, fill: Some(Rgb(0xFF, 0xA5, 0x00)), .. }]
|
||||
[InlineObject::Shape { kind: ShapeKind::Arrow, w: 60.0, h: 20.0, fill: Some(Fill::Solid { color: Rgb(0xFF, 0xA5, 0x00), .. }), .. }]
|
||||
),
|
||||
"{:?}",
|
||||
paras(&again)[0].objects
|
||||
@@ -2417,7 +2487,7 @@ fn cover_page_group_fills_freeforms_and_text_box_margins() {
|
||||
let InlineObject::Group { children, .. } = &objs[0] else { panic!("{:?}", objs[0]) };
|
||||
assert_eq!(children.len(), 3);
|
||||
let shape = |i: usize| match &children[i].obj {
|
||||
InlineObject::Shape { kind, fill, freeform, story, .. } => (*kind, *fill, freeform.is_some(), *story),
|
||||
InlineObject::Shape { kind, fill, freeform, story, .. } => (*kind, fill.as_ref().map(Fill::main_color), freeform.is_some(), *story),
|
||||
o => panic!("{o:?}"),
|
||||
};
|
||||
let (kind, fill, free, _) = shape(0);
|
||||
@@ -2469,7 +2539,7 @@ fn drawingml_shapes_survive_saving() {
|
||||
.iter()
|
||||
.map(|o| match o {
|
||||
InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, extra, .. } => {
|
||||
(*kind, *w, *h, *fill, *stroke, *stroke_width, float.x, float.y, float.wrap, extra.arrow_end)
|
||||
(*kind, *w, *h, fill.clone(), *stroke, *stroke_width, float.x, float.y, float.wrap, extra.arrow_end)
|
||||
}
|
||||
o => panic!("{o:?}"),
|
||||
})
|
||||
@@ -2492,7 +2562,7 @@ fn drawingml_shapes_survive_saving() {
|
||||
]
|
||||
);
|
||||
for s in &read[..5] {
|
||||
assert_eq!(s.3, Some(accent), "{:?} is filled from the theme", s.0);
|
||||
assert_eq!(s.3, Some(wordcraft_doc::fill::Fill::solid(accent)), "{:?} is filled from the theme", s.0);
|
||||
assert!(s.4.is_some() && s.5 > 0.0, "{:?} has its outline", s.0);
|
||||
assert_eq!((s.6, s.7, s.8), (1.0, 2.0, Wrap::InFrontOfText), "{:?} keeps its place", s.0);
|
||||
}
|
||||
@@ -3109,6 +3179,39 @@ fn toc_entry_links_lose_the_hyperlink_style() {
|
||||
assert_eq!(run("plain").style.as_deref(), Some("Hyperlink"), "outside fields: kept");
|
||||
}
|
||||
|
||||
/// Localized Word (Spanish: `Hipervnculo`) and older files (`Hyperlnk`) give the built-in
|
||||
/// Hyperlink character style another id; its name stays "Hyperlink". TOC entries carrying it are
|
||||
/// plain too (#3239: entries showed blue).
|
||||
#[test]
|
||||
fn toc_entry_links_lose_a_localized_hyperlink_style() {
|
||||
for id in ["Hipervnculo", "Hyperlnk"] {
|
||||
let styles = format!(
|
||||
r#"<w:styles {W_NS}><w:style w:type="paragraph" w:styleId="Normal" w:default="1"><w:name w:val="Normal"/></w:style>
|
||||
<w:style w:type="character" w:styleId="{id}"><w:name w:val="Hyperlink"/><w:rPr><w:color w:val="0563C1"/><w:u w:val="single"/></w:rPr></w:style></w:styles>"#
|
||||
);
|
||||
let link = |t: &str| {
|
||||
format!(
|
||||
r#"<w:hyperlink w:anchor="_Toc1"><w:r><w:rPr><w:rStyle w:val="{id}"/></w:rPr><w:t>{t}</w:t></w:r><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> PAGEREF _Toc1 \h </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:t>1</w:t></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r></w:hyperlink>"#
|
||||
)
|
||||
};
|
||||
let body = format!(
|
||||
r#"<w:sdt><w:sdtPr><w:docPartObj><w:docPartGallery w:val="Table of Contents"/><w:docPartUnique/></w:docPartObj></w:sdtPr><w:sdtContent><w:p><w:pPr><w:pStyle w:val="TOC1"/></w:pPr><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> TOC \o "1-3" \h \z \u </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>{}</w:p><w:p><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p></w:sdtContent></w:sdt><w:p>{}</w:p><w:p><w:bookmarkStart w:id="1" w:name="_Toc1"/><w:r><w:t>Target</w:t></w:r><w:bookmarkEnd w:id="1"/></w:p>"#,
|
||||
link("entry"),
|
||||
link("plain")
|
||||
);
|
||||
let d = wordcraft_docx::read(&docx(&body, &[("rId1", "styles", "styles.xml")], &[("word/styles.xml", &styles)])).unwrap();
|
||||
let run = |word: &str| {
|
||||
paras(&d)
|
||||
.into_iter()
|
||||
.find_map(|p| p.text.find(word).map(|at| p.props_of_char(at).clone()))
|
||||
.unwrap_or_else(|| panic!("no run with {word}"))
|
||||
};
|
||||
let entry = run("entry");
|
||||
assert_eq!((entry.style.as_deref(), entry.link.as_deref()), (None, Some("#_Toc1")), "{id}: a TOC entry: plain, still a link");
|
||||
assert_eq!(run("plain").style.as_deref(), Some(id), "{id}: outside fields: kept");
|
||||
}
|
||||
}
|
||||
|
||||
/// A style or paragraph whose `w:numPr` gives only a level (`w:ilvl`) takes the list from its
|
||||
/// style chain (ECMA-376 §17.3.1.19): a Heading 3 based on a numbered Heading 2 is level 3 of the
|
||||
/// same list. WordCraft dropped the level, so Heading 3s numbered as Heading 2s (6.15 where Word
|
||||
@@ -3190,6 +3293,45 @@ fn text_frames_read_and_round_trip() {
|
||||
assert!(q.iter().any(|x| x.props.drop_cap == Some(2) && x.props.frame.is_none()));
|
||||
}
|
||||
|
||||
/// #2360: a `w:framePr` in the document defaults, the sizeless top-left one Apple Pages writes,
|
||||
/// frames no paragraph, as in Word: a header's title and the body's text stay in the text instead
|
||||
/// of moving to the page's corner, and the default isn't written back. The defaults' other
|
||||
/// properties are kept, and a style's own frame still applies.
|
||||
#[test]
|
||||
fn a_frame_in_the_paragraph_defaults_frames_nothing() {
|
||||
let styles = format!(
|
||||
r#"<w:styles {W_NS}><w:docDefaults><w:pPrDefault><w:pPr><w:framePr w:anchorLock="0" w:w="0" w:h="0" w:vSpace="0" w:hSpace="0" w:xAlign="left" w:y="0" w:hRule="exact" w:vAnchor="margin"/><w:spacing w:after="120"/></w:pPr></w:pPrDefault></w:docDefaults><w:style w:type="paragraph" w:styleId="Normal" w:default="1"><w:name w:val="Normal"/></w:style><w:style w:type="paragraph" w:styleId="Framed"><w:name w:val="Framed"/><w:pPr><w:framePr w:wrap="around" w:hAnchor="margin" w:vAnchor="text" w:xAlign="right" w:y="1"/></w:pPr></w:style></w:styles>"#
|
||||
);
|
||||
let header = format!(r#"<w:hdr {W_NS}><w:p><w:r><w:t>Title</w:t></w:r></w:p></w:hdr>"#);
|
||||
let body = r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p><w:p><w:pPr><w:pStyle w:val="Framed"/></w:pPr><w:r><w:t>Boxed</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rIdH"/></w:sectPr>"#;
|
||||
let bytes = docx(
|
||||
body,
|
||||
&[("rIdS", "styles", "styles.xml"), ("rIdH", "header", "header1.xml")],
|
||||
&[("word/styles.xml", &styles), ("word/header1.xml", &header)],
|
||||
);
|
||||
let check = |d: &Document| {
|
||||
assert!(d.styles.default_para.frame.is_none(), "{:?}", d.styles.default_para.frame);
|
||||
assert_eq!(d.styles.default_para.space_after, Some(6.0), "the defaults' spacing is kept");
|
||||
let frame = |p: &Paragraph| d.styles.resolve_para(&p.props).frame;
|
||||
let body = paras(d);
|
||||
assert_eq!(body[0].text, "Body");
|
||||
assert!(frame(body[0]).is_none(), "body text is not a frame");
|
||||
let boxed = frame(body[1]).expect("a style's frame still applies");
|
||||
assert_eq!((boxed.h_anchor.as_str(), boxed.x_align.as_deref()), ("margin", Some("right")));
|
||||
let hid = d.last_section.headers.default.expect("a header");
|
||||
let title = d.parts.get(&hid).unwrap().blocks.iter().find_map(|b| b.as_para()).expect("the title");
|
||||
assert_eq!(title.text, "Title");
|
||||
assert!(frame(title).is_none(), "the header's title is not a frame");
|
||||
};
|
||||
let d = wordcraft_docx::read(&bytes).unwrap();
|
||||
check(&d);
|
||||
let saved = wordcraft_docx::write(&d).unwrap();
|
||||
let xml = text(&unzip(&saved), "word/styles.xml");
|
||||
let defaults = xml.split("</w:docDefaults>").next().unwrap_or_default();
|
||||
assert!(!defaults.contains("w:framePr"), "the default frame isn't written back: {defaults}");
|
||||
check(&wordcraft_docx::read(&saved).unwrap());
|
||||
}
|
||||
|
||||
/// A legacy VML freeform (`v:shape` with a `path`, ECMA-376 Part 4): a rule across a header,
|
||||
/// drawn in a 1000 × 1000 coordinate space, outlined, its fill transparent. It is read as a
|
||||
/// freeform of the shape's size and kept when saved (as DrawingML custom geometry) and read back.
|
||||
@@ -3207,7 +3349,7 @@ fn vml_freeform_rule_in_header() {
|
||||
let shapes: Vec<&InlineObject> = d.parts.get(&hid).unwrap().blocks.iter().filter_map(|b| b.as_para()).flat_map(|p| &p.objects).collect();
|
||||
let [rule, tri] = shapes.as_slice() else { panic!("{shapes:?}") };
|
||||
let InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, freeform: Some(f), .. } = rule else { panic!("{rule:?}") };
|
||||
assert_eq!((*kind, *fill, *stroke), (ShapeKind::Freeform, None, Some(Rgb(0x33, 0x66, 0xCC))));
|
||||
assert_eq!((*kind, fill.as_ref().map(Fill::main_color), *stroke), (ShapeKind::Freeform, None, Some(Rgb(0x33, 0x66, 0xCC))));
|
||||
assert!(near(*w, 360.0) && near(*h, 12.0) && near(*stroke_width, 1.5), "{w} x {h}, {stroke_width}");
|
||||
assert_eq!((float.h_rel, float.v_rel, float.wrap), (Anchor::Margin, Anchor::Margin, Wrap::InFrontOfText));
|
||||
assert!(near(float.y, 30.0), "{float:?}");
|
||||
@@ -3277,21 +3419,56 @@ fn hostile_vml_paths_stay_finite() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Measured in Word: `w:asciiTheme="minorBidi"` names the theme's complex-script font (`a:cs`);
|
||||
/// when that is empty it names nothing, and the run keeps the font it inherits.
|
||||
/// Measured in Word for Mac: a `majorBidi`/`minorBidi` theme slot names the theme's font for the
|
||||
/// script of the settings' `w:themeFontLang/@w:bidi` (the Latin font for a Latin language), else
|
||||
/// the theme's `a:cs`, else Times New Roman; with no bidi language there, Times New Roman whatever
|
||||
/// the theme says. The run's own `w:lang` and the inherited fonts play no part. Papers set in
|
||||
/// `majorBidi` with Office's usual theme (`Arab` → Times New Roman) came out in Calibri Light.
|
||||
#[test]
|
||||
fn bidi_theme_slots_name_the_complex_script_font() {
|
||||
let font = |cs: &str| {
|
||||
fn bidi_theme_slots_follow_the_theme_font_language() {
|
||||
let tnr = "Times New Roman";
|
||||
// (themeFontLang, a:cs, Arab entries present, run lang) → (majorBidi, minorBidi)
|
||||
let cases: &[(Option<&str>, &str, bool, &str, [&str; 2])] = &[
|
||||
(None, "", true, "", [tnr, tnr]),
|
||||
(None, "Arial", true, r#"<w:lang w:bidi="ar-SA"/>"#, [tnr, tnr]),
|
||||
(Some(r#"w:val="en-US""#), "Arial", true, "", [tnr, tnr]),
|
||||
(Some(r#"w:bidi="ar-SA""#), "", true, r#"<w:lang w:bidi="he-IL"/>"#, ["Georgia", "Trebuchet MS"]),
|
||||
(Some(r#"w:bidi="ar-SA""#), "Arial", true, "", ["Georgia", "Trebuchet MS"]),
|
||||
(Some(r#"w:bidi="he-IL""#), "", true, "", ["Courier New", "Comic Sans MS"]),
|
||||
(Some(r#"w:bidi="ur-PK""#), "Arial", true, "", ["Georgia", "Trebuchet MS"]),
|
||||
(Some(r#"w:bidi="pa-PK""#), "Arial", true, "", ["Georgia", "Trebuchet MS"]),
|
||||
(Some(r#"w:bidi="pa-IN""#), "Arial", true, "", ["Arial", "Arial"]),
|
||||
(Some(r#"w:bidi="fr-FR""#), "Arial", true, "", ["Verdana", "Impact"]),
|
||||
(Some(r#"w:bidi="th-TH""#), "Arial", true, "", ["Arial", "Arial"]),
|
||||
(Some(r#"w:bidi="th-TH""#), "", true, "", [tnr, tnr]),
|
||||
(Some(r#"w:bidi="ar-SA""#), "Arial", false, "", ["Arial", "Arial"]),
|
||||
];
|
||||
for (i, (lang, cs, arab, run_lang, want)) in cases.iter().enumerate() {
|
||||
let script = |f: &str| if *arab { format!(r#"<a:font script="Arab" typeface="{f}"/>"#) } else { String::new() };
|
||||
let theme = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="T"><a:themeElements><a:fontScheme name="F"><a:majorFont><a:latin typeface="Cambria"/><a:ea typeface=""/><a:cs typeface="{cs}"/></a:majorFont><a:minorFont><a:latin typeface="Calibri"/><a:ea typeface=""/><a:cs typeface="{cs}"/></a:minorFont></a:fontScheme></a:themeElements></a:theme>"#
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="T"><a:themeElements><a:fontScheme name="F"><a:majorFont><a:latin typeface="Verdana"/><a:ea typeface=""/><a:cs typeface="{cs}"/>{}<a:font script="Hebr" typeface="Courier New"/></a:majorFont><a:minorFont><a:latin typeface="Impact"/><a:ea typeface=""/><a:cs typeface="{cs}"/>{}<a:font script="Hebr" typeface="Comic Sans MS"/></a:minorFont></a:fontScheme></a:themeElements></a:theme>"#,
|
||||
script("Georgia"),
|
||||
script("Trebuchet MS")
|
||||
);
|
||||
let rels = [("rId9", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme", "theme/theme1.xml")];
|
||||
let body = r#"<w:p><w:r><w:rPr><w:rFonts w:asciiTheme="minorBidi" w:hAnsiTheme="minorBidi"/></w:rPr><w:t>x</w:t></w:r></w:p>"#;
|
||||
let d = wordcraft_docx::read(&docx(body, &rels, &[("word/theme/theme1.xml", &theme)])).unwrap();
|
||||
paras(&d)[0].runs[0].props.font.clone()
|
||||
};
|
||||
assert_eq!(font(""), None);
|
||||
assert_eq!(font("Times New Roman").as_deref(), Some("Times New Roman"));
|
||||
let font_lang = lang.map(|l| format!("<w:themeFontLang {l}/>")).unwrap_or_default();
|
||||
let settings = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:settings {W_NS}>{font_lang}</w:settings>"#);
|
||||
let styles = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles {W_NS}><w:docDefaults><w:rPrDefault><w:rPr><w:rFonts w:ascii="Andale Mono" w:hAnsi="Andale Mono" w:cs="Andale Mono"/><w:lang w:val="en-US" w:bidi="ar-SA"/></w:rPr></w:rPrDefault></w:docDefaults></w:styles>"#
|
||||
);
|
||||
let run = |slot: &str| {
|
||||
format!(r#"<w:p><w:r><w:rPr><w:rFonts w:asciiTheme="{slot}" w:hAnsiTheme="{slot}"/>{run_lang}</w:rPr><w:t>x</w:t></w:r></w:p>"#)
|
||||
};
|
||||
let body = [run("majorBidi"), run("minorBidi"), run("majorHAnsi")].concat();
|
||||
let rels = [
|
||||
("rId7", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings", "settings.xml"),
|
||||
("rId8", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles", "styles.xml"),
|
||||
("rId9", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme", "theme/theme1.xml"),
|
||||
];
|
||||
let parts = [("word/theme/theme1.xml", theme.as_str()), ("word/settings.xml", settings.as_str()), ("word/styles.xml", styles.as_str())];
|
||||
let d = wordcraft_docx::read(&docx(&body, &rels, &parts)).unwrap();
|
||||
let fonts: Vec<_> = paras(&d).iter().map(|p| p.runs[0].props.font.clone().unwrap_or_default()).collect();
|
||||
assert_eq!(fonts, [want[0], want[1], "Verdana"], "case {i}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The font table's family classes: Word draws a missing roman font in a serif face.
|
||||
@@ -3540,3 +3717,200 @@ fn equation_in_alternate_content_is_read() {
|
||||
assert_eq!(equations(p), vec![("x+1".to_string(), false)]);
|
||||
assert!(!p.plain_text().contains("[picture]"));
|
||||
}
|
||||
|
||||
/// #1500: gradient fills and transparency on shapes (the cases in the issue), from the shape's
|
||||
/// own properties, the theme's fill styles (`a:fillRef` 3 and 1003, as Word's cover pages use),
|
||||
/// a group's fill (`a:grpFill`), an outline gradient and VML's `v:fill`. Each is read as the
|
||||
/// file states it and survives saving.
|
||||
#[test]
|
||||
fn shape_gradients_and_transparency_are_read_and_survive_saving() {
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::fill::{Gradient, GradientFlip, GradientKind, PathShape};
|
||||
use wordcraft_doc::wordart::GradStop;
|
||||
let theme = r#"<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="T"><a:themeElements><a:fmtScheme name="T"><a:fillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:gradFill rotWithShape="1"><a:gsLst><a:gs pos="0"><a:schemeClr val="phClr"/></a:gs><a:gs pos="100000"><a:schemeClr val="phClr"><a:shade val="50000"/></a:schemeClr></a:gs></a:gsLst><a:lin ang="5400000" scaled="0"/></a:gradFill></a:fillStyleLst><a:bgFillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:gradFill rotWithShape="1"><a:gsLst><a:gs pos="0"><a:schemeClr val="phClr"/></a:gs><a:gs pos="100000"><a:srgbClr val="000000"/></a:gs></a:gsLst><a:path path="circle"><a:fillToRect l="50000" t="50000" r="50000" b="50000"/></a:path></a:gradFill></a:bgFillStyleLst></a:fmtScheme></a:themeElements></a:theme>"#;
|
||||
let rect = r#"<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>"#;
|
||||
let wsp = |sppr: &str, style: &str| anchored_wsp("S", 914400, 914400, &format!("{rect}{sppr}"), style, "<wps:cNvSpPr/>");
|
||||
let fill_ref = |idx: u32| {
|
||||
format!(
|
||||
r#"<wps:style><a:lnRef idx="0"><a:srgbClr val="4080C0"/></a:lnRef><a:fillRef idx="{idx}"><a:srgbClr val="4080C0"/></a:fillRef><a:effectRef idx="0"><a:srgbClr val="4080C0"/></a:effectRef><a:fontRef idx="minor"/></wps:style>"#
|
||||
)
|
||||
};
|
||||
let gs = |pos: u32, c: &str| format!(r#"<a:gs pos="{pos}"><a:srgbClr val="{c}"/></a:gs>"#);
|
||||
let shapes = [
|
||||
// A: 30%-opaque white.
|
||||
wsp(r#"<a:solidFill><a:srgbClr val="FFFFFF"><a:alpha val="30000"/></a:srgbClr></a:solidFill>"#, ""),
|
||||
// B: two stops top to bottom, the second 40% opaque.
|
||||
wsp(&format!(r#"<a:gradFill rotWithShape="1"><a:gsLst>{}<a:gs pos="100000"><a:srgbClr val="0000FF"><a:alpha val="40000"/></a:srgbClr></a:gs></a:gsLst><a:lin ang="5400000" scaled="1"/></a:gradFill>"#, gs(0, "FF0000")), ""),
|
||||
// C: three stops at 45°.
|
||||
wsp(&format!(r#"<a:gradFill><a:gsLst>{}{}{}</a:gsLst><a:lin ang="2700000" scaled="0"/></a:gradFill>"#, gs(0, "FF0000"), gs(50000, "00FF00"), gs(100000, "0000FF")), ""),
|
||||
// D: circular from the centre.
|
||||
wsp(&format!(r#"<a:gradFill rotWithShape="1"><a:gsLst>{}{}</a:gsLst><a:path path="circle"><a:fillToRect l="50000" t="50000" r="50000" b="50000"/></a:path></a:gradFill>"#, gs(0, "FFFFFF"), gs(100000, "000000")), ""),
|
||||
// E: rectangular from a corner, flipped and tiled, not turning with the shape.
|
||||
wsp(&format!(r#"<a:gradFill flip="xy" rotWithShape="0"><a:gsLst>{}{}</a:gsLst><a:path path="rect"><a:fillToRect l="100000" t="100000" r="0" b="0"/></a:path><a:tileRect l="-100000" t="-100000" r="0" b="0"/></a:gradFill>"#, gs(0, "FFFFFF"), gs(100000, "000000")), ""),
|
||||
// F, G: the theme's fill style 3 and background style 3 in the reference's colour.
|
||||
wsp("", &fill_ref(3)),
|
||||
wsp("", &fill_ref(1003)),
|
||||
// H: an outline gradient.
|
||||
wsp(&format!(r#"<a:noFill/><a:ln w="12700"><a:gradFill><a:gsLst>{}{}{}</a:gsLst><a:lin ang="0" scaled="0"/></a:gradFill></a:ln>"#, gs(0, "FF0000"), gs(50000, "00FF00"), gs(100000, "0000FF")), ""),
|
||||
]
|
||||
.concat();
|
||||
let member = |id: u32, paint: &str| {
|
||||
format!(
|
||||
r#"<wps:wsp><wps:cNvPr id="{id}" name="M{id}"/><wps:cNvSpPr/><wps:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="914400" cy="914400"/></a:xfrm>{rect}{paint}</wps:spPr><wps:bodyPr/></wps:wsp>"#
|
||||
)
|
||||
};
|
||||
// I: a group with a gradient fill; one member takes it (`a:grpFill`), one has its own, and
|
||||
// one in a nested group without a fill of its own takes the outer group's.
|
||||
let group = format!(
|
||||
r#"<w:r><w:drawing><wp:inline><wp:extent cx="914400" cy="914400"/><wp:docPr id="9" name="G"/><a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup"><wpg:wgp><wpg:cNvGrpSpPr/><wpg:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="914400" cy="914400"/><a:chOff x="0" y="0"/><a:chExt cx="914400" cy="914400"/></a:xfrm><a:gradFill><a:gsLst>{}{}</a:gsLst><a:lin ang="0" scaled="0"/></a:gradFill></wpg:grpSpPr>{}{}<wpg:grpSp><wpg:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="914400" cy="914400"/><a:chOff x="0" y="0"/><a:chExt cx="914400" cy="914400"/></a:xfrm></wpg:grpSpPr>{}</wpg:grpSp></wpg:wgp></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#,
|
||||
gs(0, "112233"),
|
||||
gs(100000, "445566"),
|
||||
member(10, "<a:grpFill/>"),
|
||||
member(11, r#"<a:solidFill><a:srgbClr val="00FF00"/></a:solidFill>"#),
|
||||
member(12, "<a:grpFill/>")
|
||||
);
|
||||
// VML: a linear gradient to `color2`, half opaque.
|
||||
let vml = r##"<w:r><w:pict><v:rect style="width:72pt;height:72pt" fillcolor="#ff0000" stroked="f"><v:fill type="gradient" color2="#0000ff" opacity="0.5"/></v:rect></w:pict></w:r>"##;
|
||||
let bytes = docx(&format!("<w:p>{shapes}{group}{vml}</w:p>"), &[("rId2", "theme", "theme/theme1.xml")], &[("word/theme/theme1.xml", theme)]);
|
||||
|
||||
let stop = |pos: f32, c: Rgb, transparency: f32| GradStop { pos, color: c, transparency };
|
||||
let (red, green, blue, white, black) = (Rgb(255, 0, 0), Rgb(0, 255, 0), Rgb(0, 0, 255), Rgb::WHITE, Rgb::BLACK);
|
||||
let grad = |stops: Vec<GradStop>, kind: GradientKind, rot: bool| {
|
||||
Fill::Gradient(Arc::new(Gradient { stops, kind, tile: None, rot_with_shape: rot, flip: GradientFlip::None }))
|
||||
};
|
||||
let lin = |angle: f32, scaled: bool| GradientKind::Linear { angle, scaled };
|
||||
let centre = GradientKind::Path { shape: PathShape::Circle, focus: [50.0; 4] };
|
||||
let ph = Rgb(0x40, 0x80, 0xC0);
|
||||
let expected = [
|
||||
Some(Fill::Solid { color: white, transparency: 70.0 }),
|
||||
Some(grad(vec![stop(0.0, red, 0.0), stop(100.0, blue, 60.0)], lin(90.0, true), true)),
|
||||
Some(grad(vec![stop(0.0, red, 0.0), stop(50.0, green, 0.0), stop(100.0, blue, 0.0)], lin(45.0, false), false)),
|
||||
Some(grad(vec![stop(0.0, white, 0.0), stop(100.0, black, 0.0)], centre, true)),
|
||||
Some(Fill::Gradient(Arc::new(Gradient {
|
||||
stops: vec![stop(0.0, white, 0.0), stop(100.0, black, 0.0)],
|
||||
kind: GradientKind::Path { shape: PathShape::Rect, focus: [100.0, 100.0, 0.0, 0.0] },
|
||||
tile: Some([-100.0, -100.0, 0.0, 0.0]),
|
||||
rot_with_shape: false,
|
||||
flip: GradientFlip::Xy,
|
||||
}))),
|
||||
Some(grad(vec![stop(0.0, ph, 0.0), stop(100.0, Rgb(0x20, 0x40, 0x60), 0.0)], lin(90.0, false), true)),
|
||||
Some(grad(vec![stop(0.0, ph, 0.0), stop(100.0, black, 0.0)], centre, true)),
|
||||
None,
|
||||
];
|
||||
let check = |d: &Document| {
|
||||
let objs = ¶s(d)[0].objects;
|
||||
assert_eq!(objs.len(), expected.len() + 2);
|
||||
for (i, want) in expected.iter().enumerate() {
|
||||
let InlineObject::Shape { fill, .. } = &objs[i] else { panic!("{:?}", objs[i]) };
|
||||
assert_eq!(fill, want, "shape {i}");
|
||||
}
|
||||
let InlineObject::Shape { stroke, extra, .. } = &objs[7] else { panic!() };
|
||||
let outline = grad(vec![stop(0.0, red, 0.0), stop(50.0, green, 0.0), stop(100.0, blue, 0.0)], lin(0.0, false), false);
|
||||
assert_eq!((*stroke, extra.outline_fill.as_ref()), (Some(green), Some(&outline)), "outline drawn in its middle stop");
|
||||
let InlineObject::Group { children, .. } = &objs[8] else { panic!("{:?}", objs[8]) };
|
||||
let fills: Vec<_> = children.iter().map(|c| if let InlineObject::Shape { fill, .. } = &c.obj { fill.clone() } else { None }).collect();
|
||||
let group_fill = grad(vec![stop(0.0, Rgb(0x11, 0x22, 0x33), 0.0), stop(100.0, Rgb(0x44, 0x55, 0x66), 0.0)], lin(0.0, false), false);
|
||||
assert_eq!(fills, [Some(group_fill.clone()), Some(Fill::solid(green)), Some(group_fill)], "a:grpFill takes the group's fill");
|
||||
let InlineObject::Shape { fill, .. } = &objs[9] else { panic!("{:?}", objs[9]) };
|
||||
assert_eq!(fill.as_ref(), Some(&grad(vec![stop(0.0, red, 50.0), stop(100.0, blue, 0.0)], lin(90.0, false), true)), "VML gradient");
|
||||
};
|
||||
let d = wordcraft_docx::read(&bytes).unwrap();
|
||||
check(&d);
|
||||
check(&wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap());
|
||||
}
|
||||
|
||||
/// #3415: a table style's row and column band sizes (`w:tblStyleRowBandSize`,
|
||||
/// `w:tblStyleColBandSize`, ECMA-376 §17.7.6.5 and §17.7.6.7) are read, zero included, and left
|
||||
/// unset when the file has none, which is how Word reads it: no bands. A style based on one with
|
||||
/// sizes inherits them.
|
||||
#[test]
|
||||
fn table_style_band_sizes_read_as_word_reads_them() {
|
||||
let band = r#"<w:tblStylePr w:type="band1Horz"><w:tcPr><w:shd w:val="clear" w:color="auto" w:fill="C1E4F5"/></w:tcPr></w:tblStylePr>"#;
|
||||
let styles = format!(
|
||||
r#"<w:styles {W_NS}>
|
||||
<w:style w:type="table" w:default="1" w:styleId="TableNormal"><w:name w:val="Normal Table"/></w:style>
|
||||
<w:style w:type="table" w:styleId="Missing"><w:name w:val="Missing"/><w:basedOn w:val="TableNormal"/>{band}</w:style>
|
||||
<w:style w:type="table" w:styleId="One"><w:name w:val="One"/><w:basedOn w:val="TableNormal"/><w:tblPr><w:tblStyleRowBandSize w:val="1"/><w:tblStyleColBandSize w:val="1"/></w:tblPr>{band}</w:style>
|
||||
<w:style w:type="table" w:styleId="Zero"><w:name w:val="Zero"/><w:basedOn w:val="One"/><w:tblPr><w:tblStyleRowBandSize w:val="0"/></w:tblPr></w:style>
|
||||
<w:style w:type="table" w:styleId="Child"><w:name w:val="Child"/><w:basedOn w:val="One"/>{band}</w:style>
|
||||
</w:styles>"#
|
||||
);
|
||||
let d = wordcraft_docx::read(&docx("<w:p/>", &[("rId1", "styles", "styles.xml")], &[("word/styles.xml", &styles)])).unwrap();
|
||||
let sizes = |id: &str| d.styles.table_style(id).map(|t| (t.parts.band_size, t.parts.col_band_size));
|
||||
assert_eq!(sizes("Missing"), Some((None, None)), "no sizes: no bands, as in Word");
|
||||
assert_eq!(sizes("One"), Some((Some(1), Some(1))));
|
||||
assert_eq!(sizes("Zero"), Some((Some(0), Some(1))));
|
||||
assert_eq!(sizes("Child"), Some((Some(1), Some(1))), "inherited");
|
||||
// Written back as read: still no sizes where there were none.
|
||||
let xml = text(&unzip(&wordcraft_docx::write(&d).unwrap()), "word/styles.xml");
|
||||
let style = |id: &str| {
|
||||
let start = xml.find(&format!(r#"w:styleId="{id}""#)).unwrap();
|
||||
xml[start..start + xml[start..].find("</w:style>").unwrap()].to_string()
|
||||
};
|
||||
assert!(!style("Missing").contains("BandSize"), "{}", style("Missing"));
|
||||
assert!(style("One").contains(r#"<w:tblStyleRowBandSize w:val="1"/><w:tblStyleColBandSize w:val="1"/>"#), "{}", style("One"));
|
||||
assert!(style("Zero").contains(r#"<w:tblStyleRowBandSize w:val="0"/>"#), "{}", style("Zero"));
|
||||
}
|
||||
|
||||
/// #3415: a document saved with one of our banded table styles tells Word its band sizes, so Word
|
||||
/// shades the same rows we do (it reads a style without them as having no bands).
|
||||
#[test]
|
||||
fn our_banded_table_styles_write_their_band_sizes() {
|
||||
let d = Document::new();
|
||||
let xml = text(&unzip(&wordcraft_docx::write(&d).unwrap()), "word/styles.xml");
|
||||
let start = xml.find(r#"w:styleId="GridTable4AccentBlue""#).expect("Grid Table 4 – Blue is written");
|
||||
let style = &xml[start..start + xml[start..].find("</w:style>").unwrap()];
|
||||
assert!(style.contains(r#"<w:tblPr><w:tblStyleRowBandSize w:val="1"/><w:tblStyleColBandSize w:val="1"/>"#), "{style}");
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
assert_eq!(back.styles.table_style("GridTable4AccentBlue").map(|t| t.parts.band_size), Some(Some(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vml_shape_wrap_side_and_anchor_case_insensitivity() {
|
||||
let body = r##"<w:p>
|
||||
<w:r>
|
||||
<w:pict xmlns:w10="urn:schemas-microsoft-com:office:word">
|
||||
<v:rect style="position:absolute;width:100pt;height:50pt;mso-position-horizontal-relative:Page;mso-position-vertical-relative:Margin" filled="False" stroked="t">
|
||||
<w10:wrap type="Square" side="left"/>
|
||||
</v:rect>
|
||||
</w:pict>
|
||||
</w:r>
|
||||
</w:p>"##;
|
||||
let doc = wordcraft_docx::read(&docx(body, &[], &[])).unwrap();
|
||||
let p = doc.body.iter().find_map(|b| b.as_para()).unwrap();
|
||||
let shape = match p.objects.first() {
|
||||
Some(InlineObject::Shape { fill, float, .. }) => (fill, float),
|
||||
o => panic!("expected shape, got {o:?}"),
|
||||
};
|
||||
assert_eq!(*shape.0, None);
|
||||
assert_eq!(shape.1.wrap, wordcraft_doc::para::Wrap::Square);
|
||||
assert_eq!(shape.1.wrap_text, wordcraft_doc::para::WrapText::Left);
|
||||
assert_eq!(shape.1.h_rel, wordcraft_doc::para::Anchor::Page);
|
||||
assert_eq!(shape.1.v_rel, wordcraft_doc::para::Anchor::Margin);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vml_textbox_style_anchor_and_fit() {
|
||||
let body = r#"<w:p>
|
||||
<w:r>
|
||||
<w:pict>
|
||||
<v:rect style="width:100pt;height:50pt">
|
||||
<v:textbox style="v-text-anchor:Middle;mso-fit-shape-to-text:True">
|
||||
<w:txbxContent>
|
||||
<w:p><w:r><w:t>Centered text</w:t></w:r></w:p>
|
||||
</w:txbxContent>
|
||||
</v:textbox>
|
||||
</v:rect>
|
||||
</w:pict>
|
||||
</w:r>
|
||||
</w:p>"#;
|
||||
let doc = wordcraft_docx::read(&docx(body, &[], &[])).unwrap();
|
||||
let p = doc.body.first().unwrap().as_para().unwrap();
|
||||
let story_id = match p.objects.first() {
|
||||
Some(InlineObject::Shape { story: Some(id), .. }) => *id,
|
||||
o => panic!("expected shape with story, got {o:?}"),
|
||||
};
|
||||
let tb = doc.parts.get(&story_id).unwrap();
|
||||
assert_eq!(tb.body.anchor, wordcraft_doc::props::VAlign::Center);
|
||||
assert!(tb.body.auto_fit);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Round-trip tests: build documents programmatically → write → read → compare.
|
||||
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::fill::Fill;
|
||||
|
||||
use wordcraft_doc::numbering::ListKind;
|
||||
use wordcraft_doc::para::{Anchor, Float, FloatAlign, NoteKind, ShapeKind, Wrap};
|
||||
@@ -22,6 +23,21 @@ fn paras(doc: &Document) -> Vec<&Paragraph> {
|
||||
doc.body.iter().filter_map(|b| b.as_para()).collect()
|
||||
}
|
||||
|
||||
/// `doc` with the stacking order of its body's floating objects cleared: saving gives objects
|
||||
/// without one an order (`relativeHeight`), so tests about other properties compare without it.
|
||||
fn unstacked(mut doc: Document) -> Document {
|
||||
for b in &mut doc.body {
|
||||
if let Block::Para(p) = std::sync::Arc::make_mut(b) {
|
||||
for o in &mut p.objects {
|
||||
if let Some(f) = o.float_mut() {
|
||||
f.z = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
doc
|
||||
}
|
||||
|
||||
fn part_text(doc: &Document, id: Option<u32>) -> String {
|
||||
id.and_then(|i| doc.parts.get(&i))
|
||||
.map(|p| p.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect::<Vec<_>>().join("\n"))
|
||||
@@ -113,6 +129,7 @@ fn every_char_prop_round_trips() {
|
||||
bold_cs: Some(false),
|
||||
italic_cs: Some(true),
|
||||
lang_bidi: Some("fa-IR".into()),
|
||||
lang_ea: Some("ja-JP".into()),
|
||||
east_asian_layout: Some(wordcraft_doc::props::EastAsianLayout {
|
||||
id: Some(3),
|
||||
combine: Some(true),
|
||||
@@ -457,7 +474,7 @@ fn images_round_trip() {
|
||||
p.insert_object(end, f.clone(), &CharProps::default()).unwrap();
|
||||
}
|
||||
d.body = vec![para_block(p)];
|
||||
let r = rt(&d);
|
||||
let r = unstacked(rt(&d));
|
||||
let got = paras(&r)[0];
|
||||
let mut expect = vec![inline];
|
||||
expect.extend(floats);
|
||||
@@ -1101,7 +1118,7 @@ fn shapes_textboxes_equations_dropcaps_round_trip() {
|
||||
kind: ShapeKind::TextBox,
|
||||
w: 144.0,
|
||||
h: 72.0,
|
||||
fill: Some(Rgb(255, 255, 200)),
|
||||
fill: Some(Fill::solid(Rgb(255, 255, 200))),
|
||||
stroke: Some(Rgb(0, 0, 0)),
|
||||
stroke_width: 1.0,
|
||||
float: Float { wrap: Wrap::Square, h_rel: Anchor::Margin, v_rel: Anchor::Paragraph, x: 10.0, y: 20.0, dist: 0.0, ..Default::default() },
|
||||
@@ -1139,8 +1156,8 @@ fn shapes_textboxes_equations_dropcaps_round_trip() {
|
||||
match &got[0].objects[0] {
|
||||
InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, story, .. } => {
|
||||
assert_eq!(
|
||||
(*kind, *w, *h, *fill, *stroke, *stroke_width),
|
||||
(ShapeKind::TextBox, 144.0, 72.0, Some(Rgb(255, 255, 200)), Some(Rgb(0, 0, 0)), 1.0)
|
||||
(*kind, *w, *h, fill.clone(), *stroke, *stroke_width),
|
||||
(ShapeKind::TextBox, 144.0, 72.0, Some(Fill::solid(Rgb(255, 255, 200))), Some(Rgb(0, 0, 0)), 1.0)
|
||||
);
|
||||
assert_eq!(float.wrap, Wrap::Square);
|
||||
assert_eq!(part_text(&r, *story), "Inside the box");
|
||||
@@ -1174,7 +1191,7 @@ fn groups_round_trip() {
|
||||
kind: ShapeKind::Ellipse,
|
||||
w: 50.0,
|
||||
h: 30.0,
|
||||
fill: Some(Rgb(200, 0, 0)),
|
||||
fill: Some(Fill::solid(Rgb(200, 0, 0))),
|
||||
stroke: None,
|
||||
stroke_width: 0.0,
|
||||
float: Float::default(),
|
||||
@@ -1199,7 +1216,7 @@ fn groups_round_trip() {
|
||||
kind: ShapeKind::TextBox,
|
||||
w: 100.0,
|
||||
h: 40.0,
|
||||
fill: Some(Rgb(255, 255, 255)),
|
||||
fill: Some(Fill::solid(Rgb(255, 255, 255))),
|
||||
stroke: Some(Rgb(0, 0, 0)),
|
||||
stroke_width: 0.75,
|
||||
float: Float::default(),
|
||||
@@ -1226,7 +1243,7 @@ fn groups_round_trip() {
|
||||
let end = p.len();
|
||||
p.insert_object(end, group, &CharProps::default()).unwrap();
|
||||
d.body = vec![para_block(p)];
|
||||
let r = rt(&d);
|
||||
let r = unstacked(rt(&d));
|
||||
let got = paras(&r);
|
||||
let InlineObject::Group { w, h, float: f, ch_w, ch_h, children, .. } = &got[0].objects[0] else { panic!("{:?}", got[0].objects) };
|
||||
assert_eq!((*w, *h, *ch_w, *ch_h), (240.0, 120.0, 200.0, 100.0));
|
||||
@@ -1261,7 +1278,7 @@ fn drawing_canvas_round_trips() {
|
||||
kind: ShapeKind::Star,
|
||||
w: 72.0,
|
||||
h: 72.0,
|
||||
fill: Some(Rgb(0xE0, 0xA0, 0x20)),
|
||||
fill: Some(Fill::solid(Rgb(0xE0, 0xA0, 0x20))),
|
||||
stroke: Some(Rgb(0x40, 0x40, 0x40)),
|
||||
stroke_width: 1.0,
|
||||
float: Float::default(),
|
||||
@@ -1270,7 +1287,11 @@ fn drawing_canvas_round_trips() {
|
||||
freeform: None,
|
||||
extra: Default::default(),
|
||||
};
|
||||
let style = CanvasStyle { fill: Some(Rgb(0xF0, 0xF4, 0xFF)), stroke: Some(Rgb(0x20, 0x40, 0x80)), stroke_width: 1.5 };
|
||||
let style = CanvasStyle {
|
||||
fill: Some(Fill::Solid { color: Rgb(0xF0, 0xF4, 0xFF), transparency: 25.0 }),
|
||||
stroke: Some(Rgb(0x20, 0x40, 0x80)),
|
||||
stroke_width: 1.5,
|
||||
};
|
||||
let canvas = InlineObject::Group {
|
||||
w: 432.0,
|
||||
h: 216.0,
|
||||
@@ -1278,7 +1299,7 @@ fn drawing_canvas_round_trips() {
|
||||
ch_w: 432.0,
|
||||
ch_h: 216.0,
|
||||
children: vec![GroupChild { x: 12.0, y: 18.0, obj: pic }, GroupChild { x: 300.0, y: 100.0, obj: star.clone() }],
|
||||
canvas: Some(style),
|
||||
canvas: Some(style.clone()),
|
||||
};
|
||||
let mut p = Paragraph::with_text("drawn ", CharProps::default());
|
||||
let end = p.len();
|
||||
@@ -1309,7 +1330,7 @@ fn rotated_drawing_canvas_round_trips() {
|
||||
kind: ShapeKind::Rectangle,
|
||||
w: 60.0,
|
||||
h: 40.0,
|
||||
fill: Some(Rgb(1, 2, 3)),
|
||||
fill: Some(Fill::solid(Rgb(1, 2, 3))),
|
||||
stroke: None,
|
||||
stroke_width: 0.0,
|
||||
float: Float { rot: 15.0, ..Default::default() },
|
||||
@@ -1325,7 +1346,7 @@ fn rotated_drawing_canvas_round_trips() {
|
||||
ch_w: 200.0,
|
||||
ch_h: 100.0,
|
||||
children: vec![GroupChild { x: 10.0, y: 20.0, obj: member.clone() }],
|
||||
canvas: Some(CanvasStyle { fill: Some(Rgb(0xF0, 0xF4, 0xFF)), stroke: None, stroke_width: 0.0 }),
|
||||
canvas: Some(CanvasStyle { fill: Some(Fill::solid(Rgb(0xF0, 0xF4, 0xFF))), stroke: None, stroke_width: 0.0 }),
|
||||
};
|
||||
let square = Float { wrap: Wrap::Square, ..Default::default() };
|
||||
let mut p = Paragraph::with_text("c", CharProps::default());
|
||||
@@ -1511,7 +1532,10 @@ fn watermark_roundtrips() {
|
||||
color: Rgb(0x80, 0x10, 0x20),
|
||||
diagonal: false,
|
||||
semitransparent: false,
|
||||
size: None,
|
||||
},
|
||||
// A box of its own (not Word's default for the page) is kept.
|
||||
Watermark { text: "COPY".into(), size: Some([300.0, 80.0]), ..Default::default() },
|
||||
] {
|
||||
let mut d = doc_with(vec![Paragraph::with_text("Body", CharProps::default())]);
|
||||
d.settings.watermark = Some(wm.clone());
|
||||
@@ -1911,11 +1935,11 @@ fn list_level_overrides_round_trip() {
|
||||
#[test]
|
||||
fn ink_and_freeforms_round_trip_as_custom_geometry() {
|
||||
use wordcraft_doc::freeform::{FreePath, Freeform, InkTool};
|
||||
let shape = |fill, stroke_width, wrap, f: Freeform| InlineObject::Shape {
|
||||
let shape = |fill: Option<Rgb>, stroke_width, wrap, f: Freeform| InlineObject::Shape {
|
||||
kind: ShapeKind::Freeform,
|
||||
w: f.w,
|
||||
h: f.h,
|
||||
fill,
|
||||
fill: fill.map(Fill::solid),
|
||||
stroke: Some(Rgb(0xC0, 0, 0)),
|
||||
stroke_width,
|
||||
float: Float { wrap, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x: 30.0, y: 12.0, ..Default::default() },
|
||||
@@ -1951,7 +1975,7 @@ fn ink_and_freeforms_round_trip_as_custom_geometry() {
|
||||
assert_eq!(xml.matches("<a:custGeom>").count(), 3, "{xml}");
|
||||
assert!(xml.contains(r#"<a:path w="762000" h="254000" fill="none"><a:moveTo><a:pt x="12700" y="12700"/></a:moveTo><a:lnTo>"#), "{xml}");
|
||||
assert!(xml.contains(r#"<a:alpha val="50000"/>"#) && xml.contains(r#"cap="rnd""#), "{xml}");
|
||||
let r = rt(&d);
|
||||
let r = unstacked(rt(&d));
|
||||
let objs = ¶s(&r)[0].objects;
|
||||
assert_eq!(objs.len(), 3);
|
||||
// Written in reverse (each inserted at the start): the triangle, the highlighter, the pen.
|
||||
@@ -1975,7 +1999,7 @@ fn shape_effects_round_trip() {
|
||||
kind: ShapeKind::RoundedRectangle,
|
||||
w: 100.0,
|
||||
h: 50.0,
|
||||
fill: Some(Rgb(0x15, 0x60, 0x82)),
|
||||
fill: Some(Fill::solid(Rgb(0x15, 0x60, 0x82))),
|
||||
stroke: None,
|
||||
stroke_width: 0.0,
|
||||
float: Float::default(),
|
||||
@@ -2051,7 +2075,7 @@ fn rotation_and_flips_round_trip() {
|
||||
kind,
|
||||
w: 80.0,
|
||||
h: 40.0,
|
||||
fill: Some(Rgb(1, 2, 3)),
|
||||
fill: Some(Fill::solid(Rgb(1, 2, 3))),
|
||||
stroke: None,
|
||||
stroke_width: 0.0,
|
||||
float,
|
||||
@@ -2108,7 +2132,7 @@ fn rotated_group_and_shadow_rotation_round_trip() {
|
||||
kind: ShapeKind::Rectangle,
|
||||
w: 60.0,
|
||||
h: 40.0,
|
||||
fill: Some(Rgb(1, 2, 3)),
|
||||
fill: Some(Fill::solid(Rgb(1, 2, 3))),
|
||||
stroke: None,
|
||||
stroke_width: 0.0,
|
||||
float: Float { rot: 20.0, ..Default::default() },
|
||||
@@ -2306,7 +2330,7 @@ fn wrap_polygons_round_trip() {
|
||||
kind,
|
||||
w: 80.0,
|
||||
h: 40.0,
|
||||
fill: Some(Rgb(1, 2, 3)),
|
||||
fill: Some(Fill::solid(Rgb(1, 2, 3))),
|
||||
stroke: None,
|
||||
stroke_width: 0.0,
|
||||
float,
|
||||
@@ -2332,7 +2356,7 @@ fn wrap_polygons_round_trip() {
|
||||
std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
|
||||
assert!(xml.contains(r#"<wp:wrapPolygon edited="1"><wp:start x="-300" y="0"/><wp:lineTo x="21600" y="1200"/>"#), "{xml}");
|
||||
assert!(xml.contains(r#"<wp:lineTo x="-300" y="0"/></wp:wrapPolygon>"#), "closed: {xml}");
|
||||
let r = wordcraft_docx::read(&bytes).expect("read");
|
||||
let r = unstacked(wordcraft_docx::read(&bytes).expect("read"));
|
||||
let got = paras(&r)[0];
|
||||
let floats: Vec<Float> = got.objects.iter().filter_map(|o| o.frame().map(|(_, _, f)| f.clone())).collect();
|
||||
assert_eq!(floats[0], edited);
|
||||
@@ -2430,7 +2454,7 @@ fn wrap_text_sides_round_trip() {
|
||||
kind: ShapeKind::Rectangle,
|
||||
w: 80.0,
|
||||
h: 40.0,
|
||||
fill: Some(Rgb(1, 2, 3)),
|
||||
fill: Some(Fill::solid(Rgb(1, 2, 3))),
|
||||
stroke: None,
|
||||
stroke_width: 0.0,
|
||||
float,
|
||||
@@ -2484,7 +2508,7 @@ fn alt_text_title_and_decorative_round_trip_for_every_object() {
|
||||
kind: ShapeKind::Ellipse,
|
||||
w: 80.0,
|
||||
h: 40.0,
|
||||
fill: Some(Rgb(1, 2, 3)),
|
||||
fill: Some(Fill::solid(Rgb(1, 2, 3))),
|
||||
stroke: None,
|
||||
stroke_width: 0.0,
|
||||
float: Float { alt, ..Default::default() },
|
||||
|
||||
@@ -196,3 +196,41 @@ fn hidden_drawings_stay_hidden() {
|
||||
assert_eq!((marked("wp:docPr"), marked("wps:cNvPr")), (3, 1), "{xml}");
|
||||
assert_eq!(hidden(&wordcraft_docx::read(&saved).unwrap()), want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picture_in_box_keeps_its_frame() {
|
||||
use wordcraft_doc::{Paragraph, Pos, StoryRef};
|
||||
|
||||
let mut d = Document::from_text("Anchor");
|
||||
let body = TextBody { anchor: VAlign::Center, inset: Some([0; 4]), ..Default::default() };
|
||||
let outer = text_box(&mut d, "Label", body);
|
||||
let id = outer.text_box().unwrap();
|
||||
let mut bytes = std::io::Cursor::new(Vec::new());
|
||||
image::RgbaImage::from_pixel(10, 4, image::Rgba([20, 40, 60, 255])).write_to(&mut bytes, image::ImageFormat::Png).unwrap();
|
||||
let media = d.add_media(bytes.into_inner(), "png");
|
||||
let image = InlineObject::Image {
|
||||
media: media.clone(),
|
||||
w: 90.0,
|
||||
h: 36.0,
|
||||
alt: "Synthetic marker".into(),
|
||||
float: Default::default(),
|
||||
crop: [0.0; 4],
|
||||
ole: None,
|
||||
};
|
||||
let mut p = Paragraph::new();
|
||||
p.insert_object(0, image, &Default::default()).unwrap();
|
||||
d.parts.get_mut(&id).unwrap().blocks.push(para_block(p));
|
||||
d.insert_object(&Pos::body(0, 0), outer, &Default::default()).unwrap();
|
||||
|
||||
let read = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
let ids = boxes(&read);
|
||||
assert_eq!(ids.len(), 1, "a nested picture must not replace its text box");
|
||||
let id = ids[0];
|
||||
assert_eq!(read.parts[&id].body, body);
|
||||
assert!(read.plain_text(StoryRef::Part(id)).starts_with("Label"));
|
||||
let p = read.para(StoryRef::Part(id), &wordcraft_doc::Path::top(1)).unwrap();
|
||||
assert!(matches!(&p.objects[0], InlineObject::Image { w, h, alt, .. }
|
||||
if *w == 90.0 && *h == 36.0 && alt == "Synthetic marker"));
|
||||
let p = read.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap();
|
||||
assert!(matches!(&p.objects[0], InlineObject::Shape { w, h, .. } if *w == 144.0 && *h == 72.0));
|
||||
}
|
||||
|
||||
@@ -175,3 +175,71 @@ fn section_break_keeps_an_explicit_range_and_toc_prefix_is_not_a_toc() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The paragraph's characters other than objects, each with the link it carries.
|
||||
fn linked_chars(p: &wordcraft_doc::Paragraph) -> Vec<(char, Option<String>)> {
|
||||
p.text.char_indices().filter(|(_, c)| *c != wordcraft_doc::para::OBJ).map(|(i, c)| (c, p.props_of_char(i).link.clone())).collect()
|
||||
}
|
||||
|
||||
/// #2174: Word for the web writes each table of contents entry as a `HYPERLINK` complex field
|
||||
/// around the heading's text, a tab and a `PAGEREF` field. The entry reads as linked text, as a
|
||||
/// `w:hyperlink` would: the tab is a tab (it reaches the dotted right tab stop) and the page
|
||||
/// number stays a field of its own, through a save.
|
||||
#[test]
|
||||
fn hyperlink_field_entries_read_as_linked_text_with_their_page_field() {
|
||||
let entry = |anchor: &str, text: &str, page: &str| {
|
||||
format!(
|
||||
r#"{}<w:r><w:t>{text}</w:t></w:r><w:r><w:tab/></w:r>{}<w:r><w:t>{page}</w:t></w:r>{END}{END}"#,
|
||||
start(&format!(r#"HYPERLINK \l "{anchor}" "#)),
|
||||
start(&format!(r#"PAGEREF {anchor} \h"#))
|
||||
)
|
||||
};
|
||||
let ppr = r#"<w:pPr><w:pStyle w:val="TOC1"/><w:tabs><w:tab w:val="right" w:leader="dot" w:pos="9015"/></w:tabs></w:pPr>"#;
|
||||
let body = format!(
|
||||
r#"<w:p>{ppr}{}{}</w:p><w:p>{ppr}{}{END}</w:p><w:p><w:r><w:t>After.</w:t></w:r></w:p>"#,
|
||||
start(r#"TOC \o \z \u \h"#),
|
||||
entry("_Toc1", "Intro", "1"),
|
||||
entry("_Toc2", "Title 2", "2")
|
||||
);
|
||||
let doc = wordcraft_docx::read(&package(&body)).unwrap();
|
||||
let saved = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap();
|
||||
for d in [&doc, &saved] {
|
||||
for (i, anchor, text, page) in [(0, "_Toc1", "Intro", "1"), (1, "_Toc2", "Title 2", "2")] {
|
||||
let p = d.body[i].as_para().unwrap();
|
||||
assert_eq!(p.plain_text(), format!("{text}\t{page}"));
|
||||
let link = Some(format!("#{anchor}"));
|
||||
assert_eq!(linked_chars(p), format!("{text}\t").chars().map(|c| (c, link.clone())).collect::<Vec<_>>());
|
||||
assert!(p.objects.iter().all(|o| !matches!(o, InlineObject::Field { instr, .. } if instr.starts_with("HYPERLINK"))));
|
||||
let pageref = format!(r#"PAGEREF {anchor} \h"#);
|
||||
assert!(p.objects.iter().any(|o| matches!(o, InlineObject::Field { instr, result, .. } if *instr == pageref && result == page)));
|
||||
}
|
||||
assert_eq!(d.field_ranges(StoryRef::Body).len(), 1);
|
||||
assert_eq!(d.body[2].as_para().unwrap().plain_text(), "After.");
|
||||
}
|
||||
}
|
||||
|
||||
/// A `HYPERLINK` field's target, whatever the field's spelling: a web address with a bookmark,
|
||||
/// quoted words with spaces, a tooltip or target frame before it, and a simple field. One with
|
||||
/// no target stays a field.
|
||||
#[test]
|
||||
fn hyperlink_field_targets_follow_the_field_code() {
|
||||
let simple = |code: &str| format!(r#"<w:p><w:fldSimple w:instr="{code}"><w:r><w:t>Go</w:t></w:r></w:fldSimple></w:p>"#);
|
||||
let complex = |code: &str| format!(r#"<w:p>{}<w:r><w:t>Go</w:t></w:r>{END}</w:p>"#, start(code));
|
||||
for (para, link) in [
|
||||
(complex(r#"HYPERLINK "https://example.org/a" \l "sec""#), Some("https://example.org/a#sec")),
|
||||
(complex(r#"hyperlink \o "A tip" \t "_blank" "https://example.org/""#), Some("https://example.org/")),
|
||||
(complex(r#"HYPERLINK "C:\\Docs\\My file.docx""#), Some(r"C:\Docs\My file.docx")),
|
||||
(simple(r#"HYPERLINK \l "Sec" \m"#), Some("#Sec")),
|
||||
(simple("HYPERLINK https://example.org/b"), Some("https://example.org/b")),
|
||||
(complex("HYPERLINK"), None),
|
||||
(complex(r#"HYPERLINKS "https://example.org/""#), None),
|
||||
] {
|
||||
let doc = wordcraft_docx::read(&package(¶)).unwrap();
|
||||
let p = doc.body[0].as_para().unwrap();
|
||||
assert_eq!(p.plain_text(), "Go");
|
||||
match link {
|
||||
Some(l) => assert_eq!(linked_chars(p), "Go".chars().map(|c| (c, Some(l.to_string()))).collect::<Vec<_>>(), "{para}"),
|
||||
None => assert!(matches!(p.objects.as_slice(), [InlineObject::Field { result, .. }] if result == "Go"), "{para}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,8 +183,8 @@ fn a_pict_of_a_rect_a_text_box_and_a_group_all_come_through() {
|
||||
assert_eq!(kinds(&ps[1].objects), [ShapeKind::TextBox]);
|
||||
let InlineObject::Group { children, .. } = &ps[2].objects[0] else { panic!("{:?}", ps[2].objects) };
|
||||
assert_eq!(kinds(&children.iter().map(|c| c.obj.clone()).collect::<Vec<_>>()), [ShapeKind::Rectangle], "the green rectangle is read");
|
||||
let InlineObject::Shape { fill, .. } = children[0].obj else { panic!() };
|
||||
assert_eq!(fill, Some(wordcraft_doc::Rgb(0x00, 0xff, 0x00)), "with its colour");
|
||||
let InlineObject::Shape { fill, .. } = children[0].obj.clone() else { panic!() };
|
||||
assert_eq!(fill, Some(wordcraft_doc::fill::Fill::solid(wordcraft_doc::Rgb(0x00, 0xff, 0x00))), "with its colour");
|
||||
// And it is still there after a save, which is what the round trip lost.
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
assert_eq!(paras(&back).iter().map(|p| p.objects.len()).collect::<Vec<_>>(), [1, 1, 1]);
|
||||
@@ -255,9 +255,9 @@ fn a_group_member_finds_its_shapetype_beside_the_group() {
|
||||
)));
|
||||
let InlineObject::Group { children, .. } = &objects(&d)[0] else { panic!("{:?}", objects(&d)) };
|
||||
assert_eq!(children.len(), 1, "the member is read");
|
||||
let InlineObject::Shape { kind, fill, .. } = children[0].obj else { panic!("{:?}", children[0].obj) };
|
||||
let InlineObject::Shape { kind, fill, .. } = children[0].obj.clone() else { panic!("{:?}", children[0].obj) };
|
||||
assert_eq!(kind, ShapeKind::Diamond, "the preset comes from the shapetype's o:spt");
|
||||
assert_eq!(fill, Some(wordcraft_doc::Rgb(0x00, 0x00, 0xff)), "and the fill from its fillcolor");
|
||||
assert_eq!(fill, Some(wordcraft_doc::fill::Fill::solid(wordcraft_doc::Rgb(0x00, 0x00, 0xff))), "and the fill from its fillcolor");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -267,6 +267,32 @@ fn a_top_level_shape_still_finds_its_shapetype() {
|
||||
r##"<v:shapetype id="_x0000_t4" o:spt="4" fillcolor="#00ff00"/>"##,
|
||||
r##"<v:shape style="width:40pt;height:40pt" type="#_x0000_t4"/>"##,
|
||||
)));
|
||||
let InlineObject::Shape { kind, fill, .. } = objects(&d)[0] else { panic!("{:?}", objects(&d)) };
|
||||
assert_eq!((kind, fill), (ShapeKind::Diamond, Some(wordcraft_doc::Rgb(0x00, 0xff, 0x00))));
|
||||
let InlineObject::Shape { kind, fill, .. } = objects(&d)[0].clone() else { panic!("{:?}", objects(&d)) };
|
||||
assert_eq!((kind, fill), (ShapeKind::Diamond, Some(wordcraft_doc::fill::Fill::solid(wordcraft_doc::Rgb(0x00, 0xff, 0x00)))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_preset_takes_its_gradient_from_the_shapetype() {
|
||||
// #1500: the shape states nothing but its shapetype; the shapetype's `v:fill` (a gradient to
|
||||
// `color2`, its opacity in 65536ths) is the shape's fill, and it survives saving.
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::fill::{Fill, Gradient, GradientFlip, GradientKind};
|
||||
use wordcraft_doc::wordart::GradStop;
|
||||
let d = read_body(&pict(concat!(
|
||||
r##"<v:shapetype id="_x0000_t4" o:spt="4" fillcolor="#ff0000"><v:fill type="gradient" color2="#0000ff" opacity="32768f"/></v:shapetype>"##,
|
||||
r##"<v:shape style="width:40pt;height:40pt" type="#_x0000_t4"/>"##,
|
||||
)));
|
||||
let stop = |pos: f32, color, transparency: f32| GradStop { pos, color, transparency };
|
||||
let want = Fill::Gradient(Arc::new(Gradient {
|
||||
stops: vec![stop(0.0, wordcraft_doc::Rgb(0xff, 0, 0), 50.0), stop(100.0, wordcraft_doc::Rgb(0, 0, 0xff), 0.0)],
|
||||
kind: GradientKind::Linear { angle: 90.0, scaled: false },
|
||||
tile: None,
|
||||
rot_with_shape: true,
|
||||
flip: GradientFlip::None,
|
||||
}));
|
||||
let saved = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
for doc in [&d, &saved] {
|
||||
let InlineObject::Shape { kind, fill, .. } = objects(doc)[0].clone() else { panic!("{:?}", objects(doc)) };
|
||||
assert_eq!((kind, fill), (ShapeKind::Diamond, Some(want.clone())));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,13 +23,14 @@ serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
regex-automata = { workspace = true }
|
||||
image = { workspace = true }
|
||||
zeroize = { workspace = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
# The browser clock for `now_iso` (`SystemTime::now()` panics on wasm32).
|
||||
[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies]
|
||||
# The browser clock for `now_iso` (`SystemTime::now()` panics on wasm32-unknown-unknown).
|
||||
js-sys = "0.3"
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
|
||||
@@ -30,6 +30,7 @@ File|Backstage|Version History|file.versions
|
||||
File|Backstage|Recover Unsaved|file.recover
|
||||
File|Backstage|New from Template|file.newFromTemplate
|
||||
File|Backstage|Save as Template|file.saveTemplate
|
||||
File|Backstage|Personal Templates|file.listTemplates
|
||||
Quick Access|Toolbar|Undo|edit.undo
|
||||
Quick Access|Toolbar|Undo List|edit.undoTo
|
||||
Quick Access|Toolbar|Redo|edit.redo
|
||||
@@ -56,6 +57,7 @@ Home|Font|Bold|format.bold
|
||||
Home|Font|Italic|format.italic
|
||||
Home|Font|Underline|format.underline
|
||||
Home|Font|Double Underline|format.doubleUnderline
|
||||
Home|Font|Underline Color|format.underlineColor
|
||||
Home|Font|Strikethrough|format.strikethrough
|
||||
Home|Font|Subscript|format.subscript
|
||||
Home|Font|Superscript|format.superscript
|
||||
@@ -455,6 +457,8 @@ Chart Design|Chart Layouts|Chart Title|chart.title
|
||||
Chart Design|Chart Layouts|Legend|chart.legend
|
||||
Chart Design|Chart Layouts|Data Labels|chart.dataLabels
|
||||
Shape Format|Shape Styles|Shape Fill|shape.fill
|
||||
Shape Format|Shape Styles|Shape Fill Gradient|shape.fill
|
||||
Shape Format|Shape Styles|Format Shape Pane|view.formatShapePane
|
||||
Shape Format|Shape Styles|Shape Outline|shape.outline
|
||||
Shape Format|Shape Styles|Shape Effects|shape.effects
|
||||
Shape Format|WordArt Styles|WordArt Quick Styles|wordArt.style
|
||||
@@ -474,6 +478,7 @@ Editing|Keyboard|New Paragraph|text.newParagraph
|
||||
Editing|Keyboard|Line Break|text.lineBreak
|
||||
Editing|Keyboard|Column Break|text.columnBreak
|
||||
Editing|Keyboard|Tab|text.tab
|
||||
Editing|Keyboard|Tab Character|text.insertTab
|
||||
Editing|Keyboard|Backspace|text.backspace
|
||||
Editing|Keyboard|Delete|text.delete
|
||||
Editing|Keyboard|Delete Previous Word|text.deleteWordBack
|
||||
@@ -515,6 +520,7 @@ Tools|Customize|Customize Ribbon|tools.customizeRibbon
|
||||
Tools|Customize|Customize Keyboard|tools.customizeKeyboard
|
||||
Tools|Protection|Encrypt with Password|file.encrypt
|
||||
Tools|Templates|Templates and Add-ins|tools.templates
|
||||
Tools|Views|Full Screen View|view.fullScreenView
|
||||
";
|
||||
|
||||
/// Catalog entries: (tab, group, feature, id).
|
||||
|
||||
@@ -58,7 +58,7 @@ fn insert(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let w = p::f32(v, "width").unwrap_or(432.0).clamp(MIN_SIDE, MAX_SIDE);
|
||||
let h = p::f32(v, "height").unwrap_or(216.0).clamp(MIN_SIDE, MAX_SIDE);
|
||||
let style = CanvasStyle {
|
||||
fill: p::str(v, "fill").and_then(wordcraft_doc::props::Rgb::parse),
|
||||
fill: p::str(v, "fill").and_then(wordcraft_doc::props::Rgb::parse).map(wordcraft_doc::fill::Fill::solid),
|
||||
stroke: p::str(v, "outline").and_then(wordcraft_doc::props::Rgb::parse),
|
||||
stroke_width: 0.75,
|
||||
};
|
||||
@@ -275,10 +275,14 @@ mod tests {
|
||||
// Fill and outline colour the canvas.
|
||||
s.run("shape.fill", &json!({"color": "FFF2CC"})).unwrap();
|
||||
s.run("shape.outline", &json!({"color": "1F4E79", "width": 1.5})).unwrap();
|
||||
let style = *canvas(&s).canvas_style().unwrap();
|
||||
let style = canvas(&s).canvas_style().unwrap().clone();
|
||||
assert_eq!(
|
||||
style,
|
||||
CanvasStyle { fill: wordcraft_doc::props::Rgb::parse("FFF2CC"), stroke: wordcraft_doc::props::Rgb::parse("1F4E79"), stroke_width: 1.5 }
|
||||
CanvasStyle {
|
||||
fill: wordcraft_doc::props::Rgb::parse("FFF2CC").map(wordcraft_doc::fill::Fill::solid),
|
||||
stroke: wordcraft_doc::props::Rgb::parse("1F4E79"),
|
||||
stroke_width: 1.5
|
||||
}
|
||||
);
|
||||
// Members move, resize and go within the canvas.
|
||||
s.run("canvas.member", &json!({"index": 1, "x": 10, "y": 20, "width": 30})).unwrap();
|
||||
|
||||
@@ -378,8 +378,33 @@ fn select_sentence(s: &mut Session, _: &Value) -> CmdResult {
|
||||
select_unit(s, Unit::Sentence)
|
||||
}
|
||||
|
||||
fn select_paragraph(s: &mut Session, _: &Value) -> CmdResult {
|
||||
select_unit(s, Unit::Paragraph)
|
||||
/// A triple-click (`cell: true`) on the last paragraph of a table cell selects the cell (Word
|
||||
/// takes its end mark too, which shades it), so its borders then move on their own
|
||||
/// (`table.resizeDrag`). Without `cell` (the ribbon's Select Paragraph) only the paragraph is selected.
|
||||
fn select_paragraph(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let r = select_unit(s, Unit::Paragraph);
|
||||
if v.get("cell").and_then(Value::as_bool) != Some(true) {
|
||||
return r;
|
||||
}
|
||||
let (a, b) = (s.sel.anchor.clone(), s.sel.focus.clone());
|
||||
let n = a.path.0.len();
|
||||
if a.story == b.story
|
||||
&& a.path.0.len() == b.path.0.len()
|
||||
&& let Some(tp) = a.path.0.get(..n.saturating_sub(3))
|
||||
&& !tp.is_empty()
|
||||
&& a.path.0.get(..n.saturating_sub(1)) == b.path.0.get(..n.saturating_sub(1))
|
||||
&& let (Some(&row), Some(&cell)) = (a.path.0.get(n.saturating_sub(3)), a.path.0.get(n.saturating_sub(2)))
|
||||
{
|
||||
let tp = wordcraft_doc::Path(tp.to_vec());
|
||||
let (row, cell) = (row as usize, cell as usize);
|
||||
let last = s.doc.table(a.story, &tp).and_then(|t| t.rows.get(row)?.cells.get(cell)).map(|c| c.blocks.len().saturating_sub(1));
|
||||
let only = last.is_some_and(|l| a.path.0.last().is_some_and(|&i| i as usize == l));
|
||||
let grid = s.doc.table(a.story, &tp).map(|t| super::table::grid_cols(t, row, cell));
|
||||
if let (true, Some(cols)) = (only, grid) {
|
||||
return super::table::select_range(s, a.story, &tp, (row, row), cols);
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// A click in the selection bar (#856): the whole line at `pos` (default: the caret's), its
|
||||
|
||||
@@ -8,6 +8,7 @@ use wordcraft_doc::para::InlineObject;
|
||||
use wordcraft_doc::props::{CharProps, TabAlign, TabLeader, TabStop};
|
||||
use wordcraft_doc::{Block, Document, Paragraph, Path, Pos, Source, StoryRef};
|
||||
|
||||
use super::references::FieldCode;
|
||||
use super::{delete_selection, sel_result};
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
|
||||
|
||||
@@ -312,12 +313,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
sel_result(s)
|
||||
})
|
||||
.params(r#"{"title"?: string}"#),
|
||||
CommandSpec::new("references.tableOfFigures", "Insert Table of Figures", "References › Captions", |s, v| {
|
||||
let label = p::str(v, "label").unwrap_or("Figure").to_string();
|
||||
generated_list(s, &format!("TOC \\c \"{label}\""), "")?;
|
||||
sel_result(s)
|
||||
})
|
||||
.params(r#"{"label"?: "Figure|Table|Equation"}"#),
|
||||
CommandSpec::new("references.tableOfFigures", "Insert Table of Figures", "References › Captions", table_of_figures).params(
|
||||
r#"{"label"?: "Figure|Table|Equation" or a caption label of your own, "includeLabel"?: bool, "pageNumbers"?: bool, "rightAlign"?: bool, "tabLeader"?: "dot|hyphen|underscore|none"}"#,
|
||||
),
|
||||
CommandSpec::new("references.updateFigures", "Update Table", "References › Captions", |s, _| {
|
||||
update_generated(s)?;
|
||||
sel_result(s)
|
||||
@@ -771,9 +769,43 @@ fn refresh_citations(s: &mut Session) -> Result<(), CmdError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The TOC field for `references.tableOfFigures` (ECMA-376 §17.16.5.68): `\c "<label>"` lists the
|
||||
/// captions that label's SEQ field numbers, `\a "<label>"` lists them without their label and
|
||||
/// number; `\n` leaves page numbers out and `\p " "` puts them right after the text.
|
||||
pub fn figures_instr(v: &Value) -> String {
|
||||
let label: String = p::str(v, "label").unwrap_or("Figure").chars().filter(|c| !matches!(c, '"' | '\\') && !c.is_control()).take(40).collect();
|
||||
let ident = seq_ident(&label);
|
||||
let ident = if ident.is_empty() { "Figure".to_string() } else { ident };
|
||||
let on = |k: &str| p::bool(v, k).unwrap_or(true);
|
||||
let mut s = format!("TOC \\{} \"{ident}\"", if on("includeLabel") { 'c' } else { 'a' });
|
||||
if !on("pageNumbers") {
|
||||
s.push_str(" \\n");
|
||||
} else if !on("rightAlign") {
|
||||
s.push_str(" \\p \" \"");
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// The SEQ identifier of a caption label: Word joins a multi-word label with underscores.
|
||||
fn seq_ident(label: &str) -> String {
|
||||
label.split_whitespace().collect::<Vec<_>>().join("_")
|
||||
}
|
||||
|
||||
fn table_of_figures(s: &mut Session, v: &Value) -> CmdResult {
|
||||
generated_list_with(s, &figures_instr(v), "", p::str(v, "tabLeader").map(super::references::leader))?;
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Generated lists (BIBLIOGRAPHY, INDEX, table of figures, TOA): a heading paragraph holding
|
||||
/// the field, then entry paragraphs marked with the "GeneratedEntry" style family.
|
||||
pub(crate) fn generated_list(s: &mut Session, instr: &str, title: &str) -> Result<(), CmdError> {
|
||||
generated_list_with(s, instr, title, None)
|
||||
}
|
||||
|
||||
/// [`generated_list`] whose entries take `leader`. The leader lives in the entries' tab stops (as
|
||||
/// in a table of contents) and an update keeps the first entry's, so a placeholder entry carries
|
||||
/// it through the rebuild, however the lists above it change.
|
||||
fn generated_list_with(s: &mut Session, instr: &str, title: &str, leader: Option<TabLeader>) -> Result<(), CmdError> {
|
||||
let at = delete_selection(s)?;
|
||||
if at.story != StoryRef::Body || at.path.depth() > 0 {
|
||||
return Err(CmdError::Failed("lists go in the main text".into()));
|
||||
@@ -787,6 +819,11 @@ pub(crate) fn generated_list(s: &mut Session, instr: &str, title: &str) -> Resul
|
||||
)?;
|
||||
let i = if s.doc.para_at(&at).is_some_and(|p| p.is_empty()) { at.path.last() } else { s.doc.split_paragraph(&at)?.path.last() };
|
||||
s.doc.insert_block(StoryRef::Body, &Path::top(i), Block::Para(head))?;
|
||||
if let Some(leader) = leader {
|
||||
let mut seed = Paragraph::new().styled(ENTRY_STYLE);
|
||||
seed.props.tabs = Some(vec![TabStop { pos: 0.0, align: TabAlign::Right, leader }]);
|
||||
s.doc.insert_block(StoryRef::Body, &Path::top(i.saturating_add(1)), Block::Para(seed))?;
|
||||
}
|
||||
update_generated(s)
|
||||
}
|
||||
|
||||
@@ -794,12 +831,83 @@ fn list_kind(p: &Paragraph) -> Option<String> {
|
||||
p.objects.iter().find_map(|o| match o {
|
||||
InlineObject::Field { instr, .. } => {
|
||||
let t = instr.trim_start();
|
||||
(t.starts_with("BIBLIOGRAPHY") || t.starts_with("INDEX") || t.starts_with("TOA") || t.starts_with("TOC \\c")).then(|| t.to_string())
|
||||
(t.starts_with("BIBLIOGRAPHY") || t.starts_with("INDEX") || t.starts_with("TOA") || figures_label(t).is_some()).then(|| t.to_string())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The caption label (SEQ identifier) a table-of-figures field lists, `\c` or `\a`.
|
||||
fn figures_label(instr: &str) -> Option<String> {
|
||||
let f = FieldCode::parse(instr);
|
||||
if !f.is_table_of_figures() {
|
||||
return None;
|
||||
}
|
||||
Some(f.arg('c').or_else(|| f.arg('a')).map_or_else(|| "Figure".to_string(), seq_ident))
|
||||
}
|
||||
|
||||
/// Whether `p` is a caption numbered by SEQ identifier `ident` (any case, as Word matches it). A
|
||||
/// caption typed by hand, without a SEQ field, goes by its label.
|
||||
fn captioned_as(p: &Paragraph, ident: &str) -> bool {
|
||||
if p.props.style.as_deref() != Some("Caption") {
|
||||
return false;
|
||||
}
|
||||
let seq = p.objects.iter().find_map(|o| match o {
|
||||
InlineObject::Field { instr, .. } => Some(FieldCode::parse(instr)).filter(|f| f.name == "SEQ"),
|
||||
_ => None,
|
||||
});
|
||||
match seq {
|
||||
Some(f) => f.args.first().is_some_and(|id| id.eq_ignore_ascii_case(ident)),
|
||||
None => caption_label(p, &p.plain_text()).is_some_and(|l| seq_ident(&l).eq_ignore_ascii_case(ident)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A caption's text without its label and number (`\a`): what follows its SEQ field, or for a
|
||||
/// caption typed by hand what follows the label and the number, less the separator.
|
||||
fn caption_body(p: &Paragraph, label: &str) -> String {
|
||||
let seq_end = p.object_offsets().into_iter().find_map(|o| match p.object_at(o) {
|
||||
Some(InlineObject::Field { instr, .. }) if FieldCode::parse(instr).name == "SEQ" => Some(o + wordcraft_doc::para::OBJ.len_utf8()),
|
||||
_ => None,
|
||||
});
|
||||
let rest = match seq_end {
|
||||
Some(end) => {
|
||||
let mut k = 0;
|
||||
let mut rest = String::new();
|
||||
for (at, c) in p.text.char_indices() {
|
||||
if c == wordcraft_doc::para::OBJ {
|
||||
if at >= end
|
||||
&& let Some(o) = p.objects.get(k)
|
||||
{
|
||||
rest.push_str(o.plain_text());
|
||||
}
|
||||
k += 1;
|
||||
} else if at >= end {
|
||||
rest.push(c);
|
||||
}
|
||||
}
|
||||
rest
|
||||
}
|
||||
None => {
|
||||
let text = p.plain_text();
|
||||
// The label in any case, as captions are matched (ASCII case keeps the byte length).
|
||||
let start = text.trim_start();
|
||||
let after = start
|
||||
.get(..label.len())
|
||||
.filter(|head| head.eq_ignore_ascii_case(label))
|
||||
.and_then(|_| start.get(label.len()..))
|
||||
.unwrap_or(&text)
|
||||
.trim_start();
|
||||
// The number: the word after the label, when it starts with a digit.
|
||||
match after.find(char::is_whitespace) {
|
||||
Some(n) if after.starts_with(|c: char| c.is_ascii_digit()) => after[n..].to_string(),
|
||||
_ if after.starts_with(|c: char| c.is_ascii_digit()) => String::new(),
|
||||
_ => after.to_string(),
|
||||
}
|
||||
}
|
||||
};
|
||||
rest.trim_start_matches(|c: char| c.is_whitespace() || matches!(c, ':' | '.' | '-' | '\u{2013}' | '\u{2014}')).trim_end().to_string()
|
||||
}
|
||||
|
||||
const ENTRY_STYLE: &str = "ListEntry";
|
||||
|
||||
/// Rebuild every generated list.
|
||||
@@ -817,7 +925,38 @@ pub fn update_generated(s: &mut Session) -> Result<(), CmdError> {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
// Remove old entries.
|
||||
// A table of figures from Word can hold the list it saved as the field's result, in the
|
||||
// field's own paragraph: the rebuilt entries replace it, keeping its leader.
|
||||
let mut saved_leader = None;
|
||||
if figures_label(&kind).is_some()
|
||||
&& let Ok(p) = s.doc.para_mut(StoryRef::Body, &Path::top(i))
|
||||
{
|
||||
let leader = p.props.tabs.as_ref().and_then(|t| t.last()).map(|t| t.leader);
|
||||
let mut cleared = false;
|
||||
for o in &mut p.objects {
|
||||
if let InlineObject::Field { instr, result, .. } = o
|
||||
&& !result.is_empty()
|
||||
&& figures_label(instr).is_some()
|
||||
{
|
||||
result.clear();
|
||||
cleared = true;
|
||||
}
|
||||
}
|
||||
if cleared {
|
||||
saved_leader = leader;
|
||||
p.touch();
|
||||
}
|
||||
}
|
||||
// Remove old entries, keeping their tab leader.
|
||||
let leader = s
|
||||
.doc
|
||||
.body
|
||||
.get(i + 1)
|
||||
.and_then(|b| b.as_para())
|
||||
.filter(|p| p.props.style.as_deref() == Some(ENTRY_STYLE))
|
||||
.and_then(|p| p.props.tabs.as_ref()?.last().map(|t| t.leader))
|
||||
.or(saved_leader)
|
||||
.unwrap_or(TabLeader::Dot);
|
||||
while s.doc.body.get(i + 1).and_then(|b| b.as_para()).is_some_and(|p| p.props.style.as_deref() == Some(ENTRY_STYLE)) {
|
||||
s.doc.remove_block(StoryRef::Body, &Path::top(i + 1))?;
|
||||
}
|
||||
@@ -830,7 +969,7 @@ pub fn update_generated(s: &mut Session) -> Result<(), CmdError> {
|
||||
para.props.indent_first = Some(-36.0);
|
||||
}
|
||||
if text.contains('\t') {
|
||||
para.props.tabs = Some(vec![TabStop { pos: width - 0.5, align: TabAlign::Right, leader: TabLeader::Dot }]);
|
||||
para.props.tabs = Some(vec![TabStop { pos: width - 0.5, align: TabAlign::Right, leader }]);
|
||||
}
|
||||
s.doc.insert_block(StoryRef::Body, &Path::top(i + 1 + k), Block::Para(para))?;
|
||||
}
|
||||
@@ -874,12 +1013,14 @@ fn entries_for(s: &mut Session, kind: &str) -> Vec<(String, bool)> {
|
||||
}
|
||||
// Collect marks with positions.
|
||||
let mut marks: Vec<(String, Pos)> = Vec::new();
|
||||
let caption_label = kind.split('"').nth(1).unwrap_or("Figure").to_string();
|
||||
let figures = figures_label(kind);
|
||||
let bare = FieldCode::parse(kind).has('a');
|
||||
for path in s.doc.para_paths(StoryRef::Body) {
|
||||
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
|
||||
if kind.starts_with("TOC \\c") {
|
||||
if p.props.style.as_deref() == Some("Caption") && p.plain_text().starts_with(&caption_label) {
|
||||
marks.push((p.plain_text(), Pos { story: StoryRef::Body, path: path.clone(), off: 0 }));
|
||||
if let Some(ident) = &figures {
|
||||
if captioned_as(p, ident) {
|
||||
let text = if bare { caption_body(p, &ident.replace('_', " ")) } else { p.plain_text() };
|
||||
marks.push((text, Pos { story: StoryRef::Body, path: path.clone(), off: 0 }));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -900,8 +1041,8 @@ fn entries_for(s: &mut Session, kind: &str) -> Vec<(String, bool)> {
|
||||
let mut v = Vec::new();
|
||||
for path in s.doc.para_paths(StoryRef::Body) {
|
||||
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
|
||||
if kind.starts_with("TOC \\c") {
|
||||
if p.props.style.as_deref() == Some("Caption") && p.plain_text().starts_with(&caption_label) {
|
||||
if let Some(ident) = &figures {
|
||||
if captioned_as(p, ident) {
|
||||
v.push(Pos { story: StoryRef::Body, path: path.clone(), off: 0 });
|
||||
}
|
||||
continue;
|
||||
@@ -923,8 +1064,11 @@ fn entries_for(s: &mut Session, kind: &str) -> Vec<(String, bool)> {
|
||||
x.1 = pg;
|
||||
}
|
||||
}
|
||||
if kind.starts_with("TOC \\c") {
|
||||
return with_pages.into_iter().map(|(t, p)| (format!("{t}\t{p}"), false)).collect();
|
||||
if figures.is_some() {
|
||||
// `\n` without page numbers; `\p` the separator before them (a tab by default).
|
||||
let f = FieldCode::parse(kind);
|
||||
let sep = f.arg('p').unwrap_or("\t");
|
||||
return with_pages.into_iter().map(|(t, p)| (if f.has('n') { t } else { format!("{t}{sep}{p}") }, false)).collect();
|
||||
}
|
||||
// Index / TOA: group by entry, sort, list pages.
|
||||
let lang = super::default_lang(s);
|
||||
@@ -1297,6 +1441,190 @@ mod tests {
|
||||
assert!(s.doc.plain_text(StoryRef::Body).ends_with("See Photo Plate I"), "{}", s.doc.plain_text(StoryRef::Body));
|
||||
}
|
||||
|
||||
/// The entries of every generated list, in order, with their last tab stop's leader.
|
||||
fn list_entries(s: &Session) -> Vec<(String, Option<TabLeader>)> {
|
||||
s.doc
|
||||
.body
|
||||
.iter()
|
||||
.filter_map(|b| b.as_para())
|
||||
.filter(|p| p.props.style.as_deref() == Some(ENTRY_STYLE))
|
||||
.map(|p| (p.plain_text(), p.props.tabs.as_ref().and_then(|t| t.last()).map(|t| t.leader)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// #3013: a table of figures for any caption label, so a document can have a list of figures
|
||||
/// and a list of tables; each keeps its own label and leader when updated.
|
||||
#[test]
|
||||
fn table_of_figures_for_each_caption_label() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Kiln\nPress\nRock\nTablet\n"));
|
||||
for (text, label) in [("Kiln", "Figure"), ("Press", "Table"), ("Rock", "Photo Plate"), ("Tablet", "Figure")] {
|
||||
s.run("select.text", &json!({"text": text})).unwrap();
|
||||
s.run("references.caption", &json!({"label": label, "text": text})).unwrap();
|
||||
}
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
s.run("references.tableOfFigures", &json!({"label": "Table", "tabLeader": "hyphen"})).unwrap();
|
||||
assert_eq!(list_entries(&s), [("Table 1: Press\t1".to_string(), Some(TabLeader::Hyphen))]);
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
s.run("text.newParagraph", &json!({})).unwrap();
|
||||
s.run("references.tableOfFigures", &json!({"label": "Photo Plate"})).unwrap();
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
s.run("text.newParagraph", &json!({})).unwrap();
|
||||
s.run("references.tableOfFigures", &json!({})).unwrap();
|
||||
let instrs: Vec<String> = fields(&s).into_iter().map(|(i, _)| i).filter(|i| i.starts_with("TOC")).collect();
|
||||
assert_eq!(instrs, [r#"TOC \c "Table""#, r#"TOC \c "Photo_Plate""#, r#"TOC \c "Figure""#]);
|
||||
let want = [
|
||||
("Table 1: Press\t1", TabLeader::Hyphen),
|
||||
("Photo Plate 1: Rock\t1", TabLeader::Dot),
|
||||
("Figure 1: Kiln\t1", TabLeader::Dot),
|
||||
("Figure 2: Tablet\t1", TabLeader::Dot),
|
||||
];
|
||||
let want: Vec<(String, Option<TabLeader>)> = want.iter().map(|(t, l)| (t.to_string(), Some(*l))).collect();
|
||||
assert_eq!(list_entries(&s), want);
|
||||
// Update Table rebuilds each list with its own label and keeps the leader.
|
||||
s.run("references.updateFigures", &json!({})).unwrap();
|
||||
assert_eq!(list_entries(&s), want);
|
||||
// A table of figures is not the table of contents.
|
||||
assert!(s.run("references.updateToc", &json!({})).is_ok());
|
||||
assert_eq!(list_entries(&s), want);
|
||||
}
|
||||
|
||||
/// #3013: Insert Table of Figures' options as TOC switches: `\a` without the label and number,
|
||||
/// `\n` without page numbers, `\p " "` with the page right after the text.
|
||||
#[test]
|
||||
fn table_of_figures_options() {
|
||||
assert_eq!(figures_instr(&json!({})), r#"TOC \c "Figure""#);
|
||||
assert_eq!(figures_instr(&json!({"label": "Table", "includeLabel": false})), r#"TOC \a "Table""#);
|
||||
assert_eq!(figures_instr(&json!({"pageNumbers": false})), r#"TOC \c "Figure" \n"#);
|
||||
assert_eq!(figures_instr(&json!({"rightAlign": false})), r#"TOC \c "Figure" \p " ""#);
|
||||
assert_eq!(figures_instr(&json!({"label": "\"\\ "})), r#"TOC \c "Figure""#, "an empty label is Figure");
|
||||
for (params, entry) in [
|
||||
(json!({"includeLabel": false}), "The kiln\t1"),
|
||||
(json!({"pageNumbers": false}), "Figure 1: The kiln"),
|
||||
(json!({"rightAlign": false, "includeLabel": false}), "The kiln 1"),
|
||||
] {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Kiln\n"));
|
||||
s.run("select.text", &json!({"text": "Kiln"})).unwrap();
|
||||
s.run("references.caption", &json!({"label": "Figure", "text": "The kiln"})).unwrap();
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
s.run("references.tableOfFigures", ¶ms).unwrap();
|
||||
let entries = list_entries(&s);
|
||||
assert_eq!(entries.iter().map(|(t, _)| t.as_str()).collect::<Vec<_>>(), [entry], "{params}");
|
||||
assert_eq!(entries.first().and_then(|(_, l)| *l).is_some(), entry.contains('\t'), "a tab stop only for a tab");
|
||||
}
|
||||
}
|
||||
|
||||
/// #3013: a table of figures from Word (`TOC \h \z \c "Figure"`, switches in any order) and
|
||||
/// hand-typed captions are listed (the label in any case); a caption whose label only starts
|
||||
/// with another's is not.
|
||||
#[test]
|
||||
fn table_of_figures_from_word_and_typed_captions() {
|
||||
let mut d = wordcraft_doc::Document::from_text("Figure 1: Typed\nFigures 2: Not a figure\nFigure 3 \u{2014} Dashed\nfigure 4: Lower\nList");
|
||||
for path in d.para_paths(StoryRef::Body).into_iter().take(4) {
|
||||
d.para_mut(StoryRef::Body, &path).unwrap().props.style = Some("Caption".into());
|
||||
}
|
||||
let mut s = Session::new(d);
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
s.run("text.newParagraph", &json!({})).unwrap();
|
||||
let at = s.sel.focus.clone();
|
||||
let field = InlineObject::Field {
|
||||
instr: r#"TOC \h \z \a "Figure""#.into(),
|
||||
result: String::new(),
|
||||
locked: false,
|
||||
dirty: false,
|
||||
code: false,
|
||||
form: String::new(),
|
||||
};
|
||||
s.doc.insert_object(&at, field, &CharProps::default()).unwrap();
|
||||
s.run("references.updateFigures", &json!({})).unwrap();
|
||||
let entries: Vec<String> = list_entries(&s).into_iter().map(|(t, _)| t).collect();
|
||||
assert_eq!(entries, ["Typed\t1", "Dashed\t1", "Lower\t1"]);
|
||||
}
|
||||
|
||||
/// #3013 review: the leader asked for lands on the new list even when a list above it is out
|
||||
/// of date and gains entries (moving the new one down) while every list is rebuilt.
|
||||
#[test]
|
||||
fn table_of_figures_leader_survives_a_stale_list_above() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Kiln\nPress\nRock\n"));
|
||||
for (text, label) in [("Kiln", "Figure"), ("Press", "Table")] {
|
||||
s.run("select.text", &json!({"text": text})).unwrap();
|
||||
s.run("references.caption", &json!({"label": label, "text": text})).unwrap();
|
||||
}
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
s.run("references.tableOfFigures", &json!({})).unwrap();
|
||||
s.run("select.text", &json!({"text": "Rock"})).unwrap();
|
||||
s.run("references.caption", &json!({"label": "Figure", "text": "Rock"})).unwrap();
|
||||
assert_eq!(list_entries(&s).len(), 1, "the list of figures is out of date");
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
s.run("text.newParagraph", &json!({})).unwrap();
|
||||
s.run("references.tableOfFigures", &json!({"label": "Table", "tabLeader": "hyphen"})).unwrap();
|
||||
let want = [("Figure 1: Kiln\t1", TabLeader::Dot), ("Figure 2: Rock\t1", TabLeader::Dot), ("Table 1: Press\t1", TabLeader::Hyphen)];
|
||||
let want: Vec<(String, Option<TabLeader>)> = want.iter().map(|(t, l)| (t.to_string(), Some(*l))).collect();
|
||||
assert_eq!(list_entries(&s), want);
|
||||
}
|
||||
|
||||
/// #3013 review: a table of figures from Word whose saved list sits in the field's result (one
|
||||
/// paragraph) is replaced by the rebuilt entries, not listed twice, and keeps its leader.
|
||||
#[test]
|
||||
fn table_of_figures_from_word_replaces_its_saved_list() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Kiln\nList"));
|
||||
s.run("select.text", &json!({"text": "Kiln"})).unwrap();
|
||||
s.run("references.caption", &json!({"label": "Figure", "text": "Kiln"})).unwrap();
|
||||
let last = s.doc.body.len() - 1;
|
||||
let p = s.doc.para_mut(StoryRef::Body, &Path::top(last)).unwrap();
|
||||
p.props.style = Some("TableofFigures".into());
|
||||
p.props.tabs = Some(vec![TabStop { pos: 400.0, align: TabAlign::Right, leader: TabLeader::Hyphen }]);
|
||||
let end = p.len();
|
||||
p.delete(0, end).unwrap();
|
||||
let field = InlineObject::Field {
|
||||
instr: r#"TOC \h \z \c "Figure""#.into(),
|
||||
result: "Figure 1: Kiln\t1".into(),
|
||||
locked: false,
|
||||
dirty: false,
|
||||
code: false,
|
||||
form: String::new(),
|
||||
};
|
||||
p.insert_object(0, field, &CharProps::default()).unwrap();
|
||||
for _ in 0..2 {
|
||||
s.run("references.updateFigures", &json!({})).unwrap();
|
||||
let text = s.doc.plain_text(StoryRef::Body);
|
||||
assert_eq!(text.matches("Figure 1: Kiln\t1").count(), 1, "{text:?}");
|
||||
assert_eq!(list_entries(&s), [("Figure 1: Kiln\t1".to_string(), Some(TabLeader::Hyphen))]);
|
||||
}
|
||||
}
|
||||
|
||||
/// #3013 review: `\c` picks captions by their SEQ identifier, as Word does, not by the text
|
||||
/// before the number (`STYLEREF`, " Specimen ", `SEQ Figure` is a figure).
|
||||
#[test]
|
||||
fn table_of_figures_goes_by_the_seq_identifier() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Rock\n"));
|
||||
let field = |instr: &str| InlineObject::Field {
|
||||
instr: instr.into(),
|
||||
result: String::new(),
|
||||
locked: false,
|
||||
dirty: false,
|
||||
code: false,
|
||||
form: String::new(),
|
||||
};
|
||||
let plain = CharProps::default();
|
||||
let mut imported = Paragraph::new().styled("Caption");
|
||||
imported.insert_object(0, field("STYLEREF 1 \\s"), &plain).unwrap();
|
||||
imported.insert_text(imported.len(), " Specimen ", &plain).unwrap();
|
||||
imported.insert_object(imported.len(), field("SEQ Figure \\* ARABIC"), &plain).unwrap();
|
||||
imported.insert_text(imported.len(), ": A rock", &plain).unwrap();
|
||||
s.doc.insert_block(StoryRef::Body, &Path::top(1), Block::Para(imported)).unwrap();
|
||||
for (label, listed) in [("Figure", true), ("Specimen", false)] {
|
||||
let mut s = Session::new(s.doc.clone());
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
s.run("text.newParagraph", &json!({})).unwrap();
|
||||
s.run("references.tableOfFigures", &json!({"label": label, "includeLabel": false})).unwrap();
|
||||
let entries: Vec<String> = list_entries(&s).into_iter().map(|(t, _)| t).collect();
|
||||
assert_eq!(entries.len(), usize::from(listed), "{label}: {entries:?}");
|
||||
if listed {
|
||||
assert_eq!(entries.first().map(|t| t.split('\t').next()), Some(Some("A rock")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #2940: a caption may start with its chapter number (`STYLEREF`) and carry its label text
|
||||
/// between that and the `SEQ` number; the text is the label, not the `SEQ` identifier.
|
||||
#[test]
|
||||
|
||||
@@ -166,6 +166,13 @@ pub(crate) fn dispatch(s: &mut Session, id: &str, mutates: bool, run: fn(&mut Se
|
||||
{
|
||||
return r;
|
||||
}
|
||||
// Delete (and Backspace that doesn't delete them) empties whole cells to one paragraph each,
|
||||
// as in Word (#2802).
|
||||
if matches!(id, "text.delete" | "text.backspace" | "text.deleteWordBack" | "text.deleteWordForward")
|
||||
&& let Some(r) = super::table::clear_cells(s)
|
||||
{
|
||||
return r;
|
||||
}
|
||||
let Some(segs) = s.column_segments().map(<[(Pos, Pos)]>::to_vec) else { return run(s, v) };
|
||||
let cells = s.cell_range().is_some();
|
||||
match id {
|
||||
|
||||
@@ -123,23 +123,33 @@ pub fn parse_date(t: &str) -> Option<(i64, u32, u32)> {
|
||||
}
|
||||
|
||||
pub fn days_in_month(y: i64, m: u32) -> u32 {
|
||||
match m {
|
||||
2 if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 => 29,
|
||||
2 => 28,
|
||||
4 | 6 | 9 | 11 => 30,
|
||||
// The length of a month is the day before the first of the next month. Month lengths repeat
|
||||
// every 400 years, so the year within its Gregorian cycle (`rem_euclid(400)`) is enough and
|
||||
// always fits chrono, for any `y`. An invalid month keeps the old 31-day default.
|
||||
let y = y.rem_euclid(400);
|
||||
let first = |y: i64, m: u32| i32::try_from(y).ok().and_then(|y| chrono::NaiveDate::from_ymd_opt(y, m, 1));
|
||||
match (first(y, m), if m == 12 { first(y + 1, 1) } else { first(y, m + 1) }) {
|
||||
(Some(a), Some(b)) => u32::try_from((b - a).num_days()).unwrap_or(31),
|
||||
_ => 31,
|
||||
}
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 for a date (inverse of [`super::civil_from_days`]).
|
||||
/// Days since 1970-01-01 for a date (inverse of [`super::civil_from_days`]), exact for any year.
|
||||
///
|
||||
/// The Gregorian calendar repeats every 400 years (146_097 days): chrono converts the year
|
||||
/// within its cycle and whole cycles are added back as days (an i128 so the extremes can't
|
||||
/// overflow). 1970-01-01 is `num_days_from_ce` 719_163; the result matches the proleptic
|
||||
/// Gregorian count the old hand-rolled code gave.
|
||||
pub fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
|
||||
let y = if m <= 2 { y - 1 } else { y };
|
||||
let era = if y >= 0 { y } else { y - 399 } / 400;
|
||||
let yoe = y - era * 400;
|
||||
let mp = i64::from((m + 9) % 12);
|
||||
let doy = (153 * mp + 2) / 5 + i64::from(d) - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
era * 146_097 + doe - 719_468
|
||||
let (cycles, year) = (i128::from(y.div_euclid(400)), y.rem_euclid(400));
|
||||
i32::try_from(year)
|
||||
.ok()
|
||||
.and_then(|year| chrono::NaiveDate::from_ymd_opt(year, m, d))
|
||||
.and_then(|date| {
|
||||
let days = i128::from(chrono::Datelike::num_days_from_ce(&date)) - 719_163 + cycles * 146_097;
|
||||
i64::try_from(days).ok()
|
||||
})
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The built-in name of the placeholder style. Its id is localized: German Word writes
|
||||
@@ -750,6 +760,22 @@ mod tests {
|
||||
assert_eq!(days_from_civil(1970, 1, 1), 0);
|
||||
assert_eq!(super::super::civil_from_days(days_from_civil(2000, 3, 1)), (2000, 3, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn days_from_civil_and_days_in_month_exact_beyond_chronos_range() {
|
||||
// Past `NaiveDate`'s ±262143 the 400-year cycles carry the result; values from the
|
||||
// pre-chrono formula, both directions, both sides of the epoch.
|
||||
for (y, m, d, days) in
|
||||
[(262_144i64, 1u32, 1u32, 95_026_602i64), (273_022, 10, 18, 99_000_000), (-262_145, 3, 1, -96_465_964), (-271_821, 4, 21, -99_999_999)]
|
||||
{
|
||||
assert_eq!(days_from_civil(y, m, d), days, "{y}-{m}-{d}");
|
||||
assert_eq!(super::super::civil_from_days(days), (y, m, d), "{days}");
|
||||
}
|
||||
assert_eq!(days_in_month(272_952, 2), 29, "272952 is a leap year");
|
||||
assert_eq!(days_in_month(-269_153, 2), 28);
|
||||
// The extremes must not overflow or panic (i128 intermediates / `rem_euclid`).
|
||||
let _ = (days_from_civil(i64::MAX, 12, 31), days_from_civil(i64::MIN, 1, 1), days_in_month(i64::MAX, 2), days_in_month(i64::MIN, 2));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -670,7 +670,17 @@ fn step(s: &mut Session, dir: i64) -> CmdResult {
|
||||
Ok(json!({"index": idx, "count": n}))
|
||||
}
|
||||
|
||||
/// Replace needs something to look for: the `text` given, or the last search's (Find then Replace
|
||||
/// with just a `with`). Neither is a call that would silently replace nothing (a misspelt `text`).
|
||||
fn need_query(s: &Session, v: &Value) -> Result<(), CmdError> {
|
||||
if p::str(v, "text").is_none() && s.find.query.is_empty() {
|
||||
return Err(CmdError::Params("`text` (string) is required".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace(s: &mut Session, v: &Value) -> CmdResult {
|
||||
need_query(s, v)?;
|
||||
read_opts(s, v);
|
||||
let story = s.sel.focus.story;
|
||||
let results = search_full(s, story, true)?;
|
||||
@@ -686,6 +696,7 @@ fn replace(s: &mut Session, v: &Value) -> CmdResult {
|
||||
/// Every match replaced, last first so earlier positions stay valid (one undo step). A `\r` in
|
||||
/// the replacement (`^p`) splits the paragraph.
|
||||
fn replace_all(s: &mut Session, v: &Value) -> CmdResult {
|
||||
need_query(s, v)?;
|
||||
read_opts(s, v);
|
||||
let story = s.sel.focus.story;
|
||||
let results = search_full(s, story, true)?;
|
||||
|
||||
@@ -193,6 +193,7 @@ fn shown_text(s: &mut Session, f: &FieldAt) -> String {
|
||||
.map(|p| l.pages.iter().filter(|pg| pg.section == p.section).count() as u32)
|
||||
.unwrap_or_else(|| u32::try_from(l.pages.len()).unwrap_or(u32::MAX));
|
||||
let page_format = page.and_then(|p| s.doc.sections().get(p.section).map(|(_, sec)| sec.page_num_format)).unwrap_or_default();
|
||||
let (fn_name, fn_path) = s.resolved_filenames();
|
||||
let ctx = wordcraft_layout::fields::FieldCtx {
|
||||
page: page.map(|p| p.number).unwrap_or(1),
|
||||
page_format,
|
||||
@@ -201,7 +202,8 @@ fn shown_text(s: &mut Session, f: &FieldAt) -> String {
|
||||
section,
|
||||
title: s.doc.core.title.as_str().into(),
|
||||
author: s.doc.core.creator.as_str().into(),
|
||||
filename: s.path.as_ref().and_then(|p| p.file_name()).map(|n| n.to_string_lossy().to_string()).unwrap_or_default().into(),
|
||||
filename: fn_name.into(),
|
||||
filepath: fn_path.into(),
|
||||
..Default::default()
|
||||
};
|
||||
wordcraft_layout::fields::field_text(&instr, &result, &ctx).0
|
||||
@@ -283,6 +285,52 @@ mod tests {
|
||||
s
|
||||
}
|
||||
|
||||
/// #1798: Update Field evaluates `DOCPROPERTY` against custom and built-in document
|
||||
/// properties (with a `\* MERGEFORMAT` switch), instead of leaving the cached result.
|
||||
#[test]
|
||||
fn update_fields_evaluates_docproperty() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("file.properties", &json!({"custom": {"ProjectName": "Atlas"}})).unwrap();
|
||||
s.run("insert.field", &json!({"instr": "DOCPROPERTY \"ProjectName\" \\* MERGEFORMAT", "result": "stale"})).unwrap();
|
||||
s.run("text.insert", &json!({"text": " "})).unwrap();
|
||||
s.doc.core.title = "Report".into();
|
||||
s.run("insert.field", &json!({"instr": "DOCPROPERTY Title", "result": ""})).unwrap();
|
||||
s.run("references.updateFields", &json!({})).unwrap();
|
||||
let results: Vec<String> = s
|
||||
.doc
|
||||
.body
|
||||
.first()
|
||||
.and_then(|b| b.as_para())
|
||||
.map(|p| {
|
||||
p.objects
|
||||
.iter()
|
||||
.filter_map(|o| match o {
|
||||
InlineObject::Field { result, .. } => Some(result.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
assert_eq!(results, ["Atlas", "Report"]);
|
||||
}
|
||||
|
||||
/// `DOCPROPERTY LastSavedBy`, Word's name for the last author, reads the last-modified-by
|
||||
/// property.
|
||||
#[test]
|
||||
fn update_fields_evaluates_docproperty_last_saved_by() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.doc.core.last_modified_by = "Ada".into();
|
||||
s.run("insert.field", &json!({"instr": "DOCPROPERTY LastSavedBy", "result": "stale"})).unwrap();
|
||||
s.run("references.updateFields", &json!({})).unwrap();
|
||||
let result = s.doc.body.first().and_then(|b| b.as_para()).and_then(|p| {
|
||||
p.objects.iter().find_map(|o| match o {
|
||||
InlineObject::Field { result, .. } => Some(result.clone()),
|
||||
_ => None,
|
||||
})
|
||||
});
|
||||
assert_eq!(result.as_deref(), Some("Ada"));
|
||||
}
|
||||
|
||||
fn page_text(s: &mut Session) -> String {
|
||||
let l = s.layout();
|
||||
let mut out = String::new();
|
||||
|
||||
@@ -45,8 +45,16 @@ fn rename(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if let Some(path) = &s.path {
|
||||
let mut destination = path.with_file_name(name);
|
||||
// The field edits the stem; retain the original format, including macro/template files.
|
||||
// A name that already ends in that extension is taken as the whole file name, since a
|
||||
// script or an agent passes one ("renamed.docx" became "renamed.docx.docx", #2983). The
|
||||
// extension alone (".docx") is a stem, not a file name, so it still gets one.
|
||||
if let Some(ext) = path.extension() {
|
||||
destination.set_file_name(format!("{name}.{}", ext.to_string_lossy()));
|
||||
let ext = ext.to_string_lossy();
|
||||
// Split at the name's own last dot rather than counting bytes back from its end: a
|
||||
// name whose characters are more than one byte each ("résumé.docm") would be cut
|
||||
// inside one of them, and slicing there panics (#3128 review).
|
||||
let given = name.rsplit_once('.').is_some_and(|(stem, suffix)| !stem.is_empty() && suffix.eq_ignore_ascii_case(ext.as_ref()));
|
||||
destination.set_file_name(if given { name.to_string() } else { format!("{name}.{ext}") });
|
||||
}
|
||||
if destination != *path {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -54,16 +62,18 @@ fn rename(s: &mut Session, v: &Value) -> CmdResult {
|
||||
crate::io::rename_file(path, &destination).map_err(CmdError::Failed)?;
|
||||
}
|
||||
s.path = Some(destination);
|
||||
s.relayout();
|
||||
}
|
||||
} else {
|
||||
s.file_name = Some(name.to_string());
|
||||
s.relayout();
|
||||
}
|
||||
Ok(json!({"name": name, "path": s.path}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("file.rename", "Rename Document", "File › Info", rename).params(r#"{"name": string (document name without its extension)}"#).pure(),
|
||||
CommandSpec::new("file.rename", "Rename Document", "File › Info", rename).params(r#"{"name": string (document name; its extension is kept, so the name may be given with or without it)}"#).pure(),
|
||||
CommandSpec::new("file.new", "New", "File", new).key("Mod+N").params(r#"{"template"?: "blank|sample|letter|resume|report"}"#).pure(),
|
||||
CommandSpec::new("file.open", "Open", "File", open)
|
||||
.key("Mod+O / Mod+F12 / Mod+Alt+F2")
|
||||
@@ -182,6 +192,7 @@ fn privacy(s: &Session) -> Value {
|
||||
json!({"removePersonalInfo": s.doc.settings.remove_personal_info, "removeDateTime": s.doc.settings.remove_date_time})
|
||||
}
|
||||
|
||||
/// Create a blank document in the saved proofing language, or use the requested template.
|
||||
fn new(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let doc = match p::str(v, "template").unwrap_or("blank") {
|
||||
"sample" => crate::sample::sample_document(),
|
||||
@@ -199,12 +210,13 @@ fn new(s: &mut Session, v: &Value) -> CmdResult {
|
||||
/// language (the templates' text is English).
|
||||
fn normal_document(s: &Session) -> Document {
|
||||
let mut d = s.blank_document();
|
||||
if let Some(lang) = &s.editing_lang {
|
||||
if let Some(lang) = s.prefs.default_language.as_ref().or(s.editing_lang.as_ref()) {
|
||||
d.styles.default_chr.lang = Some(lang.clone());
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// Open a document, using the saved proofing default only for plain text and Markdown.
|
||||
fn open(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let Some(path) = p::str(v, "path") else {
|
||||
s.ui_requests.push(json!({"open": "openFile"}));
|
||||
@@ -227,7 +239,7 @@ fn open(s: &mut Session, v: &Value) -> CmdResult {
|
||||
};
|
||||
// Plain text and Markdown carry no language: they are in the editing language.
|
||||
let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
|
||||
if let Some(lang) = &s.editing_lang
|
||||
if let Some(lang) = s.prefs.default_language.as_ref().or(s.editing_lang.as_ref())
|
||||
&& matches!(ext.as_str(), "txt" | "text" | "md" | "markdown")
|
||||
{
|
||||
doc.styles.default_chr.lang = Some(lang.clone());
|
||||
@@ -297,15 +309,27 @@ fn save(s: &mut Session, v: &Value) -> CmdResult {
|
||||
return Err(CmdError::Params(format!("`format` is \"{FORMAT_WORD_2003}\" or \"{FORMAT_FLAT_OPC}\", and only for an .xml file")));
|
||||
}
|
||||
}
|
||||
// Undone below if the write fails: a failed save (no permission, disk full, a bad path)
|
||||
// must not leave the document's own save stamps — zoom, modified/created, author, revision —
|
||||
// changed, or a later save would persist them for a write that never actually happened.
|
||||
let before_stamp =
|
||||
(s.doc.settings.zoom, s.doc.core.last_modified_by.clone(), s.doc.core.created.clone(), s.doc.core.modified.clone(), s.doc.core.revision);
|
||||
s.stamp_save();
|
||||
let password = s.password.as_ref().map(crate::io::Password::as_str);
|
||||
// A header/footer just made and left empty isn't saved (#960).
|
||||
let copy = super::insert::saved_copy(s);
|
||||
crate::io::save_path_with(&path, copy.as_ref().unwrap_or(&s.doc), password, &opts).map_err(CmdError::Failed)?;
|
||||
if let Err(e) = crate::io::save_path_with(&path, copy.as_ref().unwrap_or(&s.doc), password, &opts) {
|
||||
(s.doc.settings.zoom, s.doc.core.last_modified_by, s.doc.core.created, s.doc.core.modified, s.doc.core.revision) = before_stamp;
|
||||
return Err(CmdError::Failed(e));
|
||||
}
|
||||
let encrypted = password.is_some() && crate::io::can_encrypt(&path.to_string_lossy());
|
||||
if ["docx", "docm", "dotx", "dotm", "odt", "rtf", "json", "xml"].contains(&ext.as_str()) {
|
||||
// A save in a format that doesn't keep everything (plain text, Markdown, a page image) is a
|
||||
// copy, except over the document's own file: a note.txt opened and saved is saved (#2376).
|
||||
let own_file = s.path.as_deref() == Some(path.as_path());
|
||||
if own_file || ["docx", "docm", "dotx", "dotm", "odt", "rtf", "json", "xml"].contains(&ext.as_str()) {
|
||||
s.path = Some(path.clone());
|
||||
s.dirty = false;
|
||||
s.relayout();
|
||||
}
|
||||
Ok(json!({"saved": true, "path": path.to_string_lossy(), "encrypted": encrypted}))
|
||||
}
|
||||
|
||||
@@ -106,6 +106,16 @@ fn code_char(n: u32) -> Option<char> {
|
||||
})
|
||||
}
|
||||
|
||||
/// A Windows-1252 code as its Unicode scalar: 128–159 hold typographic characters there (151 is
|
||||
/// an em dash); the five undefined slots and every other code stay as they are.
|
||||
fn ansi(n: u32) -> u32 {
|
||||
const HIGH: [u32; 32] = [
|
||||
0x20AC, 0x81, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0x8D, 0x017D, 0x8F, 0x90, 0x2018,
|
||||
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0x9D, 0x017E, 0x0178,
|
||||
];
|
||||
n.checked_sub(128).and_then(|k| HIGH.get(k as usize)).copied().unwrap_or(n)
|
||||
}
|
||||
|
||||
/// Read a numeric code after `^` (`13`, `0151`, `u8212`) starting at `chars[i]`; returns the
|
||||
/// character and how many characters it took.
|
||||
fn numeric_code(chars: &[char], i: usize) -> Option<(char, usize)> {
|
||||
@@ -115,6 +125,8 @@ fn numeric_code(chars: &[char], i: usize) -> Option<(char, usize)> {
|
||||
return None;
|
||||
}
|
||||
let n: u32 = digits.parse().ok()?;
|
||||
// `^0nnn` is a Windows-1252 (ANSI) code; `^nnn` and `^unnnn` are Unicode.
|
||||
let n = if start == i && digits.starts_with('0') { ansi(n) } else { n };
|
||||
Some((code_char(n)?, start - i + digits.chars().count()))
|
||||
}
|
||||
|
||||
@@ -415,3 +427,40 @@ pub fn expand(pieces: &[Piece], found: &str, group: &dyn Fn(usize) -> Option<Str
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn finds(query: &str, hay: &str) -> bool {
|
||||
let c = compile(query, &Opts::default()).unwrap();
|
||||
regex::Regex::new(&c.pattern).unwrap().is_match(hay)
|
||||
}
|
||||
|
||||
fn replaced(with: &str) -> String {
|
||||
expand(&parse_replace(with, &Opts::default()), "", &|_| None, "")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ansi_codes_128_to_159_are_windows_1252() {
|
||||
for (code, ch) in [("^0151", '\u{2014}'), ("^0150", '\u{2013}'), ("^0128", '\u{20AC}'), ("^0145", '\u{2018}')] {
|
||||
let hay = format!("a{ch}b");
|
||||
assert!(finds(code, &hay), "{code} should find {ch:?}");
|
||||
assert!(!finds(code, code), "{code} should not match its literal text");
|
||||
let w = Opts { wildcards: true, ..Opts::default() };
|
||||
let c = compile(code, &w).unwrap();
|
||||
assert!(regex::Regex::new(&c.pattern).unwrap().is_match(&hay), "wildcard {code} should find {ch:?}");
|
||||
}
|
||||
assert_eq!(replaced("x^0151y"), "x\u{2014}y");
|
||||
assert_eq!(replaced("^0128"), "\u{20AC}");
|
||||
// Unchanged: other codes and the undefined Windows-1252 slots.
|
||||
assert!(finds("^13", "a\rb"));
|
||||
assert!(finds("^0065", "A"));
|
||||
for undefined in ["^0129", "^0141", "^0143", "^0144", "^0157"] {
|
||||
assert_eq!(replaced(undefined), undefined);
|
||||
}
|
||||
// Without the leading zero the code is not read as Windows-1252.
|
||||
assert!(!finds("^151", "\u{2014}"));
|
||||
assert!(!finds("^150", "\u{2013}"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ use std::sync::Arc;
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::props::VMerge;
|
||||
use wordcraft_doc::props::{CellProps, CharProps, ParaProps, PropChange, RowProps, TableProps};
|
||||
use wordcraft_doc::section::SectionProps;
|
||||
use wordcraft_doc::table::{Cell, Row, Table};
|
||||
@@ -146,15 +147,30 @@ impl Recorder<'_> {
|
||||
(Block::Para(pb), Block::Para(pa)) if pb.text == pa.text => {
|
||||
if let Block::Para(pa) = Arc::make_mut(a) {
|
||||
self.para(pb, pa);
|
||||
// The section's properties on the paragraph (`w:sectPrChange`).
|
||||
let (Some(b), Some(x)) = (pb.section.as_deref(), pa.section.as_deref_mut()) else { continue };
|
||||
if let Some(w) = self.change(b, x, strip_section, |s| &s.fmt_change) {
|
||||
x.fmt_change = w;
|
||||
}
|
||||
}
|
||||
}
|
||||
(Block::Table(tb), Block::Table(ta)) if tb.rows.len() == ta.rows.len() => {
|
||||
(Block::Table(tb), Block::Table(ta)) if tb.rows.len() == ta.rows.len() && tb.grid == ta.grid => {
|
||||
let Block::Table(ta) = Arc::make_mut(a) else { continue };
|
||||
if let Some(w) = self.change(&tb.props, &ta.props, strip_table, |t| &t.fmt_change) {
|
||||
ta.props.fmt_change = w;
|
||||
}
|
||||
for (rb, ra) in tb.rows.iter().zip(ta.rows.iter_mut()) {
|
||||
if let Some(w) = self.change(&rb.props, &ra.props, strip_row, |r| &r.fmt_change) {
|
||||
ra.props.fmt_change = w;
|
||||
}
|
||||
if rb.cells.len() != ra.cells.len() {
|
||||
continue;
|
||||
}
|
||||
for (cb, ca) in rb.cells.iter().zip(ra.cells.iter_mut()) {
|
||||
// Merges are structure, not formatting: span and vertical merge stay out.
|
||||
if let Some(w) = self.change(&cb.props, &ca.props, strip_cell, |c| &c.fmt_change) {
|
||||
ca.props.fmt_change = w;
|
||||
}
|
||||
self.blocks(&cb.blocks, &mut ca.blocks, depth + 1);
|
||||
}
|
||||
}
|
||||
@@ -165,6 +181,21 @@ impl Recorder<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The formatting-comparison strips: what isn't formatting in these properties. A cell's merge
|
||||
/// is structure, a section's headers and footers are content.
|
||||
fn strip_table(t: &TableProps) -> TableProps {
|
||||
TableProps { fmt_change: None, ..t.clone() }
|
||||
}
|
||||
fn strip_row(r: &RowProps) -> RowProps {
|
||||
RowProps { fmt_change: None, ..r.clone() }
|
||||
}
|
||||
fn strip_cell(c: &CellProps) -> CellProps {
|
||||
CellProps { fmt_change: None, span: 0, vmerge: VMerge::None, ..c.clone() }
|
||||
}
|
||||
fn strip_section(s: &SectionProps) -> SectionProps {
|
||||
SectionProps { fmt_change: None, headers: Default::default(), footers: Default::default(), ..s.clone() }
|
||||
}
|
||||
|
||||
/// Keep the formatting changes the command `id` made to `s.doc` (compared with `before`) as
|
||||
/// tracked formatting changes, when Track Changes and Track Formatting are on.
|
||||
pub(crate) fn record(s: &mut Session, id: &str, before: &Document) {
|
||||
|
||||
@@ -102,6 +102,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("format.growFont1", "Grow Font 1 Point", "Home › Font", |s, _| nudge_size(s, 1.0)).key("Mod+]"),
|
||||
CommandSpec::new("format.shrinkFont1", "Shrink Font 1 Point", "Home › Font", |s, _| nudge_size(s, -1.0)).key("Mod+["),
|
||||
CommandSpec::new("format.color", "Font Color", "Home › Font", color).params(r#"{"color": "RRGGBB" | "auto"}"#),
|
||||
CommandSpec::new("format.underlineColor", "Underline Color", "Home › Font › Underline", underline_color)
|
||||
.params(r#"{"color": "RRGGBB" | "auto"} (the underline's own colour; "auto" follows the text colour)"#),
|
||||
CommandSpec::new("format.highlight", "Text Highlight Color", "Home › Font", highlight)
|
||||
.params(
|
||||
r#"{"color": "yellow|brightGreen|turquoise|pink|blue|red|darkBlue|teal|green|violet|darkRed|darkYellow|gray50|gray25|black|none"}"#,
|
||||
@@ -366,6 +368,17 @@ fn color(s: &mut Session, v: &Value) -> CmdResult {
|
||||
apply(s, &|x| x.color = Some(tc))
|
||||
}
|
||||
|
||||
/// `format.underlineColor`: the underline's own colour (`w:u w:color`), or `auto` to drop it so the
|
||||
/// underline follows the text colour again. The underline style is left as it is.
|
||||
fn underline_color(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let c = p::req_str(v, "color")?;
|
||||
let rgb = match TextColor::parse(c).ok_or_else(|| CmdError::Params("color must be RRGGBB or auto".into()))? {
|
||||
TextColor::Auto => None,
|
||||
TextColor::Rgb(rgb) => Some(rgb),
|
||||
};
|
||||
apply(s, &|x| x.underline_color = rgb)
|
||||
}
|
||||
|
||||
fn highlight(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let c = p::str(v, "color").unwrap_or("yellow");
|
||||
let h = Highlight::ALL
|
||||
@@ -603,7 +616,10 @@ fn convert_chars(src: &str, mode: &str, para_start: bool) -> Vec<String> {
|
||||
} else {
|
||||
lower(i, c)
|
||||
};
|
||||
if matches!(c, '.' | '!' | '?') {
|
||||
let is_decimal = c == '.'
|
||||
&& i.checked_sub(1).and_then(|j| chars.get(j)).is_some_and(|p| p.is_ascii_digit())
|
||||
&& chars.get(i + 1).is_some_and(|n| n.is_ascii_digit());
|
||||
if matches!(c, '!' | '?') || (c == '.' && !is_decimal) {
|
||||
up = true;
|
||||
}
|
||||
n
|
||||
@@ -677,6 +693,7 @@ pub fn state(s: &Session) -> Value {
|
||||
"bold": all.iter().all(|r| r.bold),
|
||||
"italic": all.iter().all(|r| r.italic),
|
||||
"underline": all.iter().all(|r| r.underline != Underline::None),
|
||||
"underlineColor": same(&|r| json!(r.underline_color.map_or_else(|| "auto".to_string(), |c| c.hex()))),
|
||||
"strike": all.iter().all(|r| r.strike),
|
||||
"border": all.iter().all(|r| r.border.is_some()),
|
||||
"subscript": all.iter().all(|r| r.vert_align == VertAlign::Subscript),
|
||||
@@ -710,6 +727,13 @@ mod tests {
|
||||
assert_eq!(convert("hello world", "title", false), "Hello World");
|
||||
assert_eq!(convert("ΣΟΦΟΣ ΟΔΟΣ", "lower", true), "σοφος οδος");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sentence_case_ignores_decimal_points() {
|
||||
// #3485: A period between two digits is a decimal point, not a sentence boundary.
|
||||
assert_eq!(convert("one 3.14 apples and two 2.5 pears.", "sentence", true), "One 3.14 apples and two 2.5 pears.");
|
||||
assert_eq!(convert("pi is 3.14. next sentence.", "sentence", true), "Pi is 3.14. Next sentence.");
|
||||
}
|
||||
}
|
||||
|
||||
/// What Enclose Characters works on: the selected text (one full-width or up to two half-width
|
||||
|
||||
@@ -65,11 +65,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("insert.createFooter", "Footer", "Insert › Header & Footer", |s, v| create_hf(s, v, false))
|
||||
.params(r#"{"page"?: number (0-based; default: the caret's section)} — the footer that page shows, created if it has none, with the caret in it"#),
|
||||
CommandSpec::new("insert.closeHeader", "Close Header and Footer", "Header & Footer", |s, _| {
|
||||
// Back where the caret was in the body, as Word does, not on page 1.
|
||||
let back = s.hf_return.take().filter(|_| s.in_header_footer());
|
||||
if !new_empty_headers(s).is_empty() {
|
||||
// Its own undo step; a protected document keeps it, and the caret still leaves.
|
||||
let _ = s.run("insert.removeEmptyHeaders", &json!({}));
|
||||
}
|
||||
s.sel = Selection::caret(s.doc.start_of(StoryRef::Body));
|
||||
s.sel = back.unwrap_or_else(|| Selection::caret(s.doc.start_of(StoryRef::Body)));
|
||||
sel_result(s)
|
||||
})
|
||||
.pure(),
|
||||
@@ -340,8 +342,19 @@ fn shape(s: &mut Session, v: &Value) -> CmdResult {
|
||||
Float { wrap: Wrap::Inline, ..Default::default() }
|
||||
};
|
||||
let stroke = p::str(v, "stroke").and_then(Rgb::parse).or(Some(Rgb(0x0E, 0x40, 0x5A)));
|
||||
let obj =
|
||||
InlineObject::Shape { kind, w, h, fill, stroke, stroke_width: 1.0, float, story: None, freeform: None, effects: Default::default(), extra };
|
||||
let obj = InlineObject::Shape {
|
||||
kind,
|
||||
w,
|
||||
h,
|
||||
fill: fill.map(wordcraft_doc::fill::Fill::solid),
|
||||
stroke,
|
||||
stroke_width: 1.0,
|
||||
float,
|
||||
story: None,
|
||||
freeform: None,
|
||||
effects: Default::default(),
|
||||
extra,
|
||||
};
|
||||
// A selected Drawing Canvas takes it in (and stays selected for the next one).
|
||||
if super::canvas::add_member(s, obj.clone())?.is_some() {
|
||||
return sel_result(s);
|
||||
@@ -365,7 +378,7 @@ fn text_box(s: &mut Session, v: &Value) -> CmdResult {
|
||||
kind: ShapeKind::TextBox,
|
||||
w,
|
||||
h,
|
||||
fill: Some(Rgb::WHITE),
|
||||
fill: Some(wordcraft_doc::fill::Fill::solid(Rgb::WHITE)),
|
||||
stroke: Some(Rgb::BLACK),
|
||||
stroke_width: 0.75,
|
||||
float: Float::default(),
|
||||
@@ -456,9 +469,11 @@ fn link(s: &mut Session, v: &Value) -> CmdResult {
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Whether `name` can name a bookmark: no spaces, at most 40 characters, not starting with a digit.
|
||||
/// Whether `name` can name a bookmark: starts with a letter or underscore, contains only alphanumeric characters
|
||||
/// or underscores, and is at most 40 characters (#3491). Word allows leading underscores for hidden bookmarks (_Toc, _Ref).
|
||||
pub fn bookmark_name_ok(name: &str) -> bool {
|
||||
!name.is_empty() && !name.contains(' ') && name.len() <= 40 && !name.chars().next().is_some_and(|c| c.is_ascii_digit())
|
||||
let mut chars = name.chars();
|
||||
chars.next().is_some_and(|c| c.is_alphabetic() || c == '_') && chars.all(|c| c.is_alphanumeric() || c == '_') && name.chars().count() <= 40
|
||||
}
|
||||
|
||||
/// Where bookmark `name` is: see [`bookmark_span`]. `None` when there is no such bookmark.
|
||||
@@ -842,18 +857,19 @@ pub fn format_date_parts(fmt: &str, y: i64, m: usize, d: u32, (hh, mm, ss): (u32
|
||||
n += 1;
|
||||
}
|
||||
match (c, n) {
|
||||
('y', 4..) => out.push_str(&format!("{y:04}")),
|
||||
('y', _) => out.push_str(&format!("{:02}", y % 100)),
|
||||
('y' | 'Y', 4..) => out.push_str(&format!("{y:04}")),
|
||||
('y' | 'Y', _) => out.push_str(&format!("{:02}", y % 100)),
|
||||
('M', 4..) => out.push_str(month),
|
||||
('M', 3) => out.push_str(month.get(..3).unwrap_or(month)),
|
||||
('M', 2) => out.push_str(&format!("{m:02}")),
|
||||
('M', 1) => out.push_str(&m.to_string()),
|
||||
('d', 4..) => out.push_str(day),
|
||||
('d', 3) => out.push_str(day.get(..3).unwrap_or(day)),
|
||||
('d', 2) => out.push_str(&format!("{d:02}")),
|
||||
('d', 1) => out.push_str(&d.to_string()),
|
||||
('d' | 'D', 4..) => out.push_str(day),
|
||||
('d' | 'D', 3) => out.push_str(day.get(..3).unwrap_or(day)),
|
||||
('d' | 'D', 2) => out.push_str(&format!("{d:02}")),
|
||||
('d' | 'D', 1) => out.push_str(&d.to_string()),
|
||||
('H', 2..) => out.push_str(&format!("{hh:02}")),
|
||||
('H', _) => out.push_str(&hh.to_string()),
|
||||
('h', 2..) => out.push_str(&format!("{:02}", if hh.is_multiple_of(12) { 12 } else { hh % 12 })),
|
||||
('h', _) => out.push_str(&(if hh.is_multiple_of(12) { 12 } else { hh % 12 }).to_string()),
|
||||
('m', 2..) => out.push_str(&format!("{mm:02}")),
|
||||
('m', _) => out.push_str(&mm.to_string()),
|
||||
@@ -967,6 +983,28 @@ fn cover_page(s: &mut Session, v: &Value) -> CmdResult {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Closing the header or footer (Esc, the ribbon) puts the caret back where it was in the
|
||||
/// body, not on page 1 (#3331).
|
||||
#[test]
|
||||
fn close_header_returns_to_the_body_selection() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("text.insert", &json!({"text": "One"})).unwrap();
|
||||
s.run("insert.pageBreak", &json!({})).unwrap();
|
||||
s.run("text.insert", &json!({"text": "Two words here"})).unwrap();
|
||||
s.run("select.text", &json!({"text": "words"})).unwrap();
|
||||
let before = s.sel.clone();
|
||||
// A header written to stays.
|
||||
s.run("insert.editHeader", &json!({})).unwrap();
|
||||
s.run("text.insert", &json!({"text": "Head"})).unwrap();
|
||||
s.run("insert.closeHeader", &json!({})).unwrap();
|
||||
assert_eq!(s.sel, before);
|
||||
// An empty one made by a double-click on page 2 is taken away again.
|
||||
s.run("insert.editFooter", &json!({"page": 1})).unwrap();
|
||||
s.run("insert.closeHeader", &json!({})).unwrap();
|
||||
assert_eq!(s.sel, before);
|
||||
assert_eq!(s.doc.last_section.footers, Default::default());
|
||||
}
|
||||
|
||||
/// #960: editing the header of a page that has none creates the one that page shows (first
|
||||
/// page, even page or default); typing goes into it, and closing an unwritten one takes it
|
||||
/// away again so the saved file stays as it was.
|
||||
@@ -1173,6 +1211,47 @@ mod tests {
|
||||
assert_eq!(crate::cmd::references::bookmark_text(&s.doc, "Spot").as_deref(), Some("delta"));
|
||||
}
|
||||
|
||||
/// #3491: Bookmark names are validated by Unicode character count and character classes.
|
||||
#[test]
|
||||
fn bookmark_name_validation_rules() {
|
||||
// Valid names:
|
||||
assert!(bookmark_name_ok("Mark1"));
|
||||
assert!(bookmark_name_ok("A"));
|
||||
assert!(bookmark_name_ok("Alpha_Beta_123"));
|
||||
// Valid leading underscore (Word hidden bookmarks, e.g. _Toc, _Ref, _Hidden):
|
||||
assert!(bookmark_name_ok("_Hidden"));
|
||||
assert!(bookmark_name_ok("_foo"));
|
||||
// Valid 27-character Unicode Cyrillic name (51 UTF-8 bytes):
|
||||
assert!(bookmark_name_ok("быстрый_доступ_к_документам"));
|
||||
// Valid 40-character name:
|
||||
let exactly_40 = "a".repeat(40);
|
||||
assert!(bookmark_name_ok(&exactly_40));
|
||||
let exactly_40_unicode = "я".repeat(40);
|
||||
assert!(bookmark_name_ok(&exactly_40_unicode));
|
||||
|
||||
// Invalid: empty
|
||||
assert!(!bookmark_name_ok(""));
|
||||
// Invalid: leading digit
|
||||
assert!(!bookmark_name_ok("1mark"));
|
||||
// Invalid: symbols / punctuation
|
||||
assert!(!bookmark_name_ok("a&b"));
|
||||
assert!(!bookmark_name_ok("a!b"));
|
||||
assert!(!bookmark_name_ok("a-b"));
|
||||
assert!(!bookmark_name_ok("a.b"));
|
||||
assert!(!bookmark_name_ok("foo bar"));
|
||||
// Invalid: more than 40 characters
|
||||
assert!(!bookmark_name_ok(&"a".repeat(41)));
|
||||
assert!(!bookmark_name_ok(&"я".repeat(41)));
|
||||
|
||||
// Verification through `insert.bookmark` command
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Test content"));
|
||||
assert!(s.run("insert.bookmark", &json!({"name": "быстрый_доступ_к_документам"})).is_ok());
|
||||
assert!(s.run("insert.bookmark", &json!({"name": "_Hidden"})).is_ok());
|
||||
assert!(s.run("insert.bookmark", &json!({"name": "1mark"})).is_err());
|
||||
assert!(s.run("insert.bookmark", &json!({"name": "a&b"})).is_err());
|
||||
assert!(s.run("insert.bookmark", &json!({"name": "a!b"})).is_err());
|
||||
}
|
||||
|
||||
/// Insert › Symbol with a font: only the symbol takes it, the caret's font carries on after.
|
||||
#[test]
|
||||
fn a_mixed_symbol_string_keeps_symbols_and_text_apart() {
|
||||
@@ -1210,6 +1289,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn date_pictures() {
|
||||
// Word accepts either case for the day and year instructions.
|
||||
assert_eq!(format_date_parts("MM/DD/YYYY", 2026, 10, 21, (17, 27, 3), 0), "10/21/2026");
|
||||
assert_eq!(format_date_parts("D MMMM YYYY", 2026, 10, 21, (17, 27, 3), 0), "21 October 2026");
|
||||
// Both 12-hour instructions pad a single-digit hour; only the 12-hour one wraps at noon.
|
||||
assert_eq!(format_date_parts("hh:mm", 2026, 10, 21, (17, 27, 3), 0), "05:27");
|
||||
assert_eq!(format_date_parts("h:mm", 2026, 10, 21, (17, 27, 3), 0), "5:27");
|
||||
assert_eq!(format_date_parts("HH:mm", 2026, 10, 21, (5, 7, 3), 0), "05:07");
|
||||
let d = format_date("yyyy-MM-dd");
|
||||
assert_eq!(d.len(), 10);
|
||||
assert!(format_date("MMMM d, yyyy").contains(", "));
|
||||
|
||||
@@ -35,6 +35,7 @@ pub mod phonetic;
|
||||
pub mod protect;
|
||||
pub mod references;
|
||||
pub mod review;
|
||||
pub mod shape_fill;
|
||||
pub mod smart_art;
|
||||
pub mod speech;
|
||||
pub mod table;
|
||||
@@ -46,6 +47,7 @@ pub mod table_style;
|
||||
pub mod templates;
|
||||
pub mod text;
|
||||
pub mod tools;
|
||||
pub mod user_templates;
|
||||
pub mod view;
|
||||
pub mod wordart;
|
||||
|
||||
@@ -89,6 +91,7 @@ pub fn registry() -> Registry {
|
||||
v.extend(citations::specs());
|
||||
v.extend(index::specs());
|
||||
v.extend(objects::specs());
|
||||
v.extend(shape_fill::specs());
|
||||
v.extend(smart_art::specs());
|
||||
v.extend(chart::specs());
|
||||
v.extend(draw::specs());
|
||||
@@ -97,6 +100,7 @@ pub fn registry() -> Registry {
|
||||
v.extend(connectors::specs());
|
||||
v.extend(tools::specs());
|
||||
v.extend(templates::specs());
|
||||
v.extend(user_templates::specs());
|
||||
v.extend(protect::specs());
|
||||
v.extend(speech::specs());
|
||||
v.extend(controls::specs());
|
||||
@@ -420,19 +424,27 @@ fn split_para_as(s: &mut Session, at: &Pos, enter: bool) -> Result<Pos, CmdError
|
||||
|
||||
/// ISO-8601 timestamp (UTC, seconds) for `secs` since the Unix epoch.
|
||||
pub fn iso_from_unix_secs(secs: u64) -> String {
|
||||
let (y, m, d) = civil_from_days((secs / 86_400) as i64);
|
||||
use chrono::Datelike;
|
||||
let t = secs % 86_400;
|
||||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", t / 3600, t / 60 % 60, t % 60)
|
||||
let hms = format!("{:02}:{:02}:{:02}", t / 3600, t / 60 % 60, t % 60);
|
||||
// Within chrono's range, read the date from it; past it, keep the proleptic Gregorian answer
|
||||
// `civil_from_days` gives rather than falling back to the epoch.
|
||||
let (y, m, d) = match i64::try_from(secs).ok().and_then(|s| chrono::DateTime::from_timestamp(s, 0)) {
|
||||
Some(dt) => (i64::from(dt.year()), dt.month(), dt.day()),
|
||||
None => civil_from_days((secs / 86_400) as i64),
|
||||
};
|
||||
// `{y:04}` rather than `%Y`, which writes a `+` before years past 9999.
|
||||
format!("{y:04}-{m:02}-{d:02}T{hms}Z")
|
||||
}
|
||||
|
||||
/// Seconds since the Unix epoch (UTC): the system clock, or the browser's on the web.
|
||||
pub fn now_unix() -> u64 {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
|
||||
{
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
||||
}
|
||||
// `SystemTime::now()` panics on wasm32-unknown-unknown, so ask the browser's clock.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
|
||||
{
|
||||
let ms = js_sys::Date::now();
|
||||
// Clamp a NaN or pre-epoch clock to 0 rather than fail.
|
||||
@@ -445,18 +457,25 @@ pub fn now_iso() -> String {
|
||||
iso_from_unix_secs(now_unix())
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 → (year, month, day) (Howard Hinnant's algorithm).
|
||||
/// Days since 1970-01-01 → (year, month, day).
|
||||
///
|
||||
/// `NaiveDate` only covers about ±262143 years, but this is reached by the system clock (and by
|
||||
/// [`iso_from_unix_secs`] for a `u64` seconds count past it), so it must stay exact for every
|
||||
/// `z`. The Gregorian calendar repeats every 400 years (146_097 days): chrono converts the day
|
||||
/// within its cycle and whole cycles go back into the year (an i128 so i64::MIN/MAX can't
|
||||
/// overflow). 1970-01-01 is `num_days_from_ce` 719_163, and 0..146_097 days from CE day 0 always
|
||||
/// fit chrono. The result matches the proleptic Gregorian count the old hand-rolled code gave.
|
||||
pub fn civil_from_days(z: i64) -> (i64, u32, u32) {
|
||||
let z = z + 719_468;
|
||||
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||
let doe = z - era * 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
|
||||
(if m <= 2 { y + 1 } else { y }, m, d)
|
||||
use chrono::Datelike;
|
||||
let (cycles, rest) = (z.div_euclid(146_097), z.rem_euclid(146_097));
|
||||
match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
|
||||
Some(d) => {
|
||||
let y = i128::from(d.year()) + i128::from(cycles) * 400;
|
||||
(i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.month(), d.day())
|
||||
}
|
||||
// Unreachable: `rest + 719_163` is 719_163..865_259, always a valid CE day.
|
||||
None => (1970, 1, 1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Story given in params (`"story": "body"` / `{"part": 3}`), else the caret's.
|
||||
@@ -484,7 +503,7 @@ pub fn default_lang(s: &Session) -> Option<String> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod iso_tests {
|
||||
use super::iso_from_unix_secs;
|
||||
use super::{civil_from_days, iso_from_unix_secs};
|
||||
|
||||
#[test]
|
||||
fn formats_epoch_and_known_timestamps() {
|
||||
@@ -498,4 +517,26 @@ mod iso_tests {
|
||||
assert_eq!(iso_from_unix_secs(951_782_400), "2000-02-29T00:00:00Z");
|
||||
assert_eq!(iso_from_unix_secs(1_735_689_599), "2024-12-31T23:59:59Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_dates_outside_chronos_range() {
|
||||
// Year 10000 is still inside chrono's range: a plain zero-padded year, no leading `+`.
|
||||
assert_eq!(iso_from_unix_secs(253_402_300_800), "10000-01-01T00:00:00Z");
|
||||
// Past chrono's range, keep the proleptic Gregorian date instead of the Unix epoch.
|
||||
assert_eq!(iso_from_unix_secs(10_000_000_000_000), "318857-05-20T17:46:40Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn civil_from_days_is_exact_beyond_chronos_range() {
|
||||
assert_eq!(civil_from_days(0), (1970, 1, 1));
|
||||
assert_eq!(civil_from_days(20_734), (2026, 10, 8));
|
||||
// Past `NaiveDate`'s ±262143 the 400-year cycles carry the year; values from the
|
||||
// pre-chrono formula, both directions, both sides of the epoch.
|
||||
assert_eq!(civil_from_days(95_026_602), (262_144, 1, 1));
|
||||
assert_eq!(civil_from_days(99_000_000), (273_022, 10, 18));
|
||||
assert_eq!(civil_from_days(-96_465_964), (-262_145, 3, 1));
|
||||
assert_eq!(civil_from_days(-99_000_000), (-269_083, 3, 17));
|
||||
// The extremes must not overflow or panic (i128 intermediates).
|
||||
let _ = (civil_from_days(i64::MAX), civil_from_days(i64::MIN));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ fn has_group(s: &Session) -> Option<&'static str> {
|
||||
}
|
||||
}
|
||||
/// Shape Fill and Shape Outline: a shape, or a Drawing Canvas's background and border.
|
||||
fn has_shape_or_canvas(s: &Session) -> Option<&'static str> {
|
||||
pub(crate) fn has_shape_or_canvas(s: &Session) -> Option<&'static str> {
|
||||
match selected(s) {
|
||||
Some((_, o)) if o.is_canvas() => None,
|
||||
_ => has_shape(s),
|
||||
@@ -98,7 +98,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.when(has_sizable),
|
||||
CommandSpec::new("picture.crop", "Crop", "Picture Format › Size", crop)
|
||||
.params(
|
||||
r#"{"left"?, "top"?, "right"?, "bottom"? (fractions of the source 0–0.45; a side left out is uncropped), "width"?: pt, "height"?: pt (the new display size, in the same undo step; a floating picture also moves by what a left/top crop trims, so the visible part stays put)}"#,
|
||||
r#"{"left"?, "top"?, "right"?, "bottom"? (fractions of the source 0–0.95; the two sides of an axis keep at least 5% between them; a side left out is uncropped), "width"?: pt, "height"?: pt (the new display size, in the same undo step; a floating picture also moves by what a left/top crop trims, so the visible part stays put)}"#,
|
||||
)
|
||||
.when(has_picture),
|
||||
// Kept for scripts written before every object had alt text: `object.altText` on a picture.
|
||||
@@ -324,10 +324,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.when(has_movable),
|
||||
CommandSpec::new("arrange.selectionPane", "Selection Pane", "Layout › Arrange", objects_list).pure(),
|
||||
CommandSpec::new("arrange.bounds", "Move or Resize", "Layout › Arrange", bounds)
|
||||
.params(r#"{"width"?: pt, "height"?: pt, "x"?: pt, "y"?: pt, "page"?: n} (x/y: the top-left on page `page` (0-based, default its page); moving an inline object floats it)"#)
|
||||
.params(r#"{"width"?: pt, "height"?: pt, "x"?: pt, "y"?: pt, "page"?: n, "to"?: Pos} (x/y: the top-left on page `page` (0-based, default its page); moving an inline object by x/y floats it. to: an In Line with Text object moves to that text position and stays inline, as a drag does)"#)
|
||||
.when(has_movable),
|
||||
CommandSpec::new("arrange.copyTo", "Copy to", "Layout › Arrange", copy_to)
|
||||
.params(r#"{"x": pt, "y": pt, "page"?: n} (Ctrl+drag: a copy of the selected object lands with its top-left there and is selected; the original and the clipboard stay as they were)"#)
|
||||
.params(r#"{"x": pt, "y": pt, "page"?: n} | {"to": Pos} (Ctrl+drag: a copy of the selected object lands with its top-left there, or an In Line with Text one at the text position `to`, and is selected; the original and the clipboard stay as they were)"#)
|
||||
.when(has_movable),
|
||||
CommandSpec::new("arrange.nudge", "Nudge", "Layout › Arrange", |s, v| {
|
||||
let d = |k| p::f32(v, k).unwrap_or(0.0).clamp(-MAX_OFFSET, MAX_OFFSET);
|
||||
@@ -357,24 +357,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
.params(r#"{"index"?: n}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("shape.fill", "Shape Fill", "Shape Format › Shape Styles", |s, v| {
|
||||
let c = p::str(v, "color").and_then(Rgb::parse);
|
||||
with_obj(s, |o| {
|
||||
if let InlineObject::Shape { fill, .. } = o {
|
||||
*fill = c;
|
||||
} else if let Some(style) = super::canvas::style_mut(o) {
|
||||
style.fill = c;
|
||||
}
|
||||
})
|
||||
})
|
||||
.params(r#"{"color": "RRGGBB" | null} (a selected drawing canvas: its background)"#)
|
||||
.when(has_shape_or_canvas),
|
||||
CommandSpec::new("shape.outline", "Shape Outline", "Shape Format › Shape Styles", |s, v| {
|
||||
let c = p::str(v, "color").and_then(Rgb::parse);
|
||||
let w = p::f32(v, "width").map(|w| w.clamp(0.0, 72.0));
|
||||
with_obj(s, |o| {
|
||||
if let InlineObject::Shape { stroke, stroke_width, .. } = o {
|
||||
if let InlineObject::Shape { stroke, stroke_width, extra, .. } = o {
|
||||
*stroke = c;
|
||||
// A colour chosen here replaces an outline gradient from a file.
|
||||
extra.outline_fill = None;
|
||||
if let Some(w) = w {
|
||||
*stroke_width = w;
|
||||
}
|
||||
@@ -678,6 +668,10 @@ fn bounds(s: &mut Session, v: &Value) -> CmdResult {
|
||||
(None, None) => None,
|
||||
_ => return Err(CmdError::Params("`x` and `y` go together".into())),
|
||||
};
|
||||
let in_text = text_target(v, &obj)?;
|
||||
if in_text.is_some() && to.is_some() {
|
||||
return Err(CmdError::Params("`to` or `x`/`y`, not both".into()));
|
||||
}
|
||||
let (w0, h0) = obj_size(&obj);
|
||||
let min = min_size(&obj);
|
||||
let w = p::f32(v, "width").unwrap_or(w0).clamp(min, MAX_OFFSET);
|
||||
@@ -685,24 +679,92 @@ fn bounds(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if (w, h) != (w0, h0) {
|
||||
with_obj(s, |o| o.set_size(w, h))?;
|
||||
}
|
||||
let pos = match to {
|
||||
Some((x, y)) => move_object(s, pos, p::u64(v, "page"), x, y, (w, h))?,
|
||||
None => pos,
|
||||
let pos = match (to, in_text) {
|
||||
(Some((x, y)), _) => move_object(s, pos, p::u64(v, "page"), x, y, (w, h))?,
|
||||
(None, Some(at)) => move_in_text(s, pos, &at)?,
|
||||
(None, None) => pos,
|
||||
};
|
||||
s.sel = Selection { anchor: pos.clone(), focus: Pos { off: pos.off + wordcraft_doc::para::OBJ.len_utf8(), ..pos.clone() } };
|
||||
select_object(s, &pos);
|
||||
let o = s.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)).cloned();
|
||||
Ok(json!({"object": serde_json::to_value(o).unwrap_or(Value::Null), "pos": super::pos_json(&pos)}))
|
||||
}
|
||||
|
||||
/// Select the object whose character is at `pos`.
|
||||
fn select_object(s: &mut Session, pos: &Pos) {
|
||||
s.sel = Selection { anchor: pos.clone(), focus: Pos { off: pos.off + wordcraft_doc::para::OBJ.len_utf8(), ..pos.clone() } };
|
||||
}
|
||||
|
||||
/// The text position in `v`'s `to`, for an In Line with Text `obj` (a floating one is placed by
|
||||
/// `x`/`y`); None without one.
|
||||
fn text_target(v: &Value, obj: &InlineObject) -> Result<Option<Pos>, CmdError> {
|
||||
let Some(to) = v.get("to").filter(|t| !t.is_null()) else { return Ok(None) };
|
||||
if obj.is_floating() {
|
||||
return Err(CmdError::Params("`to` places an In Line with Text object; move a floating one by `x` and `y`".into()));
|
||||
}
|
||||
super::parse_pos(to).map(Some).ok_or_else(|| CmdError::Params("bad `to`".into()))
|
||||
}
|
||||
|
||||
/// The text position `to` an In Line with Text object at `pos` can land at: in the object's own
|
||||
/// story (a table cell there too), not inside a field's code or such (it's clamped to a
|
||||
/// character).
|
||||
fn landing(s: &Session, pos: &Pos, to: &Pos) -> Result<Pos, CmdError> {
|
||||
if pos.path.depth() > 0 || !movable_story(s, pos.story) {
|
||||
return Err(CmdError::Disabled("only objects in the body text, headers and footers can be moved".into()));
|
||||
}
|
||||
if to.story != pos.story || s.doc.para_at(to).is_none() {
|
||||
return Err(CmdError::Params("`to` must be a position in the object's own text".into()));
|
||||
}
|
||||
Ok(s.doc.clamp(to))
|
||||
}
|
||||
|
||||
/// Move the In Line with Text object at `pos` to the text position `to`, where it stays In Line
|
||||
/// with Text, keeping its character formatting: Word's drag of an inline picture, which lands
|
||||
/// where its dashed insertion cursor was (#2233, #2234). Dropped on itself, nothing changes.
|
||||
/// Returns the object's position afterwards.
|
||||
fn move_in_text(s: &mut Session, pos: Pos, to: &Pos) -> Result<Pos, CmdError> {
|
||||
let to = landing(s, &pos, to)?;
|
||||
let len = wordcraft_doc::para::OBJ.len_utf8();
|
||||
let same_para = to.path == pos.path;
|
||||
if same_para && (to.off == pos.off || to.off == pos.off + len) {
|
||||
return Ok(pos);
|
||||
}
|
||||
let para = s.doc.para_at(&pos).ok_or_else(|| CmdError::Failed("object vanished".into()))?;
|
||||
let obj = para.object_at(pos.off).cloned().ok_or_else(|| CmdError::Failed("object vanished".into()))?;
|
||||
let props = para.props_of_char(pos.off).clone();
|
||||
s.doc.delete_range(&pos, &Pos { off: pos.off + len, ..pos.clone() })?;
|
||||
let mut at = to;
|
||||
if same_para && at.off > pos.off {
|
||||
at.off = at.off.saturating_sub(len);
|
||||
}
|
||||
let at = s.doc.clamp(&at);
|
||||
s.doc.insert_object(&at, obj, &props)?;
|
||||
Ok(at)
|
||||
}
|
||||
|
||||
/// Ctrl+drag: copy the selected object (a text box gets its own story) and move the copy to
|
||||
/// (x, y), leaving the original in place and the clipboard alone. The copy is selected.
|
||||
/// (x, y), or for an In Line with Text object put it at the text position `to`, leaving the
|
||||
/// original in place and the clipboard alone. The copy is selected.
|
||||
fn copy_to(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let (pos, _) = selected(s).ok_or_else(|| CmdError::Disabled("no picture or shape selected".into()))?;
|
||||
let (pos, obj) = selected(s).ok_or_else(|| CmdError::Disabled("no picture or shape selected".into()))?;
|
||||
let len = wordcraft_doc::para::OBJ.len_utf8();
|
||||
let end = Pos { off: pos.off + len, ..pos.clone() };
|
||||
if let Some(to) = text_target(v, &obj)? {
|
||||
if p::f32(v, "x").is_some() || p::f32(v, "y").is_some() {
|
||||
return Err(CmdError::Params("`to` or `x`/`y`, not both".into()));
|
||||
}
|
||||
let at = landing(s, &pos, &to)?;
|
||||
let frag = s.doc.copy_range(&pos, &end);
|
||||
s.sel = Selection::caret(at.clone());
|
||||
super::paste::insert(s, frag)?;
|
||||
// The copy starts where it was pasted.
|
||||
let copy = s.doc.clamp(&at);
|
||||
select_object(s, ©);
|
||||
let o = s.doc.para_at(©).and_then(|p| p.object_at(copy.off)).cloned();
|
||||
return Ok(json!({"object": serde_json::to_value(o).unwrap_or(Value::Null), "pos": super::pos_json(©)}));
|
||||
}
|
||||
if p::f32(v, "x").is_none() || p::f32(v, "y").is_none() {
|
||||
return Err(CmdError::Params("`x` and `y` are required".into()));
|
||||
}
|
||||
let len = wordcraft_doc::para::OBJ.len_utf8();
|
||||
let end = Pos { off: pos.off + len, ..pos.clone() };
|
||||
let frag = s.doc.copy_range(&pos, &end);
|
||||
s.sel = Selection::caret(end.clone());
|
||||
super::paste::insert(s, frag)?;
|
||||
@@ -775,27 +837,27 @@ fn move_object_once(s: &mut Session, pos: &Pos, page: usize, x: f32, y: f32, (w,
|
||||
s.doc.insert_object(&at, obj, &wordcraft_doc::CharProps::default())?;
|
||||
at
|
||||
};
|
||||
let (h_rel, v_rel) = if on_page { (Anchor::Page, Anchor::Page) } else { (Anchor::Column, Anchor::Paragraph) };
|
||||
// First on the page where it was dropped, so the text wraps around it as it will in the end.
|
||||
edit_float(s, &at, |f| {
|
||||
if f.wrap == Wrap::Inline {
|
||||
f.wrap = Wrap::Square;
|
||||
}
|
||||
f.h_rel = h_rel;
|
||||
f.v_rel = v_rel;
|
||||
f.h_rel = Anchor::Page;
|
||||
f.v_rel = Anchor::Page;
|
||||
f.h_align = None;
|
||||
f.v_align = None;
|
||||
f.x = 0.0;
|
||||
f.y = 0.0;
|
||||
f.x = x.clamp(-MAX_OFFSET, MAX_OFFSET);
|
||||
f.y = y.clamp(-MAX_OFFSET, MAX_OFFSET);
|
||||
})?;
|
||||
// Offsets from where the anchor puts it (its paragraph may have moved with the edit).
|
||||
let origin = if on_page {
|
||||
wordcraft_geom::Point::new(0.0, 0.0)
|
||||
} else {
|
||||
s.touch(); // lay out the edit so far
|
||||
let layout = s.layout();
|
||||
layout.object(&at, page).ok_or_else(|| CmdError::Failed("the object isn't laid out".into()))?.origin
|
||||
};
|
||||
if on_page {
|
||||
return Ok(at);
|
||||
}
|
||||
// Then offsets from its paragraph, as laid out with the object there.
|
||||
s.touch();
|
||||
let origin = s.layout().object(&at, page).ok_or_else(|| CmdError::Failed("the object isn't laid out".into()))?.origin;
|
||||
edit_float(s, &at, |f| {
|
||||
f.h_rel = Anchor::Column;
|
||||
f.v_rel = Anchor::Paragraph;
|
||||
f.x = (x - origin.x).clamp(-MAX_OFFSET, MAX_OFFSET);
|
||||
f.y = (y - origin.y).clamp(-MAX_OFFSET, MAX_OFFSET);
|
||||
})?;
|
||||
@@ -945,7 +1007,7 @@ fn with_obj(s: &mut Session, f: impl Fn(&mut InlineObject)) -> CmdResult {
|
||||
}
|
||||
|
||||
/// Change the object at `pos`; returns it afterwards.
|
||||
fn edit_obj(s: &mut Session, pos: &Pos, f: impl Fn(&mut InlineObject)) -> Result<InlineObject, CmdError> {
|
||||
pub(crate) fn edit_obj(s: &mut Session, pos: &Pos, f: impl Fn(&mut InlineObject)) -> Result<InlineObject, CmdError> {
|
||||
let para = s.doc.para_mut(pos.story, &pos.path)?;
|
||||
let o = para.object_at_mut(pos.off).ok_or_else(|| CmdError::Failed("object vanished".into()))?;
|
||||
f(o);
|
||||
@@ -980,13 +1042,17 @@ fn with_float(s: &mut Session, f: impl Fn(&mut Float)) -> CmdResult {
|
||||
/// trims (or restores) — for a flipped picture that's the source's right/bottom — so what stays
|
||||
/// visible keeps its place on the page: a crop handle drop is one command.
|
||||
fn crop(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let c = [p::f32(v, "left"), p::f32(v, "top"), p::f32(v, "right"), p::f32(v, "bottom")].map(|x| x.unwrap_or(0.0).clamp(0.0, 0.45));
|
||||
let mut c = [p::f32(v, "left"), p::f32(v, "top"), p::f32(v, "right"), p::f32(v, "bottom")].map(|x| x.unwrap_or(0.0).clamp(0.0, 0.95));
|
||||
// The two sides of an axis keep at least 5% of the source between them, as the renderer
|
||||
// draws it: the second side stops where the first one already left less than that.
|
||||
c[2] = c[2].min(0.95 - c[0]);
|
||||
c[3] = c[3].min(0.95 - c[1]);
|
||||
let size = |k: &str| p::f32(v, k).map(|x| x.clamp(1.0, 4000.0));
|
||||
let (nw, nh) = (size("width"), size("height"));
|
||||
with_obj(s, |o| {
|
||||
if let InlineObject::Image { crop, w, h, float, .. } = o {
|
||||
// The source's full size at the new display size (the visible part is 1-lo-hi of it).
|
||||
let full = |len: f32, lo: f32, hi: f32| len / (1.0 - lo - hi).max(0.1);
|
||||
let full = |len: f32, lo: f32, hi: f32| len / (1.0 - lo - hi).max(0.05);
|
||||
// Flipped, the source's right (bottom) edge is the one on the page's left (top).
|
||||
let (left, top) = (if float.flip_h { 2 } else { 0 }, if float.flip_v { 3 } else { 1 });
|
||||
if let Some(nw) = nw {
|
||||
@@ -1035,7 +1101,7 @@ pub fn original_size(s: &Session, media: &str) -> Option<OriginalSize> {
|
||||
pub fn cropped_original_size(s: &Session, o: &InlineObject) -> Option<(f32, f32)> {
|
||||
let InlineObject::Image { media, crop, .. } = o else { return None };
|
||||
let orig = original_size(s, media)?;
|
||||
let part = |lo: f32, hi: f32| (1.0 - lo - hi).max(0.1);
|
||||
let part = |lo: f32, hi: f32| (1.0 - lo - hi).max(0.05);
|
||||
Some((orig.w * part(crop[0], crop[2]), orig.h * part(crop[1], crop[3])))
|
||||
}
|
||||
|
||||
@@ -1611,7 +1677,7 @@ fn shown_members(group: &InlineObject, [x, y, w, h]: [f32; 4]) -> Vec<([f32; 4],
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn all_objects(s: &Session) -> Vec<(Pos, InlineObject)> {
|
||||
pub(crate) fn all_objects(s: &Session) -> Vec<(Pos, InlineObject)> {
|
||||
let mut v = Vec::new();
|
||||
for path in s.doc.para_paths(StoryRef::Body) {
|
||||
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
|
||||
@@ -1724,6 +1790,103 @@ mod tests {
|
||||
assert_eq!(count(&s), 1);
|
||||
}
|
||||
|
||||
/// The body's top-level paragraphs, an object's character shown as ▣.
|
||||
fn body_text(s: &Session) -> String {
|
||||
(0..2)
|
||||
.filter_map(|i| s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(i)))
|
||||
.map(|p| p.text.replace(wordcraft_doc::para::OBJ, "▣"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// Two paragraphs, the second starting with an inline picture (bold, so its formatting can be
|
||||
/// followed), selected.
|
||||
fn inline_picture_session() -> Session {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("text.insert", &json!({"text": "First paragraph."})).unwrap();
|
||||
s.run("text.newParagraph", &json!({})).unwrap();
|
||||
s.run("format.bold", &json!({})).unwrap();
|
||||
let data = super::super::insert::base64_encode(&png(20, 10));
|
||||
s.run("insert.picture", &json!({"data": data, "width": 100})).unwrap();
|
||||
s.run("format.bold", &json!({})).unwrap();
|
||||
s.run("text.insert", &json!({"text": " second paragraph."})).unwrap();
|
||||
let pic = Pos::body(1, 0);
|
||||
s.run("select.range", &json!({"anchor": pic, "focus": Pos { off: pic.off + wordcraft_doc::para::OBJ.len_utf8(), ..pic.clone() }})).unwrap();
|
||||
assert!(selected(&s).is_some_and(|(_, o)| !o.is_floating()));
|
||||
s
|
||||
}
|
||||
|
||||
/// #2234: dragging an In Line with Text picture into the text (`to`) moves it there and it stays
|
||||
/// inline, with its formatting, selected, in one undo step; it never floats.
|
||||
#[test]
|
||||
fn an_inline_picture_dragged_into_the_text_stays_inline() {
|
||||
let mut s = inline_picture_session();
|
||||
let text = body_text;
|
||||
assert_eq!(text(&s), "First paragraph.\n▣ second paragraph.");
|
||||
// Into the first paragraph, after "First".
|
||||
let r = s.run("arrange.bounds", &json!({"to": Pos::body(0, 5)})).unwrap();
|
||||
assert_eq!(text(&s), "First▣ paragraph.\n second paragraph.");
|
||||
assert_eq!(r["object"]["float"]["wrap"], "inline", "{r}");
|
||||
let (pos, obj) = selected(&s).expect("still selected");
|
||||
assert_eq!(pos, Pos::body(0, 5));
|
||||
assert!(!obj.is_floating());
|
||||
assert!(s.doc.para_at(&pos).unwrap().props_of_char(pos.off).bold == Some(true), "keeps its run's formatting");
|
||||
// Later in its own paragraph (after "paragraph", counted with it still there): the offset
|
||||
// accounts for its own character leaving.
|
||||
s.run("arrange.bounds", &json!({"to": Pos::body(0, 18)})).unwrap();
|
||||
assert_eq!(text(&s), "First paragraph▣.\n second paragraph.");
|
||||
// Each move is one undo step.
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(text(&s), "First▣ paragraph.\n second paragraph.");
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(text(&s), "First paragraph.\n▣ second paragraph.");
|
||||
// Dropped on itself (either side): nothing changes.
|
||||
let here = selected(&s).unwrap().0;
|
||||
s.run("arrange.bounds", &json!({"to": here})).unwrap();
|
||||
s.run("arrange.bounds", &json!({"to": Pos { off: here.off + wordcraft_doc::para::OBJ.len_utf8(), ..here.clone() }})).unwrap();
|
||||
assert_eq!(text(&s), "First paragraph.\n▣ second paragraph.");
|
||||
assert_eq!(selected(&s).unwrap().0, here);
|
||||
}
|
||||
|
||||
/// `to` is only for In Line with Text objects, in their own text; bad targets change nothing.
|
||||
#[test]
|
||||
fn moving_into_the_text_refuses_bad_targets() {
|
||||
let mut s = inline_picture_session();
|
||||
let before = body_text(&s);
|
||||
for bad in [
|
||||
json!({"to": "junk"}),
|
||||
json!({"to": {"story": "body", "path": [9], "off": 0}}),
|
||||
json!({"to": Pos::body(0, 0), "x": 10, "y": 10}),
|
||||
json!({"to": {"story": {"part": 99}, "path": [0], "off": 0}}),
|
||||
] {
|
||||
assert!(s.run("arrange.bounds", &bad).is_err(), "{bad}");
|
||||
assert!(s.run("arrange.copyTo", &bad).is_err(), "{bad}");
|
||||
}
|
||||
// A huge offset is clamped to the paragraph's end.
|
||||
s.run("arrange.bounds", &json!({"to": {"story": "body", "path": [0], "off": 999_999}})).unwrap();
|
||||
assert_eq!(body_text(&s), "First paragraph.▣\n second paragraph.");
|
||||
assert_ne!(body_text(&s), before);
|
||||
// A floating object is moved by x/y, not into the text.
|
||||
s.run("arrange.bounds", &json!({"page": 0, "x": 200, "y": 300})).unwrap();
|
||||
assert!(s.run("arrange.bounds", &json!({"to": Pos::body(1, 0)})).is_err());
|
||||
}
|
||||
|
||||
/// Ctrl+drag of an inline picture into the text: a copy lands there, inline and selected; the
|
||||
/// original stays.
|
||||
#[test]
|
||||
fn copying_an_inline_picture_into_the_text() {
|
||||
let mut s = inline_picture_session();
|
||||
s.clipboard_text = "keep".into();
|
||||
s.run("arrange.copyTo", &json!({"to": Pos::body(0, 5)})).unwrap();
|
||||
assert_eq!(body_text(&s), "First▣ paragraph.\n▣ second paragraph.");
|
||||
let (pos, obj) = selected(&s).expect("the copy is selected");
|
||||
assert_eq!(pos, Pos::body(0, 5));
|
||||
assert!(!obj.is_floating());
|
||||
assert_eq!(s.clipboard_text, "keep");
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(body_text(&s), "First paragraph.\n▣ second paragraph.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shapes_can_be_clicked_resized_and_dragged() {
|
||||
for kind in ["rectangle", "ellipse", "star", "arrow"] {
|
||||
@@ -2273,14 +2436,45 @@ mod tests {
|
||||
assert_eq!(image(&s), (150.0, 90.0, [0.25, 0.0, 0.0, 0.1]));
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(image(&s), (200.0, 100.0, [0.0; 4]));
|
||||
// Clamps: each side at most 0.45; hostile sizes are ignored or clamped, never applied raw.
|
||||
// Clamps: each side at most 0.95; hostile sizes are ignored or clamped, never applied raw.
|
||||
s.run("picture.crop", &json!({"left": 9.0, "right": -3.0, "width": "x", "height": -5.0})).unwrap();
|
||||
let (w, h, crop) = image(&s);
|
||||
assert_eq!(crop, [0.45, 0.0, 0.0, 0.0]);
|
||||
assert_eq!(crop, [0.95, 0.0, 0.0, 0.0]);
|
||||
assert_eq!(w, 200.0);
|
||||
assert!(h >= 1.0, "{h}");
|
||||
}
|
||||
|
||||
/// #3135: a picture can be cropped to any part of itself, not just its middle. A side goes to
|
||||
/// 0.95 (5% stays, as the renderer draws), and the two sides of an axis keep their 5%.
|
||||
#[test]
|
||||
fn a_picture_can_be_cropped_to_any_part_of_itself() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
let data = super::super::insert::base64_encode(&png(20, 10));
|
||||
s.run("insert.picture", &json!({"data": data})).unwrap();
|
||||
s.run("picture.size", &json!({"width": 200.0, "height": 100.0, "lockAspect": false})).unwrap();
|
||||
let crop = |s: &Session| match selected(s) {
|
||||
Some((_, InlineObject::Image { crop, .. })) => crop,
|
||||
other => panic!("expected image, got {other:?}"),
|
||||
};
|
||||
let left = |s: &Session| {
|
||||
let (_, o) = selected(s).unwrap();
|
||||
super::cropped_original_size(s, &o).unwrap().0
|
||||
};
|
||||
// 90% off the left keeps the rightmost tenth: the side is not held at 45%.
|
||||
s.run("picture.crop", &json!({"left": 0.9})).unwrap();
|
||||
assert_eq!(crop(&s), [0.9, 0.0, 0.0, 0.0]);
|
||||
assert!((left(&s) - 1.5).abs() < 1e-3, "a tenth of the 15 pt original: {}", left(&s));
|
||||
// With the left at 0.9 the right can take only 5% more, and what the crop leaves of the
|
||||
// original follows the crop down to that 5%.
|
||||
s.run("picture.crop", &json!({"left": 0.9, "right": 0.9})).unwrap();
|
||||
let c = crop(&s);
|
||||
assert!((c[0] - 0.9).abs() < 1e-6 && (c[2] - 0.05).abs() < 1e-5 && c[1] == 0.0 && c[3] == 0.0, "{c:?}");
|
||||
assert!((left(&s) - 0.75).abs() < 1e-3, "5% of the 15 pt original: {}", left(&s));
|
||||
// A single side past the cap stops at 0.95.
|
||||
s.run("picture.crop", &json!({"right": 4.0})).unwrap();
|
||||
assert_eq!(crop(&s), [0.0, 0.0, 0.95, 0.0]);
|
||||
}
|
||||
|
||||
/// Cropping a floating picture's left or top with its new size keeps the visible part where
|
||||
/// it is on the page: the frame moves by the trimmed amount, in the same undo step.
|
||||
#[test]
|
||||
|
||||
@@ -11,7 +11,7 @@ pub const PANES: [&str; 8] = ["general", "display", "proofing", "save", "languag
|
||||
|
||||
/// Longest initials kept, in characters.
|
||||
pub const MAX_INITIALS_CHARS: usize = 9;
|
||||
/// Longest dictionaries folder path taken, in characters.
|
||||
/// Longest folder path (dictionaries, user templates) taken, in characters.
|
||||
const MAX_FOLDER_CHARS: usize = 4096;
|
||||
/// Longest word the custom dictionary takes, in characters.
|
||||
pub const MAX_WORD_CHARS: usize = 64;
|
||||
@@ -22,7 +22,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("file.options", "Options", "File", options)
|
||||
.params(
|
||||
r#"{"pane"?: "general|display|proofing|save|language|accessibility|advanced|agents", "author"?: string, "initials"?: string, "units"?: "inches|centimeters|millimeters|points|picas|auto" (auto: the region's), "region"?: string (a locale tag like pl-PL, saved: the paper File › New starts on, the default units and the decimal separator; auto: the system's), "typingReplacesSelection"?: bool, "insertKeyOvertype"?: bool, "overtype"?: bool, "marks"?: {"tabs"?: bool, "spaces"?: bool, "paragraphs"?: bool, "hidden"?: bool}, "checkSpelling"?: bool, "markGrammar"?: bool, "ignoreUppercase"?: bool, "ignoreNumbers"?: bool, "ignoreInternet"?: bool, "dictionariesFolder"?: string (a folder of Hunspell .aff/.dic files searched first, saved; auto: the system locations only), "addWords"?: [string], "removeWords"?: [string]} — with any setting it changes them and opens nothing; without, it opens File › Options (at `pane`). Returns the settings."#,
|
||||
r#"{"pane"?: "general|display|proofing|save|language|accessibility|advanced|agents", "author"?: string, "initials"?: string, "units"?: "inches|centimeters|millimeters|points|picas|auto" (auto: the region's), "region"?: string (a locale tag like pl-PL, saved: the paper File › New starts on, the default units and the decimal separator; auto: the system's), "typingReplacesSelection"?: bool, "insertKeyOvertype"?: bool, "overtype"?: bool, "marks"?: {"tabs"?: bool, "spaces"?: bool, "paragraphs"?: bool, "hidden"?: bool}, "checkSpelling"?: bool, "markGrammar"?: bool, "ignoreUppercase"?: bool, "ignoreNumbers"?: bool, "ignoreInternet"?: bool, "dictionariesFolder"?: string (a folder of Hunspell .aff/.dic files searched first, saved; auto: the system locations only), "userTemplatesFolder"?: string (the folder File › New lists personal templates from, saved; auto: the app's own Templates folder; not a network folder), "addWords"?: [string], "removeWords"?: [string]} — with any setting it changes them and opens nothing; without, it opens File › Options (at `pane`). Returns the settings."#,
|
||||
)
|
||||
.pure(),
|
||||
CommandSpec::new("text.overtype", "Overtype", "File › Options › Advanced", |s, v| {
|
||||
@@ -117,9 +117,15 @@ fn options(s: &mut Session, v: &Value) -> CmdResult {
|
||||
};
|
||||
let folder = match v.get("dictionariesFolder") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(f)) => Some(dictionaries_folder(f)?),
|
||||
Some(Value::String(f)) => Some(folder_setting("dictionariesFolder", f, false)?),
|
||||
Some(_) => return Err(CmdError::Params("`dictionariesFolder` must be a folder path, or auto".into())),
|
||||
};
|
||||
let templates_folder = match v.get("userTemplatesFolder") {
|
||||
None | Some(Value::Null) => None,
|
||||
// Never a folder on another machine (#2852): listing it would make Windows authenticate there.
|
||||
Some(Value::String(f)) => Some(folder_setting("userTemplatesFolder", f, true)?),
|
||||
Some(_) => return Err(CmdError::Params("`userTemplatesFolder` must be a folder path, or auto".into())),
|
||||
};
|
||||
let mut flag_values = Vec::new();
|
||||
for (k, f) in flags() {
|
||||
if let Some(b) = bool_param(v, k)? {
|
||||
@@ -147,6 +153,7 @@ fn options(s: &mut Session, v: &Value) -> CmdResult {
|
||||
|| units.is_some()
|
||||
|| region.is_some()
|
||||
|| folder.is_some()
|
||||
|| templates_folder.is_some()
|
||||
|| !flag_values.is_empty()
|
||||
|| !mark_values.is_empty()
|
||||
|| check_spelling.is_some()
|
||||
@@ -168,6 +175,9 @@ fn options(s: &mut Session, v: &Value) -> CmdResult {
|
||||
use_dictionaries_folder(f.as_deref());
|
||||
s.prefs.dictionaries_folder = f;
|
||||
}
|
||||
if let Some(f) = templates_folder {
|
||||
s.prefs.user_templates_folder = f;
|
||||
}
|
||||
for (f, b) in flag_values {
|
||||
*f(&mut s.prefs) = b;
|
||||
}
|
||||
@@ -176,6 +186,7 @@ fn options(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}
|
||||
if let Some(b) = check_spelling {
|
||||
s.view.proofing = b;
|
||||
s.prefs.check_spelling = b;
|
||||
}
|
||||
for w in &add {
|
||||
wordcraft_proof::add_word(w);
|
||||
@@ -221,6 +232,7 @@ pub fn current(s: &Session) -> Value {
|
||||
"ignoreInternet": pr.ignore_internet,
|
||||
"dictionary": wordcraft_proof::user_dictionary(),
|
||||
"dictionariesFolder": pr.dictionaries_folder,
|
||||
"userTemplatesFolder": pr.user_templates_folder,
|
||||
"dictionaries": wordcraft_proof::installed_dictionaries()
|
||||
.into_iter()
|
||||
.map(|(lang, dic)| json!({"language": lang.code(), "file": dic.map(|d| d.to_string_lossy().into_owned())}))
|
||||
@@ -228,23 +240,28 @@ pub fn current(s: &Session) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
/// The `dictionariesFolder` setting: `auto` (or nothing) forgets it; otherwise an existing
|
||||
/// folder, given as an absolute path or one starting with `~/`.
|
||||
fn dictionaries_folder(f: &str) -> Result<Option<String>, CmdError> {
|
||||
/// A folder setting (`dictionariesFolder`, `userTemplatesFolder`): `auto` (or nothing) forgets
|
||||
/// it; otherwise an existing folder, given as an absolute path or one starting with `~/`. With
|
||||
/// `local_only`, a network folder is refused before anything looks at it (#2852): even checking
|
||||
/// that it exists would make Windows authenticate to that host.
|
||||
fn folder_setting(key: &str, f: &str, local_only: bool) -> Result<Option<String>, CmdError> {
|
||||
let f = f.trim();
|
||||
if f.is_empty() || f.eq_ignore_ascii_case("auto") {
|
||||
return Ok(None);
|
||||
}
|
||||
if f.chars().count() > MAX_FOLDER_CHARS || f.chars().any(char::is_control) {
|
||||
return Err(CmdError::Params("`dictionariesFolder` isn't a folder path".into()));
|
||||
return Err(CmdError::Params(format!("`{key}` isn't a folder path")));
|
||||
}
|
||||
let home = std::env::var_os("HOME").filter(|h| !h.is_empty()).map(std::path::PathBuf::from);
|
||||
let path = match (f.strip_prefix("~/"), home) {
|
||||
(Some(rest), Some(h)) => h.join(rest),
|
||||
_ => std::path::PathBuf::from(f),
|
||||
};
|
||||
if local_only && (super::templates::is_network_path(f) || super::templates::is_network_path(&path.to_string_lossy())) {
|
||||
return Err(CmdError::Params(format!("`{key}` can't be a network folder")));
|
||||
}
|
||||
if !path.is_absolute() {
|
||||
return Err(CmdError::Params(format!("`dictionariesFolder`: {f:?} isn't a full path")));
|
||||
return Err(CmdError::Params(format!("`{key}`: {f:?} isn't a full path")));
|
||||
}
|
||||
if !path.is_dir() {
|
||||
return Err(CmdError::Failed(format!("no folder at {}", path.display())));
|
||||
|
||||
@@ -72,15 +72,21 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}),
|
||||
CommandSpec::new("layout.pageNumberFormat", "Format Page Numbers", "Insert › Header & Footer › Page Number", |s, v| {
|
||||
let fmt = p::str(v, "format").map(wordcraft_doc::section::NumFormat::from_ooxml);
|
||||
let start = p::u64(v, "start").map(|x| x.min(100_000) as u32);
|
||||
with_sect(s, |x| {
|
||||
if let Some(f) = fmt {
|
||||
x.page_num_format = f;
|
||||
}
|
||||
x.page_num_start = start;
|
||||
// Left out entirely: the numbering keeps its start. Given as null: the pages
|
||||
// number on from the section before (the dialog's "Continue from previous
|
||||
// section"). A number: numbering starts there.
|
||||
if v.get("start").is_some() {
|
||||
x.page_num_start = p::u64(v, "start").map(|n| n.min(100_000) as u32);
|
||||
}
|
||||
})
|
||||
})
|
||||
.params(r#"{"format"?: "decimal|lowerRoman|upperRoman|lowerLetter|upperLetter", "start"?: n}"#),
|
||||
.params(
|
||||
r#"{"format"?: "decimal|lowerRoman|upperRoman|lowerLetter|upperLetter", "start"?: n | null} ("start" left out: the numbering keeps its start; null: the pages number on from the section before; a number: numbering starts there)"#,
|
||||
),
|
||||
CommandSpec::new("layout.documentGrid", "Document Grid", "Layout › Page Setup › Document Grid", document_grid).params(
|
||||
r#"{"type"?: "none|lines|linesAndChars|snapToChars", "linesPerPage"?: n (sets the line pitch from the text height), "charsPerLine"?: n (sets the character pitch from the text width), "linePitch"?: pt, "charSpace"?: n (4096ths of a point added to the default font size: the character pitch)} — left out stays as it is; returns the grid with its lines per page and characters per line"#,
|
||||
),
|
||||
@@ -234,7 +240,7 @@ fn section_of_part(s: &Session, id: u32) -> usize {
|
||||
0
|
||||
}
|
||||
|
||||
fn section_index_of_focus(s: &Session) -> usize {
|
||||
pub fn section_index_of_focus(s: &Session) -> usize {
|
||||
match s.sel.focus.story {
|
||||
wordcraft_doc::StoryRef::Body => s.doc.section_index_of(s.sel.focus.path.0.first().copied().unwrap_or(0) as usize),
|
||||
wordcraft_doc::StoryRef::Part(id) => section_of_part(s, id),
|
||||
@@ -1171,4 +1177,27 @@ mod tests {
|
||||
assert_eq!(s.doc.plain_text(wordcraft_doc::StoryRef::Body).matches('\u{ad}').count(), found);
|
||||
assert!(s.run("layout.manualHyphenation", &json!({"from": {"block": 999, "off": 0}})).unwrap()["done"] == true);
|
||||
}
|
||||
|
||||
/// `layout.pageNumberFormat` sets the section's number format and start; a call without
|
||||
/// `start` keeps it (the ribbon's old plain call cleared it), and a null `start` numbers on
|
||||
/// from the section before — the Format Page Numbers dialog's "Continue from previous
|
||||
/// section" (#1113).
|
||||
#[test]
|
||||
fn page_number_format_keeps_or_clears_the_start() {
|
||||
use wordcraft_doc::section::NumFormat;
|
||||
let mut s = doc();
|
||||
s.run("layout.pageNumberFormat", &json!({"format": "upperRoman", "start": 3})).unwrap();
|
||||
let x = sect(&s);
|
||||
assert_eq!((x.page_num_format, x.page_num_start), (NumFormat::UpperRoman, Some(3)));
|
||||
s.run("layout.pageNumberFormat", &json!({"format": "lowerLetter"})).unwrap();
|
||||
let x = sect(&s);
|
||||
assert_eq!((x.page_num_format, x.page_num_start), (NumFormat::LowerLetter, Some(3)), "a format change keeps the start");
|
||||
s.run("layout.pageNumberFormat", &json!({"start": null})).unwrap();
|
||||
let x = sect(&s);
|
||||
assert_eq!((x.page_num_format, x.page_num_start), (NumFormat::LowerLetter, None), "continue numbers on from the section before");
|
||||
// The result carries the section back (scripts read it instead of the dialog).
|
||||
let back = s.run("layout.pageNumberFormat", &json!({"format": "decimal", "start": 1})).unwrap();
|
||||
assert_eq!(back["pageNumFormat"], "decimal");
|
||||
assert_eq!(back["pageNumStart"], 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -510,17 +510,20 @@ fn apply_style(s: &mut Session, v: &Value) -> CmdResult {
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Toggle lists using effective paragraph numbering; an explicit off request removes numbering.
|
||||
fn list(s: &mut Session, v: &Value, default: ListKind) -> CmdResult {
|
||||
let kind = p::str(v, "kind").and_then(ListKind::parse).unwrap_or(default);
|
||||
let off = p::bool(v, "off").unwrap_or(false);
|
||||
let a = s.sel.ordered().0;
|
||||
// Toggle: if every selected paragraph already has this kind of list, remove it.
|
||||
let current: Vec<Option<NumRef>> =
|
||||
super::selected_paths(s).iter().map(|p| s.doc.para(a.story, p).and_then(|x| x.props.numbering).filter(|n| n.num != 0)).collect();
|
||||
let current: Vec<Option<NumRef>> = super::selected_paths(s)
|
||||
.iter()
|
||||
.map(|p| s.doc.para(a.story, p).and_then(|x| s.doc.styles.resolve_para(&x.props).numbering).filter(|n| n.num != 0))
|
||||
.collect();
|
||||
let same_kind = |n: &NumRef| {
|
||||
s.doc.numbering.level(n.num, 0).is_some_and(|l| {
|
||||
s.doc.numbering.level(n.num, n.level).is_some_and(|l| {
|
||||
let want = wordcraft_doc::numbering::levels_for(kind);
|
||||
want.first().is_some_and(|w| {
|
||||
want.get(usize::from(n.level)).is_some_and(|w| {
|
||||
(w.format == wordcraft_doc::section::NumFormat::Bullet) == (l.format == wordcraft_doc::section::NumFormat::Bullet)
|
||||
&& (p::str(v, "kind").is_none() || w.text == l.text)
|
||||
})
|
||||
@@ -528,13 +531,14 @@ fn list(s: &mut Session, v: &Value, default: ListKind) -> CmdResult {
|
||||
};
|
||||
let remove = off || (!current.is_empty() && current.iter().all(|n| n.as_ref().is_some_and(same_kind)));
|
||||
if remove {
|
||||
let keep_list_style = s.doc.styles.resolve_para(&ParaProps { style: Some("ListParagraph".into()), ..Default::default() }).numbering.is_some();
|
||||
return fmt(s, &|p| {
|
||||
p.numbering = Some(NumRef { num: 0, level: 0 });
|
||||
p.indent_left = None;
|
||||
p.indent_first = None;
|
||||
p.indent_left_chars = None;
|
||||
p.indent_first_chars = None;
|
||||
if p.style.as_deref() == Some("ListParagraph") {
|
||||
if p.style.as_deref() == Some("ListParagraph") && !keep_list_style {
|
||||
p.style = None;
|
||||
}
|
||||
});
|
||||
@@ -544,7 +548,7 @@ fn list(s: &mut Session, v: &Value, default: ListKind) -> CmdResult {
|
||||
.doc
|
||||
.prev_para(a.story, &a.path)
|
||||
.and_then(|q| s.doc.para(a.story, &q).and_then(|x| x.props.numbering))
|
||||
.filter(|n| n.num != 0 && same_kind(n));
|
||||
.filter(|n| n.num != 0 && same_kind(&NumRef { level: 0, ..*n }));
|
||||
let num = match prev_num {
|
||||
Some(n) => n.num,
|
||||
None => match kind {
|
||||
|
||||
@@ -207,6 +207,23 @@ fn always_allowed(id: &str) -> bool {
|
||||
id == "review.restrict" || id.starts_with("file.") || id == "edit.undo" || id == "edit.redo"
|
||||
}
|
||||
|
||||
/// Why protection refuses command `id` whatever its params and the selection, so the ribbon greys
|
||||
/// it out ([`Session::why_disabled`]); cheap enough to ask for every button on every frame.
|
||||
/// Observed in Word for Mac: with Filling in forms enforced, the Developer tab's control buttons
|
||||
/// and Properties are greyed out and a field's shortcut menu offers no Remove Content Control.
|
||||
/// The form's fields stay as they are; only their content is filled in.
|
||||
pub(crate) fn greyed_out(s: &Session, id: &str) -> Option<&'static str> {
|
||||
if s.doc.settings.protection.as_deref() != Some("forms") {
|
||||
return None;
|
||||
}
|
||||
match id {
|
||||
"developer.removeControl" => Some("stop protection before removing a content control"),
|
||||
"developer.properties" => Some("stop protection before changing a content control's properties"),
|
||||
_ if CONTROL_INSERTS.contains(&id) => Some("stop protection before inserting a content control"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// What a protected document lets command `id` do before it runs: `Err` refuses it; `Ok(true)`
|
||||
/// lets it run as long as it only changes the exceptions (checked afterwards with
|
||||
/// [`outside_unchanged`]).
|
||||
@@ -223,19 +240,8 @@ pub(crate) fn check_before(s: &Session, id: &str, params: &Value) -> Result<bool
|
||||
if id == "developer.properties" && (params.get("lockEdit").is_some() || params.get("lockDelete").is_some()) {
|
||||
return Err(CmdError::Disabled(format!("{id}: stop protection before changing a content control's locks")));
|
||||
}
|
||||
// Observed in Word for Mac: with Filling in forms enforced, the Developer tab's control
|
||||
// buttons and Properties are greyed out and a field's shortcut menu offers no Remove Content
|
||||
// Control. The form's fields stay as they are; only their content is filled in.
|
||||
if mode == "forms" {
|
||||
if id == "developer.removeControl" {
|
||||
return Err(CmdError::Disabled(format!("{id}: stop protection before removing a content control")));
|
||||
}
|
||||
if id == "developer.properties" {
|
||||
return Err(CmdError::Disabled(format!("{id}: stop protection before changing a content control's properties")));
|
||||
}
|
||||
if CONTROL_INSERTS.contains(&id) {
|
||||
return Err(CmdError::Disabled(format!("{id}: stop protection before inserting a content control")));
|
||||
}
|
||||
if let Some(why) = greyed_out(s, id) {
|
||||
return Err(CmdError::Disabled(format!("{id}: {why}")));
|
||||
}
|
||||
if s.doc.settings.protect_formatting {
|
||||
check_formatting(s, id, params)?;
|
||||
@@ -766,6 +772,27 @@ mod tests {
|
||||
assert!(s.doc.control_ranges(StoryRef::Body).is_empty());
|
||||
}
|
||||
|
||||
/// The ribbon greys out what forms mode refuses outright (as Word for Mac does), and only that:
|
||||
/// typing into the field and the rest of the ribbon stay live.
|
||||
#[test]
|
||||
fn forms_mode_greys_out_the_control_commands() {
|
||||
let mut s = Session::new(Document::from_text("Name: Fill name."));
|
||||
select(&mut s, "Fill name.");
|
||||
s.run("developer.richText", &json!({"tag": "name"})).unwrap();
|
||||
let greyed = ["developer.removeControl", "developer.properties"].into_iter().chain(CONTROL_INSERTS);
|
||||
assert!(greyed.clone().all(|id| s.why_disabled(id).is_none()), "unprotected");
|
||||
s.run("review.restrict", &json!({"mode": "forms"})).unwrap();
|
||||
for id in greyed {
|
||||
let why = s.why_disabled(id).unwrap_or_default();
|
||||
assert!(why.contains("stop protection"), "{id}: {why:?}");
|
||||
}
|
||||
for id in ["text.insert", "format.bold", "review.restrict", "view.zoom"] {
|
||||
assert_eq!(s.why_disabled(id), None, "{id}");
|
||||
}
|
||||
s.run("review.restrict", &json!({"stop": true})).unwrap();
|
||||
assert_eq!(s.why_disabled("developer.removeControl"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forms_mode_without_fillable_controls_stays_locked() {
|
||||
let mut s = Session::new(Document::from_text("No controls here."));
|
||||
|
||||
@@ -149,6 +149,11 @@ impl FieldCode {
|
||||
self.switches.iter().any(|(k, _)| *k == c)
|
||||
}
|
||||
|
||||
/// A table of figures (`TOC \c` or `TOC \a`, ECMA-376 §17.16.5.68), not a table of contents.
|
||||
pub fn is_table_of_figures(&self) -> bool {
|
||||
self.name == "TOC" && (self.has('c') || self.has('a'))
|
||||
}
|
||||
|
||||
/// The argument of switch `c`, if the switch is there with one.
|
||||
pub fn arg(&self, c: char) -> Option<&str> {
|
||||
self.switches.iter().find(|(k, _)| *k == c).and_then(|(_, a)| a.as_deref())
|
||||
@@ -360,7 +365,7 @@ fn convertible_toc_range(s: &Session, r: &wordcraft_doc::FieldRange) -> bool {
|
||||
let field = FieldCode::parse(&r.instr);
|
||||
toc_range(s).is_none()
|
||||
&& field.name == "TOC"
|
||||
&& !field.has('c')
|
||||
&& !field.is_table_of_figures()
|
||||
&& r.depth == 0
|
||||
&& !r.locked
|
||||
&& r.start.path.depth() == 0
|
||||
@@ -425,7 +430,7 @@ fn toc_field(p: &Paragraph) -> Option<String> {
|
||||
p.objects.iter().find_map(|o| match o {
|
||||
InlineObject::Field { instr, .. } => {
|
||||
let f = FieldCode::parse(instr);
|
||||
(f.name == "TOC" && !f.has('c')).then(|| instr.clone())
|
||||
(f.name == "TOC" && !f.is_table_of_figures()).then(|| instr.clone())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
@@ -436,7 +441,7 @@ fn cached_toc_tabs(s: &Session) -> Vec<(String, Vec<TabStop>)> {
|
||||
let span = toc_range(s).map(|(start, end)| (start.saturating_add(1), end)).or_else(|| {
|
||||
s.doc.field_ranges(StoryRef::Body).into_iter().find_map(|r| {
|
||||
let f = FieldCode::parse(&r.instr);
|
||||
(r.depth == 0 && !r.locked && r.start.path.depth() == 0 && r.end.path.depth() == 0 && f.name == "TOC" && !f.has('c'))
|
||||
(r.depth == 0 && !r.locked && r.start.path.depth() == 0 && r.end.path.depth() == 0 && f.name == "TOC" && !f.is_table_of_figures())
|
||||
.then_some((r.start.path.last(), r.end.path.last()))
|
||||
})
|
||||
});
|
||||
@@ -560,7 +565,7 @@ fn regenerate_toc(s: &mut Session, leader: Option<TabLeader>, tabs: &[(String, V
|
||||
for o in &mut p.objects {
|
||||
if let InlineObject::Field { instr, result, .. } = o {
|
||||
let f = FieldCode::parse(instr);
|
||||
if f.name == "TOC" && !f.has('c') {
|
||||
if f.name == "TOC" && !f.is_table_of_figures() {
|
||||
result.clear();
|
||||
}
|
||||
}
|
||||
@@ -1013,6 +1018,19 @@ pub fn update_fields_in(s: &mut Session, all: bool) -> Result<(), CmdError> {
|
||||
"TITLE" => updates.push((k, s.doc.core.title.clone())),
|
||||
"AUTHOR" => updates.push((k, s.doc.core.creator.clone())),
|
||||
"NUMWORDS" => updates.push((k, s.doc.word_count().to_string())),
|
||||
// DOCPROPERTY <name>: a custom property first, then a built-in one (#1798).
|
||||
"DOCPROPERTY" => {
|
||||
if let Some(name) = doc_property_arg(instr)
|
||||
&& let Some(v) = doc_property(&s.doc, &name)
|
||||
{
|
||||
updates.push((k, v));
|
||||
}
|
||||
}
|
||||
"FILENAME" => {
|
||||
let (fn_name, fn_path) = s.resolved_filenames();
|
||||
let text = if has_switch(instr, 'p') { fn_path } else { fn_name };
|
||||
updates.push((k, text));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -1074,6 +1092,38 @@ pub fn refresh_date_fields(doc: &mut wordcraft_doc::Document) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The property name of a `DOCPROPERTY` field: the word (or quoted text) after the field name,
|
||||
/// before any switch (`DOCPROPERTY "Project Name" \* MERGEFORMAT`).
|
||||
fn doc_property_arg(instr: &str) -> Option<String> {
|
||||
let rest = instr.trim().split_once(char::is_whitespace)?.1.trim_start();
|
||||
let name = match rest.strip_prefix('"') {
|
||||
Some(q) => q.split('"').next().unwrap_or(""),
|
||||
None => rest.split([' ', '\\']).next().unwrap_or(""),
|
||||
};
|
||||
(!name.is_empty()).then(|| name.to_string())
|
||||
}
|
||||
|
||||
/// The value of a document property under the name a `DOCPROPERTY` field uses: a custom property
|
||||
/// when one has this name, else the built-in property (English names, case-insensitively, as
|
||||
/// Word's field accepts). `None` for a name this application doesn't know.
|
||||
fn doc_property(doc: &wordcraft_doc::Document, name: &str) -> Option<String> {
|
||||
if let Some(v) = doc.custom_prop(name) {
|
||||
return Some(v.to_string());
|
||||
}
|
||||
let c = &doc.core;
|
||||
Some(match name.trim().to_ascii_lowercase().as_str() {
|
||||
"title" => c.title.clone(),
|
||||
"subject" => c.subject.clone(),
|
||||
"author" | "creator" => c.creator.clone(),
|
||||
"keywords" => c.keywords.clone(),
|
||||
"comments" | "description" => c.description.clone(),
|
||||
"category" => c.category.clone(),
|
||||
"lastmodifiedby" | "last modified by" | "lastsavedby" | "last saved by" => c.last_modified_by.clone(),
|
||||
"revisionnumber" | "revision number" | "revision" => c.revision.to_string(),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// A field's `\@` date picture: the quoted text after it (switches may follow), or one word.
|
||||
pub fn date_picture(instr: &str) -> Option<String> {
|
||||
let rest = instr.split_once("\\@")?.1.trim_start();
|
||||
@@ -1325,6 +1375,48 @@ mod tests {
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_fields_filename() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.path = Some(std::path::PathBuf::from("/docs/my_thesis.docx"));
|
||||
s.run("insert.field", &serde_json::json!({"instr": "FILENAME"})).unwrap();
|
||||
let fields = story_fields(&s, StoryRef::Body);
|
||||
assert_eq!(fields[0], ("FILENAME".into(), "my_thesis.docx".into()));
|
||||
|
||||
s.run("insert.field", &serde_json::json!({"instr": r"FILENAME \p"})).unwrap();
|
||||
let fields = story_fields(&s, StoryRef::Body);
|
||||
assert_eq!(fields[1], (r"FILENAME \p".into(), "/docs/my_thesis.docx".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_fields_filename_titled_unsaved() {
|
||||
let mut doc = wordcraft_doc::Document::new();
|
||||
doc.core.title = "Important Document".into();
|
||||
let mut s = Session::new(doc);
|
||||
s.run("insert.field", &serde_json::json!({"instr": "FILENAME"})).unwrap();
|
||||
let fields = story_fields(&s, StoryRef::Body);
|
||||
assert_eq!(fields[0], ("FILENAME".into(), "Document1".into()));
|
||||
|
||||
let l = s.layout();
|
||||
let text: String = l.pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|it| {
|
||||
if let wordcraft_layout::Placed::Lines { para, .. } = it {
|
||||
Some(para.shown.iter().map(|x| x.1.clone()).collect::<String>())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
assert!(text.contains("Document1"), "live layout renders Document1: {text}");
|
||||
assert!(!text.contains("Important Document"), "live layout must not render title: {text}");
|
||||
|
||||
s.run("references.updateFields", &serde_json::json!({})).unwrap();
|
||||
let fields_after = story_fields(&s, StoryRef::Body);
|
||||
assert_eq!(fields_after[0], ("FILENAME".into(), "Document1".into()));
|
||||
}
|
||||
|
||||
/// #1706 (Word check): Update Field with the caret in the body leaves a header's fields alone.
|
||||
#[test]
|
||||
fn update_fields_in_the_body_leaves_headers_alone() {
|
||||
|
||||
+271
-13
@@ -12,17 +12,21 @@ use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("review.newComment", "New Comment", "Review › Comments", new_comment).key(super::mac_or("Mod+Alt+A / Mod+Alt+M", "Mod+Alt+M")).params(r#"{"text": string}"#),
|
||||
CommandSpec::new("review.reply", "Reply", "Review › Comments", reply).params(r#"{"id": n, "text": string}"#).when(comments_change),
|
||||
CommandSpec::new("review.editComment", "Edit Comment", "Review › Comments", edit_comment)
|
||||
.params(r#"{"id": n, "text": string}"#)
|
||||
CommandSpec::new("review.reply", "Reply", "Review › Comments", reply)
|
||||
.params(r#"{"id": non-negative JSON integer, "text": string}"#)
|
||||
.when(comments_change),
|
||||
CommandSpec::new("review.deleteComment", "Delete", "Review › Comments", delete_comment).params(r#"{"id"?: n, "all"?: bool}"#),
|
||||
CommandSpec::new("review.editComment", "Edit Comment", "Review › Comments", edit_comment)
|
||||
.params(r#"{"id": non-negative JSON integer, "text": string}"#)
|
||||
.when(comments_change),
|
||||
CommandSpec::new("review.deleteComment", "Delete", "Review › Comments", delete_comment).params(r#"{"id"?: non-negative JSON integer (omit to use the caret), "all"?: bool}"#),
|
||||
CommandSpec::new("review.resolveComment", "Resolve", "Review › Comments", |s, v| {
|
||||
validate_comment_id(v)?;
|
||||
let id = p::u64(v, "id").map(|x| x as u32).or_else(|| comment_at_caret(s)).ok_or_else(|| CmdError::Params("`id` required".into()))?;
|
||||
let c = s.doc.comments.get_mut(&id).ok_or_else(|| CmdError::Params("no such comment".into()))?;
|
||||
c.resolved = p::bool(v, "value").unwrap_or(!c.resolved);
|
||||
sel_result(s)
|
||||
}),
|
||||
})
|
||||
.params(r#"{"id"?: non-negative JSON integer (omit to use the caret), "value"?: bool (omit to toggle)}"#),
|
||||
CommandSpec::new("review.nextComment", "Next", "Review › Comments", |s, _| nav_comment(s, 1)).pure(),
|
||||
CommandSpec::new("review.previousComment", "Previous", "Review › Comments", |s, _| nav_comment(s, -1)).pure(),
|
||||
CommandSpec::new("review.comments", "Show Comments", "Review › Comments", list_comments).pure(),
|
||||
@@ -110,6 +114,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.params(r#"{"text": string}"#),
|
||||
CommandSpec::new("review.proofing", "Check Spelling as You Type", "File › Options › Proofing", |s, v| {
|
||||
s.view.proofing = p::bool(v, "value").unwrap_or(!s.view.proofing);
|
||||
s.prefs.check_spelling = s.view.proofing;
|
||||
Ok(json!({"value": s.view.proofing}))
|
||||
})
|
||||
.pure(),
|
||||
@@ -146,7 +151,17 @@ fn new_comment(s: &mut Session, v: &Value) -> CmdResult {
|
||||
Ok(json!({"id": id, "story": part}))
|
||||
}
|
||||
|
||||
/// An omitted ID permits caret targeting; an explicit malformed value must not.
|
||||
/// Unlike the general numeric parameter helper, comment IDs never coerce floats.
|
||||
fn validate_comment_id(v: &Value) -> Result<(), CmdError> {
|
||||
if v.get("id").is_some_and(|id| id.as_u64().is_none_or(|n| u32::try_from(n).is_err())) {
|
||||
return Err(CmdError::Params("`id` must be a non-negative 32-bit JSON integer".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reply(s: &mut Session, v: &Value) -> CmdResult {
|
||||
validate_comment_id(v)?;
|
||||
let parent = p::u64(v, "id").map(|x| x as u32).ok_or_else(|| CmdError::Params("`id` required".into()))?;
|
||||
if !s.doc.comments.contains_key(&parent) {
|
||||
return Err(CmdError::Params("no such comment".into()));
|
||||
@@ -179,6 +194,7 @@ fn comments_change(s: &Session) -> Option<&'static str> {
|
||||
|
||||
/// Replace a comment's (or reply's) text; each line becomes a paragraph.
|
||||
fn edit_comment(s: &mut Session, v: &Value) -> CmdResult {
|
||||
validate_comment_id(v)?;
|
||||
let id = p::u64(v, "id").and_then(|x| u32::try_from(x).ok()).ok_or_else(|| CmdError::Params("`id` required".into()))?;
|
||||
let text = p::req_str(v, "text")?;
|
||||
let part = s.doc.comments.get(&id).map(|c| c.part).ok_or_else(|| CmdError::Params("no such comment".into()))?;
|
||||
@@ -269,6 +285,7 @@ fn thread_of(s: &Session, id: u32) -> Vec<u32> {
|
||||
}
|
||||
|
||||
fn delete_comment(s: &mut Session, v: &Value) -> CmdResult {
|
||||
validate_comment_id(v)?;
|
||||
let ids: Vec<u32> = if p::bool(v, "all").unwrap_or(false) {
|
||||
s.doc.comments.keys().copied().collect()
|
||||
} else {
|
||||
@@ -285,14 +302,22 @@ fn delete_comment(s: &mut Session, v: &Value) -> CmdResult {
|
||||
Ok(json!({"deleted": ids}))
|
||||
}
|
||||
|
||||
/// Comments in document order with their anchors.
|
||||
/// Comments in reading order with their anchors: every story the reader sees, in
|
||||
/// `Document::shown_stories` order (#2289). A comment anchored only in a story nothing shows
|
||||
/// (a removed header) has no anchor.
|
||||
pub fn comment_list(s: &Session) -> Vec<(u32, Option<Pos>)> {
|
||||
let mut found: Vec<(u32, Option<Pos>)> = Vec::new();
|
||||
for path in s.doc.para_paths(StoryRef::Body) {
|
||||
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
|
||||
for o in p.object_offsets() {
|
||||
if let Some(InlineObject::CommentStart { id }) = p.object_at(o) {
|
||||
found.push((*id, Some(Pos { story: StoryRef::Body, path: path.clone(), off: o })));
|
||||
// The balloons ask every frame; most documents have no comments.
|
||||
if s.doc.comments.is_empty() {
|
||||
return found;
|
||||
}
|
||||
for story in s.doc.shown_stories() {
|
||||
for path in s.doc.para_paths(story) {
|
||||
let Some(p) = s.doc.para(story, &path) else { continue };
|
||||
for o in p.object_offsets() {
|
||||
if let Some(InlineObject::CommentStart { id }) = p.object_at(o) {
|
||||
found.push((*id, Some(Pos { story, path: path.clone(), off: o })));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -322,8 +347,16 @@ fn list_comments(s: &mut Session, _: &Value) -> CmdResult {
|
||||
|
||||
fn nav_comment(s: &mut Session, dir: i32) -> CmdResult {
|
||||
let list: Vec<Pos> = comment_list(s).into_iter().filter_map(|(_, p)| p).collect();
|
||||
let caret = s.sel.focus.clone();
|
||||
let t = if dir > 0 { list.iter().find(|p| **p > caret).or(list.first()) } else { list.iter().rev().find(|p| **p < caret).or(list.last()) };
|
||||
// Reading order: the story's place in `shown_stories`, then the place in the story. Part ids
|
||||
// follow creation, not reading, so `Pos` order won't do across stories.
|
||||
let rank: BTreeMap<StoryRef, usize> = s.doc.shown_stories().into_iter().enumerate().map(|(i, st)| (st, i)).collect();
|
||||
let key = |p: &Pos| rank.get(&p.story).map(|i| (*i, p.path.clone(), p.off));
|
||||
let caret = key(&s.sel.focus);
|
||||
let t = if dir > 0 {
|
||||
list.iter().find(|p| caret.is_some() && key(p) > caret).or(list.first())
|
||||
} else {
|
||||
list.iter().rev().find(|p| caret.is_some() && key(p) < caret).or(list.last())
|
||||
};
|
||||
if let Some(p) = t {
|
||||
s.sel = Selection::caret(p.clone());
|
||||
}
|
||||
@@ -1747,6 +1780,231 @@ mod tests {
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(5), "took {:?}", started.elapsed());
|
||||
}
|
||||
|
||||
/// A comment on the whole of the story the caret is in; its id and that story.
|
||||
fn comment_here(s: &mut Session, text: &str) -> (u64, wordcraft_doc::StoryRef) {
|
||||
let story = s.sel.focus.story;
|
||||
s.run("select.all", &json!({})).unwrap();
|
||||
(s.run("review.newComment", &json!({"text": text})).unwrap()["id"].as_u64().unwrap(), story)
|
||||
}
|
||||
|
||||
/// Comments in a header, a footer, a text box and a footnote, made in that order, then one
|
||||
/// on a word of the body. Returns each comment's id and story in reading order, which isn't
|
||||
/// the order they were made: the body, the text box and footnote it shows, then the header
|
||||
/// and the footer.
|
||||
fn comments_in_every_story(s: &mut Session) -> Vec<(u64, wordcraft_doc::StoryRef)> {
|
||||
s.view.proofing = false;
|
||||
s.run("document.setText", &json!({"text": "Body text with a comment on it."})).unwrap();
|
||||
s.run("insert.header", &json!({"text": "header"})).unwrap();
|
||||
let header = comment_here(s, "header");
|
||||
s.run("insert.closeHeader", &json!({})).unwrap();
|
||||
s.run("insert.footer", &json!({"text": "footer"})).unwrap();
|
||||
let footer = comment_here(s, "footer");
|
||||
s.run("insert.closeHeader", &json!({})).unwrap();
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
s.run("insert.textBox", &json!({"text": "boxed"})).unwrap();
|
||||
let text_box = comment_here(s, "text box");
|
||||
s.run("insert.closeHeader", &json!({})).unwrap();
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
s.run("references.footnote", &json!({"text": "noted"})).unwrap();
|
||||
let note = comment_here(s, "footnote");
|
||||
s.run("insert.closeHeader", &json!({})).unwrap();
|
||||
s.run("select.text", &json!({"text": "comment"})).unwrap();
|
||||
let body = (s.run("review.newComment", &json!({"text": "body"})).unwrap()["id"].as_u64().unwrap(), wordcraft_doc::StoryRef::Body);
|
||||
s.run("caret.docStart", &json!({})).unwrap();
|
||||
vec![body, text_box, note, header, footer]
|
||||
}
|
||||
|
||||
/// `review.comments` as (id, the anchor's story).
|
||||
fn listed(s: &mut Session) -> Vec<(u64, Option<wordcraft_doc::StoryRef>)> {
|
||||
let list = s.run("review.comments", &json!({})).unwrap();
|
||||
list.as_array().unwrap().iter().map(|c| (c["id"].as_u64().unwrap(), serde_json::from_value(c["anchor"]["story"].clone()).ok())).collect()
|
||||
}
|
||||
|
||||
/// The stories Next Comment visits, `n` presses from the caret.
|
||||
fn next_stories(s: &mut Session, n: usize) -> Vec<wordcraft_doc::StoryRef> {
|
||||
(0..n)
|
||||
.map(|_| {
|
||||
s.run("review.nextComment", &json!({})).unwrap();
|
||||
s.sel.focus.story
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// #2289: comments in headers, footers, text boxes and footnotes are listed with their
|
||||
/// anchors, in reading order rather than the order their stories were made.
|
||||
#[test]
|
||||
fn comments_outside_the_body_are_listed_with_their_anchors() {
|
||||
let mut s = Session::new(Document::new());
|
||||
let want: Vec<_> = comments_in_every_story(&mut s).into_iter().map(|(id, st)| (id, Some(st))).collect();
|
||||
assert_eq!(listed(&mut s), want);
|
||||
}
|
||||
|
||||
/// #2289: Next and Previous Comment go through every story's comments in reading order and
|
||||
/// wrap around.
|
||||
#[test]
|
||||
fn next_and_previous_comment_reach_comments_outside_the_body() {
|
||||
let mut s = Session::new(Document::new());
|
||||
let want: Vec<_> = comments_in_every_story(&mut s).into_iter().map(|(_, st)| st).collect();
|
||||
let mut wrapped = want.clone();
|
||||
wrapped.push(want[0]);
|
||||
assert_eq!(next_stories(&mut s, want.len() + 1), wrapped, "Next visits each comment, then wraps to the first");
|
||||
let back: Vec<_> = (0..want.len())
|
||||
.map(|_| {
|
||||
s.run("review.previousComment", &json!({})).unwrap();
|
||||
s.sel.focus.story
|
||||
})
|
||||
.collect();
|
||||
let mut rev = want.clone();
|
||||
rev.reverse();
|
||||
assert_eq!(back, rev, "Previous from the first comment wraps to the last");
|
||||
}
|
||||
|
||||
/// #2289: a header comes before the footer whichever was made first.
|
||||
#[test]
|
||||
fn next_comment_reaches_the_header_before_the_footer() {
|
||||
let mut s = Session::new(Document::new());
|
||||
s.view.proofing = false;
|
||||
s.run("document.setText", &json!({"text": "Body"})).unwrap();
|
||||
s.run("insert.footer", &json!({"text": "footer"})).unwrap();
|
||||
let footer = comment_here(&mut s, "footer").1;
|
||||
s.run("insert.closeHeader", &json!({})).unwrap();
|
||||
s.run("insert.header", &json!({"text": "header"})).unwrap();
|
||||
let header = comment_here(&mut s, "header").1;
|
||||
s.run("insert.closeHeader", &json!({})).unwrap();
|
||||
assert_eq!(next_stories(&mut s, 2), vec![header, footer]);
|
||||
}
|
||||
|
||||
/// #2289: a comment left in a story nothing shows any more (a removed header, a deleted
|
||||
/// footnote's text) has no anchor, and Next never takes the caret into that hidden story.
|
||||
#[test]
|
||||
fn comments_in_hidden_stories_are_not_navigated_to() {
|
||||
let mut s = Session::new(Document::new());
|
||||
let all = comments_in_every_story(&mut s);
|
||||
let (note, header) = (all[2], all[3]);
|
||||
s.run("insert.removeHeader", &json!({})).unwrap();
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
s.run("text.backspace", &json!({})).unwrap();
|
||||
assert!(!s.doc.shown_stories().contains(¬e.1), "the footnote's reference is gone");
|
||||
let list = listed(&mut s);
|
||||
for hidden in [note, header] {
|
||||
assert!(list.contains(&(hidden.0, None)), "comment {} sits in a hidden story: {list:?}", hidden.0);
|
||||
}
|
||||
let shown = [all[0].1, all[1].1, all[4].1];
|
||||
for st in next_stories(&mut s, all.len()) {
|
||||
assert!(shown.contains(&st), "Next went to hidden {st:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A body comment, then, with `option` on, a comment in the header of page `page`; `option`
|
||||
/// is turned off again. Returns the header comment's id and story.
|
||||
fn comment_in_header_version(s: &mut Session, option: &str, page: usize) -> (u64, wordcraft_doc::StoryRef) {
|
||||
s.view.proofing = false;
|
||||
s.run("document.setText", &json!({"text": "Body text with a comment on it."})).unwrap();
|
||||
s.run("select.text", &json!({"text": "comment"})).unwrap();
|
||||
s.run("review.newComment", &json!({"text": "body"})).unwrap();
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
s.run("insert.pageBreak", &json!({})).unwrap();
|
||||
s.run(option, &json!({"value": true})).unwrap();
|
||||
s.run("insert.editHeader", &json!({"page": page})).unwrap();
|
||||
s.run("text.insert", &json!({"text": "header"})).unwrap();
|
||||
let header = comment_here(s, "header");
|
||||
s.run("insert.closeHeader", &json!({})).unwrap();
|
||||
s.run(option, &json!({"value": false})).unwrap();
|
||||
s.run("caret.docStart", &json!({})).unwrap();
|
||||
header
|
||||
}
|
||||
|
||||
/// A first-page or even-page header is hidden while its option is off: its comments have no
|
||||
/// anchor and Next skips them.
|
||||
#[test]
|
||||
fn comments_in_header_versions_whose_option_is_off_are_not_navigated_to() {
|
||||
for (option, page, slot) in [("layout.differentFirstPage", 0, "first"), ("layout.differentOddEven", 1, "even")] {
|
||||
let mut s = Session::new(Document::new());
|
||||
let (id, story) = comment_in_header_version(&mut s, option, page);
|
||||
let headers = s.doc.sections().first().map(|(_, sec)| sec.headers).unwrap();
|
||||
let want = if slot == "first" { headers.first } else { headers.even };
|
||||
assert_eq!(want.map(wordcraft_doc::StoryRef::Part), Some(story), "{option}: the comment is in the {slot} header");
|
||||
assert!(listed(&mut s).contains(&(id, None)), "{option} off: no anchor");
|
||||
for st in next_stories(&mut s, 3) {
|
||||
assert_eq!(st, wordcraft_doc::StoryRef::Body, "{option} off: Next went to the hidden {slot} header");
|
||||
}
|
||||
s.run(option, &json!({"value": true})).unwrap();
|
||||
assert!(listed(&mut s).contains(&(id, Some(story))), "{option} on: anchored again");
|
||||
}
|
||||
}
|
||||
|
||||
/// With Different First Page on, the first page's header comes before the later pages'
|
||||
/// header, in the list and for Next.
|
||||
#[test]
|
||||
fn first_page_header_comes_before_the_default_header() {
|
||||
let mut s = Session::new(Document::new());
|
||||
s.view.proofing = false;
|
||||
s.run("document.setText", &json!({"text": "Page one"})).unwrap();
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
s.run("insert.pageBreak", &json!({})).unwrap();
|
||||
s.run("layout.differentFirstPage", &json!({"value": true})).unwrap();
|
||||
let mut header = |page: usize| {
|
||||
s.run("insert.editHeader", &json!({"page": page})).unwrap();
|
||||
s.run("text.insert", &json!({"text": "header"})).unwrap();
|
||||
let c = comment_here(&mut s, "header");
|
||||
s.run("insert.closeHeader", &json!({})).unwrap();
|
||||
c
|
||||
};
|
||||
let (first, default) = (header(0), header(1));
|
||||
assert_ne!(first.1, default.1);
|
||||
s.run("caret.docStart", &json!({})).unwrap();
|
||||
assert_eq!(listed(&mut s), vec![(first.0, Some(first.1)), (default.0, Some(default.1))]);
|
||||
assert_eq!(next_stories(&mut s, 2), vec![first.1, default.1]);
|
||||
}
|
||||
|
||||
/// A linked text box shows the first box's text: comments in its own text have no anchor and
|
||||
/// Next skips them.
|
||||
#[test]
|
||||
fn comments_in_a_linked_text_box_own_text_are_not_navigated_to() {
|
||||
let mut s = Session::new(Document::new());
|
||||
s.view.proofing = false;
|
||||
s.run("document.setText", &json!({"text": "Body text with a comment on it."})).unwrap();
|
||||
s.run("select.text", &json!({"text": "comment"})).unwrap();
|
||||
s.run("review.newComment", &json!({"text": "body"})).unwrap();
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
s.run("insert.textBox", &json!({"text": "first box"})).unwrap();
|
||||
let first = s.sel.focus.story;
|
||||
s.run("insert.closeHeader", &json!({})).unwrap();
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
// Only a blank box can be linked to.
|
||||
s.run("insert.textBox", &json!({"text": " "})).unwrap();
|
||||
let (id, follower) = comment_here(&mut s, "follower");
|
||||
s.run("insert.closeHeader", &json!({})).unwrap();
|
||||
s.run("caret.set", &json!({"pos": {"story": first, "path": [0], "off": 0}})).unwrap();
|
||||
s.run("shape.link", &json!({"target": 1})).unwrap();
|
||||
assert_eq!(s.doc.text_box_chains().first().and_then(|c| c.get(1)).copied().map(wordcraft_doc::StoryRef::Part), Some(follower));
|
||||
s.run("caret.docStart", &json!({})).unwrap();
|
||||
assert!(listed(&mut s).contains(&(id, None)), "linked: no anchor");
|
||||
for st in next_stories(&mut s, 3) {
|
||||
assert_ne!(st, follower, "Next went into the linked box's own text");
|
||||
}
|
||||
s.run("caret.set", &json!({"pos": {"story": first, "path": [0], "off": 0}})).unwrap();
|
||||
s.run("shape.breakLink", &json!({})).unwrap();
|
||||
assert!(listed(&mut s).contains(&(id, Some(follower))), "unlinked: anchored again");
|
||||
}
|
||||
|
||||
/// Text inside a comment isn't an anchor: a comment marker in a comment's own story is not
|
||||
/// listed as that comment's place in the document.
|
||||
#[test]
|
||||
fn comment_list_skips_comment_text() {
|
||||
let mut s = Session::new(Document::new());
|
||||
s.view.proofing = false;
|
||||
s.run("document.setText", &json!({"text": "hello"})).unwrap();
|
||||
s.run("select.all", &json!({})).unwrap();
|
||||
let id = s.run("review.newComment", &json!({"text": "note"})).unwrap()["id"].as_u64().unwrap() as u32;
|
||||
let part = s.doc.comments.get(&id).unwrap().part;
|
||||
let at = wordcraft_doc::Pos { story: wordcraft_doc::StoryRef::Part(part), path: wordcraft_doc::Path::top(0), off: 0 };
|
||||
s.doc.insert_object(&at, wordcraft_doc::InlineObject::CommentStart { id }, &Default::default()).unwrap();
|
||||
let list = super::comment_list(&s);
|
||||
assert_eq!(list.iter().filter(|(x, _)| *x == id).count(), 1);
|
||||
assert_eq!(list.first().and_then(|(_, p)| p.as_ref()).map(|p| p.story), Some(wordcraft_doc::StoryRef::Body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtf_tracked_changes_accept_and_reject_after_opening_and_round_trip() {
|
||||
// Gap 19: an RTF file's tracked insertions and deletions open as tracked changes, also
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
//! Shape Format › Shape Fill (#1500): a solid colour with its transparency, or a gradient, for
|
||||
//! every selected shape, text box and drawing canvas.
|
||||
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::Pos;
|
||||
use wordcraft_doc::fill::{Fill, Gradient, GradientFlip, GradientKind, PathShape};
|
||||
use wordcraft_doc::para::InlineObject;
|
||||
use wordcraft_doc::props::Rgb;
|
||||
use wordcraft_doc::wordart::GradStop;
|
||||
|
||||
use super::objects::{edit_obj, picked_objects, selected_all};
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
|
||||
|
||||
/// Most shapes one command fills (a selection over a whole document).
|
||||
const MAX_FILL_SHAPES: usize = 10_000;
|
||||
/// Most stops read from a command (the fill keeps at most `MAX_STOPS` after sanitizing).
|
||||
const MAX_PARAM_STOPS: usize = 64;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("shape.fill", "Shape Fill", "Shape Format › Shape Styles", shape_fill)
|
||||
.params(
|
||||
r#"{"color": "RRGGBB" | null, "transparency"?: %} | {"gradient": {"stops": [{"pos": %, "color": "RRGGBB", "transparency"?: %}], "type"?: "linear|radial|rectangular|path", "angle"?: deg, "focus"?: [l, t, r, b], "tile"?: [l, t, r, b], "scaled"?: bool, "rotateWithShape"?: bool}} (tile: percent insets of the gradient's area; scaled: a linear angle scaled with the shape; a colour alone keeps the shape's transparency; angle: linear only, 0 = left to right, 90 = top to bottom; focus: percent insets of where a radial, rectangular or path gradient starts; applies to every selected shape, text box and canvas background)"#,
|
||||
)
|
||||
.when(super::objects::has_shape_or_canvas),
|
||||
]
|
||||
}
|
||||
|
||||
/// What a `shape.fill` command sets.
|
||||
enum Change {
|
||||
None,
|
||||
/// A colour, with its transparency or (`None`) keeping each shape's.
|
||||
Solid(Rgb, Option<f32>),
|
||||
Fill(Fill),
|
||||
}
|
||||
|
||||
fn shape_fill(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let change = parse(v)?;
|
||||
let targets = targets(s);
|
||||
if targets.is_empty() {
|
||||
return Err(CmdError::Disabled("no shape selected".into()));
|
||||
}
|
||||
let mut last = Value::Null;
|
||||
for pos in &targets {
|
||||
let o = edit_obj(s, pos, |o| {
|
||||
let slot = match o {
|
||||
InlineObject::Shape { fill, .. } => fill,
|
||||
o => match super::canvas::style_mut(o) {
|
||||
Some(style) => &mut style.fill,
|
||||
None => return,
|
||||
},
|
||||
};
|
||||
*slot = match &change {
|
||||
Change::None => None,
|
||||
Change::Fill(f) => Some(f.clone()),
|
||||
Change::Solid(color, t) => {
|
||||
let kept = match slot {
|
||||
Some(Fill::Solid { transparency, .. }) => *transparency,
|
||||
_ => 0.0,
|
||||
};
|
||||
Fill::Solid { color: *color, transparency: t.unwrap_or(kept) }.sanitized()
|
||||
}
|
||||
};
|
||||
})?;
|
||||
last = serde_json::to_value(o).unwrap_or(Value::Null);
|
||||
}
|
||||
Ok(serde_json::json!({"object": last, "count": targets.len()}))
|
||||
}
|
||||
|
||||
/// The shapes and canvases in the selection and those added to it (Shift+click).
|
||||
fn targets(s: &Session) -> Vec<Pos> {
|
||||
let fillable = |o: &InlineObject| matches!(o, InlineObject::Shape { .. }) || o.is_canvas();
|
||||
let mut out: Vec<Pos> = selected_all(s, MAX_FILL_SHAPES).into_iter().filter(|(_, o)| fillable(o)).map(|(pos, _)| pos).collect();
|
||||
for pos in picked_objects(s) {
|
||||
if !out.contains(&pos) && s.doc.para_at(&pos).and_then(|q| q.object_at(pos.off)).is_some_and(fillable) {
|
||||
out.push(pos);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn color(v: &Value) -> Result<Rgb, CmdError> {
|
||||
p::str(v, "color").and_then(Rgb::parse).ok_or_else(|| CmdError::Params("`color`: \"RRGGBB\"".into()))
|
||||
}
|
||||
|
||||
fn parse(v: &Value) -> Result<Change, CmdError> {
|
||||
let Some(g) = v.get("gradient") else {
|
||||
return match v.get("color") {
|
||||
None | Some(Value::Null) => Ok(Change::None),
|
||||
Some(_) => Ok(Change::Solid(color(v)?, p::f32(v, "transparency"))),
|
||||
};
|
||||
};
|
||||
let bad = |m: &str| CmdError::Params(format!("`gradient`: {m}"));
|
||||
if !g.is_object() {
|
||||
return Err(bad("an object"));
|
||||
}
|
||||
let stops = g
|
||||
.get("stops")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| bad("`stops` is a list"))?
|
||||
.iter()
|
||||
.take(MAX_PARAM_STOPS)
|
||||
.map(|st| Ok(GradStop { pos: p::f32(st, "pos").unwrap_or(0.0), color: color(st)?, transparency: p::f32(st, "transparency").unwrap_or(0.0) }))
|
||||
.collect::<Result<Vec<_>, CmdError>>()?;
|
||||
if stops.len() < 2 {
|
||||
return Err(bad("two or more stops"));
|
||||
}
|
||||
let rect = |key: &str, default: f32| -> Option<[f32; 4]> {
|
||||
let a = g.get(key)?.as_array()?;
|
||||
let mut r = [default; 4];
|
||||
for (slot, x) in r.iter_mut().zip(a) {
|
||||
*slot = x.as_f64().map_or(default, |x| x as f32);
|
||||
}
|
||||
Some(r)
|
||||
};
|
||||
let path = |shape| GradientKind::Path { shape, focus: rect("focus", 50.0).unwrap_or([50.0; 4]) };
|
||||
let kind = match p::str(g, "type").unwrap_or("linear") {
|
||||
"linear" => {
|
||||
GradientKind::Linear { angle: p::f32(g, "angle").unwrap_or(90.0), scaled: g.get("scaled").and_then(Value::as_bool).unwrap_or(false) }
|
||||
}
|
||||
"radial" => path(PathShape::Circle),
|
||||
"rectangular" => path(PathShape::Rect),
|
||||
"path" => path(PathShape::Shape),
|
||||
t => return Err(bad(&format!("unknown type `{t}`"))),
|
||||
};
|
||||
let rot_with_shape = g.get("rotateWithShape").and_then(Value::as_bool).unwrap_or(true);
|
||||
let fill = Fill::Gradient(Arc::new(Gradient { stops, kind, tile: rect("tile", 0.0), rot_with_shape, flip: GradientFlip::None }));
|
||||
fill.sanitized().map(Change::Fill).ok_or_else(|| bad("no stops"))
|
||||
}
|
||||
|
||||
/// The `shape.fill` params that set `fill` (`None`: no fill).
|
||||
pub fn params(fill: Option<&Fill>) -> Value {
|
||||
use serde_json::json;
|
||||
match fill {
|
||||
None => json!({"color": null}),
|
||||
Some(Fill::Solid { color, transparency }) => json!({"color": color.hex(), "transparency": transparency}),
|
||||
Some(Fill::Gradient(g)) => {
|
||||
let stops: Vec<Value> = g.stops.iter().map(|s| json!({"pos": s.pos, "color": s.color.hex(), "transparency": s.transparency})).collect();
|
||||
let mut out = json!({"stops": stops, "rotateWithShape": g.rot_with_shape});
|
||||
if let Some(t) = g.tile {
|
||||
out["tile"] = json!(t);
|
||||
}
|
||||
match g.kind {
|
||||
GradientKind::Linear { angle, scaled } => {
|
||||
out["type"] = json!("linear");
|
||||
out["angle"] = json!(angle);
|
||||
out["scaled"] = json!(scaled);
|
||||
}
|
||||
GradientKind::Path { shape, focus } => {
|
||||
out["type"] = json!(match shape {
|
||||
PathShape::Circle => "radial",
|
||||
PathShape::Rect => "rectangular",
|
||||
PathShape::Shape => "path",
|
||||
});
|
||||
out["focus"] = json!(focus);
|
||||
}
|
||||
}
|
||||
json!({"gradient": out})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The directions Format Shape offers for a linear gradient, as angles (0 = left to right, 90 =
|
||||
/// top to bottom).
|
||||
pub const LINEAR_DIRECTIONS: [f32; 8] = [45.0, 90.0, 135.0, 0.0, 180.0, 315.0, 270.0, 225.0];
|
||||
|
||||
/// The directions Format Shape offers for radial and rectangular gradients, as where they start
|
||||
/// (percent insets, left, top, right, bottom): each corner, then the centre.
|
||||
pub const PATH_DIRECTIONS: [[f32; 4]; 5] =
|
||||
[[100.0, 100.0, 0.0, 0.0], [0.0, 100.0, 100.0, 0.0], [50.0, 50.0, 50.0, 50.0], [100.0, 0.0, 0.0, 100.0], [0.0, 0.0, 100.0, 100.0]];
|
||||
|
||||
fn grad(stops: &[(f32, Rgb)], kind: GradientKind) -> Fill {
|
||||
let stops = stops.iter().map(|&(pos, color)| GradStop { pos, color, transparency: 0.0 }).collect();
|
||||
Fill::Gradient(Arc::new(Gradient { stops, kind, tile: None, rot_with_shape: true, flip: GradientFlip::None }))
|
||||
}
|
||||
|
||||
/// The gradient Format Shape's Gradient fill starts from, from the theme's first accent
|
||||
/// whatever the shape's colour (as Word 365 does): four stops, 0% at 95% lighter, 74% and 83%
|
||||
/// at 55% lighter, 100% at 70% lighter, top to bottom, scaled with the shape.
|
||||
pub fn default_gradient(accent1: Rgb) -> Fill {
|
||||
let lighter = |off: f64| {
|
||||
let c = accent1.modified("lumMod", 1.0 - off);
|
||||
c.modified("lumOff", off)
|
||||
};
|
||||
let stops = [(0.0, 0.95), (74.0, 0.55), (83.0, 0.55), (100.0, 0.7)]
|
||||
.into_iter()
|
||||
.map(|(pos, off)| GradStop { pos, color: lighter(off), transparency: 0.0 })
|
||||
.collect();
|
||||
let g = Gradient { stops, kind: GradientKind::Linear { angle: 90.0, scaled: true }, tile: None, rot_with_shape: true, flip: GradientFlip::None };
|
||||
Fill::Gradient(Arc::new(g))
|
||||
}
|
||||
|
||||
/// Where each corner tile's gradient starts (percent insets) and the tile rectangle Word gives
|
||||
/// it: from the bottom-right, bottom-left, top-right and top-left corners. Observed for the
|
||||
/// bottom-right corner (`fillToRect l=t=100%`, `tileRect r=b=-100%`); the others mirror it.
|
||||
const CORNERS: [([f32; 4], [f32; 4]); 4] = [
|
||||
([100.0, 100.0, 0.0, 0.0], [0.0, 0.0, -100.0, -100.0]),
|
||||
([0.0, 100.0, 100.0, 0.0], [-100.0, 0.0, 0.0, -100.0]),
|
||||
([100.0, 0.0, 0.0, 100.0], [0.0, -100.0, -100.0, 0.0]),
|
||||
([0.0, 0.0, 100.0, 100.0], [-100.0, -100.0, 0.0, 0.0]),
|
||||
];
|
||||
|
||||
/// The tile rectangle Word gives a radial or rectangular gradient starting at `focus`: a corner's,
|
||||
/// or none from the centre.
|
||||
pub fn corner_tile(focus: [f32; 4]) -> Option<[f32; 4]> {
|
||||
CORNERS.iter().find(|(f, _)| *f == focus).map(|(_, t)| *t)
|
||||
}
|
||||
|
||||
/// Shape Fill › Gradient's Light and Dark Variations of `c`, each with its name, in Word's
|
||||
/// order (a grid five wide): three stops at 0, 50 and 100%, light ones `c` tinted to 66, 44.5
|
||||
/// and 23.5% with saturation ×1.6, dark ones shaded to 30, 67.5 and 100% with saturation
|
||||
/// ×1.15 (as Word writes them); linear ones scaled with the shape, the rest circular.
|
||||
pub fn variations(c: Rgb) -> [Vec<(Fill, String)>; 2] {
|
||||
let tiles = |m: &str, steps: [f64; 3], sat: f64| -> Vec<(Fill, String)> {
|
||||
let stop = |i: usize| {
|
||||
let color = c.modified(m, steps[i]).modified("satMod", sat);
|
||||
GradStop { pos: i as f32 * 50.0, color, transparency: 0.0 }
|
||||
};
|
||||
let fill = |kind, tile| {
|
||||
let g = Gradient { stops: (0..3).map(stop).collect(), kind, tile, rot_with_shape: true, flip: GradientFlip::None };
|
||||
Fill::Gradient(Arc::new(g))
|
||||
};
|
||||
let lin = |angle: f32| (fill(GradientKind::Linear { angle, scaled: true }, None), linear_direction_name(angle));
|
||||
let corner = |i: usize| {
|
||||
let (focus, tile) = CORNERS[i];
|
||||
(fill(GradientKind::Path { shape: PathShape::Circle, focus }, Some(tile)), path_direction_name(focus))
|
||||
};
|
||||
let centre = (fill(GradientKind::Path { shape: PathShape::Circle, focus: [50.0; 4] }, None), path_direction_name([50.0; 4]));
|
||||
vec![
|
||||
lin(45.0),
|
||||
lin(90.0),
|
||||
lin(135.0),
|
||||
corner(0),
|
||||
corner(1),
|
||||
lin(0.0),
|
||||
centre,
|
||||
lin(180.0),
|
||||
corner(2),
|
||||
corner(3),
|
||||
lin(315.0),
|
||||
lin(270.0),
|
||||
lin(225.0),
|
||||
]
|
||||
};
|
||||
[tiles("tint", [0.66, 0.445, 0.235], 1.6), tiles("shade", [0.3, 0.675, 1.0], 1.15)]
|
||||
}
|
||||
|
||||
/// The 30 preset gradients from the theme's six accents: per accent, soft, lit from the top,
|
||||
/// deep, lit from the bottom and centre glow. Our own design (the repo's clean-room rule: no
|
||||
/// copying Word's presets), on the same accents-by-styles grid as Word's.
|
||||
pub fn presets(theme: &[Rgb]) -> Vec<Fill> {
|
||||
let accent = |i: usize| theme.get(4 + i).copied().unwrap_or(Rgb(68, 114, 196));
|
||||
let down = GradientKind::Linear { angle: 90.0, scaled: false };
|
||||
let mut out = Vec::with_capacity(30);
|
||||
for style in 0..5 {
|
||||
for i in 0..6 {
|
||||
let c = accent(i);
|
||||
out.push(match style {
|
||||
0 => grad(&[(0.0, c.modified("tint", 0.2)), (100.0, c.modified("tint", 0.6))], down),
|
||||
1 => grad(&[(0.0, c.modified("tint", 0.4)), (50.0, c), (100.0, c.modified("shade", 0.75))], down),
|
||||
2 => grad(&[(0.0, c), (100.0, c.modified("shade", 0.5))], down),
|
||||
3 => grad(&[(0.0, c.modified("shade", 0.75)), (50.0, c), (100.0, c.modified("tint", 0.4))], down),
|
||||
_ => grad(
|
||||
&[(0.0, c.modified("tint", 0.4)), (100.0, c.modified("shade", 0.75))],
|
||||
GradientKind::Path { shape: PathShape::Circle, focus: [50.0; 4] },
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The preset styles' names, in [`presets`]'s order (each for accents 1 to 6).
|
||||
pub const PRESET_STYLES: [&str; 5] = ["Soft", "Lit from Top", "Deep", "Lit from Bottom", "Center Glow"];
|
||||
|
||||
/// The name of the linear direction at `angle`.
|
||||
pub fn linear_direction_name(angle: f32) -> String {
|
||||
match angle.round() as i32 {
|
||||
0 => "Linear Right",
|
||||
45 => "Linear Diagonal - Top Left to Bottom Right",
|
||||
90 => "Linear Down",
|
||||
135 => "Linear Diagonal - Top Right to Bottom Left",
|
||||
180 => "Linear Left",
|
||||
225 => "Linear Diagonal - Bottom Right to Top Left",
|
||||
270 => "Linear Up",
|
||||
315 => "Linear Diagonal - Bottom Left to Top Right",
|
||||
_ => "Linear",
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
/// The name of the radial or rectangular direction starting at `focus`.
|
||||
pub fn path_direction_name(focus: [f32; 4]) -> String {
|
||||
match focus.map(|v| v.round() as i32) {
|
||||
[0, 0, 100, 100] => "From Top Left Corner",
|
||||
[100, 0, 0, 100] => "From Top Right Corner",
|
||||
[0, 100, 100, 0] => "From Bottom Left Corner",
|
||||
[100, 100, 0, 0] => "From Bottom Right Corner",
|
||||
_ => "From Center",
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Stop editing for the Format Shape pane's gradient stops bar. Stops stay in position order; a
|
||||
/// gradient keeps 2 to `MAX_STOPS` of them.
|
||||
pub mod stops {
|
||||
use wordcraft_doc::props::Rgb;
|
||||
use wordcraft_doc::wordart::{GradStop, MAX_STOPS};
|
||||
|
||||
/// The gradient's colour and transparency at `pos` (percent).
|
||||
pub fn at(stops: &[GradStop], pos: f32) -> (Rgb, f32) {
|
||||
let after = stops.iter().position(|s| s.pos >= pos);
|
||||
let (a, b) = match after {
|
||||
Some(0) | None => {
|
||||
let s = if after.is_none() { stops.last() } else { stops.first() };
|
||||
return s.map_or((Rgb::BLACK, 0.0), |s| (s.color, s.transparency));
|
||||
}
|
||||
Some(i) => (stops[i - 1], stops[i]),
|
||||
};
|
||||
let t = if b.pos > a.pos { (pos - a.pos) / (b.pos - a.pos) } else { 0.0 };
|
||||
let mix = |x: u8, y: u8| (f32::from(x) + (f32::from(y) - f32::from(x)) * t).round().clamp(0.0, 255.0) as u8;
|
||||
(Rgb(mix(a.color.0, b.color.0), mix(a.color.1, b.color.1), mix(a.color.2, b.color.2)), a.transparency + (b.transparency - a.transparency) * t)
|
||||
}
|
||||
|
||||
/// Adds a stop at `pos` in the gradient's colour there; its index, or `None` when full.
|
||||
pub fn add(stops: &mut Vec<GradStop>, pos: f32) -> Option<usize> {
|
||||
if stops.len() >= MAX_STOPS {
|
||||
return None;
|
||||
}
|
||||
let pos = if pos.is_finite() { pos.clamp(0.0, 100.0) } else { 50.0 };
|
||||
let (color, transparency) = at(stops, pos);
|
||||
let i = stops.iter().position(|s| s.pos > pos).unwrap_or(stops.len());
|
||||
stops.insert(i, GradStop { pos, color, transparency });
|
||||
Some(i)
|
||||
}
|
||||
|
||||
/// Removes stop `i` unless only two are left; whether it did.
|
||||
pub fn remove(stops: &mut Vec<GradStop>, i: usize) -> bool {
|
||||
if stops.len() <= 2 || i >= stops.len() {
|
||||
return false;
|
||||
}
|
||||
stops.remove(i);
|
||||
true
|
||||
}
|
||||
|
||||
/// Moves stop `i` to `pos`, keeping the stops in order; its new index.
|
||||
pub fn move_to(stops: &mut Vec<GradStop>, i: usize, pos: f32) -> usize {
|
||||
if i >= stops.len() || !pos.is_finite() {
|
||||
return i;
|
||||
}
|
||||
let mut s = stops.remove(i);
|
||||
s.pos = pos.clamp(0.0, 100.0);
|
||||
let j = stops.iter().position(|o| o.pos > s.pos).unwrap_or(stops.len());
|
||||
stops.insert(j, s);
|
||||
j
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Change, params, parse, stops};
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::fill::{Fill, Gradient, GradientFlip, GradientKind, PathShape};
|
||||
|
||||
/// The variations Word writes for accent 1 (`tint`/`shade` with `satMod`, observed in Word 365).
|
||||
#[test]
|
||||
fn variations_follow_words() {
|
||||
use super::variations;
|
||||
let c = Rgb(0x15, 0x60, 0x82);
|
||||
let [light, dark] = variations(c);
|
||||
assert_eq!((light.len(), dark.len()), (13, 13));
|
||||
assert_eq!(light[1].1, "Linear Down");
|
||||
assert_eq!(light[6].1, "From Center");
|
||||
let Fill::Gradient(g) = &light[0].0 else { panic!() };
|
||||
assert_eq!(g.kind, GradientKind::Linear { angle: 45.0, scaled: true });
|
||||
assert_eq!(g.stops.iter().map(|s| s.pos).collect::<Vec<_>>(), [0.0, 50.0, 100.0]);
|
||||
assert!(g.stops[0].color.0 > g.stops[2].color.0 || g.stops[0].color.2 < g.stops[2].color.2, "lighter towards the end: {:?}", g.stops);
|
||||
let Fill::Gradient(g) = &light[3].0 else { panic!() };
|
||||
assert_eq!(
|
||||
(g.kind, g.tile),
|
||||
(GradientKind::Path { shape: PathShape::Circle, focus: [100.0, 100.0, 0.0, 0.0] }, Some([0.0, 0.0, -100.0, -100.0]))
|
||||
);
|
||||
let Fill::Gradient(g) = super::default_gradient(c) else { panic!() };
|
||||
assert_eq!(g.stops.iter().map(|s| s.pos).collect::<Vec<_>>(), [0.0, 74.0, 83.0, 100.0], "Word's starting gradient");
|
||||
assert_eq!(g.stops[1].color, g.stops[2].color);
|
||||
assert!(g.stops.iter().all(|s| s.color.0 > c.0 && s.color.1 > c.1), "lighter than the accent");
|
||||
let Fill::Gradient(g) = &dark[1].0 else { panic!() };
|
||||
assert_eq!(g.stops[2].color, c.modified("satMod", 1.15), "the last dark stop is the colour itself");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_set_the_fill_they_were_made_from() {
|
||||
let g = |kind| {
|
||||
Fill::Gradient(Arc::new(Gradient {
|
||||
stops: vec![st(0.0, 10, 0.0), st(40.0, 90, 25.0), st(100.0, 250, 100.0)],
|
||||
kind,
|
||||
tile: None,
|
||||
rot_with_shape: false,
|
||||
flip: GradientFlip::None,
|
||||
}))
|
||||
};
|
||||
let fills = [
|
||||
Fill::Solid { color: Rgb(1, 2, 3), transparency: 35.0 },
|
||||
g(GradientKind::Linear { angle: 135.0, scaled: false }),
|
||||
g(GradientKind::Linear { angle: 45.0, scaled: true }),
|
||||
g(GradientKind::Path { shape: PathShape::Circle, focus: [100.0, 0.0, 0.0, 100.0] }),
|
||||
g(GradientKind::Path { shape: PathShape::Rect, focus: [50.0; 4] }),
|
||||
g(GradientKind::Path { shape: PathShape::Shape, focus: [50.0; 4] }),
|
||||
];
|
||||
let tiled = super::variations(Rgb(9, 9, 9))[0][3].0.clone();
|
||||
for f in fills.into_iter().chain([tiled]) {
|
||||
let v = params(Some(&f));
|
||||
let back = match parse(&v) {
|
||||
Ok(Change::Fill(f)) => f,
|
||||
Ok(Change::Solid(c, t)) => Fill::Solid { color: c, transparency: t.unwrap_or(0.0) },
|
||||
_ => panic!("{v}"),
|
||||
};
|
||||
assert_eq!(back, f, "{v}");
|
||||
}
|
||||
assert!(matches!(parse(¶ms(None)), Ok(Change::None)));
|
||||
}
|
||||
use wordcraft_doc::props::Rgb;
|
||||
use wordcraft_doc::wordart::{GradStop, MAX_STOPS};
|
||||
|
||||
fn st(pos: f32, c: u8, transparency: f32) -> GradStop {
|
||||
GradStop { pos, color: Rgb(c, c, c), transparency }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stops_are_added_in_the_gradients_colour_moved_in_order_and_kept_two_to_ten() {
|
||||
let mut g = vec![st(0.0, 0, 0.0), st(100.0, 200, 50.0)];
|
||||
assert_eq!(stops::add(&mut g, 25.0), Some(1));
|
||||
assert_eq!((g[1].color, g[1].transparency), (Rgb(50, 50, 50), 12.5));
|
||||
assert_eq!(stops::add(&mut g, f32::NAN), Some(2), "a junk position goes in the middle");
|
||||
assert_eq!(stops::move_to(&mut g, 1, 300.0), 3, "past the end: last");
|
||||
assert_eq!(g.iter().map(|s| s.pos).collect::<Vec<_>>(), [0.0, 50.0, 100.0, 100.0]);
|
||||
assert_eq!(stops::move_to(&mut g, 3, -1.0), 1, "after the stop already at 0%");
|
||||
assert!(stops::remove(&mut g, 0) && stops::remove(&mut g, 0));
|
||||
assert!(!stops::remove(&mut g, 0), "two stops stay");
|
||||
assert!(!stops::remove(&mut vec![st(0.0, 0, 0.0); 3], 7));
|
||||
while stops::add(&mut g, 10.0).is_some() {}
|
||||
assert_eq!(g.len(), MAX_STOPS);
|
||||
assert_eq!(stops::at(&g, 200.0).0, g[g.len() - 1].color);
|
||||
assert_eq!(stops::at(&[], 5.0), (Rgb::BLACK, 0.0));
|
||||
}
|
||||
}
|
||||
@@ -615,6 +615,57 @@ pub(crate) fn backspace_cells(s: &mut Session) -> Option<CmdResult> {
|
||||
Some(r)
|
||||
}
|
||||
|
||||
/// Delete with whole cells selected (#2802): each cell is emptied to one empty paragraph, as in
|
||||
/// Word, nested tables included. That paragraph keeps the formatting of the cell's last one (its
|
||||
/// mark is the end-of-cell mark). `None` without whole cells, and with Track Changes on (the text
|
||||
/// is marked deleted as for any selection).
|
||||
pub(crate) fn clear_cells(s: &mut Session) -> Option<CmdResult> {
|
||||
if s.doc.settings.track_changes {
|
||||
return None;
|
||||
}
|
||||
let cr = s.cell_range()?.clone();
|
||||
let t = s.doc.table(cr.story, &cr.table)?;
|
||||
let prefixes: Vec<Vec<u32>> = cr.cells(t).into_iter().map(|(r, c)| cell_prefix(&cr.table, r, c)).collect();
|
||||
let spans = text_spans(s, cr.story, &prefixes);
|
||||
Some(clear_spans(s, cr.table.0.len().saturating_add(2), &spans))
|
||||
}
|
||||
|
||||
/// Empty each span (one cell's text, from its first paragraph to its last; the cell's path is the
|
||||
/// first `depth` steps of its paths) to one paragraph with the last paragraph's formatting, bottom
|
||||
/// up so earlier spans stay valid; the caret goes to the start of the first cell.
|
||||
fn clear_spans(s: &mut Session, depth: usize, spans: &[(Pos, Pos)]) -> CmdResult {
|
||||
// A cell's first block (its first paragraph may have been in a nested table that goes).
|
||||
let first_block = |p: &Pos| {
|
||||
let mut v = p.path.0.get(..depth).unwrap_or_default().to_vec();
|
||||
v.push(0);
|
||||
Pos { story: p.story, path: Path(v), off: 0 }
|
||||
};
|
||||
for (a, b) in spans {
|
||||
super::controls::check_delete(s, a, b)?;
|
||||
}
|
||||
for (a, b) in spans.iter().rev() {
|
||||
let Some(last) = s.doc.para(b.story, &b.path).map(|p| (p.props.clone(), p.mark.clone())) else { continue };
|
||||
s.doc.delete_range(a, b)?;
|
||||
// Now the cell's only block, unless it holds a table nested deeper than the paragraph
|
||||
// walk (`delete_range` leaves those).
|
||||
let first = first_block(a);
|
||||
if s.doc.container(first.story, &first.path).is_some_and(|bl| bl.len() == 1)
|
||||
&& let Ok(p) = s.doc.para_mut(first.story, &first.path)
|
||||
{
|
||||
(p.props, p.mark) = last;
|
||||
p.touch();
|
||||
}
|
||||
}
|
||||
let at = spans.first().map(|(a, _)| s.doc.clamp(&first_block(a))).unwrap_or_else(|| s.sel.focus.clone());
|
||||
s.sel = Selection::caret(at);
|
||||
s.column = None;
|
||||
s.column_mode = false;
|
||||
s.goal_x = None;
|
||||
s.pending = None;
|
||||
super::controls::restore_placeholder(s)?;
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
fn del_table(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let (tp, _, _) = cell(s)?;
|
||||
let story = s.sel.focus.story;
|
||||
|
||||
@@ -724,13 +724,21 @@ fn call(name: &str, args: &[Expr], here: &Here) -> Result<f64, CmdError> {
|
||||
}
|
||||
"ABS" => Ok(one(name, args, here)?.abs()),
|
||||
"INT" => Ok(one(name, args, here)?.floor()),
|
||||
"SIGN" => Ok(one(name, args, here)?.signum()),
|
||||
"SIGN" => {
|
||||
let x = one(name, args, here)?;
|
||||
Ok(if x == 0.0 { 0.0 } else { x.signum() })
|
||||
}
|
||||
"ROUND" => match args {
|
||||
[a, b] => {
|
||||
let x = scalar(eval(a, here)?)?;
|
||||
let digits = scalar(eval(b, here)?)?.clamp(-15.0, 15.0).trunc();
|
||||
let scale = 10f64.powi(digits as i32);
|
||||
finite((x * scale).round() / scale)
|
||||
let scaled = x * scale;
|
||||
if !scaled.is_finite() {
|
||||
// Too large to have a fractional part at this precision.
|
||||
return finite(x);
|
||||
}
|
||||
finite(scaled.round() / scale)
|
||||
}
|
||||
_ => Err(CmdError::Failed("ROUND takes two arguments".into())),
|
||||
},
|
||||
@@ -769,6 +777,16 @@ mod tests {
|
||||
evaluate(t, r, c, f)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sign_of_zero_is_zero() {
|
||||
let t = table(&[&["0", "5", "-5"]]);
|
||||
assert_eq!(ev(&t, 0, 0, "=SIGN(0)"), Ok(0.0));
|
||||
assert_eq!(ev(&t, 0, 0, "=SIGN(-0)"), Ok(0.0));
|
||||
assert_eq!(ev(&t, 0, 1, "=SIGN(A1)"), Ok(0.0), "a cell holding 0");
|
||||
assert_eq!(ev(&t, 0, 0, "=SIGN(5)"), Ok(1.0));
|
||||
assert_eq!(ev(&t, 0, 0, "=SIGN(-5)"), Ok(-1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_references_count_from_one_and_from_a() {
|
||||
assert_eq!(cell_ref("A1"), Some((0, 0)));
|
||||
@@ -807,6 +825,14 @@ mod tests {
|
||||
assert!(err.contains("MEDIAN"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_large_values_return_input() {
|
||||
let t = table(&[&["1", "2"], &["3", "4"], &["", ""]]);
|
||||
let big = format!("1{}", "0".repeat(300));
|
||||
assert_eq!(ev(&t, 2, 0, &format!("=ROUND({big},15)")), Ok(1e300));
|
||||
assert_eq!(ev(&t, 2, 0, &format!("=ROUND({big},2)")), Ok(1e300));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ranges_and_references_and_operators() {
|
||||
let t = table(&[&["1", "2"], &["3", "4"], &["", ""]]);
|
||||
|
||||
@@ -199,6 +199,30 @@ pub fn handle_table(l: &DocLayout, doc: &Document, story: StoryRef, page: usize,
|
||||
.max_by_key(|g| g.table.0.len())
|
||||
}
|
||||
|
||||
/// The move and resize handles of table `table` of `story` on this page, as Word shows them
|
||||
/// while the caret is in the table: the move handle where it starts, the resize handle where it
|
||||
/// ends.
|
||||
pub fn caret_handles(l: &DocLayout, doc: &Document, story: StoryRef, table: &Path, page: usize) -> (Option<Rect>, Option<Rect>) {
|
||||
let rows = doc.table(story, table).map_or(0, |t| t.rows.len());
|
||||
let has_row = |p: usize, r: usize| {
|
||||
l.pages.get(p).is_some_and(|pg| {
|
||||
pg.items.iter().any(|it| matches!(it, Placed::Cell { table: t, story: st, row, .. } if *st == story && t == table && *row == r))
|
||||
})
|
||||
};
|
||||
// A repeated header row, or a row running on to the next page, is drawn on more than one
|
||||
// page: the handles go where the table starts and where it ends (its pages are consecutive).
|
||||
let first = has_row(page, 0) && !page.checked_sub(1).is_some_and(|p| has_row(p, 0));
|
||||
let last = rows.checked_sub(1).is_some_and(|r| has_row(page, r) && !page.checked_add(1).is_some_and(|p| has_row(p, r)));
|
||||
if !first && !last {
|
||||
return (None, None);
|
||||
}
|
||||
// The table's geometry on this page that the pointer handles are drawn and grabbed from.
|
||||
let Some(g) = table_resize::tables_on(l, doc, page).into_iter().find(|g| g.story == story && g.table == *table) else {
|
||||
return (None, None);
|
||||
};
|
||||
(first.then(|| handle(&g)), last.then(|| g.handle()))
|
||||
}
|
||||
|
||||
/// The insert button a target is, if it's one.
|
||||
pub fn insert_mark(t: &Target) -> Option<InsertMark> {
|
||||
let g = &t.geom;
|
||||
@@ -374,7 +398,7 @@ fn select_at(s: &mut Session, v: &Value) -> CmdResult {
|
||||
/// before it) or, at the table's right edge, a row's last cell (after it). (row, cell, after)
|
||||
fn beside_line(t: &Table, line: usize) -> Option<(usize, usize, bool)> {
|
||||
t.rows.iter().enumerate().find_map(|(r, row)| {
|
||||
let mut g = 0usize;
|
||||
let mut g = row.first_grid();
|
||||
for (c, cell) in row.cells.iter().enumerate() {
|
||||
if g == line {
|
||||
return Some((r, c, false));
|
||||
@@ -425,7 +449,7 @@ fn insert_at(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}
|
||||
|
||||
/// Where table `table` of `story` is first drawn: its page and top-left corner.
|
||||
fn corner(l: &DocLayout, story: StoryRef, table: &Path) -> Option<(usize, f32, f32)> {
|
||||
pub fn corner(l: &DocLayout, story: StoryRef, table: &Path) -> Option<(usize, f32, f32)> {
|
||||
l.pages.iter().enumerate().find_map(|(pi, pg)| {
|
||||
let cells = pg.items.iter().filter_map(|it| match it {
|
||||
Placed::Cell { rect, table: t, story: st, .. } if *st == story && t == table => Some(*rect),
|
||||
@@ -1027,4 +1051,35 @@ mod tests {
|
||||
let moved = cell_rect(&mut s, 0, 0);
|
||||
assert!(moved.x >= -0.5 && moved.right() <= 612.5 && moved.y >= -0.5, "{moved:?}");
|
||||
}
|
||||
|
||||
/// #2038: a table dragged to the right floats and the empty paragraph after it moves up beside
|
||||
/// it. As in Word, a click anywhere below the table then goes to that paragraph, not to a cell of the last row; a click in a cell still lands there.
|
||||
#[test]
|
||||
fn a_click_below_a_floating_table_goes_to_the_text_not_its_cells() {
|
||||
let mut s = table_session(3, 3);
|
||||
let h = handle(&table_resize::tables_on(&s.layout(), &s.doc, 0)[0]);
|
||||
let (hx, hy) = (h.x + h.w / 2.0, h.y + h.h / 2.0);
|
||||
s.run("table.moveDrag", &json!({"page": 0, "x": hx, "y": hy, "toX": hx + 60.0, "toY": hy + 10.0})).unwrap();
|
||||
assert!(first_table(&s).props.float.is_some());
|
||||
let after = Path(vec![table_path(&s).0[0] + 1]);
|
||||
assert!(s.doc.block(StoryRef::Body, &after).is_some_and(|b| b.as_table().is_none()), "a paragraph follows the table");
|
||||
let (first, last) = (cell_rect(&mut s, 0, 0), cell_rect(&mut s, 2, 2));
|
||||
let (mid, bottom, margin) = ((first.x + last.right()) / 2.0, last.bottom(), s.layout().pages[0].body.x + 5.0);
|
||||
let at = |s: &mut Session, x: f32, y: f32| {
|
||||
s.run("caret.set", &json!({"page": 0, "x": x, "y": y})).unwrap();
|
||||
s.sel.focus.path.clone()
|
||||
};
|
||||
for (name, x, y) in [
|
||||
("just below the middle", mid, bottom + 12.0),
|
||||
("far below the middle", mid, bottom + 200.0),
|
||||
("below, at the margin", margin, bottom + 12.0),
|
||||
] {
|
||||
assert_eq!(at(&mut s, x, y), after, "a click {name}");
|
||||
}
|
||||
let b2 = cell_rect(&mut s, 1, 1);
|
||||
assert_eq!(at(&mut s, b2.x + b2.w / 2.0, mid_y(b2)), cell_pos(&s, 1, 1).path, "in B2");
|
||||
assert_eq!(at(&mut s, last.x + last.w / 2.0, last.bottom() - 1.0), cell_pos(&s, 2, 2).path, "low in C3");
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
assert_eq!(s.sel.focus.path, after, "Ctrl+End");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1016,4 +1016,130 @@ mod tests {
|
||||
assert_eq!(first_table(&s).grid.len(), 3, "no new grid line");
|
||||
assert!((cell_rect(&mut s, 0, 2).x - (c.x + 20.0)).abs() < 1.0, "the first row follows");
|
||||
}
|
||||
|
||||
/// Selects the cell at (row, cell) one of the ways Word does: Select Cell, the left-edge
|
||||
/// arrow, or a triple-click on its text.
|
||||
fn select_one_cell(s: &mut Session, how: &str, r: usize, c: usize) {
|
||||
let b = cell_rect(s, r, c);
|
||||
let y = b.y + b.h / 2.0;
|
||||
match how {
|
||||
"arrow" => {
|
||||
s.run("table.selectAt", &json!({"page": 0, "x": b.x + 4.5, "y": y})).unwrap();
|
||||
}
|
||||
"triple" => {
|
||||
s.run("caret.set", &json!({"page": 0, "x": b.x + b.w / 2.0, "y": y})).unwrap();
|
||||
s.run("select.paragraph", &json!({"cell": true})).unwrap();
|
||||
}
|
||||
_ => {
|
||||
s.run("caret.set", &json!({"page": 0, "x": b.x + b.w / 2.0, "y": y})).unwrap();
|
||||
s.run("table.selectCell", &json!({})).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Each way of selecting one cell makes its right and its left border move in its row
|
||||
/// only; the neighbour on that side gives up or gains the room and the row keeps its width.
|
||||
#[test]
|
||||
fn a_single_selected_cells_borders_move_only_in_its_row() {
|
||||
for how in ["cell", "arrow", "triple"] {
|
||||
for left in [false, true] {
|
||||
let mut s = table_session(3, 3);
|
||||
let (a, b, c) = (cell_rect(&mut s, 1, 0), cell_rect(&mut s, 1, 1), cell_rect(&mut s, 1, 2));
|
||||
let y = b.y + b.h / 2.0;
|
||||
select_one_cell(&mut s, how, 1, 1);
|
||||
let start = first_table(&s);
|
||||
let x = if left { b.x } else { b.right() };
|
||||
s.run("table.resizeDrag", &json!({"page": 0, "x": x, "y": y, "toX": x + 30.0, "toY": y})).unwrap();
|
||||
let (a1, b1, c1) = (cell_rect(&mut s, 1, 0), cell_rect(&mut s, 1, 1), cell_rect(&mut s, 1, 2));
|
||||
if left {
|
||||
assert!((b1.w - (b.w - 30.0)).abs() < 1.0 && (a1.w - (a.w + 30.0)).abs() < 1.0, "{how} left: {a1:?} {b1:?}");
|
||||
assert!((c1.w - c.w).abs() < 0.5, "{how} left");
|
||||
} else {
|
||||
assert!((b1.w - (b.w + 30.0)).abs() < 1.0 && (c1.w - (c.w - 30.0)).abs() < 1.0, "{how} right: {b1:?} {c1:?}");
|
||||
assert!((a1.w - a.w).abs() < 0.5, "{how} right");
|
||||
}
|
||||
assert!((c1.right() - c.right()).abs() < 0.5, "{how}: the row keeps its width");
|
||||
for r in [0, 2] {
|
||||
assert!(
|
||||
(cell_rect(&mut s, r, 1).x - b.x).abs() < 0.5 && (cell_rect(&mut s, r, 1).right() - b.right()).abs() < 0.5,
|
||||
"{how}: row {r} unchanged"
|
||||
);
|
||||
}
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(first_table(&s), start, "{how}: one undo step");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A caret, a part of a cell's text, or another cell's border with a cell selected: the
|
||||
/// whole column moves.
|
||||
#[test]
|
||||
fn without_that_cell_selected_the_whole_column_moves() {
|
||||
let mut s = table_session(3, 3);
|
||||
let b = cell_rect(&mut s, 1, 1);
|
||||
let y = b.y + b.h / 2.0;
|
||||
s.run("caret.set", &json!({"page": 0, "x": b.x + b.w / 2.0, "y": y})).unwrap();
|
||||
s.run("table.resizeDrag", &json!({"page": 0, "x": b.right(), "y": y, "toX": b.right() + 30.0, "toY": y})).unwrap();
|
||||
assert!((cell_rect(&mut s, 0, 1).w - (b.w + 30.0)).abs() < 1.0, "caret only");
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
select_one_cell(&mut s, "arrow", 2, 2);
|
||||
s.run("table.resizeDrag", &json!({"page": 0, "x": b.right(), "y": y, "toX": b.right() + 30.0, "toY": y})).unwrap();
|
||||
assert!((cell_rect(&mut s, 0, 1).w - (b.w + 30.0)).abs() < 1.0, "another cell selected");
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
// All of a cell's text, picked as a range (no cell mark): not a cell selection.
|
||||
s.run("caret.set", &json!({"page": 0, "x": b.x + b.w / 2.0, "y": y})).unwrap();
|
||||
s.run("text.insert", &json!({"text": "Wide"})).unwrap();
|
||||
s.run("select.text", &json!({"text": "Wide"})).unwrap();
|
||||
let (anchor, focus) = (serde_json::to_value(&s.sel.anchor).unwrap(), serde_json::to_value(&s.sel.focus).unwrap());
|
||||
s.run("select.range", &json!({"anchor": anchor, "focus": focus})).unwrap();
|
||||
assert!(s.cell_range().is_none());
|
||||
let b = cell_rect(&mut s, 1, 1);
|
||||
s.run("table.resizeDrag", &json!({"page": 0, "x": b.right(), "y": y, "toX": b.right() + 30.0, "toY": y})).unwrap();
|
||||
assert!((cell_rect(&mut s, 0, 1).w - (b.w + 30.0)).abs() < 1.0, "all of a cell's text selected");
|
||||
}
|
||||
|
||||
/// The jagged row a single cell's drag leaves is saved to .docx and read back the same.
|
||||
#[test]
|
||||
fn a_jagged_row_survives_a_docx_round_trip() {
|
||||
let mut s = table_session(3, 3);
|
||||
let b = cell_rect(&mut s, 1, 1);
|
||||
let y = b.y + b.h / 2.0;
|
||||
select_one_cell(&mut s, "arrow", 1, 1);
|
||||
s.run("table.resizeDrag", &json!({"page": 0, "x": b.right(), "y": y, "toX": b.right() + 30.0, "toY": y})).unwrap();
|
||||
let t = first_table(&s);
|
||||
let bytes = crate::io::save_bytes("jagged.docx", &s.doc).unwrap();
|
||||
let doc = crate::io::open_bytes("jagged.docx", &bytes).unwrap();
|
||||
let back = doc.body.iter().find_map(|b| b.as_table()).cloned().unwrap();
|
||||
let spans = |t: &Table| t.rows.iter().map(|r| r.cells.iter().map(Cell::span).collect::<Vec<_>>()).collect::<Vec<_>>();
|
||||
assert_eq!(spans(&back), spans(&t));
|
||||
let mut s2 = Session::new(doc);
|
||||
for c in 0..spans(&t)[1].len() {
|
||||
assert!((cell_rect(&mut s2, 1, c).w - cell_rect(&mut s, 1, c).w).abs() < 1.0, "cell {c}");
|
||||
}
|
||||
}
|
||||
|
||||
/// In a cell of two paragraphs, a triple-click on the last one selects the cell (its border
|
||||
/// then moves in its row only); on the first one it selects text, and the whole column moves.
|
||||
#[test]
|
||||
fn triple_click_selects_a_cell_only_on_its_last_paragraph() {
|
||||
for last in [true, false] {
|
||||
let mut s = table_session(3, 3);
|
||||
let b = cell_rect(&mut s, 1, 1);
|
||||
s.run("caret.set", &json!({"page": 0, "x": b.x + b.w / 2.0, "y": b.y + b.h / 2.0})).unwrap();
|
||||
s.run("text.insert", &json!({"text": "One"})).unwrap();
|
||||
s.run("text.newParagraph", &json!({})).unwrap();
|
||||
s.run("text.insert", &json!({"text": "Two"})).unwrap();
|
||||
s.run("select.text", &json!({"text": if last { "Two" } else { "One" }})).unwrap();
|
||||
s.run("select.paragraph", &json!({})).unwrap();
|
||||
assert!(s.cell_range().is_none(), "the ribbon's Select Paragraph never selects the cell");
|
||||
s.run("select.text", &json!({"text": if last { "Two" } else { "One" }})).unwrap();
|
||||
s.run("select.paragraph", &json!({"cell": true})).unwrap();
|
||||
assert_eq!(s.cell_range().is_some(), last);
|
||||
let b = cell_rect(&mut s, 1, 1);
|
||||
let y = b.y + b.h / 2.0;
|
||||
s.run("table.resizeDrag", &json!({"page": 0, "x": b.right(), "y": y, "toX": b.right() + 30.0, "toY": y})).unwrap();
|
||||
let moved = (cell_rect(&mut s, 0, 1).w - (b.w + 30.0)).abs() < 1.0;
|
||||
assert_eq!(moved, !last, "last paragraph {last}: the column moves only without a cell selection");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,14 @@ fn build_new(s: &Session, v: &Value) -> Result<Style, CmdError> {
|
||||
};
|
||||
let id = s.doc.styles.new_id(&name);
|
||||
let mut st = Style { id, name, kind: StyleKind::Table, based_on, priority: Some(99), ..Default::default() };
|
||||
// Bands of one row and one column unless the style it's based on sets them: Word, like us,
|
||||
// reads a style with no band size as having no bands (#3415).
|
||||
let base = st.based_on.as_deref().and_then(|b| s.doc.styles.table_style(b)).map(|t| t.parts).unwrap_or_default();
|
||||
if base.band_size.is_none() || base.col_band_size.is_none() {
|
||||
let parts = st.table.get_or_insert_with(TableStyleParts::default);
|
||||
parts.band_size = base.band_size.is_none().then_some(1);
|
||||
parts.col_band_size = base.col_band_size.is_none().then_some(1);
|
||||
}
|
||||
edit(&mut st, v)?;
|
||||
Ok(st)
|
||||
}
|
||||
|
||||
@@ -240,7 +240,7 @@ pub fn local_path(target: &str, base: Option<&Path>) -> Option<PathBuf> {
|
||||
Some(p)
|
||||
}
|
||||
|
||||
fn is_network_path(p: &str) -> bool {
|
||||
pub(crate) fn is_network_path(p: &str) -> bool {
|
||||
let b = p.as_bytes();
|
||||
matches!((b.first(), b.get(1)), (Some(b'\\' | b'/'), Some(b'\\' | b'/')))
|
||||
}
|
||||
|
||||
@@ -17,6 +17,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("text.columnBreak", "Column Break", "Layout › Breaks", |s, _| ins_char(s, COLUMN_BREAK)).key("Mod+Shift+Enter"),
|
||||
CommandSpec::new("text.tab", "Tab", "Editing", tab).key("Tab"),
|
||||
CommandSpec::new("text.backTab", "Shift+Tab", "Editing", back_tab).key("Shift+Tab"),
|
||||
// A tab character even in a table cell, where Tab moves to the next cell (#3137); ⌥Tab on macOS.
|
||||
CommandSpec::new("text.insertTab", "Tab Character", "Editing", |s, _| ins_char(s, '\t'))
|
||||
.key(if cfg!(target_os = "macos") { "Alt+Tab" } else { "Mod+Tab" }),
|
||||
CommandSpec::new("text.backspace", "Backspace", "Editing", backspace).key("Backspace"),
|
||||
CommandSpec::new("text.delete", "Delete", "Editing", delete).key("Delete"),
|
||||
CommandSpec::new("text.deleteWordBack", "Delete Previous Word", "Editing", delete_word_back).key("Mod+Backspace / Alt+Backspace"),
|
||||
@@ -133,14 +136,20 @@ fn insert(s: &mut Session, v: &Value) -> CmdResult {
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// "* " / "- " / "> " → bullets, "1. " / "a) " → numbering, typed at the start of a paragraph.
|
||||
/// "* " / "- " / "> " → bullets, "1. " / "a) " → numbering, typed at the start of a paragraph
|
||||
/// (Word triggers on the paragraph's first characters, whatever text already follows the caret,
|
||||
/// not only when the trigger is the paragraph's entire content — #3352).
|
||||
fn list_autoformat(s: &mut Session) -> Result<bool, CmdError> {
|
||||
let f = s.sel.focus.clone();
|
||||
let Some(para) = s.doc.para_at(&f) else { return Ok(false) };
|
||||
if para.props.numbering.is_some_and(|n| n.num != 0) || f.off != para.len() {
|
||||
// A style-linked list (a heading-linked Multilevel List preset, #1793) numbers the paragraph
|
||||
// without `props.numbering` itself being set: resolve it, so autoformat doesn't paper over an
|
||||
// already-numbered heading with a fresh bullet.
|
||||
if s.doc.styles.resolve_para(¶.props).numbering.is_some_and(|n| n.num != 0) {
|
||||
return Ok(false);
|
||||
}
|
||||
let kind = match para.text.as_str() {
|
||||
let Some(prefix) = para.text.get(..f.off) else { return Ok(false) };
|
||||
let kind = match prefix {
|
||||
"* " => ListKind::Bullet,
|
||||
"- " => ListKind::BulletChar('-'),
|
||||
// Arrow bullets (Word draws these from a symbol font; we use the Unicode arrows).
|
||||
@@ -168,8 +177,7 @@ fn list_autoformat(s: &mut Session) -> Result<bool, CmdError> {
|
||||
None => s.doc.numbering.try_add_list(kind)?,
|
||||
};
|
||||
let para = s.doc.para_mut(f.story, &f.path)?;
|
||||
let len = para.len();
|
||||
para.delete(0, len)?;
|
||||
para.delete(0, f.off)?;
|
||||
para.props.numbering = Some(NumRef { num, level: 0 });
|
||||
// A heading stays a heading (Word: "1. " in a Heading 1 gives a numbered Heading 1).
|
||||
if para.props.style.as_deref().is_none_or(|st| st == "Normal") {
|
||||
|
||||
@@ -489,17 +489,20 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.pure(),
|
||||
CommandSpec::new("file.newFromTemplate", "New from Template", "File › New", |s, v| {
|
||||
let path = p::req_str(v, "path")?;
|
||||
let doc = crate::io::open_path(std::path::Path::new(path)).map_err(CmdError::Failed)?;
|
||||
let mut doc = crate::io::open_path(std::path::Path::new(path)).map_err(CmdError::Failed)?;
|
||||
// As in Word, the new document is attached to the template it came from (#2852).
|
||||
doc.settings.attached_template = Some(path.to_string());
|
||||
s.set_document(doc);
|
||||
s.path = None;
|
||||
s.dirty = true;
|
||||
sel_result(s)
|
||||
})
|
||||
.params(r#"{"path": string}"#)
|
||||
.params(r#"{"path": string} → a new, untitled document from the template, attached to it"#)
|
||||
.pure(),
|
||||
CommandSpec::new("file.saveTemplate", "Save as Template", "File › Save As", |s, v| {
|
||||
let path = p::req_str(v, "path")?;
|
||||
let path = if path.ends_with(".dotx") { path.to_string() } else { format!("{path}.dotx") };
|
||||
// A .dotm keeps its name (#2852); anything else becomes a .dotx.
|
||||
let path = if super::user_templates::is_template_name(path) { path.to_string() } else { format!("{path}.dotx") };
|
||||
crate::io::save_path(std::path::Path::new(&path), &s.doc).map_err(CmdError::Failed)?;
|
||||
Ok(json!({"path": path}))
|
||||
})
|
||||
@@ -629,14 +632,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.params(r#"{"header"?: pt, "footer"?: pt}"#),
|
||||
CommandSpec::new("table.cellMargins", "Cell Margins", "Table Layout › Alignment", |s, v| {
|
||||
let Some((tp, _, _)) = s.sel.focus.path.cell() else { return Err(CmdError::Disabled("not in a table".into())) };
|
||||
let story = s.sel.focus.story;
|
||||
let current = {
|
||||
let table = s.doc.table(story, &tp).ok_or_else(|| CmdError::Failed("no table".into()))?;
|
||||
let style = table.props.style.as_deref().and_then(|id| s.doc.styles.table_style(id));
|
||||
table.props.cell_margins.or(style.and_then(|st| st.parts.cell_margins)).unwrap_or([0.0, 5.4, 0.0, 5.4])
|
||||
};
|
||||
let m = [
|
||||
p::f32(v, "top").unwrap_or(0.0),
|
||||
p::f32(v, "left").unwrap_or(5.4),
|
||||
p::f32(v, "bottom").unwrap_or(0.0),
|
||||
p::f32(v, "right").unwrap_or(5.4),
|
||||
p::f32(v, "top").unwrap_or(current[0]),
|
||||
p::f32(v, "left").unwrap_or(current[1]),
|
||||
p::f32(v, "bottom").unwrap_or(current[2]),
|
||||
p::f32(v, "right").unwrap_or(current[3]),
|
||||
]
|
||||
.map(|x| x.clamp(0.0, 144.0));
|
||||
let story = s.sel.focus.story;
|
||||
s.doc.table_mut(story, &tp)?.props.cell_margins = Some(m);
|
||||
for path in s.doc.para_paths(story).into_iter().filter(|p| p.0.starts_with(&tp.0) && p.0.len() > tp.0.len()) {
|
||||
s.doc.para_mut(story, &path)?.touch();
|
||||
@@ -699,7 +707,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
.params(r#"{"format": "decimal|upperRoman|lowerLetter…", "text"?: "%1.", "start"?: n}"#),
|
||||
CommandSpec::new("review.language", "Language", "Review › Language", language).params(
|
||||
r#"{"lang"?: "en-US|en-GB|pl-PL|fr-FR|…" (BCP 47; default en-US), "noProof"?: bool, "detect"?: bool (detect language automatically, a preference), "default"?: bool (also the document's default language)}"#,
|
||||
r#"{"lang"?: "en-US|en-GB|pl-PL|fr-FR|…" (BCP 47; default en-US), "noProof"?: bool, "detect"?: bool (detect language automatically, a preference), "default"?: bool (also the document's default and the saved language for future blank documents)}"#,
|
||||
),
|
||||
CommandSpec::new("review.showMarkup", "Show Markup", "Review › Tracking", |s, v| {
|
||||
s.view.show_markup = p::bool(v, "value").unwrap_or(!s.view.show_markup);
|
||||
@@ -752,7 +760,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
|
||||
/// Set Proofing Language: the selection's language and "do not check spelling or grammar",
|
||||
/// or (`default`) the document's default language; `detect` is the per-user preference.
|
||||
/// or (`default`) the document's default and the saved language for future blank documents;
|
||||
/// `detect` is the per-user detection preference.
|
||||
/// Nothing at all sets US English, as before.
|
||||
fn language(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let lang = match p::str(v, "lang") {
|
||||
@@ -784,14 +793,18 @@ fn language(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if lang.is_none() && no_proof.is_none() {
|
||||
return Ok(json!({"detect": s.prefs.detect_language}));
|
||||
}
|
||||
super::format::apply(s, &|c| {
|
||||
let result = super::format::apply(s, &|c| {
|
||||
if let Some(l) = &lang {
|
||||
c.lang = Some(l.clone());
|
||||
}
|
||||
if let Some(np) = no_proof {
|
||||
c.no_proof = Some(np);
|
||||
}
|
||||
})
|
||||
})?;
|
||||
if p::bool(v, "default") == Some(true) {
|
||||
s.prefs.default_language = lang;
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn macros(s: &mut Session, v: &Value) -> CmdResult {
|
||||
@@ -892,8 +905,8 @@ pub fn autocorrect_word(s: &mut Session, on_enter: bool) -> Result<(), CmdError>
|
||||
word = format!("{}{fixed}{}", word.get(..at).unwrap_or(""), word.get(at + letters.len()..).unwrap_or(""));
|
||||
}
|
||||
// A whole-word replacement, else "wait..." → "wait…". Proofing decides what is English the
|
||||
// same way (#835).
|
||||
let english = s.doc.para_at(&f).is_none_or(|_| wordcraft_proof::ProofLang::from_tag(lang.as_deref()) == wordcraft_proof::ProofLang::En);
|
||||
// same way (#835); every English (en-GB, en-CA…) counts (#1281).
|
||||
let english = s.doc.para_at(&f).is_none_or(|_| wordcraft_proof::ProofLang::from_tag(lang.as_deref()).is_english());
|
||||
let found = if ac.replace_text { ac.replacement_in(&word, english) } else { None };
|
||||
let (from, rep) = match found {
|
||||
Some(r) => (start, Some(r)),
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
//! Personal templates (#2852): the `.dotx`/`.dotm` files in the user templates folder, which
|
||||
//! File › New lists next to the built-in templates.
|
||||
//!
|
||||
//! The folder is the user's own (File › Options › Advanced › File locations, else the app's
|
||||
//! `Templates` folder, which the front end passes as `dir`). As in Word, the templates at its top
|
||||
//! level are listed, and each folder directly inside it is a category of the templates it holds;
|
||||
//! deeper folders are not looked into. A template is named by its file name, as in Word's
|
||||
//! gallery; its title (the core properties' `dc:title`), when it has one, comes along for search
|
||||
//! (a template saved from a document often keeps that document's title, so it can't be the name).
|
||||
//!
|
||||
//! Everything read here comes from the disk, so it is bounded: the number of entries looked at,
|
||||
//! of categories and of templates listed, the size of a template whose title is read and of the
|
||||
//! part it is read from. A file that can't be read is listed by its name or skipped, never an
|
||||
//! error. A network folder (`\\host\share`) is refused, as attached templates are
|
||||
//! ([`super::templates`]), and the automation read root applies to the folder and each file.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Session};
|
||||
|
||||
/// Most templates one listing returns.
|
||||
pub const MAX_TEMPLATES: usize = 500;
|
||||
/// Most category folders looked into.
|
||||
pub const MAX_CATEGORIES: usize = 64;
|
||||
/// Most directory entries looked at in one folder.
|
||||
const MAX_ENTRIES: usize = 10_000;
|
||||
/// Longest folder path taken, in bytes.
|
||||
const MAX_DIR: usize = 32 * 1024;
|
||||
/// Largest template whose title is read (bigger ones are listed by file name).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_TITLE_FILE: u64 = 64 << 20;
|
||||
/// Most bytes of the core properties part read for the title.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_CORE_XML: u64 = 64 << 10;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("file.listTemplates", "Personal Templates", "File › New", list)
|
||||
.params(r#"{"dir"?: string (the user templates folder; default: the one saved in File › Options)} → {"dir", "templates": [{"label" (the file name without its extension), "title": string|null (dc:title), "path", "category" ("" at the top level, else the subfolder's name), "modified": ISO-8601|null}] (.dotx and .dotm, at most 500, by category then label), "truncated": bool}; a network folder is refused"#)
|
||||
.pure(),
|
||||
]
|
||||
}
|
||||
|
||||
/// One template in the folder.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PersonalTemplate {
|
||||
/// Its file name without the extension.
|
||||
pub label: String,
|
||||
/// The title in its core properties, if any.
|
||||
pub title: Option<String>,
|
||||
pub path: String,
|
||||
/// The subfolder it is in; empty at the top level.
|
||||
pub category: String,
|
||||
/// When the file last changed (ISO-8601, UTC), if the system says.
|
||||
pub modified: Option<String>,
|
||||
}
|
||||
|
||||
/// What [`list_templates`] found.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Listing {
|
||||
pub templates: Vec<PersonalTemplate>,
|
||||
/// Some were left out (a cap was reached).
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let dir = match v.get("dir") {
|
||||
None | Some(Value::Null) => {
|
||||
s.prefs.user_templates_folder.clone().ok_or_else(|| CmdError::Params("give `dir`: no user templates folder is saved".into()))?
|
||||
}
|
||||
Some(Value::String(d)) => d.clone(),
|
||||
Some(_) => return Err(CmdError::Params("`dir` must be a folder path".into())),
|
||||
};
|
||||
let found = list_templates(&dir)?;
|
||||
Ok(json!({
|
||||
"dir": dir,
|
||||
"templates": found.templates.iter().map(|t| json!({"label": t.label, "title": t.title, "path": t.path, "category": t.category, "modified": t.modified})).collect::<Vec<_>>(),
|
||||
"truncated": found.truncated,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Check that `dir` may be listed: a full local path, not too long, not on another machine.
|
||||
pub fn check_dir(dir: &str) -> Result<(), CmdError> {
|
||||
let d = dir.trim();
|
||||
if d.is_empty() {
|
||||
return Err(CmdError::Params("`dir` is empty".into()));
|
||||
}
|
||||
if d.len() > MAX_DIR || d.chars().any(char::is_control) {
|
||||
return Err(CmdError::Params("`dir` isn't a folder path".into()));
|
||||
}
|
||||
// A folder on another machine: listing it would make Windows authenticate to that host.
|
||||
if super::templates::is_network_path(d) {
|
||||
return Err(CmdError::Params("templates aren't listed from a network folder".into()));
|
||||
}
|
||||
if !std::path::Path::new(d).is_absolute() {
|
||||
return Err(CmdError::Params(format!("`dir`: {d:?} isn't a full path")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The personal templates in `dir` (see the module docs).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn list_templates(dir: &str) -> Result<Listing, CmdError> {
|
||||
use std::path::Path;
|
||||
check_dir(dir)?;
|
||||
let dir = dir.trim();
|
||||
crate::file_access::check_read(dir).map_err(CmdError::Failed)?;
|
||||
let root = Path::new(dir);
|
||||
if !root.is_dir() {
|
||||
return Err(CmdError::Failed(format!("no folder at {dir}")));
|
||||
}
|
||||
let mut out = Listing::default();
|
||||
let (files, folders, cut) = entries(root);
|
||||
out.truncated |= cut;
|
||||
let mut found: Vec<(String, crate::io::DirEntry)> = files.into_iter().map(|f| (String::new(), f)).collect();
|
||||
let mut folders = folders;
|
||||
folders.sort();
|
||||
if folders.len() > MAX_CATEGORIES {
|
||||
folders.truncate(MAX_CATEGORIES);
|
||||
out.truncated = true;
|
||||
}
|
||||
for folder in folders {
|
||||
let Some(category) = folder.file_name().map(|n| n.to_string_lossy().into_owned()) else { continue };
|
||||
if crate::file_access::check_read(&folder.to_string_lossy()).is_err() {
|
||||
continue;
|
||||
}
|
||||
let (files, _, cut) = entries(&folder);
|
||||
out.truncated |= cut;
|
||||
found.extend(files.into_iter().map(|f| (category.clone(), f)));
|
||||
}
|
||||
// The cap applies to what is listed; titles are read only for those.
|
||||
found.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()).then_with(|| a.1.path.cmp(&b.1.path)));
|
||||
if found.len() > MAX_TEMPLATES {
|
||||
found.truncate(MAX_TEMPLATES);
|
||||
out.truncated = true;
|
||||
}
|
||||
for (category, e) in found {
|
||||
let title = title_of(&e.path);
|
||||
let modified = e.modified.map(super::iso_from_unix_secs);
|
||||
out.templates.push(PersonalTemplate { label: label_of(&e.name), title, path: e.path.to_string_lossy().into_owned(), category, modified });
|
||||
}
|
||||
// Two templates of one category that would show the same name (`Memo.dotx`, `Memo.dotm`)
|
||||
// show their file names instead, so they can be told apart.
|
||||
let key = |t: &PersonalTemplate| (t.category.to_lowercase(), t.label.to_lowercase());
|
||||
let mut seen = std::collections::HashMap::<(String, String), usize>::new();
|
||||
for t in &out.templates {
|
||||
*seen.entry(key(t)).or_default() += 1;
|
||||
}
|
||||
for t in &mut out.templates {
|
||||
if seen.get(&key(t)).is_some_and(|n| *n > 1)
|
||||
&& let Some(name) = std::path::Path::new(&t.path).file_name()
|
||||
{
|
||||
t.label = name.to_string_lossy().into_owned();
|
||||
}
|
||||
}
|
||||
// The top level first, then each category; by label within them.
|
||||
out.templates.sort_by_cached_key(|t| (!t.category.is_empty(), t.category.to_lowercase(), t.label.to_lowercase(), t.path.clone()));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn list_templates(dir: &str) -> Result<Listing, CmdError> {
|
||||
check_dir(dir)?;
|
||||
Err(CmdError::Failed("templates folders can't be listed on the web".into()))
|
||||
}
|
||||
|
||||
/// The template files and the folders directly in `dir` (hidden ones left out), and whether the
|
||||
/// entry cap cut the listing short. An unreadable folder has none.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn entries(dir: &std::path::Path) -> (Vec<crate::io::DirEntry>, Vec<std::path::PathBuf>, bool) {
|
||||
let Ok((list, cut)) = crate::io::list_dir(dir, MAX_ENTRIES) else { return (Vec::new(), Vec::new(), false) };
|
||||
let (mut files, mut folders) = (Vec::new(), Vec::new());
|
||||
for e in list {
|
||||
if e.name.starts_with('.') || e.name.starts_with("~$") {
|
||||
continue;
|
||||
}
|
||||
if e.dir {
|
||||
folders.push(e.path);
|
||||
} else if is_template_name(&e.name) {
|
||||
files.push(e);
|
||||
}
|
||||
}
|
||||
(files, folders, cut)
|
||||
}
|
||||
|
||||
/// The name a template shows: its file name without the template extension, every one of them
|
||||
/// (`Contract.dotm.dotx`, as older Save as Template made, is `Contract`); the whole name if
|
||||
/// nothing would be left.
|
||||
fn label_of(name: &str) -> String {
|
||||
let mut label = name;
|
||||
while is_template_name(label) {
|
||||
let Some(stem) = label.get(..label.len().saturating_sub(5)) else { break };
|
||||
label = stem;
|
||||
}
|
||||
let label = label.trim_end();
|
||||
if label.is_empty() { name.to_string() } else { label.to_string() }
|
||||
}
|
||||
|
||||
/// A Word template by its name: `.dotx` or `.dotm`, in any case.
|
||||
pub fn is_template_name(name: &str) -> bool {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
(lower.ends_with(".dotx") || lower.ends_with(".dotm")) && lower.len() > 5
|
||||
}
|
||||
|
||||
/// The template's title from its core properties, read from the package's directory and at most
|
||||
/// [`MAX_CORE_XML`] bytes of the part; `None` for anything else.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn title_of(path: &std::path::Path) -> Option<String> {
|
||||
let title = crate::io::package_title(path, MAX_TITLE_FILE, MAX_CORE_XML)?;
|
||||
// One line of a sensible length for a gallery caption.
|
||||
let title: String = title.chars().map(|c| if c.is_control() { ' ' } else { c }).take(200).collect();
|
||||
let title = title.trim();
|
||||
(!title.is_empty()).then(|| title.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn scratch(tag: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("wc-personal-templates-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// A real template written by the engine, with `title` as its core title (empty: none).
|
||||
fn template(path: &std::path::Path, text: &str, title: &str) {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("document.setText", &json!({"text": text})).unwrap();
|
||||
s.doc.core.title = title.into();
|
||||
crate::io::save_path(path, &s.doc).unwrap();
|
||||
}
|
||||
|
||||
fn labels(r: &Value) -> Vec<(String, String)> {
|
||||
r["templates"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|t| (t["category"].as_str().unwrap().to_string(), t["label"].as_str().unwrap().to_string()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_dotx_and_dotm_with_subfolders_as_categories() {
|
||||
let d = scratch("list");
|
||||
template(&d.join("Weekly report.dotx"), "Report body", "");
|
||||
template(&d.join("letterhead.dotx"), "Letter body", "Company letterhead");
|
||||
template(&d.join("Macros.dotm"), "Macro body", "");
|
||||
template(&d.join("Not a template.docx"), "Doc body", "");
|
||||
std::fs::write(d.join("notes.txt"), "x").unwrap();
|
||||
std::fs::write(d.join("Broken.dotx"), b"this is no zip").unwrap();
|
||||
std::fs::write(d.join(".hidden.dotx"), b"x").unwrap();
|
||||
std::fs::create_dir_all(d.join("Legal/Deeper")).unwrap();
|
||||
template(&d.join("Legal/NDA.DOTX"), "NDA body", "");
|
||||
template(&d.join("Legal/Deeper/Too deep.dotx"), "x", "");
|
||||
std::fs::create_dir_all(d.join("Empty")).unwrap();
|
||||
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
let r = s.run("file.listTemplates", &json!({"dir": d.to_string_lossy()})).unwrap();
|
||||
assert_eq!(
|
||||
labels(&r),
|
||||
[
|
||||
("".into(), "Broken".into()),
|
||||
("".into(), "letterhead".into()),
|
||||
("".into(), "Macros".into()),
|
||||
("".into(), "Weekly report".into()),
|
||||
("Legal".into(), "NDA".into()),
|
||||
],
|
||||
"{r}"
|
||||
);
|
||||
assert_eq!(r["truncated"], json!(false));
|
||||
let first = &r["templates"][1];
|
||||
assert_eq!(first["title"], json!("Company letterhead"));
|
||||
assert_eq!(r["templates"][0]["title"], json!(null), "a broken file has no title");
|
||||
assert!(first["path"].as_str().unwrap().ends_with("letterhead.dotx"));
|
||||
assert!(first["modified"].as_str().is_some_and(|m| m.ends_with('Z')));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_network_relative_and_missing_folders() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
for dir in [r"\\host\share\Templates", "//host/share/Templates", "relative/Templates", "", " "] {
|
||||
assert!(matches!(s.run("file.listTemplates", &json!({"dir": dir})), Err(CmdError::Params(_))), "{dir:?}");
|
||||
}
|
||||
let missing = std::env::temp_dir().join(format!("wc-no-such-templates-{}", std::process::id()));
|
||||
assert!(s.run("file.listTemplates", &json!({"dir": missing.to_string_lossy()})).is_err());
|
||||
// Without `dir` the saved folder is used; none saved is an error.
|
||||
assert!(matches!(s.run("file.listTemplates", &json!({})), Err(CmdError::Params(_))));
|
||||
for junk in [json!(null), json!({"dir": 5}), json!({"dir": []}), json!({"dir": "x".repeat(40_000)}), json!({"dir": "/a\u{0}b"})] {
|
||||
let _ = s.run("file.listTemplates", &junk);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_saved_folder_is_listed_without_dir() {
|
||||
let d = scratch("saved");
|
||||
template(&d.join("Saved.dotx"), "x", "");
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("file.options", &json!({"userTemplatesFolder": d.to_string_lossy()})).unwrap();
|
||||
let r = s.run("file.listTemplates", &json!({})).unwrap();
|
||||
assert_eq!(labels(&r), [("".to_string(), "Saved".to_string())]);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_listing_is_capped() {
|
||||
let d = scratch("cap");
|
||||
for i in 0..MAX_TEMPLATES + 5 {
|
||||
std::fs::write(d.join(format!("t{i:04}.dotx")), b"").unwrap();
|
||||
}
|
||||
let found = list_templates(&d.to_string_lossy()).unwrap();
|
||||
assert_eq!(found.templates.len(), MAX_TEMPLATES);
|
||||
assert!(found.truncated);
|
||||
assert_eq!(found.templates.first().map(|t| t.label.as_str()), Some("t0000"));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// A document made from a personal template is untitled, unsaved and attached to the
|
||||
/// template, as in Word.
|
||||
#[test]
|
||||
fn new_from_template_is_untitled_and_attached() {
|
||||
let d = scratch("new");
|
||||
let path = d.join("Memo.dotx");
|
||||
template(&path, "Memo text", "");
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.path = Some(d.join("Earlier.docx"));
|
||||
s.run("file.newFromTemplate", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
assert_eq!(s.path, None);
|
||||
assert!(s.dirty);
|
||||
assert_eq!(s.doc.settings.attached_template.as_deref(), Some(path.to_string_lossy().as_ref()));
|
||||
assert!(s.doc.plain_text(wordcraft_doc::StoryRef::Body).contains("Memo text"));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_templates_folder_option_is_saved_and_checked() {
|
||||
let d = scratch("option");
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
for bad in [json!(r"\\host\share"), json!("//host/share"), json!("relative/x"), json!(7)] {
|
||||
assert!(s.run("file.options", &json!({"userTemplatesFolder": bad})).is_err(), "{bad}");
|
||||
}
|
||||
assert!(s.run("file.options", &json!({"userTemplatesFolder": d.join("missing").to_string_lossy()})).is_err());
|
||||
assert_eq!(s.prefs.user_templates_folder, None, "a refused folder changes nothing");
|
||||
let r = s.run("file.options", &json!({"userTemplatesFolder": d.to_string_lossy()})).unwrap();
|
||||
assert_eq!(r["userTemplatesFolder"], json!(d.to_string_lossy()));
|
||||
assert_eq!(serde_json::to_value(&s.prefs).unwrap()["userTemplatesFolder"], json!(d.to_string_lossy()));
|
||||
s.run("file.options", &json!({"userTemplatesFolder": "auto"})).unwrap();
|
||||
assert_eq!(s.prefs.user_templates_folder, None);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// .dotx and .dotm templates show their names the same way, without the extension; two that
|
||||
/// would look alike in one category show their file names.
|
||||
#[test]
|
||||
fn labels_drop_the_extension_alike_and_tell_twins_apart() {
|
||||
let d = scratch("labels");
|
||||
template(&d.join("Service contract.dotm"), "a", "");
|
||||
template(&d.join("Old contract.dotm.dotx"), "b", "");
|
||||
template(&d.join("Memo.dotx"), "c", "");
|
||||
template(&d.join("Memo.DOTM"), "d", "");
|
||||
std::fs::create_dir_all(d.join("Other")).unwrap();
|
||||
template(&d.join("Other/Memo.dotx"), "e", "");
|
||||
let found = list_templates(&d.to_string_lossy()).unwrap();
|
||||
let got: Vec<(&str, &str)> = found.templates.iter().map(|t| (t.category.as_str(), t.label.as_str())).collect();
|
||||
assert_eq!(got, [("", "Memo.DOTM"), ("", "Memo.dotx"), ("", "Old contract"), ("", "Service contract"), ("Other", "Memo")]);
|
||||
assert_eq!(label_of(".dotx.dotm"), ".dotx", "never empty");
|
||||
assert_eq!(label_of(" .dotx"), " .dotx", "nothing left: the whole name");
|
||||
assert_eq!(label_of("é.dotx"), "é");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// Save as Template keeps a .dotm (any case) and makes anything else a .dotx.
|
||||
#[test]
|
||||
fn save_template_keeps_a_template_extension() {
|
||||
let d = scratch("save");
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
for (asked, saved) in [("A.dotm", "A.dotm"), ("B.DOTX", "B.DOTX"), ("C", "C.dotx"), ("D.docx", "D.docx.dotx")] {
|
||||
let r = s.run("file.saveTemplate", &json!({"path": d.join(asked).to_string_lossy()})).unwrap();
|
||||
assert_eq!(r["path"], json!(d.join(saved).to_string_lossy()));
|
||||
assert!(d.join(saved).is_file(), "{saved}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_names() {
|
||||
assert!(is_template_name("a.dotx") && is_template_name("B.DOTM"));
|
||||
assert!(!is_template_name(".dotx") && !is_template_name("a.docx") && !is_template_name("a.dot"));
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,22 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("view.hideWhiteSpace", "Hide White Space", "View › Show", |s, v| toggle(s, v, |x| &mut x.hide_white_space))
|
||||
.params(r#"{"value"?: bool}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("view.focus", "Focus", "View › Immersive", |s, v| toggle(s, v, |x| &mut x.focus_mode)).pure(),
|
||||
// Focus and Full Screen View (#1678) fill the screen; turning one on turns the other off.
|
||||
// Full Screen View has no key of its own: Customize Keyboard binds one.
|
||||
CommandSpec::new("view.focus", "Focus", "View › Immersive", |s, v| {
|
||||
let r = toggle(s, v, |x| &mut x.focus_mode);
|
||||
s.view.full_screen_view &= !s.view.focus_mode;
|
||||
r
|
||||
})
|
||||
.params(r#"{"value"?: bool}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("view.fullScreenView", "Full Screen View", "View › Immersive", |s, v| {
|
||||
let r = toggle(s, v, |x| &mut x.full_screen_view);
|
||||
s.view.focus_mode &= !s.view.full_screen_view;
|
||||
r
|
||||
})
|
||||
.params(r#"{"value"?: bool}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("view.ruler", "Ruler", "View › Show", |s, v| toggle(s, v, |x| &mut x.ruler)).pure(),
|
||||
// The drawing grid over the page (screen only; never printed or exported).
|
||||
CommandSpec::new("view.gridlines", "Gridlines", "View › Show", |s, v| toggle(s, v, |x| &mut x.gridlines))
|
||||
@@ -100,6 +115,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("view.altTextPane", "Alt Text Pane", "Review › Accessibility", |s, v| toggle(s, v, |x| &mut x.alt_text_pane))
|
||||
.params(r#"{"value"?: bool}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("view.formatShapePane", "Format Shape Pane", "Shape Format › Shape Styles", |s, v| {
|
||||
toggle(s, v, |x| &mut x.format_shape_pane)
|
||||
})
|
||||
.params(r#"{"value"?: bool}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("view.newWindow", "New Window", "View › Window", |s, _| {
|
||||
s.ui_requests.push(json!({"open": "newWindow"}));
|
||||
sel_result(s)
|
||||
|
||||
+87
-8
@@ -398,12 +398,7 @@ pub fn open_path_with(path: &std::path::Path, password: Option<&str>) -> Result<
|
||||
/// [`open_path_with`] in `region` (see [`open_bytes_in`]).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn open_path_in(path: &std::path::Path, password: Option<&str>, region: &wordcraft_geom::Region) -> Result<(Document, bool), String> {
|
||||
crate::file_access::check_read(&path.to_string_lossy())?;
|
||||
let meta = std::fs::metadata(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
if meta.len() > 2 << 30 {
|
||||
return Err(format!("{}: file is larger than 2 GB", path.display()));
|
||||
}
|
||||
let bytes = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let bytes = read_document(path)?;
|
||||
let name = path.to_string_lossy();
|
||||
if matches!(ext_of(&name).as_str(), "html" | "htm" | "xhtml") {
|
||||
// Pictures referenced by a relative path load from beside the HTML file.
|
||||
@@ -417,6 +412,32 @@ pub fn open_path_in(path: &std::path::Path, password: Option<&str>, region: &wor
|
||||
open_bytes_in(&name, &bytes, password, region)
|
||||
}
|
||||
|
||||
/// Largest document file opened by path.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_DOCUMENT: u64 = 2 << 30;
|
||||
|
||||
/// The bytes of the document at `path`, read as `file.open` reads a path: inside the automation
|
||||
/// read root, and only a regular file of at most 2 GB. A named pipe would block the open and a
|
||||
/// device such as `/dev/zero` would never end ([`read_regular_file`]). Public for the app, which
|
||||
/// opens a file whose name isn't valid Unicode through `file.open`'s `data` (#1440).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn read_document(path: &std::path::Path) -> Result<Vec<u8>, String> {
|
||||
crate::file_access::check_read(&path.to_string_lossy())?;
|
||||
let meta = std::fs::metadata(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
if !meta.is_file() {
|
||||
return Err(format!("{}: not a file", path.display()));
|
||||
}
|
||||
if meta.len() > MAX_DOCUMENT {
|
||||
return Err(format!("{}: file is larger than 2 GB", path.display()));
|
||||
}
|
||||
// Checked again on the opened file, which may not be the one just looked at.
|
||||
match read_regular_file(path, MAX_DOCUMENT) {
|
||||
Some(bytes) if bytes.len() as u64 <= MAX_DOCUMENT => Ok(bytes),
|
||||
Some(_) => Err(format!("{}: file is larger than 2 GB", path.display())),
|
||||
None => Err(format!("{}: could not be read as a file", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
/// The pictures an HTML file names by a path relative to its folder.
|
||||
///
|
||||
/// Only regular files inside the folder (after resolving `..` and symlinks) are read. The folder is
|
||||
@@ -521,12 +542,17 @@ fn read_regular_file(path: &std::path::Path, limit: u64) -> Option<Vec<u8>> {
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Largest file [`read_file`] reads whole — the same 2 GB ceiling [`open_path_with`] gives a
|
||||
/// document.
|
||||
pub const MAX_READ_FILE: u64 = 2 << 30;
|
||||
|
||||
/// The bytes of the file at `path`, for commands that take a path (pictures, mail-merge data).
|
||||
/// Every such read goes through here so the automation read root applies ([`crate::file_access`]).
|
||||
/// Only a regular file is read, and at most [`MAX_READ_FILE`] bytes of it: a named pipe blocks
|
||||
/// when opened and a device like `/dev/zero` never reaches end of file.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn read_file(path: &str) -> Result<Vec<u8>, String> {
|
||||
crate::file_access::check_read(path)?;
|
||||
std::fs::read(path).map_err(|e| format!("{path}: {e}"))
|
||||
read_file_capped(path, MAX_READ_FILE, "file")
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
@@ -561,6 +587,59 @@ pub fn read_file_head(path: &str, max: u64) -> Result<Vec<u8>, String> {
|
||||
Ok(head)
|
||||
}
|
||||
|
||||
/// An entry of [`list_dir`]: a file or folder (links followed).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DirEntry {
|
||||
pub name: String,
|
||||
pub path: std::path::PathBuf,
|
||||
pub dir: bool,
|
||||
/// The file's size in bytes (0 for a folder).
|
||||
pub len: u64,
|
||||
/// When it last changed, in seconds since the Unix epoch, if the system says.
|
||||
pub modified: Option<u64>,
|
||||
}
|
||||
|
||||
/// The files and folders in the folder at `path`, inside the automation read root (entries
|
||||
/// leading outside it, and ones that can't be read, are left out); at most `max` entries are
|
||||
/// looked at, and `true` says the listing was cut short there.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn list_dir(path: &std::path::Path, max: usize) -> Result<(Vec<DirEntry>, bool), String> {
|
||||
crate::file_access::check_read(&path.to_string_lossy())?;
|
||||
let read = std::fs::read_dir(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let mut out = Vec::new();
|
||||
for (seen, entry) in read.enumerate() {
|
||||
if seen >= max {
|
||||
return Ok((out, true));
|
||||
}
|
||||
let Ok(entry) = entry else { continue };
|
||||
let path = entry.path();
|
||||
if crate::file_access::check_read(&path.to_string_lossy()).is_err() {
|
||||
continue;
|
||||
}
|
||||
let Ok(meta) = std::fs::metadata(&path) else { continue };
|
||||
if !meta.is_dir() && !meta.is_file() {
|
||||
continue;
|
||||
}
|
||||
let modified = meta.modified().ok().and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_secs());
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
out.push(DirEntry { name, path, dir: meta.is_dir(), len: if meta.is_file() { meta.len() } else { 0 }, modified });
|
||||
}
|
||||
Ok((out, false))
|
||||
}
|
||||
|
||||
/// The title in the core properties of the Word package at `path` (a file of at most `max_file`
|
||||
/// bytes; at most `max_part` bytes of the part are read), inside the automation read root.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn package_title(path: &std::path::Path, max_file: u64, max_part: u64) -> Option<String> {
|
||||
crate::file_access::check_read(&path.to_string_lossy()).ok()?;
|
||||
let file = std::fs::File::open(path).ok()?;
|
||||
if file.metadata().ok()?.len() > max_file {
|
||||
return None;
|
||||
}
|
||||
wordcraft_docx::package_title(std::io::BufReader::new(file), max_part)
|
||||
}
|
||||
|
||||
/// Rename without replacing another file, respecting both automation access roots.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn rename_file(source: &std::path::Path, destination: &std::path::Path) -> Result<(), String> {
|
||||
|
||||
@@ -24,7 +24,7 @@ pub(super) fn write(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
let written = replace_with(path, private, |file| {
|
||||
file.write_all(bytes)?;
|
||||
if let Some(permissions) = permissions {
|
||||
file.set_permissions(permissions)?;
|
||||
keep_permissions(file.set_permissions(permissions))?;
|
||||
}
|
||||
// Synchronize the prepared file before replacing the destination. Directory durability
|
||||
// and network-filesystem/power-loss guarantees are outside this operation's contract.
|
||||
@@ -33,6 +33,34 @@ pub(super) fn write(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
written.map_err(explain)
|
||||
}
|
||||
|
||||
/// A folder that keeps no Unix modes refuses to set one with "Operation not supported": a NAS
|
||||
/// share mounted by GVFS (`smb://` in the Linux file manager) refuses every mode change (#3003).
|
||||
/// There is no mode to keep there, so the save goes on; any other failure still stops it.
|
||||
fn keep_permissions(set: io::Result<()>) -> io::Result<()> {
|
||||
match set {
|
||||
Err(e) if not_supported(&e) => Ok(()),
|
||||
set => set,
|
||||
}
|
||||
}
|
||||
|
||||
/// ENOTSUP and EOPNOTSUPP, which std doesn't count as `Unsupported` (only ENOSYS), plus that kind.
|
||||
fn not_supported(e: &io::Error) -> bool {
|
||||
const CODES: &[i32] = if cfg!(any(target_os = "linux", target_os = "android")) {
|
||||
&[95]
|
||||
} else if cfg!(any(target_os = "macos", target_os = "ios")) {
|
||||
&[45, 102]
|
||||
} else if cfg!(target_os = "openbsd") {
|
||||
&[45, 91]
|
||||
} else if cfg!(target_os = "netbsd") {
|
||||
&[45, 86]
|
||||
} else if cfg!(any(target_os = "freebsd", target_os = "dragonfly")) {
|
||||
&[45]
|
||||
} else {
|
||||
&[]
|
||||
};
|
||||
e.kind() == io::ErrorKind::Unsupported || e.raw_os_error().is_some_and(|code| CODES.contains(&code))
|
||||
}
|
||||
|
||||
fn replace_with(path: &Path, private: bool, prepare: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> {
|
||||
let mut temp = match Temporary::create(path, private) {
|
||||
Ok(temp) => temp,
|
||||
@@ -245,6 +273,43 @@ mod tests {
|
||||
assert_eq!(fs::read_dir(&d.0).unwrap().count(), 2);
|
||||
}
|
||||
|
||||
/// #3003: a folder that refuses mode changes (a GVFS-mounted NAS share) still takes the
|
||||
/// save; a refused mode change for any other reason still stops it and keeps the original.
|
||||
#[test]
|
||||
fn a_folder_without_unix_modes_still_takes_the_save() {
|
||||
let d = Scratch::new();
|
||||
let path = d.target();
|
||||
fs::write(&path, b"Original bytes.").unwrap();
|
||||
replace_with(&path, true, |file| {
|
||||
file.write_all(b"Saved on the share.")?;
|
||||
keep_permissions(Err(io::Error::from(io::ErrorKind::Unsupported)))?;
|
||||
file.sync_all()
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(fs::read(&path).unwrap(), b"Saved on the share.");
|
||||
assert_eq!(fs::read_dir(&d.0).unwrap().count(), 1);
|
||||
|
||||
let err = replace_with(&path, true, |file| {
|
||||
file.write_all(b"Refused.")?;
|
||||
keep_permissions(Err(io::Error::from(io::ErrorKind::PermissionDenied)))
|
||||
})
|
||||
.unwrap_err();
|
||||
assert_eq!(err.kind(), io::ErrorKind::PermissionDenied);
|
||||
assert_eq!(fs::read(&path).unwrap(), b"Saved on the share.");
|
||||
assert_eq!(fs::read_dir(&d.0).unwrap().count(), 1);
|
||||
}
|
||||
|
||||
/// GVFS reports a refused mode change as ENOTSUP, which std leaves uncategorized; other OS
|
||||
/// errors still stop the save.
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
#[test]
|
||||
fn enotsup_from_the_os_is_an_unsupported_mode_change() {
|
||||
let enotsup = if cfg!(target_os = "linux") { 95 } else { 45 };
|
||||
assert!(keep_permissions(Err(io::Error::from_raw_os_error(enotsup))).is_ok());
|
||||
let eacces = 13;
|
||||
assert!(keep_permissions(Err(io::Error::from_raw_os_error(eacces))).is_err());
|
||||
}
|
||||
|
||||
/// Makes `dir` read-only while `f` runs. `None` when running as root, where the folder isn't
|
||||
/// read-only to us.
|
||||
#[cfg(unix)]
|
||||
|
||||
@@ -88,6 +88,21 @@ impl Session {
|
||||
}
|
||||
None
|
||||
}
|
||||
/// Waits for recovery to finish anything already under way, discarding this session's own
|
||||
/// copy first if it no longer needs one (the document was saved, say). Desktop hosts call
|
||||
/// this right before the process exits, the same as they wait out pending print jobs:
|
||||
/// otherwise a save just before close can race the process exiting before a now-redundant
|
||||
/// recovery copy actually gets removed, so the next launch wrongly offers to recover a
|
||||
/// document that was in fact saved cleanly.
|
||||
pub fn finish_recovery(&mut self) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(mut r) = self.recovery.take() {
|
||||
if let Err(e) = r.finish(self) {
|
||||
log::warn!("recovery: {e}");
|
||||
}
|
||||
self.recovery = Some(r);
|
||||
}
|
||||
}
|
||||
/// Only an explicit protect/save action waits for the worker and removes plaintext copies.
|
||||
/// A failure prevents applying the password; typing never waits.
|
||||
pub(crate) fn clear_recovery_before_password(&mut self) -> Result<(), CmdError> {
|
||||
|
||||
@@ -25,6 +25,29 @@ pub(crate) struct Snapshot {
|
||||
generation: u64,
|
||||
pub document: Document,
|
||||
}
|
||||
/// A failed snapshot write. `permanent` failures come from the snapshot itself (its size, the
|
||||
/// folder or disk budget, a value the format cannot keep): writing the same document again would
|
||||
/// fail the same way, so it is not retried until the document changes (#2625).
|
||||
#[derive(Debug)]
|
||||
struct SaveError {
|
||||
message: String,
|
||||
permanent: bool,
|
||||
}
|
||||
impl SaveError {
|
||||
fn permanent(message: impl Into<String>) -> Self {
|
||||
Self { message: message.into(), permanent: true }
|
||||
}
|
||||
}
|
||||
impl From<String> for SaveError {
|
||||
fn from(message: String) -> Self {
|
||||
Self { message, permanent: false }
|
||||
}
|
||||
}
|
||||
impl From<&str> for SaveError {
|
||||
fn from(message: &str) -> Self {
|
||||
message.to_owned().into()
|
||||
}
|
||||
}
|
||||
struct Entry {
|
||||
info: RecoveryInfo,
|
||||
path: PathBuf,
|
||||
@@ -222,7 +245,7 @@ impl Store {
|
||||
}
|
||||
Ok(used)
|
||||
}
|
||||
fn save(&mut self, s: Snapshot) -> Result<(), String> {
|
||||
fn save(&mut self, s: Snapshot) -> Result<(), SaveError> {
|
||||
let id = s.info.id.clone();
|
||||
let result = self.write_snapshot(s);
|
||||
// Include failures before temp-file creation (quota checks, locking, etc.).
|
||||
@@ -235,11 +258,11 @@ impl Store {
|
||||
}
|
||||
result
|
||||
}
|
||||
fn write_snapshot(&mut self, mut s: Snapshot) -> Result<(), String> {
|
||||
fn write_snapshot(&mut self, mut s: Snapshot) -> Result<(), SaveError> {
|
||||
let dir = self.root.join(&s.info.id);
|
||||
if !dir.exists() {
|
||||
if self.directories()?.len() >= MAX_RECORDS {
|
||||
return Err("recovery folder is full; discard an older copy".into());
|
||||
return Err(SaveError::permanent("recovery folder is full; discard an older copy"));
|
||||
}
|
||||
private_dir(&dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
@@ -253,16 +276,20 @@ impl Store {
|
||||
let limit = MAX_BYTES.min(DISK_BUDGET.saturating_sub(self.disk_used()?));
|
||||
let tmp = dir.join(format!("pending-{}.tmp", new_id()?));
|
||||
let final_path = dir.join(format!("snapshot-{:020}.json", s.generation));
|
||||
let result = (|| {
|
||||
let result = (|| -> Result<(), SaveError> {
|
||||
let file = private_file(&tmp, true).map_err(|e| e.to_string())?;
|
||||
let mut writer = Limited { inner: BufWriter::new(file), remaining: limit };
|
||||
serde_json::to_writer(&mut writer, &s).map_err(|e| e.to_string())?;
|
||||
writer.flush().map_err(|e| e.to_string())?;
|
||||
let mut writer = Limited { inner: BufWriter::new(file), remaining: limit, exceeded: false };
|
||||
let written = serde_json::to_writer(&mut writer, &s).map_err(|e| e.to_string()).and_then(|()| writer.flush().map_err(|e| e.to_string()));
|
||||
if let Err(e) = written {
|
||||
return Err(SaveError { message: e, permanent: writer.exceeded });
|
||||
}
|
||||
writer.inner.get_ref().sync_all().map_err(|e| e.to_string())?;
|
||||
drop(writer);
|
||||
let check = read_snapshot(&tmp)?;
|
||||
// The copy was written and synchronized; one that does not decode back to the same
|
||||
// document fails the same way every time.
|
||||
let check = read_snapshot(&tmp).map_err(SaveError::permanent)?;
|
||||
if check.document != s.document {
|
||||
return Err("recovery format did not preserve the full document".into());
|
||||
return Err(SaveError::permanent("recovery format did not preserve the full document"));
|
||||
}
|
||||
// A new generation is never written over the last valid file.
|
||||
fs::rename(&tmp, &final_path).map_err(|e| e.to_string())?;
|
||||
@@ -329,10 +356,12 @@ impl Store {
|
||||
struct Limited<W> {
|
||||
inner: W,
|
||||
remaining: u64,
|
||||
exceeded: bool,
|
||||
}
|
||||
impl<W: Write> Write for Limited<W> {
|
||||
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||
if bytes.len() as u64 > self.remaining {
|
||||
self.exceeded = true;
|
||||
return Err(io::Error::other("recovery snapshot or folder size limit exceeded"));
|
||||
}
|
||||
let n = self.inner.write(bytes)?;
|
||||
@@ -357,6 +386,8 @@ enum Outcome {
|
||||
struct Event {
|
||||
documents: Vec<RecoveryInfo>,
|
||||
outcome: Result<Outcome, String>,
|
||||
/// The failure would repeat for the same document; see [`SaveError`].
|
||||
permanent: bool,
|
||||
}
|
||||
pub(crate) struct Recovery {
|
||||
tx: mpsc::SyncSender<Job>,
|
||||
@@ -381,24 +412,28 @@ impl Recovery {
|
||||
let mut store = match Store::new(root) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
let _ = events.send(Event { documents: Vec::new(), outcome: Err(e) });
|
||||
let _ = events.send(Event { documents: Vec::new(), outcome: Err(e), permanent: false });
|
||||
wake();
|
||||
return;
|
||||
}
|
||||
};
|
||||
let initial = store.scan().map(|_| Outcome::Done);
|
||||
if events.send(Event { documents: store.inventory(), outcome: initial }).is_err() {
|
||||
if events.send(Event { documents: store.inventory(), outcome: initial, permanent: false }).is_err() {
|
||||
return;
|
||||
}
|
||||
wake();
|
||||
while let Ok(job) = jobs.recv() {
|
||||
let mut permanent = false;
|
||||
let result = match job {
|
||||
Job::Save(s) => store.save(*s).map(|_| Outcome::Done),
|
||||
Job::Save(s) => store.save(*s).map(|_| Outcome::Done).map_err(|e| {
|
||||
permanent = e.permanent;
|
||||
e.message
|
||||
}),
|
||||
Job::Scan => store.scan().map(|_| Outcome::Done),
|
||||
Job::Load(id, expected) => store.load(&id).map(|s| Outcome::Loaded(Box::new(s), expected)),
|
||||
Job::Discard(id) => store.discard(&id).map(|_| Outcome::Discarded(id)),
|
||||
};
|
||||
if events.send(Event { documents: store.inventory(), outcome: result }).is_err() {
|
||||
if events.send(Event { documents: store.inventory(), outcome: result, permanent }).is_err() {
|
||||
break;
|
||||
}
|
||||
wake();
|
||||
@@ -437,6 +472,7 @@ impl Recovery {
|
||||
self.busy = false;
|
||||
self.ready = true;
|
||||
self.documents = e.documents;
|
||||
let event_permanent = e.permanent;
|
||||
match e.outcome {
|
||||
Ok(Outcome::Done) => None,
|
||||
Ok(Outcome::Discarded(id)) => {
|
||||
@@ -448,7 +484,11 @@ impl Recovery {
|
||||
Ok(Outcome::Loaded(s, expected)) => Some((*s, expected)),
|
||||
Err(e) => {
|
||||
self.error = Some(e);
|
||||
self.revision = None;
|
||||
// A transient failure (a lock, an I/O error) is retried at the next
|
||||
// interval; one the snapshot itself causes waits for the next edit.
|
||||
if !event_permanent {
|
||||
self.revision = None;
|
||||
}
|
||||
self.due = Instant::now() + INTERVAL;
|
||||
None
|
||||
}
|
||||
@@ -549,6 +589,51 @@ impl Recovery {
|
||||
self.revision = None;
|
||||
Ok(())
|
||||
}
|
||||
/// Waits out any job already in flight (an autosave queued just before shutdown, say), then,
|
||||
/// if the document turns out to need no recovery copy kept (not dirty, or password-protected
|
||||
/// — [`tick`](Self::tick)'s own condition), discards it and waits for that too. Desktop hosts
|
||||
/// call this before the process exits, the same as the print spooler's own pending-job wait
|
||||
/// (`finish_pending_jobs`): otherwise a save that lands right before close can race the
|
||||
/// process exiting before the now-redundant recovery copy is actually removed, so the *next*
|
||||
/// launch wrongly offers to recover a document that was in fact saved cleanly.
|
||||
pub fn finish(&mut self, s: &Session) -> Result<(), String> {
|
||||
let mut error = None;
|
||||
if self.busy {
|
||||
let e = self.rx.recv().map_err(|e| e.to_string())?;
|
||||
self.documents = e.documents;
|
||||
self.busy = false;
|
||||
self.ready = true;
|
||||
// An in-flight job failing (e.g. a stray autosave) doesn't mean the document itself
|
||||
// wasn't saved cleanly by an explicit Save — still try the discard below, so one
|
||||
// doesn't keep the other from doing its job; log it once there's nothing further to do.
|
||||
if let Err(e) = e.outcome {
|
||||
error = Some(e);
|
||||
}
|
||||
}
|
||||
// Same guard `tick` uses: a document switch with no regular tick in between to notice and
|
||||
// reset `self.id` must not let the *new* document's clean state discard the *previous*
|
||||
// document's still-live recovery copy under a stale identity.
|
||||
if self.document == Some(s.document_id())
|
||||
&& let Some(id) = self.id.clone()
|
||||
&& (!s.dirty || s.password.is_some())
|
||||
{
|
||||
self.queue(Job::Discard(id))?;
|
||||
let e = self.rx.recv().map_err(|e| e.to_string())?;
|
||||
self.busy = false;
|
||||
self.documents = e.documents;
|
||||
match e.outcome {
|
||||
Ok(_) => {
|
||||
self.id = None;
|
||||
self.revision = None;
|
||||
}
|
||||
Err(e) => error = Some(e),
|
||||
}
|
||||
}
|
||||
match error {
|
||||
Some(e) => Err(e),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -628,6 +713,49 @@ mod tests {
|
||||
settle(s);
|
||||
}
|
||||
|
||||
/// #3140: a save that lands right before the app closes can race the process exiting before
|
||||
/// the now-redundant recovery copy is actually removed, so the next launch wrongly offers to
|
||||
/// recover a document that was in fact saved cleanly. `finish_recovery` (desktop hosts call it
|
||||
/// on exit, the same as they wait for pending print jobs) must discard it synchronously
|
||||
/// instead, with no regular tick in between — simulating closing right after Save, not after
|
||||
/// however long it'd take the next normal autosave tick to notice and clean up.
|
||||
#[test]
|
||||
fn finish_recovery_discards_its_own_copy_for_a_document_saved_right_before_exit() {
|
||||
let profile = Temp::new();
|
||||
let mut s = session(&profile.0);
|
||||
s.run("document.setText", &json!({"text": "About to be saved"})).unwrap();
|
||||
save_now(&mut s);
|
||||
assert_eq!(s.recovery_documents().len(), 1, "a recovery copy exists for the dirty document");
|
||||
// A real Save does this, then the app can close on the very next frame, with no regular
|
||||
// recovery_tick() in between to notice `!s.dirty` and queue the discard itself.
|
||||
s.dirty = false;
|
||||
assert_eq!(
|
||||
s.recovery_documents().len(),
|
||||
1,
|
||||
"confirms the race: setting dirty=false alone (what Save does) doesn't discard anything by itself"
|
||||
);
|
||||
s.finish_recovery();
|
||||
assert!(s.recovery_documents().is_empty(), "finish_recovery should have discarded the now-redundant copy before returning");
|
||||
}
|
||||
|
||||
/// `tick` resets `self.id` the moment the session's document no longer matches what it last
|
||||
/// saw (so it never discards under a stale identity); `finish_recovery` must guard the same
|
||||
/// way. Switching documents and exiting immediately, with no regular tick in between to reset
|
||||
/// that identity, must not let the *new* (clean, blank) document's state discard the
|
||||
/// *previous* document's still-live recovery copy.
|
||||
#[test]
|
||||
fn finish_recovery_does_not_discard_a_previous_document_s_copy_after_switching() {
|
||||
let profile = Temp::new();
|
||||
let mut s = session(&profile.0);
|
||||
s.run("document.setText", &json!({"text": "The document the user is still working on"})).unwrap();
|
||||
save_now(&mut s);
|
||||
assert_eq!(s.recovery_documents().len(), 1, "a recovery copy exists for the dirty document");
|
||||
s.set_document(Document::new()); // e.g. File > New, with no tick() in between
|
||||
assert!(!s.dirty, "a freshly switched-to document starts clean");
|
||||
s.finish_recovery();
|
||||
assert_eq!(s.recovery_documents().len(), 1, "switching to a new, unrelated document must not discard the previous one's recovery copy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_commands_only_address_known_profile_records() {
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
@@ -690,9 +818,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn size_limit_and_lossy_values_fail_without_committing() {
|
||||
let mut out = Limited { inner: Vec::new(), remaining: 3 };
|
||||
let mut out = Limited { inner: Vec::new(), remaining: 3, exceeded: false };
|
||||
assert!(out.write_all(b"four").is_err());
|
||||
assert!(out.inner.is_empty());
|
||||
assert!(out.exceeded);
|
||||
let t = Temp::new();
|
||||
let id = new_id().unwrap();
|
||||
let mut store = Store::new(t.0.clone()).unwrap();
|
||||
@@ -701,11 +830,54 @@ mod tests {
|
||||
let bytes = fs::read(&first).unwrap();
|
||||
let mut invalid = snapshot(&id, "invalid");
|
||||
invalid.document.last_section.page_w = f32::NAN;
|
||||
assert!(store.save(invalid).is_err());
|
||||
assert!(store.save(invalid).is_err_and(|e| e.permanent));
|
||||
assert_eq!(fs::read(first).unwrap(), bytes);
|
||||
assert_eq!(Store::files(&t.0.join(id)).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
/// Copies are written with picture bytes in base64 (#586 follow-up); those written before kept
|
||||
/// them as arrays of numbers and are still listed and restored, bytes intact.
|
||||
#[test]
|
||||
fn copies_with_byte_arrays_written_before_still_restore() {
|
||||
let t = Temp::new();
|
||||
let id = new_id().unwrap();
|
||||
let mut s = snapshot(&id, "Synthetic draft with a picture");
|
||||
s.generation = 1;
|
||||
s.document.media.insert("media/image1.png".into(), Arc::new(vec![0, 1, 127, 128, 254, 255]));
|
||||
let mut value = serde_json::to_value(&s).unwrap();
|
||||
assert_eq!(value["document"]["media"]["media/image1.png"], "AAF/gP7/");
|
||||
value["document"]["media"]["media/image1.png"] = json!([0, 1, 127, 128, 254, 255]);
|
||||
private_dir(&t.0.join(&id)).unwrap();
|
||||
fs::write(t.0.join(&id).join("snapshot-00000000000000000001.json"), serde_json::to_vec(&value).unwrap()).unwrap();
|
||||
let mut store = Store::new(t.0.clone()).unwrap();
|
||||
store.scan().unwrap();
|
||||
assert_eq!(store.inventory().len(), 1);
|
||||
assert_eq!(store.load(&id).unwrap().document, s.document);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_that_cannot_be_copied_is_not_retried_until_the_document_changes() {
|
||||
// #2625: a failure caused by the snapshot itself (here a value the format cannot keep;
|
||||
// in the field, the 256 MiB limit) used to be rewritten every interval while dirty.
|
||||
let t = Temp::new();
|
||||
let mut s = session(&t.0);
|
||||
s.run("document.setText", &json!({"text": "Synthetic draft"})).unwrap();
|
||||
s.doc.last_section.page_w = f32::NAN;
|
||||
save_now(&mut s);
|
||||
assert!(s.recovery_error().is_some());
|
||||
assert!(s.recovery_wait().is_none());
|
||||
s.recovery.as_mut().unwrap().due = Instant::now();
|
||||
s.recovery_tick();
|
||||
assert!(!s.recovery_busy(), "the unchanged document was snapshotted again");
|
||||
assert!(s.recovery_error().is_some());
|
||||
// An edit tries again.
|
||||
s.doc.last_section.page_w = 612.0;
|
||||
s.run("text.insert", &json!({"text": "!"})).unwrap();
|
||||
save_now(&mut s);
|
||||
assert!(s.recovery_error().is_none());
|
||||
assert_eq!(s.recovery_documents().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_pruning_cannot_push_a_record_beyond_the_discovery_limit() {
|
||||
let t = Temp::new();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user